feat(project,cli): onboarding interview template and north-star approval (F2)

REQ-SETUP-2 (PRD 1.20 draft), unified workspace direction ruling 14 and
phase F2.

- packages/project/templates/onboarding-interview.json: the interview's
  conversation name, four questions, draft paths and first message.
  loadInterviewTemplate(url) reads it and validateInterviewTemplate checks
  it, so an organization file or a plug-in can pass a replacement later.
- approveNorthStarDraft: agents never write spec/, so the assistant
  proposes docs/north-star-draft.md with its existing record.write grant
  and the human approves. The approval copies the exact reviewed draft
  commit into spec/north-star.md through the human save verb, pinned to
  the reviewed spec commit. A rewritten draft or a moved spec writes
  nothing. No new verb, grant or role change.
- mosaic project north-star <project> [--approve <draft> --base <spec>]
  shows the spec and the draft and approves as the human; mosaic project
  interview <project> prints the first message; mosaic thread create
  takes --instance.
This commit is contained in:
jason.woltje committed 2026-11-09 12:58:29 -06:00
1 parent 00ef0bd301
commit 00ae2c718c
9 files changed
+636 -4

No files matched your search

@@ -0,0 +1,76 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { fixture, git } from './spec-fixtures.mjs';
import { readSpec } from '../../project/src/index.mjs';
import { approveNorthStarDraft, isTemplateNorthStar, loadInterviewTemplate, SPEC_PATH } from '../../project/src/onboarding.mjs';
// F2 (REQ-SETUP-2): the north star through the bus. The assistant proposes with the record.write grant
// the shipped roles/assistant.json already gives it; only the human's project.file.save writes the spec.
// The assistant runs under the shipped authority and messaging, so this is the grant the stack runs.
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
const { drafts } = loadInterviewTemplate();
async function setup(t) {
const f = await fixture(t, { roles: {
pm: { definition: 'pm', ...within }, cto: { definition: 'cto', ...within },
assistant: { definition: 'assistant', authority: shipped('assistant').authority },
} });
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
const thread = f.call(f.human, 'thread.create', { project, name: 'Onboarding interview', instance: 'assistant' }).id;
f.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
f.broker.request(f.assistant, { verb: 'role.claim' });
f.notices = () => f.store.all("SELECT to_role, body FROM messages WHERE from_role='_bus' ORDER BY seq");
f.approver = {
read: args => f.call(f.human, 'project.file', args),
save: args => f.call(f.human, 'project.file.save', args),
};
return f;
}
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
test('the assistant cannot write the north star by any verb; it proposes a draft doc instead', async t => {
const f = await setup(t);
await assert.rejects(f.call(f.assistant, 'record.write', { project: 'stack', path: SPEC_PATH, record: '# Mine\n' }), { code: 'project-spec-human-only' });
await assert.rejects(f.call(f.assistant, 'project.file.save', { project: 'stack', path: SPEC_PATH, base: f.commit, content: '# Mine\n' }), { code: 'human-required' });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), f.commit);
assert.ok(refusals(f).includes('project-spec-human-only') && refusals(f).includes('human-required'));
const draft = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# North star\n\nOnboard new organizations.\n' });
assert.equal(git(f.repo, 'show', '-s', '--format=%an', draft.commit), 'assistant.assistant');
// The draft alone changes nothing: the spec is still the template, unconfirmed, and no notice went out.
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
assert.equal(spec.commit, f.commit); assert.equal(isTemplateNorthStar(spec.content), true);
assert.deepEqual(f.notices(), []);
});
test('the human approval copies the reviewed draft into the spec as the human, confirms it and notifies pm and cto', async t => {
const f = await setup(t);
const text = '# North star\n\nOnboard new organizations in one sitting.\n';
const draft = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: text });
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
const done = await approveNorthStarDraft({ ...f.approver, project: 'stack', draft: draft.commit, base: spec.commit, drafts });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), done.commit);
assert.equal(git(f.repo, 'show', '-s', '--format=%an|%s', done.commit), 'jason|Edit spec/north-star.md');
const after = await readSpec({ business: f.business, project: 'stack', dataRoot: f.root });
assert.equal(after.content, text); assert.equal(after.confirmed, true); assert.equal(after.commit, done.commit);
const sent = f.notices();
assert.deepEqual(sent.map(n => n.to_role).sort(), ['cto', 'pm']);
for (const n of sent) assert.ok(n.body.includes(`commit ${done.commit}, from the editor's base ${spec.commit}`));
});
test('an approval of a draft the assistant rewrote, or over a spec that moved, writes nothing and sends no notice', async t => {
const f = await setup(t);
const first = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# First\n' });
const spec = await f.call(f.human, 'project.file', { project: 'stack', path: SPEC_PATH });
const second = await f.call(f.assistant, 'record.write', { project: 'stack', path: drafts.northStar, record: '# Second\n' });
await assert.rejects(approveNorthStarDraft({ ...f.approver, project: 'stack', draft: first.commit, base: spec.commit, drafts }), { code: 'draft-stale' });
const moved = await f.call(f.human, 'project.file.save', { project: 'stack', path: SPEC_PATH, base: spec.commit, content: '# Typed by hand\n' });
const head = git(f.repo, 'rev-parse', 'HEAD'), notices = f.notices().length;
await assert.rejects(approveNorthStarDraft({ ...f.approver, project: 'stack', draft: second.commit, base: spec.commit, drafts }), { code: 'stale-base', stage: 'save' });
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), head);
assert.equal(f.notices().length, notices);
assert.equal(git(f.repo, 'show', `HEAD:${SPEC_PATH}`), '# Typed by hand');
assert.equal(moved.commit, head);
});
+3 -1
View File
@@ -36,8 +36,10 @@ mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--archived] [--business <id>] [--json] [--tester]
mosaic project archive <name-or-id> [--business <id>]
mosaic project unarchive <name-or-id> [--business <id>]
mosaic project north-star <project> [--business <id>] [--approve <draft-commit> --base <spec-commit>]
mosaic project interview <project>
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>] [--tester]
mosaic thread create <project> <name> [--instance <key>] [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester]
mosaic thread read <thread> [--business <id>] [--json] [--tester]
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
+5
View File
@@ -13,6 +13,7 @@ import { basename, join } from "node:path";
import { createInterface } from "node:readline/promises";
import { fileURLToPath } from "node:url";
import { projectBusCommand, projectCommand, PROJECT_BUS_USAGE, PROJECT_USAGE } from "./project.mjs";
import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE } from "./onboarding.mjs";
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
@@ -61,6 +62,8 @@ export const USAGE = `usage:
${INSTANCES_USAGE}
${PROJECT_USAGE}
${PROJECT_BUS_USAGE}
${NORTH_STAR_USAGE}
${INTERVIEW_USAGE}
${THREADS_USAGE}
${THREAD_USAGE}
${SESSION_USAGE}
@@ -691,6 +694,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
const [verb, ...rest] = argv;
if (verb === 'org') return orgCommand(rest, io, deps);
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps));
+68
View File
@@ -0,0 +1,68 @@
// F2 (PRD 1.20 draft REQ-SETUP-2): the CLI side of the north-star approval the Projects page offers.
// `mosaic project north-star <project>` shows the spec and the assistant's draft; with --approve and
// --base it copies that exact draft commit into spec/north-star.md through the human save verb, the
// same approveNorthStarDraft the Console's /api/north-star/approve calls. It runs as the human through
// callFor, so it refuses inside an agent like every other human command.
import { approveNorthStarDraft, interviewMessage, isTemplateNorthStar, loadInterviewTemplate, OnboardingError, SPEC_PATH } from '../../project/src/onboarding.mjs';
import { CliError } from './errors.mjs';
import { parseArgs } from './thread.mjs';
import { busExit } from './transport.mjs';
export const NORTH_STAR_USAGE = 'mosaic project north-star <project> [--business <id>] [--approve <draft-commit> --base <spec-commit>]';
// The Console's "Start onboarding interview" is two bus writes; the CLI does each: thread create with the
// assistant instance, then talk with this message. It reads only the template and contacts no bus.
export const INTERVIEW_USAGE = 'mosaic project interview <project> prints the interview\'s first message for mosaic talk --thread';
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const COMMIT = /^[0-9a-f]{40}$/;
// A bus refusal keeps the exit the human transport gave it; a check of approveNorthStarDraft's own is invalid input (2).
const exitOf = error => error.cause?.exitCode ?? (error.code === 'onboarding-template-invalid' || error.code === 'not-configured' ? 3 : busExit(error.code));
// The test transport throws plain coded errors; the real one throws CliError already.
const asCli = error => {
if (error instanceof CliError || typeof error?.code !== 'string') return error;
return Object.assign(new CliError(error.code, busExit(error.code)), { code: error.code });
};
export async function northStarCommand(args, io, callFor, { template = loadInterviewTemplate } = {}) {
const parsed = parseArgs(args, NORTH_STAR_USAGE, { flags: [], values: ['--business', '--approve', '--base'] });
if (parsed.positional.length !== 1) throw new CliError(`project north-star takes one records project\n ${NORTH_STAR_USAGE}`, 4);
const [project] = parsed.positional;
if (!ID.test(project)) throw new CliError(`${JSON.stringify(project)} is not a records project id\n ${NORTH_STAR_USAGE}`, 4);
const draft = parsed.values['--approve'], base = parsed.values['--base'];
if ((draft === undefined) !== (base === undefined)) throw new CliError(`--approve and --base go together\n ${NORTH_STAR_USAGE}`, 4);
if (draft !== undefined && (!COMMIT.test(draft) || !COMMIT.test(base))) throw new CliError(`--approve and --base each take a full 40-character commit\n ${NORTH_STAR_USAGE}`, 4);
let drafts;
try { ({ drafts } = template()); } catch (error) { throw new CliError(`the interview template is unusable: ${error.message}`, 3); }
const call = callFor(parsed);
if (draft !== undefined) {
try {
const done = await approveNorthStarDraft({ read: a => call('project.file', a), save: a => call('project.file.save', a), project, draft, base, drafts });
return io.stdout.write(`approved ${SPEC_PATH} commit ${done.commit} from ${done.draft.path} ${done.draft.commit}\n`);
} catch (error) {
if (!(error instanceof OnboardingError)) throw asCli(error);
const exit = exitOf(error);
const unknown = exit === 1 ? ' The north star may have been saved; read the project before you approve again.' : '';
throw Object.assign(new CliError(`${error.code}: ${error.message}${unknown}`, exit), { code: error.code });
}
}
let spec, found = null;
try { spec = await call('project.file', { project, path: SPEC_PATH }); } catch (error) { throw asCli(error); }
try { found = await call('project.file', { project, path: drafts.northStar }); }
catch (error) { if (error?.code !== 'project-read-missing') throw asCli(error); }
const isTemplate = isTemplateNorthStar(spec.content);
const lines = [`${SPEC_PATH} commit ${spec.commit} ${spec.at} ${isTemplate ? 'still the template' : `by ${spec.author}`}`];
if (!found) lines.push(`${drafts.northStar}: no draft yet${isTemplate ? '; the onboarding interview on the project page asks the assistant for one' : ''}`);
else {
lines.push(`${drafts.northStar} commit ${found.commit} ${found.at} by ${found.author}`, '', found.content.replace(/\n?$/, '\n'));
const business = parsed.values['--business'] ? ` --business ${parsed.values['--business']}` : '';
lines.push(found.content.trim() ? `approve this draft: mosaic project north-star ${project}${business} --approve ${found.commit} --base ${spec.commit}` : 'the draft is empty; nothing to approve');
}
io.stdout.write(`${lines.join('\n')}\n`);
}
export function interviewCommand(args, io, { template = loadInterviewTemplate } = {}) {
const parsed = parseArgs(args, INTERVIEW_USAGE);
if (parsed.positional.length !== 1 || !ID.test(parsed.positional[0])) throw new CliError(`project interview takes one records project id\n ${INTERVIEW_USAGE}`, 4);
let plan;
try { plan = template(); } catch (error) { throw new CliError(`the interview template is unusable: ${error.message}`, 3); }
io.stdout.write(`${interviewMessage(plan, { project: parsed.positional[0] })}\n`);
}
+5 -3
View File
@@ -14,7 +14,7 @@ import { rolesDir } from "./config.mjs";
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
export const THREAD_USAGE = [
"mosaic thread create <project> <name> [--business <id>] [--tester]",
"mosaic thread create <project> <name> [--instance <key>] [--business <id>] [--tester]",
"mosaic thread open <thread> [--session <id>] [--business <id>] [--tester]",
"mosaic thread read <thread> [--session <id>] [--business <id>] [--json] [--tester]",
].join("\n ");
@@ -132,7 +132,7 @@ export async function threadsCommand(args, io, callFor) {
export async function threadCommand(args, io, callFor, format) {
const [sub, ...rest] = args;
if (!["create", "open", "read"].includes(sub)) throw new CliError(`usage:\n ${THREAD_USAGE}`, 4);
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business", ...(sub === "create" ? [] : ["--session"])] });
const parsed = parseArgs(rest, THREAD_USAGE, { flags: sub === "read" ? ["--json", "--tester"] : ["--tester"], values: ["--business", ...(sub === "create" ? ["--instance"] : ["--session"])] });
if (parsed.values["--session"] !== undefined && !UUID.test(parsed.values["--session"])) throw new CliError("--session needs a full session id", 4);
if (parsed.positional.length !== (sub === "create" ? 2 : 1)) {
throw new CliError(`thread ${sub} takes ${sub === "create" ? "a project and a name" : "one thread"}\n${THREAD_USAGE}`, 4);
@@ -141,7 +141,9 @@ export async function threadCommand(args, io, callFor, format) {
if (sub === "create") {
const [ref, name] = parsed.positional;
const project = await resolveProject(call, ref, { business: call.business, env: io.env });
const made = await call("thread.create", { project: project.id, name });
// F2: --instance picks the conversation's instance, as the Console does; without it the bus takes the only one.
const instance = parsed.values["--instance"];
const made = await call("thread.create", { project: project.id, name, ...(instance === undefined ? {} : { instance }) });
return io.stdout.write(`created ${project.name}/${name} id ${made.id} instance ${made.instance}\n`);
}
const thread = await resolveThread(call, parsed.positional[0]);
+137
View File
@@ -0,0 +1,137 @@
// F2 (REQ-SETUP-2): mosaic project north-star, mosaic project interview and thread create --instance. The
// north-star runs go through a real broker and a real records repository (the bus spec fixture); the
// transport is the human's, as callFor gives it in tests.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { run } from '../src/cli.mjs';
import { io } from './helpers.mjs';
import { fixture, git } from '../../bus/tests/spec-fixtures.mjs';
import { interviewMessage, loadInterviewTemplate, SPEC_PATH } from '../../project/src/onboarding.mjs';
const shipped = name => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const DRAFT = loadInterviewTemplate().drafts.northStar;
async function setup(t) {
const f = await fixture(t, { roles: { assistant: { definition: 'assistant', authority: shipped('assistant').authority } } });
f.business.definitions = { assistant: { messaging: shipped('assistant').messaging } };
const project = f.call(f.human, 'project.create', { name: 'stack work', repo: 'stack' }).id;
const thread = f.call(f.human, 'thread.create', { project, name: 'Onboarding interview', instance: 'assistant' }).id;
f.assistant = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread });
f.broker.request(f.assistant, { verb: 'role.claim' });
f.calls = [];
const transport = business => async (verb, args) => {
f.calls.push(verb);
assert.equal(business, 'demo');
return structuredClone(await f.call(f.human, verb, args));
};
f.cli = async args => {
const x = io();
const code = await run(['project', 'north-star', ...args, '--business', 'demo'], x, { system: { dataRoot: f.root }, transport });
return { code, out: x.out.text, err: x.err.text };
};
f.propose = text => f.call(f.assistant, 'record.write', { project: 'stack', path: DRAFT, record: text });
return f;
}
test('north-star shows the template spec and says there is no draft yet', async t => {
const f = await setup(t);
const r = await f.cli(['stack']);
assert.equal(r.code, 0, r.err);
assert.match(r.out, new RegExp(`^${SPEC_PATH} commit ${f.commit} \\S+ still the template\\n`));
assert.ok(r.out.includes(`${DRAFT}: no draft yet; the onboarding interview on the project page asks the assistant for one`));
assert.deepEqual(f.calls, ['project.file', 'project.file']);
});
test('north-star shows the draft and the exact approve command; --approve writes the spec as the human', async t => {
const f = await setup(t);
const text = '# North star\n\nOnboard new organizations.\n';
const draft = await f.propose(text);
const shown = await f.cli(['stack']);
assert.equal(shown.code, 0, shown.err);
assert.ok(shown.out.includes(`${DRAFT} commit ${draft.commit}`));
assert.ok(shown.out.includes('by assistant.assistant'));
assert.ok(shown.out.includes(text));
const command = `mosaic project north-star stack --business demo --approve ${draft.commit} --base ${f.commit}`;
assert.ok(shown.out.trimEnd().endsWith(`approve this draft: ${command}`), shown.out);
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), draft.commit);
const approved = await f.cli(['stack', '--approve', draft.commit, '--base', f.commit]);
assert.equal(approved.code, 0, approved.err);
const head = git(f.repo, 'rev-parse', 'HEAD');
assert.equal(approved.out, `approved ${SPEC_PATH} commit ${head} from ${DRAFT} ${draft.commit}\n`);
assert.equal(git(f.repo, 'show', '-s', '--format=%an', head), 'jason');
assert.equal(git(f.repo, 'show', `HEAD:${SPEC_PATH}`), text.trimEnd());
const after = await f.cli(['stack']);
assert.match(after.out, new RegExp(`^${SPEC_PATH} commit ${head} \\S+ by jason\\n`));
});
test('north-star approve refusals: a rewritten draft, a moved spec and bad arguments write nothing', async t => {
const f = await setup(t);
const first = await f.propose('# First\n');
const second = await f.propose('# Second\n');
const before = git(f.repo, 'rev-parse', 'HEAD');
const stale = await f.cli(['stack', '--approve', first.commit, '--base', f.commit]);
assert.equal(stale.code, 2); assert.match(stale.err, /^mosaic: draft-stale: docs\/north-star-draft\.md changed since you reviewed it/);
const base = await f.cli(['stack', '--approve', second.commit, '--base', second.commit]);
assert.equal(base.code, 2); assert.match(base.err, /^mosaic: stale-base: the north star was not confirmed saved/);
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before);
for (const [args, message] of [
[['stack', '--approve', second.commit], /--approve and --base go together/],
[['stack', '--base', f.commit], /--approve and --base go together/],
[['stack', '--approve', 'abc', '--base', f.commit], /each take a full 40-character commit/],
[[], /project north-star takes one records project/],
[['a', 'b'], /project north-star takes one records project/],
[['../x'], /is not a records project id/],
[['stack', '--draft', 'x'], /unknown option --draft/],
]) {
const r = await f.cli(args);
assert.equal(r.code, 4, JSON.stringify(args)); assert.match(r.err, message);
}
assert.equal(git(f.repo, 'rev-parse', 'HEAD'), before);
});
test('north-star as the human refuses inside an agent, and a bus refusal keeps its exit code', async t => {
const f = await setup(t);
const x = io({ ...io().env, MOSAIC_RUN_ID: 'r1' });
const refused = await run(['project', 'north-star', 'stack', '--business', 'demo'], x, { system: { dataRoot: f.root }, transport: () => async () => assert.fail('no bus call inside an agent') });
assert.equal(refused, 3); assert.match(x.err.text, /runs only from a human shell/);
const denied = io();
const human = async () => { throw Object.assign(new Error('human-required'), { code: 'human-required' }); };
assert.equal(await run(['project', 'north-star', 'stack', '--business', 'demo'], denied, { system: { dataRoot: f.root }, transport: () => human }), 3);
// run() adds its other-tree hint after a human-required refusal, as for every bus command.
assert.ok(denied.err.text.startsWith('mosaic: human-required\n'), denied.err.text);
const missing = io();
const none = async () => { throw Object.assign(new Error('project-not-declared'), { code: 'project-not-declared' }); };
assert.equal(await run(['project', 'north-star', 'other', '--business', 'demo'], missing, { system: { dataRoot: f.root }, transport: () => none }), 2);
});
test('project interview prints the first message from the template and contacts no bus', async () => {
const x = io();
assert.equal(await run(['project', 'interview', 'alpha'], x, { transport: () => assert.fail('no bus') }), 0);
assert.equal(x.out.text, `${interviewMessage(loadInterviewTemplate(), { project: 'alpha' })}\n`);
for (const args of [[], ['../x'], ['a', 'b'], ['alpha', '--business', 'demo']]) {
const bad = io();
assert.equal(await run(['project', 'interview', ...args], bad, {}), 4, JSON.stringify(args));
}
});
test('thread create passes --instance to the bus and leaves it out when not given', async () => {
const sent = [];
const transport = () => async (verb, args) => {
if (verb === 'plugins') return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
if (verb === 'project.list') return [{ id: '11111111-1111-4111-8111-111111111111', name: 'stack work' }];
sent.push([verb, args]);
return { id: '22222222-2222-4222-8222-222222222222', instance: args.instance ?? 'assistant' };
};
for (const extra of [['--instance', 'assistant'], []]) {
const x = io();
const code = await run(['thread', 'create', 'stack work', 'Onboarding interview', ...extra, '--business', 'demo'], x, { system: { dataRoot: '/nonexistent' }, transport });
assert.equal(code, 0, x.err.text);
assert.match(x.out.text, /^created stack work\/Onboarding interview id 22222222-.* instance assistant\n$/);
}
assert.deepEqual(sent, [
['thread.create', { project: '11111111-1111-4111-8111-111111111111', name: 'Onboarding interview', instance: 'assistant' }],
['thread.create', { project: '11111111-1111-4111-8111-111111111111', name: 'Onboarding interview' }],
]);
});
+144
View File
@@ -0,0 +1,144 @@
// 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 ${q.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 {${name}}`);
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: ${error.message}`); }
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 } };
}
@@ -0,0 +1,26 @@
{
"templateVersion": 1,
"id": "mosaic.onboarding-interview",
"threadName": "Onboarding interview",
"drafts": {
"northStar": "docs/north-star-draft.md",
"prd": "docs/prd-1.0.md",
"requirementPrefix": "REQ-"
},
"questions": [
{ "key": "outcome", "text": "What outcome should this project achieve?" },
{ "key": "audience", "text": "Who is it for, and what do they need from it?" },
{ "key": "limits", "text": "What limits must it respect, and what is out of scope?" },
{ "key": "success", "text": "How will we know it worked? Name the measures." }
],
"prompt": [
"Let's start project {project} with an onboarding interview.",
"",
"Ask me these questions one at a time. Wait for my answer before you ask the next one, and ask a follow-up when an answer is too vague to write down:",
"{questions}",
"",
"When you have every answer, write a draft north star with write_record at {northStarDraft}. Use my words where you can. Then tell me the draft is ready. I review and approve it on the project page, or with `mosaic project north-star {project}`. You can't write spec/north-star.md yourself, so don't try.",
"",
"After I approve the north star, write a first PRD with write_record at {prdDraft}, and one requirement record per requirement at requirements/{requirementPrefix}<n>.json with status draft. Then ask me whether to send it to the chief of staff for PRD intake. Approving a PRD is my decision."
]
}
+172
View File
@@ -0,0 +1,172 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import * as api from '../src/index.mjs';
import {
approveNorthStarDraft, INTERVIEW_TEMPLATE_URL, interviewMessage, isTemplateNorthStar, loadInterviewTemplate,
MESSAGE_MAX, northStarTemplateText, OnboardingError, SPEC_PATH, validateInterviewTemplate,
} from '../src/onboarding.mjs';
// F2 (REQ-SETUP-2): the interview template and the north-star proposal-plus-approval path.
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const shipped = () => JSON.parse(readFileSync(INTERVIEW_TEMPLATE_URL, 'utf8'));
const A = 'a'.repeat(40), B = 'b'.repeat(40), C = 'c'.repeat(40);
const DRAFTS = { northStar: 'docs/north-star-draft.md', prd: 'docs/prd-1.0.md', requirementPrefix: 'REQ-' };
test('the shipped template is valid, asks the four questions in order and renders a first message', () => {
const template = loadInterviewTemplate();
assert.deepEqual(validateInterviewTemplate(template), []);
assert.deepEqual(template.questions.map(q => q.key), ['outcome', 'audience', 'limits', 'success']);
assert.deepEqual(template.drafts, DRAFTS);
const message = interviewMessage(template, { project: 'alpha' });
assert.match(message, /^Let's start project alpha with an onboarding interview\./);
for (const [i, q] of template.questions.entries()) assert.ok(message.includes(`${i + 1}. ${q.text}`));
assert.ok(message.includes('write_record at docs/north-star-draft.md'));
assert.ok(message.includes('`mosaic project north-star alpha`'));
assert.ok(message.includes('requirements/REQ-<n>.json'));
assert.ok(message.includes("can't write spec/north-star.md"));
assert.doesNotMatch(message, /\{[a-zA-Z]+\}/);
assert.ok(message.length <= MESSAGE_MAX);
assert.throws(() => interviewMessage(template, { project: '../x' }), { code: 'invalid-request' });
});
test('a replacement template is checked key by key and every problem is named', () => {
const cases = [
[t => { t.extra = 1; }, 'template keys must be exactly'],
[t => { t.templateVersion = 2; }, 'templateVersion must be 1'],
[t => { t.id = '../x'; }, 'id must be a record id'],
[t => { t.threadName = 'two\nlines'; }, 'threadName must be one line'],
[t => { t.threadName = 'x'.repeat(121); }, 'threadName must be one line'],
[t => { t.drafts.northStar = 'spec/north-star.md'; }, 'drafts.northStar must be a Markdown doc directly under docs/'],
[t => { t.drafts.northStar = 'docs/sub/x.md'; }, 'drafts.northStar must be a Markdown doc directly under docs/'],
[t => { t.drafts.prd = 'docs/prd.md'; }, 'drafts.prd must be docs/prd-<version>.md'],
[t => { t.drafts.northStar = t.drafts.prd; }, 'drafts.northStar and drafts.prd must differ'],
[t => { t.drafts.requirementPrefix = '-x'; }, 'drafts.requirementPrefix must start a record id'],
[t => { delete t.drafts.prd; }, 'drafts keys must be exactly'],
[t => { t.questions = []; }, 'questions must list 1-12 questions'],
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats outcome'],
[t => { t.questions[0].key = 'Bad Key'; }, 'questions[0].key must be lower-case'],
[t => { t.questions[0].text = ''; }, 'questions[0].text must be one line'],
[t => { t.questions[0].extra = 1; }, 'questions[0] keys must be exactly key, text'],
[t => { t.prompt = ['line\nbreak']; }, 'prompt must be 1-200 lines'],
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder {secret}'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{questions}', 'the questions')); }, 'prompt must use {questions}'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{northStarDraft}', 'a doc')); }, 'prompt must use {northStarDraft}'],
];
for (const [edit, problem] of cases) {
const t = shipped(); edit(t);
const problems = validateInterviewTemplate(t);
assert.ok(problems.some(p => p.startsWith(problem)), `${problem}: got ${JSON.stringify(problems)}`);
assert.throws(() => interviewMessage(t, { project: 'alpha' }), { code: 'onboarding-template-invalid' });
}
assert.deepEqual(validateInterviewTemplate(null), ['template must be a JSON object']);
const long = shipped(); long.prompt = Array(20).fill(`${'x'.repeat(1960)} {questions} {northStarDraft}`);
assert.deepEqual(validateInterviewTemplate(long), []);
assert.throws(() => interviewMessage(long, { project: 'alpha' }), { code: 'onboarding-template-invalid', message: /longer than 32768/ });
});
test('loadInterviewTemplate reads the file a replacement names and refuses one that is unreadable or invalid', t => {
const root = mkdtempSync(join(tmpdir(), 'onboarding-template-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const good = shipped(); good.threadName = 'Kickoff'; good.questions = good.questions.slice(0, 2);
writeFileSync(join(root, 'good.json'), JSON.stringify(good));
assert.equal(loadInterviewTemplate(join(root, 'good.json')).threadName, 'Kickoff');
writeFileSync(join(root, 'broken.json'), '{');
assert.throws(() => loadInterviewTemplate(join(root, 'broken.json')), { code: 'onboarding-template-invalid', message: /unreadable/ });
writeFileSync(join(root, 'bad.json'), JSON.stringify({ ...good, templateVersion: 9 }));
assert.throws(() => loadInterviewTemplate(join(root, 'bad.json')), { code: 'onboarding-template-invalid', message: /templateVersion must be 1/ });
assert.throws(() => loadInterviewTemplate(join(root, 'missing.json')), { code: 'onboarding-template-invalid' });
});
test('the template check ignores line endings and outer blank lines only', () => {
const text = northStarTemplateText();
assert.equal(isTemplateNorthStar(text), true);
assert.equal(isTemplateNorthStar(`\n${text.replace(/\n/g, '\r\n')}\n\n`), true);
assert.equal(isTemplateNorthStar(`${text}\nOne more line.\n`), false);
assert.equal(isTemplateNorthStar(undefined), false);
});
test('approval refuses before any read on bad input and never saves a draft it did not read as reviewed', async () => {
const calls = [];
const read = async args => { calls.push(['read', args]); return { content: '# Draft\n', commit: A }; };
const save = async args => { calls.push(['save', args]); return { path: SPEC_PATH, commit: C }; };
const ok = { read, save, project: 'stack', draft: A, base: B, drafts: DRAFTS };
for (const [over, code] of [
[{ read: undefined }, 'not-configured'], [{ save: null }, 'not-configured'],
[{ project: '../x' }, 'invalid-request'], [{ draft: 'abc' }, 'invalid-request'], [{ base: A.toUpperCase() }, 'invalid-request'],
[{ drafts: { northStar: 'spec/north-star.md' } }, 'onboarding-template-invalid'], [{ drafts: undefined }, 'onboarding-template-invalid'],
]) await assert.rejects(approveNorthStarDraft({ ...ok, ...over }), e => e instanceof OnboardingError && e.code === code && e.stage === 'check');
assert.deepEqual(calls, []);
// The draft moved on since the human read it: refused at the read, nothing saved.
await assert.rejects(approveNorthStarDraft({ ...ok, draft: B }), { code: 'draft-stale', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => ({ content: ' \n', commit: A }) }), { code: 'draft-empty', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => ({ content: '# x' }) }), { code: 'invalid-response', stage: 'read' });
await assert.rejects(approveNorthStarDraft({ ...ok, read: async () => { throw Object.assign(Error('gone'), { code: 'project-read-missing' }); } }),
e => e.code === 'project-read-missing' && e.stage === 'read' && e.cause.message === 'gone');
assert.deepEqual(calls.filter(([kind]) => kind === 'save'), []);
// A save refusal keeps its code; a reply without the new commit is an unknown outcome.
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => { throw Object.assign(Error('stale'), { code: 'stale-base' }); } }), { code: 'stale-base', stage: 'save' });
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => ({ path: SPEC_PATH }) }), { code: 'outcome-unknown', stage: 'save' });
await assert.rejects(approveNorthStarDraft({ ...ok, save: async () => { throw new Error('socket closed'); } }), { code: 'outcome-unknown', stage: 'save' });
calls.length = 0;
assert.deepEqual(await approveNorthStarDraft(ok), { path: SPEC_PATH, commit: C, draft: { path: DRAFTS.northStar, commit: A } });
assert.deepEqual(calls, [
['read', { project: 'stack', path: DRAFTS.northStar }],
['save', { project: 'stack', path: SPEC_PATH, base: B, content: '# Draft\n' }],
]);
});
async function repoFixture(t) {
const root = mkdtempSync(join(tmpdir(), 'onboarding-repo-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } }, project: 'stack', dataRoot: root };
const { repo } = await api.initProject(context);
const assistant = { definition: 'assistant', instance: 'assistant' };
const human = {
read: args => api.readProjectFile({ ...context, ...args }),
save: args => api.writeHumanFile({ ...context, ...args }),
};
return { context, repo, assistant, human };
}
test('the assistant proposes a draft doc, cannot write the spec, and only the human approval writes it', async t => {
const { context, repo, assistant, human } = await repoFixture(t);
const before = await api.readSpec(context);
assert.equal(isTemplateNorthStar(before.content), true);
// The agent writer refuses spec/ outright: no approval, no write.
await assert.rejects(api.writeRecord({ ...context, path: SPEC_PATH, record: '# Mine\n', author: assistant }), { code: 'project-spec-human-only' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), before.commit);
const text = '# North star\n\nShip the onboarding interview.\n\n## Who it is for\n\nNew organizations.\n';
const draft = await api.writeRecord({ ...context, path: DRAFTS.northStar, record: text, author: assistant });
assert.equal(git(repo, 'show', '-s', '--format=%an', draft.commit), 'assistant.assistant');
// Without the approval the spec is still the template.
const unchanged = await api.readSpec(context);
assert.equal(unchanged.commit, before.commit); assert.equal(isTemplateNorthStar(unchanged.content), true);
// A base older than the spec the human saw refuses in the save and writes nothing.
const head = git(repo, 'rev-parse', 'HEAD');
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: draft.commit, drafts: DRAFTS }), { code: 'stale-base', stage: 'save' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), head);
const spec = await api.readProjectFile({ ...context, path: SPEC_PATH });
const done = await approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: spec.commit, drafts: DRAFTS });
assert.equal(done.path, SPEC_PATH); assert.deepEqual(done.draft, { path: DRAFTS.northStar, commit: draft.commit });
assert.equal(git(repo, 'rev-parse', 'HEAD'), done.commit);
assert.equal(git(repo, 'show', '-s', '--format=%an|%s', done.commit), 'jason|Edit spec/north-star.md');
assert.equal(git(repo, 'show', `${done.commit}:${SPEC_PATH}`), text.trimEnd());
const after = await api.readSpec(context);
assert.equal(after.content, text); assert.equal(after.confirmed, true);
assert.equal(git(repo, 'status', '--porcelain'), '');
// The draft the approval used stays as it was; a second approval against the old spec commit is stale.
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: draft.commit, base: spec.commit, drafts: DRAFTS }), { code: 'stale-base' });
});
test('a draft rewritten after the human read it refuses draft-stale and the spec is untouched', async t => {
const { context, repo, assistant, human } = await repoFixture(t);
const first = await api.writeRecord({ ...context, path: DRAFTS.northStar, record: '# First\n', author: assistant });
await api.writeRecord({ ...context, path: DRAFTS.northStar, record: '# Second\n', author: assistant });
const spec = await api.readProjectFile({ ...context, path: SPEC_PATH }), head = git(repo, 'rev-parse', 'HEAD');
await assert.rejects(approveNorthStarDraft({ ...human, project: 'stack', draft: first.commit, base: spec.commit, drafts: DRAFTS }), { code: 'draft-stale' });
assert.equal(git(repo, 'rev-parse', 'HEAD'), head);
assert.equal(isTemplateNorthStar((await api.readSpec(context)).content), true);
});