Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/MULTIAGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ it. Planner memory is not read by the coder. This introduces no new store,
shared raw history, model-learning claim or autonomous permission grant.
These boundaries implement FEATURE_SPEC sections 5.2, 5.4 and 5.6.

Acceptance checks are bounded to 240 characters after whitespace normalization.
New slice JSON, written task/plan proposals, child outlines and planning receipts
reject oversized checks explicitly rather than cutting off requirements.
Reading existing task documents preserves complete checks, including legacy
long checks, so source-validated acceptance continuation remains compatible.
Model validation feedback requests concise complete wording within the existing
turn budget. Oversized written checks receive the single validation correction
immediately, rather than repeating rewrites until the general budget expires.
A still-invalid correction stops before coding and publication.
Title shortening is unchanged. This implements FEATURE_SPEC
sections 5.3-5.5; neither the original Ask nor its safety obligations are waived.
Directory-document checks also recognize content phrasing such as "covering",
"gives instructions" and "indexes"; operation-success assertions stay denied.

Both ordinary minimum and full coder packs include the complete configured
[coder principal](../principals/coder.md) and root repository coding rules.
The existing rules filter still omits harness-only publication, credentials
Expand Down
1 change: 1 addition & 0 deletions src/planner/plan-mode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function planSlice(ask, {
{ role: 'system', content: 'You are the read-only plan-mode planner. Explore with read_file, list_dir, or search_text. ' +
'You may write only PLAN.md, never TASK.md, RECIPE.yml, product code, tests, or publishing operations. ' +
'PLAN.md must contain a title, Original Ask, Acceptance checks, explicit Files allowed, and task metadata. ' +
'Each acceptance check is at most 240 characters; shorten wording without dropping requirements. ' +
'Alternatively return JSON with title, acceptance_checks, files_allowed, and optional difficulty, estimate_min, task_class, model. ' +
'Stay inside the files named by the human Ask. The human must accept before coding.' },
{ role: 'user', content: ask },
Expand Down
5 changes: 3 additions & 2 deletions src/planner/plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mergeUsage } from '../metrics/run.mjs';
import { isAllowedFile } from '../runtime/tools.mjs';
import { redactSecrets } from '../runtime/memory.mjs';
import { askRequirements, cleanAskText } from './stub.mjs';
import { allowedFile, checkedList, oneLine } from './task.mjs';
import { acceptanceCheck, allowedFile, checkedList, oneLine } from './task.mjs';
import { selectReasoning } from '../llm/reasoning.mjs';

function object(value, required, optional = []) {
Expand Down Expand Up @@ -56,7 +56,7 @@ export function validatePlan(value, { kind, filesAllowed = [], repositoryFiles,
}
return { title, outcome: oneLine(issue.outcome, 'Child issue outcome'),
acceptance_checks: checkedList(issue.acceptance_checks, 'Child issue checks',
(check) => oneLine(check, 'Acceptance check')),
acceptanceCheck),
wave: issue.wave, files_allowed: files };
});
const waves = [...new Set(issues.map(({ wave }) => wave))].sort((a, b) => a - b);
Expand Down Expand Up @@ -152,6 +152,7 @@ export async function planOutline(ask, {
'Do not implement, create issues, or grant policy permissions. Return only JSON with outcomes (1-8 short lines) ' +
`and issues (${kind === 'feature' ? '2-5' : '2-12'} child issue drafts). Each issue has title, outcome, ` +
'acceptance_checks (1-8 short verifiable lines), wave (integer1-8 starting at1 without gaps), ' +
'Each acceptance check is at most 240 characters; shorten wording without dropping requirements. ' +
(proposeScope
? 'and files_allowed (1-32 paths). The human named no files: propose each draft\'s planned files from ' +
'repository_files, or new files inside their directories. Planned files are scope the coder may expand ' +
Expand Down
4 changes: 2 additions & 2 deletions src/planner/receipt.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { applicationFiles, normalizeAsk, oneLine } from './task.mjs';
import { acceptanceCheck, applicationFiles, normalizeAsk } from './task.mjs';
import { estimateTask } from '../runtime/estimate.mjs';

const fields = new Set(['task', 'title', 'task_class', 'difficulty', 'estimate_min', 'model', 'ask',
Expand Down Expand Up @@ -46,7 +46,7 @@ export function validatePlanningReceipt(source, task) {
!Array.isArray(receipt.files_allowed)) {
throw new TypeError('Planning receipt must match the task title and contain checks and allowed files');
}
receipt.acceptance_checks.forEach((check) => oneLine(check, 'Receipt acceptance check'));
receipt.acceptance_checks.forEach(acceptanceCheck);
const files = applicationFiles(receipt.files_allowed);
if (files.length !== task.files_allowed.length || files.some((file) => !task.files_allowed.includes(file))) {
throw new TypeError('Planning receipt application paths differ from TASK.md');
Expand Down
16 changes: 14 additions & 2 deletions src/planner/stub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { isAllowedFile, plannerToolDefinitions } from '../runtime/tools.mjs';
import { redactSecrets } from '../runtime/memory.mjs';
import { applyFeedback } from './feedback.mjs';
import { parsePlannerToolCalls } from './tool-calls.mjs';
import { allowedFile, checkedList, ensureAcceptanceChecks, ensureAllowedFiles, ensureOriginalAsk, ensureTitleHeading, isDirectoryDocumentAsk, oneLine, outputDirectoryFromAsk, parseTaskDocument, validateDirectoryDocumentPlan, validateDirectoryFiles } from './task.mjs';
import { acceptanceCheck, allowedFile, checkedList, ensureAcceptanceChecks, ensureAllowedFiles, ensureOriginalAsk, ensureTitleHeading, isDirectoryDocumentAsk, oneLine, outputDirectoryFromAsk, parseTaskDocument, validateDirectoryDocumentPlan, validateDirectoryFiles } from './task.mjs';
import { selectReasoning } from '../llm/reasoning.mjs';
import { isLlmTimeout, localRequestTimeoutMs, resolveRequestTimeout } from '../llm/request.mjs';
import { issueWave } from '../lib/wave-labels.mjs';
Expand Down Expand Up @@ -89,7 +89,7 @@ export function buildPlan(ask, { reference, title, acceptanceChecks, filesAllowe
const files = checkedList(filesAllowed, 'Files allowed', allowedFile, 32);
const docsOnly = files.length > 0 && files.every((file) => file.endsWith('.md'));
const checks = checkedList(acceptanceChecks, 'Acceptance checks',
(check) => oneLine(check, 'Acceptance check'))
acceptanceCheck)
.filter((check) => !(docsOnly && /node --test/.test(check)));
const requirements = scope ?? askRequirements(cleanAsk);
if (requirements.outputDirectory && !checks.length) {
Expand Down Expand Up @@ -145,6 +145,7 @@ export function planStub(ask, { reference = 'local:draft', title, metadata } = {

export function planFromTask(task, ask, { issueTitle, issueBody } = {}) {
const { title, acceptance_checks, files_allowed } = parseTaskDocument(task, { expectedAsk: ask, issueTitle, issueBody });
acceptance_checks.forEach(acceptanceCheck);
checkAskScope(files_allowed, askRequirements(issueTitle ? `${issueTitle}\n${ask}` : ask, { allowMissing: true }));
const { difficulty, estimate_min, task_class, model } = readTaskMetadata(task);
return { title, acceptance_checks, files_allowed, difficulty, estimate_min, task_class, model, design: parseDesign(task) };
Expand Down Expand Up @@ -222,6 +223,7 @@ export async function planAsk(ask, {
: 'You have no tools in this draft-only planning context. ') +
'Plan one software task. JSON shape: {"title":"Task title","acceptance_checks":["check"],"files_allowed":["path"]}. ' +
'Use 1-8 acceptance checks in JSON or TASK.md; group related checks without dropping requirements. ' +
'Each acceptance check must be at most 240 characters; shorten wording without dropping requirements. ' +
'A code task needs acceptance_checks including node --test exits 0 and files_allowed. ' +
'Optional fields: difficulty (1-5), estimate_min, task_class (feat|fix|docs|test), model, steps, notes. ' +
(lockedModel ? `Keep model ${lockedModel}; do not choose another model. ` : '') +
Expand Down Expand Up @@ -427,6 +429,16 @@ export async function planAsk(ask, {
});
} catch (error) {
if (!(error instanceof Error)) throw error;
if (!/^[{\[]/.test(plannerPlanText(message.content)) &&
/^Acceptance check must be at most 240 characters/.test(error.message)) {
if (validationRepairUsed) return fallback(error.message, turn);
validationRepairUsed = true;
messages.push({ role: 'user', content:
`The written TASK.md is invalid (${redactSecrets(error.message, { env, apiKeyEnv: config.llm.api_key_env })}). ` +
'Use this single validation correction to return the complete required JSON plan. ' +
'Keep every requirement and the original Ask; the harness will write the validated artifacts.' });
continue;
}
if (!/^[{\[]/.test(plannerPlanText(message.content)) && turn >= budget + Number(repairUsed)) {
if (validationRepairUsed) return fallback(error.message, turn);
if (!/^Acceptance checks must contain 1-8 entries/.test(error.message)) {
Expand Down
20 changes: 16 additions & 4 deletions src/planner/task.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { splitTaskFrontmatter } from '../runtime/skills.mjs';
import { isForbiddenWrite, planArtifactFiles, plannerArtifactFiles } from '../runtime/tools.mjs';

export function oneLine(value, label) {
function normalizedLine(value, label) {
const collapsed = typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim() : '';
if (!collapsed) throw new TypeError(`${label} must be one nonempty line (at most 240 characters)`);
return collapsed.slice(0, 240);
return collapsed;
}

export function oneLine(value, label) {
return normalizedLine(value, label).slice(0, 240);
}

export function acceptanceCheck(value) {
const check = normalizedLine(value, 'Acceptance check');
if (check.length > 240) {
throw new TypeError('Acceptance check must be at most 240 characters; shorten it without dropping requirements');
}
return check;
}

export function allowedFile(value) {
Expand Down Expand Up @@ -67,7 +79,7 @@ export function validateDirectoryDocumentPlan(ask, files, checks) {
const commands = /\b(?:npm|npx|pnpm|yarn|pip|apt|docker|kubectl|terraform|curl|wget|ssh|bash|powershell|Invoke-WebRequest)\b|\b(?:git\s+(?:push|clone)|gh\s+(?:pr|release)|node\s+--test)\b/i;
const action = /\b(?:install(?:ation|ed|s)?|deploy(?:ment|ed|s)?|publish(?:ed|es|ing)?|purchase(?:d|s)?|buy|account|network|request|fetch|download(?:ed|s)?|upload(?:ed|s)?|serve(?:d|s)?|hosting)\b/i;
const assertion = /\b(?:run|execute|succeeds?|successful(?:ly)?|exits?|passes?|created|activated|completed|live|reachable)\b|\b(?:is|are|was|were)\s+(?:installed|deployed|published|purchased|serving|running)\b/i;
const content = /\b(?:documents?|describes?|explains?|outlines?|contains?|includes?|covers?|lists?|mentions?|states?|warns?|contrasts?|shows?|compares?|defines?|presents?)\b/i;
const content = /\b(?:documents?|describes?|explains?|outlines?|contains?|includes?|cover(?:s|ing)?|lists?|mentions?|states?|warns?|contrasts?|shows?|compares?|defines?|presents?|gives?|indexes)\b/i;
const activeClause = /(?:[;:]|\b(?:and|then))\s+(?:run|execute|install|deploy|publish|purchase|buy|register|create an? account|fetch|download|upload|request)(?:s|ed)?\b/i;
for (const [index, check] of checks.entries()) {
if (!content.test(check) || activeClause.test(check) || commands.test(check) && assertion.test(check) ||
Expand Down Expand Up @@ -266,7 +278,7 @@ export function parseTaskDocument(task, { expectedAsk, issueTitle, issueBody } =
}
const acceptance_checks = checkedList(sectionList(parsed.sections.find(({ name }) => name === 'acceptance checks'),
'Acceptance Checks (or acceptance_checks)', { continuations: true }),
'Acceptance checks', (line) => oneLine(line, 'Acceptance check'));
'Acceptance checks', (line) => normalizedLine(line, 'Acceptance check'));
const files_allowed = taskFilesAllowed(task);
if (outputDirectory) {
validateDirectoryFiles(files_allowed, outputDirectory);
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/checklist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const keysAre = (value, keys) => value && typeof value === 'object' && !Array.is
Object.keys(value).sort().join(',') === keys.split(',').sort().join(',');
const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);

// The planner's display parser caps each check at 240 characters; continuity must not lose obligations.
// Existing task checks are authoritative data; continuity must not lose obligations.
export function authoritativeAcceptanceChecks(task) {
const parsed = parseTaskDocument(task);
const content = taskSections(task).sections.find(({ name }) => name === 'acceptance checks').content;
Expand Down
6 changes: 5 additions & 1 deletion tests/planner-task.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,15 @@ test('observed plain-language document contrasts and examples validate without a
'The guide shows one read-only PowerShell example that lists branches, `git branch -a`, and one that lists worktrees, `git worktree list`.',
'The guide compares branches with worktrees.',
'The guide defines the terms and presents illustrative examples.',
'The design is a one-page design covering layout, sample copy and visual style.',
'SETUP gives step-by-step instructions for obtaining a domain and serving the page publicly.',
'README indexes the two documents and describes their purpose.',
];
const plan = buildPlan(directoryAsk, { ...directoryOptions, acceptanceChecks: checks });
assert.deepEqual(parseTaskDocument(plan.task, { expectedAsk: directoryAsk }).acceptance_checks, checks);
for (const check of ['The guide shows deployment completed', 'The guide presents account creation and the account is activated',
'The guide compares instructions and run npm install', 'The guide defines setup; execute powershell']) {
'The guide compares instructions and run npm install', 'The guide defines setup; execute powershell',
'The guide gives instructions and run npm install', 'The guide is covering deployment completed']) {
assert.throws(() => buildPlan(directoryAsk, { ...directoryOptions, acceptanceChecks: [check] }),
/document-content checks.*check 1/i);
}
Expand Down
Loading
Loading