@bongos/core 1.21.127 → 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.
@@ -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 };
@@ -0,0 +1,108 @@
1
+ // modules/onboarding/howto-state.js — each person's read/unread state for the how-to steps (task 1004634)
2
+ //
3
+ // WHY. The how-to guide (howto-guide.js, task 1004627) is shown one step at a time
4
+ // at the moment it is useful, and a step once read must never be shown again — on
5
+ // this computer or any other, in Claude or another AI tool. So the read marks live in
6
+ // Bongos, per person (owner decision 2026-10-08), in `onboarding_howto_reads`
7
+ // (migrations/onboarding_001_howto_reads.sql): one row per step read, unread = no row.
8
+ //
9
+ // Three operations, served by routes/onboarding.js under /me/howto:
10
+ // • readState — every step with read / skipped / unread, the count, and the next unread
11
+ // • markRead — mark steps read (or skipped); `all: true` skips the whole guide
12
+ // • reset — forget some steps, or all of them, so they show again
13
+ // Step ids are checked against the guide: an id it does not name is refused, so a
14
+ // typo cannot store a mark nothing will ever read. Marking is idempotent and keeps the
15
+ // FIRST read time (a re-mark does not move it), except that reading a step you had
16
+ // skipped upgrades it to read.
17
+ //
18
+ // Pure of core require(): the pool comes from the doorway, or is injected (tests).
19
+
20
+ 'use strict';
21
+
22
+ const guide = require('./howto-guide');
23
+
24
+ const HOWS = Object.freeze(['read', 'skipped']);
25
+
26
+ function poolFor(deps) {
27
+ if (deps && deps.pool) return deps.pool;
28
+ return require('../../src/module-api').pool;
29
+ }
30
+
31
+ // Validate a list of step ids against the guide. Returns { ok, ids } or { ok:false, unknown }.
32
+ function checkSteps(ids) {
33
+ const list = [...new Set((Array.isArray(ids) ? ids : [ids]).map((s) => String(s || '').trim()).filter(Boolean))];
34
+ const unknown = list.filter((id) => !guide.stepById(id));
35
+ return unknown.length ? { ok: false, unknown } : { ok: true, ids: list };
36
+ }
37
+
38
+ // Pure: shape stored rows into the API answer, in guide order.
39
+ function shapeState(rows) {
40
+ const byId = new Map((rows || []).map((r) => [r.step_id, r]));
41
+ const steps = guide.STEPS.map((s) => {
42
+ const r = byId.get(s.id);
43
+ return {
44
+ id: s.id, moment: s.moment, title: s.title,
45
+ state: r ? (r.how === 'skipped' ? 'skipped' : 'read') : 'unread',
46
+ read_at: r ? new Date(r.read_at).toISOString() : null,
47
+ };
48
+ });
49
+ const done = steps.filter((s) => s.state !== 'unread');
50
+ const next = guide.nextUnread(done.map((s) => s.id));
51
+ return {
52
+ steps,
53
+ total: steps.length,
54
+ read: steps.filter((s) => s.state === 'read').length,
55
+ skipped: steps.filter((s) => s.state === 'skipped').length,
56
+ unread: steps.length - done.length,
57
+ next_unread: next ? next.id : null,
58
+ };
59
+ }
60
+
61
+ async function readState(builderId, deps = {}) {
62
+ const pool = poolFor(deps);
63
+ const { rows } = await pool.query(
64
+ 'SELECT step_id, how, read_at FROM onboarding_howto_reads WHERE builder_id = $1',
65
+ [builderId],
66
+ );
67
+ return shapeState(rows);
68
+ }
69
+
70
+ // { steps: [...] } or { all: true }; how = 'read' | 'skipped'.
71
+ async function markRead(builderId, { steps, all = false, how = 'read' } = {}, deps = {}) {
72
+ if (!HOWS.includes(how)) return { ok: false, reason: 'bad_how' };
73
+ let ids;
74
+ if (all) ids = guide.STEPS.map((s) => s.id);
75
+ else {
76
+ const c = checkSteps(steps);
77
+ if (!c.ok) return { ok: false, reason: 'unknown_step', unknown: c.unknown };
78
+ if (!c.ids.length) return { ok: false, reason: 'no_steps' };
79
+ ids = c.ids;
80
+ }
81
+ const pool = poolFor(deps);
82
+ // One statement: first read time is kept; a 'skipped' row becomes 'read' when the
83
+ // step is read for real, never the other way round.
84
+ await pool.query(
85
+ `INSERT INTO onboarding_howto_reads (builder_id, step_id, how)
86
+ SELECT $1, s, $3 FROM unnest($2::text[]) AS s
87
+ ON CONFLICT (builder_id, step_id) DO UPDATE
88
+ SET how = 'read'
89
+ WHERE onboarding_howto_reads.how = 'skipped' AND EXCLUDED.how = 'read'`,
90
+ [builderId, ids, how],
91
+ );
92
+ return { ok: true, state: await readState(builderId, deps) };
93
+ }
94
+
95
+ // { steps: [...] } forgets those; no steps (or all: true) forgets every mark.
96
+ async function reset(builderId, { steps, all = false } = {}, deps = {}) {
97
+ const pool = poolFor(deps);
98
+ if (all || steps == null || (Array.isArray(steps) && steps.length === 0)) {
99
+ await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id = $1', [builderId]);
100
+ } else {
101
+ const c = checkSteps(steps);
102
+ if (!c.ok) return { ok: false, reason: 'unknown_step', unknown: c.unknown };
103
+ await pool.query('DELETE FROM onboarding_howto_reads WHERE builder_id = $1 AND step_id = ANY($2::text[])', [builderId, c.ids]);
104
+ }
105
+ return { ok: true, state: await readState(builderId, deps) };
106
+ }
107
+
108
+ module.exports = { HOWS, checkSteps, shapeState, readState, markRead, reset };
@@ -0,0 +1,33 @@
1
+ -- onboarding_001_howto_reads.sql — which how-to steps each person has read (task 1004634).
2
+ --
3
+ -- The in-session how-to guide (modules/onboarding/howto-guide.js, task 1004627) is
4
+ -- walked one step at a time, and a step once read must never be shown again. The
5
+ -- owner's call (2026-10-08): the read state is PER PERSON and lives in Bongos, so it
6
+ -- follows them across computers and AI tools.
7
+ --
8
+ -- One row per (builder, step) that has been read. Unread = no row, so a step added to
9
+ -- the guide later is unread for everyone with no backfill, and a reset is a DELETE.
10
+ -- `step_id` is the guide's stable id; it is not constrained here because the guide is
11
+ -- code — the API refuses an id the guide does not name, and a row for a step the guide
12
+ -- later retires is simply ignored on read. `how` records whether the person read the
13
+ -- step or skipped it (task 1004636's "skip the tutorial"), so progress can tell them
14
+ -- apart.
15
+ --
16
+ -- ADDITIVE, module-owned (ADR 0083 §Decision #5). Idempotent: safe to re-run.
17
+
18
+ BEGIN;
19
+
20
+ CREATE TABLE IF NOT EXISTS onboarding_howto_reads (
21
+ builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
22
+ step_id text NOT NULL,
23
+ how text NOT NULL DEFAULT 'read',
24
+ read_at timestamptz NOT NULL DEFAULT now(),
25
+ PRIMARY KEY (builder_id, step_id),
26
+ CONSTRAINT onboarding_howto_reads_how_chk CHECK (how IN ('read', 'skipped')),
27
+ CONSTRAINT onboarding_howto_reads_step_chk CHECK (step_id ~ '^[a-z][a-z0-9_]{2,39}$')
28
+ );
29
+
30
+ INSERT INTO schema_migrations (version) VALUES ('onboarding_001_howto_reads')
31
+ ON CONFLICT (version) DO NOTHING;
32
+
33
+ COMMIT;
@@ -6,7 +6,7 @@
6
6
  "coreVersion": "^1.19.532",
7
7
  "default": true,
8
8
  "maintenance": { "status": "core-maintained" },
9
- "contributes": { "routes": ["onboarding"] },
9
+ "contributes": { "routes": ["onboarding"], "migrations": true },
10
10
  "provides": ["onboarding"],
11
11
  "consumes": []
12
12
  }
@@ -37,6 +37,7 @@ const buildAccessRequestsRouter = require('./access-requests');
37
37
  const foundingBuilders = require('../founding-builders');
38
38
  const genesis = require('../genesis-home');
39
39
  const foundingBirth = require('../founding-birth');
40
+ const howtoState = require('../howto-state');
40
41
 
41
42
  // Register the `onboarding` kernel PORT. See the header + port.js for the full
42
43
  // consumer list and the graceful-degradation contract.
@@ -58,15 +59,61 @@ module.exports = function buildOnboardingRouter() {
58
59
  // ranks page and the home's founding wall; an empty list on a project that was
59
60
  // never founded, so those surfaces render exactly as before. Signed-in, like the
60
61
  // hall pages that read it; it carries nothing the roster does not already show.
61
- router.get('/founding/builders', api.requireBuilder, async (req, res) => {
62
+ router.get('/founding/builders', api.requireBuilder, async (_req, res) => {
62
63
  const builders = await foundingBuilders.foundingBuildersFor();
63
64
  res.set('Cache-Control', 'private, max-age=60');
64
65
  res.json({ builders });
65
66
  });
66
67
  mountGenesisRoutes(router);
68
+ mountHowtoRoutes(router);
67
69
  return router;
68
70
  };
69
71
 
72
+ // ── the how-to read state (task 1004634) ─────────────────────────────────────
73
+ // Each person's own read/unread marks for the in-session how-to guide. Self-scoped:
74
+ // every route reads and writes only req.builder's rows, so any signed-in builder may
75
+ // call them (the /me/wandering posture). The AI marks a step read after walking the
76
+ // person through it (task 1004635); reopen / continue / skip use the same three calls
77
+ // (task 1004636).
78
+ const HOWTO_REFUSALS = { unknown_step: 422, no_steps: 422, bad_how: 422 };
79
+
80
+ function mountHowtoRoutes(router) {
81
+ const handle = (fn) => async (req, res) => {
82
+ try {
83
+ const r = await fn(req);
84
+ if (r && r.ok === false) {
85
+ return res.status(HOWTO_REFUSALS[r.reason] || 422).json({
86
+ error: { code: r.reason, message: r.unknown ? `not a how-to step: ${r.unknown.join(', ')}` : r.reason },
87
+ });
88
+ }
89
+ res.set('Cache-Control', 'no-store');
90
+ res.json(r && r.state ? r.state : r);
91
+ } catch (err) {
92
+ api.logger('onboarding').error('[onboarding] howto', err);
93
+ res.status(500).json({ error: { code: 'howto_failed', message: 'internal error' } });
94
+ }
95
+ };
96
+ // rank: any-builder — the caller's own read state.
97
+ router.get('/me/howto', api.requireBuilder, handle((req) => howtoState.readState(req.builder.id)));
98
+ // rank: any-builder — marks the caller's own steps read ({ steps }) or skips the guide ({ all: true, how: 'skipped' }).
99
+ router.post('/me/howto/read', api.requireBuilder, (req, res, next) => {
100
+ if (api.validateOrRespond(req, res, {
101
+ steps: { type: 'array', maxItems: 100 },
102
+ all: { type: 'boolean' },
103
+ how: { type: 'string', maxLength: 16 },
104
+ })) return;
105
+ next();
106
+ }, handle((req) => howtoState.markRead(req.builder.id, req.body || {})));
107
+ // rank: any-builder — forgets the caller's own marks ({ steps }, or everything when none are named).
108
+ router.post('/me/howto/reset', api.requireBuilder, (req, res, next) => {
109
+ if (api.validateOrRespond(req, res, {
110
+ steps: { type: 'array', maxItems: 100 },
111
+ all: { type: 'boolean' },
112
+ })) return;
113
+ next();
114
+ }, handle((req) => howtoState.reset(req.builder.id, req.body || {})));
115
+ }
116
+
70
117
  // ── the genesis home (task 1004423) ──────────────────────────────────────────
71
118
  // Read by any signed-in builder of a founding project (everyone sees where genesis
72
119
  // is); every write is the project owner's, behind the same atom that guards the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.127",
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.127",
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.127",
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",
@@ -9760,5 +9760,21 @@
9760
9760
  "id": "1004414",
9761
9761
  "text": "Each hosted project now has a memory and CPU limit, so one runaway project can no longer slow down every other project or the hub."
9762
9762
  }
9763
+ ],
9764
+ "1.21.128": [
9765
+ {
9766
+ "id": "1004634",
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
+ }
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
+ }
9763
9779
  ]
9764
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.127'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');