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

<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.
/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.
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:
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.
/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.
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.
hooks/register.ts 297 lines1import 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};
297src/board.ts 363 lines1export 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