@bongos/core 1.19.786 → 1.19.788

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.786",
6
- "core_contract": "1.19.786",
7
- "source_commit": "785c921afb48af049814782f27929cc27885667c",
5
+ "core_version": "1.19.788",
6
+ "core_contract": "1.19.788",
7
+ "source_commit": "92849625024c376906ce07c3c0c0ca7a3d680548",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-16T22:20:27.767Z",
9
+ "built_at": "2026-09-16T22:46:46.860Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 483,
12
+ "docs_redacted": 484,
13
13
  "agent_docs_stubbed": 25,
14
14
  "functional_verbatim": 2160,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2668,
20
- "tree_sha256": "3d454ef4a876f5368603708cbe4f13b53dc57a854dc03e217285b327b6dd0bf9",
19
+ "file_count": 2669,
20
+ "tree_sha256": "de7ca524df98188a2d7081d557c66df028de4810b9e2fccd1e052d3db4cc787a",
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": "8ed3e62213268015e49ad180d9f9c0c3c875867b392e93430a9850962a70455b"
210
+ "sha256": "ede2aa15718bff19d38ffa7f42d64bae7490a9025f7e85d43c43306da3f611b6"
211
211
  },
212
212
  {
213
213
  "path": ".claude/skills/priority-session/SKILL.md",
@@ -1137,7 +1137,7 @@
1137
1137
  {
1138
1138
  "path": "docs/adr/0134-private-first-npm-distribution.md",
1139
1139
  "mode": "0000644",
1140
- "sha256": "47b4d52804d291c8fd8952d8a2e8ad673f173a965cf0b5fe100aa6b93d850cd6"
1140
+ "sha256": "46272b26074a2f5531d27bf1699ad6eb93c8dcfa31431239bc6417b9a3d61c47"
1141
1141
  },
1142
1142
  {
1143
1143
  "path": "docs/adr/0135-module-upstream-submission-interim-queue.md",
@@ -1944,10 +1944,15 @@
1944
1944
  "mode": "0000644",
1945
1945
  "sha256": "83b8971f9b2cf280c7ad3baa0fbbaaa59d1a4c3566eccdff02ab1e3195963862"
1946
1946
  },
1947
+ {
1948
+ "path": "docs/adr/0292-both-install-paths-stay-and-vendoring-is-the-production-one.md",
1949
+ "mode": "0000644",
1950
+ "sha256": "6a5afb0821a42af204fe97ec68040ea7781344651e599bce135bf8f15d8c86dc"
1951
+ },
1947
1952
  {
1948
1953
  "path": "docs/adr/README.md",
1949
1954
  "mode": "0000644",
1950
- "sha256": "708c4562d94e836ea3c755eb7bd0c5c0fec81e86e40c532aca27042c0c5ec5dc"
1955
+ "sha256": "7eb54577c118f262fa9bd5bd8b00aa194d88cff85646f57b78bd616813472723"
1951
1956
  },
1952
1957
  {
1953
1958
  "path": "docs/api-reference.md",
@@ -2512,7 +2517,7 @@
2512
2517
  {
2513
2518
  "path": "docs/module-api-changelog.md",
2514
2519
  "mode": "0000644",
2515
- "sha256": "580f9ae0d3677bc11fe335f73027c314edd8633f2397cd71400e07725a8f4a03"
2520
+ "sha256": "67cecf319da71f951791242844391a055cbf58a76b1490b8fd0818258e0937ae"
2516
2521
  },
2517
2522
  {
2518
2523
  "path": "docs/modules-contract.md",
@@ -5447,7 +5452,7 @@
5447
5452
  {
5448
5453
  "path": "modules/lifecycle/db-deps-criteria.js",
5449
5454
  "mode": "0000644",
5450
- "sha256": "04406d383068e42eafed42fa51cf9108df2907f0807b1ed7bcd0abc9eaf7bfb7"
5455
+ "sha256": "2fefa2efa05b2a63ee510345180c04d211d5068c8bc82c050392dac3d02cfac2"
5451
5456
  },
5452
5457
  {
5453
5458
  "path": "modules/lifecycle/db-goals.js",
@@ -5737,12 +5742,12 @@
5737
5742
  {
5738
5743
  "path": "modules/lifecycle/routes/task-write-routes.js",
5739
5744
  "mode": "0000644",
5740
- "sha256": "b821b384c67c5182f52653a283674da62756885c4fa5cf6790cea5eccd688f49"
5745
+ "sha256": "9cff3bcd27467dc413aa975cd20262b47e8595a50a845d93b557a24828719bbf"
5741
5746
  },
5742
5747
  {
5743
5748
  "path": "modules/lifecycle/routes/tasks.js",
5744
5749
  "mode": "0000644",
5745
- "sha256": "3c4bbd0b62fbef12e8511bfcbaae22fa677fc956b83423a8f213207fc1062c90"
5750
+ "sha256": "0497ab99a5c13399617b43f70d775e77773f1eed259548433f19c20c708ebab9"
5746
5751
  },
5747
5752
  {
5748
5753
  "path": "modules/lifecycle/routes/version-route-authz.js",
@@ -7622,12 +7627,12 @@
7622
7627
  {
7623
7628
  "path": "package-lock.json",
7624
7629
  "mode": "0000644",
7625
- "sha256": "a08e66b825d352b3b0c128f3b5b71d4665183bf734d69d0e837adf8946a28f19"
7630
+ "sha256": "49f7824295dab411ce5b50f1d5f37837439ee56ca2f66b90b16c93dd0d4dae58"
7626
7631
  },
7627
7632
  {
7628
7633
  "path": "package.json",
7629
7634
  "mode": "0000644",
7630
- "sha256": "9f9dfe9f5f8cc3d3ebc1cd46909eb73ac9ef03046933478f391cb6c23680cfa6"
7635
+ "sha256": "a9dd34b4fde96d0c2486b92cf898b95b6b6570784d1d3f7019cdd4e8acfe3776"
7631
7636
  },
7632
7637
  {
7633
7638
  "path": "public-docs/index.html",
@@ -8702,7 +8707,7 @@
8702
8707
  {
8703
8708
  "path": "scripts/gds/seed-bongos-v1-idea-routing-tasks.js",
8704
8709
  "mode": "0000644",
8705
- "sha256": "d028e80a7c1cd85805954187e43ec32d624244bf9b3374b4f27c5d47ced79f95"
8710
+ "sha256": "fc30acb5a295289c446b5fd7dd79bcd4e2ba8a3b90bbe1c426fecc087bbefadd"
8706
8711
  },
8707
8712
  {
8708
8713
  "path": "scripts/gds/seed-bongos-v1-seo-tasks.js",
@@ -8727,7 +8732,7 @@
8727
8732
  {
8728
8733
  "path": "scripts/gds/seed-bv1-profiles-tasks.js",
8729
8734
  "mode": "0000644",
8730
- "sha256": "a1101fcbb8988a19bd34f5c80789b8d69e421907be4c6143f6052e594c0b6944"
8735
+ "sha256": "53353029323de1e200b11bceccf822f524a19ff857cf17f6fa4b1ada73408c40"
8731
8736
  },
8732
8737
  {
8733
8738
  "path": "scripts/gds/seed-bv1-r01-delta-tasks.js",
@@ -9492,7 +9497,7 @@
9492
9497
  {
9493
9498
  "path": "src/module-api.js",
9494
9499
  "mode": "0000644",
9495
- "sha256": "0eb65d596ccf4f9af9cb2ec8769e373c18f3148b93e785157895d4dbb94299c7"
9500
+ "sha256": "9ba56c6268099a478b0af08849ee7cecce87b2650bd1d82757614bd688651900"
9496
9501
  },
9497
9502
  {
9498
9503
  "path": "src/module-loader/catalog.js",
@@ -10342,7 +10347,7 @@
10342
10347
  {
10343
10348
  "path": "tests/criterion_ref_parse.mjs",
10344
10349
  "mode": "0000644",
10345
- "sha256": "8eca863d641ce68a3edad2b902ecc15e9ec64c14fb420418a463461f29e44ff1"
10350
+ "sha256": "80ce711abf62a78dc4f36b513b852a5396173a2ff50e5deab2460531d17911e4"
10346
10351
  },
10347
10352
  {
10348
10353
  "path": "tests/criterion_suggest.mjs",
@@ -172,7 +172,7 @@ Don't tunnel-vision. Surface lateral options the user might not have thought of:
172
172
  - **Criteria (done-when)** — the goal's criteria verbatim by slug (C1, C2, …); every task links to one.
173
173
  - **Design decisions & assumptions** — what we're committing to and why; note grounding from the codebase (existing modules/tables/routes to reuse). Starts from your best defaults; the Phase-4 interview fills and revises this.
174
174
  - **Open questions (for the interview)** — the non-obvious decisions still unresolved, each with why-it-matters + the options you see. This is the raw material for Phase 4; empty it as answers land.
175
- - **Task outline** — grouped by criterion (and phase, if phased), one row per task: `R## · kind · title · one-line done-when · deps:[R##] · criterion_ids:[Cn]`, tagged `[E]`/`[I]`/`[N]`. Mark **spikes** (needs-exploration) and **proofs** (the live demo per criterion).
175
+ - **Task outline** — grouped by criterion (and phase, if phased), one row per task: `R## · kind · title · one-line done-when · deps:[R##] · criterion_ids:[<criterion-slug>]`, tagged `[E]`/`[I]`/`[N]`. Mark **spikes** (needs-exploration) and **proofs** (the live demo per criterion).
176
176
  - **Dependency graph & ranking** — filled in Phase 4 (topo order + priority tiers).
177
177
  - **Out of scope** — what this goal explicitly does NOT do, so the interview and tasks stay bounded.
178
178
 
@@ -269,10 +269,10 @@ Once the spec is finalized (Phase 4), **read `docs/specs/<ver>-<goal-slug>.md` a
269
269
  - **Migration** at `migrations/<NN>_<ver_slug>_planning_seed.sql` (CORE — platform-universal, non-scope-truth changes only): `UPDATE versions SET done_when = ...` and `UPDATE tasks` for existing-task priority + V##.R## title prefix (guarded by `WHERE title NOT LIKE 'V<ver>.R%'`). **Do NOT put versions/goals/`done_when_criteria` INSERTs here** — that's scope-truth and fails the `selfhost-boot` gate (ADR 0105).
270
270
 
271
271
  3. **Write a seed script** at `scripts/gds/seed-<ver_slug>-tasks.js` modeled on `scripts/gds/seed-v3-tasks.js` containing:
272
- - The TASKS array — one entry per net-new task with full description, kind, discipline, priority, est_minutes, manual_degree, credits_reward, **optional** `touches` (see below), optional `promoted_idea_id`, `depends_on_refs: ['R##', ...]` (the dep edges captured during brainstorm), AND **`criterion_ids: ['Cn', ...]`** — the done-when criterion(s) this task gates. The brainstorm in Phase 3 is already grouped by criterion, so this is just transcribing that grouping. Use the positional `"Cn"` token (C1 = the version's first criterion); the server resolves it against the task's version at create (ADR 0025 / task 438). **This replaces the old `**Criterion:** Cn` prose** — don't write that line anymore; pass `criterion_ids` instead so the task is linked in `task_criteria` at creation and `/status` counts it with no backfill.
272
+ - The TASKS array — one entry per net-new task with full description, kind, discipline, priority, est_minutes, manual_degree, credits_reward, **optional** `touches` (see below), optional `promoted_idea_id`, `depends_on_refs: ['R##', ...]` (the dep edges captured during brainstorm), AND **`criterion_ids: ['Cn', ...]`** — the done-when criterion(s) this task gates. The brainstorm in Phase 3 is already grouped by criterion, so this is just transcribing that grouping. **Use the criterion_id SLUG, not a positional `"Cn"` token.** `Cn` ranks over the whole VERSION's criteria, so on a version holding more than one goal it resolves to some other goal's criterion — which is how all 15 tasks of goal 1000071 were mislinked while the seed printed success (learning 1000188). Since task 1003108 the server **refuses** a positional ref on a multi-goal version (`400 ambiguous_criterion_ref`) rather than guessing, so a slug is the only ref that works there. The server resolves it against the task's version at create (ADR 0025 / task 438). **This replaces the old `**Criterion:** Cn` prose** — don't write that line anymore; pass `criterion_ids` instead so the task is linked in `task_criteria` at creation and `/status` counts it with no backfill.
273
273
  - **`touches` is now OPTIONAL (task 882 / [ADR 0049](../../../docs/adr/0049-split-parallel-safety-contract.md)).** Omit it (or leave it blank) unless you already know a tight file set and want the advisory overlap signal during the brainstorm. At first `/builder-ship` the server backfills `touches[]` from the real committed diff — the declared set becomes what git actually reported. Don't burn planning effort predicting file lists; a rough or absent prediction is fine and no longer ship-blocking.
274
274
  - **`requires_rank` AUTO-DERIVES — you don't hand-set it per task (task [#1498](https://example.com/builders#/task/1498) / [ADR 0084](../../../docs/adr/0084-auto-derived-per-task-rank-floor.md)).** On create the server floors a task at `metic` when it is `security_sensitive` or its `touches` reach the permission / authz / ship-grade / deploy / migration core (the same protected paths a sub-Metic builder can't push to); everything else stays the open `xenos` default. To deliberately gate a task **higher** than the auto-floor (e.g. owner-only work), pass `requires_rank: 'metic' | 'archon'` on the task — it can only RAISE the floor, never drop below it.
275
- - **Pass `goal_id: <gid>` on each task** so it lands under its goal (not the version's default catch-all). `POST /tasks` now honors `goal_id` (validated to belong to the version); to move an already-created task, `PATCH /tasks/:id` with `{goal_id}` (task 1763). `criterion_ids` accepts a **slug** (the `criterion_id` you set above), a `"Cn"` position, or a numeric id — the slug is position-independent and cleanest.
275
+ - **Pass `goal_id: <gid>` on each task** so it lands under its goal (not the version's default catch-all). `POST /tasks` now honors `goal_id` (validated to belong to the version); to move an already-created task, `PATCH /tasks/:id` with `{goal_id}` (task 1763). `criterion_ids` accepts a **slug** (the `criterion_id` you set above), a numeric id, or a `"Cn"` position — **but `Cn` is rejected outright once the version holds a second goal**, and a version almost always gains one. Treat the slug as the only form you write.
276
276
  - A main() that POSTs each task **with its `criterion_ids`** (idempotent on `source_ref`; the criterion links are inserted at create, idempotent on `(task_id, criterion_id)`), PATCHes `idea_inbox` for promotions, and after all tasks exist, POSTs `task_dependencies` via `POST /tasks/:id/dependencies` (resolving each R## to the just-created task ID). Idempotent on `(task_id, depends_on_task_id)`. (Need to re-attribute an already-seeded task? `POST /tasks/:id/criteria` with `{criterion_ids:['Cn',...]}`; `DELETE /tasks/:id/criteria/:criterionId` to detach — both Metic+ ([ADR 0090](../../../docs/adr/0090-metic-task-authoring.md)), mirroring the dependency endpoints.)
277
277
 
278
278
  > **Seeding contract — validated on goal 1000045 (2026-07-16):** proof-tasks use `kind='verify'` (a deliverable that IS a verification; can't be prematurely confirmed), spikes `kind='spike'`; `POST /tasks` accepts `criterion_ids` + `goal_id` inline and disciplines include `ui`; there is **no `promoted_idea_id`** field on `POST /tasks` — mark a promoted idea via `PATCH /inbox/:id`; **descoping a criterion has no API** (done-when is create/satisfy/unsatisfy only) — remove the row via a direct DB delete (`ssh … "sudo -u postgres psql -d <db> -c \"DELETE FROM done_when_criteria WHERE id=…\""`) under the meta-task claim, capturing the SQL in its notes; and a seed script **must throttle + honor `429` `retry_after`** (per-IP rate limit) — add a base delay between calls and back off on 429, or a large seed dies halfway. A completed reference seed: `scripts/gds/<redacted>.js`.
@@ -1,6 +1,6 @@
1
1
  # 0134 — Private-first npm distribution for the core + client (paid scoped, then public at launch)
2
2
 
3
- - **Status:** **Superseded.** The **core half** — the private-by-default posture — is superseded by [ADR 0283](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) ([task 1003872](https://cloudbongos.com/builders#/task/1003872), 2026-09-13): `@bongos/core` now publishes **public**. This ADR's own "public at launch" step is the thing that happened; what remains accurate here is the *reasoning* for the pre-launch posture and the scoped-package mechanics. The **client half** was already superseded by [task 1003742](https://cloudbongos.com/builders#/task/1003742) (2026-09-09) — see the note under Decision. Publish path built in task 2089.
3
+ - **Status:** **Superseded.** The **core half** — the private-by-default posture — is superseded by [ADR 0283](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) ([task 1003872](https://cloudbongos.com/builders#/task/1003872), 2026-09-13): `@bongos/core` now publishes **public**. This ADR's own "public at launch" step is the thing that happened; what remains accurate here is the *reasoning* for the pre-launch posture and the scoped-package mechanics. The **client half** was already superseded by [task 1003742](https://cloudbongos.com/builders#/task/1003742) (2026-09-09) — see the note under Decision. Publish path built in task 2089. The **vendored-is-the-default** clause — *"until this registry path is proven end-to-end on a real greenfield standup"* — is superseded by [ADR 0292](0292-both-install-paths-stay-and-vendoring-is-the-production-one.md) ([task 1003879](https://cloudbongos.com/builders#/task/1003879), 2026-09-16): the condition is met, and vendoring stays the default anyway because it is the production provisioning path, not a fallback awaiting proof.
4
4
  - **Date:** 2026-07-07
5
5
  - **Deciders:** example-owner (owner/Archon), Claude. Owner asked to have the npm/CLI onboarding commands ready but kept private pre-launch, choosing paid private npm over the free GitHub Packages option.
6
6
  - **Task:** [#2089](https://example.com/builders#/task/2089) (publish path + private config) · [#2092](https://example.com/builders#/task/2092) (the `@cloudbongos` → `@bongos` scope rename + first real publish) · **Goal:** 35 (project-creation flow), criterion 165.
@@ -0,0 +1,77 @@
1
+ # ADR 0292 — Both install paths stay, and vendoring turns out to be the production one
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-09-16
5
+ - **Task:** [1003879](https://cloudbongos.com/builders#/task/1003879) (goal 1000090, working area 4 — Bongos Core distribution). Public-npm **gate 6**, the decision row deliberately held back from gate 5's mechanical cleanup ([1003878](https://cloudbongos.com/builders#/task/1003878)) because both candidates were large deletions that could as easily be mistakes.
6
+ - **Gated on:** [ADR 0283](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) / [task 1003872](https://cloudbongos.com/builders#/task/1003872) — the flip to a public publish, which is what made this question answerable.
7
+ - **Supersedes:** [ADR 0134](0134-private-first-npm-distribution.md)'s **"vendored is the default, until the registry path is proven end-to-end"** clause. The condition is met; the default does not change, and the reason it does not change is the substance of this ADR.
8
+ - **Adjacent:** [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md) (the CLI package, candidate 2) · [ADR 0161](0161-publish-on-merge.md) (the lane that publishes) · [ADR 0108](0108-instance-composition-configurable-root.md) (core-as-npm-dep).
9
+
10
+ ## Context
11
+
12
+ Gate 6 asked two questions that the public flip appeared to answer by implication, and deliberately refused to let them be swept out as consequences:
13
+
14
+ 1. Does the **vendored-tarball install path** (~124 references across `upgrade.js`, `init.js`, `provision-repo.js`, `package-core.js`) still earn its keep now that a registry install works for everyone?
15
+ 2. Does **`@cloudbongos/cli`** ([ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md)) still earn its keep, given task 1003872 named it as "redundant — that package exists only because the core is private"?
16
+
17
+ Both were framed as probable deletions. Both are **keeps**, and in the first case the framing was wrong in an instructive way.
18
+
19
+ ## The question the task asked was not the question that decides it
20
+
21
+ Gate 6 framed vendoring as insurance for hosts that cannot reach the registry: *"does anyone need to stand up an instance with no access to npm — an air-gapped host, a CI runner with no egress, a self-hoster behind a corporate proxy?"*, to be weighed against the AGPL self-host promise. Read that way, the honest answer is "probably nobody, yet" — and vendoring looks like 124 references maintained for a case that never happens.
22
+
23
+ That is not what the code does with it. From `provision-repo.js`, on the path every project created through the wizard takes:
24
+
25
+ > Core resolution is vendored (`--vendor-core`): **a tarball built from this checkout**, file:-pinned. init still accepts `--registry-core` for an operator who explicitly wants a published version.
26
+
27
+ Vendoring is not the fallback. It is the **production provisioning path for every instance this platform creates**, and `--registry-core` is the opt-out. Deleting it does not remove a contingency; it breaks project creation.
28
+
29
+ The reason is visible in `resolveVendorableCoreTarball`'s three-rung ladder, whose rungs are ordered *freshest-correct first* and whose comments name the bugs each one fixed:
30
+
31
+ 1. a `file:`-pinned control plane reuses its own pinned tarball — rebuilding from source aborts with an empty core set when the runner is itself a consumer instance (task 1002275, which left new repos empty);
32
+ 2. a registry-pinned control plane **packs the core it has installed**, so the child pins the same, current core the hub runs — this is what ended "the frozen-old-tarball births" (task 1002706);
33
+ 3. a genuine core source checkout rebuilds fresh via `package-core.js`.
34
+
35
+ Every rung answers one question: *what is the control plane actually running?* A registry install cannot express that. It can only express *what was most recently published*. Those coincide often and are not the same thing, and the gap between them is precisely where the bugs above lived.
36
+
37
+ This is the owner's own input, stated more sharply than the task body had it: vendoring is what makes standing up today's code work at all, because a checkout routinely runs ahead of the registry. Worth recording honestly, since the obvious measurement appears to contradict it:
38
+
39
+ - `main` is **not** durably ahead of the registry any more. Since publish-on-merge was armed, 136 of the last 300 commits on `main` are release bumps, releases land every 9–51 minutes, and `main`'s `CORE_VERSION` and the registry's `latest` were identical (1.19.785) at the moment this was written.
40
+ - But provisioning does not provision from `main`. It provisions from **the control plane's own checkout**, which is whatever commit that box is on, and it hands the child a byte-exact tarball of it. An unmerged branch, an uncommitted fix, a box deliberately held at an older pin — none of those exist on the registry at any version, ever.
41
+
42
+ So the air-gap argument, the one the task put forward, is the weakest of the reasons. The strongest is that vendoring is the only way to say **"this exact core"** instead of **"the newest published one"**.
43
+
44
+ ## Decision
45
+
46
+ ### 1. The vendored-tarball install path STAYS
47
+
48
+ No removal task follows. It is the default because it is load-bearing, not because the registry path was unproven.
49
+
50
+ ADR 0134's condition — *"until this registry path is proven end-to-end on a real greenfield standup"* — is **met**, and has been twice over: proven with a token on 2026-07-08 (task 2118 / blocker 71, a real `bongos init --registry-core` scaffold resolving `@bongos/core@1.17.0` and generating a reproducible lockfile), and tokenless since ADR 0283 (`npm pack @bongos/core` from a clean `HOME` with no credentials fetches 25.5 MB at the current version). This ADR closes that clause: the registry path is proven, supported, and reachable via `--registry-core` — and vendoring remains the default anyway, for the reason above rather than for want of proof.
51
+
52
+ What the public flip **did** retire is one of vendoring's supporting arguments, not its main one. Rung 2's own comment justifies packing the installed core partly "without requiring the child's owner to hold a token for the still-private registry package". That clause is now obsolete. The primary reason in the same sentence — the child pins the same, current core the hub runs — is untouched by the flip.
53
+
54
+ The costs gate 6 counted are real and are accepted with open eyes: ~124 references, a second install mode every scaffold/upgrade/provision path must keep straight, a Windows-only test failure ([task 1003839](https://cloudbongos.com/builders#/task/1003839)), and the pin-refresh complexity in `refreshStandaloneCorePin`. They buy the ability to provision an instance that matches the platform that provisioned it. That is a fair price.
55
+
56
+ ### 2. `@cloudbongos/cli` STAYS, on a new justification
57
+
58
+ Task 1003872 was right that the *original* reason is gone: the package was built so a newcomer could reach the board without a private-registry credential, and there is no private registry any more. Keeping it needs a reason of its own, and there is one, measured rather than asserted:
59
+
60
+ | | files | unpacked | runtime deps |
61
+ |---|---|---|---|
62
+ | `@cloudbongos/cli` | 32 | 0.5 MB | `undici` |
63
+ | `@bongos/core` | 2,611 | 46.4 MB | `express`, `pg`, `pino` |
64
+
65
+ `npx @cloudbongos/cli` costs a newcomer half a megabyte. Collapsing it into the core would make the same front door a 46 MB download that drags in a web server and a Postgres driver to print a task list. The thin client is the better artifact on merit, independently of why it first existed.
66
+
67
+ The three-way framing in the task body is also narrower than it looked: **unpublishing is not on the table.** The package is public at 0.1.3 and strangers may have installed it; withdrawing a published name is a different act from deleting private code, and nothing here justifies it.
68
+
69
+ So: keep it, keep publishing it, and let it depend on the now-public core. [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md) stands, with its *rationale* amended — the package is a deliberately small entry point, no longer a workaround for a private core.
70
+
71
+ ## Consequences
72
+
73
+ - **No code is deleted by this decision.** Gate 6 closes as a keep/keep, which the task explicitly allowed.
74
+ - ADR 0134's "vendored until proven" clause is spent. A future reader asking "why is vendoring still the default when the registry works?" is answered here, not there.
75
+ - The one genuine piece of rot the flip left behind in this area is rung 2's stale token clause; it is a comment, and it is corrected by this ADR's existence rather than by a migration.
76
+ - **What would reopen this:** if provisioning ever stops meaning "match the control plane" — if instances came to be pinned to published versions by policy rather than to the hub's own tree — vendoring's main justification goes with it, and candidate 1 should be re-asked. Nothing on the current roadmap does that.
77
+ - The self-host/AGPL argument the task raised is not the reason for the outcome, but it is not contradicted either: a self-hoster behind a proxy keeps a supported path, as a side effect of a decision made for other reasons.
@@ -383,3 +383,4 @@ This keeps the decision history honest and traceable.
383
383
  | 0289 | [**An unconsumed contribution is a CI failure, not a boot refusal** ([task 1003819](https://cloudbongos.com/builders#/task/1003819), successor to abandoned task 1002464). A module declares `contributes` and nothing checked that a declared seam is actually WIRED: `verifyPortsSatisfied` asks the OPPOSITE question (every `consumes` has exactly one provider) and `validateManifest` checks only the SHAPE (a closed key set, so a typo'd KEY errors). So `contributes.pollers: ["ghost"]` with no `pollers/ghost.js` boots silent — and correctly so, because `startModulePollers`, `hallWidgetScripts` and `moduleWebSurfaces` each skip a missing artifact the same way they skip a genuine declarative-only entry. Verified on main: 84 declared entries resolve, 5 do not. **Decision: `scripts/gds/seam-wiring-guard.js`, fitness Check 33.** Its `SEAM_CONSUMERS` table pairs each seam with THE FUNCTION THAT ACTUALLY READS IT, so the guard asks the consumer's own question instead of a second convention that could drift; a seam with no reader anywhere (`rooms`) carries `consumer: null` and every declaration of it is reported. The exemption is **per-entry, in the manifest** (`declarativeSeams`), validated in `validateManifest` against the module's own `contributes` — a typo'd exemption is an ERROR, since one that validated clean would exempt nothing while reading as though it exempted something — and a **stale** exemption whose artifact now exists is reported too, so the list cannot lie in either direction. The **wired set prints on the PASS path**: a check that is silent when it passes is no evidence it ran. CI and not boot, deliberately asymmetric with `verifyEnabledPorts`: an unprovided port becomes a runtime throw on a request path in a healthy-looking process, while an unconsumed contribution crashes nothing — a start-up refusal would trade a real outage risk for a cosmetic one. Rejected: a boot refusal; a blanket skip for the seams that allow declarative entries (that is how the gap arose); rebasing branch `task-1002464` (333 lines, 461 commits behind, against a since-rewritten `loader.js` — read its tests, close PR #589).](0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md) | modules / architecture |
384
384
  | 0290 | [**"Disconnect from Bongos entirely" is the owner-initiated exception to retention** ([task 1003249](https://cloudbongos.com/builders#/task/1003249), owner decision 2026-08-24 + this session). ADR 0181 keeps a torn-down project's database, dumps, port and address so "Take it offline" is reversible; this is the separate true delete. Own-scoped + archon override, with the typed name checked SERVER-side (`confirm_slug`); refused while any other run is open (supersedes nothing). The runner tears down, drops the database and its role, erases the dumps by their EXACT nightly filename, removes env/state/checkout/account, forgets the catalog + membership + client rows (new `project.catalog` verb `forget`, both claim rules), and deletes the provisioning row LAST — releasing name, port and address, and keeping every earlier failure retryable. The GitHub repository and any sign-in App are never touched.](0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md) |
385
385
  | 0291 | [**The accessibility dimension scores the transcript, so it lives in the session evaluator, not the grader panel** ([task 1002516](https://cloudbongos.com/builders#/task/1002516), clause 4 of criterion `accessibility-fluency-profile`). The task and its sibling task 1003937 both point at `modules/grading/`, but a v4 worker is handed `{ task, valueSummary, diff, changedFiles, … }` and **no transcript** — it judges the change a session produced, never how the builder was spoken to while producing it. The transcript lives in `session_records`, so the dimension does too: `modules/sessions/accessibility.js`, a pure scorer over the digest's per-turn detail, scored SERVER-SIDE at upload (a score the uploader could supply is a score nobody can read; and the SessionEnd hook's 6-second watchdog rules out a second round trip for the profile). Three signals, exactly the three the criterion names. **Profile-relative, and a sanctioned signal is not scored at all** — `engineer` waives jargon + framing, `comfortable` waives terminal-first, `mixed` widens rather than waives, and an all-waived session scores `null`, not a number, so a profile-sanctioned reply cannot score low even in principle; `verbosity` is not an input and every rate is per-1000-characters, so brevity is arithmetically incapable of lowering it. Advisory, gating nothing (ADR 0241's shape). The profile crosses the module boundary as DATA — the route resolves the `builder-settings` kernel port, never a sibling require (ADR 0083), with a test as the tripwire against a value rename. Detectors deliberately undercount (word-bounded jargon, after a substring match fired `ORM` on "format"; a click-path table of UIs that actually exist), because a metric that punishes correct work is what the task itself calls actively harmful. Does NOT answer task 1003937, whose copy/voice lens judges a DIFF and still needs the owner on cost, gate-or-advisory, and what standard to judge against.](0291-the-accessibility-dimension-scores-the-transcript-not-the-diff.md) | grading / sessions / accessibility |
386
+ | 0292 | [**Both install paths stay, and vendoring turns out to be the production one** ([task 1003879](https://cloudbongos.com/builders#/task/1003879), goal 1000090 — public-npm **gate 6**, the decision row held back from gate 5's mechanical cleanup because both candidates were large deletions that could as easily be mistakes). Gate 6 framed the **vendored-tarball path** (~124 refs across `upgrade.js`/`init.js`/`provision-repo.js`/`package-core.js`) as insurance for hosts that cannot reach npm, to be weighed against the AGPL self-host promise. Read that way it loses. **That is not what the code does with it:** `provision-repo.js` resolves the core for every wizard-created project as "a tarball built from THIS CHECKOUT, file:-pinned", with `--registry-core` as the opt-out — vendoring is the production provisioning path, not the fallback, and deleting it breaks project creation. `resolveVendorableCoreTarball`'s three-rung ladder (reuse the control plane's own pinned tarball → pack the core it has INSTALLED → rebuild from source) exists to answer one question a registry install cannot express: *what is the control plane actually running?* Rungs 1 and 2 each name the bug they fixed — empty new repos when the runner is itself a consumer (task 1002275), and "the frozen-old-tarball births" (task 1002706). The owner's input — a checkout routinely runs ahead of the registry — holds, though not via `main`: since publish-on-merge, 136 of the last 300 `main` commits are release bumps, releases land every 9–51 min, and `main`'s `CORE_VERSION` equalled the registry `latest` (1.19.785) when this was written. Provisioning does not provision from `main`; it provisions from the control plane's own tree, and an unmerged branch or a deliberately-held pin exists on the registry at NO version, ever. **Supersedes ADR 0134's "vendored is the default until the registry path is proven end-to-end" clause:** the condition is MET twice over — with a token 2026-07-08 (task 2118 / blocker 71) and tokenless since [ADR 0283](0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md) (`npm pack @bongos/core` from a clean `HOME` fetches 25.5 MB) — and the default stands anyway, for load-bearing reasons rather than for want of proof. Costs accepted with open eyes: ~124 refs, a second install mode every scaffold/upgrade/provision path must keep straight, a Windows-only test failure ([task 1003839](https://cloudbongos.com/builders#/task/1003839)), `refreshStandaloneCorePin`. **Candidate 2 — `@cloudbongos/cli` — also STAYS, on a NEW justification:** task 1003872 was right that the original reason (reaching the board without a private-registry credential) is gone, so it needs one of its own, and the measurement supplies it — 32 files / 0.5 MB / `undici`, versus the core's 2,611 files / 46.4 MB / `express`+`pg`+`pino`. Collapsing it would make `npx` a 46 MB download dragging in a web server and a Postgres driver to print a task list. **Unpublishing was never on the table** (public at 0.1.3, strangers may have installed it); [ADR 0258](0258-the-public-cli-is-a-generated-client-package-not-the-published-core.md) stands with its *rationale* amended — a deliberately small entry point, no longer a private-core workaround. The one real rot the flip left is rung 2's stale "still-private registry package" clause, a comment. **Gate 6 closes keep/keep, no removal task.** Reopens if provisioning ever stops meaning "match the control plane".](0292-both-install-paths-stay-and-vendoring-is-the-production-one.md) | distribution / provisioning |
@@ -2046,5 +2046,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2046
2046
  landed since 1.19.784 with no explicit bump. run 35156349018. (task 1002620)
2047
2047
  1.19.786 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2048
2048
  landed since 1.19.785 with no explicit bump. run 35157132070. (task 1002620)
2049
+ 1.19.787 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2050
+ landed since 1.19.786 with no explicit bump. run 35158163962. (task 1002620)
2051
+ 1.19.788 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2052
+ landed since 1.19.787 with no explicit bump. run 35159266737. (task 1002620)
2049
2053
  ---------------------------------------------------------------------------
2050
2054
  ```
@@ -286,13 +286,26 @@ function parseCriterionRef(raw) {
286
286
  }
287
287
 
288
288
  // Resolve an array of refs to done_when_criteria.id values within a version.
289
- // Returns { ids: [unique numeric ids, first-appearance order], unresolved: [refs] }.
290
-
291
- async function resolveCriterionIds(versionId, refs) {
289
+ // Returns { ids: [unique numeric ids, first-appearance order], unresolved: [refs],
290
+ // ambiguous: [positional refs refused because the version holds >1 goal] }.
291
+ //
292
+ // WHY ambiguous exists (task 1003108). A positional "Cn" ranks over the WHOLE
293
+ // version's criteria. That is unambiguous only while the version holds ONE goal:
294
+ // add a second and the "C1" a planner reads off a per-goal spec silently resolves
295
+ // to some other goal's criterion — a wrong link, not an error. It mislinked all 15
296
+ // tasks of goal 1000071 (2026-08-17, learning 1000188) and the seed still printed
297
+ // success. Task 1003076 added the after-the-fact tally that catches the symptom;
298
+ // this refuses the ref at the write, so the mislink cannot be created. Slugs are
299
+ // position-independent and stay legal on every version, which is why the skill now
300
+ // requires them. `executor` is the same testability seam countTasksWithoutCriterionLink
301
+ // uses — a fake SQL-recording client needs no live Postgres.
302
+
303
+ async function resolveCriterionIds(versionId, refs, executor = pool) {
292
304
  const ids = [];
293
305
  const unresolved = [];
294
- if (!Array.isArray(refs) || refs.length === 0) return { ids, unresolved };
295
- const { rows } = await pool.query(
306
+ const ambiguous = [];
307
+ if (!Array.isArray(refs) || refs.length === 0) return { ids, unresolved, ambiguous };
308
+ const { rows } = await executor.query(
296
309
  `SELECT id, criterion_id,
297
310
  ROW_NUMBER() OVER (ORDER BY sort_order, criterion_id) AS cnum
298
311
  FROM done_when_criteria
@@ -302,17 +315,31 @@ async function resolveCriterionIds(versionId, refs) {
302
315
  const byId = new Set(rows.map((r) => Number(r.id)));
303
316
  const byCnum = new Map(rows.map((r) => [Number(r.cnum), Number(r.id)]));
304
317
  const bySlug = new Map(rows.map((r) => [String(r.criterion_id), Number(r.id)]));
318
+ // Only pay for the goal count when a positional ref is actually present — the
319
+ // slug path (the one the skill now requires) costs no extra query.
320
+ let multiGoal = false;
321
+ if (refs.some((r) => parseCriterionRef(r).kind === 'cnum')) {
322
+ const { rows: g } = await executor.query(
323
+ 'SELECT COUNT(*)::int AS n FROM goals WHERE version_id = $1',
324
+ [versionId]
325
+ );
326
+ multiGoal = Number(g[0] ? g[0].n : 0) > 1;
327
+ }
305
328
  const seen = new Set();
306
329
  for (const raw of refs) {
307
330
  const p = parseCriterionRef(raw);
308
331
  let id = null;
309
- if (p.kind === 'cnum') id = byCnum.get(p.n) ?? null;
310
- else if (p.kind === 'id') id = byId.has(p.id) ? p.id : null;
332
+ if (p.kind === 'cnum') {
333
+ // Refuse rather than guess: resolving it would produce a plausible-looking
334
+ // wrong link that no later check attributes to the ref.
335
+ if (multiGoal) { ambiguous.push(String(raw).trim()); continue; }
336
+ id = byCnum.get(p.n) ?? null;
337
+ } else if (p.kind === 'id') id = byId.has(p.id) ? p.id : null;
311
338
  else id = bySlug.get(p.slug) ?? null;
312
339
  if (id == null) { unresolved.push(String(raw).trim()); continue; }
313
340
  if (!seen.has(id)) { seen.add(id); ids.push(id); }
314
341
  }
315
- return { ids, unresolved };
342
+ return { ids, unresolved, ambiguous };
316
343
  }
317
344
 
318
345
  // countTasksWithoutCriterionLink — task 1003157's DONE WHEN item 2: the count of
@@ -62,8 +62,9 @@ module.exports = function registerTaskWriteRoutes(router) {
62
62
  requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — per-task rank floor (auto-derived; explicit value can only raise it)
63
63
  auto_detect_touches: { type: 'boolean' },
64
64
  // task 438: link the new task to done-when criteria at creation time. Each
65
- // element is a numeric done_when_criteria.id, a positional "Cn" token, or a
66
- // criterion_id slug — resolved against this task's version below (ADR 0025).
65
+ // element is a criterion_id slug (required on a multi-goal version — task
66
+ // 1003108), a numeric done_when_criteria.id, or a positional "Cn" token —
67
+ // resolved against this task's version below (ADR 0025).
67
68
  criterion_ids: { type: 'array', maxItems: 100 },
68
69
  // BV1.R53 (task 1422): module affinity label. Drives module-disjointness at
69
70
  // claim time and diff⊆module drift detection at ship time.
@@ -119,7 +120,10 @@ module.exports = function registerTaskWriteRoutes(router) {
119
120
  // is unambiguous and a numeric id from another version doesn't resolve.
120
121
  let criterionIdsResolved = [];
121
122
  if (Array.isArray(body.criterion_ids) && body.criterion_ids.length > 0) {
122
- const { ids, unresolved } = await db.resolveCriterionIds(body.version_id, body.criterion_ids);
123
+ const { ids, unresolved, ambiguous } = await db.resolveCriterionIds(body.version_id, body.criterion_ids);
124
+ if (ambiguous.length > 0) {
125
+ return res.fail('ambiguous_criterion_ref', { status: 400, message: `Version '${body.version_id}' holds more than one goal, so a positional "Cn" ref ranks over the WHOLE version's criteria — not this goal's — and would link to the wrong criterion silently (task 1003108). Re-send these as criterion_id slugs: ${ambiguous.join(', ')}.`, details: { ambiguous } });
126
+ }
123
127
  if (unresolved.length > 0) {
124
128
  return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${body.version_id}': ` +
125
129
  `${unresolved.join(', ')}. Use a numeric done_when_criteria.id, a "Cn" position, or a criterion_id slug.`, details: { unresolved } });
@@ -452,7 +452,10 @@ module.exports = function buildTasksRouter() {
452
452
 
453
453
  const task = await db.getTask(id);
454
454
  if (!task) return res.fail('task_not_found', 404);
455
- const { ids, unresolved } = await db.resolveCriterionIds(task.version_id, refs);
455
+ const { ids, unresolved, ambiguous } = await db.resolveCriterionIds(task.version_id, refs);
456
+ if (ambiguous.length > 0) {
457
+ return res.fail('ambiguous_criterion_ref', { status: 400, message: `Version '${task.version_id}' holds more than one goal, so a positional "Cn" ref ranks over the WHOLE version's criteria — not this goal's — and would link to the wrong criterion silently (task 1003108). Re-send these as criterion_id slugs: ${ambiguous.join(', ')}.`, details: { ambiguous } });
458
+ }
456
459
  if (unresolved.length > 0) {
457
460
  return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${task.version_id}': ${unresolved.join(', ')}.`, details: { unresolved } });
458
461
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.786",
3
+ "version": "1.19.788",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.786",
9
+ "version": "1.19.788",
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.786",
3
+ "version": "1.19.788",
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",
@@ -1,3 +1,10 @@
1
+ // DO NOT COPY THE `crit:` TOKENS FROM THIS FILE (task 1003108) — copy its
2
+ // verifySeedCriteriaLinks tail instead, which is what this script is cited for.
3
+ // Its positional "Cn" refs rank over the whole VERSION's criteria, so on a
4
+ // multi-goal version they resolve to another goal's criterion silently (learning
5
+ // 1000188). POST /tasks now refuses a positional ref there (400
6
+ // ambiguous_criterion_ref). A new seed uses criterion_id SLUGS.
7
+ //
1
8
  // Seed the tasks for goal 1000071 "Idea routing — work that has a home lands in it" (BONGOS-V1).
2
9
  // Source of truth: docs/specs/bongos-v1-idea-routing.md (status: final).
3
10
  // Idempotent on (source, source_ref). Re-running resolves to the existing rows.
@@ -1,3 +1,12 @@
1
+ // DO NOT COPY THE `crit:` TOKENS FROM THIS FILE (task 1003108). Its positional
2
+ // "Cn" refs were written when BONGOS-V1 held one goal, where they were unambiguous.
3
+ // Cn ranks over the whole VERSION's criteria, so on a multi-goal version it resolves
4
+ // to another goal's criterion silently — that is how all 15 tasks of goal 1000071 were
5
+ // mislinked (learning 1000188). POST /tasks now refuses a positional ref on a
6
+ // multi-goal version (400 ambiguous_criterion_ref). A new seed uses criterion_id
7
+ // SLUGS. This file is kept as-is because it has already run and is idempotent on
8
+ // source_ref — a re-run returns the existing rows before criterion resolution.
9
+ //
1
10
  // Seed the tasks for goal 1000045 "Public builder profiles" (BONGOS-V1, internal).
2
11
  // Source of truth: docs/specs/bongos-v1-public-builder-profiles.md (status: final).
3
12
  // Idempotent on (source, source_ref). Re-running resolves to the existing rows.
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.786'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.788'; // 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');
@@ -6,8 +6,10 @@
6
6
  // - a numeric done_when id → { kind: 'id', id }
7
7
  // - a criterion_id slug → { kind: 'slug', slug }
8
8
  // resolveCriterionIds itself is DB-backed (it ranks a version's criteria), so the
9
- // parsing rule — the part that decides what a token MEANS — is what's unit-tested
10
- // here without a database. Run: node tests/criterion_ref_parse.mjs
9
+ // parsing rule — the part that decides what a token MEANS — is unit-tested here
10
+ // without a database. Task 1003108 added a second block covering resolveCriterionIds
11
+ // itself through its executor seam: a positional "Cn" must be REFUSED once the
12
+ // version holds more than one goal. Run: node tests/criterion_ref_parse.mjs
11
13
 
12
14
  import { strict as assert } from 'node:assert';
13
15
  import { createRequire } from 'node:module';
@@ -49,5 +51,82 @@ test('a "Cn" embedded in a longer string does NOT match (anchored)', () => {
49
51
  assert.equal(db.parseCriterionRef('see C8 above').kind, 'slug');
50
52
  });
51
53
 
52
- console.log(`\ncriterion ref parse (task 438) tests: ${passed} passed, ${failed} failed`);
53
- process.exit(failed > 0 ? 1 : 0);
54
+ // --- resolveCriterionIds: the multi-goal refusal (task 1003108) -------------
55
+ // resolveCriterionIds is DB-backed, but it takes an `executor` seam (the one
56
+ // countTasksWithoutCriterionLink uses), so a fake SQL-recording client covers the
57
+ // rule without Postgres. The rule: a positional "Cn" ranks over the WHOLE
58
+ // version's criteria, so once a version holds a second goal it silently resolves
59
+ // to another goal's criterion — the shape that mislinked all 15 tasks of goal
60
+ // 1000071. It must come back as `ambiguous`, never as an id.
61
+
62
+ function fakeExecutor(goalCount) {
63
+ const seen = [];
64
+ return {
65
+ seen,
66
+ async query(sql, params) {
67
+ seen.push(sql);
68
+ if (/FROM goals/.test(sql)) return { rows: [{ n: goalCount }] };
69
+ return { rows: [
70
+ { id: 501, criterion_id: 'first-slug', cnum: 1 },
71
+ { id: 502, criterion_id: 'second-slug', cnum: 2 },
72
+ ] };
73
+ },
74
+ };
75
+ }
76
+
77
+ async function atest(name, fn) {
78
+ try { await fn(); passed++; console.log(` ok ${name}`); }
79
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
80
+ }
81
+
82
+ async function main() {
83
+ await atest('a positional "Cn" resolves on a SINGLE-goal version', async () => {
84
+ const r = await db.resolveCriterionIds('V9', ['C2'], fakeExecutor(1));
85
+ assert.deepEqual(r.ids, [502]);
86
+ assert.deepEqual(r.ambiguous, []);
87
+ assert.deepEqual(r.unresolved, []);
88
+ });
89
+
90
+ await atest('a positional "Cn" is REFUSED on a multi-goal version', async () => {
91
+ const r = await db.resolveCriterionIds('V9', ['C2'], fakeExecutor(2));
92
+ assert.deepEqual(r.ids, [], 'must not resolve to an id — refusing is the whole point');
93
+ assert.deepEqual(r.ambiguous, ['C2']);
94
+ assert.deepEqual(r.unresolved, [], 'ambiguous is not the same failure as unknown');
95
+ });
96
+
97
+ await atest('slugs still resolve on a multi-goal version', async () => {
98
+ const r = await db.resolveCriterionIds('V9', ['second-slug'], fakeExecutor(2));
99
+ assert.deepEqual(r.ids, [502]);
100
+ assert.deepEqual(r.ambiguous, []);
101
+ });
102
+
103
+ await atest('a mixed list refuses only the positional ref', async () => {
104
+ const r = await db.resolveCriterionIds('V9', ['first-slug', 'C2', '502'], fakeExecutor(2));
105
+ assert.deepEqual(r.ids, [501, 502], 'the slug and the numeric id survive');
106
+ assert.deepEqual(r.ambiguous, ['C2']);
107
+ });
108
+
109
+ await atest('an unknown slug is unresolved, not ambiguous', async () => {
110
+ const r = await db.resolveCriterionIds('V9', ['no-such-slug'], fakeExecutor(2));
111
+ assert.deepEqual(r.unresolved, ['no-such-slug']);
112
+ assert.deepEqual(r.ambiguous, []);
113
+ });
114
+
115
+ await atest('the goal count is NOT queried when no positional ref is present', async () => {
116
+ // The slug path is the one the skill now requires — it must not pay for an
117
+ // extra round trip. A green test here is what keeps the fast path fast.
118
+ const ex = fakeExecutor(2);
119
+ await db.resolveCriterionIds('V9', ['first-slug'], ex);
120
+ assert.equal(ex.seen.filter((s) => /FROM goals/.test(s)).length, 0);
121
+ });
122
+
123
+ await atest('an empty ref list returns all three arrays', async () => {
124
+ const r = await db.resolveCriterionIds('V9', [], fakeExecutor(2));
125
+ assert.deepEqual(r, { ids: [], unresolved: [], ambiguous: [] });
126
+ });
127
+
128
+ console.log(`\ncriterion ref parse (task 438) + multi-goal refusal (task 1003108) tests: ${passed} passed, ${failed} failed`);
129
+ process.exit(failed > 0 ? 1 : 0);
130
+ }
131
+
132
+ main();