Files

145 lines
10 KiB
JavaScript

// 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-<version>.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 } };
}