From 71a86287a386a3be25a6f04d20470a7f9c2b8e69 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:21:37 +0800 Subject: [PATCH] Add a delegation plan to the tree: set_plan, report_part, read_plan A coordinator can record its split of a task before delegating; each part gets a stable id a child can report with report_part (todo/doing/done/ blocked), and read_plan shows a Bot its own plan and its parent's. The coordinator's prompt shows one line: what is done, what is still open and what is blocked. The plan lives in the coordinator's field flybot-tree/plan and is worded by the pure src/plan.js. --- changelog.d/tree-plan.added.md | 1 + docs/subsystems/tree.md | 12 +++++- plugins/tree/src/plan.js | 74 ++++++++++++++++++++++++++++++++++ plugins/tree/src/tools.js | 69 +++++++++++++++++++++++++++++++ plugins/tree/src/tree.js | 11 ++++- plugins/tree/test/plan.test.js | 38 +++++++++++++++++ plugins/tree/test/tree.test.js | 19 +++++++++ 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 changelog.d/tree-plan.added.md create mode 100644 plugins/tree/src/plan.js create mode 100644 plugins/tree/test/plan.test.js diff --git a/changelog.d/tree-plan.added.md b/changelog.d/tree-plan.added.md new file mode 100644 index 000000000..5c45e6e6a --- /dev/null +++ b/changelog.d/tree-plan.added.md @@ -0,0 +1 @@ +A tree coordinator can now keep a delegation plan. `set_plan(goal, parts)` records the split before it delegates, each child reports progress with `report_part` (todo / doing / done / blocked), and `read_plan` shows a Bot its own plan and the one its parent gave it. The coordinator's prompt shows the plan as one line — what is done, what is still open, and what is blocked — so a split task is tracked instead of held in one prompt's memory. diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 2a07f80d2..ad81d2f51 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -26,6 +26,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). | `plugins/tree/src/organize.js` | Self-organization (#446): placement by role, role groups and leads, repair (over-full parents, orphans), daily tidy (merge, idle, archive), journal and undo. Exposes the shared primitives (`shared`) allocation uses. | | `plugins/tree/src/allocate.js` | Forward allocation (#447): `find_bots`, reuse before create, `create_child_bot(parent?)` placement, `create_team` (lead plus children), on `organize.js`'s shared primitives. | | `plugins/tree/src/roles.js` | Role profiles and similarity (name, role label, description, own prompt, recent tools; deterministic, `classify` hook). | +| `plugins/tree/src/plan.js` | The delegation plan (#TBD): pure `makePlan`/`reportPart`/`planLine` for the coordinator's split and its children's progress reports. | ## Entry points @@ -70,7 +71,14 @@ The tree organizes itself by role instead of leaving Bots outside it or a parent - **The owner is told afterwards**, no plan card first: the notice in the tree view, and the Main Bot's prompt names the last run (within 7 days, not undone) so Chief can say what moved. - **No silent pull-up**: freeing a slot moves nobody up; the next new Bot takes it. Activity is metadata only (when, which tool names), never message text. -### Optional Tree section toggle +### Delegation plan (#TBD) + +A coordinator may record its split of a task before it delegates, so a child can report progress back to it and the coordinator sees what is still open. + +- Three tools, visible to every tree member: `set_plan({ goal, parts })` saves the caller's plan (replacing any earlier one); `report_part({ part, status, note })` updates a part (a child reports to its parent's plan, a coordinator to its own), `status` one of `todo`/`doing`/`done`/`blocked`; `read_plan()` reads the caller's own plan and its parent's. `src/plan.js` (pure) validates and words them. +- The plan lives in the coordinator's own field `flybot-tree/plan`; a part's id (`p1`…) is stable within a plan and a report may name a part by its id or its name. The model caps and per-field writes match the tree's other fields. +- The coordinator's prompt shows one line: `Your delegation plan for "" (n/m done). Still open: …`, with blocked parts called out. It is refreshed through the same `refreshPrompt` path as every other tree note, so the marked block is replaced, never the user's own text. + - The Bot list itself uses the core `.bt-tree-toggle` (see [core-client](core-client.md)). When `sidebarSection: true` brings this plugin's separate Tree section back, `.flybot-tree-toggle` matches that control: 28×28 hit target, 16px inner `.flybot-tree-caret`, hover, focus ring, and rotation of the glyph only. Leaf spacers (`.flybot-tree-dot`) keep the same 28px width. `aria-expanded` and Expand/Collapse `{name}` labels stay on the button. - The right-click menu entry from `menuItems({ bot })` is a single **Copy invite link** (bare URL, shown when `social.canInvite(bot.id)`). The invite with instructions is offered only in the Bot's Sharing section, so the menu does not list the same invite twice (#173). @@ -81,7 +89,7 @@ The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment ## Tests -Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. The `#447` tests cover reuse of a same-role Bot at depth 3 and `find_bots` (scope, fields, ranking), `create_child_bot(parent)` refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, `create_team` (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; `test/host.test.js` runs `find_bots`, `create_team` and `create_child_bot(parent)` on the real host. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. +Covered by the plugin's own tests under `plugins/tree/` / core test suite as wired. The `#447` tests cover reuse of a same-role Bot at depth 3 and `find_bots` (scope, fields, ranking), `create_child_bot(parent)` refusing a non-descendant (sibling, ancestor, outsider, unknown) and checking limits at the target under its lock, `create_team` (Chief → FlyBot Lead → four members, one slot; reuse of the lead and members; a flat member's slot at a full level, journal and undo), later Bots joining their group's lead (only down leads of groups they belong to), and orphan reattachment; `test/host.test.js` runs `find_bots`, `create_team` and `create_child_bot(parent)` on the real host. The `#446` tests in `test/tree.test.js` cover role grouping (Life Lead, FlyBot Lead), existing leads, no unrelated fallback, the cap at every step, the daily tidy (merge, idle, archive), journal, undo and the client notice; `test/host.test.js` runs `create_bot` and the `tree/reorg` / `tree/undo` endpoints on the real host. `#16 Option B` in `test/tree.test.js` (and the matching host test) asserts a non-Main tree Bot can create a child under itself, that `maxChildren`/depth still refuse, and that the child is not auto-granted. The delegation plan (#TBD) is covered by `test/plan.test.js` (pure `makePlan`/`reportPart`/`planLine`) and a `test/tree.test.js` case: a coordinator's `set_plan` shows in its prompt, a child's `report_part` updates the parent's plan and the prompt line, `read_plan` shows own and parent, and bad arguments return an error. ## Common mistakes diff --git a/plugins/tree/src/plan.js b/plugins/tree/src/plan.js new file mode 100644 index 000000000..254bf0394 --- /dev/null +++ b/plugins/tree/src/plan.js @@ -0,0 +1,74 @@ +// The delegation plan (#TBD): a coordinator Bot's split of a task into parts, and the +// progress its children report back. It lives in the coordinator's own field +// `flybot-tree/plan`, so the tree view and the coordinator's prompt can show it, and a child +// reports through its parent edge without any new messaging. +// +// Pure: makePlan validates and normalizes, reportPart applies one report, planLine words the +// one-line view that goes into the coordinator's prompt. Nothing here touches storage. +export const PLAN_KEY = 'plan' +export const PLAN_MAX_PARTS = 12 +const STATUSES = ['todo', 'doing', 'done', 'blocked'] +export const PLAN_STATUSES = Object.freeze(STATUSES) +const GOAL_MAX = 300 +const NAME_MAX = 60 +const NOTE_MAX = 200 + +const clean = value => String(value ?? '').replace(/\s+/g, ' ').trim() +const norm = value => clean(value).toLowerCase() +const statusOf = value => (STATUSES.includes(String(value ?? '').toLowerCase()) ? String(value).toLowerCase() : undefined) + +/** + * A new plan from the model's arguments. `parts` is a list of names, or `{ name, owner? }`. + * Resolves `{ plan }` or `{ error }`. Each part gets a stable id `p1`… so a report can name it. + */ +export function makePlan({ goal, parts }, now = Date.now) { + const cleanGoal = clean(goal).slice(0, GOAL_MAX) + if (cleanGoal === '') return { error: 'a plan needs a goal' } + if (!Array.isArray(parts) || parts.length === 0) return { error: 'a plan needs at least one part' } + if (parts.length > PLAN_MAX_PARTS) return { error: `a plan holds at most ${PLAN_MAX_PARTS} parts` } + const seen = new Set() + const out = [] + for (const [index, raw] of parts.entries()) { + const name = clean(typeof raw === 'string' ? raw : raw?.name).slice(0, NAME_MAX) + const owner = clean(typeof raw === 'object' ? raw?.owner : '').slice(0, NAME_MAX) + if (name === '') return { error: `part ${index + 1} needs a name` } + if (seen.has(norm(name))) return { error: `two parts are named "${name}"` } + seen.add(norm(name)) + out.push({ id: `p${index + 1}`, name, ...(owner ? { owner } : {}), status: 'todo' }) + } + return { plan: { goal: cleanGoal, parts: out, at: now() } } +} + +/** + * Applies one report to a plan. `part` matches a part id (`p2`) or its name. Resolves + * `{ plan, part }` or `{ error }`. The plan is copied, never mutated in place. + */ +export function reportPart(plan, { part, status, note }, now = Date.now) { + if (plan === undefined || plan === null || !Array.isArray(plan.parts)) return { error: 'there is no plan to report on' } + const wanted = norm(part) + if (wanted === '') return { error: 'name the part to report on (its id or its name)' } + const at = plan.parts.find(item => item.id === String(part).trim() || norm(item.name) === wanted) + if (at === undefined) return { error: `no part in the plan is "${clean(part)}"` } + const next = statusOf(status) + if (next === undefined) return { error: `status must be one of ${STATUSES.join(', ')}` } + const cleanNote = clean(note).slice(0, NOTE_MAX) + const updated = { ...at, status: next, ...(cleanNote ? { note: cleanNote } : {}), at: now() } + const parts = plan.parts.map(item => (item === at ? updated : item)) + return { plan: { ...plan, parts, at: now() }, part: updated } +} + +/** + * The one-line prompt view: how far the plan is, and what is still open. Undefined when + * there is no plan. `blocked` parts are called out so a stuck part is not missed. + */ +export function planLine(plan) { + if (plan === undefined || plan === null || !Array.isArray(plan.parts) || plan.parts.length === 0) return undefined + const done = plan.parts.filter(item => item.status === 'done').length + const blocked = plan.parts.filter(item => item.status === 'blocked') + const open = plan.parts.filter(item => item.status !== 'done') + const head = `Your delegation plan for "${plan.goal}" (${done}/${plan.parts.length} done).` + if (open.length === 0) return `${head} Every part is done; combine their results.` + const shown = open.slice(0, PLAN_MAX_PARTS).map(item => `${item.name} [${item.status}${item.owner ? ` · ${item.owner}` : ''}]`).join('; ') + const stuck = blocked.length > 0 ? ` Blocked: ${blocked.map(item => item.name).join(', ')}.` : '' + return `${head} Still open: ${shown}.${stuck}` +} diff --git a/plugins/tree/src/tools.js b/plugins/tree/src/tools.js index 74b0cf1ae..2e86bc6de 100644 --- a/plugins/tree/src/tools.js +++ b/plugins/tree/src/tools.js @@ -1,5 +1,6 @@ import { MAX_NAME_LENGTH } from './limits.js' // = ds-bot roster's 40-character name truncation import { MAX_TEAM_LENGTH } from './allocate.js' +import { PLAN_KEY, makePlan, planLine, reportPart } from './plan.js' // Model tools. The caller comes from the tool context ({ botId }), never from arguments. A Bot // creates only in its own downstream: `parent` must be the caller or one of its descendants (#447). // Visible to every tree member (#16 Option B: not Main/root-only). maxChildren and depth are @@ -58,6 +59,26 @@ export const CREATE_TEAM_SCHEMA = Object.freeze({ required: ['team', 'members'], additionalProperties: false, }) +export const SET_PLAN_SCHEMA = Object.freeze({ + type: 'object', + properties: { + goal: { type: 'string', minLength: 1, maxLength: 300, description: 'One line: what the whole task is.' }, + parts: { type: 'array', items: { type: 'string' }, description: 'The parts you split it into, one short name each, in the wording a child will recognize. At most 12.' }, + }, + required: ['goal', 'parts'], + additionalProperties: false, +}) +export const REPORT_PART_SCHEMA = Object.freeze({ + type: 'object', + properties: { + part: { type: 'string', minLength: 1, maxLength: 60, description: 'The part you report on: its id (p2) or its name.' }, + status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'], description: 'done when finished; blocked when it cannot go on.' }, + note: { type: 'string', maxLength: 200, description: 'One short line of result or reason, when it helps.' }, + }, + required: ['part', 'status'], + additionalProperties: false, +}) +export const READ_PLAN_SCHEMA = Object.freeze({ type: 'object', properties: {}, additionalProperties: false }) export function registerTreeTools(fb, tree) { const inTree = botId => tree.isMember(botId) // visibleTo is sync @@ -109,5 +130,53 @@ export function registerTreeTools(fb, tree) { try { return await tree.createTeam(botId, { team: args.team, members: args.members, parent: args.parent }) } catch (e) { return fail(e) } }, }), + fb.registerTool({ + name: 'set_plan', + description: 'Record your split of a task into parts before you delegate (a coordinator). Each part gets an id (p1…) so a child can report with report_part. Replaces any earlier plan of yours.', + parameters: SET_PLAN_SCHEMA, + visibleTo: inTree, + async execute(args, { botId }) { + const bad = validateArgs(SET_PLAN_SCHEMA, args) + if (bad) return fail(bad) + const { plan, error } = makePlan({ goal: args.goal, parts: args.parts }) + if (error) return fail(error) + fb.setField(botId, PLAN_KEY, plan) + tree.refreshPrompt(botId) + return { plan: plan.parts.map(item => ({ id: item.id, name: item.name })), note: `Plan saved (${plan.parts.length} parts). Delegate each part to the child whose scope fits; each reports back with report_part.` } + }, + }), + fb.registerTool({ + name: 'report_part', + description: 'Report progress on a plan part. A child reports to the plan of its parent; a coordinator reports on its own plan. status: todo, doing, done or blocked.', + parameters: REPORT_PART_SCHEMA, + visibleTo: inTree, + async execute(args, { botId }) { + const bad = validateArgs(REPORT_PART_SCHEMA, args) + if (bad) return fail(bad) + const own = fb.getField(botId, PLAN_KEY) + const parent = tree.parentOf(botId) + const targetId = (own !== undefined && own !== null) || !tree.isMember(parent) ? botId : parent + const { plan, part, error } = reportPart(fb.getField(targetId, PLAN_KEY), { part: args.part, status: args.status, note: args.note }) + if (error) return fail(error) + fb.setField(targetId, PLAN_KEY, plan) + tree.refreshPrompt(targetId) + const who = targetId === botId ? 'your plan' : `${tree.snapshot()[targetId]?.name ?? 'your parent'}'s plan` + return { part: { id: part.id, name: part.name, status: part.status }, note: `Reported "${part.name}" as ${part.status}, on ${who}.` } + }, + }), + fb.registerTool({ + name: 'read_plan', + description: 'Read your own plan, and as a child the plan your parent made, with each part id, name, owner and status.', + parameters: READ_PLAN_SCHEMA, + visibleTo: inTree, + async execute(args, { botId }) { + const bad = validateArgs(READ_PLAN_SCHEMA, args ?? {}) + if (bad) return fail(bad) + const own = fb.getField(botId, PLAN_KEY) ?? null + const parent = tree.parentOf(botId) + const fromParent = tree.isMember(parent) ? (fb.getField(parent, PLAN_KEY) ?? null) : null + return { plan: own, parent: fromParent, note: own ? (planLine(own) ?? 'Your plan is empty.') : 'You have no plan. Set one with set_plan before delegating.' } + }, + }), ] } diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index bc2ff27dc..2c0fd9379 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -12,6 +12,7 @@ import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js' import { forestOf } from './forest.js' import { createOrganizer } from './organize.js' import { createAllocator } from './allocate.js' +import { PLAN_KEY, planLine } from './plan.js' export { forestOf } export const TREE_KEYS = new Set(['parent', 'treeRole']) @@ -38,7 +39,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const promptFor = (id, nodes = snapshot()) => { const n = nodes[id] - return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: notesFor(id, n), limits }) + return n && systemPrompt({ name: n.name, role: n.role, trace: traceOf(nodes, id), children: childrenOf(nodes, id), root: n.root, notes: [...notesFor(id, n), ...planNotes(id)], limits }) } const stamp = at => { const d = new Date(at); const p = v => String(v).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}` } // The Main Bot hears what the last reorganization did (the owner sees it in the tree view); a kept @@ -56,6 +57,12 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis if (Array.isArray(merged) && merged.length) notes.push(`You took over the role of ${merged.map(m => m.name).join(', ')} (merged into you; the old Bot is archived with its own chat history).`) return notes } + // The delegation plan (#TBD) the Bot set, as one prompt line, so it sees its own split and + // the progress its children reported. + function planNotes(id) { + const line = planLine(fb.getField(id, PLAN_KEY)) + return line === undefined ? [] : [line] + } // Prompt injection: only the tree's marked block is replaced; other text (e.g. Chief's own) stays. const writeBlock = (id, block) => { const current = fb.getSystemPrompt(id) @@ -240,5 +247,5 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis const { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, idle: settled, repairArchiveNames } = organizer const { findBots, createChild: allocateChild, createTeam } = allocator - return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, createChild, allocateChild, createTeam, findBots, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } + return { tidy: tidyNow, maybeTidy, undo, dismiss, overview, noteActivity, settled, repairArchiveNames, limits, forest, adoptUnattached, snapshot, childrenOf, depthOf, traceOf, ensureRoot, adoptChief, findChief, migrateLegacyRoots, promptFor, refreshPrompt, parentOf, createChild, allocateChild, createTeam, findBots, isMember, isRoot, rootOf, isTreeEdge, onFieldChange, onBotRemoved, subscribe } } diff --git a/plugins/tree/test/plan.test.js b/plugins/tree/test/plan.test.js new file mode 100644 index 000000000..8e0e54d69 --- /dev/null +++ b/plugins/tree/test/plan.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { makePlan, planLine, reportPart } from '../src/plan.js' + +test('makePlan validates, ids parts and cleans names', () => { + const { plan } = makePlan({ goal: ' Ship the launch ', parts: ['Research flights', { name: 'Book hotel', owner: 'Travel' }] }) + assert.equal(plan.goal, 'Ship the launch') + assert.deepEqual(plan.parts.map(p => [p.id, p.name, p.status]), [['p1', 'Research flights', 'todo'], ['p2', 'Book hotel', 'todo']]) + assert.equal(plan.parts[1].owner, 'Travel') + assert.match(makePlan({ goal: '', parts: ['x'] }).error, /goal/) + assert.match(makePlan({ goal: 'g', parts: [] }).error, /at least one part/) + assert.match(makePlan({ goal: 'g', parts: ['a', 'A'] }).error, /two parts are named/) + assert.match(makePlan({ goal: 'g', parts: Array.from({ length: 13 }, () => 'x') }).error, /at most 12/) +}) + +test('reportPart matches a part by id or name and returns a copy, not a mutation', () => { + const { plan } = makePlan({ goal: 'g', parts: ['Research', 'Book'] }) + const byId = reportPart(plan, { part: 'p2', status: 'doing', note: 'searching' }) + assert.equal(byId.part.status, 'doing') + assert.equal(plan.parts[1].status, 'todo', 'the original plan is untouched') + assert.equal(byId.plan.parts[1].note, 'searching') + const byName = reportPart(byId.plan, { part: 'research', status: 'done' }) + assert.equal(byName.plan.parts[0].status, 'done') + assert.match(reportPart(plan, { part: 'nope', status: 'done' }).error, /no part in the plan/) + assert.match(reportPart(plan, { part: 'p1', status: 'nearly' }).error, /status must be one of/) + assert.match(reportPart(undefined, { part: 'p1', status: 'done' }).error, /no plan to report on/) +}) + +test('planLine counts done and calls out blocked and open parts', () => { + assert.equal(planLine(undefined), undefined) + const { plan } = makePlan({ goal: 'Ship', parts: ['A', 'B'] }) + assert.match(planLine(plan), /Ship.* \(0\/2 done\).*Still open: A \[todo\]; B \[todo\]/) + const done = reportPart(reportPart(plan, { part: 'A', status: 'done' }).plan, { part: 'B', status: 'blocked' }).plan + assert.match(planLine(done), /\(1\/2 done\)/) + assert.match(planLine(done), /Blocked: B/) + const allDone = reportPart(done, { part: 'B', status: 'done' }).plan + assert.match(planLine(allDone), /Every part is done/) +}) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index e50824a7b..42337ef8b 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -1428,3 +1428,22 @@ test('#447: without parent, a new Bot goes only down leads of groups it belongs // Asked from inside FlyBot Lead, its dev group takes a dev Bot. assert.equal(f(m, (await child(m, team.team.lead.id, 'Style Checker', 'checks code style')).created.id, 'parent'), quality) }) + +test('#TBD: a coordinator plans, a child reports, and the plan shows in the prompt', async () => { + const m = await setup() + const work = await make(m, m.root, 'Work') + const saved = await m.call(m.root, 'set_plan', { goal: 'Ship the launch', parts: ['Research flights', 'Book hotel'] }) + assert.deepEqual(saved.plan.map(p => p.name), ['Research flights', 'Book hotel']) + assert.match(sp(m, m.root), /Your delegation plan for "Ship the launch"/) + const report = await m.call(work, 'report_part', { part: 'Book hotel', status: 'done', note: 'booked' }) + assert.equal(report.part.status, 'done') + assert.equal(f(m, m.root, 'plan').parts.find(p => p.name === 'Book hotel').status, 'done') + assert.match(sp(m, m.root), /\(1\/2 done\)\)?\. Still open: Research flights \[todo\]/) + // A child reads its own (none) and its parent's plan. + const read = await m.call(work, 'read_plan', {}) + assert.equal(read.plan, null) + assert.equal(read.parent.goal, 'Ship the launch') + // Errors are reported, not thrown. + assert.match((await m.call(m.root, 'set_plan', { goal: 'g', parts: [] })).error, /at least one part/) + assert.match((await m.call(work, 'report_part', { part: 'nope', status: 'done' })).error, /no part in the plan/) +})