openvibers/openvibe.codes Commits

The child return contract: a named path, 5 bullets, BLOCKED/CONCERNS (R1 item 5) (#381)

OP OpenVibers committed 3 days ago · 7c24606a0f · 8 files, +259 −4

* The child return contract: a named path, 5 bullets, BLOCKED/CONCERNS (R1 item 5)

A conductor names report_path when it starts a sub-task (report.md when it
names none); the child's brief carries the instruction after routing, so the
router still reads the parent's own words. What the parent reads on the show
is always the shaped return — the path, at most 5 bullets, the BLOCKED and
CONCERNS lines — parsed from the child's last word and bounded even when the
child ignored the shape. codes_task_start takes report_path, codes_task_show
hands the report back.

The plan points at engine/scope.js, but that file is systemd cgroups; the
return crosses at the task show, so the contract lives there instead.

* Merge remote-tracking branch 'origin/main' into codes-r1-childreturn

# Conflicts:
#	bin/mcp.js
#	server/http/conductor-api.js
#	test/conductor.test.js

Changes

ddiff --git a/bin/mcp.js b/bin/mcp.js
iindex 19e1a77..57f1bb6 100644
---- a/bin/mcp.js
++++ b/bin/mcp.js
@@@ -4,7 +4,7 @@
* `openvibe-codes mcp` — the Codes MCP server: the conductor's tools (Codes C9b). A stdio JSON-RPC server, started by
* the Claude Code session a conductor's turn runs, and usable from any Claude Code session with a Codes login.
*
-- * codes_task_start { prompt, repo, agent?, model? } a sub-task on one of the person's machines
++ * codes_task_start { prompt, repo, agent?, model?, report_path? } a sub-task on one of the person's machines
* codes_task_list { state? } running | needs | done
* codes_task_show { id } state, attempts, outcome, the last lines
* codes_task_continue { id, message } carry a task on in the same conversation
@@@ -70,6 +70,7 @@ const TOOLS = [
agent: { type: 'string', description: 'codex, opencode, command-code, deepseek, aider: never claude-code or claude-other' },
model: { type: 'string', description: 'The model for that agent (never an Anthropic one)' },
ref: { type: 'string', description: 'A branch or tag to start from' },
++ report_path: { type: 'string', description: 'Where the child writes its full output (default report.md); its return is that path plus at most 5 bullets and BLOCKED/CONCERNS lines' },
effort: { type: 'string', description: 'How hard it thinks: auto, low, medium, high, xhigh or max' },
},
required: ['prompt', 'repo'],
@@@ -139,6 +140,7 @@ async function startTask(args) {
const effort = typeof args.effort === 'string' && ['auto', 'low', 'medium', 'high', 'xhigh', 'max'].includes(args.effort) ? args.effort : null;
const answer = await call(c, 'POST', `/api/v1/conductor/${encodeURIComponent(task)}/tasks`, {
prompt, repo, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(args.ref ? { ref: String(args.ref).slice(0, 100) } : {}),
++ ...(args.report_path ? { report_path: String(args.report_path).slice(0, 200) } : {}),
...(effort ? { effort } : {}),
});
const t = answer.task || {};
@@@ -176,6 +178,7 @@ async function showTask(args) {
outcome: t.outcome ? { state: t.outcome.state, note: t.outcome.note } : null,
attempts: (t.attempts || []).map((a) => ({ n: a.n, agent: a.agent, model: a.model, machine: a.machine, state: a.state, why: a.why, end_why: a.end_why })),
summary: t.result && t.result.summary ? String(t.result.summary).slice(0, 4000) : null,
++ ...(t.report ? { report: t.report } : {}),
error: t.error || null,
last_lines: lines.map((l) => {
const b = l.body || {};
ddiff --git a/server/domain/child-report.js b/server/domain/child-report.js
nnew file mode 100644
iindex 0000000..c0625d2
---- /dev/null
++++ b/server/domain/child-report.js
@@@ -0,0 +1,120 @@
++'use strict';
++
++/**
++ * The child return contract (plan R1 item 5): what a conductor's sub-task hands back.
++ *
++ * The parent names the report path when it starts the child (report.md when it names none); the child writes
++ * its full output there and ends with a short report: at most 5 `- ` bullets, then any `BLOCKED: …` or
++ * `CONCERNS: …` lines. What the parent reads is always that shape — parseReport bounds even a child that
++ * ignored it (its first lines become the bullets), so the parent never gets a raw dump.
++ *
++ * checkPath(value) the stored path, or a 422
++ * instruction(path) the paragraph the child's brief carries
++ * parseReport(text, { path }) { path, bullets, blocked, concerns, truncated }
++ * of(row) the report for a task row, or null when it has none to give
++ */
++
++const DEFAULT_PATH = 'report.md';
++const MAX_BULLETS = 5;
++const MAX_LINE = 500;
++const MAX_FLAGGED = 10;
++
++/** A report path: relative, no climbing out, no absolute paths, no blanks. */
++const REPORT_PATH = /^(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
++
++/**
++ * The stored report path for a sub-task start.
++ *
++ * @param {*} value what the parent sent (absent: the default)
++ * @returns {string} the path the child writes
++ * @throws {{ status, code, message }} codes.report_path_invalid
++ */
++function checkPath(value) {
++ if (value == null || value === '') return DEFAULT_PATH;
++ const p = String(value).trim();
++ if (p.length > 200 || !REPORT_PATH.test(p)) {
++ throw { status: 422, code: 'codes.report_path_invalid', message: 'report_path is a relative path in the workspace (no .., no absolute path), at most 200 characters' };
++ }
++ return p;
++}
++
++/**
++ * The paragraph a sub-task's brief carries: where its full output goes, and the shape of its last word.
++ *
++ * @param {string} path the parent-named report path
++ * @returns {string} the instruction
++ */
++function instruction(path) {
++ return `When the work is done, write your full output to ${path} in the workspace, then end with a short report ` +
++ `for the task that started you: at most 5 lines starting with "- ", then any BLOCKED: … or CONCERNS: … lines ` +
++ `(leave a kind out when there is none). That report is what the parent reads; the file holds the rest.`;
++}
++
++const cut = (s) => String(s).trim().slice(0, MAX_LINE);
++const BULLET = /^\s*(?:-|\*|•)\s+(.*)$/;
++const FLAGGED = /^\s*(blocked|concerns)\s*:\s*(.*)$/i;
++
++/**
++ * Shape a child's last word into the contract: the path, at most 5 bullets, the BLOCKED and CONCERNS lines.
++ * A child that ignored the shape still comes back bounded: its first lines become the bullets.
++ *
++ * @param {*} text the child's summary
++ * @param {object} [opts] { path }
++ * @returns {{ path: string, bullets: string[], blocked: string[], concerns: string[], truncated: boolean }}
++ */
++function parseReport(text, { path = DEFAULT_PATH } = {}) {
++ const bullets = [];
++ const blocked = [];
++ const concerns = [];
++ let extraBullets = 0;
++ for (const raw of String(text == null ? '' : text).split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ const flag = line.match(FLAGGED);
++ if (flag && flag[2].trim()) {
++ const list = flag[1].toLowerCase() === 'blocked' ? blocked : concerns;
++ if (list.length < MAX_FLAGGED) list.push(cut(flag[2]));
++ continue;
++ }
++ const bullet = line.match(BULLET);
++ if (bullet && bullet[1].trim()) {
++ if (bullets.length < MAX_BULLETS) bullets.push(cut(bullet[1]));
++ else extraBullets++;
++ continue;
++ }
++ }
++ if (!bullets.length && !blocked.length && !concerns.length) {
++ for (const raw of String(text == null ? '' : text).split('\n')) {
++ const line = raw.trim();
++ if (!line) continue;
++ if (bullets.length < MAX_BULLETS) bullets.push(cut(line));
++ else { extraBullets++; break; }
++ }
++ }
++ return { path, bullets, blocked, concerns, truncated: extraBullets > 0 };
++}
++
++const asObject = (v) => {
++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
++ if (typeof v === 'string' && v.trim()) { try { const d = JSON.parse(v); if (d && typeof d === 'object' && !Array.isArray(d)) return d; } catch { /* not a report */ } }
++ return null;
++};
++
++/**
++ * The report a task row hands its parent: the shaped summary of a conductor's child, or null when the task
++ * is no child or has no last word yet (still running, or ended silent).
++ *
++ * @param {object} row the run_tasks row
++ * @returns {object|null} parseReport's shape, or null
++ */
++function of(row) {
++ const ref = asObject(row && row.actor_ref);
++ const child = ref && ref.conductor && typeof ref.conductor === 'object' ? ref.conductor : null;
++ if (!child) return null;
++ const summary = asObject(row.result);
++ const text = summary && typeof summary.summary === 'string' ? summary.summary : '';
++ if (!text.trim()) return null;
++ return parseReport(text, { path: typeof child.report_path === 'string' && child.report_path ? child.report_path : DEFAULT_PATH });
++}
++
++module.exports = { DEFAULT_PATH, MAX_BULLETS, checkPath, instruction, parseReport, of };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex 533d23b..a66a834 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -32,6 +32,7 @@
const { RunError } = require('./run-client');
const { chain, budget } = require('../../engine');
const { parseArgs } = require('../../harness/job');
++const childReport = require('./child-report');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const STATE_LABEL = {
@@@ -597,6 +598,12 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
const runArgs = { ...args, agent, model };
if (runArgs.goal) runArgs.prompt = `${runArgs.prompt}\n\nGoal: ${runArgs.goal.goal}\nCheck (${runArgs.goal.check.kind}): ${runArgs.goal.check.value}\nWork toward this outcome, show the check output as evidence, and propose completion when ready. The independent verifier decides whether the task is accepted.`;
++ // A conductor's child writes its full output where the parent named it and ends with the shaped return
++ // (server/domain/child-report.js). After routing, like the goal: the router reads the parent's own words.
++ if (actorRef && actorRef.conductor && typeof actorRef.conductor === 'object') {
++ const reportPath = typeof actorRef.conductor.report_path === 'string' && actorRef.conductor.report_path ? actorRef.conductor.report_path : childReport.DEFAULT_PATH;
++ runArgs.prompt = `${runArgs.prompt}\n\n${childReport.instruction(reportPath)}`;
++ }
// Know K5: the lessons and facts earlier runs learned, in the prompt this run's harness gets. A store that
// cannot be read is skipped, never an error: a run must not fail because the memory is unavailable. The
// project the run belongs to also scopes the team rows it may read.
ddiff --git a/server/http/conductor-api.js b/server/http/conductor-api.js
iindex ac8bebd..f84fa5f 100644
---- a/server/http/conductor-api.js
++++ b/server/http/conductor-api.js
@@@ -31,6 +31,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { parseArgs } = require('../../harness/job');
++const childReport = require('../domain/child-report');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -225,15 +226,22 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
const agent = wanted;
if (agent && !harnesses.list().some((h) => h.id === agent)) return problem(req, res, 422, 'codes.agent_unknown', `agent is one of GET /api/v1/harnesses, and not an Anthropic one`);
if (typeof b.repo !== 'string' || !/^https:\/\//.test(b.repo)) return problem(req, res, 422, 'codes.task_invalid', 'a sub-task needs the repository it works in (https://…)');
++ let reportPath;
++ try {
++ reportPath = childReport.checkPath(b.report_path);
++ } catch (e) {
++ return problem(req, res, e.status || 422, e.code || 'codes.task_invalid', e.message);
++ }
const effort = b.effort == null || b.effort === '' ? null : String(b.effort);
if (effort != null && !['auto', 'low', 'medium', 'high', 'xhigh', 'max'].includes(effort)) return problem(req, res, 422, 'codes.effort_invalid', 'effort is auto, low, medium, high, xhigh or max');
let args;
try {
++ // The router reads the parent's own words; the report instruction joins the brief after it picks.
args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}), permission: 'edit' }));
} catch (e) {
return problem(req, res, 422, 'codes.task_invalid', e.message);
}
-- const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id } } });
++ const task = await tasks.start({ subject: req.person.subject, project: row.project_id, args, via: 'conductor', key: null, actorRef: { conductor: { task_id: row.id, report_path: reportPath } } });
log.info(`[Codes] conductor ${row.id} started sub-task ${task.id} on ${agent || 'auto'}`);
return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });
});
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 8203892..31d0b4e 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -35,6 +35,7 @@ const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
const { createTaskEvents } = require('./task-events');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { expandMatrix } = require('../domain/bench');
++const childReport = require('../domain/child-report');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -142,10 +143,14 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
/** The task, its attempts and its outcome in plain words. The task's own fields stay at the top level. */
const timeline = async (row) => {
const attempts = await taskStore.attempts.list(row.id);
++ // A conductor's child hands its parent the shaped report (path, ≤5 bullets, BLOCKED/CONCERNS);
++ // anything else has none to give, and says nothing.
++ const report = childReport.of(row);
return {
...taskStore.present(row),
attempts: attempts.map((a) => taskStore.attempts.present(a)),
outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null },
++ ...(report ? { report } : {}),
};
};
ddiff --git a/test/child-report.test.js b/test/child-report.test.js
nnew file mode 100644
iindex 0000000..fb97b26
---- /dev/null
++++ b/test/child-report.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The child return contract (server/domain/child-report.js): the parent names the report path, the child's
++ * brief carries the instruction, and what the parent reads is always the path, at most 5 bullets and the
++ * BLOCKED/CONCERNS lines — bounded even when the child ignored the shape.
++ */
++const assert = require('node:assert/strict');
++const { check, done } = require('./helpers/boot');
++const childReport = require('../server/domain/child-report');
++
++(async () => {
++ await check('the path defaults, and a climbing or absolute one is 422', () => {
++ assert.equal(childReport.checkPath(undefined), 'report.md');
++ assert.equal(childReport.checkPath(''), 'report.md');
++ assert.equal(childReport.checkPath('notes/work.md'), 'notes/work.md');
++ for (const bad of ['/etc/report.md', '../evil.md', 'a/../../evil.md', '..', 'a/..', 'has space.md', 'x'.repeat(201)]) {
++ assert.throws(() => childReport.checkPath(bad), (e) => e.code === 'codes.report_path_invalid' && e.status === 422, bad);
++ }
++ });
++
++ await check('the instruction names the path and the shape', () => {
++ const text = childReport.instruction('notes/work.md');
++ assert.match(text, /notes\/work\.md/);
++ assert.match(text, /at most 5 lines starting with "- "/);
++ assert.match(text, /BLOCKED: … or CONCERNS: …/);
++ });
++
++ await check('a shaped return: 5 bullets, the flags, the rest cut', () => {
++ const r = childReport.parseReport('- one\n- two\n* three\n• four\n- five\n- six\nBLOCKED: the token\nconcerns: the flake\n', { path: 'r.md' });
++ assert.equal(r.path, 'r.md');
++ assert.deepEqual(r.bullets, ['one', 'two', 'three', 'four', 'five']);
++ assert.equal(r.truncated, true);
++ assert.deepEqual(r.blocked, ['the token']);
++ assert.deepEqual(r.concerns, ['the flake']);
++ });
++
++ await check('an unshaped child still comes back bounded: its first lines are the bullets', () => {
++ const r = childReport.parseReport('Did the parser.\n\nAlso the test.\nThird.\nFourth.\nFifth.\nSixth.\n', { path: 'r.md' });
++ assert.deepEqual(r.bullets, ['Did the parser.', 'Also the test.', 'Third.', 'Fourth.', 'Fifth.']);
++ assert.equal(r.truncated, true);
++ assert.deepEqual(r.blocked, []);
++ const empty = childReport.parseReport(' \n', { path: 'r.md' });
++ assert.deepEqual(empty.bullets, []);
++ assert.equal(empty.truncated, false);
++ });
++
++ await check('lines are cut short, and empty flags are left out', () => {
++ const r = childReport.parseReport(`- ${'x'.repeat(600)}\nBLOCKED: \nCONCERNS: ${'y'.repeat(600)}\n`, { path: 'r.md' });
++ assert.equal(r.bullets[0].length, 500);
++ assert.deepEqual(r.blocked, []);
++ assert.equal(r.concerns[0].length, 500);
++ });
++
++ await check('of(): a conductor child with a last word, else null', () => {
++ const child = { actor_ref: JSON.stringify({ conductor: { task_id: 'tsk_x', report_path: 'n.md' } }), result: { summary: '- done\nBLOCKED: none needed' } };
++ const r = childReport.of(child);
++ assert.equal(r.path, 'n.md');
++ assert.deepEqual(r.bullets, ['done']);
++ assert.deepEqual(r.blocked, ['none needed']);
++ assert.equal(childReport.of({ actor_ref: null, result: { summary: '- done' } }), null, 'no parent, no report');
++ assert.equal(childReport.of({ actor_ref: JSON.stringify({ conductor: { task_id: 'tsk_x' } }), result: null }), null, 'no last word yet');
++ assert.equal(childReport.of({ actor_ref: JSON.stringify({ conductor: { task_id: 'tsk_x' } }), result: { summary: ' ' } }), null, 'an empty word is none');
++ const plain = childReport.of({ actor_ref: { conductor: { task_id: 'tsk_x' } }, result: JSON.stringify({ summary: 'plain prose' }) });
++ assert.equal(plain.path, 'report.md', 'objects and strings both read, the default path when unnamed');
++ assert.deepEqual(plain.bullets, ['plain prose']);
++ });
++
++ done();
++})();
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
iindex 0deaba4..4a2dbc1 100644
---- a/test/conductor.test.js
++++ b/test/conductor.test.js
@@@ -6,7 +6,8 @@
*
* What this checks: the shape the task, its attempt and its first turn are written with; the hand-over brief; a message
* that waits while a turn runs and starts when it ends, resuming the session; an idle conductor (succeeded, no outcome)
-- * and a stopped one; and the server's rule that a conductor may start sub-tasks on any agent except an Anthropic one.
++ * and a stopped one; the server's rule that a conductor may start sub-tasks on any agent except an Anthropic one; and
++ * the child return contract (a named report path, a shaped return on the show).
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
@@@ -192,6 +193,36 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(subRow.via, 'conductor');
});
++ await check('a sub-task names its report path; its return comes back shaped', async () => {
++ const named = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'fix the parser', repo: 'https://github.com/octocat/Hello-World', report_path: 'notes/work.md' });
++ assert.strictEqual(named.status, 201, named.text);
++ const submit = submits().at(-1)[1];
++ assert.ok(submit.args.prompt.startsWith('fix the parser\n\n'), 'the brief comes first');
++ assert.match(submit.args.prompt, /write your full output to notes\/work\.md/, 'the brief carries the instruction');
++ const sub = named.json().task;
++ const stored = (await rowOf(sub.id)).actor_ref;
++ const ref = typeof stored === 'string' ? JSON.parse(stored) : stored;
++ assert.strictEqual(ref.conductor.report_path, 'notes/work.md');
++ const bad = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'x', repo: 'https://github.com/octocat/Hello-World', report_path: '../evil.md' });
++ assert.strictEqual(bad.status, 422, bad.text);
++ assert.strictEqual(bad.json().code, 'codes.report_path_invalid');
++ // The child ends shaped: six bullets, a block, a concern.
++ const row = await rowOf(sub.id);
++ const updated = await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job: { id: row.job_id, state: 'succeeded', result: { ok: true, summary: '- a\n- b\n- c\n- d\n- e\n- f\nBLOCKED: the token\nCONCERNS: the flake' } } });
++ await t.ctx.tasks.afterEnd(updated.id);
++ const shown = await get(`/api/v1/tasks/${sub.id}`);
++ assert.strictEqual(shown.status, 200, shown.text);
++ assert.deepStrictEqual(shown.json().report, { path: 'notes/work.md', bullets: ['a', 'b', 'c', 'd', 'e'], blocked: ['the token'], concerns: ['the flake'], truncated: true });
++ // A default path and unshaped prose: the first lines are the return.
++ const plain = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'tidy up', repo: 'https://github.com/octocat/Hello-World' });
++ const plainSub = plain.json().task;
++ const plainRow = await rowOf(plainSub.id);
++ const plainUpdated = await t.ctx.tasks.applyEvent(plainRow, { state: 'succeeded', job: { id: plainRow.job_id, state: 'succeeded', result: { ok: true, summary: 'Tidied.\nAll green.' } } });
++ await t.ctx.tasks.afterEnd(plainUpdated.id);
++ const plainShown = await get(`/api/v1/tasks/${plainSub.id}`);
++ assert.deepStrictEqual(plainShown.json().report, { path: 'report.md', bullets: ['Tidied.', 'All green.'], blocked: [], concerns: [], truncated: false });
++ });
++
await check('a sub-task takes the effort it is given, and a level that is none is 422', async () => {
const asked = await post(`/api/v1/conductor/${taskId}/tasks`, { prompt: 'think hard', repo: 'https://github.com/octocat/Hello-World', effort: 'high' });
assert.strictEqual(asked.status, 201, asked.text);
ddiff --git a/test/mcp.test.js b/test/mcp.test.js
iindex fa66782..a3dc4e2 100644
---- a/test/mcp.test.js
++++ b/test/mcp.test.js
@@@ -113,7 +113,8 @@ async function fakeGitHub() {
const submit = calls.at(-1)[1];
assert.strictEqual(submit.args.agent, 'codex');
assert.strictEqual(submit.args.model, 'gpt-5');
-- assert.strictEqual(submit.args.prompt, 'Fix the parser and add a test.');
++ assert.ok(submit.args.prompt.startsWith('Fix the parser and add a test.\n\n'), 'the brief comes first');
++ assert.match(submit.args.prompt, /write your full output to report\.md/, 'the default report instruction rides along');
const row = await rowOf(out.id);
assert.strictEqual(row.kind, 'code');
assert.strictEqual(row.project_id, (await rowOf(conductorId)).project_id, 'the conductor\'s own project');
@@@ -182,6 +183,17 @@ async function fakeGitHub() {
assert.strictEqual(row.state, 'cancelled');
});
++ await check('codes_task_start names the report path; codes_task_show hands back the shaped return', async () => {
++ const started = await mcp.call('codes_task_start', { prompt: 'write the notes', repo, agent: 'codex', report_path: 'notes/work.md' });
++ assert.ok(started.id, JSON.stringify(started));
++ assert.match(calls.at(-1)[1].args.prompt, /write your full output to notes\/work\.md/);
++ const row = await rowOf(started.id);
++ const updated = await t.ctx.tasks.applyEvent(row, { state: 'succeeded', job: { id: row.job_id, state: 'succeeded', result: { ok: true, summary: '- notes written\nBLOCKED: none' } } });
++ await t.ctx.tasks.afterEnd(updated.id);
++ const out = await mcp.call('codes_task_show', { id: started.id });
++ assert.deepStrictEqual(out.report, { path: 'notes/work.md', bullets: ['notes written'], blocked: ['none'], concerns: [], truncated: false });
++ });
++
await check('codes_fleet: each coding CLI on this machine, and its windows', async () => {
const out = await mcp.call('codes_fleet', {});
assert.ok(Array.isArray(out.agents) && out.agents.length >= 5, JSON.stringify(out));