Files
stack/packages/cli/tests/north-star.test.mjs
jason.woltje 00ae2c718c 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.
2026-11-09 12:58:29 -06:00

138 lines
8.2 KiB
JavaScript

// 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' }],
]);
});