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:
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);
|
||||
});
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' }],
|
||||
]);
|
||||
});
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user