SLOPSHOPPER

agent-budget-board

Review planned worker roles, scope overlap, model preferences, and concurrency before deciding whether to delegate.

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-budget-board
│ ┃ Agent Budget Board ✕ › fix the failing auth test and add an audit log call │ ┃ AGENT BUDGET BOARD │ ┃ ⏺ Read(src/auth.ts) │ ┃ Advisory planning · No worker starts · No ⎿ Read 6 lines │ ┃ model calls or filesystem access ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ DRAFT · REVIEW REQUIRED ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Plan goal: What outcome needs multiple worke │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Requested concurrency (1–8; advisory only): │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 0/8 planned workers · up to 0 in a suggested │ ┃ batch · 0 workers started by this mod › /agent-budget-board │ ┃ │ ┃ Add worker ID: auth-review ⏎ Add │ ┃ │ ┃ [ Review roles, overlap, models, and suggest │ ┃ │ ┃ [ Export plan JSON (approval excluded) ] │ ┃ │ ┃ Import pasted plan JSON (replaces saved plan │ ┃ │ ┃ [ Clear plan and local approval ] │ ┃ │ ┃ Plan the work before delegating. Approval is ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Budget Board
AGENT BUDGET BOARD Advisory planning · No worker starts · No model calls or filesystem access DRAFT · REVIEW REQUIRED Plan goal: What outcome needs multiple workers? ⏎ Save Requested concurrency (1–8; advisory only): 2 ⏎ Save 0/8 planned workers · up to 0 in a suggested batch · 0 workers started by this mod Add worker ID: auth-review ⏎ Add [ Review roles, overlap, models, and suggested batches ] [ Export plan JSON (approval excluded) ] Import pasted plan JSON (replaces saved plan): Paste version [ Clear plan and local approval ] Plan the work before delegating. Approval is advisory and starts no workers.
README

Agent Budget Board

<img src="assets/icon.png" width="128" alt="Agent Budget Board: task tiles connected to a resource dial" />

Experimental 0.1.0. Review a proposed delegation plan before choosing whether to start workers. Edit roles, distinct tasks, scope labels, model preferences, and requested concurrency in a native pane. The board identifies lexical overlaps and repeated assignments, suggests batches, and records local approval of the exact saved plan.

This is an advisory planning tool. It starts no workers, intercepts no agent calls, changes no models or permissions, and enforces no live concurrency, token, or billing limits. It does not observe workers started elsewhere.

Install and plan

/plugin marketplace add GadDev/claude-mods
/plugin install agent-budget-board@gaddev-mods
/reload-plugins
/agent-budget-board

Set the goal, add worker IDs, and give each worker a distinct task and scope. Fields save when submitted; unfinished typing is not part of the reviewed plan. Model values are authored preferences. The default inherit label means an intended preference for the eventual host's default; the mod does not resolve or select a real model. Model availability, pricing, and suitability are not checked.

Example commands for two distinct workers:

/agent-budget-board goal Review the API change and its documentation
/agent-budget-board add api-review
/agent-budget-board set api-review role API reviewer
/agent-budget-board set api-review task Inspect API validation
/agent-budget-board set api-review scopes src/api/
/agent-budget-board add docs-review
/agent-budget-board set docs-review role Documentation reviewer
/agent-budget-board set docs-review task Check the usage examples
/agent-budget-board set docs-review scopes docs/
/agent-budget-board concurrency 2
/agent-budget-board review

Use set <id> model <label> to change a preference and set <id> access write to describe planned editing. Access modes are labels, not permission grants. Scope strings name exact files or directories ending in /; separate multiple scopes with commas. ./ explicitly means the entire workspace and produces a broad-scope warning. Hidden paths and spaces are supported as labels. No files are read or resolved.

Review, narrow, or approve

The report shows workers, the canonical plan's SHA-256 fingerprint, suggested batches, approval blockers, and warnings. Missing goal, workers, role, task, model, or scopes block approval. Warnings include:

  • Shared read scopes, which may repeat context gathering.
  • Overlap involving planned writing, which needs coordination.
  • Repeated role labels or identical task text after case/whitespace normalization.
  • Workspace-wide scopes and internally overlapping scopes in one worker.

Scope comparison is lexical and case-sensitive. Directory prefixes respect / boundaries, so src/api/ overlaps src/api/auth.ts but not src/apix.ts. A file label and directory label with the same base, such as src/api and src/api/, conservatively overlap. Globs, commas inside paths, traversal, absolute paths, and Windows separators are rejected. There is no glob expansion, filesystem inspection, symlink resolution, semantic task analysis, or dependency scheduling. No warning does not prove independence.

Suggested batches follow saved worker order, with at most the requested concurrency per batch. They are a planning aid, not a scheduler. Warnings distinguish same-batch from different-batch overlap; sequential batches can still repeat work or require coordination. To narrow a plan:

/agent-budget-board set api-review scopes src/api/auth.ts
/agent-budget-board set api-review task Inspect only authentication validation
/agent-budget-board concurrency 1
/agent-budget-board remove docs-review
/agent-budget-board review

Read all report pages; use page 5000 or the pane's offset field when the footer shows more content. Then use approve for a complete warning-free plan. If warnings remain intentional, use approve acknowledge or the explicitly labeled acknowledgment button. Acknowledgment bypasses no completeness blocker.

Every saved edit, removal, concurrency change, clear, or JSON load invalidates review and approval, even when the new text is identical. Invalid inputs preserve the previous saved plan. Re-reviewing an unchanged plan preserves its approval. The approval covers only the saved data, not unsubmitted field text, a repository revision, a verified model, or actual agent behavior.

Use the reviewed plan

/agent-budget-board approve
/agent-budget-board draft

draft is available only after local approval. It appends the reviewed advisory plan to the current prompt draft, leaves existing text intact, and does not submit. Read it before sending; any subsequent delegation uses your normal host workflow and permissions. If no editor is available, export and copy the plan instead. The handoff is capped at 16,000 characters and is refused rather than silently truncated; narrow an oversized plan. Changes made after insertion do not retract already inserted text.

export displays the complete version 1 JSON for manual saving/copying, with no approval credential or status. The command prints a short message before the JSON; copy only the JSON object. load <json> or the pane's pasted-JSON field imports authored data and always resets approval. It reads no local or remote file. Unknown fields, versions, duplicate IDs/scopes, malformed data, and execution/configuration fields are rejected. Invalid replacement imports preserve existing saved work. No JSON text is executed. Export before leaving if you need the plan; all state clears at session end.

Bounds and compatibility

  • At most eight workers, each with a lowercase ID of 1–24 letters/digits/hyphens, starting with a letter.
  • Goal: 1,000 characters; role: 80; distinct task: 400; model preference: 120.
  • At most six scopes per worker, 240 characters per scope.
  • Requested concurrency: an integer from 1 to 8. Actual runtime concurrency is not measured or limited.
  • JSON: at most 32 KiB in UTF-8. Review pages: at most 5,000 report characters plus an offset footer; all warnings remain available through paging.

Empty, edited, reviewed, and approved pane contracts are tested on terminal, Desktop, and VS Code, including field edits, access selection, and approval buttons. This is a Claude Code plugin, not a claude.ai browser integration. It registers no model-facing tool, makes no model/network calls, accesses no filesystem or conversation history, and writes no files. Native tests do not replace a manual visual smoke test. Human plans can still contain private information; review before sharing. Any reduction in redundant work or token use remains unmeasured.

Source 2 files
hooks/register.ts 297 lines
1import type { EngineInterface, Register } from 'claude-code';
2import { Board } from '../src/board.ts';
3import type { WorkerField } from '../src/board.ts';
4const NAME = 'agent-budget-board';
5const HELP =
6  '/agent-budget-board goal <text> | add <id> | set <id> <role|task|model|access|scopes> <value> | remove <id> | concurrency <1-8> | review | page [offset] | approve [acknowledge] | export | load <json> | draft | clear';
7interface State {
8  board: Board;
9  text: string;
10  format: 'text' | 'json';
11  message: string;
12  queue: Promise<void>;
13}
14const schedule = <T>(state: State, work: () => Promise<T>): Promise<T> => {
15  const result = state.queue.then(work);
16  state.queue = result.then(
17    () => undefined,
18    () => undefined,
19  );
20  return result;
21};
22const act = ($: EngineInterface, state: State, kind: string, value = '') =>
23  schedule(state, async () => {
24    state.text = '';
25    try {
26      if (['review', 'export', 'draft', 'clear'].includes(kind) && value.trim())
27        throw new Error(`${kind} takes no arguments.`);
28      if (kind === 'goal') state.board.goal(value);
29      else if (kind === 'add') state.board.add(value.trim());
30      else if (kind === 'remove') state.board.remove(value.trim());
31      else if (kind === 'concurrency') state.board.concurrency(Number(value));
32      else if (kind === 'set') {
33        const match = /^(\S+)\s+(role|task|model|access|scopes)(?:\s+([\s\S]*))?$/.exec(value);
34        if (!match) throw new Error('Use set <id> <role|task|model|access|scopes> <value>.');
35        state.board.edit(match[1] ?? '', match[2] as WorkerField, match[3] ?? '');
36      } else if (kind === 'load') {
37        state.board.load(value);
38        state.message =
39          'Plan JSON imported. Local approval is reset; review this authored data before use.';
40      } else if (kind === 'clear') {
41        state.board.clear();
42        state.message = 'Plan and approval cleared. No workers were stopped or started.';
43      } else if (kind === 'review') {
44        const review = await state.board.review();
45        state.text = state.board.view();
46        state.format = 'text';
47        state.message = `${review.blockers.length} approval blocker(s), ${review.warnings.length} warning(s). Inspect all report pages before approval.`;
48      } else if (kind === 'page') {
49        state.text = state.board.view(Number(value || '0'));
50        state.format = 'text';
51        state.message = 'Saved-plan review page. No worker activity is observed or changed.';
52      } else if (kind === 'approve') {
53        if (value && value !== 'acknowledge')
54          throw new Error('Use approve, or approve acknowledge after reviewing warnings.');
55        await state.board.approve(value === 'acknowledge');
56        state.message =
57          'Current plan approved locally. No workers started and no engine permissions changed.';
58      } else if (kind === 'export') {
59        state.text = state.board.export();
60        state.format = 'json';
61        state.message =
62          'Copy this complete JSON to save it. No file was written; approval is not exported.';
63      } else if (kind === 'draft') {
64        const result = await $.prompt.fill({
65          text: `\n\n${state.board.draft()}\n`,
66          mode: 'append',
67        });
68        if (!result.isFilled)
69          throw new Error(
70            'No prompt editor is available. Export and copy the reviewed plan instead.',
71          );
72        state.message =
73          'Reviewed plan appended to the current draft. Read it before sending. Nothing was submitted or started.';
74      } else throw new Error(HELP);
75      if (['goal', 'add', 'remove', 'concurrency', 'set'].includes(kind))
76        state.message = 'Saved plan changed. Review and approval reset. No workers started.';
77      return {
78        text: kind === 'export' ? `${state.message}\n\n${state.text}` : state.text || state.message,
79      };
80    } catch (error) {
81      state.message =
82        error instanceof Error
83          ? error.message
84          : 'Plan action failed. Existing saved data is retained.';
85      return { text: `Agent Budget Board: ${state.message}` };
86    } finally {
87      $.ui.invalidate('ui.render');
88    }
89  });
90export const register: Register = (on) => {
91  const state: State = {
92    board: new Board(),
93    text: '',
94    format: 'text',
95    message: 'Plan the work before delegating. Approval is advisory and starts no workers.',
96    queue: Promise.resolve(),
97  };
98  on('session.start', async ($, e, next) => {
99    await schedule(state, async () => {
100      state.board.clear();
101      state.text = '';
102    });
103    await $.command.register({
104      name: NAME,
105      description: 'Review planned agent roles, scope overlap, model preferences, and concurrency',
106      argumentHint: '[goal|add|set|remove|concurrency|review|approve|export|load|draft|clear]',
107    });
108    return next(e);
109  });
110  on('session.end', async (_$, e, next) => {
111    await schedule(state, async () => {
112      state.board.clear();
113      state.text = '';
114      state.message = 'Session plan and local approval cleared.';
115    });
116    return next(e);
117  });
118  on('command.run', { command: NAME }, async ($, e) => {
119    if (!e.args.trim()) {
120      await $.ui.open({ id: NAME, title: 'Agent Budget Board', focus: true, closeOnEscape: true });
121      return {};
122    }
123    const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(e.args.trim());
124    return act($, state, match?.[1] ?? '', match?.[2] ?? '');
125  }).catch(() => ({
126    text: 'Agent Budget Board could not complete the action. No workers were started.',
127  }));
128  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
129    if (
130      e.requestId !== NAME ||
131      (e.surface !== 'terminal' && e.surface !== 'desktop' && e.surface !== 'vscode')
132    )
133      return next(e);
134    const { Box, Text, Input, Select, Button, Code } = $.ui.resolve(e);
135    const plan = state.board.plan;
136    const reviewed = state.board.reviewed;
137    const run = (kind: string, value = '') => act($, state, kind, value).then(() => undefined);
138    return Box({
139      flexDirection: 'column',
140      gap: 1,
141      children: [
142        Text({ bold: true, children: ['AGENT BUDGET BOARD'] }),
143        Text({
144          children: ['Advisory planning · No worker starts · No model calls or filesystem access'],
145        }),
146        Text({
147          bold: true,
148          children: [
149            state.board.approved
150              ? 'APPROVED LOCALLY · NOTHING STARTED'
151              : reviewed
152                ? 'REVIEWED · APPROVAL PENDING'
153                : 'DRAFT · REVIEW REQUIRED',
154          ],
155        }),
156        Input({
157          key: 'goal',
158          label: 'Plan goal',
159          value: plan.goal,
160          placeholder: 'What outcome needs multiple workers?',
161          submitLabel: 'Save',
162          onSubmit: (value) => run('goal', value),
163        }),
164        Input({
165          key: 'concurrency',
166          label: 'Requested concurrency (1–8; advisory only)',
167          value: String(plan.concurrency),
168          submitLabel: 'Save',
169          onSubmit: (value) => run('concurrency', value),
170        }),
171        Text({
172          children: [
173            `${plan.workers.length}/8 planned workers · up to ${Math.min(plan.workers.length, plan.concurrency)} in a suggested batch · 0 workers started by this mod`,
174          ],
175        }),
176        ...plan.workers.map((worker) =>
177          Box({
178            flexDirection: 'column',
179            gap: 1,
180            children: [
181              Text({ bold: true, children: [worker.id.toUpperCase()] }),
182              Input({
183                key: `role-${worker.id}`,
184                label: 'Role',
185                value: worker.role,
186                submitLabel: 'Save',
187                onSubmit: (value) => run('set', `${worker.id} role ${value}`),
188              }),
189              Input({
190                key: `task-${worker.id}`,
191                label: 'Distinct task',
192                value: worker.task,
193                placeholder: 'A bounded responsibility',
194                submitLabel: 'Save',
195                onSubmit: (value) => run('set', `${worker.id} task ${value}`),
196              }),
197              Input({
198                key: `model-${worker.id}`,
199                label: 'Model preference (unverified label)',
200                value: worker.model,
201                submitLabel: 'Save',
202                onSubmit: (value) => run('set', `${worker.id} model ${value}`),
203              }),
204              Select({
205                key: `access-${worker.id}`,
206                label: 'Planned access (not a permission grant)',
207                value: worker.access,
208                options: [
209                  { value: 'read', label: 'Read / analyze' },
210                  { value: 'write', label: 'Write / implement' },
211                ],
212                onSelect: (value) => run('set', `${worker.id} access ${value}`),
213              }),
214              Input({
215                key: `scopes-${worker.id}`,
216                label: 'Scopes: comma-separated files or folders ending /',
217                value: worker.scopes.join(', '),
218                placeholder: 'src/auth/, tests/auth.test.ts',
219                submitLabel: 'Save',
220                onSubmit: (value) => run('set', `${worker.id} scopes ${value}`),
221              }),
222              Button({
223                key: `remove-${worker.id}`,
224                label: `Remove worker ${worker.id}`,
225                onPress: () => run('remove', worker.id),
226              }),
227            ],
228          }),
229        ),
230        Input({
231          key: 'add-worker',
232          label: 'Add worker ID',
233          placeholder: 'auth-review',
234          submitLabel: 'Add',
235          onSubmit: (value) => run('add', value),
236        }),
237        Button({
238          key: 'review',
239          label: 'Review roles, overlap, models, and suggested batches',
240          onPress: () => run('review'),
241        }),
242        ...(reviewed
243          ? [
244              Text({
245                children: [
246                  `${reviewed.blockers.length} blocker(s) · ${reviewed.warnings.length} warning(s) · plan ${reviewed.fingerprint.slice(0, 12)}`,
247                ],
248              }),
249              Button({
250                key: 'approve',
251                label: reviewed.warnings.length
252                  ? `Acknowledge ${reviewed.warnings.length} warnings and approve locally`
253                  : 'Approve reviewed plan locally',
254                onPress: () => run('approve', reviewed.warnings.length ? 'acknowledge' : ''),
255              }),
256              Input({
257                key: 'report-offset',
258                label: 'Review report character offset',
259                placeholder: '5000',
260                submitLabel: 'Read page',
261                onSubmit: (value) => run('page', value),
262              }),
263            ]
264          : []),
265        ...(state.board.approved
266          ? [
267              Button({
268                key: 'draft',
269                label: 'Append reviewed advisory plan to current draft',
270                onPress: () => run('draft'),
271              }),
272            ]
273          : []),
274        Button({
275          key: 'export',
276          label: 'Export plan JSON (approval excluded)',
277          onPress: () => run('export'),
278        }),
279        Input({
280          key: 'load',
281          label: 'Import pasted plan JSON (replaces saved plan)',
282          placeholder: 'Paste version 1 plan JSON',
283          submitLabel: 'Import',
284          onSubmit: (value) => run('load', value),
285        }),
286        Button({
287          key: 'clear',
288          label: 'Clear plan and local approval',
289          onPress: () => run('clear'),
290        }),
291        Text({ children: [state.message] }),
292        ...(state.text ? [Code({ source: state.text, language: state.format, wrap: 'wrap' })] : []),
293      ],
294    });
295  });
296};
297
src/board.ts 363 lines
1export const LIMITS = {
2  workers: 8,
3  scopes: 6,
4  path: 240,
5  goal: 1000,
6  role: 80,
7  task: 400,
8  model: 120,
9  jsonBytes: 32_768,
10  page: 5000,
11  draft: 16_000,
12} as const;
13export interface Worker {
14  id: string;
15  role: string;
16  task: string;
17  model: string;
18  access: 'read' | 'write';
19  scopes: string[];
20}
21export interface Plan {
22  kind: 'gaddev.agent-plan';
23  schemaVersion: 1;
24  goal: string;
25  concurrency: number;
26  workers: Worker[];
27}
28export interface Review {
29  fingerprint: string;
30  blockers: string[];
31  warnings: string[];
32  batches: string[][];
33  report: string;
34}
35export type WorkerField = 'role' | 'task' | 'model' | 'access' | 'scopes';
36const record = (value: unknown): value is Record<string, unknown> =>
37  typeof value === 'object' && value !== null && !Array.isArray(value);
38function keys(value: Record<string, unknown>, expected: string[]): void {
39  if (
40    Object.keys(value).length !== expected.length ||
41    expected.some((key) => !Object.hasOwn(value, key))
42  )
43    throw new Error('Unexpected or missing plan fields.');
44}
45function text(value: unknown, max: number): string {
46  if (typeof value !== 'string' || value.length > max)
47    throw new Error(`Use text of at most ${max} characters.`);
48  /* oxlint-disable no-control-regex -- Keep authored labels free of terminal/binary control characters. */
49  if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value))
50    throw new Error('Control characters are excluded.');
51  /* oxlint-enable no-control-regex */
52  return value.trim();
53}
54export function validateScope(value: unknown): string {
55  const path = text(value, LIMITS.path);
56  if (path === './') return path;
57  /* oxlint-disable no-control-regex -- Scope labels must be printable relative paths. */
58  if (!path || path.startsWith('/') || /[\\:\x00-\x1f\x7f*?{}[\]!,]/.test(path))
59    throw new Error(
60      'Use a relative file path or directory ending in /; globs and commas are unsupported.',
61    );
62  /* oxlint-enable no-control-regex */
63  const parts = path.replace(/\/$/, '').split('/');
64  if (parts.some((part) => !part || part === '.' || part === '..'))
65    throw new Error('Scope traversal and empty path segments are excluded.');
66  return path;
67}
68export function overlap(a: string, b: string): boolean {
69  return (
70    a === './' ||
71    b === './' ||
72    a === b ||
73    a.replace(/\/$/, '') === b.replace(/\/$/, '') ||
74    (a.endsWith('/') && b.startsWith(a)) ||
75    (b.endsWith('/') && a.startsWith(b))
76  );
77}
78export function parsePlan(raw: string): Plan {
79  if (new TextEncoder().encode(raw).length > LIMITS.jsonBytes)
80    throw new Error('Plan JSON exceeds 32 KiB.');
81  let value: unknown;
82  try {
83    value = JSON.parse(raw);
84  } catch {
85    throw new Error('Invalid plan JSON.');
86  }
87  if (!record(value)) throw new Error('Plan must be an object.');
88  keys(value, ['kind', 'schemaVersion', 'goal', 'concurrency', 'workers']);
89  if (value.kind !== 'gaddev.agent-plan' || value.schemaVersion !== 1)
90    throw new Error('Unsupported plan kind or version.');
91  if (
92    typeof value.concurrency !== 'number' ||
93    !Number.isSafeInteger(value.concurrency) ||
94    value.concurrency < 1 ||
95    value.concurrency > LIMITS.workers
96  )
97    throw new Error('Concurrency must be an integer from 1 to 8.');
98  if (!Array.isArray(value.workers) || value.workers.length > LIMITS.workers)
99    throw new Error('Plan at most eight workers.');
100  const workers = value.workers.map((item: unknown): Worker => {
101    if (!record(item)) throw new Error('Invalid worker.');
102    keys(item, ['id', 'role', 'task', 'model', 'access', 'scopes']);
103    if (typeof item.id !== 'string' || !/^[a-z][a-z0-9-]{0,23}$/.test(item.id))
104      throw new Error(
105        'Worker IDs use 1–24 lowercase letters/digits/hyphens and start with a letter.',
106      );
107    const model = text(item.model, LIMITS.model);
108    if (model && !/^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/.test(model))
109      throw new Error('Use a model preference label without spaces or shell syntax.');
110    if (item.access !== 'read' && item.access !== 'write')
111      throw new Error('Choose read or write access for planning.');
112    if (!Array.isArray(item.scopes) || item.scopes.length > LIMITS.scopes)
113      throw new Error('Use at most six scopes per worker.');
114    const scopes = item.scopes.map(validateScope);
115    if (new Set(scopes).size !== scopes.length)
116      throw new Error('Duplicate scope labels in one worker are excluded.');
117    return {
118      id: item.id,
119      role: text(item.role, LIMITS.role),
120      task: text(item.task, LIMITS.task),
121      model,
122      access: item.access,
123      scopes,
124    };
125  });
126  if (new Set(workers.map((worker) => worker.id)).size !== workers.length)
127    throw new Error('Worker IDs must be unique.');
128  return {
129    kind: 'gaddev.agent-plan',
130    schemaVersion: 1,
131    goal: text(value.goal, LIMITS.goal),
132    concurrency: value.concurrency,
133    workers,
134  };
135}
136export function serialize(plan: Plan): string {
137  const result = JSON.stringify(plan, null, 2) + '\n';
138  parsePlan(result);
139  return result;
140}
141export async function fingerprint(text: string): Promise<string> {
142  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
143  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
144}
145const normalized = (value: string) => value.trim().replace(/\s+/g, ' ').toLowerCase();
146export function analyze(plan: Plan): Omit<Review, 'fingerprint' | 'report'> {
147  const blockers: string[] = [];
148  const warnings: string[] = [];
149  const batches: string[][] = [];
150  if (!plan.goal) blockers.push('Set a concrete plan goal.');
151  if (!plan.workers.length) blockers.push('Add at least one worker.');
152  for (const worker of plan.workers) {
153    for (const field of ['role', 'task', 'model'] as const)
154      if (!worker[field]) blockers.push(`${worker.id}: set ${field}.`);
155    if (!worker.scopes.length) blockers.push(`${worker.id}: select at least one scope.`);
156    if (worker.scopes.includes('./'))
157      warnings.push(`${worker.id}: workspace-wide scope. Consider narrowing it.`);
158    for (let i = 0; i < worker.scopes.length; i++)
159      for (let j = i + 1; j < worker.scopes.length; j++) {
160        const a = worker.scopes[i];
161        const b = worker.scopes[j];
162        if (a && b && overlap(a, b))
163          warnings.push(
164            `${worker.id}: internally overlapping scopes ${a} and ${b}; consider keeping only the needed scope.`,
165          );
166      }
167  }
168  for (let i = 0; i < plan.workers.length; i++)
169    for (let j = i + 1; j < plan.workers.length; j++) {
170      const a = plan.workers[i];
171      const b = plan.workers[j];
172      if (!a || !b) continue;
173      const simultaneous = Math.floor(i / plan.concurrency) === Math.floor(j / plan.concurrency);
174      const when = simultaneous ? 'same suggested batch' : 'different suggested batches';
175      if (a.task && normalized(a.task) === normalized(b.task))
176        warnings.push(
177          `${a.id} / ${b.id}: identical task text after whitespace/case normalization; review redundant work (${when}).`,
178        );
179      if (a.role && normalized(a.role) === normalized(b.role))
180        warnings.push(
181          `${a.id} / ${b.id}: repeated role label; confirm distinct responsibilities (${when}).`,
182        );
183      const matches = a.scopes.flatMap((left) =>
184        b.scopes.filter((right) => overlap(left, right)).map((right) => `${left} ↔ ${right}`),
185      );
186      if (matches.length)
187        warnings.push(
188          `${a.id} / ${b.id}: ${a.access === 'read' && b.access === 'read' ? 'shared read scope; review repeated context gathering' : 'write-involved scope overlap; coordinate changes'} (${when}): ${matches.slice(0, 3).join('; ')}${matches.length > 3 ? `; ${matches.length - 3} additional scope pair(s)` : ''}.`,
189        );
190    }
191  for (let i = 0; i < plan.workers.length; i += plan.concurrency)
192    batches.push(plan.workers.slice(i, i + plan.concurrency).map((worker) => worker.id));
193  return { blockers, warnings, batches };
194}
195export function report(plan: Plan, review: Omit<Review, 'report'>): string {
196  return [
197    'AGENT BUDGET BOARD — ADVISORY REVIEW',
198    `Goal: ${plan.goal || '(not set)'}`,
199    `${plan.workers.length} planned worker(s) · requested concurrency ${plan.concurrency} · ${review.batches.length} suggested batch(es)`,
200    `Plan SHA-256 ${review.fingerprint}`,
201    'No workers started by this mod. Concurrency and access modes are planning labels, not enforced limits or permissions. Models are unverified preferences, not price or availability claims.',
202    '',
203    'WORKERS',
204    ...plan.workers.flatMap((worker) => [
205      `${worker.id} · ${worker.role || '(role missing)'} · model ${worker.model || '(missing)'} · ${worker.access}`,
206      `Task: ${worker.task || '(missing)'}`,
207      `Scopes: ${worker.scopes.join(', ') || '(missing)'}`,
208    ]),
209    '',
210    'SUGGESTED BATCHES (current list order; no dependency or runtime scheduling analysis)',
211    ...review.batches.map((batch, index) => `${index + 1}. ${batch.join(', ')}`),
212    '',
213    `${review.blockers.length} APPROVAL BLOCKER(S)`,
214    ...review.blockers,
215    '',
216    `${review.warnings.length} REVIEW WARNING(S)`,
217    ...review.warnings,
218    '',
219    'Overlap analysis is lexical and case-sensitive for paths. It does not read files, expand globs, resolve symlinks, or prove task independence. No warning does not mean no overlap.',
220    'Review flagged scopes or explicitly acknowledge warnings before local approval. Every saved edit invalidates review and approval. No token usage, billing cap, or savings measurement is provided.',
221  ].join('\n');
222}
223export function page(text: string, offset = 0): string {
224  if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length)
225    throw new Error('Use an integer character offset within the report.');
226  return `${text.slice(offset, offset + LIMITS.page)}\n[characters ${offset}–${Math.min(text.length, offset + LIMITS.page)} of ${text.length}; next offset ${Math.min(text.length, offset + LIMITS.page)}]`;
227}
228export class Board {
229  private value: Plan = {
230    kind: 'gaddev.agent-plan',
231    schemaVersion: 1,
232    goal: '',
233    concurrency: 2,
234    workers: [],
235  };
236  private last: Review | undefined;
237  private approval: { fingerprint: string; serialized: string } | undefined;
238  get plan(): Plan {
239    return parsePlan(serialize(this.value));
240  }
241  get reviewed(): Review | undefined {
242    return this.last
243      ? {
244          ...this.last,
245          blockers: [...this.last.blockers],
246          warnings: [...this.last.warnings],
247          batches: this.last.batches.map((batch) => [...batch]),
248        }
249      : undefined;
250  }
251  get approved(): boolean {
252    return this.approval?.serialized === serialize(this.value);
253  }
254  private update(plan: Plan): void {
255    this.value = parsePlan(serialize(plan));
256    this.last = undefined;
257    this.approval = undefined;
258  }
259  clear(): void {
260    this.update({
261      kind: 'gaddev.agent-plan',
262      schemaVersion: 1,
263      goal: '',
264      concurrency: 2,
265      workers: [],
266    });
267  }
268  goal(value: string): void {
269    this.update({ ...this.value, goal: text(value, LIMITS.goal) });
270  }
271  concurrency(value: number): void {
272    this.update({ ...this.value, concurrency: value });
273  }
274  add(id: string): void {
275    this.update({
276      ...this.value,
277      workers: [
278        ...this.value.workers,
279        { id, role: id, task: '', model: 'inherit', access: 'read', scopes: [] },
280      ],
281    });
282  }
283  remove(id: string): void {
284    if (!this.value.workers.some((worker) => worker.id === id))
285      throw new Error('Unknown worker ID.');
286    this.update({
287      ...this.value,
288      workers: this.value.workers.filter((worker) => worker.id !== id),
289    });
290  }
291  edit(id: string, field: WorkerField, value: string): void {
292    const index = this.value.workers.findIndex((worker) => worker.id === id);
293    const worker = this.value.workers[index];
294    if (!worker) throw new Error('Unknown worker ID.');
295    const next = [...this.value.workers];
296    next[index] = {
297      ...worker,
298      [field]:
299        field === 'scopes'
300          ? value.trim()
301            ? value.split(',').map((item) => item.trim())
302            : []
303          : value,
304    };
305    this.update({ ...this.value, workers: next });
306  }
307  load(raw: string): void {
308    this.update(parsePlan(raw));
309  }
310  export(): string {
311    return serialize(this.value);
312  }
313  async review(): Promise<Review> {
314    const serialized = this.export();
315    const plan = parsePlan(serialized);
316    const hash = await fingerprint(serialized);
317    if (serialized !== this.export()) throw new Error('Plan changed during review. Review again.');
318    const analysis = analyze(plan);
319    const result = { ...analysis, fingerprint: hash };
320    this.last = { ...result, report: report(plan, result) };
321    return this.reviewed!;
322  }
323  view(offset = 0): string {
324    if (!this.last) throw new Error('Review the saved plan first.');
325    return page(this.last.report, offset);
326  }
327  async approve(acknowledge = false): Promise<void> {
328    const reviewed = this.last;
329    if (!reviewed) throw new Error('Review the saved plan before approval.');
330    if (reviewed.blockers.length)
331      throw new Error('Complete the plan before approval; see review blockers.');
332    if (reviewed.warnings.length && !acknowledge)
333      throw new Error('Narrow the flagged plan or explicitly acknowledge the review warnings.');
334    const serialized = this.export();
335    const hash = await fingerprint(serialized);
336    if (this.last !== reviewed || serialized !== this.export() || hash !== reviewed.fingerprint)
337      throw new Error('Plan changed. Review and approve it again.');
338    this.approval = { fingerprint: hash, serialized };
339  }
340  draft(): string {
341    if (!this.approved || !this.approval || !this.last)
342      throw new Error('Review and approve the current plan first.');
343    const json = this.export();
344    const longest = Math.max(0, ...Array.from(json.matchAll(/`+/g), (match) => match[0].length));
345    const fence = '`'.repeat(Math.max(3, longest + 1));
346    const result = [
347      'USER-REVIEWED ADVISORY AGENT PLAN',
348      `Plan SHA-256 ${this.approval.fingerprint}`,
349      `${this.last.warnings.length} review warning(s) acknowledged locally. No workers have been started by this mod.`,
350      'Review this authored plan before deciding whether to delegate. Planned models, access, and concurrency are not engine permissions, resource reservations, or enforced limits. No cost or token savings has been established.',
351      '',
352      fence + 'json',
353      json.trimEnd(),
354      fence,
355    ].join('\n');
356    if (result.length > LIMITS.draft)
357      throw new Error(
358        'Plan exceeds the 16,000-character draft limit. Narrow it before draft insertion.',
359      );
360    return result;
361  }
362}
363