openvibers/openvibe.codes Commits
The child return contract: a named path, 5 bullets, BLOCKED/CONCERNS (R1 item 5) (#381)
Changes
@@@ -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 || {};nnew file mode 100644@@@ -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 };@@@ -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.@@@ -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 } } });});@@@ -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 } : {}),};};nnew file mode 100644@@@ -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();++})();@@@ -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);@@@ -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));
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* `openvibe-codes mcp` — the Codes MCP server: the conductor's tools (Codes C9b). A stdio JSON-RPC server, started by
* `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.
* 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_list { state? } running | needs | done
* codes_task_show { id } state, attempts, outcome, the last lines
* codes_task_show { id } state, attempts, outcome, the last lines
* codes_task_continue { id, message } carry a task on in the same conversation
* codes_task_continue { id, message } carry a task on in the same conversation
@@ -70,6 +70,7 @@ const TOOLS = [
@@ -70,6 +70,7 @@ const TOOLS = [
agent: { type: 'string', description: 'codex, opencode, command-code, deepseek, aider: never claude-code or claude-other' },
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)' },
model: { type: 'string', description: 'The model for that agent (never an Anthropic one)' },
ref: { type: 'string', description: 'A branch or tag to start from' },
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' },
effort: { type: 'string', description: 'How hard it thinks: auto, low, medium, high, xhigh or max' },
},
},
required: ['prompt', 'repo'],
required: ['prompt', 'repo'],
@@ -139,6 +140,7 @@ async function startTask(args) {
@@ -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 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`, {
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) } : {}),
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 } : {}),
...(effort ? { effort } : {}),
});
});
const t = answer.task || {};
const t = answer.task || {};
@@ -176,6 +178,7 @@ async function showTask(args) {
@@ -176,6 +178,7 @@ async function showTask(args) {
outcome: t.outcome ? { state: t.outcome.state, note: t.outcome.note } : null,
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 })),
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,
summary: t.result && t.result.summary ? String(t.result.summary).slice(0, 4000) : null,
+ ...(t.report ? { report: t.report } : {}),
error: t.error || null,
error: t.error || null,
last_lines: lines.map((l) => {
last_lines: lines.map((l) => {
const b = l.body || {};
const b = l.body || {};
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -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 };
@@ -32,6 +32,7 @@
@@ -32,6 +32,7 @@
const { RunError } = require('./run-client');
const { RunError } = require('./run-client');
const { chain, budget } = require('../../engine');
const { chain, budget } = require('../../engine');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
+const childReport = require('./child-report');
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
const STATE_LABEL = {
const STATE_LABEL = {
@@ -597,6 +598,12 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -597,6 +598,12 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
}
}
const runArgs = { ...args, agent, model };
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.`;
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
// 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
// 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.
// project the run belongs to also scopes the team rows it may read.
@@ -31,6 +31,7 @@ const express = require('express');
@@ -31,6 +31,7 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
+const childReport = require('../domain/child-report');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[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,
@@ -225,15 +226,22 @@ function createConductorApi({ config, store, keys, harnesses, tasks, conductor,
const agent = wanted;
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 (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://…)');
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);
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');
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;
let args;
try {
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' }));
args = parseArgs(JSON.stringify({ repo: b.repo, ref: b.ref || null, prompt, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(effort ? { effort } : {}), permission: 'edit' }));
} catch (e) {
} catch (e) {
return problem(req, res, 422, 'codes.task_invalid', e.message);
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'}`);
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 } } });
return res.status(201).json({ task: { ...tasks.present(task), outcome: { state: task.outcome || null, note: task.outcome_note || null } } });
});
});
@@ -35,6 +35,7 @@ const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
@@ -35,6 +35,7 @@ const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
const { createTaskEvents } = require('./task-events');
const { createTaskEvents } = require('./task-events');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { expandMatrix } = require('../domain/bench');
const { expandMatrix } = require('../domain/bench');
+const childReport = require('../domain/child-report');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[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
@@ -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. */
/** 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 timeline = async (row) => {
const attempts = await taskStore.attempts.list(row.id);
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 {
return {
...taskStore.present(row),
...taskStore.present(row),
attempts: attempts.map((a) => taskStore.attempts.present(a)),
attempts: attempts.map((a) => taskStore.attempts.present(a)),
outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null },
outcome: { state: row.end_state || row.outcome || null, note: row.outcome_note || null },
+ ...(report ? { report } : {}),
};
};
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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();
+})();
@@ -6,7 +6,8 @@
@@ -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
* 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)
* 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 assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
@@ -192,6 +193,36 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -192,6 +193,36 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(subRow.via, 'conductor');
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 () => {
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' });
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);
assert.strictEqual(asked.status, 201, asked.text);
@@ -113,7 +113,8 @@ async function fakeGitHub() {
@@ -113,7 +113,8 @@ async function fakeGitHub() {
const submit = calls.at(-1)[1];
const submit = calls.at(-1)[1];
assert.strictEqual(submit.args.agent, 'codex');
assert.strictEqual(submit.args.agent, 'codex');
assert.strictEqual(submit.args.model, 'gpt-5');
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);
const row = await rowOf(out.id);
assert.strictEqual(row.kind, 'code');
assert.strictEqual(row.kind, 'code');
assert.strictEqual(row.project_id, (await rowOf(conductorId)).project_id, 'the conductor\'s own project');
assert.strictEqual(row.project_id, (await rowOf(conductorId)).project_id, 'the conductor\'s own project');
@@ -182,6 +183,17 @@ async function fakeGitHub() {
@@ -182,6 +183,17 @@ async function fakeGitHub() {
assert.strictEqual(row.state, 'cancelled');
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 () => {
await check('codes_fleet: each coding CLI on this machine, and its windows', async () => {
const out = await mcp.call('codes_fleet', {});
const out = await mcp.call('codes_fleet', {});
assert.ok(Array.isArray(out.agents) && out.agents.length >= 5, JSON.stringify(out));
assert.ok(Array.isArray(out.agents) && out.agents.length >= 5, JSON.stringify(out));