145 lines
10 KiB
JavaScript
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 } };
|
|
}
|