@bongos/core 1.21.124 → 1.21.125

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.21.124",
6
- "core_contract": "1.21.124",
7
- "source_commit": "695a6d5cad41a0d7a9e79cf58a7de3282b64daf3",
5
+ "core_version": "1.21.125",
6
+ "core_contract": "1.21.125",
7
+ "source_commit": "6537f85e10dcb60107037c138237fe4e5d855500",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-14T19:49:05.130Z",
9
+ "built_at": "2026-10-15T15:06:45.205Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 605,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 3124,
14
+ "functional_verbatim": 3128,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3758,
20
- "tree_sha256": "97c9a1a6dc5c913bdacb02a6bbef3bef853e5babbbd971cd4f097403aa754be9",
19
+ "file_count": 3762,
20
+ "tree_sha256": "7fb72ed794b2cdaa7f0fe66ffdccb7b56f5c18a9d4d66f18b4767254072e1827",
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": "20794ff2c632668d2a65ab1ca1a0e6d5b240c19769dbabf6999c7347d41f54e6"
315
+ "sha256": "f2a64dc742bb1f13938840c9f294ea7a60f1895dc9366200e33d53891e5e0fe5"
316
316
  },
317
317
  {
318
318
  "path": "clients/bongos-client/README.md",
@@ -2922,7 +2922,7 @@
2922
2922
  {
2923
2923
  "path": "docs/module-api-changelog.md",
2924
2924
  "mode": "0000644",
2925
- "sha256": "1ead6d6c8697d53d3f324f3d5c1523f5c590ebcce268abcb2b29a367d6c34f17"
2925
+ "sha256": "a1ec4ace0bc91b34031c59bb4fd08263af542f5ddd1284204bfd821cf843b527"
2926
2926
  },
2927
2927
  {
2928
2928
  "path": "docs/modules-contract.md",
@@ -7929,6 +7929,11 @@
7929
7929
  "mode": "0000644",
7930
7930
  "sha256": "22904d8c06daabb634b55f240b895c906e90ba4c6ed81e7a41cdbbb78635ace8"
7931
7931
  },
7932
+ {
7933
+ "path": "modules/onboarding/howto-guide.js",
7934
+ "mode": "0000644",
7935
+ "sha256": "18984c946d746da66ff7678379e9f49c9cfdcdb8f70d2acd852ab29c820baad0"
7936
+ },
7932
7937
  {
7933
7938
  "path": "modules/onboarding/module.json",
7934
7939
  "mode": "0000644",
@@ -10472,12 +10477,12 @@
10472
10477
  {
10473
10478
  "path": "package-lock.json",
10474
10479
  "mode": "0000644",
10475
- "sha256": "31a3c967bca985d1dc5007279f9ed7e9352b0466128af6edd9debbf4e7b592bb"
10480
+ "sha256": "cb1fe1a974c6063f91ae16893f1daddb028647374dc13d9df725ea4be5074d9a"
10476
10481
  },
10477
10482
  {
10478
10483
  "path": "package.json",
10479
10484
  "mode": "0000644",
10480
- "sha256": "4714b7161307f36c4ae4322e72f3670f6d958b1ab3b19f728a3f05f880898771"
10485
+ "sha256": "e92ae06c5b33aa14e3ec516f009526f335ebec59b5e03b4e774a4a48c252d087"
10481
10486
  },
10482
10487
  {
10483
10488
  "path": "public-docs/index.html",
@@ -10497,7 +10502,7 @@
10497
10502
  {
10498
10503
  "path": "release-notes.json",
10499
10504
  "mode": "0000644",
10500
- "sha256": "aeb72646e01f775fe09e570e0f5e8dccf9733fc59cb79d5a67e6315f2195d88c"
10505
+ "sha256": "415b376c73ce06c6803897093c02aa9157378d55487a0f84dd8387dc749a75a0"
10501
10506
  },
10502
10507
  {
10503
10508
  "path": "scripts/cron/style_guide_audit.sh",
@@ -10874,6 +10879,11 @@
10874
10879
  "mode": "0000644",
10875
10880
  "sha256": "9dd80d2c37602c59cb75db0be02cfadc737fc64891a81d03f5be5f0ea5e35a27"
10876
10881
  },
10882
+ {
10883
+ "path": "scripts/gds/core-pin-sync.js",
10884
+ "mode": "0000644",
10885
+ "sha256": "f8a3a0ee2c0f4dbc2583603cd6e7b547140a5f8dc286b7c5f0b6dadd6096a002"
10886
+ },
10877
10887
  {
10878
10888
  "path": "scripts/gds/coreB-carve-proof.sh",
10879
10889
  "mode": "0000644",
@@ -12817,7 +12827,7 @@
12817
12827
  {
12818
12828
  "path": "src/module-api.js",
12819
12829
  "mode": "0000644",
12820
- "sha256": "792dad669f6c0d0b86433d206c837ca7ffcbf6a8b5b7c02bda8707c3dd3f7099"
12830
+ "sha256": "81694f072dbbb4e64a286a4f5157d07a8e1105ae8a94d800f09eba00f30848e6"
12821
12831
  },
12822
12832
  {
12823
12833
  "path": "src/module-loader/catalog.js",
@@ -13829,6 +13839,11 @@
13829
13839
  "mode": "0000644",
13830
13840
  "sha256": "a3cf352d5dc340b453b22513c28d8c0a439526862535f51f84b7aea2161c734f"
13831
13841
  },
13842
+ {
13843
+ "path": "tests/core_pin_sync.mjs",
13844
+ "mode": "0000644",
13845
+ "sha256": "ac3714b1b089ba2450f1125c74cbe5491deb4ddec1eec77c4c83a8a635a84d93"
13846
+ },
13832
13847
  {
13833
13848
  "path": "tests/core_update_banner.mjs",
13834
13849
  "mode": "0000644",
@@ -15584,6 +15599,11 @@
15584
15599
  "mode": "0000644",
15585
15600
  "sha256": "7cd031d48b41424a654ddc66404cf112a21ecc7d558256ac27ec9357401f112c"
15586
15601
  },
15602
+ {
15603
+ "path": "tests/howto_guide.mjs",
15604
+ "mode": "0000644",
15605
+ "sha256": "8e9b206fdde738f289bc11e204852595e9128b523cfc20cd67e3e83fb60a2fac"
15606
+ },
15587
15607
  {
15588
15608
  "path": "tests/html_comment_nesting.mjs",
15589
15609
  "mode": "0000644",
package/bin/bongos.js CHANGED
@@ -190,6 +190,15 @@ function main(argv) {
190
190
  return 0;
191
191
  }
192
192
 
193
+ // Run the core the checkout PINS (task 1004632). A task worktree has no node_modules
194
+ // of its own, so after a core update lands it resolves the main checkout's older core
195
+ // and the ship fails on a bug the pin already fixed. `ship` installs the pinned core
196
+ // into the checkout and re-runs under it; scripts/gds/core-pin-sync.js.
197
+ if (!COMPILED && verb === 'ship') {
198
+ const synced = require('../scripts/gds/core-pin-sync.js').ensurePinnedCore({ coreRoot: REPO_ROOT, argv });
199
+ if (synced != null) return synced;
200
+ }
201
+
193
202
  // `bongos exec <relpath> [args]` — run a core script by its package-relative path (see above).
194
203
  if (verb === 'exec') {
195
204
  if (COMPILED) {
@@ -3085,5 +3085,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3085
3085
  landed since 1.21.122 with no explicit bump. run 38624070870. (task 1002620)
3086
3086
  1.21.124 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3087
3087
  landed since 1.21.123 with no explicit bump. run 38626749089. (task 1002620)
3088
+ 1.21.125 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3089
+ landed since 1.21.124 with no explicit bump. run 38757326466. (task 1002620)
3088
3090
  ---------------------------------------------------------------------------
3089
3091
  ```
@@ -0,0 +1,259 @@
1
+ // modules/onboarding/howto-guide.js — the in-session how-to guide: ~25 short steps an AI walks a person through (task 1004627)
2
+ //
3
+ // WHY. The owner opened Bongos on their own project and did not know where to begin:
4
+ // nothing told them `/builder-start` exists, what a goal is for, or what happens on a
5
+ // first ship. This guide is the CONTENT an AI assistant walks a person through inside
6
+ // their own session, one short step at a time, at the moment it is useful — so someone
7
+ // who has never heard of a Bongos command is never lost.
8
+ //
9
+ // It is the single source of truth for the three tasks that build on it:
10
+ // • task 1004634 stores each person's read/unread state per step id;
11
+ // • task 1004635 shows the right unread step when its moment arrives, then marks it read;
12
+ // • task 1004636 lets a person reopen the guide, see progress, or skip it.
13
+ // So the step ids are STABLE: a stored "read" mark names one. Never rename or reuse an
14
+ // id — retire a step by deleting it and add a new id for new content
15
+ // (tests/howto_guide.mjs pins the shape and the plain-language rule).
16
+ //
17
+ // Each step is: what the feature is FOR (`what`), WHEN to reach for it (`when`), and the
18
+ // exact words or command to TRY (`try`). Plain language throughout — the reader may not
19
+ // be technical; the test bans the accessibility scorer's jargon list. Commands are the
20
+ // ones an instance actually gets (core skills + `npx bongos` verbs); a step names a
21
+ // slash command AND the plain words that reach it, because typed-only commands do not
22
+ // answer to plain words.
23
+ //
24
+ // Read it in a terminal: `node modules/onboarding/howto-guide.js` (add `--moment <id>`).
25
+
26
+ 'use strict';
27
+
28
+ // The moments, in the order a new project meets them. `trigger` says, for task 1004635,
29
+ // what event opens the moment; it is descriptive here, not wiring.
30
+ const MOMENTS = Object.freeze([
31
+ { id: 'first_open', title: 'Opening the project for the first time', trigger: 'the first prompt of the first session in a project folder' },
32
+ { id: 'setup', title: 'Setting up the project', trigger: 'the project has no goal yet, or setup is unfinished' },
33
+ { id: 'goal', title: 'Creating a goal', trigger: 'the person asks to plan work, or the project has no open goal' },
34
+ { id: 'task', title: 'Creating a task', trigger: 'the person describes a change they want, or a goal has no tasks' },
35
+ { id: 'find_work', title: 'Finding work', trigger: 'the person asks what to do next, or starts a session with nothing claimed' },
36
+ { id: 'first_claim', title: 'Claiming your first task', trigger: 'the person claims a task for the first time' },
37
+ { id: 'first_ship', title: 'Your first ship', trigger: 'the person finishes the work on their first claimed task' },
38
+ { id: 'after_ship', title: 'After a ship', trigger: 'a task reaches shipped for the first time' },
39
+ { id: 'invite', title: 'Inviting people', trigger: 'the person mentions a teammate, or asks how others can help' },
40
+ { id: 'settings', title: 'Settings', trigger: 'the person asks to change how Bongos behaves for them or the project' },
41
+ { id: 'stuck', title: 'When something goes wrong', trigger: 'a command fails, or the person says they are stuck or confused' },
42
+ ]);
43
+
44
+ const STEPS = Object.freeze([
45
+ // first_open
46
+ {
47
+ id: 'welcome', moment: 'first_open', title: 'What Bongos is',
48
+ what: 'Bongos keeps the list of everything your project needs, and the AI does the building. You decide what gets made; the AI does the work and records it.',
49
+ when: 'The first time you open this project with an AI assistant.',
50
+ try: 'Ask: "What is Bongos and what can it do for me?"',
51
+ },
52
+ {
53
+ id: 'where_to_start', moment: 'first_open', title: 'Where to start',
54
+ what: 'There are three ways in: finish setting up the project, make a goal (a bigger outcome you want), or make a task (one piece of work).',
55
+ when: 'Any time you are not sure what to do first.',
56
+ try: 'Say: "Where should I start?" The AI will look at the project and suggest one of the three.',
57
+ },
58
+ {
59
+ id: 'ask_in_plain_words', moment: 'first_open', title: 'Just ask in plain words',
60
+ what: 'You never need to memorise commands. Describe what you want and the AI picks the right one, then tells you which it used.',
61
+ when: 'Whenever you want something done and do not know the command.',
62
+ try: 'Say: "I want to add a contact form to the website."',
63
+ },
64
+ {
65
+ id: 'the_hall', moment: 'first_open', title: 'The hall: your project in a browser',
66
+ what: 'The hall is your project\'s web page. It shows goals, tasks, who is working on what, and what has shipped.',
67
+ when: 'When you want to see everything at a glance instead of asking.',
68
+ try: 'Ask: "Open the hall for this project" or "What is the hall link?"',
69
+ },
70
+ // setup
71
+ {
72
+ id: 'sign_in', moment: 'setup', title: 'Sign in once',
73
+ what: 'Signing in links this computer to your project, so the AI can claim and ship work in your name.',
74
+ when: 'The first time on a new computer, or when the AI says your sign-in has expired.',
75
+ try: 'Type /builder-setup, or in a terminal run `npx bongos login`.',
76
+ },
77
+ {
78
+ id: 'check_setup', moment: 'setup', title: 'Check everything is ready',
79
+ what: 'A quick health check of this computer and your sign-in, with a plain fix for anything missing.',
80
+ when: 'Before your first task, or when something that used to work stops working.',
81
+ try: 'Say: "Check my setup", or run `npx bongos doctor`.',
82
+ },
83
+ {
84
+ id: 'project_status', moment: 'setup', title: 'See where the project stands',
85
+ what: 'A summary of the current version: what is done, what is left, and what is blocking.',
86
+ when: 'At the start of a working session, or before planning.',
87
+ try: 'Type /status, or say "How far along is the project?"',
88
+ },
89
+ // goal
90
+ {
91
+ id: 'what_is_a_goal', moment: 'goal', title: 'What a goal is',
92
+ what: 'A goal is a bigger outcome, like "people can sign up" — a home for the tasks that get you there, with a clear "done when".',
93
+ when: 'When what you want is too big to finish in one sitting.',
94
+ try: 'Ask: "Should this be a goal or a single task?"',
95
+ },
96
+ {
97
+ id: 'create_goal', moment: 'goal', title: 'Create a goal',
98
+ what: 'The AI asks what the goal is for and what "done" looks like, then writes it up with its first few tasks.',
99
+ when: 'When you have decided on a new outcome to work towards.',
100
+ try: 'Type /goal-create, or say "Make a goal for letting people sign up."',
101
+ },
102
+ // task
103
+ {
104
+ id: 'what_is_a_task', moment: 'task', title: 'What a task is',
105
+ what: 'A task is one piece of work small enough to finish and check in one go. Every change to the project belongs to a task, so there is always a record of it.',
106
+ when: 'Whenever you want something changed.',
107
+ try: 'Ask: "What makes a good task?"',
108
+ },
109
+ {
110
+ id: 'create_task', moment: 'task', title: 'Create a task',
111
+ what: 'Describe the change and how you will know it is done. The AI writes it up and files it under the right goal.',
112
+ when: 'When you know a specific change you want.',
113
+ try: 'Say: "Make a task: the contact form should email me each message."',
114
+ },
115
+ {
116
+ id: 'capture_an_idea', moment: 'task', title: 'Save an idea for later',
117
+ what: 'Not ready to commit to it? Save it as an idea. Ideas wait in a list until you decide to turn them into tasks or drop them.',
118
+ when: 'When a thought comes up in the middle of other work.',
119
+ try: 'Say: "Save an idea: a dark mode for the website."',
120
+ },
121
+ // find_work
122
+ {
123
+ id: 'whats_claimable', moment: 'find_work', title: 'See what you can work on',
124
+ what: 'A list of the tasks that are ready right now and safe to start without clashing with anyone else\'s work.',
125
+ when: 'At the start of a session, or when you finish a task.',
126
+ try: 'Type /builder-start, or say "What can I work on?"',
127
+ },
128
+ {
129
+ id: 'work_a_goal', moment: 'find_work', title: 'Work through a whole goal',
130
+ what: 'The AI takes the goal\'s tasks one at a time — start, build, ship, next — and stops to ask you only when it needs a decision.',
131
+ when: 'When a goal has several tasks that follow on from each other.',
132
+ try: 'Say: "Work through goal 12 end to end", or type /builder-sequence.',
133
+ },
134
+ // first_claim
135
+ {
136
+ id: 'claim_a_task', moment: 'first_claim', title: 'Claiming a task',
137
+ what: 'Claiming says "I am on this one", so nobody else starts the same work. The AI then works on it in its own separate copy of the project, so your main folder stays tidy.',
138
+ when: 'When you pick a task to work on.',
139
+ try: 'Type /builder-claim followed by the task number, or say "Claim task 42."',
140
+ },
141
+ {
142
+ id: 'give_a_task_back', moment: 'first_claim', title: 'Give a task back',
143
+ what: 'Changed your mind? Giving it back makes the task available again. Nothing is lost and nothing is charged.',
144
+ when: 'When you will not finish a task you claimed.',
145
+ try: 'Type /builder-release, or say "Give this task back."',
146
+ },
147
+ {
148
+ id: 'preview_before_ship', moment: 'first_claim', title: 'See a change before it ships',
149
+ what: 'For changes you can see, the AI can show you the page as it will look, before anything goes live.',
150
+ when: 'When the task changes something on screen.',
151
+ try: 'Say: "Let me see it before you ship", or type /builder-stage.',
152
+ },
153
+ // first_ship
154
+ {
155
+ id: 'what_ship_does', moment: 'first_ship', title: 'What shipping does',
156
+ what: 'Shipping finishes a task: automatic reviewers check the work, then it is added to the project. Going live for your users can wait for a person to say yes.',
157
+ when: 'When the work on your task is finished.',
158
+ try: 'Type /builder-ship, or say "Ship it."',
159
+ },
160
+ {
161
+ id: 'review_findings', moment: 'first_ship', title: 'When the reviewers ask for fixes',
162
+ what: 'If the reviewers find a problem, the task is held, not lost. The AI fixes what they listed and asks for another review.',
163
+ when: 'When a ship says it needs fixes.',
164
+ try: 'Say: "Fix what the reviewers found and try again."',
165
+ },
166
+ // after_ship
167
+ {
168
+ id: 'what_happens_after', moment: 'after_ship', title: 'What happens after a ship',
169
+ what: 'The task is marked shipped, anything that was waiting on it becomes ready, and the record of the work is kept for the next person.',
170
+ when: 'Right after your first task ships.',
171
+ try: 'Ask: "What did that ship unlock?"',
172
+ },
173
+ {
174
+ id: 'fresh_session', moment: 'after_ship', title: 'Start fresh after a few tasks',
175
+ what: 'A long conversation gets slower and costlier. After a few tasks, the AI can write a short note so a fresh conversation picks up exactly where you left off.',
176
+ when: 'After three or so tasks in one conversation.',
177
+ try: 'Say: "Hand this off to a fresh session", or type /session-handoff.',
178
+ },
179
+ // invite
180
+ {
181
+ id: 'invite_people', moment: 'invite', title: 'Invite people',
182
+ what: 'Bring someone onto the project by their GitHub name. You choose whether anyone can join, people must ask first, or only invited people can sign in.',
183
+ when: 'When you want someone else to help build.',
184
+ try: 'In the hall, use "Invite someone" and enter their GitHub name, or ask "How do I invite someone?"',
185
+ },
186
+ {
187
+ id: 'ask_for_help', moment: 'invite', title: 'Ask a teammate for help',
188
+ what: 'Send a named person, or anyone with the right skills, a request for help on a task. It shows up in their own list.',
189
+ when: 'When you are stuck on a task and someone else knows the area.',
190
+ try: 'Say: "Ask Sam to help with task 42", or type /ask-for-help.',
191
+ },
192
+ // settings
193
+ {
194
+ id: 'ship_mode', moment: 'settings', title: 'Ship mode: who starts a ship',
195
+ what: 'In Auto, the AI ships a finished task by itself. In Manual, it stops and waits for you to say "ship it".',
196
+ when: 'When you want more, or less, control over when work is added to the project.',
197
+ try: 'In the hall\'s project settings pick Auto or Manual, or run `npx bongos ship-mode` to see the current one.',
198
+ },
199
+ {
200
+ id: 'how_the_ai_talks', moment: 'settings', title: 'How the AI talks to you',
201
+ what: 'Tell Bongos how technical you are and how long you like answers. Every AI session on this project follows it.',
202
+ when: 'When answers feel too technical, too long, or too short.',
203
+ try: 'Open your settings in the hall and look for how the AI talks to you, or say "Keep your answers shorter."',
204
+ },
205
+ // stuck
206
+ {
207
+ id: 'when_stuck', moment: 'stuck', title: 'When something goes wrong',
208
+ what: 'Every refusal comes with a plain explanation: what happened, what the AI will do, and whether it needs you. Most fix themselves.',
209
+ when: 'When a command fails or you do not understand what happened.',
210
+ try: 'Say: "Explain that in plain words and fix it."',
211
+ },
212
+ {
213
+ id: 'tell_the_bongos_team', moment: 'stuck', title: 'Tell the Bongos team',
214
+ what: 'If Bongos itself gets in your way, say so. Your words go to the people who build Bongos, and each complaint becomes a fix.',
215
+ when: 'Whenever Bongos is confusing, slow, or broken.',
216
+ try: 'Say: "Bongos feedback: …" followed by what went wrong.',
217
+ },
218
+ ]);
219
+
220
+ const MOMENT_IDS = new Set(MOMENTS.map((m) => m.id));
221
+
222
+ function stepById(id) { return STEPS.find((s) => s.id === id) || null; }
223
+
224
+ // The steps for one moment, in guide order. Unknown moment → [].
225
+ function stepsForMoment(momentId) { return STEPS.filter((s) => s.moment === momentId); }
226
+
227
+ // The first step not in `readIds` (an iterable of step ids), in guide order — what
228
+ // "continue the tutorial" means. null when everything is read.
229
+ function nextUnread(readIds) {
230
+ const read = new Set(readIds || []);
231
+ return STEPS.find((s) => !read.has(s.id)) || null;
232
+ }
233
+
234
+ function formatStep(step) {
235
+ return [`${step.title} [${step.id}]`, ` What it is for: ${step.what}`, ` When: ${step.when}`, ` Try: ${step.try}`].join('\n');
236
+ }
237
+
238
+ function formatGuide({ moment = null } = {}) {
239
+ const out = [];
240
+ for (const m of MOMENTS) {
241
+ if (moment && m.id !== moment) continue;
242
+ out.push(`## ${m.title}`);
243
+ for (const s of stepsForMoment(m.id)) out.push(formatStep(s), '');
244
+ }
245
+ return out.join('\n').trimEnd() + '\n';
246
+ }
247
+
248
+ if (require.main === module) {
249
+ const i = process.argv.indexOf('--moment');
250
+ const moment = i > -1 ? process.argv[i + 1] : null;
251
+ if (moment && !MOMENT_IDS.has(moment)) {
252
+ process.stderr.write(`unknown moment "${moment}" — one of: ${[...MOMENT_IDS].join(', ')}\n`);
253
+ process.exitCode = 2;
254
+ } else {
255
+ process.stdout.write(formatGuide({ moment }));
256
+ }
257
+ }
258
+
259
+ module.exports = { MOMENTS, STEPS, MOMENT_IDS, stepById, stepsForMoment, nextUnread, formatStep, formatGuide };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.124",
3
+ "version": "1.21.125",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.124",
9
+ "version": "1.21.125",
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.124",
3
+ "version": "1.21.125",
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",
@@ -9726,5 +9726,15 @@
9726
9726
  "id": "1004633",
9727
9727
  "text": "Found why a merged task on your own project stayed at 'confirmed': with no GitHub key set up on the project's server, Bongos could not prove the merge happened, and its background checker switched itself off. It now confirms t"
9728
9728
  }
9729
+ ],
9730
+ "1.21.125": [
9731
+ {
9732
+ "id": "1004632",
9733
+ "text": "When a project updates its Bongos core, the next task's ship now uses the new core automatically instead of the old one, so it no longer fails for no visible reason."
9734
+ },
9735
+ {
9736
+ "id": "1004627",
9737
+ "text": "Bongos now has a 27-step plain-language how-to guide that an AI can walk a new person through inside their session, from where to start to their first ship."
9738
+ }
9729
9739
  ]
9730
9740
  }
@@ -0,0 +1,233 @@
1
+ // scripts/gds/core-pin-sync.js — make `bongos ship` run the core the checkout PINS (task 1004632)
2
+ //
3
+ // WHY. An instance's task worktrees (`.claude/worktrees/task-N`) have no `node_modules`
4
+ // of their own: Node and `npx` walk up to the MAIN checkout's, and so does every
5
+ // `bongos ship`. After a core-update task lands, the next task's branch pins the new
6
+ // core in package.json — but its worktree still resolves the main checkout's install,
7
+ // which is whatever version was there before. On the owner's project that meant a
8
+ // ship ran 1.21.20 against a branch pinned to 1.21.71 and failed on a bug 1.21.71 had
9
+ // already fixed; the AI had to `npm ci` by hand in both places.
10
+ //
11
+ // THE FIX. Before `bongos ship` runs, the dispatcher asks `planCoreSync()` whether the
12
+ // core it is running is the one the checkout it stands in pins. When it is not, it
13
+ // installs the pinned core INTO that checkout (`npm ci`, or `npm install` with no
14
+ // lockfile) and re-runs the same command under it. A worktree that gets its own
15
+ // install keeps resolving it from then on. If the install fails, the builder gets one
16
+ // line that names the command to run — never a silent run on the wrong core. Only
17
+ // `ship` checks: it is the verb that runs the core's land/smoke code against the
18
+ // branch, and it is slow anyway — the many quick verbs (status, recall, exec) pay no
19
+ // git spawn for it.
20
+ //
21
+ // Safety. The pin comes from the branch, and installing it then RUNS it — so a branch
22
+ // must never be able to choose what gets installed. Two gates, both required:
23
+ // 1. the spec is a shape the core itself writes — a vendored
24
+ // `file:vendor/bongos-core-<v>.tgz` or an exact registry semver;
25
+ // 2. the pin has already LANDED: package.json, package-lock.json and the vendored
26
+ // tarball in this checkout are byte-identical (same git blob, no local edits) to
27
+ // the ones on `main` / `origin/main`. That is exactly the case this fixes — a
28
+ // core update merged, the next task's branch carries it — and a branch that pins
29
+ // something main does not get the remedy line, never an automatic install.
30
+ // The install also runs with `--ignore-scripts` (the core's dependencies need no
31
+ // install hook), through npm's own JS entry under this node where it can be found, so
32
+ // no shell is involved; only a Windows box without it falls back to `npm.cmd` + shell,
33
+ // with fixed arguments. A `node_modules` that
34
+ // is a LINK (the junction worktree.js gives an outside-repo worktree) is unlinked
35
+ // first — installing through it would rewrite the MAIN checkout's install. It is never
36
+ // followed and a real directory is never removed. The re-run sets BONGOS_CORE_SYNCED
37
+ // so a pin the install still cannot satisfy cannot loop.
38
+ //
39
+ // It does nothing in the core repo itself (the running package is not `@bongos/core`),
40
+ // outside git, or when the checkout pins no core. Pure planner + injectable runner, so
41
+ // tests/core_pin_sync.mjs drives every branch without npm or a network.
42
+
43
+ 'use strict';
44
+
45
+ const fs = require('node:fs');
46
+ const path = require('node:path');
47
+ const { spawnSync } = require('node:child_process');
48
+
49
+ const CORE_PKG = '@bongos/core';
50
+ const SYNCED_ENV = 'BONGOS_CORE_SYNCED';
51
+
52
+ function readJson(file, fsImpl = fs) {
53
+ try { return JSON.parse(String(fsImpl.readFileSync(file, 'utf8')).replace(/^/, '')); } catch { return null; }
54
+ }
55
+
56
+ // { version, safe } for a dependency spec. `version` reads the same shapes upgrade.js
57
+ // does (kept local so the dispatcher never loads upgrade.js's tree); `safe` is true
58
+ // only for the two shapes the core writes itself — a tarball under vendor/ named for
59
+ // its version, or an EXACT registry semver.
60
+ function parseSpec(spec) {
61
+ const s = String(spec || '').trim();
62
+ const tgz = s.match(/bongos-core-(\d+\.\d+\.\d+[^/\\]*)\.tgz$/);
63
+ if (tgz) return { version: tgz[1], safe: /^file:(\.\/)?vendor\/bongos-core-[^/\\]+\.tgz$/.test(s) };
64
+ if (/^file:/.test(s)) return { version: null, safe: false };
65
+ const sem = s.match(/^[\^~>=<\s]*v?(\d+\.\d+\.\d+[A-Za-z0-9.+-]*)$/);
66
+ if (!sem) return { version: null, safe: false };
67
+ return { version: sem[1], safe: /^\d+\.\d+\.\d+(-[A-Za-z0-9.]+)?$/.test(s) };
68
+ }
69
+
70
+ function versionFromSpec(spec) { return parseSpec(spec).version; }
71
+
72
+ function coreSpec(root, fsImpl = fs) {
73
+ const pkg = readJson(path.join(root, 'package.json'), fsImpl);
74
+ return pkg ? ((pkg.dependencies || {})[CORE_PKG] || (pkg.devDependencies || {})[CORE_PKG] || null) : null;
75
+ }
76
+
77
+ // What the checkout at `root` pins: the lockfile's resolved version first (exact), then
78
+ // the package.json spec.
79
+ function pinnedCoreVersion(root, fsImpl = fs) {
80
+ const lock = readJson(path.join(root, 'package-lock.json'), fsImpl);
81
+ const locked = lock && lock.packages && lock.packages[`node_modules/${CORE_PKG}`];
82
+ if (locked && locked.version) return locked.version;
83
+ return versionFromSpec(coreSpec(root, fsImpl));
84
+ }
85
+
86
+ // The refs that count as LANDED for gate 2: a pin is trusted only once it is on one of them.
87
+ const LANDED_REFS = ['main', 'origin/main'];
88
+
89
+ function gitOut(args, cwd, run) {
90
+ try {
91
+ const r = run('git', args, { cwd, encoding: 'utf8' });
92
+ return r && r.status === 0 ? String(r.stdout || '').trim() : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ // Gate 2: every file that decides what `npm ci` installs — package.json, the lockfile,
97
+ // the project .npmrc (it can redirect the registry), and the vendored tarball the spec
98
+ // names — is in the SAME state as on a landed ref: the same blob as it sits on disk (so
99
+ // an uncommitted edit counts), or absent on both. Deleting a file the landed ref has
100
+ // is a difference too. True when one landed ref matches all of them. The lockfile is
101
+ // also required outright: the install is `npm ci`, whose integrity check is the point.
102
+ const PIN_FILES = ['package.json', 'package-lock.json', '.npmrc'];
103
+
104
+ function pinLanded(root, { run = spawnSync, fsImpl = fs } = {}) {
105
+ if (!fsImpl.existsSync(path.join(root, 'package-lock.json'))) return false;
106
+ const spec = coreSpec(root, fsImpl);
107
+ const files = [...PIN_FILES];
108
+ const vendored = String(spec || '').match(/^file:(?:\.\/)?(vendor\/bongos-core-[^/\\]+\.tgz)$/);
109
+ if (vendored) files.push(vendored[1]);
110
+ const local = files.map((f) => (fsImpl.existsSync(path.join(root, f)) ? (gitOut(['hash-object', '--', f], root, run) || 'unreadable') : null));
111
+ return LANDED_REFS.some((ref) => files.every((f, i) => gitOut(['rev-parse', '--verify', '--quiet', `${ref}:${f}`], root, run) === local[i]));
112
+ }
113
+
114
+ function gitTopLevel(cwd, run = spawnSync) {
115
+ try {
116
+ const r = run('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' });
117
+ if (r.status !== 0) return null;
118
+ const top = String(r.stdout || '').trim();
119
+ return top ? path.resolve(top) : null;
120
+ } catch { return null; }
121
+ }
122
+
123
+ // Pure decision. Inputs are facts; output says whether to sync and why.
124
+ // runningPkg the running core's package.json ({ name, version })
125
+ // root the git top-level of the cwd (null outside git)
126
+ // pinned what that checkout pins (null when it pins no core)
127
+ // specSafe gate 1: the spec is a shape the core writes itself (parseSpec)
128
+ // landed gate 2: the pin is byte-identical to a landed ref's (pinLanded)
129
+ // env process env (the re-run guard)
130
+ function planCoreSync({ runningPkg, root, pinned, specSafe = false, landed = false, env = process.env }) {
131
+ if (!runningPkg || runningPkg.name !== CORE_PKG) return { action: 'none', reason: 'not-installed-core' };
132
+ if (!root) return { action: 'none', reason: 'no-git' };
133
+ if (!pinned) return { action: 'none', reason: 'no-pin' };
134
+ const running = runningPkg.version || null;
135
+ if (running === pinned) return { action: 'none', reason: 'in-sync', running, pinned };
136
+ if (env && env[SYNCED_ENV]) return { action: 'refuse', reason: 'still-mismatched', running, pinned, root };
137
+ if (!specSafe) return { action: 'refuse', reason: 'unrecognised-spec', running, pinned, root };
138
+ if (!landed) return { action: 'refuse', reason: 'pin-not-landed', running, pinned, root };
139
+ return { action: 'sync', reason: 'mismatch', running, pinned, root };
140
+ }
141
+
142
+ function installArgs(hasLock) {
143
+ return [hasLock ? 'ci' : 'install', '--ignore-scripts', '--no-audit', '--no-fund'];
144
+ }
145
+
146
+ // npm's own JS entry beside this node (Windows: <dir>/node_modules/npm; POSIX:
147
+ // <prefix>/lib/node_modules/npm), so the install needs no shell. null when absent.
148
+ function npmCliPath(execPath = process.execPath, fsImpl = fs) {
149
+ const dir = path.dirname(execPath);
150
+ const candidates = [
151
+ path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
152
+ path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
153
+ ];
154
+ return candidates.find((c) => fsImpl.existsSync(c)) || null;
155
+ }
156
+
157
+ function remedyLine({ root, pinned, running, hasLock = true }) {
158
+ return `bongos: this checkout pins core ${pinned} but ${running || 'another version'} is running. ` +
159
+ `Run \`npm ${hasLock ? 'ci' : 'install'}\` in ${root}, then run the command again.`;
160
+ }
161
+
162
+ // Remove a node_modules LINK without following it — the same move as worktree.js's
163
+ // unlinkNodeModulesLink, kept here rather than required: bin/bongos.js is in the
164
+ // executed closure (tests/executed_closure.mjs), and worktree.js would pull its
165
+ // computed require in with it. unlink takes a symlink and (via libuv) a junction; a
166
+ // Windows reparse point unlink refuses falls back to rmdir, which removes the link only.
167
+ function unlinkLink(linkPath, { platform = process.platform, fsImpl = fs } = {}) {
168
+ if (!fsImpl.lstatSync(linkPath).isSymbolicLink()) throw new Error(`${linkPath} is not a link`);
169
+ try { fsImpl.unlinkSync(linkPath); } catch (e) {
170
+ if (platform !== 'win32' || (e.code !== 'EPERM' && e.code !== 'EISDIR')) throw e;
171
+ fsImpl.rmdirSync(linkPath);
172
+ }
173
+ }
174
+
175
+ // Install the pinned core into `root` and re-run the same bongos command under it.
176
+ // Returns the exit code to use.
177
+ function syncAndRerun(plan, { argv, run = spawnSync, fsImpl = fs, log = (s) => process.stderr.write(s + '\n'), env = process.env, platform = process.platform, npmCli = npmCliPath(process.execPath, fsImpl) } = {}) {
178
+ const { root, pinned, running } = plan;
179
+ const hasLock = fsImpl.existsSync(path.join(root, 'package-lock.json'));
180
+ const remedy = () => log(remedyLine({ root, pinned, running, hasLock }));
181
+ log(`bongos: this checkout pins core ${pinned}, but ${running} is running — installing ${pinned} here first.`);
182
+ const nm = path.join(root, 'node_modules');
183
+ let st = null;
184
+ try { st = fsImpl.lstatSync(nm); } catch { /* absent */ }
185
+ if (st && st.isSymbolicLink()) {
186
+ try { unlinkLink(nm, { platform, fsImpl }); } catch (e) {
187
+ log(`bongos: could not detach the shared node_modules link (${e.message}).`);
188
+ remedy();
189
+ return 1;
190
+ }
191
+ }
192
+ // npm's JS entry under this node — no shell. Fallback: `npm`, which on win32 is
193
+ // npm.cmd and needs a shell (Node refuses a .cmd without one); the args are fixed
194
+ // strings, never branch data.
195
+ const cli = npmCli;
196
+ const inst = cli
197
+ ? run(process.execPath, [cli, ...installArgs(hasLock)], { cwd: root, stdio: 'inherit', env })
198
+ : run('npm', installArgs(hasLock), { cwd: root, stdio: 'inherit', env, ...(platform === 'win32' ? { shell: true } : {}) });
199
+ if (!inst || inst.error || inst.status !== 0) {
200
+ log(`bongos: installing core ${pinned} failed.`);
201
+ remedy();
202
+ return 1;
203
+ }
204
+ const bin = path.join(nm, '@bongos', 'core', 'bin', 'bongos.js');
205
+ if (!fsImpl.existsSync(bin)) { remedy(); return 1; }
206
+ const res = run(process.execPath, [bin, ...argv.slice(2)], { stdio: 'inherit', env: { ...env, [SYNCED_ENV]: '1' } });
207
+ if (!res || res.error) return 1;
208
+ return res.status == null ? 1 : res.status;
209
+ }
210
+
211
+ // The dispatcher's one call. Returns an exit code when it handled the command (synced
212
+ // and re-ran, or refused), or null to run the verb on the current core.
213
+ function ensurePinnedCore({ coreRoot, argv, cwd = process.cwd(), env = process.env, run = spawnSync, fsImpl = fs, log } = {}) {
214
+ if (env && env.BONGOS_SKIP_CORE_SYNC) return null;
215
+ const runningPkg = readJson(path.join(coreRoot, 'package.json'), fsImpl);
216
+ if (!runningPkg || runningPkg.name !== CORE_PKG) return null; // fast path: the core repo itself
217
+ const root = gitTopLevel(cwd, run);
218
+ const pinned = root ? pinnedCoreVersion(root, fsImpl) : null;
219
+ const specSafe = root ? parseSpec(coreSpec(root, fsImpl)).safe : false;
220
+ const out = { runningPkg, root, pinned, specSafe, env };
221
+ // gate 2 costs git spawns — only pay it when there is a mismatch to act on
222
+ const pre = planCoreSync({ ...out, landed: true });
223
+ const plan = pre.action === 'sync' ? planCoreSync({ ...out, landed: pinLanded(root, { run, fsImpl }) }) : pre;
224
+ if (plan.action === 'none') return null;
225
+ const say = log || ((s) => process.stderr.write(s + '\n'));
226
+ if (plan.action === 'refuse') {
227
+ say(remedyLine({ ...plan, hasLock: fsImpl.existsSync(path.join(root, 'package-lock.json')) }));
228
+ return 1;
229
+ }
230
+ return syncAndRerun(plan, { argv, run, fsImpl, log: say, env });
231
+ }
232
+
233
+ module.exports = { CORE_PKG, SYNCED_ENV, LANDED_REFS, pinLanded, npmCliPath, parseSpec, versionFromSpec, installArgs, pinnedCoreVersion, planCoreSync, remedyLine, syncAndRerun, ensurePinnedCore };
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.124'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.125'; // 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,237 @@
1
+ // tests/core_pin_sync.mjs — `bongos <verb>` runs the core the checkout pins (task 1004632)
2
+ //
3
+ // The bug: an instance's task worktree has no node_modules of its own, so after a
4
+ // core-update task lands the next task's `bongos ship` resolved the MAIN checkout's
5
+ // older core and failed on a bug the new pin had already fixed. These cases pin the
6
+ // decision (planCoreSync), the pin reader, and the install-then-re-run path with an
7
+ // injected runner — no npm, no network. The last case is end to end on a real temp
8
+ // git repo with a fake installed core.
9
+ //
10
+ // Run: node tests/core_pin_sync.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const sync = require('../scripts/gds/core-pin-sync.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.stack || err.message}`); }
27
+ }
28
+
29
+ const CORE = { name: '@bongos/core', version: '1.21.20' };
30
+
31
+ test('versionFromSpec reads a vendored tarball and a registry semver', () => {
32
+ assert.equal(sync.versionFromSpec('file:vendor/bongos-core-1.21.71.tgz'), '1.21.71');
33
+ assert.equal(sync.versionFromSpec('^1.21.71'), '1.21.71');
34
+ assert.equal(sync.versionFromSpec('1.21.71'), '1.21.71');
35
+ assert.equal(sync.versionFromSpec('file:../somewhere'), null);
36
+ assert.equal(sync.versionFromSpec(undefined), null);
37
+ });
38
+
39
+ test('parseSpec: only the shapes the core writes itself are safe to auto-install', () => {
40
+ assert.equal(sync.parseSpec('file:vendor/bongos-core-1.21.71.tgz').safe, true);
41
+ assert.equal(sync.parseSpec('1.21.71').safe, true);
42
+ assert.equal(sync.parseSpec('^1.21.71').safe, false, 'a range is not an exact pin');
43
+ assert.equal(sync.parseSpec('file:../evil/bongos-core-1.21.71.tgz').safe, false);
44
+ assert.equal(sync.parseSpec('git+https://x/y.git').safe, false);
45
+ });
46
+
47
+ test('planCoreSync: a pin that has not landed on main refuses — a branch cannot choose what gets installed', () => {
48
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '9.9.9', specSafe: true, landed: false, env: {} });
49
+ assert.equal(p.action, 'refuse');
50
+ assert.equal(p.reason, 'pin-not-landed');
51
+ });
52
+
53
+ test('planCoreSync: an unrecognised spec refuses with the remedy, never installs', () => {
54
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', specSafe: false, env: {} });
55
+ assert.equal(p.action, 'refuse');
56
+ });
57
+
58
+ test('installArgs: never runs install scripts; ci with a lockfile, install without', () => {
59
+ assert.deepEqual(sync.installArgs(true).slice(0, 2), ['ci', '--ignore-scripts']);
60
+ assert.deepEqual(sync.installArgs(false).slice(0, 2), ['install', '--ignore-scripts']);
61
+ });
62
+
63
+ test('planCoreSync: the core repo itself is never touched', () => {
64
+ const p = sync.planCoreSync({ runningPkg: { name: 'cloud-bongos', version: '0.0.2' }, root: '/r', pinned: '1.21.71', env: {} });
65
+ assert.equal(p.action, 'none');
66
+ });
67
+
68
+ test('planCoreSync: in sync, no git, no pin → carry on', () => {
69
+ assert.equal(sync.planCoreSync({ runningPkg: { ...CORE, version: '1.21.71' }, root: '/r', pinned: '1.21.71', env: {} }).action, 'none');
70
+ assert.equal(sync.planCoreSync({ runningPkg: CORE, root: null, pinned: '1.21.71', env: {} }).action, 'none');
71
+ assert.equal(sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: null, env: {} }).action, 'none');
72
+ });
73
+
74
+ test('planCoreSync: the task-49 shape (pinned 1.21.71, running 1.21.20) syncs', () => {
75
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', specSafe: true, landed: true, env: {} });
76
+ assert.equal(p.action, 'sync');
77
+ assert.equal(p.pinned, '1.21.71');
78
+ assert.equal(p.running, '1.21.20');
79
+ });
80
+
81
+ test('planCoreSync: a re-run that is STILL mismatched refuses instead of looping', () => {
82
+ const p = sync.planCoreSync({ runningPkg: CORE, root: '/r', pinned: '1.21.71', env: { [sync.SYNCED_ENV]: '1' } });
83
+ assert.equal(p.action, 'refuse');
84
+ assert.match(sync.remedyLine(p), /npm ci/);
85
+ });
86
+
87
+ function tmpCheckout({ pinSpec, lockVersion }) {
88
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-sync-'));
89
+ fs.writeFileSync(path.join(dir, 'package.json'), '' + JSON.stringify({ name: 'inst', dependencies: { '@bongos/core': pinSpec } }));
90
+ if (lockVersion) {
91
+ fs.writeFileSync(path.join(dir, 'package-lock.json'), JSON.stringify({ packages: { 'node_modules/@bongos/core': { version: lockVersion } } }));
92
+ }
93
+ return dir;
94
+ }
95
+
96
+ test('pinnedCoreVersion prefers the lockfile, falls back to package.json (BOM tolerated)', () => {
97
+ const a = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.72' });
98
+ assert.equal(sync.pinnedCoreVersion(a), '1.21.72');
99
+ const b = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz' });
100
+ assert.equal(fs.readFileSync(path.join(b, 'package.json'), 'utf8').charCodeAt(0), 0xFEFF, 'fixture really carries a BOM');
101
+ assert.equal(sync.pinnedCoreVersion(b), '1.21.71');
102
+ });
103
+
104
+ test('syncAndRerun: npm ci in the checkout, then re-run the same argv under its own core with the loop guard', () => {
105
+ const root = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.71' });
106
+ const calls = [];
107
+ const run = (cmd, args, opts) => {
108
+ calls.push({ cmd, args, opts });
109
+ if (cmd === 'npm') {
110
+ const d = path.join(root, 'node_modules', '@bongos', 'core', 'bin');
111
+ fs.mkdirSync(d, { recursive: true });
112
+ fs.writeFileSync(path.join(d, 'bongos.js'), '');
113
+ return { status: 0 };
114
+ }
115
+ return { status: 7 };
116
+ };
117
+ const code = sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship', '49'], run, log: () => {}, env: {}, platform: 'linux', npmCli: null });
118
+ assert.equal(code, 7, 'the re-run exit code is passed through');
119
+ assert.equal(calls[0].cmd, 'npm');
120
+ assert.equal(calls[0].args[0], 'ci');
121
+ assert.equal(path.resolve(calls[0].opts.cwd), path.resolve(root));
122
+ assert.equal(calls[1].cmd, process.execPath);
123
+ assert.deepEqual(calls[1].args.slice(1), ['ship', '49']);
124
+ assert.ok(calls[1].args[0].includes(path.join('node_modules', '@bongos', 'core', 'bin', 'bongos.js')));
125
+ assert.equal(calls[1].opts.env[sync.SYNCED_ENV], '1');
126
+ });
127
+
128
+ test('syncAndRerun: a failed install exits 1 with the one-line remedy, and never re-runs', () => {
129
+ const root = tmpCheckout({ pinSpec: '1.21.71' });
130
+ const logs = [];
131
+ const calls = [];
132
+ const run = (cmd, args) => { calls.push(cmd); return { status: 1 }; };
133
+ const code = sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship'], run, log: (s) => logs.push(s), env: {}, platform: 'linux', npmCli: null });
134
+ assert.equal(code, 1);
135
+ assert.deepEqual(calls, ['npm']);
136
+ assert.equal(calls.length, 1);
137
+ assert.ok(logs.some((l) => /Run `npm install` in /.test(l)), 'no lockfile → remedy names npm install: ' + logs.join('\n'));
138
+ });
139
+
140
+ test('syncAndRerun: a node_modules LINK is detached first, never installed through', () => {
141
+ const root = tmpCheckout({ pinSpec: '1.21.71', lockVersion: '1.21.71' });
142
+ const shared = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-shared-'));
143
+ fs.writeFileSync(path.join(shared, 'keep.txt'), 'shared');
144
+ fs.symlinkSync(shared, path.join(root, 'node_modules'), 'junction');
145
+ const run = (cmd) => {
146
+ if (cmd === 'npm') {
147
+ assert.ok(!fs.existsSync(path.join(root, 'node_modules')), 'link must be gone before npm runs');
148
+ return { status: 1 };
149
+ }
150
+ return { status: 0 };
151
+ };
152
+ sync.syncAndRerun({ root, pinned: '1.21.71', running: '1.21.20' }, { argv: ['node', 'bongos', 'ship'], run, log: () => {}, env: {}, npmCli: null });
153
+ assert.equal(fs.readFileSync(path.join(shared, 'keep.txt'), 'utf8'), 'shared', 'the shared install is untouched');
154
+ });
155
+
156
+ function git(cwd, ...args) {
157
+ const r = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' });
158
+ assert.equal(r.status, 0, r.stderr);
159
+ return r.stdout;
160
+ }
161
+
162
+ // A real repo: main carries the 1.21.71 pin (the core update landed); the checkout is
163
+ // on a task branch. The running core is the older 1.21.20.
164
+ function landedRepo() {
165
+ const root = tmpCheckout({ pinSpec: 'file:vendor/bongos-core-1.21.71.tgz', lockVersion: '1.21.71' });
166
+ fs.mkdirSync(path.join(root, 'vendor'));
167
+ fs.writeFileSync(path.join(root, 'vendor', 'bongos-core-1.21.71.tgz'), 'tarball-bytes');
168
+ git(root, 'init', '-q', '-b', 'main');
169
+ git(root, 'add', '-A');
170
+ git(root, 'commit', '-qm', 'pin 1.21.71');
171
+ git(root, 'checkout', '-qb', 'task-49');
172
+ const coreRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'core-pin-running-'));
173
+ fs.writeFileSync(path.join(coreRoot, 'package.json'), JSON.stringify(CORE));
174
+ const installs = [];
175
+ const run = (cmd, args, opts) => {
176
+ if (cmd === 'git') return spawnSync(cmd, args, opts);
177
+ if (cmd === 'npm' || String(args[0] || '').endsWith('npm-cli.js')) installs.push(args);
178
+ return { status: 1 };
179
+ };
180
+ return { root, coreRoot, installs, run };
181
+ }
182
+
183
+ test('ensurePinnedCore end to end: a pin that LANDED on main gets installed', () => {
184
+ const { root, coreRoot, installs, run } = landedRepo();
185
+ assert.equal(sync.pinLanded(root, { run }), true);
186
+ const code = sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} });
187
+ assert.equal(code, 1, 'the fake install fails, so the remedy exit');
188
+ assert.equal(installs.length, 1, 'npm ran once');
189
+ // in sync → null (carry on), no install
190
+ fs.writeFileSync(path.join(coreRoot, 'package.json'), JSON.stringify({ ...CORE, version: '1.21.71' }));
191
+ installs.length = 0;
192
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), null);
193
+ assert.equal(installs.length, 0);
194
+ });
195
+
196
+ test('ensurePinnedCore end to end: a branch-swapped tarball (committed or not) is refused, never installed', () => {
197
+ const { root, coreRoot, installs, run } = landedRepo();
198
+ fs.writeFileSync(path.join(root, 'vendor', 'bongos-core-1.21.71.tgz'), 'attacker-bytes');
199
+ assert.equal(sync.pinLanded(root, { run }), false, 'uncommitted swap');
200
+ git(root, 'commit', '-qam', 'swap');
201
+ assert.equal(sync.pinLanded(root, { run }), false, 'committed on the branch only');
202
+ const logs = [];
203
+ const code = sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: (l) => logs.push(l) });
204
+ assert.equal(code, 1);
205
+ assert.equal(installs.length, 0);
206
+ assert.ok(logs.some((l) => /Run `npm ci`/.test(l)), logs.join('\n'));
207
+ });
208
+
209
+ test('ensurePinnedCore end to end: a branch-only version bump is refused, never installed', () => {
210
+ const { root, coreRoot, installs, run } = landedRepo();
211
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'inst', dependencies: { '@bongos/core': '9.9.9' } }));
212
+ fs.rmSync(path.join(root, 'package-lock.json'));
213
+ git(root, 'commit', '-qam', 'bump');
214
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), 1);
215
+ assert.equal(installs.length, 0);
216
+ });
217
+
218
+ test('ensurePinnedCore end to end: deleting the lockfile or adding an .npmrc on the branch is refused', () => {
219
+ for (const tamper of [
220
+ (root) => fs.rmSync(path.join(root, 'package-lock.json')),
221
+ (root) => fs.writeFileSync(path.join(root, '.npmrc'), 'registry=https://evil.example/\n'),
222
+ ]) {
223
+ const { root, coreRoot, installs, run } = landedRepo();
224
+ tamper(root);
225
+ assert.equal(sync.pinLanded(root, { run }), false);
226
+ assert.equal(sync.ensurePinnedCore({ coreRoot, argv: ['node', 'bongos', 'ship'], cwd: root, env: {}, run, log: () => {} }), 1);
227
+ assert.equal(installs.length, 0);
228
+ }
229
+ });
230
+
231
+ test('the dispatcher runs the check for ship only', () => {
232
+ const src = fs.readFileSync(new URL('../bin/bongos.js', import.meta.url), 'utf8');
233
+ assert.match(src, /if \(!COMPILED && verb === 'ship'\) \{\s*const synced = require\('\.\.\/scripts\/gds\/core-pin-sync\.js'\)\.ensurePinnedCore/);
234
+ });
235
+
236
+ console.log(`\n${passed} passed, ${failed} failed`);
237
+ if (failed) process.exit(1);
@@ -0,0 +1,96 @@
1
+ // tests/howto_guide.mjs — the in-session how-to guide's shape + plain-language rule (task 1004627)
2
+ //
3
+ // The guide's step ids are stored as per-person read marks (task 1004634) and matched to
4
+ // moments (task 1004635), so they must be unique, stable-shaped, and every step must
5
+ // belong to a known moment and carry all three parts (what / when / try). It is written
6
+ // for people who may not be technical, so no step may use the accessibility scorer's
7
+ // jargon list (the same list tests/plain_cards.mjs holds the failure cards to).
8
+ //
9
+ // Run: node tests/howto_guide.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 { JARGON_TERMS } = require('../modules/sessions/accessibility.js');
17
+
18
+ let passed = 0;
19
+ let failed = 0;
20
+ function test(name, fn) {
21
+ try { fn(); passed++; console.log(` ok ${name}`); }
22
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
23
+ }
24
+
25
+ test('about 25 steps, each with a unique snake_case id', () => {
26
+ assert.ok(guide.STEPS.length >= 20 && guide.STEPS.length <= 32, `got ${guide.STEPS.length}`);
27
+ const ids = guide.STEPS.map((s) => s.id);
28
+ assert.equal(new Set(ids).size, ids.length, 'duplicate step id');
29
+ for (const id of ids) assert.match(id, /^[a-z][a-z0-9_]{2,39}$/, id);
30
+ });
31
+
32
+ test('every step has a known moment and all three parts', () => {
33
+ for (const s of guide.STEPS) {
34
+ assert.ok(guide.MOMENT_IDS.has(s.moment), `${s.id}: unknown moment ${s.moment}`);
35
+ for (const f of ['title', 'what', 'when', 'try']) assert.ok(String(s[f] || '').trim().length > 5, `${s.id}.${f} empty`);
36
+ }
37
+ });
38
+
39
+ test('every moment has at least one step and a trigger', () => {
40
+ for (const m of guide.MOMENTS) {
41
+ assert.ok(guide.stepsForMoment(m.id).length > 0, `${m.id} has no steps`);
42
+ assert.ok(m.trigger, `${m.id} has no trigger`);
43
+ }
44
+ });
45
+
46
+ test('the moments the owner named are covered', () => {
47
+ for (const m of ['first_open', 'setup', 'goal', 'task', 'find_work', 'first_ship', 'invite', 'settings']) {
48
+ assert.ok(guide.MOMENT_IDS.has(m), m);
49
+ }
50
+ assert.ok(guide.stepById('where_to_start'), 'the "where to start" step exists');
51
+ assert.ok(guide.stepById('ship_mode'), 'settings covers the ship mode');
52
+ });
53
+
54
+ test('steps are short and in plain language (no jargon)', () => {
55
+ const hits = [];
56
+ for (const s of guide.STEPS) {
57
+ for (const f of ['title', 'what', 'when', 'try']) {
58
+ const text = s[f];
59
+ if (f !== 'try') assert.ok(text.length <= 240, `${s.id}.${f} is ${text.length} chars — keep a step short`);
60
+ for (const term of JARGON_TERMS) {
61
+ const re = new RegExp(`(^|[^A-Za-z])${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^A-Za-z]|$)`, /^[A-Z+-]+$/.test(term) ? '' : 'i');
62
+ if (re.test(text)) hits.push(`${s.id}.${f}: "${term}"`);
63
+ }
64
+ }
65
+ }
66
+ assert.deepEqual(hits, []);
67
+ });
68
+
69
+ test('every slash command a step names is a core skill an instance gets', async () => {
70
+ const fs = await import('node:fs');
71
+ const { isPublishable } = require('../scripts/gds/publish-manifest.js');
72
+ for (const s of guide.STEPS) {
73
+ for (const m of s.try.matchAll(/\/([a-z][a-z-]+)/g)) {
74
+ const skill = `.claude/skills/${m[1]}/SKILL.md`;
75
+ assert.ok(fs.existsSync(new URL(`../${skill}`, import.meta.url)), `${s.id}: /${m[1]} is not a skill`);
76
+ assert.ok(isPublishable(skill), `${s.id}: /${m[1]} does not ship to an instance`);
77
+ }
78
+ }
79
+ });
80
+
81
+ test('nextUnread walks the guide in order and ends at null', () => {
82
+ assert.equal(guide.nextUnread([]).id, guide.STEPS[0].id);
83
+ assert.equal(guide.nextUnread([guide.STEPS[0].id]).id, guide.STEPS[1].id);
84
+ assert.equal(guide.nextUnread(guide.STEPS.map((s) => s.id)), null);
85
+ });
86
+
87
+ test('formatGuide renders every step, and one moment on request', () => {
88
+ const all = guide.formatGuide();
89
+ for (const s of guide.STEPS) assert.ok(all.includes(`[${s.id}]`), s.id);
90
+ const one = guide.formatGuide({ moment: 'first_ship' });
91
+ assert.ok(one.includes('[what_ship_does]'));
92
+ assert.ok(!one.includes('[welcome]'));
93
+ });
94
+
95
+ console.log(`\n${passed} passed, ${failed} failed`);
96
+ if (failed) process.exit(1);