bus, harness: PM task reads that fit a session (#1583)

Row 89, round 1. The broker's `tasks` read takes optional filter and
page arguments and returns a short form. The harness adds `list_tasks`
arguments (open tasks by default), a `get_task` tool, and compact JSON
replies capped below the 50000-character threshold where Claude Code
2.1.296 saves a tool result to a file outside the workspace, which was
the PM's fault in Jason's test. The bundle's tools line names the
harness's own tools.

Candidate manifest b8c5bb3a (agents/dewey/work/queue-89/), base
a3f6e9e6, no drift on the 12 paths. Builder Dewey. Approved by
Darkwing (comment 27940). Sage's gate on f04185fc plus the patch
2026-10-12T22:00:55Z-22:03:38Z: bus 108, harness 65, webui 86, cli
115, control-board 124, every scripts/test-*.sh green.

Live at the next bus restart (broker) and role session launch
(harness), both Mos's call (decision 109); Mos had the notice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:04:15 -05:00
co-authored by Claude Opus 5.5
parent 4643911ac1
commit ce104b27cc
12 changed files with 491 additions and 20 deletions
+60 -2
View File
@@ -42,6 +42,10 @@ const CLOSED = "('resolved','withdrawn','expired')";
export const RECENT = 50;
// How many replies `replies` returns, oldest first.
export const REPLIES = 50;
// Row 89 (#1583): the arguments `tasks` takes, and its page size by default and at most.
const TASK_ARGS = ['done', 'assignee', 'label', 'requirement', 'limit', 'after', 'task_ref'];
export const TASK_PAGE = 50;
export const TASK_PAGE_MAX = 200;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
@@ -328,6 +332,59 @@ export class Broker {
return { ...r, fields, priority, requirement: requirement.get(taskId(r.task_ref)) ?? null };
});
}
// Row 89 (#1583): with no arguments, every row as #taskRows gives it, which the Console and
// `mosaic tasks` read. `task_ref`, alone, gives that one row. Any other argument filters and pages
// the short form in task_ref order: no description, and the assignees that are a role's tracker
// bot, by role. A tombstone isn't on the board, so a filtered read leaves it out. `total` counts
// every task the filters match; `next` is the `after` for the next page, null on the last.
#tasks(business, a) {
const rows = this.#taskRows(business);
if (!Object.keys(a).length) return rows;
if ('task_ref' in a) {
if (Object.keys(a).length !== 1 || !taskRef(a.task_ref)) fail('invalid-request');
return rows.find((r) => r.task_ref === a.task_ref) ?? fail('task-not-found');
}
const roles = this.#business(business).roles ?? {};
const positive = (x, max = Number.MAX_SAFE_INTEGER) => Number.isSafeInteger(x) && x >= 1 && x <= max;
if (
('done' in a && typeof a.done !== 'boolean') ||
('assignee' in a && !(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))) ||
('label' in a && !positive(a.label)) ||
('requirement' in a && !(typeof a.requirement === 'string' && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement))) ||
('limit' in a && !positive(a.limit, TASK_PAGE_MAX)) ||
('after' in a && !taskRef(a.after))
)
fail('invalid-request');
const bots = new Map(
Object.entries(roles)
.filter(([, r]) => r.tracker?.botId !== undefined)
.map(([role, r]) => [r.tracker.botId, role]),
);
const matched = rows
.filter((r) => !r.fields.gone)
.map(({ task_ref, fields: f, priority, requirement, at }) => ({
task_ref,
title: f.title ?? null,
done: Boolean(f.done),
bucket: f.bucket ?? null,
assignees: (f.assignees ?? []).filter((u) => bots.has(u)).map((u) => bots.get(u)).sort(),
labels: f.labels ?? [],
priority,
requirement,
changed_at: at,
}))
.filter(
(t) =>
(!('done' in a) || t.done === a.done) &&
(!('assignee' in a) || t.assignees.includes(a.assignee)) &&
(!('label' in a) || t.labels.includes(a.label)) &&
(!('requirement' in a) || t.requirement === a.requirement),
);
const rest = 'after' in a ? matched.filter((t) => t.task_ref > a.after) : matched;
const limit = a.limit ?? TASK_PAGE;
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
#checkAuthority(s, action, { decision = null, target = null } = {}) {
this.#agent(s);
if (
@@ -841,7 +898,8 @@ export class Broker {
case 'agents':
case 'tasks':
case 'trail': {
keys(a, verb === 'trail' ? ['subject'] : [], verb === 'trail' ? ['subject'] : []);
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')
return this.#store
@@ -859,7 +917,7 @@ export class Broker {
// A role session gets the claims only: another role's decisions aren't its to read.
return s.human || s.reader ? claims.map((c) => this.#agentState(c)) : claims;
}
if (verb === 'tasks') return this.#taskRows(s.business);
if (verb === 'tasks') return this.#tasks(s.business, a);
return this.#trail(s, identifier(a.subject));
}
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and