@bongos/core 1.25.12 → 1.25.14

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.
Files changed (55) hide show
  1. package/.bongos-core.json +106 -51
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +13 -4
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0328-a-hosted-projects-authority-is-its-membership-row-not-its-owner-column.md +1 -1
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +124 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +198 -11
  11. package/docs/api-reference.md +7 -4
  12. package/docs/copy-inventory.md +28 -34
  13. package/docs/copy-registry.json +41 -125
  14. package/docs/file-map.md +1 -1
  15. package/docs/module-api-changelog.md +4 -0
  16. package/docs/page-inventory.json +1 -0
  17. package/docs/page-readings.json +3 -3
  18. package/modules/front-door-guide/claude.js +11 -7
  19. package/modules/front-door-guide/context.js +54 -0
  20. package/modules/front-door-guide/db.js +79 -6
  21. package/modules/front-door-guide/guide.js +18 -3
  22. package/modules/front-door-guide/migrations/front_door_guide_002_plan_and_account.sql +37 -0
  23. package/modules/front-door-guide/migrations/front_door_guide_003_starting_point.sql +12 -0
  24. package/modules/front-door-guide/module.json +7 -3
  25. package/modules/front-door-guide/plan.js +102 -0
  26. package/modules/front-door-guide/pollers/retention-sweep.js +67 -0
  27. package/modules/front-door-guide/routes/front-door-guide-plan.js +145 -0
  28. package/modules/front-door-guide/routes/front-door-guide.js +36 -33
  29. package/modules/front-door-guide/settings.js +4 -0
  30. package/modules/front-door-guide/starting-point.js +55 -0
  31. package/modules/hall-ui/public/genesis-home.js +29 -2
  32. package/modules/hall-ui/public/goals-page.js +3 -29
  33. package/modules/hall-ui/public/index.html +2 -1
  34. package/modules/hall-ui/public/task-lib.js +45 -0
  35. package/modules/hall-ui/public/work.js +3 -47
  36. package/modules/lifecycle/kickoff-seed.js +9 -5
  37. package/modules/onboarding/genesis-home.js +1 -1
  38. package/modules/provisioning/module.json +2 -1
  39. package/modules/provisioning/seams.js +10 -0
  40. package/package-lock.json +306 -3
  41. package/package.json +4 -2
  42. package/release-notes.json +24 -0
  43. package/scripts/bongos/package-core.js +4 -2
  44. package/scripts/bongos/run-unit-tests.js +4 -0
  45. package/src/bongos/module-scope-map.js +2 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/cutover_codemod.mjs +1 -1
  48. package/tests/front_door_guide_db.mjs +115 -0
  49. package/tests/front_door_guide_existing.mjs +238 -0
  50. package/tests/front_door_guide_plan.mjs +344 -0
  51. package/tests/genesis_home.mjs +80 -2
  52. package/tests/package_core.mjs +11 -7
  53. package/tests/pruned_lockfile_integrity.mjs +4 -2
  54. package/tests/status_page_no_dead_uptime.mjs +1 -1
  55. package/tests/task_lib.mjs +46 -0
@@ -0,0 +1,145 @@
1
+ // modules/front-door-guide/routes/front-door-guide-plan.js — where the start-a-project chat
2
+ // ends (task 1005273): the visitor's plan, then the chat and plan moving onto the account
3
+ // they sign up as.
4
+ //
5
+ // POST /front-door-guide/conversations/:id/plan write (or re-read) the plan { token, website? }
6
+ // POST /front-door-guide/conversations/:id/attach put the chat on my account { token }
7
+ // GET /front-door-guide/mine my newest chat and its plan
8
+ //
9
+ // The plan is ONE structured-output call per chat (a JSON schema, never parsed prose),
10
+ // asked once the visitor has said something; a second request re-reads the stored plan
11
+ // and spends nothing. Its modules come only from the live catalog (context.moduleCatalog,
12
+ // the project-creation picker's own list). It is held to the site's daily and monthly
13
+ // budget like a reply, and its cost lands in the same ledger; it is not one of the
14
+ // visitor's messages.
15
+ //
16
+ // Attaching is the sign-up hand-off's call (task 1005274): right after the visitor signs
17
+ // in, the page that still holds the chat's token sends it here. A chat already on another
18
+ // account is never moved. A chat nobody attaches is deleted by the retention sweep
19
+ // (pollers/retention-sweep.js) after BONGOS_FRONT_DOOR_GUIDE_RETENTION_DAYS.
20
+
21
+ 'use strict';
22
+
23
+ const express = require('express');
24
+ const api = require('../../../src/module-api');
25
+ const G = require('../guide');
26
+ const P = require('../plan');
27
+ const db = require('../db');
28
+ const claude = require('../claude');
29
+ const { readSettings } = require('../settings');
30
+ const L = require('../limits');
31
+ const C = require('../context');
32
+
33
+ const log = api.logger('front-door-guide');
34
+ const ID_RE = /^\d{1,18}$/;
35
+ const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
36
+
37
+ // The words for "write my plan now", sent as the visitor's last turn of the plan call.
38
+ const PLAN_ASK = 'Please write my first plan now, from what I have told you.';
39
+
40
+ function planAnswer(conv, settings) {
41
+ return {
42
+ ok: true, mode: 'plan', plan: conv.plan, next: P.NEXT_STEP,
43
+ message: G.END_REASONS.planned,
44
+ saved: G.savedNotice(settings.retentionDays),
45
+ conversation: G.conversationView(conv, settings),
46
+ };
47
+ }
48
+
49
+ // What a signed-in builder sees of their chat: never the visitor hash.
50
+ function mineView(conv, settings) {
51
+ return {
52
+ id: String(conv.id),
53
+ starting_point: conv.starting_point || null,
54
+ status: conv.status,
55
+ end_reason: conv.end_reason || null,
56
+ attached_at: conv.attached_at || null,
57
+ transcript: G.transcriptView(conv.messages),
58
+ plan: conv.plan || null,
59
+ ...(conv.plan ? { next: P.NEXT_STEP } : {}),
60
+ messages_used: Number(conv.visitor_messages) || 0,
61
+ max_message_chars: settings.maxMessageChars,
62
+ };
63
+ }
64
+
65
+ module.exports = function buildFrontDoorGuidePlanRouter() {
66
+ const router = express.Router();
67
+ let current = readSettings();
68
+ const fresh = (req, res, next) => { current = readSettings(); next(); };
69
+ const planLimit = L.makeLimiter({ windowMs: 60_000, max: () => current.messagesPerMinute, scope: 'front-door-guide-plan' });
70
+
71
+ // rank: public — the visitor has no account yet.
72
+ router.post('/front-door-guide/conversations/:id/plan', fresh, planLimit, api.asyncHandler('POST /front-door-guide/conversations/:id/plan', async (req, res) => {
73
+ if (api.validateOrRespond(req, res, {
74
+ token: { required: true, type: 'string', maxLength: 64 },
75
+ website: { type: 'string', maxLength: 500 },
76
+ })) return;
77
+ const s = current;
78
+ const body = req.body || {};
79
+ if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(body.token)) return res.status(400).json({ ok: false, error: 'bad_request' });
80
+ const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
81
+ if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
82
+ if (conv.plan) return res.json(planAnswer(conv, s));
83
+ if (!(Number(conv.visitor_messages) > 0)) return res.status(409).json({ ok: false, error: 'too_early' });
84
+ if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
85
+ return res.json(G.formAnswer('check_failed'));
86
+ }
87
+ if (!s.apiKey) return res.json(G.formAnswer('no_key'));
88
+
89
+ const catalog = await C.moduleCatalog();
90
+ const schema = P.planSchema(catalog.optional.map((m) => m.key));
91
+ const system = `${G.systemPrompt(C.productName(), conv.starting_point)}\n\n${P.planInstructions(catalog, conv.starting_point)}`;
92
+ const messages = [...(conv.messages || []), { role: 'user', content: PLAN_ASK }];
93
+ const estimate = G.estimateMicro({ model: s.model, system: system + JSON.stringify(schema), messages, maxReplyTokens: s.maxPlanTokens });
94
+ const budget = await C.siteBudgetReason(s, estimate);
95
+ if (budget) return res.json(G.formAnswer(budget));
96
+
97
+ const visitor = conv.visitor_hash;
98
+ const release = C.reservations.take(visitor, estimate);
99
+ if (!release) return res.status(429).json({ ok: false, error: 'busy', retry_after_seconds: 2 });
100
+ let result;
101
+ try {
102
+ result = await claude.askGuide({ client: claude.createClient(s.apiKey), settings: s, system, messages, schema });
103
+ } finally {
104
+ release();
105
+ }
106
+ if (!result.ok) {
107
+ log.warn({ conversation_id: String(conv.id), error: result.error }, 'front-door-guide: plan call failed');
108
+ return res.json(G.formAnswer('unavailable'));
109
+ }
110
+ const u = result.usage || {};
111
+ const call = {
112
+ visitorHash: visitor, model: result.model, costMicro: G.costMicro(result.model, u), stopReason: result.stopReason || null,
113
+ inputTokens: Number(u.input_tokens) || 0, outputTokens: Number(u.output_tokens) || 0,
114
+ cacheReadTokens: Number(u.cache_read_input_tokens) || 0, cacheWriteTokens: Number(u.cache_creation_input_tokens) || 0,
115
+ };
116
+ // A refused or cut-off reply may not match the schema: it costs what it cost, and the
117
+ // visitor is offered the form rather than half a plan.
118
+ const plan = result.stopReason === 'end_turn' ? P.parsePlan(G.replyText(result.content), catalog, conv.starting_point) : null;
119
+ if (!plan) {
120
+ await db.recordPlanSpend(conv.id, call);
121
+ log.warn({ conversation_id: String(conv.id), stop_reason: result.stopReason }, 'front-door-guide: no usable plan');
122
+ return res.json(G.formAnswer('unavailable'));
123
+ }
124
+ const saved = await db.savePlan(conv.id, plan, call);
125
+ res.json(planAnswer(saved || { ...conv, plan }, s));
126
+ }));
127
+
128
+ // rank: builder — the visitor has just signed in; the chat moves onto their account.
129
+ router.post('/front-door-guide/conversations/:id/attach', api.requireBuilder, fresh, api.asyncHandler('POST /front-door-guide/conversations/:id/attach', async (req, res) => {
130
+ if (api.validateOrRespond(req, res, { token: { required: true, type: 'string', maxLength: 64 } })) return;
131
+ const token = req.body.token;
132
+ if (!ID_RE.test(String(req.params.id)) || !TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'bad_request' });
133
+ const conv = await db.attachToBuilder(req.params.id, L.tokenHash(token), req.builder.id);
134
+ if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
135
+ res.json({ ok: true, conversation: mineView(conv, current) });
136
+ }));
137
+
138
+ // rank: builder — my newest chat and plan (the creation wizard's pre-fill reads it).
139
+ router.get('/front-door-guide/mine', api.requireBuilder, fresh, api.asyncHandler('GET /front-door-guide/mine', async (req, res) => {
140
+ const conv = await db.latestForBuilder(req.builder.id);
141
+ res.json({ ok: true, conversation: conv ? mineView(conv, current) : null });
142
+ }));
143
+
144
+ return router;
145
+ };
@@ -7,6 +7,9 @@
7
7
  // POST /front-door-guide/conversations/:id/messages send one message { token, text, website? }
8
8
  // GET /front-door-guide/conversations/:id read it back header x-guide-token
9
9
  //
10
+ // The plan, attaching a chat to an account at sign-up, and "my plan" are in
11
+ // routes/front-door-guide-plan.js (task 1005273).
12
+ //
10
13
  // rank: public — every route. The visitor has no account yet; that is the point. What
11
14
  // stands in for one: per-visitor rate limits, a bot check, a token per conversation, and
12
15
  // every money cap enforced here before a call is made (the per-visitor message count and
@@ -25,37 +28,17 @@ const db = require('../db');
25
28
  const claude = require('../claude');
26
29
  const { readSettings } = require('../settings');
27
30
  const L = require('../limits');
31
+ const C = require('../context');
32
+ const P = require('../plan');
33
+ const SP = require('../starting-point');
28
34
 
29
35
  const log = api.logger('front-door-guide');
30
36
  const ID_RE = /^\d{1,18}$/;
31
37
  const TOKEN_RE = /^[A-Za-z0-9_-]{16,64}$/;
32
38
 
33
- function salt() {
34
- let s;
35
- try { s = api.resolveEnv('FRONT_DOOR_GUIDE_SALT'); } catch (_) { s = null; }
36
- if (s) return s;
37
- let name = 'bongos';
38
- try { name = api.instanceDbName() || name; } catch (_) { /* the default salt still separates visitors */ }
39
- return `front-door-guide:${name}`;
40
- }
41
-
42
- function productName() {
43
- try { return api.branding().identity.productName || 'Cloud Bongos'; } catch (_) { return 'Cloud Bongos'; }
44
- }
45
-
46
- // Can the site afford a call of `extraMicro` right now? → null, or the form reason.
47
- async function siteBudgetReason(settings, extraMicro, reservations) {
48
- const pending = reservations.siteMicro();
49
- // Spent is spent (>=), and a call must also fit in what is left (>).
50
- const over = (spent, cap) => spent + pending >= cap || spent + pending + extraMicro > cap;
51
- if (over(await db.siteSpendMicro('day'), settings.dailyBudgetMicro)) return 'daily_budget';
52
- if (over(await db.siteSpendMicro('month'), settings.monthlyBudgetMicro)) return 'monthly_budget';
53
- return null;
54
- }
55
-
56
39
  module.exports = function buildFrontDoorGuideRouter() {
57
40
  const router = express.Router();
58
- const reservations = L.makeReservations();
41
+ const { reservations, siteBudgetReason, salt, productName } = C;
59
42
  let current = readSettings();
60
43
  const knobs = () => current;
61
44
 
@@ -68,7 +51,7 @@ module.exports = function buildFrontDoorGuideRouter() {
68
51
  // rank: public — the landing asks before it offers the chat.
69
52
  router.get('/front-door-guide/status', fresh, statusLimit, api.asyncHandler('GET /front-door-guide/status', async (req, res) => {
70
53
  const s = knobs();
71
- const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0, reservations);
54
+ const reason = !s.apiKey ? 'no_key' : await siteBudgetReason(s, 0);
72
55
  res.json({
73
56
  ok: true,
74
57
  mode: reason ? 'form' : 'chat',
@@ -79,24 +62,30 @@ module.exports = function buildFrontDoorGuideRouter() {
79
62
 
80
63
  // rank: public — start a chat. Answers the greeting and the conversation's token.
81
64
  router.post('/front-door-guide/conversations', fresh, startLimit, api.asyncHandler('POST /front-door-guide/conversations', async (req, res) => {
82
- if (api.validateOrRespond(req, res, { website: { type: 'string', maxLength: 500 } })) return;
65
+ if (api.validateOrRespond(req, res, {
66
+ website: { type: 'string', maxLength: 500 },
67
+ starting_point: { type: 'string', enum: SP.STARTING_POINTS },
68
+ })) return;
83
69
  const s = knobs();
84
70
  const body = req.body || {};
85
71
  if (G.botCheck({ body, userAgent: req.get('user-agent'), now: Date.now(), minFirstMessageMs: s.minFirstMessageMs })) {
86
72
  return res.json(G.formAnswer('check_failed'));
87
73
  }
88
74
  if (!s.apiKey) return res.json(G.formAnswer('no_key'));
89
- const budget = await siteBudgetReason(s, 0, reservations);
75
+ const budget = await siteBudgetReason(s, 0);
90
76
  if (budget) return res.json(G.formAnswer(budget));
91
77
  const visitor = L.visitorHash(L.clientIp(req), salt());
92
78
  const used = await db.visitorUsage(visitor);
93
79
  if (used.messages >= s.maxMessages || used.spendMicro >= s.visitorCapMicro) return res.json(G.formAnswer('visitor_limit'));
94
80
  const token = L.newToken();
95
- const conv = await db.createConversation(L.tokenHash(token), visitor);
81
+ let conv = await db.createConversation(L.tokenHash(token), visitor);
82
+ if (SP.isStartingPoint(body.starting_point)) conv = (await db.setStartingPoint(conv.id, body.starting_point)) || conv;
96
83
  res.status(201).json({
97
84
  ok: true, mode: 'chat',
98
85
  conversation: { ...G.conversationView(conv, s), token },
99
- greeting: G.GREETING,
86
+ greeting: SP.greetingFor(conv.starting_point),
87
+ starting_point_options: SP.OPTIONS,
88
+ saved: G.savedNotice(s.retentionDays),
100
89
  });
101
90
  }));
102
91
 
@@ -108,6 +97,7 @@ module.exports = function buildFrontDoorGuideRouter() {
108
97
  token: { required: true, type: 'string', maxLength: 64 },
109
98
  text: { required: true, type: 'string', maxLength: 100_000 },
110
99
  website: { type: 'string', maxLength: 500 },
100
+ starting_point: { type: 'string', enum: SP.STARTING_POINTS },
111
101
  })) return;
112
102
  const s = knobs();
113
103
  const body = req.body || {};
@@ -122,7 +112,10 @@ module.exports = function buildFrontDoorGuideRouter() {
122
112
  const conv = await db.getConversation(req.params.id, L.tokenHash(body.token));
123
113
  if (!conv) return res.status(404).json({ ok: false, error: 'not_found' });
124
114
  const view = () => G.conversationView(conv, s);
125
- const ended = (reason) => ({ ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap, conversation: view() });
115
+ const ended = (reason) => ({
116
+ ok: true, mode: 'ended', reason, message: G.END_REASONS[reason] || G.END_REASONS.message_cap,
117
+ next_step: reason === 'planned' ? 'sign_up' : 'plan', conversation: view(),
118
+ });
126
119
  if (conv.status === 'ended') return res.json(ended(conv.end_reason));
127
120
 
128
121
  const bot = G.botCheck({
@@ -141,14 +134,20 @@ module.exports = function buildFrontDoorGuideRouter() {
141
134
  return res.json(ended('message_cap'));
142
135
  }
143
136
 
144
- const system = G.systemPrompt(productName());
137
+ // A starting point sent with the message (the visitor pressed one of the buttons)
138
+ // is stored first, so this reply already follows it.
139
+ if (SP.isStartingPoint(body.starting_point) && conv.starting_point !== body.starting_point) {
140
+ Object.assign(conv, { starting_point: body.starting_point });
141
+ await db.setStartingPoint(conv.id, body.starting_point);
142
+ }
143
+ const system = G.systemPrompt(productName(), conv.starting_point);
145
144
  const messages = [...(conv.messages || []), { role: 'user', content: text }];
146
145
  const estimate = G.estimateMicro({ model: s.model, system, messages, maxReplyTokens: s.maxReplyTokens });
147
146
  if (used.spendMicro + estimate > s.visitorCapMicro) {
148
147
  await db.endConversation(conv.id, 'visitor_budget');
149
148
  return res.json(ended('visitor_budget'));
150
149
  }
151
- const budget = await siteBudgetReason(s, estimate, reservations);
150
+ const budget = await siteBudgetReason(s, estimate);
152
151
  if (budget) return res.json(G.formAnswer(budget));
153
152
 
154
153
  const release = reservations.take(visitor, estimate);
@@ -182,7 +181,7 @@ module.exports = function buildFrontDoorGuideRouter() {
182
181
  res.json({
183
182
  ok: true,
184
183
  mode: endReason ? 'ended' : 'chat',
185
- ...(endReason ? { reason: endReason, message: G.END_REASONS[endReason] } : {}),
184
+ ...(endReason ? { reason: endReason, message: G.END_REASONS[endReason], next_step: 'plan' } : {}),
186
185
  ...(refused ? { declined: true } : {}),
187
186
  reply: { text: reply },
188
187
  conversation: G.conversationView(saved || { ...conv, visitor_messages: Number(conv.visitor_messages) + 1 }, s),
@@ -199,6 +198,10 @@ module.exports = function buildFrontDoorGuideRouter() {
199
198
  ok: true,
200
199
  conversation: { ...G.conversationView(conv, knobs()), status: conv.status, end_reason: conv.end_reason || null },
201
200
  transcript: G.transcriptView(conv.messages),
201
+ plan: conv.plan || null,
202
+ ...(conv.plan ? { next: P.NEXT_STEP } : {}),
203
+ saved: G.savedNotice(knobs().retentionDays),
204
+ attached: conv.builder_id != null,
202
205
  });
203
206
  }));
204
207
 
@@ -25,6 +25,8 @@ const DEFAULTS = Object.freeze({
25
25
  STARTS_PER_HOUR: 5, // new conversations per visitor per hour
26
26
  MESSAGES_PER_MINUTE: 6, // messages per visitor per minute
27
27
  MIN_FIRST_MESSAGE_MS: 1500, // a person takes longer than this to type a first line
28
+ MAX_PLAN_TOKENS: 4000, // the plan, thinking included
29
+ RETENTION_DAYS: 30, // a chat nobody signed up for is deleted after this
28
30
  });
29
31
 
30
32
  const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
@@ -62,6 +64,8 @@ function readSettings({ resolve = defaultResolve } = {}) {
62
64
  startsPerHour: intKnob(read, 'STARTS_PER_HOUR'),
63
65
  messagesPerMinute: intKnob(read, 'MESSAGES_PER_MINUTE'),
64
66
  minFirstMessageMs: intKnob(read, 'MIN_FIRST_MESSAGE_MS'),
67
+ maxPlanTokens: intKnob(read, 'MAX_PLAN_TOKENS'),
68
+ retentionDays: intKnob(read, 'RETENTION_DAYS'),
65
69
  };
66
70
  }
67
71
 
@@ -0,0 +1,55 @@
1
+ // modules/front-door-guide/starting-point.js — fresh or existing (task 1005279). The
2
+ // guide asks early whether the visitor is starting from nothing or already has something
3
+ // (code, documents, artwork), and for an existing project it asks what they have and what
4
+ // they want to build next, then plans from their DESCRIPTION.
5
+ //
6
+ // THE OWNER'S HARD RULE: the platform never reads a visitor's repository or files on its
7
+ // own AI bill. So this branch adds no upload, no fetch and no link-reading path: the call
8
+ // carries text only and no tools (claude.buildRequest), and the guide is told it cannot
9
+ // open anything and must ask for a description instead. The deep read of their work is
10
+ // done later by their own AI, after sign-up (task 1005280).
11
+ //
12
+ // Pure.
13
+
14
+ 'use strict';
15
+
16
+ const STARTING_POINTS = Object.freeze(['fresh', 'existing']);
17
+
18
+ // The two answers, for the front end's buttons under the greeting.
19
+ const OPTIONS = Object.freeze([
20
+ Object.freeze({ value: 'fresh', label: 'Starting fresh' }),
21
+ Object.freeze({ value: 'existing', label: 'I already have something' }),
22
+ ]);
23
+
24
+ const GREETINGS = Object.freeze({
25
+ unknown: 'Hi! Are you starting a new project from scratch, or do you already have something, like code, documents or artwork? Either way, tell me what you would like to build.',
26
+ fresh: 'Hi! Tell me what you would like to build and what you want it to do for you. A sentence or two is plenty.',
27
+ existing: 'Hi! Tell me about what you already have (code, documents, artwork, and how far along it is), and what you want to build next. Describe it in your own words: I cannot open files or links.',
28
+ });
29
+
30
+ const isStartingPoint = (v) => STARTING_POINTS.includes(v);
31
+
32
+ function greetingFor(startingPoint) {
33
+ return GREETINGS[isStartingPoint(startingPoint) ? startingPoint : 'unknown'];
34
+ }
35
+
36
+ // The part of the guide's instructions that depends on the starting point. Every version
37
+ // carries the no-reading rule, because a visitor can mention existing work at any time.
38
+ function instructionsFor(startingPoint) {
39
+ const noReading = 'You cannot see, open or read any file, repository, code, document, image or link, and nothing the visitor has made is sent to you. Never ask them to paste code, upload files or share links. If they paste a link, say you cannot open links and ask them to describe it. Plan only from what they tell you in their own words; after sign-up, their own AI tools can look at their existing work in detail.';
40
+ if (startingPoint === 'existing') {
41
+ return [
42
+ 'This visitor already has something. Early on, ask what they have (code, documents, artwork or something else, and how far along it is) and what they want to build next.',
43
+ noReading,
44
+ ].join('\n');
45
+ }
46
+ if (startingPoint === 'fresh') {
47
+ return ['This visitor is starting fresh, from an idea.', noReading].join('\n');
48
+ }
49
+ return [
50
+ 'You do not yet know whether this visitor is starting fresh or already has something. If it is not clear from what they say, ask early, in one short question. If they already have something, ask what they have (code, documents, artwork, how far along) and what they want to build next.',
51
+ noReading,
52
+ ].join('\n');
53
+ }
54
+
55
+ module.exports = { STARTING_POINTS, OPTIONS, GREETINGS, isStartingPoint, greetingFor, instructionsFor };
@@ -188,6 +188,30 @@
188
188
  }).join('') + '</ol>';
189
189
  }
190
190
 
191
+ // task 1005109: Copy prompt on each task still to do, so the founder pastes it into
192
+ // Claude without leaving Genesis. The Tasks board's own button (work.js), and only that
193
+ // one verb: the owner declined a Claim button here. A finished task offers nothing.
194
+ function copyButtonHtml(t) {
195
+ if (t.finished) return '';
196
+ return '<button class="task__copy" type="button" data-genesis-copy="' + esc(t.id) + '"'
197
+ + ' title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ' + esc(t.id) + '">'
198
+ + '<span class="task__copy-icon" aria-hidden="true">📋</span></button>';
199
+ }
200
+
201
+ // The click: the stage task (with the description the genesis feed carries) handed
202
+ // to the hall's one Copy prompt (task-lib.js copyTaskPrompt). No wording of our own.
203
+ function copyPrompt(btn, state) {
204
+ var id = btn.getAttribute('data-genesis-copy');
205
+ var g = state.genesis;
206
+ var lib = window.OTBTaskLib;
207
+ var t = null;
208
+ (g ? g.stages : []).forEach(function (s) {
209
+ (s.tasks || []).forEach(function (x) { if (String(x.id) === String(id)) t = x; });
210
+ });
211
+ if (!t || !lib || typeof lib.copyTaskPrompt !== 'function') return Promise.resolve(false);
212
+ return lib.copyTaskPrompt(t, btn, toast);
213
+ }
214
+
191
215
  function nowHtml(g, viewerId) {
192
216
  var s = g.stages.find(function (x) { return x.state === 'now'; });
193
217
  if (!s) return '';
@@ -195,7 +219,8 @@
195
219
  ? '<ul class="genesis__tasks">' + s.tasks.map(function (t) {
196
220
  return '<li class="genesis__task' + (t.finished ? ' is-finished' : '') + '">'
197
221
  + '<a class="genesis__task-t" href="/task/' + encodeURIComponent(t.id) + '">' + esc(t.title) + '</a>'
198
- + window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' }) + '</li>';
222
+ + window.OTB.statusPill(t.status, { small: true, extraClass: 'genesis__task-st' })
223
+ + copyButtonHtml(t) + '</li>';
199
224
  }).join('') + '</ul>'
200
225
  : '<p class="genesis__note">No tasks for this stage yet.</p>';
201
226
  return '<section class="genesis__now" aria-labelledby="genesis-now-h">'
@@ -356,7 +381,9 @@
356
381
  host.addEventListener('click', function (e) {
357
382
  var t = e.target.closest('button');
358
383
  if (!t || !host.contains(t)) return;
359
- if (t.hasAttribute('data-genesis-plan')) {
384
+ if (t.hasAttribute('data-genesis-copy')) {
385
+ copyPrompt(t, state);
386
+ } else if (t.hasAttribute('data-genesis-plan')) {
360
387
  // The government picked here is filed as a charter amendment with the plan
361
388
  // (task 1004656). When the founder's one yes decides it, saving the plan is
362
389
  // that yes too, the same one-click fast-track the stage closes take.
@@ -390,20 +390,8 @@
390
390
  // goal-title match — a single goal makes that redundant).
391
391
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
392
392
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
393
- // Clipboard write that says whether it worked (the bulk prompts report it).
394
- async function copyText(text) {
395
- try { await navigator.clipboard.writeText(text); return true; }
396
- catch (_) {
397
- try {
398
- const ta = document.createElement('textarea');
399
- ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
400
- document.body.appendChild(ta); ta.select();
401
- const ok = document.execCommand('copy');
402
- ta.remove();
403
- return ok;
404
- } catch (_e) { return false; }
405
- }
406
- }
393
+ // Clipboard write that says whether it worked (the bulk prompts report it): task-lib's one copy.
394
+ const copyText = (text) => window.OTBTaskLib.copyText(text);
407
395
  // task 1004587 (ADR 0366 D4) — the optional "Plan with Claude" hand-off; every planning form works without it.
408
396
  async function planWithClaude() {
409
397
  const d = state.details.get(String(state.current));
@@ -419,21 +407,7 @@
419
407
  async function copyPromptForTask(taskId, btn) {
420
408
  const t = state.work.todo.find((x) => String(x.id) === String(taskId));
421
409
  if (!t) return;
422
- const prompt = buildPromptForTask(t);
423
- const icon = btn.querySelector('.task__copy-icon');
424
- try {
425
- await navigator.clipboard.writeText(prompt);
426
- if (icon) icon.textContent = '✓';
427
- setTimeout(() => { if (icon) icon.textContent = '📋'; }, 1600);
428
- toast(`Prompt for #${taskId} copied to your clipboard.`);
429
- } catch (err) {
430
- try {
431
- const ta = document.createElement('textarea');
432
- ta.value = prompt; ta.style.position = 'fixed'; ta.style.opacity = '0';
433
- document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
434
- toast(`Prompt for #${taskId} copied.`);
435
- } catch (_) { toast('Could not copy to the clipboard. Try again.'); }
436
- }
410
+ await window.OTBTaskLib.copyTaskPrompt(t, btn, toast);
437
411
  }
438
412
 
439
413
  // The claimable card (ported from work.js renderClaimable, work.js:345-394).
@@ -125,7 +125,8 @@
125
125
  <script src="/builders/founding-mark.js?v=2026-09-30-founding" defer></script>
126
126
  <script src="/builders/hall-widgets.js?v=2026-11-04-reading-room" defer></script>
127
127
  <!-- The genesis home (task 1004423): a Home banner card that renders only in founding mode. -->
128
- <script src="/builders/genesis-home.js?v=2026-10-01-genesis" defer></script>
128
+ <script src="/builders/task-lib.js" defer></script>
129
+ <script src="/builders/genesis-home.js?v=2026-11-13-copy" defer></script>
129
130
  <!-- task 1005054: the project city — the drawing (pure) and the widget — in the
130
131
  home-stats zone; the Goals card (goals.js) left Home with it. -->
131
132
  <script src="/builders/city-draw.js" defer></script>
@@ -205,11 +205,56 @@
205
205
  return (optionValues || []).includes(focusId) ? focusId : null;
206
206
  }
207
207
 
208
+ // copyText(text) → Promise<boolean>: the hall's one clipboard write. The async
209
+ // clipboard first, then the hidden-textarea fallback for an insecure context or a
210
+ // browser without it; says whether it worked so the caller can tell the builder.
211
+ async function copyText(text) {
212
+ try { await navigator.clipboard.writeText(text); return true; } catch (_) { /* fall back */ }
213
+ try {
214
+ const ta = document.createElement('textarea');
215
+ ta.value = text;
216
+ ta.setAttribute('readonly', '');
217
+ ta.style.position = 'fixed';
218
+ ta.style.opacity = '0';
219
+ document.body.appendChild(ta);
220
+ ta.select();
221
+ const ok = document.execCommand('copy');
222
+ ta.remove();
223
+ return ok;
224
+ } catch (_) { return false; }
225
+ }
226
+
227
+ // copyTaskPrompt(t, btn, toast) — the per-task "Copy prompt" button's whole click
228
+ // (task 1005109): buildPromptForTask's text onto the clipboard, the button's 📋 →
229
+ // ✓ flip for 1.6s, and the one toast wording. The Tasks board, a goal's to-do list
230
+ // and the Genesis stage list all call this, so the button behaves and reads the
231
+ // same everywhere. `t` needs its description, or the prompt says "(no description)".
232
+ async function copyTaskPrompt(t, btn, toast) {
233
+ const say = typeof toast === 'function' ? toast : () => {};
234
+ const ok = await copyText(buildPromptForTask(t));
235
+ if (!ok) { say('Could not copy to the clipboard. Try again.'); return false; }
236
+ if (btn) {
237
+ const icon = btn.querySelector && btn.querySelector('.task__copy-icon');
238
+ const label = btn.querySelector && btn.querySelector('.task__copy-label');
239
+ btn.setAttribute('data-copied', '1');
240
+ if (icon) icon.textContent = '✓';
241
+ if (label) label.textContent = 'Copied';
242
+ setTimeout(() => {
243
+ btn.setAttribute('data-copied', '0');
244
+ if (icon) icon.textContent = '📋';
245
+ if (label) label.textContent = 'Copy prompt';
246
+ }, 1600);
247
+ }
248
+ say(`Prompt for task ${t.id} copied to your clipboard.`);
249
+ return true;
250
+ }
251
+
208
252
  const api = {
209
253
  focusVersionDefault,
210
254
  msOf, taskActivityMs, STALE_CHOICES, isStale,
211
255
  priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml, deHashRefs,
212
256
  creditsPerMinute, buildPromptForTask, buildPlanPromptForGoal, friendlyBatchReason,
257
+ copyText, copyTaskPrompt,
213
258
  };
214
259
  if (typeof window !== 'undefined') window.OTBTaskLib = api;
215
260
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
@@ -641,54 +641,10 @@
641
641
  async function copyPromptForTask(taskId, btn) {
642
642
  const t = taskState.all.find((x) => String(x.id) === String(taskId));
643
643
  if (!t) return;
644
- const prompt = buildPromptForTask(t);
645
- const icon = btn.querySelector('.task__copy-icon');
646
- const label = btn.querySelector('.task__copy-label');
647
- try {
648
- await navigator.clipboard.writeText(prompt);
649
- btn.dataset.copied = '1';
650
- if (icon) icon.textContent = '✓';
651
- if (label) label.textContent = 'Copied';
652
- setTimeout(() => {
653
- btn.dataset.copied = '0';
654
- if (icon) icon.textContent = '📋';
655
- if (label) label.textContent = 'Copy prompt';
656
- }, 1600);
657
- toast(`Prompt for #${taskId} copied to your clipboard.`);
658
- } catch (err) {
659
- try {
660
- const ta = document.createElement('textarea');
661
- ta.value = prompt;
662
- ta.style.position = 'fixed';
663
- ta.style.opacity = '0';
664
- document.body.appendChild(ta);
665
- ta.select();
666
- document.execCommand('copy');
667
- ta.remove();
668
- toast(`Prompt for #${taskId} copied.`);
669
- } catch (_) {
670
- toast('Could not copy to the clipboard. Try again.');
671
- }
672
- }
673
- }
674
- async function copyText(text) {
675
- try { await navigator.clipboard.writeText(text); return true; }
676
- catch (_) {
677
- try {
678
- const ta = document.createElement('textarea');
679
- ta.value = text;
680
- ta.style.position = 'fixed';
681
- ta.style.opacity = '0';
682
- document.body.appendChild(ta);
683
- ta.select();
684
- const ok = document.execCommand('copy');
685
- ta.remove();
686
- return ok;
687
- } catch (_e) {
688
- return false;
689
- }
690
- }
644
+ await window.OTBTaskLib.copyTaskPrompt(t, btn, toast);
691
645
  }
646
+ // The hall's one clipboard write (task-lib.js), kept under this name for the ctx below.
647
+ const copyText = (text) => window.OTBTaskLib.copyText(text);
692
648
 
693
649
  // ---- bulk selection (task 1759) -------------------------------------------
694
650
 
@@ -112,20 +112,24 @@ async function seedGenesisStages({ builderId = null, existing = false } = {}, de
112
112
  return { seeded, versionId };
113
113
  }
114
114
 
115
- // genesisStageTasks(deps) → { stageKey: [{ id, title, status }] } for every genesis
116
- // task on the board, grouped by the stage its source_ref names. The genesis home lists
117
- // them, and a stage close hands their ids to the Board Room's finished-tasks gate.
115
+ // genesisStageTasks(deps) → { stageKey: [{ id, title, status, description }] } for every
116
+ // genesis task on the board, grouped by the stage its source_ref names. The genesis home
117
+ // lists them, and a stage close hands their ids to the Board Room's finished-tasks gate.
118
+ // The description rides along so each row's Copy prompt carries the task's own words
119
+ // (task 1005109); the read stays bounded, it is only the few seeded genesis tasks.
118
120
  async function genesisStageTasks(deps = {}) {
119
121
  const pool = deps.pool || api.pool;
120
122
  const { rows } = await pool.query(
121
- `SELECT id, title, status, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
123
+ `SELECT id, title, status, description, source_ref FROM tasks WHERE source = $1 ORDER BY id ASC`,
122
124
  [GENESIS_SOURCE],
123
125
  );
124
126
  const out = {};
125
127
  for (const r of rows) {
126
128
  const stage = String(r.source_ref || '').split(':')[1];
127
129
  if (!stage) continue;
128
- (out[stage] = out[stage] || []).push({ id: String(r.id), title: r.title, status: r.status });
130
+ (out[stage] = out[stage] || []).push({
131
+ id: String(r.id), title: r.title, status: r.status, description: r.description || '',
132
+ });
129
133
  }
130
134
  return out;
131
135
  }
@@ -146,7 +146,7 @@ const FINISHED = Object.freeze(['shipped', 'abandoned']); // the Board Room's ow
146
146
  // shapeGenesis({ mode, plan, tasks, board, stageAt }) — PURE. The genesis home's view.
147
147
  // mode foundingModeFor's shape (non-null)
148
148
  // plan the saved plan or null (deciding)
149
- // tasks { stage: [{ id, title, status }] }
149
+ // tasks { stage: [{ id, title, status, description }] }
150
150
  // board { stage: genesisStageStatus row } or {}
151
151
  // stageAt { stage: ISO } — when each stage began, for its due date
152
152
  function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {} }) {
@@ -39,7 +39,8 @@
39
39
  "provisioning.projectPlanets",
40
40
  "provisioning.projectOwnedBy",
41
41
  "provisioning.projectPublishIdentity",
42
- "provisioning.selfUpdateChannel"
42
+ "provisioning.selfUpdateChannel",
43
+ "provisioning.moduleCatalog"
43
44
  ],
44
45
  "consumes": []
45
46
  }