Skip to content

Commit 418101a

Browse files
Merge pull request #522 from R4yHarris/agents/acceptance-fidelity
[agent] preserve acceptance requirements with explicit length validation
2 parents 23732b1 + 3d7aedc commit 418101a

9 files changed

Lines changed: 166 additions & 12 deletions

File tree

‎docs/MULTIAGENT.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,20 @@ it. Planner memory is not read by the coder. This introduces no new store,
4242
shared raw history, model-learning claim or autonomous permission grant.
4343
These boundaries implement FEATURE_SPEC sections 5.2, 5.4 and 5.6.
4444

45+
Acceptance checks are bounded to 240 characters after whitespace normalization.
46+
New slice JSON, written task/plan proposals, child outlines and planning receipts
47+
reject oversized checks explicitly rather than cutting off requirements.
48+
Reading existing task documents preserves complete checks, including legacy
49+
long checks, so source-validated acceptance continuation remains compatible.
50+
Model validation feedback requests concise complete wording within the existing
51+
turn budget. Oversized written checks receive the single validation correction
52+
immediately, rather than repeating rewrites until the general budget expires.
53+
A still-invalid correction stops before coding and publication.
54+
Title shortening is unchanged. This implements FEATURE_SPEC
55+
sections 5.3-5.5; neither the original Ask nor its safety obligations are waived.
56+
Directory-document checks also recognize content phrasing such as "covering",
57+
"gives instructions" and "indexes"; operation-success assertions stay denied.
58+
4559
Both ordinary minimum and full coder packs include the complete configured
4660
[coder principal](../principals/coder.md) and root repository coding rules.
4761
The existing rules filter still omits harness-only publication, credentials

‎src/planner/plan-mode.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export async function planSlice(ask, {
3737
{ role: 'system', content: 'You are the read-only plan-mode planner. Explore with read_file, list_dir, or search_text. ' +
3838
'You may write only PLAN.md, never TASK.md, RECIPE.yml, product code, tests, or publishing operations. ' +
3939
'PLAN.md must contain a title, Original Ask, Acceptance checks, explicit Files allowed, and task metadata. ' +
40+
'Each acceptance check is at most 240 characters; shorten wording without dropping requirements. ' +
4041
'Alternatively return JSON with title, acceptance_checks, files_allowed, and optional difficulty, estimate_min, task_class, model. ' +
4142
'Stay inside the files named by the human Ask. The human must accept before coding.' },
4243
{ role: 'user', content: ask +

‎src/planner/plan.mjs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mergeUsage } from '../metrics/run.mjs';
22
import { isAllowedFile } from '../runtime/tools.mjs';
33
import { plannerMemoryText, redactSecrets } from '../runtime/memory.mjs';
44
import { askRequirements, cleanAskText } from './stub.mjs';
5-
import { allowedFile, checkedList, oneLine } from './task.mjs';
5+
import { acceptanceCheck, allowedFile, checkedList, oneLine } from './task.mjs';
66
import { selectReasoning } from '../llm/reasoning.mjs';
77

88
function object(value, required, optional = []) {
@@ -56,7 +56,7 @@ export function validatePlan(value, { kind, filesAllowed = [], repositoryFiles,
5656
}
5757
return { title, outcome: oneLine(issue.outcome, 'Child issue outcome'),
5858
acceptance_checks: checkedList(issue.acceptance_checks, 'Child issue checks',
59-
(check) => oneLine(check, 'Acceptance check')),
59+
acceptanceCheck),
6060
wave: issue.wave, files_allowed: files };
6161
});
6262
const waves = [...new Set(issues.map(({ wave }) => wave))].sort((a, b) => a - b);
@@ -153,6 +153,7 @@ export async function planOutline(ask, {
153153
'Do not implement, create issues, or grant policy permissions. Return only JSON with outcomes (1-8 short lines) ' +
154154
`and issues (${kind === 'feature' ? '2-5' : '2-12'} child issue drafts). Each issue has title, outcome, ` +
155155
'acceptance_checks (1-8 short verifiable lines), wave (integer1-8 starting at1 without gaps), ' +
156+
'Each acceptance check is at most 240 characters; shorten wording without dropping requirements. ' +
156157
(proposeScope
157158
? 'and files_allowed (1-32 paths). The human named no files: propose each draft\'s planned files from ' +
158159
'repository_files, or new files inside their directories. Planned files are scope the coder may expand ' +

‎src/planner/receipt.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { applicationFiles, normalizeAsk, oneLine } from './task.mjs';
1+
import { acceptanceCheck, applicationFiles, normalizeAsk } from './task.mjs';
22
import { estimateTask } from '../runtime/estimate.mjs';
33

44
const fields = new Set(['task', 'title', 'task_class', 'difficulty', 'estimate_min', 'model', 'ask',
@@ -46,7 +46,7 @@ export function validatePlanningReceipt(source, task) {
4646
!Array.isArray(receipt.files_allowed)) {
4747
throw new TypeError('Planning receipt must match the task title and contain checks and allowed files');
4848
}
49-
receipt.acceptance_checks.forEach((check) => oneLine(check, 'Receipt acceptance check'));
49+
receipt.acceptance_checks.forEach(acceptanceCheck);
5050
const files = applicationFiles(receipt.files_allowed);
5151
if (files.length !== task.files_allowed.length || files.some((file) => !task.files_allowed.includes(file))) {
5252
throw new TypeError('Planning receipt application paths differ from TASK.md');

‎src/planner/stub.mjs‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { isAllowedFile, plannerToolDefinitions } from '../runtime/tools.mjs';
77
import { plannerMemoryText, redactSecrets } from '../runtime/memory.mjs';
88
import { applyFeedback } from './feedback.mjs';
99
import { parsePlannerToolCalls } from './tool-calls.mjs';
10-
import { allowedFile, checkedList, ensureAcceptanceChecks, ensureAllowedFiles, ensureOriginalAsk, ensureTitleHeading, isDirectoryDocumentAsk, oneLine, outputDirectoryFromAsk, parseTaskDocument, validateDirectoryDocumentPlan, validateDirectoryFiles } from './task.mjs';
10+
import { acceptanceCheck, allowedFile, checkedList, ensureAcceptanceChecks, ensureAllowedFiles, ensureOriginalAsk, ensureTitleHeading, isDirectoryDocumentAsk, oneLine, outputDirectoryFromAsk, parseTaskDocument, validateDirectoryDocumentPlan, validateDirectoryFiles } from './task.mjs';
1111
import { selectReasoning } from '../llm/reasoning.mjs';
1212
import { isLlmTimeout, localRequestTimeoutMs, resolveRequestTimeout } from '../llm/request.mjs';
1313
import { issueWave } from '../lib/wave-labels.mjs';
@@ -89,7 +89,7 @@ export function buildPlan(ask, { reference, title, acceptanceChecks, filesAllowe
8989
const files = checkedList(filesAllowed, 'Files allowed', allowedFile, 32);
9090
const docsOnly = files.length > 0 && files.every((file) => file.endsWith('.md'));
9191
const checks = checkedList(acceptanceChecks, 'Acceptance checks',
92-
(check) => oneLine(check, 'Acceptance check'))
92+
acceptanceCheck)
9393
.filter((check) => !(docsOnly && /node --test/.test(check)));
9494
const requirements = scope ?? askRequirements(cleanAsk);
9595
if (requirements.outputDirectory && !checks.length) {
@@ -145,6 +145,7 @@ export function planStub(ask, { reference = 'local:draft', title, metadata } = {
145145

146146
export function planFromTask(task, ask, { issueTitle, issueBody } = {}) {
147147
const { title, acceptance_checks, files_allowed } = parseTaskDocument(task, { expectedAsk: ask, issueTitle, issueBody });
148+
acceptance_checks.forEach(acceptanceCheck);
148149
checkAskScope(files_allowed, askRequirements(issueTitle ? `${issueTitle}\n${ask}` : ask, { allowMissing: true }));
149150
const { difficulty, estimate_min, task_class, model } = readTaskMetadata(task);
150151
return { title, acceptance_checks, files_allowed, difficulty, estimate_min, task_class, model, design: parseDesign(task) };
@@ -220,6 +221,7 @@ export async function planAsk(ask, {
220221
: 'You have no tools in this draft-only planning context. ') +
221222
'Plan one software task. JSON shape: {"title":"Task title","acceptance_checks":["check"],"files_allowed":["path"]}. ' +
222223
'Use 1-8 acceptance checks in JSON or TASK.md; group related checks without dropping requirements. ' +
224+
'Each acceptance check must be at most 240 characters; shorten wording without dropping requirements. ' +
223225
'A code task needs acceptance_checks including node --test exits 0 and files_allowed. ' +
224226
'Optional fields: difficulty (1-5), estimate_min, task_class (feat|fix|docs|test), model, steps, notes. ' +
225227
(lockedModel ? `Keep model ${lockedModel}; do not choose another model. ` : '') +
@@ -425,6 +427,16 @@ export async function planAsk(ask, {
425427
});
426428
} catch (error) {
427429
if (!(error instanceof Error)) throw error;
430+
if (!/^[{\[]/.test(plannerPlanText(message.content)) &&
431+
/^Acceptance check must be at most 240 characters/.test(error.message)) {
432+
if (validationRepairUsed) return fallback(error.message, turn);
433+
validationRepairUsed = true;
434+
messages.push({ role: 'user', content:
435+
`The written TASK.md is invalid (${redactSecrets(error.message, { env, apiKeyEnv: config.llm.api_key_env })}). ` +
436+
'Use this single validation correction to return the complete required JSON plan. ' +
437+
'Keep every requirement and the original Ask; the harness will write the validated artifacts.' });
438+
continue;
439+
}
428440
if (!/^[{\[]/.test(plannerPlanText(message.content)) && turn >= budget + Number(repairUsed)) {
429441
if (validationRepairUsed) return fallback(error.message, turn);
430442
if (!/^Acceptance checks must contain 1-8 entries/.test(error.message)) {

‎src/planner/task.mjs‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { splitTaskFrontmatter } from '../runtime/skills.mjs';
22
import { isForbiddenWrite, planArtifactFiles, plannerArtifactFiles } from '../runtime/tools.mjs';
33

4-
export function oneLine(value, label) {
4+
function normalizedLine(value, label) {
55
const collapsed = typeof value === 'string' ? value.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim() : '';
66
if (!collapsed) throw new TypeError(`${label} must be one nonempty line (at most 240 characters)`);
7-
return collapsed.slice(0, 240);
7+
return collapsed;
8+
}
9+
10+
export function oneLine(value, label) {
11+
return normalizedLine(value, label).slice(0, 240);
12+
}
13+
14+
export function acceptanceCheck(value) {
15+
const check = normalizedLine(value, 'Acceptance check');
16+
if (check.length > 240) {
17+
throw new TypeError('Acceptance check must be at most 240 characters; shorten it without dropping requirements');
18+
}
19+
return check;
820
}
921

1022
export function allowedFile(value) {
@@ -67,7 +79,7 @@ export function validateDirectoryDocumentPlan(ask, files, checks) {
6779
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;
6880
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;
6981
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;
70-
const content = /\b(?:documents?|describes?|explains?|outlines?|contains?|includes?|covers?|lists?|mentions?|states?|warns?|contrasts?|shows?|compares?|defines?|presents?)\b/i;
82+
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;
7183
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;
7284
for (const [index, check] of checks.entries()) {
7385
if (!content.test(check) || activeClause.test(check) || commands.test(check) && assertion.test(check) ||
@@ -266,7 +278,7 @@ export function parseTaskDocument(task, { expectedAsk, issueTitle, issueBody } =
266278
}
267279
const acceptance_checks = checkedList(sectionList(parsed.sections.find(({ name }) => name === 'acceptance checks'),
268280
'Acceptance Checks (or acceptance_checks)', { continuations: true }),
269-
'Acceptance checks', (line) => oneLine(line, 'Acceptance check'));
281+
'Acceptance checks', (line) => normalizedLine(line, 'Acceptance check'));
270282
const files_allowed = taskFilesAllowed(task);
271283
if (outputDirectory) {
272284
validateDirectoryFiles(files_allowed, outputDirectory);

‎src/runtime/checklist.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const keysAre = (value, keys) => value && typeof value === 'object' && !Array.is
5050
Object.keys(value).sort().join(',') === keys.split(',').sort().join(',');
5151
const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
5252

53-
// The planner's display parser caps each check at 240 characters; continuity must not lose obligations.
53+
// Existing task checks are authoritative data; continuity must not lose obligations.
5454
export function authoritativeAcceptanceChecks(task) {
5555
const parsed = parseTaskDocument(task);
5656
const content = taskSections(task).sections.find(({ name }) => name === 'acceptance checks').content;

‎tests/planner-task.test.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,15 @@ test('observed plain-language document contrasts and examples validate without a
6464
'The guide shows one read-only PowerShell example that lists branches, `git branch -a`, and one that lists worktrees, `git worktree list`.',
6565
'The guide compares branches with worktrees.',
6666
'The guide defines the terms and presents illustrative examples.',
67+
'The design is a one-page design covering layout, sample copy and visual style.',
68+
'SETUP gives step-by-step instructions for obtaining a domain and serving the page publicly.',
69+
'README indexes the two documents and describes their purpose.',
6770
];
6871
const plan = buildPlan(directoryAsk, { ...directoryOptions, acceptanceChecks: checks });
6972
assert.deepEqual(parseTaskDocument(plan.task, { expectedAsk: directoryAsk }).acceptance_checks, checks);
7073
for (const check of ['The guide shows deployment completed', 'The guide presents account creation and the account is activated',
71-
'The guide compares instructions and run npm install', 'The guide defines setup; execute powershell']) {
74+
'The guide compares instructions and run npm install', 'The guide defines setup; execute powershell',
75+
'The guide gives instructions and run npm install', 'The guide is covering deployment completed']) {
7276
assert.throws(() => buildPlan(directoryAsk, { ...directoryOptions, acceptanceChecks: [check] }),
7377
/document-content checks.*check 1/i);
7478
}

0 commit comments

Comments
 (0)