@bongos/core 1.21.128 → 1.21.129
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 +41 -16
- package/bin/bongos.js +1 -0
- package/docs/architecture.md +12 -0
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +2 -0
- package/migrations/core_276_module_release_channels.sql +73 -0
- package/modules/onboarding/howto-moments.js +91 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/claude-md-template.js +1 -0
- package/scripts/gds/howto.js +65 -0
- package/src/module-api.js +1 -1
- package/tests/howto_moments.mjs +136 -0
- package/tests/module_release_channels.mjs +58 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.21.
|
|
6
|
-
"core_contract": "1.21.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.21.129",
|
|
6
|
+
"core_contract": "1.21.129",
|
|
7
|
+
"source_commit": "e1853bcff638c4af78b2c735629a68e92b323d41",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-10-15T16:
|
|
9
|
+
"built_at": "2026-10-15T16:58:38.452Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 606,
|
|
13
13
|
"agent_docs_stubbed": 28,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 3146,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 3781,
|
|
20
|
+
"tree_sha256": "86956dd5cf1f736d879fcefe1c8c370b4b62f58d3e0cb3876a00e8d4d0c25d0c",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -312,7 +312,7 @@
|
|
|
312
312
|
{
|
|
313
313
|
"path": "bin/bongos.js",
|
|
314
314
|
"mode": "0000755",
|
|
315
|
-
"sha256": "
|
|
315
|
+
"sha256": "656e91004fc2236dfd121dd3f46848ba5a51331c2eb17095398eb30a5255b7ef"
|
|
316
316
|
},
|
|
317
317
|
{
|
|
318
318
|
"path": "clients/bongos-client/README.md",
|
|
@@ -2387,7 +2387,7 @@
|
|
|
2387
2387
|
{
|
|
2388
2388
|
"path": "docs/architecture.md",
|
|
2389
2389
|
"mode": "0000644",
|
|
2390
|
-
"sha256": "
|
|
2390
|
+
"sha256": "fca9878d8501b33c0258045f61365a90e60e148c2b730b00a83a6f4f89b79b8d"
|
|
2391
2391
|
},
|
|
2392
2392
|
{
|
|
2393
2393
|
"path": "docs/branding-contract.md",
|
|
@@ -2912,7 +2912,7 @@
|
|
|
2912
2912
|
{
|
|
2913
2913
|
"path": "docs/file-map.md",
|
|
2914
2914
|
"mode": "0000644",
|
|
2915
|
-
"sha256": "
|
|
2915
|
+
"sha256": "1735499acdd1ee22b83fc0be539e3371072b289b2b3c1d89e7996ec360190077"
|
|
2916
2916
|
},
|
|
2917
2917
|
{
|
|
2918
2918
|
"path": "docs/handoff-template.md",
|
|
@@ -2922,7 +2922,7 @@
|
|
|
2922
2922
|
{
|
|
2923
2923
|
"path": "docs/module-api-changelog.md",
|
|
2924
2924
|
"mode": "0000644",
|
|
2925
|
-
"sha256": "
|
|
2925
|
+
"sha256": "791b40816885c77100c1ff5ea9773f66a122d0bb6fc1dc2163fe43c5d89b0d1b"
|
|
2926
2926
|
},
|
|
2927
2927
|
{
|
|
2928
2928
|
"path": "docs/modules-contract.md",
|
|
@@ -4154,6 +4154,11 @@
|
|
|
4154
4154
|
"mode": "0000644",
|
|
4155
4155
|
"sha256": "75381af0ac96645f2f8fe254f609c75d2fa83bc2fc1f13887c23165fc8eed58e"
|
|
4156
4156
|
},
|
|
4157
|
+
{
|
|
4158
|
+
"path": "migrations/core_276_module_release_channels.sql",
|
|
4159
|
+
"mode": "0000644",
|
|
4160
|
+
"sha256": "550884fa117e707b65f5c56faca0e4e3850905db94d3f275cad5d520aa68db03"
|
|
4161
|
+
},
|
|
4157
4162
|
{
|
|
4158
4163
|
"path": "modules/agents/CLAUDE.md",
|
|
4159
4164
|
"mode": "0000644",
|
|
@@ -7934,6 +7939,11 @@
|
|
|
7934
7939
|
"mode": "0000644",
|
|
7935
7940
|
"sha256": "18984c946d746da66ff7678379e9f49c9cfdcdb8f70d2acd852ab29c820baad0"
|
|
7936
7941
|
},
|
|
7942
|
+
{
|
|
7943
|
+
"path": "modules/onboarding/howto-moments.js",
|
|
7944
|
+
"mode": "0000644",
|
|
7945
|
+
"sha256": "1f22e2c271c92c2e1e263fab265e73b88ba796b44e519ecd5f3266b10481bf17"
|
|
7946
|
+
},
|
|
7937
7947
|
{
|
|
7938
7948
|
"path": "modules/onboarding/howto-state.js",
|
|
7939
7949
|
"mode": "0000644",
|
|
@@ -10492,12 +10502,12 @@
|
|
|
10492
10502
|
{
|
|
10493
10503
|
"path": "package-lock.json",
|
|
10494
10504
|
"mode": "0000644",
|
|
10495
|
-
"sha256": "
|
|
10505
|
+
"sha256": "fd49f31fc91c5005e4e306248e0745842d89df367cdb32fd03b04b5bc844bffb"
|
|
10496
10506
|
},
|
|
10497
10507
|
{
|
|
10498
10508
|
"path": "package.json",
|
|
10499
10509
|
"mode": "0000644",
|
|
10500
|
-
"sha256": "
|
|
10510
|
+
"sha256": "da9de77dd1ad1af7aa7a30243b2f0e12b9553e10a15a858cf3e899013769f58e"
|
|
10501
10511
|
},
|
|
10502
10512
|
{
|
|
10503
10513
|
"path": "public-docs/index.html",
|
|
@@ -10517,7 +10527,7 @@
|
|
|
10517
10527
|
{
|
|
10518
10528
|
"path": "release-notes.json",
|
|
10519
10529
|
"mode": "0000644",
|
|
10520
|
-
"sha256": "
|
|
10530
|
+
"sha256": "7211540da2e00897f888df0f4642fd0d286a04ed3433bb0683d1acb7843d8b77"
|
|
10521
10531
|
},
|
|
10522
10532
|
{
|
|
10523
10533
|
"path": "scripts/cron/style_guide_audit.sh",
|
|
@@ -10842,7 +10852,7 @@
|
|
|
10842
10852
|
{
|
|
10843
10853
|
"path": "scripts/gds/claude-md-template.js",
|
|
10844
10854
|
"mode": "0000644",
|
|
10845
|
-
"sha256": "
|
|
10855
|
+
"sha256": "e783238968b9c1dcac8e7d9e49f0f4520bde8369e95e111dcd086cb77ab28189"
|
|
10846
10856
|
},
|
|
10847
10857
|
{
|
|
10848
10858
|
"path": "scripts/gds/cleanup-orphans.js",
|
|
@@ -11249,6 +11259,11 @@
|
|
|
11249
11259
|
"mode": "0000644",
|
|
11250
11260
|
"sha256": "b7c61201d165b8c2b7e51b3a057153c3c7238bfc8bd55412acaf0ba87acd367d"
|
|
11251
11261
|
},
|
|
11262
|
+
{
|
|
11263
|
+
"path": "scripts/gds/howto.js",
|
|
11264
|
+
"mode": "0000644",
|
|
11265
|
+
"sha256": "febb8812a719ca76ef34b312b387953b723add58357441888183ba1e9577e45f"
|
|
11266
|
+
},
|
|
11252
11267
|
{
|
|
11253
11268
|
"path": "scripts/gds/http-api-client.js",
|
|
11254
11269
|
"mode": "0000644",
|
|
@@ -12867,7 +12882,7 @@
|
|
|
12867
12882
|
{
|
|
12868
12883
|
"path": "src/module-api.js",
|
|
12869
12884
|
"mode": "0000644",
|
|
12870
|
-
"sha256": "
|
|
12885
|
+
"sha256": "854a2036806028bf527d76593e2c9a5e5b57d7daa1183f18065889afe9f6d373"
|
|
12871
12886
|
},
|
|
12872
12887
|
{
|
|
12873
12888
|
"path": "src/module-loader/catalog.js",
|
|
@@ -15649,6 +15664,11 @@
|
|
|
15649
15664
|
"mode": "0000644",
|
|
15650
15665
|
"sha256": "8e9b206fdde738f289bc11e204852595e9128b523cfc20cd67e3e83fb60a2fac"
|
|
15651
15666
|
},
|
|
15667
|
+
{
|
|
15668
|
+
"path": "tests/howto_moments.mjs",
|
|
15669
|
+
"mode": "0000644",
|
|
15670
|
+
"sha256": "6fc5a865345c3db90336748eebd79425ee64496b73dd98f89c87d9da21174e54"
|
|
15671
|
+
},
|
|
15652
15672
|
{
|
|
15653
15673
|
"path": "tests/howto_state.mjs",
|
|
15654
15674
|
"mode": "0000644",
|
|
@@ -16474,6 +16494,11 @@
|
|
|
16474
16494
|
"mode": "0000644",
|
|
16475
16495
|
"sha256": "4f5a0c6e24d3883a156798f17c485183cf73be2cf3a0e0c610d5014a142909e5"
|
|
16476
16496
|
},
|
|
16497
|
+
{
|
|
16498
|
+
"path": "tests/module_release_channels.mjs",
|
|
16499
|
+
"mode": "0000644",
|
|
16500
|
+
"sha256": "8607f9b60a6094296b005def559eb3cfa6861add5bfa473d9264a3dc4025bf44"
|
|
16501
|
+
},
|
|
16477
16502
|
{
|
|
16478
16503
|
"path": "tests/module_route_rank.mjs",
|
|
16479
16504
|
"mode": "0000644",
|
package/bin/bongos.js
CHANGED
|
@@ -82,6 +82,7 @@ const VERBS = {
|
|
|
82
82
|
status: { script: 'status.js', group: 'work', summary: 'Version / criterion progress (bongos status C8)', selfHelp: true },
|
|
83
83
|
recall: { script: 'recall.js', group: 'work', summary: 'Search the docs + project knowledge (bongos recall "x")' },
|
|
84
84
|
cost: { script: 'cost.js', group: 'work', summary: 'Log a cost entry' },
|
|
85
|
+
howto: { script: 'howto.js', group: 'work', summary: 'The how-to guide: show the unread steps for a moment (bongos howto moment first_open)' },
|
|
85
86
|
|
|
86
87
|
login: { script: 'login.js', group: 'account', summary: 'Sign in to an instance (bongos login <url> — browser click, no paste)' },
|
|
87
88
|
setup: { script: 'setup.js', group: 'account', summary: 'Full builder setup on this instance (art key, disciplines, consent)' },
|
package/docs/architecture.md
CHANGED
|
@@ -251,6 +251,18 @@ store_module_prices(id, module_key, price_cents, price_credits, set_by, set_at)
|
|
|
251
251
|
-- in that currency, at least one must be set.
|
|
252
252
|
```
|
|
253
253
|
|
|
254
|
+
Module release channels (migration `core_276`, task 1003815, [ADR 0344](adr/0344-a-tester-is-a-project-that-opts-in-once.md) — which channel a published version is on; the data only, the tester-first publish, promotion gate and channel-aware install are their own tasks):
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
store_release_channels(channel PK, description, serves_installs, sort_order, created_at)
|
|
258
|
+
-- rows, not a CHECK: tester + general serve installs, withdrawn never does;
|
|
259
|
+
-- a new channel is an INSERT, not a schema change.
|
|
260
|
+
store_module_version_channels(id, version_id FK store_module_versions, channel FK,
|
|
261
|
+
reason short code, set_by builder | NULL (system), set_at)
|
|
262
|
+
-- history; newest row per version is current. Pre-existing versions were
|
|
263
|
+
-- backfilled to general ('predates_staged_rollout').
|
|
264
|
+
```
|
|
265
|
+
|
|
254
266
|
Module entitlements (migration `core_259`, task 1003813 — who holds which module, at which version; the record install, update and the telemetry signals consult; it records ownership, never payment — area 8 owns money):
|
|
255
267
|
|
|
256
268
|
```
|
package/docs/file-map.md
CHANGED
|
@@ -169,6 +169,7 @@
|
|
|
169
169
|
│ ├── linkify-refs.js ← rewrite bare `#NNN` task refs across ALL docs → status-dashboard permalinks (stops GitHub's PR/issue autolinker mis-linking them); conservative classifier, token-free id list via /public/ref-ids; --apply / --check (CI gate: tests/linkify_check.mjs, in the required `unit` check) / --dry-run default ([#604](https://example.com/builders#/task/604), [#653](https://example.com/builders#/task/653)).
|
|
170
170
|
│ ├── release.js ← resolve claim as abandoned (`/builder-release`)
|
|
171
171
|
│ ├── cost.js ← log a cost entry (`/builder-cost`)
|
|
172
|
+
│ ├── howto.js ← the how-to guide for AI tools without hooks: `bongos howto moment <id>` prints + marks read the person's unread steps for a moment (task 1004635)
|
|
172
173
|
│ ├── me.js ← current builder profile + leaderboard
|
|
173
174
|
│ ├── goal.js ← goal board CLI: `list` (rollup per goal) + `show <id>` (criteria, tasks, waits-on, next claimable)
|
|
174
175
|
│ ├── seed-v3-tasks.js ← V3 Planning Session #1: 80 net-new GDS-V3 tasks (V3.R01–V3.R89) + 10 idea_inbox promotions; idempotent on source_ref
|
|
@@ -472,7 +473,7 @@ To install on a new machine, symlink each routine's directory under `~/.claude/s
|
|
|
472
473
|
├── rank-change-greeting.js ← promotion/demotion greeting (V3.R28 #249); spawned by session-start.js, also runnable standalone
|
|
473
474
|
├── pull-memory.js ← memory pull-on-session-start (V3.R56 #275); spawned by session-start.js (kill-switches: OTB_MEMORY_PULL_OFF / OTB_SUBAGENT)
|
|
474
475
|
├── recall-hints.js ← SessionStart: when the session opens holding a claim, surface the top project-knowledge hits for that task via POST /api/bongos/search (#963/V4.R25, ADR 0060)
|
|
475
|
-
├── conductor.js ← UserPromptSubmit: MAS Conductor advisory + dispatch directive (#427/#439); self-gates on OTB_CONDUCTOR_OFF / OTB_SUBAGENT
|
|
476
|
+
├── conductor.js ← UserPromptSubmit: MAS Conductor advisory + dispatch directive (#427/#439); self-gates on OTB_CONDUCTOR_OFF / OTB_SUBAGENT; also delivers the how-to step for the moment and marks it read (task 1004635)
|
|
476
477
|
├── session-cards.js ← UserPromptSubmit: deterministic delivery of READ-ONLY in-session cards (builder-start, status) — pre-renders the card + injects a one-step render directive (task 1288)
|
|
477
478
|
├── lifecycle-card.js ← PostToolUse (Bash|PowerShell): deterministic delivery of MUTATING lifecycle cards (claim/ship/release) — the write-side mirror of session-cards.js (task 1288)
|
|
478
479
|
├── stuck-detector.js ← PostToolUse + PostToolUseFailure (Bash|PowerShell): the third time one session hits the same wall — the same `[otb-plain]` card for the same task id, or the same red check — it injects ONE instruction: stop retrying, diagnose (path named), or /ask-for-help. Never blocks; silent with no card; kill-switch BONGOS_STUCK_DETECTOR_OFF=1 (task 1003990)
|
|
@@ -3093,5 +3093,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
3093
3093
|
landed since 1.21.126 with no explicit bump. run 38764985266. (task 1002620)
|
|
3094
3094
|
1.21.128 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3095
3095
|
landed since 1.21.127 with no explicit bump. run 38770118067. (task 1002620)
|
|
3096
|
+
1.21.129 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
3097
|
+
landed since 1.21.128 with no explicit bump. run 38773346540. (task 1002620)
|
|
3096
3098
|
---------------------------------------------------------------------------
|
|
3097
3099
|
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
-- core_276_module_release_channels.sql — which release channel each published
|
|
2
|
+
-- module version sits on: tester first, then general (ADR 0344).
|
|
3
|
+
--
|
|
4
|
+
-- task 1003815 (goal 1000091, working area 5). ADR 0344 D2/D3: every new version
|
|
5
|
+
-- goes to the tester channel first, and moves to general after seven days or on
|
|
6
|
+
-- evidence. This is the DATA ONLY. Putting a new version on tester (task 1003803),
|
|
7
|
+
-- the promotion gate (task 1003804) and install/update honouring the channel
|
|
8
|
+
-- (task 1003805) are their own tasks and read/write these tables.
|
|
9
|
+
--
|
|
10
|
+
-- store_release_channels the channels that exist, as ROWS, not a CHECK
|
|
11
|
+
-- or a boolean. Two serve installs today
|
|
12
|
+
-- (tester, general); 'withdrawn' is the third,
|
|
13
|
+
-- for a version taken back. A fourth is an
|
|
14
|
+
-- INSERT here, never a schema change — the
|
|
15
|
+
-- retrofit the task warns about.
|
|
16
|
+
-- `serves_installs` says whether a project can
|
|
17
|
+
-- be handed a version on that channel; a
|
|
18
|
+
-- withdrawn version is never handed out, but a
|
|
19
|
+
-- copy already installed keeps working (ADR
|
|
20
|
+
-- 0338 D2 — nothing here can reach it).
|
|
21
|
+
-- store_module_version_channels the HISTORY of a version's channel. A channel
|
|
22
|
+
-- is a property of a version, not of a module,
|
|
23
|
+
-- and store_module_versions is immutable, so a
|
|
24
|
+
-- move is a new row and the CURRENT channel is
|
|
25
|
+
-- the newest row per version. The history is
|
|
26
|
+
-- what lets the promotion gate answer "how long
|
|
27
|
+
-- has this been on tester" and who moved it.
|
|
28
|
+
-- `set_by` is NULL when the system moved it
|
|
29
|
+
-- (the seven-day promotion); `reason` is a
|
|
30
|
+
-- short code, never free text.
|
|
31
|
+
--
|
|
32
|
+
-- Versions published before this migration were never on a tester channel, so
|
|
33
|
+
-- each gets one 'general' row with reason 'predates_staged_rollout' — every
|
|
34
|
+
-- version then has a current channel, and nothing already offered to everyone is
|
|
35
|
+
-- taken away from them.
|
|
36
|
+
--
|
|
37
|
+
-- Additive only (two new tables, rows inserted). Idempotent: safe to re-run.
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS store_release_channels (
|
|
40
|
+
channel text PRIMARY KEY
|
|
41
|
+
CHECK (channel ~ '^[a-z][a-z0-9_]{0,31}$'),
|
|
42
|
+
description text NOT NULL,
|
|
43
|
+
serves_installs boolean NOT NULL,
|
|
44
|
+
sort_order integer NOT NULL,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
INSERT INTO store_release_channels (channel, description, serves_installs, sort_order) VALUES
|
|
49
|
+
('tester', 'New versions land here first; only projects that opted in to testing get them (ADR 0344 D1).', true, 10),
|
|
50
|
+
('general', 'Offered to every project that has the module.', true, 20),
|
|
51
|
+
('withdrawn', 'Taken back: never handed out again; copies already installed keep working (ADR 0338 D2).', false, 90)
|
|
52
|
+
ON CONFLICT (channel) DO NOTHING;
|
|
53
|
+
|
|
54
|
+
CREATE TABLE IF NOT EXISTS store_module_version_channels (
|
|
55
|
+
id bigserial PRIMARY KEY,
|
|
56
|
+
version_id bigint NOT NULL REFERENCES store_module_versions(id),
|
|
57
|
+
channel text NOT NULL REFERENCES store_release_channels(channel),
|
|
58
|
+
reason text NOT NULL CHECK (reason ~ '^[a-z][a-z0-9_]{0,39}$'),
|
|
59
|
+
set_by bigint REFERENCES builders(id),
|
|
60
|
+
set_at timestamptz NOT NULL DEFAULT now()
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
-- The current channel is the newest row per version; this index answers it.
|
|
64
|
+
CREATE INDEX IF NOT EXISTS idx_store_module_version_channels_current
|
|
65
|
+
ON store_module_version_channels (version_id, set_at DESC, id DESC);
|
|
66
|
+
|
|
67
|
+
INSERT INTO store_module_version_channels (version_id, channel, reason)
|
|
68
|
+
SELECT v.id, 'general', 'predates_staged_rollout'
|
|
69
|
+
FROM store_module_versions v
|
|
70
|
+
WHERE NOT EXISTS (SELECT 1 FROM store_module_version_channels c WHERE c.version_id = v.id);
|
|
71
|
+
|
|
72
|
+
INSERT INTO schema_migrations (version) VALUES ('core_276_module_release_channels')
|
|
73
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// modules/onboarding/howto-moments.js — which how-to step to show right now, and the words that ask the AI to show it (task 1004635)
|
|
2
|
+
//
|
|
3
|
+
// WHY. The how-to guide (howto-guide.js) is only useful if a step reaches the person
|
|
4
|
+
// at the moment it applies — the first prompt in a new project, the first claim, the
|
|
5
|
+
// first ship — and never again once read (howto-state.js keeps the read marks). This
|
|
6
|
+
// file is the one place that decides WHICH step that is, so every delivery path gives
|
|
7
|
+
// the same answer:
|
|
8
|
+
// • Claude Code: the Conductor hook (.claude/hooks/conductor.js) reads the signals it
|
|
9
|
+
// already has (the prompt, the active claims, whether this is the session's first
|
|
10
|
+
// prompt), injects the step, and marks it read;
|
|
11
|
+
// • any other AI tool: the instance CLAUDE.md tells it to run
|
|
12
|
+
// `npx bongos howto moment <id>` (scripts/gds/howto.js), which prints the step and
|
|
13
|
+
// marks it read the same way.
|
|
14
|
+
//
|
|
15
|
+
// Short by design: at most MAX_STEPS steps, from ONE moment, per prompt. A read (or
|
|
16
|
+
// skipped) step is never picked again.
|
|
17
|
+
//
|
|
18
|
+
// Pure: signals and read ids in, steps and text out. No network, no files.
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const guide = require('./howto-guide');
|
|
23
|
+
|
|
24
|
+
const MAX_STEPS = 2;
|
|
25
|
+
|
|
26
|
+
// Prompt words that open a moment. Vocabulary-gated so ordinary prompts stay quiet.
|
|
27
|
+
const SHIP_RE = /\/builder-ship\b|\bship (?:it|this)\b|\bi'?m done\b|\bmark (?:this |it )?complete\b/i;
|
|
28
|
+
const STUCK_RE = /\bstuck\b|\bconfus(?:ed|ing)\b|\bi'?m lost\b|\bnot working\b|\bdoesn'?t work\b|\bwhat went wrong\b/i;
|
|
29
|
+
const FIND_WORK_RE = /\bwhat (?:can|should) i (?:work on|do)\b|\bwhat'?s next\b|\/builder-start\b|\bwhat'?s claimable\b/i;
|
|
30
|
+
const GOAL_RE = /\bgoal\b|\/goal-create\b/i;
|
|
31
|
+
const TASK_RE = /\b(?:make|create|file|new|add) (?:a |another )?task\b|\bsave (?:an |this )?idea\b/i;
|
|
32
|
+
const INVITE_RE = /\binvit(?:e|ing)\b|\bteammates?\b|\bco-?workers?\b|\bcolleagues?\b/i;
|
|
33
|
+
const SETTINGS_RE = /\bsettings?\b|\bship[- ]mode\b|\bshorter answers?\b|\btoo (?:long|technical)\b/i;
|
|
34
|
+
const SETUP_RE = /\bset ?up\b|\bsign(?:ed)? ?in\b|\blog ?in\b|\/builder-setup\b/i;
|
|
35
|
+
|
|
36
|
+
// The moments open right now, most urgent first. Signals:
|
|
37
|
+
// firstPrompt true on the first prompt of a session
|
|
38
|
+
// prompt the person's words
|
|
39
|
+
// claims null (unknown) | [] (none) | [...] active claims
|
|
40
|
+
// read iterable of step ids already read or skipped
|
|
41
|
+
function openMoments({ firstPrompt = false, prompt = '', claims = null, read = [] } = {}) {
|
|
42
|
+
const p = typeof prompt === 'string' ? prompt : '';
|
|
43
|
+
const known = Array.isArray(claims);
|
|
44
|
+
const hasClaim = known && claims.length > 0;
|
|
45
|
+
const done = new Set(read || []);
|
|
46
|
+
const out = [];
|
|
47
|
+
if (STUCK_RE.test(p)) out.push('stuck');
|
|
48
|
+
if (hasClaim && SHIP_RE.test(p)) out.push('first_ship');
|
|
49
|
+
if (hasClaim) out.push('first_claim');
|
|
50
|
+
if (firstPrompt) out.push('first_open');
|
|
51
|
+
// After a ship: no claim any more, and the shipping walkthrough was already shown.
|
|
52
|
+
if (known && !hasClaim && guide.stepsForMoment('first_ship').every((s) => done.has(s.id))) out.push('after_ship');
|
|
53
|
+
if (SETUP_RE.test(p)) out.push('setup');
|
|
54
|
+
if (GOAL_RE.test(p)) out.push('goal');
|
|
55
|
+
if (TASK_RE.test(p)) out.push('task');
|
|
56
|
+
if (FIND_WORK_RE.test(p) || (firstPrompt && known && !hasClaim)) out.push('find_work');
|
|
57
|
+
if (INVITE_RE.test(p)) out.push('invite');
|
|
58
|
+
if (SETTINGS_RE.test(p)) out.push('settings');
|
|
59
|
+
return [...new Set(out)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The unread steps of ONE moment, in guide order, at most `max`. Unknown moment → [].
|
|
63
|
+
function unreadForMoment(momentId, read = [], { max = MAX_STEPS } = {}) {
|
|
64
|
+
const done = new Set(read || []);
|
|
65
|
+
return guide.stepsForMoment(momentId).filter((s) => !done.has(s.id)).slice(0, max);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The first open moment that still has unread steps → { moment, steps }, or null.
|
|
69
|
+
function pickSteps(signals = {}, { max = MAX_STEPS } = {}) {
|
|
70
|
+
for (const id of openMoments(signals)) {
|
|
71
|
+
const steps = unreadForMoment(id, signals.read, { max });
|
|
72
|
+
if (steps.length) return { moment: guide.MOMENTS.find((m) => m.id === id), steps };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The words handed to the AI: what to tell the person, briefly, once.
|
|
78
|
+
function renderForAi({ moment, steps }) {
|
|
79
|
+
const parts = steps.map((s) => `"${s.title}": ${s.what} Try: ${s.try}`);
|
|
80
|
+
return `How-to (${moment.title.toLowerCase()}): the person has not seen this yet. `
|
|
81
|
+
+ 'At the end of your reply, in two or three plain sentences, tell them: '
|
|
82
|
+
+ parts.join(' Then: ')
|
|
83
|
+
+ ' Say it once and keep it short; it is now marked read and will not come back.';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The same steps for a person reading a terminal (`npx bongos howto moment <id>`).
|
|
87
|
+
function renderForPerson({ moment, steps }) {
|
|
88
|
+
return [`${moment.title}:`, ...steps.map((s) => `- ${s.title}: ${s.what}\n Try: ${s.try}`)].join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { MAX_STEPS, openMoments, unreadForMoment, pickSteps, renderForAi, renderForPerson };
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.129",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.129",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.129",
|
|
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/release-notes.json
CHANGED
|
@@ -9766,5 +9766,15 @@
|
|
|
9766
9766
|
"id": "1004634",
|
|
9767
9767
|
"text": "Bongos now remembers, for each person, which how-to steps they have already seen, so the AI never shows the same tip twice, on any computer."
|
|
9768
9768
|
}
|
|
9769
|
+
],
|
|
9770
|
+
"1.21.129": [
|
|
9771
|
+
{
|
|
9772
|
+
"id": "1004635",
|
|
9773
|
+
"text": "When someone is new to Bongos, the AI now gives them the right short tip at the right moment (first time opening the project, first task claimed, first ship) and never repeats a tip once seen. Other AI tools get the same tips"
|
|
9774
|
+
},
|
|
9775
|
+
{
|
|
9776
|
+
"id": "1003815",
|
|
9777
|
+
"text": "The module store can now record which release channel each module version is on — tester first, then everyone — with room for more, like taking a version back. Nothing changes for users yet; the next tasks use it."
|
|
9778
|
+
}
|
|
9769
9779
|
]
|
|
9770
9780
|
}
|
|
@@ -79,6 +79,7 @@ ${ident}
|
|
|
79
79
|
- **\`npx bongos ship\`** runs the smoke + grade, then merges and deploys; **\`npx bongos release <id>\`** drops a claim with no credit. Ship or release before you sign off.
|
|
80
80
|
- **The project's ship mode says who starts a ship** — \`npx bongos ship-mode\` prints it. In **Auto** (the default) you run \`npx bongos ship\` yourself when the task is done; never hand the person commands to run. In **Manual** ask the person first, and ship only after they say yes.
|
|
81
81
|
- **Dependencies gate everything** — a task becomes claimable only once every task it depends on has shipped.
|
|
82
|
+
- **Walk new people through the how-to, once.** The first time the person meets one of these moments, run the matching command and pass its few lines on in plain words at the end of your reply: the first prompt of a session — \`npx bongos howto moment first_open\`; their first claim — \`npx bongos howto moment first_claim\`; finishing their first task — \`npx bongos howto moment first_ship\`. It prints nothing once a step is read, so it never repeats (\`npx bongos howto moments\` lists the rest). Claude Code is handed these steps automatically.
|
|
82
83
|
- **Code you write or touch carries a doc-comment** — each module a header, each public function/class a line or two, saying *what* it does and *why* it exists (never how), in the words someone would search for. Agents find code by searching text, so meaning written into the source is what makes it findable.
|
|
83
84
|
|
|
84
85
|
## Where things live
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/howto.js — the in-session how-to guide from a terminal, for any AI tool (task 1004635)
|
|
3
|
+
//
|
|
4
|
+
// WHY. Claude Code gets the right how-to step injected by the Conductor hook at the
|
|
5
|
+
// moment it applies. Other AI tools (Codex, Cursor, Gemini…) have no such hook, so the
|
|
6
|
+
// instance CLAUDE.md / AGENTS.md tells them to run this at the same moments instead. It
|
|
7
|
+
// prints the person's unread steps for that moment — nothing at all once they are read —
|
|
8
|
+
// and marks them read in Bongos, so no tool shows the same step twice.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// node scripts/gds/howto.js moment <moment-id> print + mark read the unread steps of a moment
|
|
12
|
+
// node scripts/gds/howto.js moments list the moment ids and when each applies
|
|
13
|
+
//
|
|
14
|
+
// Also `npx bongos howto …`.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { apiCall, requireSession, errCode, cliExit } = require('./cli-lib');
|
|
18
|
+
const guide = require('../../modules/onboarding/howto-guide');
|
|
19
|
+
const moments = require('../../modules/onboarding/howto-moments');
|
|
20
|
+
|
|
21
|
+
const USAGE = 'usage: node scripts/gds/howto.js moment <moment-id> | moments';
|
|
22
|
+
|
|
23
|
+
// The step ids this person has already read or skipped, from GET /me/howto's answer.
|
|
24
|
+
function readIds(state) {
|
|
25
|
+
return ((state && state.steps) || []).filter((s) => s.state !== 'unread').map((s) => s.id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function showMoment(id) {
|
|
29
|
+
if (!guide.MOMENT_IDS.has(id)) {
|
|
30
|
+
console.error(`unknown moment "${id}" — one of: ${[...guide.MOMENT_IDS].join(', ')}`);
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
await requireSession();
|
|
34
|
+
const r = await apiCall('GET', '/api/bongos/me/howto');
|
|
35
|
+
if (!r.ok) {
|
|
36
|
+
console.error(`Could not read the how-to progress (${r.status}${errCode(r.data) ? `: ${errCode(r.data)}` : ''}).`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const steps = moments.unreadForMoment(id, readIds(r.data));
|
|
40
|
+
if (!steps.length) return 0; // all read: say nothing, so it never repeats
|
|
41
|
+
console.log(moments.renderForPerson({ moment: guide.MOMENTS.find((m) => m.id === id), steps }));
|
|
42
|
+
const w = await apiCall('POST', '/api/bongos/me/howto/read', { steps: steps.map((s) => s.id) });
|
|
43
|
+
if (!w.ok) console.error(`(could not mark these read: ${w.status}${errCode(w.data) ? ` ${errCode(w.data)}` : ''})`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main(argv) {
|
|
48
|
+
const [cmd, arg] = argv;
|
|
49
|
+
if (!cmd || cmd === '--help' || cmd === '-h') { console.log(USAGE); return cmd ? 0 : 2; }
|
|
50
|
+
if (cmd === 'moments') {
|
|
51
|
+
for (const m of guide.MOMENTS) console.log(`${m.id.padEnd(12)} ${m.trigger}`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (cmd === 'moment' && arg) return showMoment(String(arg).trim());
|
|
55
|
+
console.error(USAGE);
|
|
56
|
+
return 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (require.main === module) {
|
|
60
|
+
main(process.argv.slice(2))
|
|
61
|
+
.then((code) => cliExit(code))
|
|
62
|
+
.catch((err) => { console.error('fatal:', err && err.message ? err.message : err); cliExit(1); });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { main, readIds };
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.129'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// tests/howto_moments.mjs — the right unread how-to step at the right moment, never twice (task 1004635)
|
|
2
|
+
//
|
|
3
|
+
// The done-when: on a fresh project the first session is walked through the starting
|
|
4
|
+
// step, the first ship triggers the shipping walkthrough, and neither repeats once read.
|
|
5
|
+
// Held here at three layers: the pure picker (modules/onboarding/howto-moments.js), the
|
|
6
|
+
// Conductor advisory built from it (.claude/hooks/conductor.js howtoAdvisory), and the
|
|
7
|
+
// non-Claude path (the `npx bongos howto` verb + the instance CLAUDE.md lines naming it).
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/howto_moments.mjs
|
|
10
|
+
|
|
11
|
+
import { strict as assert } from 'node:assert';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const guide = require('../modules/onboarding/howto-guide.js');
|
|
16
|
+
const m = require('../modules/onboarding/howto-moments.js');
|
|
17
|
+
const { howtoAdvisory, evaluateTriggers } = require('../.claude/hooks/conductor.js');
|
|
18
|
+
const { VERBS } = require('../bin/bongos.js');
|
|
19
|
+
const { renderInstanceClaudeMd } = require('../scripts/gds/claude-md-template.js');
|
|
20
|
+
const { readIds } = require('../scripts/gds/howto.js');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
function test(name, fn) {
|
|
25
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
26
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
27
|
+
}
|
|
28
|
+
const ids = (pick) => (pick ? pick.steps.map((s) => s.id) : []);
|
|
29
|
+
|
|
30
|
+
console.log('the picker:');
|
|
31
|
+
|
|
32
|
+
test('a fresh project: the first prompt walks through what Bongos is and where to start', () => {
|
|
33
|
+
const pick = m.pickSteps({ firstPrompt: true, prompt: 'hello', claims: [], read: [] });
|
|
34
|
+
assert.equal(pick.moment.id, 'first_open');
|
|
35
|
+
assert.deepEqual(ids(pick), ['welcome', 'where_to_start']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('once read, the starting step never comes back — the next session moves on', () => {
|
|
39
|
+
const pick = m.pickSteps({ firstPrompt: true, prompt: 'hello', claims: [], read: ['welcome', 'where_to_start'] });
|
|
40
|
+
assert.ok(!ids(pick).includes('where_to_start'));
|
|
41
|
+
assert.ok(!ids(pick).includes('welcome'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('an ordinary later prompt with nothing claimed says nothing', () => {
|
|
45
|
+
assert.equal(m.pickSteps({ firstPrompt: false, prompt: 'rename the button', claims: [], read: [] }), null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the first ship triggers the shipping walkthrough, ahead of the claim steps', () => {
|
|
49
|
+
const pick = m.pickSteps({ prompt: 'ship it', claims: [{ task_id: 1 }], read: [] });
|
|
50
|
+
assert.equal(pick.moment.id, 'first_ship');
|
|
51
|
+
assert.deepEqual(ids(pick), ['what_ship_does', 'review_findings']);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the shipping walkthrough does not repeat once read', () => {
|
|
55
|
+
const read = ['what_ship_does', 'review_findings', 'claim_a_task', 'give_a_task_back', 'preview_before_ship'];
|
|
56
|
+
assert.equal(m.pickSteps({ prompt: 'ship it', claims: [{ task_id: 1 }], read }), null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('holding a claim opens the first-claim steps', () => {
|
|
60
|
+
assert.equal(m.pickSteps({ prompt: 'go on', claims: [{ task_id: 1 }], read: [] }).moment.id, 'first_claim');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('after a ship (no claim, shipping walkthrough read) comes what happens next', () => {
|
|
64
|
+
const pick = m.pickSteps({ prompt: 'ok', claims: [], read: ['what_ship_does', 'review_findings'] });
|
|
65
|
+
assert.equal(pick.moment.id, 'after_ship');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('unknown claim state never guesses a claim moment', () => {
|
|
69
|
+
assert.equal(m.pickSteps({ prompt: 'ship it', claims: null, read: [] }), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('short: never more than two steps, all from one moment', () => {
|
|
73
|
+
for (const firstPrompt of [true, false]) {
|
|
74
|
+
for (const prompt of ['ship it', 'I am stuck', 'make a goal', 'invite my teammate', 'what can I work on?']) {
|
|
75
|
+
const pick = m.pickSteps({ firstPrompt, prompt, claims: [{ task_id: 1 }], read: [] });
|
|
76
|
+
if (!pick) continue;
|
|
77
|
+
assert.ok(pick.steps.length <= m.MAX_STEPS);
|
|
78
|
+
assert.ok(pick.steps.every((s) => s.moment === pick.moment.id));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('every moment the picker can open is a moment the guide defines', () => {
|
|
84
|
+
const opened = new Set();
|
|
85
|
+
for (const prompt of ['ship it', 'stuck', 'set up', 'goal', 'make a task', "what's next", 'invite', 'settings']) {
|
|
86
|
+
for (const claims of [[], [{}], null]) {
|
|
87
|
+
for (const id of m.openMoments({ firstPrompt: true, prompt, claims, read: [] })) opened.add(id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const id of opened) assert.ok(guide.MOMENT_IDS.has(id), id);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('the AI is told to say it once, briefly, and that it is marked read', () => {
|
|
94
|
+
const text = m.renderForAi(m.pickSteps({ firstPrompt: true, claims: [], read: [] }));
|
|
95
|
+
assert.match(text, /Where to start/);
|
|
96
|
+
assert.match(text, /once/);
|
|
97
|
+
assert.match(text, /marked read/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log('\nthe Conductor:');
|
|
101
|
+
|
|
102
|
+
test('no read state (offline, signed out, no onboarding module) → no how-to advisory', () => {
|
|
103
|
+
assert.equal(howtoAdvisory({ howto: null, firstPrompt: true, activeClaims: [] }), null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('the advisory names the steps it shows, so main() can mark exactly those read', () => {
|
|
107
|
+
const a = howtoAdvisory({ howto: { read: [] }, firstPrompt: true, activeClaims: [] });
|
|
108
|
+
assert.deepEqual(a.howtoSteps, ['welcome', 'where_to_start']);
|
|
109
|
+
assert.equal(a.id, 'howto:welcome,where_to_start');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('evaluateTriggers carries the how-to advisory on a first prompt, and not once read', () => {
|
|
113
|
+
const first = evaluateTriggers({ prompt: 'hi', activeClaims: [], howto: { read: [] }, firstPrompt: true });
|
|
114
|
+
assert.ok(first.some((a) => a.howtoSteps));
|
|
115
|
+
const all = guide.STEPS.map((s) => s.id);
|
|
116
|
+
const later = evaluateTriggers({ prompt: 'ship it', activeClaims: [{ task_id: 1 }], howto: { read: all }, firstPrompt: true });
|
|
117
|
+
assert.ok(!later.some((a) => a.howtoSteps));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log('\nother AI tools:');
|
|
121
|
+
|
|
122
|
+
test('`bongos howto` is a verb, run by scripts/gds/howto.js', () => {
|
|
123
|
+
assert.equal(VERBS.howto.script, 'howto.js');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the instance CLAUDE.md names the three moment commands', () => {
|
|
127
|
+
const md = renderInstanceClaudeMd({});
|
|
128
|
+
for (const id of ['first_open', 'first_claim', 'first_ship']) assert.ok(md.includes(`npx bongos howto moment ${id}`), id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('the CLI counts read and skipped steps as done', () => {
|
|
132
|
+
assert.deepEqual(readIds({ steps: [{ id: 'a', state: 'read' }, { id: 'b', state: 'skipped' }, { id: 'c', state: 'unread' }] }), ['a', 'b']);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
136
|
+
if (failed) process.exitCode = 1;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// tests/module_release_channels.mjs — the release-channel schema a module version
|
|
2
|
+
// sits on (task 1003815, ADR 0344; migration core_276). No database: the migration
|
|
3
|
+
// is read as text and its shape pinned. The integration lane applies it to a real
|
|
4
|
+
// Postgres with every other migration.
|
|
5
|
+
//
|
|
6
|
+
// Run: node tests/module_release_channels.mjs
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const SQL = fs.readFileSync(path.join(ROOT, 'migrations', 'core_276_module_release_channels.sql'), 'utf8');
|
|
19
|
+
const DDL = SQL.replace(/--.*$/gm, '');
|
|
20
|
+
|
|
21
|
+
test('core_276 is additive and idempotent', () => {
|
|
22
|
+
// the project's own migration linter, so this tracks its unsafe-pattern list as it grows
|
|
23
|
+
const lint = require('../scripts/gds/lint-migrations.js');
|
|
24
|
+
assert.deepEqual(lint.scanMigrationSql(SQL).unsafe || [], [], 'additive only');
|
|
25
|
+
for (const m of DDL.matchAll(/CREATE (TABLE|INDEX)\s+(?!IF NOT EXISTS\b)(\w+)/g)) {
|
|
26
|
+
assert.fail(`CREATE ${m[1]} ${m[2]} without IF NOT EXISTS is not safe to re-run`);
|
|
27
|
+
}
|
|
28
|
+
assert.match(DDL, /ON CONFLICT \(channel\) DO NOTHING/, 're-running does not duplicate the channel rows');
|
|
29
|
+
assert.match(DDL, /WHERE NOT EXISTS \(SELECT 1 FROM store_module_version_channels c WHERE c\.version_id = v\.id\)/,
|
|
30
|
+
're-running does not add a second backfill row');
|
|
31
|
+
assert.match(SQL, /INSERT INTO schema_migrations \(version\) VALUES \('core_276_module_release_channels'\)/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('channels are rows, so a fourth needs no schema change; tester, general and withdrawn ship', () => {
|
|
35
|
+
assert.match(DDL, /CREATE TABLE IF NOT EXISTS store_release_channels \(\s*channel\s+text PRIMARY KEY/);
|
|
36
|
+
const seeded = [...DDL.matchAll(/\('(\w+)',\s+'[^']*',\s+(true|false),\s+\d+\)/g)].map((m) => [m[1], m[2]]);
|
|
37
|
+
assert.deepEqual(seeded, [['tester', 'true'], ['general', 'true'], ['withdrawn', 'false']],
|
|
38
|
+
'two channels serve installs; withdrawn never does');
|
|
39
|
+
assert.doesNotMatch(DDL, /channel\s+text[^,\n]*CHECK \(channel IN/, 'no closed list of channels in a CHECK');
|
|
40
|
+
assert.doesNotMatch(DDL, /\bis_tester\b|\btester\s+boolean/, 'not a boolean');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a channel belongs to a version, as history: the newest row is current', () => {
|
|
44
|
+
const t = DDL.match(/CREATE TABLE IF NOT EXISTS store_module_version_channels \(([\s\S]*?)\n\);/);
|
|
45
|
+
assert.ok(t, 'the history table exists');
|
|
46
|
+
const body = t[1];
|
|
47
|
+
assert.match(body, /version_id\s+bigint NOT NULL REFERENCES store_module_versions\(id\)/, 'keyed by version, not module');
|
|
48
|
+
assert.doesNotMatch(body, /\bmodule_key\b/, 'never by module');
|
|
49
|
+
assert.match(body, /channel\s+text NOT NULL REFERENCES store_release_channels\(channel\)/);
|
|
50
|
+
assert.match(body, /reason\s+text NOT NULL CHECK \(reason ~ '\^\[a-z\]\[a-z0-9_\]\{0,39\}\$'\)/, 'a short code, never free text');
|
|
51
|
+
assert.match(body, /set_by\s+bigint REFERENCES builders\(id\),/, 'nullable: the system can move a version');
|
|
52
|
+
assert.doesNotMatch(body, /UNIQUE \(version_id\)/, 'a version can move more than once');
|
|
53
|
+
assert.match(DDL, /ON store_module_version_channels \(version_id, set_at DESC, id DESC\)/, 'current-channel lookup is indexed');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('versions published before staged rollout stay offered to everyone', () => {
|
|
57
|
+
assert.match(DDL, /SELECT v\.id, 'general', 'predates_staged_rollout'\s+FROM store_module_versions v/);
|
|
58
|
+
});
|