@oaklandzoo/ostup 0.3.0 → 0.5.0

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 (33) hide show
  1. package/README.md +23 -0
  2. package/bin/cli.mjs +27 -1
  3. package/package.json +1 -1
  4. package/src/brief/classify.mjs +218 -0
  5. package/src/brief/index.mjs +126 -0
  6. package/src/brief/profile-router.mjs +89 -0
  7. package/src/brief/questions.mjs +333 -0
  8. package/src/brief/render-brief.mjs +232 -0
  9. package/src/brief/sample-briefs.mjs +304 -0
  10. package/src/brief/schema.mjs +176 -0
  11. package/src/mvp-flow.mjs +43 -1
  12. package/src/templates.mjs +4 -0
  13. package/templates/.claude/commands/break-into-stories.md +101 -0
  14. package/templates/.claude/commands/handoff-doctor.md +93 -0
  15. package/templates/.claude/commands/resume.md +102 -0
  16. package/templates/AGENTS.md +13 -2
  17. package/templates/CLAUDE.md +3 -0
  18. package/templates/START_HERE.md +11 -4
  19. package/templates/profiles/blog/.env.example.additions +7 -0
  20. package/templates/profiles/blog/README.md +70 -0
  21. package/templates/profiles/blog/section-prompts.md +63 -0
  22. package/templates/profiles/booking/.env.example.additions +16 -0
  23. package/templates/profiles/booking/README.md +61 -0
  24. package/templates/profiles/booking/section-prompts.md +47 -0
  25. package/templates/profiles/lead-gen/.env.example.additions +8 -0
  26. package/templates/profiles/lead-gen/README.md +58 -0
  27. package/templates/profiles/lead-gen/section-prompts.md +47 -0
  28. package/templates/profiles/marketing/README.md +39 -0
  29. package/templates/profiles/marketing/section-prompts.md +36 -0
  30. package/templates/profiles/saas-dashboard/.env.example.additions +21 -0
  31. package/templates/profiles/saas-dashboard/README.md +60 -0
  32. package/templates/profiles/saas-dashboard/section-prompts.md +52 -0
  33. package/templates/scripts/verify.sh +128 -0
@@ -0,0 +1,333 @@
1
+ // brief/questions.mjs: the 10 intake questions for `ostup brief`. Uses @clack/prompts.
2
+
3
+ import * as p from '@clack/prompts';
4
+ import { existsSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { SCHEMA_VERSION, AUDIENCES, BUSINESS_MODELS, CORE_FLOWS, DATA_KINDS, DEADLINES, BUDGET_POSTURES, PROFILES } from './schema.mjs';
7
+ import { classify, resolveAddons } from './classify.mjs';
8
+ import { emptyBrief } from './schema.mjs';
9
+
10
+ const PROFILE_OPTIONS = [
11
+ { value: 'marketing', label: 'Marketing site' },
12
+ { value: 'lead-gen', label: 'Lead-gen service company site' },
13
+ { value: 'booking', label: 'Booking or appointments site' },
14
+ { value: 'blog', label: 'Blog or content engine' },
15
+ { value: 'storefront', label: 'Storefront / ecommerce-lite' },
16
+ { value: 'saas-dashboard', label: 'SaaS MVP with dashboard' },
17
+ { value: 'directory', label: 'Directory or listings site' },
18
+ { value: 'portfolio', label: 'Portfolio or case-study site' },
19
+ { value: '__auto__', label: 'Not sure — classify it from my answers' },
20
+ ];
21
+
22
+ const AUDIENCE_OPTIONS = [
23
+ { value: 'small_business', label: 'A small business or service company' },
24
+ { value: 'founder', label: 'A founder validating a SaaS or AI product' },
25
+ { value: 'agency', label: 'An agency / operator building for clients' },
26
+ { value: 'creator', label: 'A creator, writer, consultant, or portfolio owner' },
27
+ { value: 'community', label: 'A marketplace, directory, or community' },
28
+ { value: 'other', label: 'Other' },
29
+ ];
30
+
31
+ const BUSINESS_MODEL_OPTIONS = [
32
+ { value: 'lead_capture', label: 'Lead capture' },
33
+ { value: 'booking_revenue', label: 'Appointment or booking revenue' },
34
+ { value: 'deposit', label: 'Deposit or full payment at booking' },
35
+ { value: 'subscription', label: 'Subscription' },
36
+ { value: 'one_time_sale', label: 'One-time product sale' },
37
+ { value: 'content_traffic', label: 'Content / search traffic' },
38
+ { value: 'dashboard', label: 'Member portal or dashboard' },
39
+ { value: 'operations', label: 'Internal operations tool' },
40
+ { value: 'brand_awareness', label: 'Brand awareness only' },
41
+ ];
42
+
43
+ const CORE_FLOW_OPTIONS = [
44
+ { value: 'contact_form', label: 'Submit contact form' },
45
+ { value: 'booking', label: 'Book appointment or request dates' },
46
+ { value: 'payment', label: 'Pay deposit or checkout' },
47
+ { value: 'auth', label: 'Sign up / log in' },
48
+ { value: 'dashboard', label: 'View dashboard' },
49
+ { value: 'content_crud', label: 'Create or edit content / listings' },
50
+ { value: 'search_filter', label: 'Search / filter listings' },
51
+ { value: 'newsletter', label: 'Subscribe to newsletter' },
52
+ { value: 'file_upload_download', label: 'Download or upload a file' },
53
+ { value: 'other', label: 'Other' },
54
+ ];
55
+
56
+ const DATA_OPTIONS = [
57
+ { value: 'none', label: 'None — static site only' },
58
+ { value: 'leads', label: 'Leads or contact submissions' },
59
+ { value: 'bookings', label: 'Appointments, bookings, or availability' },
60
+ { value: 'products_orders', label: 'Products, orders, or payments' },
61
+ { value: 'users_accounts', label: 'Users, accounts, profiles, or roles' },
62
+ { value: 'content', label: 'Blog posts or content entries' },
63
+ { value: 'listings', label: 'Listings or directory records' },
64
+ { value: 'files', label: 'Files, images, PDFs, or uploads' },
65
+ { value: 'unsure', label: 'Not sure — recommend it' },
66
+ ];
67
+
68
+ const DEADLINE_OPTIONS = [
69
+ { value: 'demo_today', label: 'Demo today' },
70
+ { value: 'mvp_7_days', label: 'MVP in 7 days' },
71
+ { value: 'production_2_4_weeks', label: 'Production in 2-4 weeks' },
72
+ { value: 'none', label: 'No deadline yet' },
73
+ ];
74
+
75
+ const BUDGET_OPTIONS = [
76
+ { value: 'free_tier_first', label: 'Free-tier only' },
77
+ { value: 'under_50_month', label: 'Under $50/month' },
78
+ { value: 'under_250_month', label: 'Under $250/month' },
79
+ { value: 'paid_ok', label: 'Paid tools okay if justified' },
80
+ ];
81
+
82
+ function isCancel(v) {
83
+ return p.isCancel ? p.isCancel(v) : false;
84
+ }
85
+
86
+ export function validateSummary(s) {
87
+ if (typeof s !== 'string' || s.trim() === '') return 'required';
88
+ const len = s.trim().length;
89
+ if (len < 20) return 'too short — at least 20 characters';
90
+ if (len > 240) return 'too long — under 240 characters';
91
+ if (/^(website|app|tool|site|landing page|something|stuff)\.?$/i.test(s.trim())) {
92
+ return 'too generic — be specific about what it does, who for, and what is special';
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function toBullets(text) {
98
+ if (!text) return [];
99
+ // Operator may pass comma-separated, semicolon-separated, or newline.
100
+ return text
101
+ .split(/[\n,;]+/)
102
+ .map((s) => s.trim())
103
+ .filter(Boolean)
104
+ .slice(0, 12);
105
+ }
106
+
107
+ function recommendDataModel(dataSelections, brief) {
108
+ const map = {
109
+ leads: 'postgres',
110
+ bookings: 'postgres',
111
+ products_orders: 'postgres',
112
+ users_accounts: 'postgres',
113
+ listings: 'postgres',
114
+ files: 'blob',
115
+ content: 'mdx',
116
+ };
117
+ const recs = new Set();
118
+ for (const sel of dataSelections) {
119
+ if (map[sel]) recs.add(map[sel]);
120
+ }
121
+ if (dataSelections.includes('unsure')) {
122
+ // Suggest based on business model
123
+ const types = brief.business_model?.types || [];
124
+ if (types.includes('subscription') || types.includes('dashboard')) recs.add('postgres');
125
+ if (types.includes('content_traffic')) recs.add('mdx');
126
+ }
127
+ return {
128
+ needs_storage: dataSelections.some((d) => !['none', 'unsure'].includes(d)),
129
+ recommended_storage: Array.from(recs),
130
+ raw_selections: dataSelections,
131
+ };
132
+ }
133
+
134
+ function entitiesFromDataKinds(dataSelections) {
135
+ const entities = [];
136
+ if (dataSelections.includes('leads')) {
137
+ entities.push({ name: 'Lead', fields: ['name', 'email', 'phone', 'message', 'created_at'], source: 'intake' });
138
+ }
139
+ if (dataSelections.includes('bookings')) {
140
+ entities.push({ name: 'Booking', fields: ['date_start', 'date_end', 'guest_name', 'status', 'deposit_status'], source: 'intake' });
141
+ }
142
+ if (dataSelections.includes('products_orders')) {
143
+ entities.push({ name: 'Product', fields: ['name', 'price', 'description'], source: 'intake' });
144
+ entities.push({ name: 'Order', fields: ['user_id', 'items', 'total', 'status'], source: 'intake' });
145
+ }
146
+ if (dataSelections.includes('users_accounts')) {
147
+ entities.push({ name: 'User', fields: ['email', 'name', 'created_at'], source: 'intake' });
148
+ }
149
+ if (dataSelections.includes('content')) {
150
+ entities.push({ name: 'Post', fields: ['title', 'slug', 'date', 'tags', 'body_mdx'], source: 'intake' });
151
+ }
152
+ if (dataSelections.includes('listings')) {
153
+ entities.push({ name: 'Listing', fields: ['title', 'description', 'category', 'created_at'], source: 'intake' });
154
+ }
155
+ return entities;
156
+ }
157
+
158
+ export async function runIntake({ defaults = {} } = {}) {
159
+ const brief = emptyBrief();
160
+
161
+ // 1. Summary
162
+ const summary = await p.text({
163
+ message: 'What are you building? One sentence (20-240 chars).',
164
+ placeholder: 'A booking site for a boutique ski lodge with deposits, calendar sync, and a guest dashboard.',
165
+ validate: (v) => validateSummary(v) || undefined,
166
+ });
167
+ if (isCancel(summary)) return { cancelled: true };
168
+ brief.project.summary = summary.trim();
169
+
170
+ // 2. Audience
171
+ const audience = await p.select({
172
+ message: 'Who is this primarily for?',
173
+ options: AUDIENCE_OPTIONS,
174
+ initialValue: defaults.audience || 'small_business',
175
+ });
176
+ if (isCancel(audience)) return { cancelled: true };
177
+ brief.audience.primary = audience;
178
+
179
+ // 3. Site type
180
+ const siteTypeChoice = await p.select({
181
+ message: 'Pick the closest app or site type.',
182
+ options: PROFILE_OPTIONS,
183
+ initialValue: defaults.profile || 'lead-gen',
184
+ });
185
+ if (isCancel(siteTypeChoice)) return { cancelled: true };
186
+ const explicitProfile = siteTypeChoice === '__auto__' ? null : siteTypeChoice;
187
+
188
+ // 4. Business model (multi)
189
+ const bm = await p.multiselect({
190
+ message: 'How does this make money or create value? (space to select, enter to confirm)',
191
+ options: BUSINESS_MODEL_OPTIONS,
192
+ required: true,
193
+ initialValues: defaults.business_model || ['lead_capture'],
194
+ });
195
+ if (isCancel(bm)) return { cancelled: true };
196
+ brief.business_model.types = bm;
197
+
198
+ // 5. Must-have sections
199
+ const sections = await p.text({
200
+ message: 'What must be on the homepage or primary dashboard? Comma-separated or one per line.',
201
+ placeholder: 'hero, pricing, services, testimonials, contact form',
202
+ validate: (v) => (toBullets(v).length === 0 ? 'at least one section required' : undefined),
203
+ });
204
+ if (isCancel(sections)) return { cancelled: true };
205
+ brief.scope.must_have_sections = toBullets(sections);
206
+
207
+ // 6. Data
208
+ const data = await p.multiselect({
209
+ message: 'What data needs to be stored? (space to select)',
210
+ options: DATA_OPTIONS,
211
+ required: true,
212
+ initialValues: ['none'],
213
+ });
214
+ if (isCancel(data)) return { cancelled: true };
215
+ const dm = recommendDataModel(data, brief);
216
+ brief.data_model.needs_storage = dm.needs_storage;
217
+ brief.data_model.recommended_storage = dm.recommended_storage;
218
+ brief.data_model.entities = entitiesFromDataKinds(data);
219
+
220
+ // 7. Core flows
221
+ const flows = await p.multiselect({
222
+ message: 'Which flows must work on day one? Pick up to 5. (space to select)',
223
+ options: CORE_FLOW_OPTIONS,
224
+ required: true,
225
+ initialValues: defaults.core_flows || ['contact_form'],
226
+ });
227
+ if (isCancel(flows)) return { cancelled: true };
228
+ brief.scope.core_flows = flows;
229
+
230
+ // 8. Brand
231
+ const brandVibe = await p.text({
232
+ message: 'Three words for vibe (comma-separated). e.g. credible, warm, premium',
233
+ placeholder: 'credible, warm, premium',
234
+ validate: (v) => (toBullets(v).length < 1 ? 'at least one word' : undefined),
235
+ });
236
+ if (isCancel(brandVibe)) return { cancelled: true };
237
+ brief.brand.vibe = toBullets(brandVibe).slice(0, 5);
238
+
239
+ const brandNotes = await p.text({
240
+ message: 'Optional: brand notes — colors, fonts, logo direction. (Enter to skip)',
241
+ placeholder: 'Navy + white. Bold sans for headlines. Mountain logo.',
242
+ });
243
+ if (isCancel(brandNotes)) return { cancelled: true };
244
+ if (brandNotes && brandNotes.trim()) {
245
+ brief.brand.color_notes = brandNotes;
246
+ }
247
+
248
+ // 9. Inputs
249
+ const wantsIngest = await p.confirm({
250
+ message: 'Do you have research, screenshots, brand files, images, or notes to ingest?',
251
+ initialValue: false,
252
+ });
253
+ if (isCancel(wantsIngest)) return { cancelled: true };
254
+ if (wantsIngest) {
255
+ const path = await p.text({
256
+ message: 'Source folder path to copy from:',
257
+ placeholder: '/path/to/materials',
258
+ validate: (v) => {
259
+ if (!v || !v.trim()) return 'required';
260
+ if (!existsSync(resolve(v))) return `path does not exist: ${v}`;
261
+ return undefined;
262
+ },
263
+ });
264
+ if (isCancel(path)) return { cancelled: true };
265
+ brief.inputs = { ingested: true, source_path: path.trim(), manifest_path: 'inputs/INGEST_MANIFEST.md' };
266
+ }
267
+
268
+ // 10. Constraints
269
+ const deadline = await p.select({
270
+ message: 'What are the launch constraints?',
271
+ options: DEADLINE_OPTIONS,
272
+ initialValue: 'mvp_7_days',
273
+ });
274
+ if (isCancel(deadline)) return { cancelled: true };
275
+ brief.constraints.deadline = deadline;
276
+
277
+ const budget = await p.select({
278
+ message: 'Budget posture:',
279
+ options: BUDGET_OPTIONS,
280
+ initialValue: 'free_tier_first',
281
+ });
282
+ if (isCancel(budget)) return { cancelled: true };
283
+ brief.constraints.budget_posture = budget;
284
+
285
+ const mustUseRaw = await p.text({
286
+ message: 'Optional: must-use vendors (comma-separated). Enter to skip.',
287
+ placeholder: 'Stripe, Resend',
288
+ });
289
+ if (isCancel(mustUseRaw)) return { cancelled: true };
290
+ if (mustUseRaw && mustUseRaw.trim()) brief.constraints.must_use = toBullets(mustUseRaw);
291
+
292
+ const mustAvoidRaw = await p.text({
293
+ message: 'Optional: must-avoid vendors (comma-separated). Enter to skip.',
294
+ placeholder: 'jQuery, anything Google',
295
+ });
296
+ if (isCancel(mustAvoidRaw)) return { cancelled: true };
297
+ if (mustAvoidRaw && mustAvoidRaw.trim()) brief.constraints.must_avoid = toBullets(mustAvoidRaw);
298
+
299
+ // Classify
300
+ const cls = classify(brief, { explicitProfile });
301
+ brief.classification = {
302
+ site_profile: cls.site_profile,
303
+ confidence: cls.confidence,
304
+ why: cls.why,
305
+ secondary_profiles: cls.secondary_profiles,
306
+ };
307
+
308
+ // Scaffold + acceptance
309
+ brief.scaffold.profile = cls.site_profile;
310
+ brief.scaffold.addons = cls.addons;
311
+ brief.scaffold.template_paths = ['templates/base', `templates/profiles/${cls.site_profile}`];
312
+
313
+ // Auto-derive acceptance from selected flows
314
+ brief.verification.acceptance_tests = autoAcceptance(brief);
315
+ brief.verification.deploy_checks = ['Live URL returns 200', 'OG image renders'];
316
+
317
+ return { brief, cancelled: false };
318
+ }
319
+
320
+ function autoAcceptance(brief) {
321
+ const tests = [];
322
+ const flows = brief.scope?.core_flows || [];
323
+ if (flows.includes('contact_form')) tests.push('Visitor can submit the contact form');
324
+ if (flows.includes('booking')) tests.push('Visitor can request dates');
325
+ if (flows.includes('payment')) tests.push('Checkout can be initiated');
326
+ if (flows.includes('auth')) tests.push('User can sign up and log in');
327
+ if (flows.includes('dashboard')) tests.push('Authenticated user reaches the dashboard');
328
+ if (flows.includes('content_crud')) tests.push('A post can be added and rendered');
329
+ if (flows.includes('search_filter')) tests.push('Listings can be filtered');
330
+ if (flows.includes('newsletter')) tests.push('Email can be submitted to the newsletter');
331
+ if (flows.includes('file_upload_download')) tests.push('A file can be uploaded and retrieved');
332
+ return tests;
333
+ }
@@ -0,0 +1,232 @@
1
+ // brief/render-brief.mjs: render docs/brief.md from a brief.json object.
2
+
3
+ export function renderBrief(brief) {
4
+ const p = brief.project || {};
5
+ const c = brief.classification || {};
6
+ const a = brief.audience || {};
7
+ const bm = brief.business_model || {};
8
+ const scope = brief.scope || {};
9
+ const dm = brief.data_model || {};
10
+ const brand = brief.brand || {};
11
+ const inputs = brief.inputs || {};
12
+ const constraints = brief.constraints || {};
13
+ const sc = brief.scaffold || {};
14
+ const v = brief.verification || {};
15
+
16
+ const lines = [];
17
+ lines.push('# Project Brief');
18
+ lines.push('');
19
+ lines.push('> Source of truth for this project. Governs scaffold profile, downstream PRDs, and brand decisions.');
20
+ lines.push(`> Schema version: ${brief.schema_version}`);
21
+ lines.push('');
22
+
23
+ lines.push('## 1. Summary');
24
+ lines.push('');
25
+ lines.push(p.summary || '_(missing)_');
26
+ lines.push('');
27
+
28
+ lines.push('## 2. Classification');
29
+ lines.push('');
30
+ lines.push(`- **Profile:** \`${c.site_profile}\``);
31
+ lines.push(`- **Confidence:** ${c.confidence}`);
32
+ lines.push(`- **Why:**`);
33
+ for (const reason of c.why || []) lines.push(` - ${reason}`);
34
+ if (c.secondary_profiles?.length) {
35
+ lines.push(`- **Secondary profiles:** ${c.secondary_profiles.map((s) => `\`${s}\``).join(', ')}`);
36
+ }
37
+ lines.push('');
38
+
39
+ lines.push('## 3. Audience');
40
+ lines.push('');
41
+ lines.push(`- **Primary:** ${a.primary}`);
42
+ if (a.buyer) lines.push(`- **Buyer:** ${a.buyer}`);
43
+ if (a.end_users?.length) lines.push(`- **End users:** ${a.end_users.join(', ')}`);
44
+ lines.push('');
45
+
46
+ lines.push('## 4. Business model');
47
+ lines.push('');
48
+ if (bm.types?.length) {
49
+ for (const t of bm.types) lines.push(`- ${t.replace(/_/g, ' ')}`);
50
+ } else {
51
+ lines.push('_(none specified)_');
52
+ }
53
+ if (bm.pricing_notes) {
54
+ lines.push('');
55
+ lines.push(`_Pricing notes:_ ${bm.pricing_notes}`);
56
+ }
57
+ lines.push('');
58
+
59
+ lines.push('## 5. Must-have experience');
60
+ lines.push('');
61
+ if (scope.must_have_sections?.length) {
62
+ for (const s of scope.must_have_sections) lines.push(`- ${s}`);
63
+ } else {
64
+ lines.push('_(none specified)_');
65
+ }
66
+ lines.push('');
67
+
68
+ lines.push('## 6. Core day-one flows');
69
+ lines.push('');
70
+ if (scope.core_flows?.length) {
71
+ for (const f of scope.core_flows) lines.push(`- ${f.replace(/_/g, ' ')}`);
72
+ } else {
73
+ lines.push('_(none specified)_');
74
+ }
75
+ if (scope.non_goals?.length) {
76
+ lines.push('');
77
+ lines.push('**Non-goals for v1:**');
78
+ for (const ng of scope.non_goals) lines.push(`- ${ng}`);
79
+ }
80
+ lines.push('');
81
+
82
+ lines.push('## 7. Data model');
83
+ lines.push('');
84
+ lines.push(`- **Needs storage:** ${dm.needs_storage ? 'yes' : 'no'}`);
85
+ if (dm.entities?.length) {
86
+ lines.push('- **Entities:**');
87
+ for (const ent of dm.entities) {
88
+ lines.push(` - **${ent.name}** — fields: ${(ent.fields || []).join(', ')}`);
89
+ }
90
+ }
91
+ if (dm.recommended_storage?.length) {
92
+ lines.push(`- **Recommended storage:** ${dm.recommended_storage.join(', ')}`);
93
+ }
94
+ lines.push('');
95
+
96
+ lines.push('## 8. Brand direction');
97
+ lines.push('');
98
+ if (brand.vibe?.length) lines.push(`- **Vibe:** ${brand.vibe.join(', ')}`);
99
+ if (brand.color_notes) lines.push(`- **Color notes:** ${brand.color_notes}`);
100
+ if (brand.font_notes) lines.push(`- **Font notes:** ${brand.font_notes}`);
101
+ if (brand.logo_notes) lines.push(`- **Logo notes:** ${brand.logo_notes}`);
102
+ if (brand.inspiration_urls?.length) {
103
+ lines.push(`- **Inspiration:** ${brand.inspiration_urls.join(', ')}`);
104
+ }
105
+ lines.push('');
106
+
107
+ lines.push('## 9. Inputs and materials');
108
+ lines.push('');
109
+ if (inputs.ingested) {
110
+ lines.push(`- **Ingested:** yes`);
111
+ if (inputs.source_path) lines.push(`- **Source path:** \`${inputs.source_path}\``);
112
+ if (inputs.manifest_path) lines.push(`- **Manifest:** \`${inputs.manifest_path}\``);
113
+ } else {
114
+ lines.push('_No materials ingested. Operator can drop files in `inputs/` anytime._');
115
+ }
116
+ lines.push('');
117
+
118
+ lines.push('## 10. Constraints');
119
+ lines.push('');
120
+ lines.push(`- **Deadline:** ${constraints.deadline?.replace(/_/g, ' ')}`);
121
+ lines.push(`- **Budget posture:** ${constraints.budget_posture?.replace(/_/g, ' ')}`);
122
+ if (constraints.must_use?.length) lines.push(`- **Must use:** ${constraints.must_use.join(', ')}`);
123
+ if (constraints.must_avoid?.length) lines.push(`- **Must avoid:** ${constraints.must_avoid.join(', ')}`);
124
+ lines.push('');
125
+
126
+ lines.push('## 11. Scaffold decision');
127
+ lines.push('');
128
+ lines.push(`- **Base:** ${sc.base}`);
129
+ lines.push(`- **Profile:** \`${sc.profile}\``);
130
+ lines.push(`- **Add-ons:** ${(sc.addons || []).map((a) => `\`${a}\``).join(', ') || '_(none)_'}`);
131
+ lines.push(`- **Template overlays:** ${(sc.template_paths || []).map((t) => `\`${t}\``).join(', ')}`);
132
+ lines.push('');
133
+
134
+ lines.push('## 12. Acceptance tests');
135
+ lines.push('');
136
+ if (v.acceptance_tests?.length) {
137
+ for (const t of v.acceptance_tests) lines.push(`- [ ] ${t}`);
138
+ } else {
139
+ lines.push('_(none specified)_');
140
+ }
141
+ lines.push('');
142
+
143
+ if (v.visual_checks?.length) {
144
+ lines.push('## 13. Visual checks');
145
+ lines.push('');
146
+ for (const c of v.visual_checks) lines.push(`- [ ] ${c}`);
147
+ lines.push('');
148
+ }
149
+
150
+ if (v.deploy_checks?.length) {
151
+ lines.push('## 14. Deploy checks');
152
+ lines.push('');
153
+ for (const c of v.deploy_checks) lines.push(`- [ ] ${c}`);
154
+ lines.push('');
155
+ }
156
+
157
+ lines.push('## First PRD seed');
158
+ lines.push('');
159
+ lines.push('See `tasks/prd-initial-build.md` for the auto-generated PRD seed from this brief.');
160
+ lines.push('');
161
+
162
+ lines.push('---');
163
+ lines.push('');
164
+ lines.push('_Generated by `ostup brief`. Edit by hand to refine. Re-run with `--brief` to regenerate JSON from this file._');
165
+ lines.push('');
166
+
167
+ return lines.join('\n');
168
+ }
169
+
170
+ export function renderInitialPrdSeed(brief) {
171
+ const p = brief.project || {};
172
+ const sc = brief.scaffold || {};
173
+ const v = brief.verification || {};
174
+ const scope = brief.scope || {};
175
+ const brand = brief.brand || {};
176
+ const constraints = brief.constraints || {};
177
+
178
+ const lines = [];
179
+ lines.push(`# Initial build PRD: ${p.display_name || p.name}`);
180
+ lines.push('');
181
+ lines.push('> Auto-seeded from `docs/brief.md`. Edit freely; the agent reads this as the first PRD.');
182
+ lines.push('');
183
+ lines.push('## What we are building');
184
+ lines.push('');
185
+ lines.push(p.summary || '_(missing — fill in from brief)_');
186
+ lines.push('');
187
+ lines.push(`## Profile: \`${sc.profile}\``);
188
+ lines.push('');
189
+ lines.push(`**Add-ons enabled:** ${(sc.addons || []).map((a) => `\`${a}\``).join(', ') || '_(none)_'}`);
190
+ lines.push('');
191
+ lines.push('## Day-one scope');
192
+ lines.push('');
193
+ lines.push('### Must-have sections');
194
+ for (const s of scope.must_have_sections || []) lines.push(`- ${s}`);
195
+ lines.push('');
196
+ lines.push('### Core flows');
197
+ for (const f of scope.core_flows || []) lines.push(`- ${f.replace(/_/g, ' ')}`);
198
+ lines.push('');
199
+ if (scope.non_goals?.length) {
200
+ lines.push('### Non-goals (v1 explicitly defers)');
201
+ for (const ng of scope.non_goals) lines.push(`- ${ng}`);
202
+ lines.push('');
203
+ }
204
+ lines.push('## Brand');
205
+ lines.push('');
206
+ if (brand.vibe?.length) lines.push(`- Vibe: ${brand.vibe.join(', ')}`);
207
+ if (brand.color_notes) lines.push(`- Color: ${brand.color_notes}`);
208
+ if (brand.font_notes) lines.push(`- Font: ${brand.font_notes}`);
209
+ if (brand.logo_notes) lines.push(`- Logo: ${brand.logo_notes}`);
210
+ lines.push('');
211
+ lines.push('## Constraints');
212
+ lines.push('');
213
+ lines.push(`- Deadline: ${constraints.deadline?.replace(/_/g, ' ')}`);
214
+ lines.push(`- Budget posture: ${constraints.budget_posture?.replace(/_/g, ' ')}`);
215
+ if (constraints.must_use?.length) lines.push(`- Must use: ${constraints.must_use.join(', ')}`);
216
+ if (constraints.must_avoid?.length) lines.push(`- Must avoid: ${constraints.must_avoid.join(', ')}`);
217
+ lines.push('');
218
+ lines.push('## Acceptance');
219
+ lines.push('');
220
+ for (const t of v.acceptance_tests || []) lines.push(`- [ ] ${t}`);
221
+ lines.push('');
222
+ lines.push('## How to start');
223
+ lines.push('');
224
+ lines.push('1. Run `/prompt-start` to load this PRD into context.');
225
+ lines.push(`2. Read \`templates/profiles/${sc.profile}/README.md\` (in the scaffold) for profile-specific guidance.`);
226
+ lines.push('3. Build the must-have sections in the order listed above.');
227
+ lines.push('4. After each visual change, run `scripts/screenshot.sh <url>` and Read the PNG per CLAUDE.md Part 19.');
228
+ lines.push('5. Mark acceptance tests as you complete them.');
229
+ lines.push('');
230
+
231
+ return lines.join('\n');
232
+ }