@bongos/core 1.19.372 → 1.19.373

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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.372",
6
- "core_contract": "1.19.372",
7
- "source_commit": "55e9c32ab7bdeda662fa00ca1ac329f7c9350f35",
5
+ "core_version": "1.19.373",
6
+ "core_contract": "1.19.373",
7
+ "source_commit": "fbe2ff6cdfb5154f956f0a473085ad2f55a9e7e2",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-25T22:14:48.327Z",
9
+ "built_at": "2026-08-25T22:26:30.155Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 334,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1901,
20
- "tree_sha256": "d935d4f065823168da32ef35c6b17f66ecb8937ea59a28be97e7aafc26cfa39d",
20
+ "tree_sha256": "028f3591a335092742ce9ca6791ae17cc69d690bbe3895e09e10742a648eb04e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -147,7 +147,7 @@
147
147
  {
148
148
  "path": ".claude/skills/goal-create/SKILL.md",
149
149
  "mode": "0000644",
150
- "sha256": "e52032cb787f813a84e16a16c0ac75751ca111ad16f5502cf5f6bd8fb7a91068"
150
+ "sha256": "88494b0c9604992c43fb0efbb5d2cc34de51fd3645e7d65b22a65f0538e8f26f"
151
151
  },
152
152
  {
153
153
  "path": ".claude/skills/goal-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "f918564f73e753583651a8d963824c981cd82dc02d1e0d809d245e541219513e"
370
+ "sha256": "bebe9282f7f60c62dc554c24bbec1fb7c9a8453e98015d5207c3b70260bcbb56"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1277,7 +1277,7 @@
1277
1277
  {
1278
1278
  "path": "docs/adr/0161-publish-on-merge.md",
1279
1279
  "mode": "0000644",
1280
- "sha256": "056633fe7521143b0e8ba89297426ffd7253deb9ae5aa2fad65c3dfbffddc44f"
1280
+ "sha256": "b625ad07227ce25f8fe082fa9ccd93d3d704fd726aceb967d1f43677e164239a"
1281
1281
  },
1282
1282
  {
1283
1283
  "path": "docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md",
@@ -1382,7 +1382,7 @@
1382
1382
  {
1383
1383
  "path": "docs/adr/0180-dependency-intake-and-drift-policy.md",
1384
1384
  "mode": "0000644",
1385
- "sha256": "62de8180208c3945c7a040b2b2799dceafd59b1c5a117aab7db56a57eb538e44"
1385
+ "sha256": "9d50f2f71de5c4eba8d5af40aca201c6e054b1eec937a9e1342d1bb3b4ce45c1"
1386
1386
  },
1387
1387
  {
1388
1388
  "path": "docs/adr/0181-teardown-state-gate-and-address-retention.md",
@@ -1477,7 +1477,7 @@
1477
1477
  {
1478
1478
  "path": "docs/api/openapi.json",
1479
1479
  "mode": "0000644",
1480
- "sha256": "ada26795f34a9ce26711b788abc87bf277c475aa14ee7bb1f92e9376f1e50cc8"
1480
+ "sha256": "dbc90270a1754caf816ce9fde9cde35af21e977da08309f56c4d96dbf7cad9bf"
1481
1481
  },
1482
1482
  {
1483
1483
  "path": "docs/architecture.md",
@@ -4102,7 +4102,7 @@
4102
4102
  {
4103
4103
  "path": "modules/lifecycle/goal-category.js",
4104
4104
  "mode": "0000644",
4105
- "sha256": "6bc1bc0152900b49d79a6c09560020dc96ff5fae6c78d7dbb84d72dfed1953d2"
4105
+ "sha256": "5b64317b3c687a08c80d0ebe78619b6b4bb8a9cdf4cf37673a0f29785fd5a7ca"
4106
4106
  },
4107
4107
  {
4108
4108
  "path": "modules/lifecycle/goal-conflicts.js",
@@ -4212,7 +4212,7 @@
4212
4212
  {
4213
4213
  "path": "modules/lifecycle/routes/goals.js",
4214
4214
  "mode": "0000644",
4215
- "sha256": "5e01d0da935758aa24c8f13d2f4e97d9f561c2b1044b326fb4184c5dbc4bf6fb"
4215
+ "sha256": "db587e3a79c76671cdba1900bfdce156d62d0ef270319adb82a3b0805f3d091c"
4216
4216
  },
4217
4217
  {
4218
4218
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -4902,12 +4902,12 @@
4902
4902
  {
4903
4903
  "path": "package-lock.json",
4904
4904
  "mode": "0000644",
4905
- "sha256": "2f3c61f9360a509a95f00a50e5b297f727fa3ad9a0eb4dc60d237c7367c82962"
4905
+ "sha256": "01e0ec5418014e61e5a0bdf1781ea84ee46c4c8bfc1b63b0e55bb6397ec06498"
4906
4906
  },
4907
4907
  {
4908
4908
  "path": "package.json",
4909
4909
  "mode": "0000644",
4910
- "sha256": "f3dca0d69c0457b6ff3a4fcc16f01eedb758732e5e2fec7f5cfe65c82782b2df"
4910
+ "sha256": "f38add7db0b96f5b469c5127f7e06336ef5626edc93d61f797d3439e0ca51760"
4911
4911
  },
4912
4912
  {
4913
4913
  "path": "public-docs/index.html",
@@ -5367,7 +5367,7 @@
5367
5367
  {
5368
5368
  "path": "scripts/gds/bump-version.js",
5369
5369
  "mode": "0000644",
5370
- "sha256": "9709fcffab1d386c174666788687b1dfd974f141ab552ffd5641abd690259619"
5370
+ "sha256": "7289ba7894edd4ae6154615f2464c55ee8c8835f3c61988dbad2354aaafeac2e"
5371
5371
  },
5372
5372
  {
5373
5373
  "path": "scripts/gds/capture.js",
@@ -6792,7 +6792,7 @@
6792
6792
  {
6793
6793
  "path": "src/module-api.js",
6794
6794
  "mode": "0000644",
6795
- "sha256": "d5baf1741759cab821c57eb5c9b701e4e256838612b81835102c6286447f884e"
6795
+ "sha256": "88ea06ed6759765c78ff0fc67ddd0a65041e7b8fda60e0f18b5809c44107ad7d"
6796
6796
  },
6797
6797
  {
6798
6798
  "path": "src/module-loader/catalog.js",
@@ -7217,7 +7217,7 @@
7217
7217
  {
7218
7218
  "path": "tests/bump_version.mjs",
7219
7219
  "mode": "0000644",
7220
- "sha256": "98ec16d513074b30de8cd897143e4a3df1b3e54132a09d015b99e13e870de384"
7220
+ "sha256": "b7c9d529eba81e1e7ea11aaed734e3a30355483cf69972453c3bb03fdd3aab97"
7221
7221
  },
7222
7222
  {
7223
7223
  "path": "tests/cap_newcomer_tasks.mjs",
@@ -7767,7 +7767,7 @@
7767
7767
  {
7768
7768
  "path": "tests/goal_category.mjs",
7769
7769
  "mode": "0000644",
7770
- "sha256": "19e6987712231ac54e58c4b6658d12bb7df3d6681344bf358c38c1655243f058"
7770
+ "sha256": "05d46d4dcfd3a3b21ef0fc258263081cf4e624bdef4348daf84a90da589387c3"
7771
7771
  },
7772
7772
  {
7773
7773
  "path": "tests/goal_category_ui.mjs",
@@ -63,6 +63,7 @@ Everything here is your call. It is presented in Phase 4 as a proposal, not as q
63
63
  - **Title** — the outcome the goal owns, not a task list. One line.
64
64
  - **`description`** — 2–4 sentences: what is true when this goal is achieved, and what it explicitly does not cover.
65
65
  - **`scope_modules`** — infer from the planned work's file paths, then **verify every key against `moduleKeys()`**. An unknown key is a hard `400 unknown_module` (the response helpfully returns the full valid roster). Keep it tight: the wall is set here and only here. **No covering key?** Do not invent one — file a `kind='refactor'` prep task scoped to `kernel` to extend `MODULE_GLOBS`, and make this goal's tasks depend on it.
66
+ - **`category_id`** — the goal's HUMAN category (task 1003277; ADR 0192 — advisory, it gates nothing). Read the live set with `GET /api/gds/work-categories` (never hard-code the labels), infer the best fit from the goal's outcome and its `scope_modules`, and state your pick in the Phase-4 approval round like every other drafted field. **When nothing in the set fits, that is a designed prompt, not an error**: do NOT silently pick the nearest, and do NOT leave it null and move on — surface the gap in the gray-area round and offer to create a new category (`POST /api/gds/work-categories` with `{"label":"..."}`; a duplicate slug converges on the existing row), then use it. Creating uncategorised stays legal — if the user declines to name one, POST the goal without `category_id` and note that the 201 body will carry a `category_advisory` reminding whoever looks next.
66
67
  - **`succeeds_goal_id`** — set it when carrying an achieved goal forward. `POST /goals` validates the predecessor exists *before* creating, so a bad ref can't orphan a goal; it then stamps the predecessor's `succeeded_by_goal_id`. Archiving the predecessor is a **separate** call.
67
68
  - **Criteria — 2 to 4.** A goal's criteria are narrower than a version's: each should plausibly anchor 2–5 tasks. Draft `criterion_id` as a stable kebab slug (position-independent, and what `criterion_ids` on `POST /tasks` should reference); `criterion_md` is the outcome prose.
68
69
  - **Seed tasks — 3 to 8.** Per criterion, with `kind`, a one-line done-when, `criterion_ids: ["<slug>"]`, and dependency edges. Omit `touches[]` (advisory, ADR 0049 — backfilled from the real diff at ship). Never hand-set `requires_rank` (auto-derives, ADR 0084). Proof tasks use `kind='verify'`; exploration uses `kind='spike'` (and spikes are deliberately left in backlog).
@@ -72,7 +73,7 @@ Everything here is your call. It is presented in Phase 4 as a proposal, not as q
72
73
  Two rounds at most, both via **AskUserQuestion**.
73
74
 
74
75
  1. **Gray-area round — at most one round, at most 4 questions, often zero.** A question earns its place only if the work is genuinely underspecified on it, reasonable people would disagree, **and** the answer changes the goal's shape. Put your recommendation first and leave room for "Other". Legitimate gray areas: where this goal's boundary falls against an adjacent one; whether a module belongs inside the wall or is a dependency; whether this succeeds a prior goal or replaces it; whether a criterion belongs to this goal or the version. **Do not ask** anything the goal board, the criteria list, or `moduleKeys()` already answers, and never ask a yes/no rubber-stamp.
75
- 2. **The approval round — always exactly one, always last.** Present the goal (title · `scope_modules` · join policy · succeeds), the **numbered** criteria, and the seed-task table. Then one question: **Create it** / **Goal + criteria only** (skip the seed tasks) / **Edit first** / **Cancel**. Number the criteria so the user edits by number rather than by copy/paste.
76
+ 2. **The approval round — always exactly one, always last.** Present the goal (title · category · `scope_modules` · join policy · succeeds), the **numbered** criteria, and the seed-task table. Then one question: **Create it** / **Goal + criteria only** (skip the seed tasks) / **Edit first** / **Cancel**. Number the criteria so the user edits by number rather than by copy/paste.
76
77
 
77
78
  ## Phase 5 — Land it (write order: goal → criteria → tasks)
78
79
 
@@ -81,10 +82,12 @@ The order is forced: a criterion needs `goal_id`, a task needs both `goal_id` an
81
82
  > **Always `--body-file`, never inline `--body`, for anything carrying a title, description or criterion prose.** Those strings are authored in this session and routinely contain an apostrophe ("the builder's hall"), which terminates a single-quoted shell argument and corrupts the JSON — or, with a hostile string, runs whatever follows on the builder's machine. Write the body to a temp `.json` file and pass its path. On Windows also heed `scripts/gds/CLAUDE.md`: do **not** create that file with `Out-File`/`>` (the UTF-8 BOM breaks `JSON.parse`), and never pipe `api.js` through `2>&1` (its banner goes to stderr by design).
82
83
 
83
84
  ```
84
- # 5a — the goal. STRICT validation: exactly these five fields are accepted
85
- # (version_id*, title*, description, scope_modules[], succeeds_goal_id).
85
+ # 5a — the goal. STRICT validation: exactly these seven fields are accepted
86
+ # (version_id*, title*, subtitle, description, scope_modules[], succeeds_goal_id, category_id).
86
87
  # ANY other key is a 400 unknown_field — do NOT pass criteria, tasks, lead or visibility.
87
- # goal.json: {"version_id":"<ver>","title":"...","description":"...","scope_modules":["<key>"]}
88
+ # goal.json: {"version_id":"<ver>","title":"...","description":"...","scope_modules":["<key>"],"category_id":<id>}
89
+ # category_id comes from GET /api/gds/work-categories (task 1003277); omitting it is
90
+ # legal — the 201 body then carries a category_advisory, never a refusal.
88
91
  # carry-forward: add "succeeds_goal_id":<prior_goal_id>
89
92
  node scripts/gds/api.js POST /api/gds/goals --body-file <path-to-goal.json>
90
93
  # → 201 {ok, goal, succeeds_goal_id}. CAPTURE goal.id.
@@ -252,7 +252,7 @@ export interface PostGoalsIdTransferResponse { ok: boolean; goal: unknown; chang
252
252
  export interface PostGoalsReorderRequest { version_id: string; ordered_ids: number[] }
253
253
  export interface PostGoalsReorderResponse { ok: boolean; goals: unknown }
254
254
  export interface PostGoalsRequest { version_id: string; title: string; subtitle?: string; description?: string; scope_modules?: string[]; succeeds_goal_id?: number; category_id?: number }
255
- export interface PostGoalsResponse { ok: boolean; goal: unknown; succeeds_goal_id: unknown }
255
+ export interface PostGoalsResponse { ok: boolean; goal: unknown; succeeds_goal_id: unknown; category_advisory: unknown }
256
256
  export interface PostGovernmentAssignmentsRequest { builder_id: number; rank_key: string }
257
257
  export interface PostGovernmentBoardItemsItemIdVotesRequest { direction: string; reason_md?: string; section_key?: string }
258
258
  export interface PostGovernmentBoardItemsRequest { subject_type: string; proposal: Record<string, unknown>; rationale_md?: string; acknowledge_self_removal?: boolean }
@@ -22,7 +22,7 @@ The consumption side (ADR 0136's subscription) was never the problem; supply was
22
22
 
23
23
  1. **Every merge to `main` publishes.** A `publish` workflow fires on the `unit` check succeeding on a main push (never on a red suite), builds via `package-core` — the fail-closed no-leak gate and `--self-verify` pin check run inside the build — publishes (restricted), and tags `core-v<version>`.
24
24
  2. **CI bumps PATCH only.** When a merge lands without a human bump, `scripts/gds/bump-version.js` bumps the patch and writes the changelog line (shape-preserving; pinned by `tests/bump_version.mjs`). A **minor or major stays a deliberate in-task human edit** — it is a statement about the doorway surface, and it decides what patch-channel subscribers receive unattended (ADR 0136). CI is not allowed to make that call.
25
- 3. **Token custody reverses, knowingly.** A standing npm **write** Automation token (`NPM_TOKEN`) and a fine-grained **contents:write** PAT (`RELEASE_PUSH_TOKEN`, for the bump commit — the default `GITHUB_TOKEN` cannot push past the required `unit` check) live as GitHub Actions secrets. This is exactly the surface 0137 declined; the owner accepted it with the incidents as evidence. Mitigations: the lane is **default-inert** behind the repo variable `PUBLISH_ON_MERGE=1` (an owner kill-switch symmetrical to `gating.js`), packages publish `restricted`, and the no-leak gate structurally cannot upload a leaking artifact.
25
+ 3. **Token custody reverses, knowingly.** A standing npm **write** Automation token (`NPM_TOKEN`) and a fine-grained **contents:write** PAT (`RELEASE_PUSH_TOKEN`, for the bump commit — the default `GITHUB_TOKEN` cannot push past the required `unit` check) live as GitHub Actions secrets. This is exactly the surface 0137 declined; the owner accepted it with the incidents as evidence. **Superseded for the npm half at task 1003202 (2026-08-25):** the write token is gone — the lane publishes via OIDC trusted publishing (ADR 0180), so no long-lived npm credential exists to steal. `RELEASE_PUSH_TOKEN` remains, as the bump commit still needs contents:write. Mitigations: the lane is **default-inert** behind the repo variable `PUBLISH_ON_MERGE=1` (an owner kill-switch symmetrical to `gating.js`), packages publish `restricted`, and the no-leak gate structurally cannot upload a leaking artifact.
26
26
  4. **Idempotent by version-compare.** The bump commit re-triggers `unit` → `publish`, which then finds HEAD's version already on npm and no-ops. npm's duplicate-version rejection is the backstop; concurrent merges queue behind a no-cancel concurrency group.
27
27
 
28
28
  ## Consequences
@@ -31,6 +31,12 @@ The consensus post-Shai-Hulud control is a **release-age cooldown** (don't insta
31
31
 
32
32
  The publish-on-merge lane (ADR 0161) authenticates with a long-lived `NPM_TOKEN` repository secret — exactly the credential class the Sept-2025 attacks monetized, and why GitHub revoked classic tokens and capped granular ones at 90 days. Decision: **adopt npm trusted publishing (OIDC)** — no long-lived secret, provenance for free. It needs a one-time owner action on npmjs.com (register the repo+workflow as a trusted publisher for `@bongos/core`), filed as a blocker. **Until then** the granular `NPM_TOKEN` remains a *documented exception*: 90-day expiry, publish-only scope, rotated on expiry.
33
33
 
34
+ > **Landed 2026-08-25 — task 1003202, blocker 1000115 resolved.** The owner registered the trusted publisher; `publish.yml` now carries `id-token: write` and publishes with no secret. Three things this needed that the decision above did not anticipate, each of which fails SILENTLY if missed:
35
+ >
36
+ > - **npm ≥ 11.5.1.** Node 22 ships npm 10.x, which ignores the OIDC token and publishes unauthenticated. The lane now installs a pinned npm and refuses to continue below that version.
37
+ > - **Provenance had to be turned OFF.** Trusted publishing enables it by default and npm *refuses* to generate provenance from a **private source repository** — so the "provenance for free" clause above does not apply to this repo, and leaving the default would have failed every publish.
38
+ > - **The registry could no longer answer "is this version taken?".** `@bongos/core` is private, so an unauthenticated `npm view` is a flat `E404` rather than a "no", and OIDC authenticates `npm publish` and nothing else. Keeping that check would have skipped the auto-bump on every run and collided on the published version — **breaking after exactly one release**. The lane now reads its own `release: core X.Y.Z` commits (`laneReleasedVersions`), which need no auth and are the source the cascade terminator already trusts.
39
+
34
40
  ## What this deliberately does not cover
35
41
 
36
42
  New-dependency *intake vetting* (registry existence/age/download checks — the slopsquatting defense) lives in the weekly patrol (task 1003128), and one-off vulnerability *fixes* (sharp ≥ 0.35 for the libvips CVEs, the colyseus chain for nanoid) are ordinary tasks that shrink the baseline — filed in the idea inbox at adoption time.
@@ -18019,12 +18019,14 @@
18019
18019
  "type": "boolean"
18020
18020
  },
18021
18021
  "goal": {},
18022
- "succeeds_goal_id": {}
18022
+ "succeeds_goal_id": {},
18023
+ "category_advisory": {}
18023
18024
  },
18024
18025
  "required": [
18025
18026
  "ok",
18026
18027
  "goal",
18027
- "succeeds_goal_id"
18028
+ "succeeds_goal_id",
18029
+ "category_advisory"
18028
18030
  ]
18029
18031
  },
18030
18032
  "PostGovernmentAssignmentsRequest": {
@@ -134,6 +134,33 @@ async function setGoalCategory({ goalId, categoryId }, deps = {}) {
134
134
  return rows[0] ?? null;
135
135
  }
136
136
 
137
+ // -------------------------------------------------------------------------
138
+ // The advisory (task 1003277)
139
+ // -------------------------------------------------------------------------
140
+
141
+ // adviseCategory — PURE, FAIL-OPEN (the goal-advisory.js posture: this is advice
142
+ // appended to a request that ALREADY SUCCEEDED, so a wrong guess must nag nobody).
143
+ // Returns null when the goal is categorised, or the advisory body when it is not.
144
+ //
145
+ // The criterion is explicit about what an uncategorised goal IS: "Claude assigns
146
+ // where it can; where it cannot, the human is prompted to create a new category —
147
+ // that failure is a designed prompt, not an error." So the advisory carries the
148
+ // live SET (so the client can offer real choices, never hard-coded labels) and
149
+ // names the create endpoint (so the prompt has somewhere to write into). It never
150
+ // escalates past advice — the moment a missing category can refuse a write, the
151
+ // label has become a gate (ADR 0192).
152
+ function adviseCategory({ categoryId, categories = [] } = {}) {
153
+ if (categoryId != null) return null;
154
+ return {
155
+ reason: 'uncategorised',
156
+ message: 'This goal has no human category yet. A category orients a person scanning the board '
157
+ + '(it gates nothing — ADR 0192). Pick one from the set below via PATCH /goals/:id/category, '
158
+ + 'or — if none fits — ask the human to name a new one and create it with POST /work-categories: '
159
+ + 'that failure is a designed prompt, not an error.',
160
+ categories: categories.map((c) => ({ id: String(c.id), slug: c.slug, label: c.label })),
161
+ };
162
+ }
163
+
137
164
  // -------------------------------------------------------------------------
138
165
  // Decoration
139
166
  // -------------------------------------------------------------------------
@@ -263,6 +290,7 @@ module.exports = {
263
290
  isValidSlug,
264
291
  listCategories,
265
292
  getCategory,
293
+ adviseCategory,
266
294
  createCategory,
267
295
  setGoalCategory,
268
296
  decorateGoalCategory,
@@ -129,7 +129,16 @@ module.exports = function buildGoalsRouter() {
129
129
  if (succeedsId != null) {
130
130
  await db.setGoalSuccessor({ goalId: succeedsId, successorId: goal.id });
131
131
  }
132
- return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId });
132
+ // task 1003277 — the category advisory, the goal_advisory precedent: advice
133
+ // on a request that already succeeded, null when the goal has a category.
134
+ // Best-effort: a failed set read must not turn advice into a 500.
135
+ let category_advisory = null;
136
+ if (categoryId === null) {
137
+ try {
138
+ category_advisory = goalCategory.adviseCategory({ categoryId, categories: await goalCategory.listCategories() });
139
+ } catch (_) { /* fail-open — advice only */ }
140
+ }
141
+ return res.status(201).json({ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory });
133
142
  } catch (err) {
134
143
  log.error('[gds] POST /goals', err);
135
144
  return res.fail('goal_create_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.372",
3
+ "version": "1.19.373",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.372",
9
+ "version": "1.19.373",
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.372",
3
+ "version": "1.19.373",
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",
@@ -86,5 +86,45 @@ function isReleaseCommit(subject) {
86
86
  return RELEASE_COMMIT_RE.test(String(subject == null ? '' : subject));
87
87
  }
88
88
 
89
- module.exports = { bumpPatch, CONST_RE, RELEASE_COMMIT_RE, isReleaseCommit };
89
+ // laneReleasedVersions — every version THIS lane has announced it published, read
90
+ // from its own release commits (task 1003202).
91
+ //
92
+ // WHY THIS EXISTS. publish.yml used to answer "is HEAD's version already on the
93
+ // registry?" with `npm view @bongos/core@<v>`. That needs a credential:
94
+ // @bongos/core is PRIVATE, so an unauthenticated read is a flat E404, not a
95
+ // "no". Moving the lane to OIDC trusted publishing removes the token — and OIDC
96
+ // only authenticates `npm publish`, nothing else — so the old check would have
97
+ // silently reported "not taken" on EVERY run, skipped the auto-bump, and then
98
+ // collided on the already-published version. It would have looked fine for
99
+ // exactly one release.
100
+ //
101
+ // The answer is already in the repo. Every version this lane ships is announced
102
+ // by a `release: core X.Y.Z — CI auto-patch carrier` commit it writes itself, and
103
+ // RELEASE_COMMIT_RE above is that contract. So the registry is not the only place
104
+ // that knows; git knows too, needs no auth, and is the same source the cascade
105
+ // terminator already trusts.
106
+ //
107
+ // Deliberately shares RELEASE_COMMIT_RE with isReleaseCommit, so a HUMAN release
108
+ // commit ("release: core 1.19.2 — carrier for the init-blocker fixes") is NOT
109
+ // counted here either — that version is new and still needs publishing, which is
110
+ // exactly the distinction the constant was written to make.
111
+ //
112
+ // BOTH failure directions are safe, which is what makes this acceptable on a
113
+ // release-critical lane:
114
+ // • commit written, publish then failed → version looks taken, next run bumps
115
+ // past it. A gap in the version sequence; harmless.
116
+ // • published, commit push then failed → version looks free, the publish
117
+ // retries and npm refuses with EPUBLISHCONFLICT. Loud, and nothing wrong ships.
118
+ function laneReleasedVersions(subjects = []) {
119
+ const out = new Set();
120
+ for (const subject of subjects) {
121
+ const line = String(subject == null ? '' : subject);
122
+ if (!RELEASE_COMMIT_RE.test(line)) continue;
123
+ const m = /^release: core (\d+\.\d+\.\d+) /.exec(line);
124
+ if (m) out.add(m[1]);
125
+ }
126
+ return out;
127
+ }
128
+
129
+ module.exports = { bumpPatch, CONST_RE, RELEASE_COMMIT_RE, isReleaseCommit, laneReleasedVersions };
90
130
  if (require.main === module) main();
package/src/module-api.js CHANGED
@@ -1174,8 +1174,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1174
1174
  // landed since 1.19.370 with no explicit bump. run 32904582252. (task 1002620)
1175
1175
  // 1.19.372 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1176
1176
  // landed since 1.19.371 with no explicit bump. run 32905166786. (task 1002620)
1177
+ // 1.19.373 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1178
+ // landed since 1.19.372 with no explicit bump. run 32906118616. (task 1002620)
1177
1179
  // ---------------------------------------------------------------------------
1178
- const CORE_VERSION = '1.19.372'; // CI auto-patch carrier (ADR 0161); see changelog above
1180
+ const CORE_VERSION = '1.19.373'; // CI auto-patch carrier (ADR 0161); see changelog above
1179
1181
 
1180
1182
  // A namespaced logger so a module's log lines are attributable + consistent.
1181
1183
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -16,7 +16,10 @@ import { createRequire } from 'node:module';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
19
- const { bumpPatch, CONST_RE } = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
19
+ const bump = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
20
+ const { bumpPatch, CONST_RE } = bump;
21
+ // The LIVE test below reads this repo's real release history (task 1003202).
22
+ const { execFileSync } = require('node:child_process');
20
23
 
21
24
  const SAMPLE = [
22
25
  '// prologue',
@@ -111,3 +114,76 @@ test('publish.yml wires the terminator: guard step present, every armed step gat
111
114
  const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
112
115
  assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
113
116
  });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // laneReleasedVersions — the token-free "is this version already published?"
120
+ // (task 1003202 / blocker 1000115, the OIDC trusted-publishing migration).
121
+ //
122
+ // publish.yml used to ask the REGISTRY, which needs a credential: @bongos/core is
123
+ // private, so an unauthenticated `npm view` is a flat E404 rather than a "no".
124
+ // OIDC authenticates `npm publish` and nothing else, so keeping that check would
125
+ // have reported "not taken" every run, skipped the auto-bump, and collided on the
126
+ // published version — working for exactly one release before breaking. git holds
127
+ // the same answer, needs no auth, and is the source the cascade terminator above
128
+ // already trusts.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ test('laneReleasedVersions reads the versions this lane announced', () => {
132
+ const subjects = [
133
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
134
+ 'Merge pull request #385 from lars589/some-branch',
135
+ 'release: core 1.19.367 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
136
+ 'feat(security): something unrelated',
137
+ ];
138
+ const v = bump.laneReleasedVersions(subjects);
139
+ assert.deepEqual([...v].sort(), ['1.19.367', '1.19.368']);
140
+ assert.equal(v.has('1.19.369'), false, 'a version never announced must read as free');
141
+ });
142
+
143
+ test('a HUMAN release commit is NOT counted — that version still needs publishing', () => {
144
+ // The same distinction RELEASE_COMMIT_RE was written to make for the cascade
145
+ // terminator. Counting it here would skip the publish of a hand-cut release.
146
+ const v = bump.laneReleasedVersions([
147
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
148
+ 'release: core 1.20.0 — hand-cut minor',
149
+ ]);
150
+ assert.equal(v.size, 0);
151
+ });
152
+
153
+ test('malformed and empty input yields an empty set, never a throw', () => {
154
+ // This runs inside the publish lane. A crash here would take down releases;
155
+ // an empty set degrades to "nothing is taken", which the publish itself
156
+ // catches with EPUBLISHCONFLICT — loud, and nothing wrong ships.
157
+ assert.equal(bump.laneReleasedVersions([]).size, 0);
158
+ assert.equal(bump.laneReleasedVersions().size, 0);
159
+ assert.equal(bump.laneReleasedVersions([null, undefined, '', 42]).size, 0);
160
+ });
161
+
162
+ test('laneReleasedVersions and isReleaseCommit agree on what a lane release looks like', () => {
163
+ // They share RELEASE_COMMIT_RE on purpose: if one ever recognises a subject the
164
+ // other does not, the cascade terminator and the version check disagree about
165
+ // the same commit, and the lane either double-publishes or silently no-ops.
166
+ const subjects = [
167
+ 'release: core 1.19.368 — CI auto-patch carrier (publish-on-merge, ADR 0161)',
168
+ 'release: core 1.19.2 — carrier for the init-blocker fixes',
169
+ 'chore: not a release at all',
170
+ ];
171
+ for (const s of subjects) {
172
+ const counted = bump.laneReleasedVersions([s]).size === 1;
173
+ assert.equal(counted, bump.isReleaseCommit(s), `disagreement on: ${s}`);
174
+ }
175
+ });
176
+
177
+ test('LIVE: the version HEAD declares is one this lane already released', () => {
178
+ // The steady state the lane depends on: HEAD carries the version set by the
179
+ // previous release commit, so `taken` is true and the auto-bump fires. If this
180
+ // ever reads false on a normal main checkout, the lane would try to republish
181
+ // an existing version.
182
+ const headVersion = /^const CORE_VERSION = '([0-9.]+)';/m
183
+ .exec(fs.readFileSync(path.join(ROOT, 'src', 'module-api.js'), 'utf8'))[1];
184
+ const subjects = execFileSync('git', ['log', '--format=%s', 'HEAD'], { encoding: 'utf8', cwd: ROOT }).split('\n');
185
+ const released = bump.laneReleasedVersions(subjects);
186
+ assert.ok(released.size > 100, `expected a long release history, saw ${released.size}`);
187
+ assert.equal(released.has(headVersion), true,
188
+ `HEAD declares ${headVersion}, which this lane has no release commit for — the auto-bump would not fire`);
189
+ });
@@ -145,6 +145,34 @@ test('an unknown category is a 404, not a silently-created one', async () => {
145
145
  assert.equal(res.statusCode, 404);
146
146
  });
147
147
 
148
+ // ---- the advisory (task 1003277) --------------------------------------------
149
+
150
+ test('an uncategorised goal earns an advisory that carries the live set and the create path', () => {
151
+ const adv = gc.adviseCategory({ categoryId: null, categories: [
152
+ { id: 3, slug: 'security', label: 'Security' },
153
+ { id: 6, slug: 'economics', label: 'Economics' },
154
+ ] });
155
+ assert.equal(adv.reason, 'uncategorised');
156
+ assert.deepEqual(adv.categories.map((c) => c.slug), ['security', 'economics'],
157
+ 'the live set rides along so a client offers real choices, never hard-coded labels');
158
+ assert.match(adv.message, /POST \/work-categories/, 'the prompt has somewhere to write into');
159
+ assert.match(adv.message, /designed prompt, not an error/, 'the criterion\'s own framing travels with the advice');
160
+ assert.match(adv.message, /gates nothing/, 'and so does the rule that outranks the feature');
161
+ });
162
+
163
+ test('a categorised goal gets NO advisory — silence, the fail-open posture', () => {
164
+ assert.equal(gc.adviseCategory({ categoryId: 3, categories: [] }), null);
165
+ });
166
+
167
+ test('the advisory is advice on a 201, never a refusal', () => {
168
+ // Pinned in source: POST /goals attaches category_advisory to its SUCCESS body
169
+ // and never fails the create over it (best-effort, wrapped in try).
170
+ const routes = read('modules', 'lifecycle', 'routes', 'goals.js');
171
+ assert.match(routes, /category_advisory = goalCategory\.adviseCategory/);
172
+ assert.match(routes, /res\.status\(201\)\.json\(\{ ok: true, goal, succeeds_goal_id: succeedsId, category_advisory \}\)/,
173
+ 'it rides the 201 — advice appended to a request that already succeeded');
174
+ });
175
+
148
176
  // ---- decoration ------------------------------------------------------------
149
177
 
150
178
  test('a goal carries a readable category, and an uncategorised one carries null', () => {