@bongos/core 1.19.658 → 1.19.660
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 +26 -16
- package/.claude/skills/ask-for-help/SKILL.md +74 -0
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/catalog.js +42 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_catalog.mjs +13 -0
- package/tests/government_parity.mjs +32 -2
- package/tests/skill_ask_for_help.mjs +72 -0
package/.bongos-core.json
CHANGED
|
@@ -2,23 +2,28 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.660",
|
|
6
|
+
"core_contract": "1.19.660",
|
|
7
|
+
"source_commit": "5d0f5acbd9c33a29dfff28dfc344af42a4bf53f3",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T01:39:06.499Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 473,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2113,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2610,
|
|
20
|
+
"tree_sha256": "02c8e791b05c6718bd28b1e66840239f3f3b6ef1cd6d7fff3e6f22598468f4db",
|
|
21
21
|
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
24
|
+
"mode": "0000644",
|
|
25
|
+
"sha256": "53dd3df0bc38a8d75de44dfb54f68df4a4f7c288f4f4bf74c84cfc3ae7b23d04"
|
|
26
|
+
},
|
|
22
27
|
{
|
|
23
28
|
"path": ".claude/skills/backlog-review/SKILL.md",
|
|
24
29
|
"mode": "0000644",
|
|
@@ -2772,7 +2777,7 @@
|
|
|
2772
2777
|
{
|
|
2773
2778
|
"path": "docs/file-map.md",
|
|
2774
2779
|
"mode": "0000644",
|
|
2775
|
-
"sha256": "
|
|
2780
|
+
"sha256": "fb772f5b797986dcfc60120d5bcb3c1097436d0434160028fe0f48f9f450e84e"
|
|
2776
2781
|
},
|
|
2777
2782
|
{
|
|
2778
2783
|
"path": "docs/handoff-template.md",
|
|
@@ -2782,7 +2787,7 @@
|
|
|
2782
2787
|
{
|
|
2783
2788
|
"path": "docs/module-api-changelog.md",
|
|
2784
2789
|
"mode": "0000644",
|
|
2785
|
-
"sha256": "
|
|
2790
|
+
"sha256": "4ed79e71c0f7543d8189f2c4d0ee3c83c34429b6785beb7ddeaa529b7f5f7576"
|
|
2786
2791
|
},
|
|
2787
2792
|
{
|
|
2788
2793
|
"path": "docs/modules-contract.md",
|
|
@@ -4482,7 +4487,7 @@
|
|
|
4482
4487
|
{
|
|
4483
4488
|
"path": "modules/government/catalog.js",
|
|
4484
4489
|
"mode": "0000644",
|
|
4485
|
-
"sha256": "
|
|
4490
|
+
"sha256": "ce3dd2f87b0c6fbb1e2c62028bf50679fa8be316546efb6bf398a2afe22a791d"
|
|
4486
4491
|
},
|
|
4487
4492
|
{
|
|
4488
4493
|
"path": "modules/government/config.js",
|
|
@@ -7747,12 +7752,12 @@
|
|
|
7747
7752
|
{
|
|
7748
7753
|
"path": "package-lock.json",
|
|
7749
7754
|
"mode": "0000644",
|
|
7750
|
-
"sha256": "
|
|
7755
|
+
"sha256": "65abd9c62cd7f323dcc6478d9203b11fcd364e6c86e43e0e11763dfa8812311f"
|
|
7751
7756
|
},
|
|
7752
7757
|
{
|
|
7753
7758
|
"path": "package.json",
|
|
7754
7759
|
"mode": "0000644",
|
|
7755
|
-
"sha256": "
|
|
7760
|
+
"sha256": "a71aea9196614109127b129b57630118e53dabf98c168c6e7ec291673d27eb86"
|
|
7756
7761
|
},
|
|
7757
7762
|
{
|
|
7758
7763
|
"path": "public-docs/index.html",
|
|
@@ -9507,7 +9512,7 @@
|
|
|
9507
9512
|
{
|
|
9508
9513
|
"path": "src/module-api.js",
|
|
9509
9514
|
"mode": "0000644",
|
|
9510
|
-
"sha256": "
|
|
9515
|
+
"sha256": "5e1cee6085965844a656e2b5791db19d9cad43770691f4ebb07100787bdbf448"
|
|
9511
9516
|
},
|
|
9512
9517
|
{
|
|
9513
9518
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10842,7 +10847,7 @@
|
|
|
10842
10847
|
{
|
|
10843
10848
|
"path": "tests/government_catalog.mjs",
|
|
10844
10849
|
"mode": "0000644",
|
|
10845
|
-
"sha256": "
|
|
10850
|
+
"sha256": "1058c2c691a05b4db0365fed68794de8cffa2c4af2c97f4e8c91ba197f67cb6d"
|
|
10846
10851
|
},
|
|
10847
10852
|
{
|
|
10848
10853
|
"path": "tests/government_charter_library.mjs",
|
|
@@ -10907,7 +10912,7 @@
|
|
|
10907
10912
|
{
|
|
10908
10913
|
"path": "tests/government_parity.mjs",
|
|
10909
10914
|
"mode": "0000644",
|
|
10910
|
-
"sha256": "
|
|
10915
|
+
"sha256": "0c619508691d41f41b0fbbb1d6939ba9ac7fde2ef4ec08ae7622bfa70bfbf624"
|
|
10911
10916
|
},
|
|
10912
10917
|
{
|
|
10913
10918
|
"path": "tests/government_principals.mjs",
|
|
@@ -12634,6 +12639,11 @@
|
|
|
12634
12639
|
"mode": "0000644",
|
|
12635
12640
|
"sha256": "7e4f4270188771f1fc1fa7d68e9730b4562fb772ee9ecbd04b65fd1738ee9795"
|
|
12636
12641
|
},
|
|
12642
|
+
{
|
|
12643
|
+
"path": "tests/skill_ask_for_help.mjs",
|
|
12644
|
+
"mode": "0000644",
|
|
12645
|
+
"sha256": "02424dd3b9f084d30802d0fde441ad7d6c20edcfbc8172683dc5d94d7d19725b"
|
|
12646
|
+
},
|
|
12637
12647
|
{
|
|
12638
12648
|
"path": "tests/skill_docs_unavailable_regrade.mjs",
|
|
12639
12649
|
"mode": "0000644",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask-for-help
|
|
3
|
+
description: >-
|
|
4
|
+
Ask a named builder or a craft for help on a task, as a filed collab help request. Triggers: "ask <builder> to help with task N", "get <name> on this", "who can help with N", "I'm stuck on task N", "/ask-for-help".
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**Route skill (authoritative).** The core action is `POST /api/gds/help-requests`. Once the addressee and the context are settled, file it and report the id — do not narrate the API.
|
|
8
|
+
|
|
9
|
+
You are asking a *person* for help on behalf of the current builder. Being asked assigns them nothing: the collab surfaces recommend, they never transfer a claim (ADR 0187 §3). Declining costs them nothing.
|
|
10
|
+
|
|
11
|
+
## Recognise the ask
|
|
12
|
+
|
|
13
|
+
"Ask <builder> to help with task N", "can someone artist-side look at this", "I'm stuck on N" — all of these are help requests. **File one before reaching for any other surface.** The failure this skill exists to stop: a session hears "ask X to help" and files a BLOCKER instead, which is the owner's review queue and is not read at `/#/collab` by anyone. Both were needed once (2026-09-10, task 1002068) and only the blocker got written.
|
|
14
|
+
|
|
15
|
+
## Pick the right surface
|
|
16
|
+
|
|
17
|
+
| The ask | Surface | Route |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| "Ask X to help with N" / "I'm stuck" | **help request** — this skill | `POST /help-requests` |
|
|
20
|
+
| "X should pick this up" / "pass N to X" | task recommendation — "you might want to claim this" | `POST /task-recommendations` (`task_id`, `recommended_to`, `reason`) |
|
|
21
|
+
| Work stuck on the owner's decision, credential, or a hand on the box | blocker — see `/blocker-review` | `POST /blockers` |
|
|
22
|
+
|
|
23
|
+
They are **complementary, not alternatives.** An owner-only task (a credential rotation, a purchase) deserves a blocker *and* a help request addressed to the owner: the blocker parks the task and holds the detail, the help request is what actually reaches them.
|
|
24
|
+
|
|
25
|
+
## How to use
|
|
26
|
+
|
|
27
|
+
1. **Resolve the addressee to an id — never guess one.**
|
|
28
|
+
```bash
|
|
29
|
+
node scripts/gds/api.js GET /api/gds/builders/directory
|
|
30
|
+
```
|
|
31
|
+
Match the `github_login` or `display_name` the user named. A name with no match is a question for the user, not a guess — a wrong `needs_builder_id` sends the ask to a stranger.
|
|
32
|
+
|
|
33
|
+
Addressing a **craft** instead of a person is the alternative: `needs_craft` is one of `artist`, `builder`, `ideator`, `ui`. It reaches only builders who declared that discipline in their profile, so a craft nobody has declared reaches nobody.
|
|
34
|
+
|
|
35
|
+
**Exactly one addressee.** Both fields is a 400 `two_addressees`; neither is `no_addressee`. The refusal body carries the valid `crafts`.
|
|
36
|
+
|
|
37
|
+
2. **Attach at most one context.** `task_id` **or** `goal_id`, never both (400 `two_contexts`). Neither is allowed — a general "who knows about X" has no context row.
|
|
38
|
+
|
|
39
|
+
3. **Write `what_is_stuck` so it is answerable without this session** (≤ 4000 chars). The recipient has none of your context. Cover:
|
|
40
|
+
- what is stuck, in plain words;
|
|
41
|
+
- why it needs *this* person (rank, access, a call only they can make) — being asked without a reason reads as work being dumped;
|
|
42
|
+
- the concrete steps, so the ask is actionable rather than a summons;
|
|
43
|
+
- how to verify it worked;
|
|
44
|
+
- what has already been tried or ruled out.
|
|
45
|
+
|
|
46
|
+
Never put a secret in it — a password, token, or key. If the work involves one, describe the rotation and let them handle the value on the box.
|
|
47
|
+
|
|
48
|
+
4. **File it.** Write the body to a file first — `api.js` silently drops a positional JSON body:
|
|
49
|
+
```bash
|
|
50
|
+
node scripts/gds/api.js POST /api/gds/help-requests --body-file <path>.json
|
|
51
|
+
```
|
|
52
|
+
```json
|
|
53
|
+
{ "needs_builder_id": 3, "task_id": 1002068, "what_is_stuck": "..." }
|
|
54
|
+
```
|
|
55
|
+
A 201 returns the row. `no_such_builder` on `needs_builder_id` means step 1 was skipped.
|
|
56
|
+
|
|
57
|
+
5. **Report it honestly, including the limit.** Give the request id and say where it lands: the recipient sees it at `/#/collab`, which reads `GET /help-requests/for-me`. That is a **pull, not a push** — help requests have no builder-needs notification (task recommendations do). If it is urgent, say so and tell the user the ask waits until that person opens Collab.
|
|
58
|
+
|
|
59
|
+
6. **Settle it when it resolves** — only the asker can, and an unsettled pile is noise:
|
|
60
|
+
```bash
|
|
61
|
+
node scripts/gds/api.js PATCH /api/gds/help-requests/<id> --body-file <path>.json
|
|
62
|
+
```
|
|
63
|
+
`{"status": "answered"}` when someone helped, `{"status": "withdrawn"}` when it is no longer needed. Check yours with `GET /api/gds/help-requests/mine`.
|
|
64
|
+
|
|
65
|
+
## Constraints
|
|
66
|
+
|
|
67
|
+
- **Never claim the task on the recipient's behalf**, and never say they are "assigned" or "on it". They still claim it themselves.
|
|
68
|
+
- **One request per ask.** Re-asking the same person about the same task makes a second open row, not a nudge.
|
|
69
|
+
- **Asking is not privileged** — any builder may file one, and gating it would exclude exactly the newcomers most likely to be stuck.
|
|
70
|
+
|
|
71
|
+
## Files this skill touches
|
|
72
|
+
|
|
73
|
+
- Calls: `GET /api/gds/builders/directory`, `POST /api/gds/help-requests`, `GET /api/gds/help-requests/{for-me,mine}`, `PATCH /api/gds/help-requests/:id`
|
|
74
|
+
- Reads: `modules/lifecycle/routes/help-requests.js` (the four routes), `modules/lifecycle/help-requests.js` (the validation rules), ADR 0187 §3 (recommend, never transfer)
|
package/docs/file-map.md
CHANGED
|
@@ -383,6 +383,7 @@ tests/
|
|
|
383
383
|
<!-- BEGIN GENERATED FILE-MAP .claude/skills/ (scripts/gds/gen-file-map.js — do not hand-edit; notes live in docs/file-map.notes.json) -->
|
|
384
384
|
```
|
|
385
385
|
.claude/skills/
|
|
386
|
+
├── ask-for-help/SKILL.md ← /ask-for-help — turn 'ask <builder> to help with task N' into a filed collab help request (POST /help-requests): resolve the name to a builder id, one addressee (person XOR craft) and one context (task XOR goal), a what_is_stuck answerable without this session, then settle it; routes the near-neighbours (recommendation vs blocker) and states that the addressed read is a pull, not a push (task 1003826)
|
|
386
387
|
├── backlog-review/SKILL.md ← /backlog-review — daily walk of status=backlog, the pre-workable state a human must say go on: splits rows waiting on a PERSON (promote / kill / water) from rows waiting on a live dep TRIGGER (counted, never walked, migration 163) and surfaces rows stranded behind an abandoned dep; runs scripts/gds/backlog-review.js (task 1003746); Metic+
|
|
387
388
|
├── blocker-review/SKILL.md ← /blocker-review — daily review of open Bongos blockers (resolve / escalate / note); Metic+
|
|
388
389
|
├── blocker-solve/SKILL.md ← /blocker-solve N — drive ONE blocker to done: do the doable parts, hand back owner-only steps, verify, auto-resolve (auto-promotes waiters); Metic+
|
|
@@ -1765,5 +1765,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1765
1765
|
landed since 1.19.656 with no explicit bump. run 34546894776. (task 1002620)
|
|
1766
1766
|
1.19.658 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1767
1767
|
landed since 1.19.657 with no explicit bump. run 34547406494. (task 1002620)
|
|
1768
|
+
1.19.659 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1769
|
+
landed since 1.19.658 with no explicit bump. run 34550130772. (task 1002620)
|
|
1770
|
+
1.19.660 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1771
|
+
landed since 1.19.659 with no explicit bump. run 34551485764. (task 1002620)
|
|
1768
1772
|
---------------------------------------------------------------------------
|
|
1769
1773
|
```
|
|
@@ -221,7 +221,7 @@ const PERMISSIONS = [
|
|
|
221
221
|
// route reads is INERT; a route reading a key with no grant rows 403s everyone.
|
|
222
222
|
// ADR 0174's Consequences record that failure landing on the Government tab
|
|
223
223
|
// itself. So grants lead, routes follow — inside this goal, not across releases.
|
|
224
|
-
{ key: 'board.item.open', system: false, floor: 'metic', guards: 'POST /board/items — put a subject to the board (ADR 0175; a Full Idea window opens automatically on grade pass, so in practice this guards an amendment)' },
|
|
224
|
+
{ key: 'board.item.open', system: false, floor: 'metic', guards: 'POST /government/board/items — put a subject to the board (ADR 0175; a Full Idea window opens automatically on grade pass, so in practice this guards an amendment)' },
|
|
225
225
|
// floor 'xenos' is NOT a mistake and must not be "tightened". Who may vote is
|
|
226
226
|
// the CONSTITUTION's membership predicate, resolved per request from config
|
|
227
227
|
// (ADR 0175 §6) — today `rank:archon`. Encoding that rank here too would fork
|
|
@@ -230,7 +230,7 @@ const PERMISSIONS = [
|
|
|
230
230
|
// is. So the atom says only "this route exists for authenticated builders" and
|
|
231
231
|
// the predicate does the real gating — the `criterion.create` pattern above,
|
|
232
232
|
// where the floor is real but explicitly not the whole gate.
|
|
233
|
-
{ key: 'board.vote.cast', system: false, floor: 'xenos', guards: 'POST /board/items/:
|
|
233
|
+
{ key: 'board.vote.cast', system: false, floor: 'xenos', guards: 'POST /government/board/items/:itemId/votes — floor is the coarse gate; board membership is the configured predicate (ADR 0175 §6), checked in-handler' },
|
|
234
234
|
{ key: 'task.newcomer_restock', system: false, floor: 'metic', guards: 'POST /tasks/newcomer-restock (ADR 0157, was archon)' },
|
|
235
235
|
{ key: 'task.peer_votes.tally', system: false, floor: 'metic', guards: 'POST /tasks/peer-votes/tally (ADR 0157, was archon)' },
|
|
236
236
|
{ key: 'goal.reorder', system: false, floor: 'metic', guards: 'POST /goals/reorder (ADR 0157, was archon)' },
|
|
@@ -247,7 +247,25 @@ const PERMISSIONS = [
|
|
|
247
247
|
// ── ownership-scoped + baseline own-work (floor thetes / xenos) ───────────────
|
|
248
248
|
{ key: 'task.claim.any', system: false, floor: 'thetes', guards: 'claim the general queue (per-task requires_rank floor still applies)' },
|
|
249
249
|
{ key: 'page.view.builder', system: false, floor: 'thetes', guards: 'authenticated hall pages (requireNonXenosPage / requireBuilderPage)' },
|
|
250
|
-
|
|
250
|
+
// floor 'metic' is real but NOT the whole gate — the `criterion.create` pattern
|
|
251
|
+
// above, and here the floor is the LOOSER half. Both routes carry requireBuilder
|
|
252
|
+
// and decide in-handler (authorizeMembershipKindChange / authorizeOwnershipTransfer,
|
|
253
|
+
// modules/lifecycle/routes/goal-route-authz.js): rank is consulted ONLY as an
|
|
254
|
+
// Archon bypass, so `actorIsOwner || actorIsManager` admits at ANY rank.
|
|
255
|
+
//
|
|
256
|
+
// THE LIVE POPULATION, recorded because a migration must not silently evict it:
|
|
257
|
+
// a goal's owner is its `created_by` (goal-authz.js isGoalOwner), so a builder
|
|
258
|
+
// DEMOTED below metic keeps owner authority over goals they created; and a
|
|
259
|
+
// sub-metic member can be promoted to manager ('lead') on any unprotected goal,
|
|
260
|
+
// reaching this permission without ever holding metic.
|
|
261
|
+
//
|
|
262
|
+
// So when R104-R107 migrates these routes, the gate stays requireBuilder +
|
|
263
|
+
// resolver.authorizeOwned(key, owner) — a bare permission gate on this key would
|
|
264
|
+
// 403 exactly the owners and managers the routes admit today. The floor is
|
|
265
|
+
// deliberately NOT lowered to xenos here (the board.vote.cast option): a floor
|
|
266
|
+
// feeds RANK_SEED and the live reset route, so widening it is an authority change
|
|
267
|
+
// that wants its own decision, not a note in a hardening batch.
|
|
268
|
+
{ key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
|
|
251
269
|
{ key: 'task.claim.newcomer', system: false, floor: 'xenos', guards: 'claim a newcomer_friendly task (xenosClaimAllowed; xenos = one active claim)' },
|
|
252
270
|
{ key: 'task.ship', system: false, floor: 'xenos', guards: '/builder-ship own claim' },
|
|
253
271
|
{ key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
|
|
@@ -261,7 +279,7 @@ const PERMISSIONS = [
|
|
|
261
279
|
// marker here. Exactly why scope is declared data: the suffix rule only runs the
|
|
262
280
|
// safe direction ('.own' ⇒ scope 'own'), never the converse.
|
|
263
281
|
{ key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
|
|
264
|
-
{ key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /
|
|
282
|
+
{ key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
|
|
265
283
|
{ key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
|
|
266
284
|
{ key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
|
|
267
285
|
{ key: 'idea.file', system: false, floor: 'xenos', guards: 'POST /inbox (own idea capture)' },
|
|
@@ -281,7 +299,8 @@ const OWNERSHIP_SCOPES = Object.freeze(['own', 'any']);
|
|
|
281
299
|
const _byKey = new Map();
|
|
282
300
|
const _byPrincipal = new Map();
|
|
283
301
|
const _byResource = new Map();
|
|
284
|
-
for (
|
|
302
|
+
for (let i = 0; i < PERMISSIONS.length; i++) {
|
|
303
|
+
const p = PERMISSIONS[i];
|
|
285
304
|
if (typeof p.key !== 'string' || !p.key) throw new Error(`government catalog: permission missing a key`);
|
|
286
305
|
if (typeof p.system !== 'boolean') throw new Error(`government catalog: ${p.key} missing boolean 'system'`);
|
|
287
306
|
if (p.floor !== null && !RANK_ORDER.includes(p.floor)) throw new Error(`government catalog: ${p.key} has invalid floor "${p.floor}"`);
|
|
@@ -320,6 +339,16 @@ for (const p of PERMISSIONS) {
|
|
|
320
339
|
// floor would seed the key into a rank that the substrate then still denies.
|
|
321
340
|
if (p.floor !== 'archon') throw new Error(`government catalog: ${p.key} is substrate-enforced and must floor at 'archon' (the substrate compares against the top of the ladder)`);
|
|
322
341
|
}
|
|
342
|
+
// A system:true key is trust-boundary authority (ADR 0016): it is never grantable
|
|
343
|
+
// to a custom rank, so the ONLY way a builder holds it is by sitting on a seeded
|
|
344
|
+
// rank at or above its floor. That makes the floor the whole wall, and the wall
|
|
345
|
+
// belongs at the top of the ladder. Without this, {system:true, floor:'metic'}
|
|
346
|
+
// loads clean and governance_002's reset route seeds Archon-only power to Metic.
|
|
347
|
+
// The two floor:null shapes are the exception and are already partitioned above:
|
|
348
|
+
// a machine principal is held by NO rank at all.
|
|
349
|
+
if (p.system && principal === null && p.floor !== 'archon') {
|
|
350
|
+
throw new Error(`government catalog: ${p.key} is system:true and must floor at 'archon' (a system key is not grantable, so its floor is the whole wall) — got "${p.floor}"`);
|
|
351
|
+
}
|
|
323
352
|
// ── the OWNERSHIP axis (R100) ─────────────────────────────────────────────
|
|
324
353
|
const scope = p.scope ?? null;
|
|
325
354
|
const resource = p.resource ?? null;
|
|
@@ -347,7 +376,15 @@ for (const p of PERMISSIONS) {
|
|
|
347
376
|
if (scope !== null && p.floor === null) {
|
|
348
377
|
throw new Error(`government catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a RANK permission, never an identity one`);
|
|
349
378
|
}
|
|
379
|
+
// ONE object per permission, shared by every reader. The frozen copy goes back
|
|
380
|
+
// into PERMISSIONS[i] as well as the lookup maps, because the alternative —
|
|
381
|
+
// freezing a COPY and leaving the original mutable in the array — splits the
|
|
382
|
+
// catalog into two views that can disagree: `Object.freeze(PERMISSIONS)` below
|
|
383
|
+
// seals the array's SHAPE but not its entries, so a runtime mutation of
|
|
384
|
+
// PERMISSIONS[i] would change the floor seedForRank() reads (and with it the
|
|
385
|
+
// live POST /government/ranks/:rankKey/reset) while byKey() kept the old value.
|
|
350
386
|
const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
|
|
387
|
+
PERMISSIONS[i] = frozen;
|
|
351
388
|
_byKey.set(p.key, frozen);
|
|
352
389
|
if (principal !== null) _byPrincipal.set(principal, frozen);
|
|
353
390
|
if (resource !== null) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.660",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.660",
|
|
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.
|
|
3
|
+
"version": "1.19.660",
|
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.660'; // 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');
|
|
@@ -178,3 +178,16 @@ test('byKey returns a frozen record or null', () => {
|
|
|
178
178
|
assert.throws(() => { p.system = false; }, /Cannot assign|read only|frozen/i);
|
|
179
179
|
assert.equal(byKey('nope'), null);
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
// Freezing the ARRAY only seals its shape. Before task 1003194 the entries inside it
|
|
183
|
+
// stayed mutable and byKey() handed out a frozen COPY, so the catalog had two views
|
|
184
|
+
// that could disagree: mutating PERMISSIONS[i].floor moved what seedForRank() reads —
|
|
185
|
+
// and with it the live POST /government/ranks/:rankKey/reset — while byKey() kept the
|
|
186
|
+
// old value. Identity is the assertion that actually forbids that, not depth.
|
|
187
|
+
test('the catalog is ONE immutable object per permission — array, entries, and byKey identity', () => {
|
|
188
|
+
assert.equal(Object.isFrozen(PERMISSIONS), true, 'the PERMISSIONS array must be frozen');
|
|
189
|
+
for (const p of PERMISSIONS) {
|
|
190
|
+
assert.equal(Object.isFrozen(p), true, `PERMISSIONS entry ${p.key} must be frozen, not just the array`);
|
|
191
|
+
assert.equal(p, byKey(p.key), `${p.key}: PERMISSIONS[i] and byKey() must be the SAME object, not equal copies`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -163,6 +163,13 @@ const normRoute = (key) => key.replace(/:[A-Za-z0-9_]+/g, ':p'); // :id vs :crit
|
|
|
163
163
|
// Leading `METHOD /path` of a guards note. Deliberately conservative: a note that
|
|
164
164
|
// lists several routes or wraps them in prose/braces simply doesn't participate.
|
|
165
165
|
const GUARD_ROUTE_RE = /^(GET|POST|PATCH|PUT|DELETE)\s+(\/[^\s,(]+)/;
|
|
166
|
+
// The conservative intent above is enforced HERE, not by the character class: a
|
|
167
|
+
// path carrying `{a,b}` (a brace set), `[/x]` (an optional segment) or `*` (a
|
|
168
|
+
// wildcard) is a PATTERN standing for several routes, not one concrete route, so
|
|
169
|
+
// it cannot be looked up and does not participate. Without this the regex happily
|
|
170
|
+
// captured `/blockers/:id/{resolve` and seven siblings, which then dangled in B0
|
|
171
|
+
// and drowned the three real typos it exists to catch (task 1003194).
|
|
172
|
+
const ROUTE_PATTERN_RE = /[{[*]/;
|
|
166
173
|
// A route's middleware rank → the LOOSEST floor still consistent with it. Only the
|
|
167
174
|
// two rank-gated classifications constrain a floor; 'any-builder' / 'public' /
|
|
168
175
|
// 'unknown' / 'bfg-principal' carry no rank middleware to compare against.
|
|
@@ -190,12 +197,17 @@ function crossReference() {
|
|
|
190
197
|
const live = liveRoutes();
|
|
191
198
|
const floorPinned = [];
|
|
192
199
|
const atomPinned = [];
|
|
200
|
+
const dangling = [];
|
|
193
201
|
for (const p of FLOORED) {
|
|
194
202
|
const m = GUARD_ROUTE_RE.exec(p.guards);
|
|
195
203
|
if (!m) continue;
|
|
204
|
+
if (ROUTE_PATTERN_RE.test(m[2])) continue;
|
|
196
205
|
const routeKey = `${m[1]} ${m[2]}`;
|
|
197
206
|
const r = live.get(normRoute(routeKey));
|
|
198
|
-
|
|
207
|
+
// A note that names ONE concrete route which does not exist is a typo, and a
|
|
208
|
+
// silent `continue` here is how it stayed invisible: the entry simply dropped
|
|
209
|
+
// out of B1 and B3 while all 27 tests stayed green. B0 asserts this is empty.
|
|
210
|
+
if (!r) { dangling.push(`${p.key} → ${routeKey}`); continue; }
|
|
199
211
|
if (r.perms) {
|
|
200
212
|
atomPinned.push({ permission: p.key, floor: p.floor, routeKey, perms: r.perms });
|
|
201
213
|
continue;
|
|
@@ -204,9 +216,27 @@ function crossReference() {
|
|
|
204
216
|
if (!minFloor) continue;
|
|
205
217
|
floorPinned.push({ permission: p.key, floor: p.floor, routeKey, routeRank: r.rank, minFloor });
|
|
206
218
|
}
|
|
207
|
-
return { floorPinned, atomPinned };
|
|
219
|
+
return { floorPinned, atomPinned, dangling };
|
|
208
220
|
}
|
|
209
221
|
|
|
222
|
+
// The oracles B1 and B3 can only judge a permission whose guards note RESOLVES to a
|
|
223
|
+
// live route. A note naming a route that does not exist therefore fails open — it is
|
|
224
|
+
// dropped, not reported, and the permission silently leaves both oracles while the
|
|
225
|
+
// suite stays green. That is not hypothetical: `cost.log` said 'POST /costs' against
|
|
226
|
+
// a real 'POST /cost', and both Board Room keys omitted the '/government' prefix, so
|
|
227
|
+
// the two newest requirePermission routes sat outside B3 — the very oracle written
|
|
228
|
+
// for them. This test is the fail-loud half (task 1003194).
|
|
229
|
+
//
|
|
230
|
+
// There is deliberately NO allowlist. A note that genuinely cannot name one concrete
|
|
231
|
+
// route does not reach here at all: it either fails GUARD_ROUTE_RE (prose, a surface
|
|
232
|
+
// name like 'planning-session surface') or is a pattern caught by ROUTE_PATTERN_RE.
|
|
233
|
+
// An allowlist would be a place to park the next typo.
|
|
234
|
+
test('B0: every guards note that names a concrete route names one that EXISTS', () => {
|
|
235
|
+
const { dangling } = crossReference();
|
|
236
|
+
assert.deepEqual(dangling, [],
|
|
237
|
+
`guards notes point at routes that do not exist — fix the note (or the route), never ignore it:\n ${dangling.join('\n ')}`);
|
|
238
|
+
});
|
|
239
|
+
|
|
210
240
|
// ONE-DIRECTIONAL, and deliberately so. A floor STRICTER than the route middleware
|
|
211
241
|
// is legitimate and common: the wall lives in the handler (task.confirm.grade_bypass
|
|
212
242
|
// on an any-builder POST /tasks/:id/confirm; goal.manage.own's owner|manager check;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tests/skill_ask_for_help.mjs — the /ask-for-help skill exists and routes the
|
|
2
|
+
// plain-language ask ("ask <builder> to help with task N") to the collab help
|
|
3
|
+
// request rather than to a blocker (task 1003826).
|
|
4
|
+
//
|
|
5
|
+
// The failure it guards: a session hears "ask X to help with N" and files a
|
|
6
|
+
// BLOCKER — the owner's review queue — while the surface the recipient actually
|
|
7
|
+
// reads (/#/collab → GET /help-requests/for-me) stays empty. So the assertions
|
|
8
|
+
// below are about ROUTING and the constraints the table's CHECKs restate, not
|
|
9
|
+
// about prose.
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const SKILL = path.join(ROOT, '.claude', 'skills', 'ask-for-help', 'SKILL.md');
|
|
18
|
+
const NOTES = path.join(ROOT, 'docs', 'file-map.notes.json');
|
|
19
|
+
|
|
20
|
+
// .claude/skills is INSTANCE-materialized; a neutral core checkout carries none
|
|
21
|
+
// (task 1002470) — skip there, with the same guard shape as skill_grade_recover.mjs.
|
|
22
|
+
const skillsDirExists = existsSync(path.join(ROOT, '.claude', 'skills'));
|
|
23
|
+
|
|
24
|
+
test('/ask-for-help files a help request and routes its near-neighbours', (t) => {
|
|
25
|
+
if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
|
|
26
|
+
assert.ok(existsSync(SKILL), '.claude/skills/ask-for-help/SKILL.md must exist (task 1003826)');
|
|
27
|
+
const md = readFileSync(SKILL, 'utf8');
|
|
28
|
+
|
|
29
|
+
// The write path, named literally — this is the whole point of the skill.
|
|
30
|
+
assert.match(md, /POST \/(?:api\/gds\/)?help-requests/, 'names the help-request write route');
|
|
31
|
+
assert.match(md, /--body-file/, 'files via --body-file (api.js drops a positional body)');
|
|
32
|
+
|
|
33
|
+
// Resolve the addressee — never guess a builder id.
|
|
34
|
+
assert.match(md, /GET \/api\/gds\/builders\/directory/, 'resolves a named builder through the directory');
|
|
35
|
+
assert.match(md, /never guess/i, 'forbids guessing a builder id');
|
|
36
|
+
|
|
37
|
+
// The two XOR rules the table's CHECK constraints enforce.
|
|
38
|
+
assert.match(md, /needs_craft/, 'names the craft addressee field');
|
|
39
|
+
assert.match(md, /needs_builder_id/, 'names the person addressee field');
|
|
40
|
+
assert.match(md, /two_addressees/, 'names the both-addressees refusal');
|
|
41
|
+
assert.match(md, /two_contexts/, 'names the both-contexts refusal');
|
|
42
|
+
assert.match(md, /task_id/, 'names the task context field');
|
|
43
|
+
assert.match(md, /goal_id/, 'names the goal context field');
|
|
44
|
+
|
|
45
|
+
// The near-neighbours are routed, not collapsed: a recommendation and a blocker
|
|
46
|
+
// are different acts, and the blocker is complementary rather than an alternative.
|
|
47
|
+
assert.match(md, /POST \/task-recommendations/, 'routes the recommendation surface');
|
|
48
|
+
assert.match(md, /POST \/blockers/, 'routes the blocker surface');
|
|
49
|
+
assert.match(md, /complementary, not alternatives/i, 'says a blocker and a help request can both be right');
|
|
50
|
+
|
|
51
|
+
// ADR 0187 §3 — being asked assigns nobody anything.
|
|
52
|
+
assert.match(md, /never transfer/i, 'states that the collab surfaces never transfer a claim');
|
|
53
|
+
|
|
54
|
+
// The honest limitation: the addressed read is a pull, and help requests have
|
|
55
|
+
// no builder-needs push (task recommendations do).
|
|
56
|
+
assert.match(md, /pull,? not a push/i, 'states the pull-not-push caveat');
|
|
57
|
+
assert.match(md, /help-requests\/for-me/, 'names the addressed read the recipient sees');
|
|
58
|
+
|
|
59
|
+
// Settling is the asker's job — an unsettled pile is noise.
|
|
60
|
+
assert.match(md, /PATCH \/api\/gds\/help-requests\/<id>/, 'names the settle route');
|
|
61
|
+
assert.match(md, /answered/, 'names the answered status');
|
|
62
|
+
assert.match(md, /withdrawn/, 'names the withdrawn status');
|
|
63
|
+
|
|
64
|
+
// No secret ever goes in the ask.
|
|
65
|
+
assert.match(md, /Never put a secret/i, 'forbids putting a secret in what_is_stuck');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('/ask-for-help is described in the generated file map', (t) => {
|
|
69
|
+
if (!skillsDirExists) return t.skip('.claude/skills not materialized in this checkout');
|
|
70
|
+
const notes = JSON.parse(readFileSync(NOTES, 'utf8'));
|
|
71
|
+
assert.ok(notes.skills['ask-for-help'], 'docs/file-map.notes.json must carry a note for ask-for-help');
|
|
72
|
+
});
|