diff --git a/packages/bus/tests/north-star-approval.test.mjs b/packages/bus/tests/north-star-approval.test.mjs new file mode 100644 index 000000000..dce5d541f --- /dev/null +++ b/packages/bus/tests/north-star-approval.test.mjs @@ -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); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index b1ee81e03..a42dd6937 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -36,8 +36,10 @@ mosaic project create [--repo ] [--business ] [--tester] mosaic project list [--archived] [--business ] [--json] [--tester] mosaic project archive [--business ] mosaic project unarchive [--business ] +mosaic project north-star [--business ] [--approve --base ] +mosaic project interview mosaic threads [--project ] [--business ] [--json] [--tester] -mosaic thread create [--business ] [--tester] +mosaic thread create [--instance ] [--business ] [--tester] mosaic thread open [--business ] [--tester] mosaic thread read [--business ] [--json] [--tester] mosaic talk --thread [--wait | --follow] [--business ] [--tester] diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 1a4c50f68..64481d160 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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"; @@ -63,6 +64,8 @@ export const USAGE = `usage: mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments ${PROJECT_USAGE} ${PROJECT_BUS_USAGE} + ${NORTH_STAR_USAGE} + ${INTERVIEW_USAGE} ${THREADS_USAGE} ${THREAD_USAGE} ${SESSION_USAGE} @@ -707,6 +710,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps)); 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)); diff --git a/packages/cli/src/onboarding.mjs b/packages/cli/src/onboarding.mjs new file mode 100644 index 000000000..18c0598b6 --- /dev/null +++ b/packages/cli/src/onboarding.mjs @@ -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 ` 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 [--business ] [--approve --base ]'; +// 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 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`); +} diff --git a/packages/cli/src/thread.mjs b/packages/cli/src/thread.mjs index f4199234a..7234708bf 100644 --- a/packages/cli/src/thread.mjs +++ b/packages/cli/src/thread.mjs @@ -14,7 +14,7 @@ import { rolesDir } from "./config.mjs"; export const THREADS_USAGE = "mosaic threads [--project ] [--business ] [--json] [--tester]"; export const THREAD_USAGE = [ - "mosaic thread create [--business ] [--tester]", + "mosaic thread create [--instance ] [--business ] [--tester]", "mosaic thread open [--session ] [--business ] [--tester]", "mosaic thread read [--session ] [--business ] [--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]); diff --git a/packages/cli/tests/north-star.test.mjs b/packages/cli/tests/north-star.test.mjs new file mode 100644 index 000000000..85bca1ec6 --- /dev/null +++ b/packages/cli/tests/north-star.test.mjs @@ -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' }], + ]); +}); diff --git a/packages/organization/tests/assistant.test.mjs b/packages/organization/tests/assistant.test.mjs index 821e86536..a50843333 100644 --- a/packages/organization/tests/assistant.test.mjs +++ b/packages/organization/tests/assistant.test.mjs @@ -160,6 +160,10 @@ test("the contract passes work to the chief of staff and directs no one", () => assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff,\s+naming\s+whose\s+ruling\s+it\s+is\s+and\s+which\s+role\s+it\s+concerns/); assert.match(text, /Don't\s+send\s+it\s+to\s+that\s+role\s+yourself/); assert.doesNotMatch(text, /to\s+the\s+role\s+it\s+concerns/); + // F2: the prose matches the record.write grant (write_record), and spec/ stays human-only. + assert.doesNotMatch(text, /You have no write tool/); + assert.match(text, /write_record commits a project document/); + assert.match(text, /never writes `spec\/`/); }); // Row 213 (C2-08, Case 2 design 4): companion and research hold it too, for the broker's web tools. diff --git a/packages/project/src/onboarding.mjs b/packages/project/src/onboarding.mjs new file mode 100644 index 000000000..6e0837e75 --- /dev/null +++ b/packages/project/src/onboarding.mjs @@ -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-.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 } }; +} diff --git a/packages/project/templates/onboarding-interview.json b/packages/project/templates/onboarding-interview.json new file mode 100644 index 000000000..4af6c9b10 --- /dev/null +++ b/packages/project/templates/onboarding-interview.json @@ -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}.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." + ] +} diff --git a/packages/project/tests/onboarding.test.mjs b/packages/project/tests/onboarding.test.mjs new file mode 100644 index 000000000..d65189b66 --- /dev/null +++ b/packages/project/tests/onboarding.test.mjs @@ -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-.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-.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); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index 9c32d0963..a75023cc0 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -24,6 +24,8 @@ baseline CLI. | Create a thread | `mosaic thread create --org ` | Planned enabled | | Open a thread | `mosaic thread open --org ` | Planned enabled | | Send a thread message | `mosaic talk --thread --org ` | Planned enabled | +| Start the onboarding interview (F2) | `mosaic thread create "Onboarding interview" --instance --org `, then `mosaic talk --thread "$(mosaic project interview )" --org ` | Enabled through the thread create and send routes above | +| Approve the north-star draft (F2) | `mosaic project north-star --org --approve --base ` | Enabled: copies the reviewed draft into `spec/north-star.md` through the human save (`project.file.save`) | | Archive or unarchive a chat project | `mosaic project archive` / `mosaic project unarchive` | Unavailable pending parity | | Open a room or send a room message | No CLI equivalent identified | Unavailable | | Share or unshare a thread | No CLI equivalent identified | Unavailable | diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 4569669e5..f8f7286d9 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -6,6 +6,7 @@ + diff --git a/packages/webui/src/public/onboarding.css b/packages/webui/src/public/onboarding.css new file mode 100644 index 000000000..4f2dc8622 --- /dev/null +++ b/packages/webui/src/public/onboarding.css @@ -0,0 +1,12 @@ +/* F2: the onboarding interview offer and the north-star draft on the project page. */ +.onboarding-panel { max-width:100%; } +.onboarding-panel p { max-width:72ch; } +.onboarding-questions { margin:8px 0 12px; padding-left:22px; } +.onboarding-questions li { margin:4px 0; } +.onboarding-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0 4px; } +.onboarding-actions .btn { display:inline-flex; align-items:center; min-height:44px; text-decoration:none; } +.onboarding-draft { max-height:320px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:8px 0; overflow-wrap:anywhere; } +.onboarding-draft:focus-visible { outline:2px solid var(--action, currentColor); outline-offset:2px; } +.onboarding-verb, .onboarding-status { color:var(--muted); font-size:13px; overflow-wrap:anywhere; } +.onboarding-status:empty { display:none; } +@media (max-width:699px) { .onboarding-actions .btn { width:100%; justify-content:center; } } diff --git a/packages/webui/src/public/onboarding.js b/packages/webui/src/public/onboarding.js new file mode 100644 index 000000000..72e68dbb5 --- /dev/null +++ b/packages/webui/src/public/onboarding.js @@ -0,0 +1,134 @@ +/* F2 (REQ-SETUP-2): the onboarding interview after Quick Setup, and the north-star draft on the project page. + The page holds no authority of its own. The interview opens through the chat routes the Projects page + already uses; the assistant writes its draft with its own record grant; Approve posts the draft and spec + commits the user saw to /api/north-star/approve, which saves through the human save verb. */ +(() => { + 'use strict'; + const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, COMMIT = /^[0-9a-f]{40}$/; + const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + const normal = text => String(text ?? '').replace(/\r\n?/g, '\n').trim(); + // An explicit organization is addressed directly, so a page shown inside another organization still + // reaches the one Quick Setup just made. Without one, display.js scopes /api/ to the current organization. + const base = org => org ? `/api/orgs/${encodeURIComponent(org)}/` : '/api/'; + async function call(url, body) { + const init = body === undefined ? { cache: 'no-store', redirect: 'manual' } + : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), redirect: 'manual' }; + let response; + try { response = await fetch(url, init); } catch { return { status: 0, body: { error: 'network', sent: body === undefined ? undefined : 'unknown' } }; } + const value = await response.json().catch(() => null); + return { status: response.status, body: value && typeof value === 'object' ? value : { error: 'unreadable', ...(body === undefined ? {} : { sent: 'unknown' }) } }; + } + const interviews = new Map(); + function interview(org, repo) { + const key = `${org || ''}\0${repo}`; + if (!interviews.has(key)) interviews.set(key, call(`${base(org)}onboarding/interview?project=${encodeURIComponent(repo)}`).then(r => { + if (r.status !== 200) { interviews.delete(key); throw Object.assign(Error(r.body.message || `The interview could not be read (${r.body.error || r.status}).`), { code: r.body.error }); } + return r.body; + })); + return interviews.get(key); + } + const refusal = (r, what) => r.body.sent === 'unknown' || r.status === 0 + ? `${what}: the outcome is unknown. Check Conversations before you try again.` + : `${what} was refused (${r.body.message || r.body.error || r.status}).`; + + // Opens a conversation with the assistant in the chat project and sends the interview's first message. + // `chatProject` is the chat project id, `repo` the records project the drafts go to. + async function start({ org = null, chatProject, repo, status = () => {} }) { + if (!UUID.test(chatProject || '') || !ID.test(repo || '')) { status('This project cannot start an interview.'); return null; } + let plan; + try { plan = await interview(org, repo); } catch (e) { status(e.message); return null; } + if (!plan.assistant || typeof plan.assistant.instance !== 'string') { status('No assistant is configured for this organization, so the interview cannot start.'); return null; } + status('Opening the interview conversation…'); + let created; + // A second interview in one project gets a numbered name; the first one stays where it was. + for (let n = 1; n <= 9; n++) { + created = await call(`${base(org)}chat/threads`, { project: chatProject, name: n === 1 ? plan.threadName : `${plan.threadName} ${n}`, instance: plan.assistant.instance }); + if (created.status !== 409 || created.body.error !== 'thread-exists') break; + } + if (created.status !== 200 && created.status !== 201) { status(refusal(created, 'Opening the conversation')); return null; } + if (!UUID.test(created.body.id || '')) { status('Opening the conversation: the reply could not be read, so its outcome is unknown. Check Conversations before you try again.'); return null; } + const thread = created.body.id; + const sent = await call(`${base(org)}chat/threads/${thread}/messages`, { body: plan.message }); + const ok = (sent.status === 200 || sent.status === 201) && UUID.test(sent.body.id || ''); + go(org, thread); + if (!ok) status(`${refusal(sent, 'The first interview message')} The conversation is open; send the interview message from there.`); + else status(`Interview started with ${plan.assistant.name}.`); + return { thread, sent: ok }; + } + function go(org, thread) { + const current = window.MosaicOrg?.id ?? null; + if (!org || org === current) { location.hash = `#/projects/threads/${thread}`; return; } + history.replaceState(null, '', `#/orgs/${encodeURIComponent(org)}/projects/threads/${thread}`); + location.reload(); + } + + // The project page's panel. It offers the interview while the north star is still the template + // project init wrote, and shows the assistant's draft with Approve when the draft is newer than the + // north star and says something else. + const runs = new WeakMap(), notes = new Map(), drafts = new Map(); + async function offer(container, { project, section, north, docs, threads = [], canSend = false }) { + const run = (runs.get(container) || 0) + 1; runs.set(container, run); + const slot = () => container.parentElement?.querySelector(':scope > [data-onboarding]'); + const clear = () => slot()?.remove(); + if (!['overview', 'requirements'].includes(section) || project?.archived || !ID.test(project?.repo || '') || typeof north?.content !== 'string' || !COMMIT.test(north.commit || '')) return clear(); + let plan; + try { plan = await interview(null, project.repo); } catch { return clear(); } + const listed = Array.isArray(docs?.files) && docs.files.some(f => f?.path === plan.drafts.northStar); + let draft = null; + if (listed) { + const key = `${project.repo}\0${north.commit}`; + const r = await call(`/api/project-file?project=${encodeURIComponent(project.repo)}&path=${encodeURIComponent(plan.drafts.northStar)}`); + if (r.status === 200 && typeof r.body.content === 'string' && COMMIT.test(r.body.commit || '')) { draft = r.body; drafts.set(key, draft); } + else draft = drafts.get(key) ?? null; + } + // Read on every offer: approval can become unavailable (an org bus going down) while the page stays open. + const config = await call('/api/config').then(r => r.status === 200 ? r.body : null); + if (runs.get(container) !== run || !container.isConnected) return; + const template = normal(north.content) === normal(plan.northStarTemplate); + // `committed` is each file's last commit time; `at` on a Console read is only when it was read. + const pending = draft && draft.content.trim() && normal(draft.content) !== normal(north.content) && Date.parse(draft.committed) >= Date.parse(north.committed); + const note = notes.get(project.repo); + if (!template && !pending && !note) return clear(); + const existing = threads.filter(t => !t.shared && t.project === project.id && (t.name === plan.threadName || t.name.startsWith(plan.threadName + ' '))); + const approve = config?.northStarApprove === true; + const verb = `mosaic project north-star ${project.repo}${window.MosaicOrg ? ` --business ${window.MosaicOrg.id}` : ''}`; + const signature = JSON.stringify([template, pending ? [draft.commit, north.commit] : null, existing.map(t => t.id), approve, canSend, notes.get(project.repo)]); + let panel = slot(); + if (panel?.dataset.signature === signature) return; + if (!panel) { panel = document.createElement('section'); panel.dataset.onboarding = ''; panel.className = 'project-panel onboarding-panel'; panel.setAttribute('aria-labelledby', 'onboarding-title'); container.before(panel); } + panel.dataset.signature = signature; + // After an approval the spec holds the draft, so neither offer applies; the receipt stays until reload. + if (!template && !pending) { panel.innerHTML = `

North star approved

${esc(note)}

`; return; } + panel.innerHTML = `

${pending ? 'North star draft ready' : 'Onboarding interview'}

` + + (pending + ? `

The assistant drafted a north star in ${esc(plan.drafts.northStar)} (commit ${esc(draft.commit.slice(0, 12))}). Read it below. Approving copies this exact draft into spec/north-star.md as your commit.

` + + (approve ? `
` : '

Approving is not available in this view.

') + + `

Same as ${esc(verb)} --approve ${esc(draft.commit)} --base ${esc(north.commit)}

` + : `

The north star is still the template. The assistant can interview you, one question at a time, and draft the north star and a first PRD for you to approve.

` + + `
    ${plan.questions.map(q => `
  1. ${esc(q.text)}
  2. `).join('')}
`) + + `
${existing.map(t => `Open ${esc(t.name)}`).join('')}` + + (!pending && canSend && plan.assistant ? `` : '') + + `
${!pending && !plan.assistant ? '

No assistant is configured, so the interview cannot start.

' : ''}

${esc(note || '')}

`; + const view = panel.querySelector('[data-onboarding-draft]'); + if (view) view.replaceChildren(window.MosaicMarkdown?.render(draft.content) || document.createTextNode(draft.content)); + const status = text => { const el = panel.querySelector('.onboarding-status'); if (el) el.textContent = text; }; + panel.querySelector('[data-onboarding-start]')?.addEventListener('click', async e => { + // Hold the button: the event's currentTarget is null once the await returns. + const button = e.currentTarget; button.disabled = true; + const done = await start({ chatProject: project.id, repo: project.repo, status }); + if (!done) button.disabled = false; + }); + panel.querySelector('[data-onboarding-approve]')?.addEventListener('click', async e => { + const button = e.currentTarget; button.disabled = true; status('Approving…'); + const r = await call('/api/north-star/approve', { project: project.repo, draft: draft.commit, base: north.commit }); + if (r.status === 200 && COMMIT.test(r.body.commit || '')) { + notes.set(project.repo, `Approved. spec/north-star.md is now commit ${r.body.commit.slice(0, 12)}. The product manager and CTO get the spec notice.`); + status(notes.get(project.repo)); button.remove(); + return; + } + status(r.body.sent === 'unknown' || r.status === 0 ? 'The approval may have been saved. Reload the project before you approve again.' : (r.body.message || `Not approved (${r.body.error || r.status}).`)); + if (r.body.sent === false) button.disabled = false; + }); + } + window.MosaicOnboarding = Object.freeze({ start, offer }); +})(); diff --git a/packages/webui/src/public/org-create.css b/packages/webui/src/public/org-create.css index 2017ae829..58bdbecbb 100644 --- a/packages/webui/src/public/org-create.css +++ b/packages/webui/src/public/org-create.css @@ -11,6 +11,8 @@ .org-inline input,.org-inline select{width:100%} .org-inline .org-help{grid-column:1/-1;grid-row:3} .settings-content .org-admin button.org-secondary{background:var(--surface);color:var(--text);border-color:var(--border)} +/* F2: the onboarding interview is the main next step after Quick Setup; Open organization skips it. */ +.settings-content .org-admin button.org-primary{background:var(--accent-fill);color:var(--on-accent);border-color:var(--accent-fill)} .settings-content .org-admin button:disabled{opacity:.55;cursor:not-allowed} .org-banner{margin:0 0 16px;padding:12px 16px;border:1px solid var(--line);border-radius:var(--r);background:var(--surface);color:var(--text);font-size:14px} .org-banner.err{border-color:color-mix(in srgb,var(--danger) 50%,var(--line));background:color-mix(in srgb,var(--danger) 8%,var(--surface))} diff --git a/packages/webui/src/public/org-create.js b/packages/webui/src/public/org-create.js index a41258340..8e35dcda7 100644 --- a/packages/webui/src/public/org-create.js +++ b/packages/webui/src/public/org-create.js @@ -38,7 +38,7 @@
    -
    +