// F2 (unified workspace direction, phase F2; PRD 1.20 draft REQ-SETUP-2): the onboarding interview that // follows Quick Setup. The Console and the CLI share two things from this file. // // The interview template, templates/onboarding-interview.json: the conversation name, the questions, // the draft paths and the first message. It is the one source today. An organization file key or a // plug-in kind may replace it later; loadInterviewTemplate takes the file to read so that caller can // pass another one, and validateInterviewTemplate is the check any replacement must pass. // // The north-star approval. Agents never write spec/ (index.mjs recordBytes refuses // `project-spec-human-only`), so the assistant proposes: it writes its draft as an ordinary doc with // its existing record.write grant. The human approves: approveNorthStarDraft copies the exact draft // commit the human reviewed into spec/north-star.md through the human save verb (project.file.save, // which calls writeHumanFile). The commit is the human's, so the spec reads as confirmed, and the bus // sends the PM and CTO their spec notice as it does for any human save. Nothing here gives the // assistant a new write. import { readFileSync } from 'node:fs'; import { ID } from './paths.mjs'; export const INTERVIEW_TEMPLATE_URL = new URL('../templates/onboarding-interview.json', import.meta.url); const NORTH_STAR_TEMPLATE_URL = new URL('../templates/north-star.md', import.meta.url); export const SPEC_PATH = 'spec/north-star.md'; const COMMIT = /^[0-9a-f]{40}$/; const KEY = /^[a-z][a-z0-9-]{0,31}$/; const DRAFT_DOC = /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/; // readPrdMaterial (prd.mjs) requires prd_path === `docs/prd-${version}.md`. const PRD_DOC = /^docs\/prd-\d+\.\d+(?:\.\d+)?\.md$/; const PREFIX = /^[A-Za-z][A-Za-z0-9._-]{0,31}$/; const PLACEHOLDERS = ['project', 'questions', 'northStarDraft', 'prdDraft', 'requirementPrefix']; const REQUIRED = ['questions', 'northStarDraft']; // chat-http.mjs refuses a message body longer than this. export const MESSAGE_MAX = 32768; const TEMPLATE_KEYS = ['drafts', 'id', 'prompt', 'questions', 'templateVersion', 'threadName']; const DRAFT_KEYS = ['northStar', 'prd', 'requirementPrefix']; const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); const line = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max && !/[\r\n\0]/.test(value); const sameKeys = (value, keys) => Object.keys(value).sort().join(',') === [...keys].sort().join(','); export class OnboardingError extends Error { // `stage` says where it stopped: 'check' before any read, 'read' at the draft or spec read, 'save' at // the human save. Only a save failure can leave a write behind; the caller reads `cause` for that. constructor(code, message, { stage = 'check', cause } = {}) { super(message, cause === undefined ? undefined : { cause }); this.code = code; this.stage = stage; } } // Every problem with a template, as text; an empty list means it is usable. export function validateInterviewTemplate(value) { if (!object(value)) return ['template must be a JSON object']; const problems = []; if (!sameKeys(value, TEMPLATE_KEYS)) problems.push(`template keys must be exactly ${TEMPLATE_KEYS.join(', ')}`); if (value.templateVersion !== 1) problems.push('templateVersion must be 1'); if (typeof value.id !== 'string' || !ID.test(value.id)) problems.push('id must be a record id'); // A conversation name is at most 200 characters (chat-http.mjs); 120 leaves room for a " 2" suffix. if (!line(value.threadName, 120)) problems.push('threadName must be one line of 1-120 characters'); const drafts = value.drafts; if (!object(drafts) || !sameKeys(drafts, DRAFT_KEYS)) problems.push(`drafts keys must be exactly ${DRAFT_KEYS.join(', ')}`); else { if (typeof drafts.northStar !== 'string' || !DRAFT_DOC.test(drafts.northStar)) problems.push('drafts.northStar must be a Markdown doc directly under docs/'); if (typeof drafts.prd !== 'string' || !PRD_DOC.test(drafts.prd)) problems.push('drafts.prd must be docs/prd-.md'); if (drafts.northStar === drafts.prd) problems.push('drafts.northStar and drafts.prd must differ'); if (typeof drafts.requirementPrefix !== 'string' || !PREFIX.test(drafts.requirementPrefix)) problems.push('drafts.requirementPrefix must start a record id'); } if (!Array.isArray(value.questions) || value.questions.length < 1 || value.questions.length > 12) problems.push('questions must list 1-12 questions'); else { const seen = new Set(); value.questions.forEach((q, i) => { if (!object(q) || !sameKeys(q, ['key', 'text'])) { problems.push(`questions[${i}] keys must be exactly key, text`); return; } if (typeof q.key !== 'string' || !KEY.test(q.key)) problems.push(`questions[${i}].key must be lower-case letters, digits and dashes`); else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats an earlier question key`); seen.add(q.key); if (!line(q.text, 500)) problems.push(`questions[${i}].text must be one line of 1-500 characters`); }); } if (!Array.isArray(value.prompt) || value.prompt.length < 1 || value.prompt.length > 200 || !value.prompt.every(l => typeof l === 'string' && l.length <= 2000 && !/[\r\n\0]/.test(l))) problems.push('prompt must be 1-200 lines of text, each at most 2000 characters without line breaks'); else { const text = value.prompt.join('\n'), used = new Set([...text.matchAll(/\{([^{}]*)\}/g)].map(m => m[1])); for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder; expected one of ${PLACEHOLDERS.join(", ")}`); for (const name of REQUIRED) if (!used.has(name)) problems.push(`prompt must use {${name}}`); if (!text.trim()) problems.push('prompt must not be empty'); } return problems; } // Reads and checks a template file. A replacement (organization file, plug-in) passes its own file. export function loadInterviewTemplate(url = INTERVIEW_TEMPLATE_URL) { let value; try { value = JSON.parse(readFileSync(url, 'utf8')); } catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: expected readable JSON in ${url}${error.code ? ` (${error.code})` : ""}`); } const problems = validateInterviewTemplate(value); if (problems.length) throw new OnboardingError('onboarding-template-invalid', problems.join('; ')); return value; } // The human's first message in the interview conversation, for one project. export function interviewMessage(template, { project }) { const problems = validateInterviewTemplate(template); if (problems.length) throw new OnboardingError('onboarding-template-invalid', problems.join('; ')); if (typeof project !== 'string' || !ID.test(project)) throw new OnboardingError('invalid-request', 'project must be a record id'); const values = { project, questions: template.questions.map((q, i) => `${i + 1}. ${q.text}`).join('\n'), northStarDraft: template.drafts.northStar, prdDraft: template.drafts.prd, requirementPrefix: template.drafts.requirementPrefix, }; const message = template.prompt.join('\n').replace(/\{([^{}]*)\}/g, (_m, name) => values[name]); if (message.length > MESSAGE_MAX) throw new OnboardingError('onboarding-template-invalid', `the first message is longer than ${MESSAGE_MAX} characters`); return message; } const normal = text => text.replace(/\r\n?/g, '\n').trim(); export function northStarTemplateText() { return readFileSync(NORTH_STAR_TEMPLATE_URL, 'utf8'); } // True while the spec still holds the text project init wrote. Line endings and outer blank lines // do not count as an edit. export function isTemplateNorthStar(content, template = northStarTemplateText()) { return typeof content === 'string' && normal(content) === normal(template); } // The approval. `read({project, path})` and `save({project, path, base, content})` are the human // transport's project.file and project.file.save. `draft` is the draft's commit as the human saw it // and `base` the spec's commit as the human saw it; a newer draft refuses here, a newer spec refuses // in the bus (`stale-base`). Nothing is written without the human's save verb. export async function approveNorthStarDraft({ read, save, project, draft, base, drafts }) { if (typeof read !== 'function' || typeof save !== 'function') throw new OnboardingError('not-configured', 'approval needs the human read and save'); if (typeof project !== 'string' || !ID.test(project)) throw new OnboardingError('invalid-request', 'project must be a record id'); if (typeof draft !== 'string' || !COMMIT.test(draft)) throw new OnboardingError('invalid-request', 'draft must be the full commit of the draft you reviewed'); if (typeof base !== 'string' || !COMMIT.test(base)) throw new OnboardingError('invalid-request', 'base must be the full commit of the north star you replace'); const path = drafts?.northStar; if (typeof path !== 'string' || !DRAFT_DOC.test(path)) throw new OnboardingError('onboarding-template-invalid', 'the template names no north-star draft'); let file; try { file = await read({ project, path }); } catch (cause) { throw new OnboardingError(cause?.code ?? 'invalid-response', `could not read ${path}`, { stage: 'read', cause }); } if (typeof file?.content !== 'string' || !COMMIT.test(file.commit ?? '')) throw new OnboardingError('invalid-response', `the bus answered ${path} without its commit`, { stage: 'read' }); if (file.commit !== draft) throw new OnboardingError('draft-stale', `${path} changed since you reviewed it; review the new draft`, { stage: 'read' }); if (!file.content.trim()) throw new OnboardingError('draft-empty', `${path} is empty`, { stage: 'read' }); let saved; try { saved = await save({ project, path: SPEC_PATH, base, content: file.content }); } catch (cause) { throw new OnboardingError(cause?.code ?? 'outcome-unknown', 'the north star was not confirmed saved', { stage: 'save', cause }); } if (!COMMIT.test(saved?.commit ?? '') || saved.path !== SPEC_PATH) throw new OnboardingError('outcome-unknown', 'the bus answered without the new commit; the north star may have been saved', { stage: 'save' }); return { path: SPEC_PATH, commit: saved.commit, draft: { path, commit: file.commit } }; }