Skip to content

Commit 4e0a8ea

Browse files
feat(requirements): ingest structured task and acceptance-criteria context (#672)
1 parent c633b83 commit 4e0a8ea

30 files changed

Lines changed: 1153 additions & 26 deletions

docs/agent.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,18 @@ not require a git diff, run configured commands, call models, or send telemetry.
2222
```bash
2323
npx codedecay agent preflight \
2424
--task "Add a dashboard filter for reviewed uploads" \
25+
--requirements .codedecay/requirements.yml \
2526
--format markdown
2627
```
2728

28-
The preflight report separates deterministic repo evidence from suggestions:
29+
The optional requirements artifact is repo-local JSON or YAML. It can provide
30+
`acceptanceCriteria`, `currentBehavior`, `expectedBehavior`, `affectedFlows`,
31+
`nonGoals`, `invariants`, `architectureConstraints`, and
32+
`unresolvedQuestions`. CodeDecay records the artifact as provenance and keeps
33+
requirement evidence separate from its own suggestions.
34+
35+
The preflight report separates requirement evidence, deterministic repo
36+
evidence, and suggestions:
2937

3038
- likely impacted areas from the task description
3139
- candidate files and route/API surfaces from repo paths
@@ -34,6 +42,12 @@ The preflight report separates deterministic repo evidence from suggestions:
3442
- configured checks listed as follow-up proof with `willRun=false`
3543
- proof plan and non-goals for the receiving agent
3644

45+
Candidate files require domain-specific task terms or stronger repo evidence.
46+
Generic words such as `api` alone do not make every API-related file a
47+
candidate. When CodeDecay cannot ground the task in the repository, it returns
48+
low confidence, no candidate files, and an unresolved question instead of
49+
inventing scope.
50+
3751
Treat preflight as a before-coding brief. After the agent edits code, run
3852
`codedecay redteam`, `codedecay agent`, and the relevant project checks to
3953
gather merge evidence.

docs/github-action.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,10 +271,16 @@ and fix tasks for a user-owned agent:
271271
base: ${{ github.event.pull_request.base.sha }}
272272
head: ${{ github.event.pull_request.head.sha }}
273273
cwd: .
274+
task: Add a billing export API
275+
requirements: .codedecay/requirements.yml
274276
format: markdown
275277
output: codedecay-agent.md
276278
```
277279

280+
`task` and `requirements` are explicit agent-mode inputs. The requirements path
281+
must resolve inside `cwd`; the action does not scrape the pull-request body,
282+
discover remote requirements, or call a model.
283+
278284
Supported modes are `analyze`, `redteam`, `agent`, and `product`. The action
279285
does not expose arbitrary command passthrough. Product mode only forwards the
280286
explicit product verification inputs documented above and still relies on

docs/mcp.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ Example preflight input:
9595
```json
9696
{
9797
"task": "Add a GET /api/users export endpoint",
98+
"requirements": {
99+
"acceptanceCriteria": [
100+
{
101+
"id": "AC-1",
102+
"text": "Authorized users can export rows.",
103+
"requiredProof": ["Call the real export route."]
104+
}
105+
],
106+
"affectedFlows": [
107+
{
108+
"name": "User export",
109+
"kind": "api"
110+
}
111+
]
112+
},
98113
"format": "markdown"
99114
}
100115
```

packages/agent/src/bundle.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function createAgentTaskBundle(report: RedteamReport, options: CreateAgen
4545
generatedAt: report.generatedAt,
4646
purpose: agentProfile.description,
4747
agentProfile,
48+
requirements: report.requirements,
4849
summary,
4950
prompt: createPortableAgentPrompt(summary, agentProfile),
5051
instructions: [...DEFAULT_INSTRUCTIONS],

packages/agent/src/preflight/render.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,56 @@ export function renderAgentPreflightMarkdown(report: AgentPreflightReport): stri
3131
`| Memory matches | ${report.summary.memoryMatches} |`,
3232
`| Design constraints | ${report.summary.designConstraints} |`,
3333
`| Configured checks listed | ${report.summary.configuredChecks} |`,
34+
`| Acceptance criteria | ${report.summary.acceptanceCriteria} |`,
35+
`| Unresolved questions | ${report.summary.unresolvedQuestions} |`,
36+
`| Insufficient repo context | ${report.summary.insufficientContext ? "yes" : "no"} |`,
37+
"",
38+
"### Requirement Evidence",
39+
"",
40+
`Requirement confidence: ${report.requirements.confidence}`,
41+
"",
42+
"Provenance:"
43+
];
44+
45+
appendList(
46+
lines,
47+
report.requirements.sources.map((source) => {
48+
const location = source.location ? ` at \`${source.location}\`` : "";
49+
return `\`${source.id}\` (${source.kind}): ${source.label}${location}`;
50+
})
51+
);
52+
lines.push("", "Acceptance criteria:");
53+
appendList(
54+
lines,
55+
report.requirements.acceptanceCriteria.map((criterion) => {
56+
const proof = criterion.requiredProof.length > 0
57+
? ` Required proof: ${criterion.requiredProof.join(" ")}`
58+
: "";
59+
return `${criterion.id}: ${criterion.text} [sources: ${criterion.sourceIds.join(", ")}].${proof}`;
60+
})
61+
);
62+
lines.push("", "Affected flows:");
63+
appendList(
64+
lines,
65+
report.requirements.affectedFlows.map(
66+
(flow) => `${flow.kind}: ${flow.name} [sources: ${flow.sourceIds.join(", ")}]`
67+
)
68+
);
69+
lines.push("", "Unresolved questions:");
70+
appendList(
71+
lines,
72+
report.requirements.unresolvedQuestions.map(
73+
(question) => `${question.text} [sources: ${question.sourceIds.join(", ")}]`
74+
)
75+
);
76+
lines.push(
3477
"",
3578
"### Deterministic Repo Evidence",
3679
"",
3780
`No git diff required: ${report.deterministicEvidence.taskSignals.noDiffRequired ? "yes" : "no"}`,
3881
"",
3982
"Task signals:"
40-
];
83+
);
4184

4285
appendList(lines, report.deterministicEvidence.taskSignals.tokens.map((token) => `\`${token}\``), "- none");
4386
lines.push("", "Matched task keywords:");

packages/agent/src/preflight/report.ts

Lines changed: 125 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { CODEDECAY_VERSION, dedupeStrings, isTestFilePath } from "@submuxhq/codedecay-core";
2-
import type { DesignMatcher } from "@submuxhq/codedecay-core";
1+
import {
2+
CODEDECAY_VERSION,
3+
dedupeStrings,
4+
isTestFilePath,
5+
normalizeRequirementContext
6+
} from "@submuxhq/codedecay-core";
7+
import type { DesignMatcher, RequirementContext } from "@submuxhq/codedecay-core";
38
import type { AgentSuggestedCheck } from "../types";
49
import type {
510
AgentPreflightArea,
@@ -45,18 +50,64 @@ const MAX_CANDIDATE_FILES = 24;
4550
const MAX_CANDIDATE_ROUTES = 16;
4651
const MAX_MEMORY_MATCHES_PER_SECTION = 8;
4752
const MAX_DESIGN_CONSTRAINTS = 16;
53+
const GENERIC_SCOPE_TOKENS = new Set([
54+
"add",
55+
"api",
56+
"change",
57+
"component",
58+
"create",
59+
"endpoint",
60+
"fix",
61+
"function",
62+
"handler",
63+
"implement",
64+
"logic",
65+
"module",
66+
"page",
67+
"refactor",
68+
"request",
69+
"response",
70+
"route",
71+
"service",
72+
"source",
73+
"test",
74+
"tests",
75+
"update",
76+
"user"
77+
]);
4878

4979
export function createAgentPreflightReport(options: CreateAgentPreflightReportOptions): AgentPreflightReport {
5080
const task = options.task.trim();
5181
if (!task) {
5282
throw new Error("agent preflight requires --task <description>.");
5383
}
5484

55-
const tokens = tokenize(task);
85+
let requirements = normalizeRequirementContext({
86+
task,
87+
context: options.requirements,
88+
source: options.requirementSource ?? {
89+
id: "task-input",
90+
kind: "task",
91+
label: "Agent preflight task"
92+
}
93+
});
94+
const tokens = tokenize(requirementSearchText(requirements));
95+
const scopeTokens = tokenize(requirementScopeText(requirements));
5696
const keywordMatches = collectKeywordMatches(tokens);
5797
const likelyAreas = collectLikelyAreas(keywordMatches, tokens);
58-
const candidateFiles = collectCandidateFiles(options.repoFiles, likelyAreas, tokens);
59-
const candidateRoutes = collectCandidateRoutes(candidateFiles, options.config);
98+
const candidateFiles = collectCandidateFiles(options.repoFiles, likelyAreas, scopeTokens);
99+
if (candidateFiles.length === 0 && requirements.unresolvedQuestions.length === 0) {
100+
requirements = {
101+
...requirements,
102+
unresolvedQuestions: [
103+
{
104+
text: `Which repository path implements ${strongScopeTokens(scopeTokens).slice(0, 4).join(" / ") || "this requirement"}?`,
105+
sourceIds: [requirements.task.sourceIds[0] ?? "task-input"]
106+
}
107+
]
108+
};
109+
}
110+
const candidateRoutes = collectCandidateRoutes(candidateFiles, options.config, scopeTokens);
60111
const configuredChecks = collectConfiguredChecks(options.config);
61112
const memory = collectMemoryEvidence(options.memory, likelyAreas, candidateFiles, candidateRoutes, tokens);
62113
const designConstraints = collectDesignConstraints(options.config, likelyAreas, candidateFiles, candidateRoutes, tokens);
@@ -88,14 +139,23 @@ export function createAgentPreflightReport(options: CreateAgentPreflightReportOp
88139
mode: "agent-preflight",
89140
generatedAt: options.generatedAt ?? new Date().toISOString(),
90141
task,
142+
requirements,
91143
summary: {
92-
confidence: confidenceFor(likelyAreas.length, candidateFiles.length, memoryCount(memory)),
144+
confidence: confidenceFor(
145+
likelyAreas.length,
146+
candidateFiles.length,
147+
memoryCount(memory),
148+
requirements.confidence
149+
),
93150
likelyAreas: likelyAreas.length,
94151
candidateFiles: candidateFiles.length,
95152
candidateRoutes: candidateRoutes.length,
96153
memoryMatches: memoryCount(memory),
97154
designConstraints: designConstraints.length,
98-
configuredChecks: configuredChecks.length
155+
configuredChecks: configuredChecks.length,
156+
acceptanceCriteria: requirements.acceptanceCriteria.length,
157+
unresolvedQuestions: requirements.unresolvedQuestions.length,
158+
insufficientContext: candidateFiles.length === 0
99159
},
100160
deterministicEvidence,
101161
suggestions,
@@ -107,7 +167,7 @@ export function createAgentPreflightReport(options: CreateAgentPreflightReportOp
107167
agentOutputTrusted: false
108168
},
109169
limits: [
110-
"Preflight does not inspect a PR diff; file and route candidates are heuristic matches from the task description and repo paths.",
170+
"Preflight does not inspect a PR diff; file and route candidates require domain-specific task terms or stronger repo evidence.",
111171
"Preflight does not execute configured commands, open browsers, call models, install tools, or send telemetry.",
112172
"Memory and docs are treated as review context, not trusted executable instruction.",
113173
"Use the proof plan as a starting point, then verify with real tests, configured checks, or product/runtime evidence."
@@ -208,7 +268,11 @@ function collectCandidateFiles(
208268
.filter(isPreflightRepoFile)
209269
.map((path) => scoreCandidateFile(path, likelyAreas, tokens))
210270
.filter((candidate): candidate is AgentPreflightCandidateFile & { score: number } => candidate !== undefined)
211-
.sort((left, right) => right.score - left.score || left.path.localeCompare(right.path));
271+
.sort((left, right) =>
272+
right.score - left.score ||
273+
Number(isTestFilePath(left.path)) - Number(isTestFilePath(right.path)) ||
274+
left.path.localeCompare(right.path)
275+
);
212276

213277
return scored.slice(0, MAX_CANDIDATE_FILES).map(({ score: _score, ...candidate }) => candidate);
214278
}
@@ -229,8 +293,14 @@ function scoreCandidateFile(
229293
): (AgentPreflightCandidateFile & { score: number }) | undefined {
230294
const normalizedPath = path.toLowerCase();
231295
const pathAreas = pathAreasFor(path);
296+
const tokenHits = strongScopeTokens(tokens).filter((token) => normalizedPath.includes(token));
297+
if (tokenHits.length === 0) {
298+
return undefined;
299+
}
300+
232301
const reasons: string[] = [];
233-
let score = 0;
302+
let score = Math.min(tokenHits.length * 4, 16);
303+
reasons.push(`Path includes requirement term(s): ${tokenHits.slice(0, 5).join(", ")}.`);
234304

235305
for (const area of likelyAreas) {
236306
if (pathAreas.includes(area.kind)) {
@@ -239,12 +309,6 @@ function scoreCandidateFile(
239309
}
240310
}
241311

242-
const tokenHits = tokens.filter((token) => token.length >= 3 && normalizedPath.includes(token));
243-
if (tokenHits.length > 0) {
244-
score += Math.min(tokenHits.length * 2, 8);
245-
reasons.push(`Path includes task term(s): ${tokenHits.slice(0, 5).join(", ")}.`);
246-
}
247-
248312
if (pathAreas.includes("test") && likelyAreas.some((area) => area.kind === "test")) {
249313
score += 4;
250314
reasons.push("Task asks for test or proof work and this is a test path.");
@@ -331,7 +395,8 @@ function pathAreasFor(path: string): AgentPreflightAreaKind[] {
331395

332396
function collectCandidateRoutes(
333397
candidateFiles: AgentPreflightCandidateFile[],
334-
config: AgentPreflightConfigInput | undefined
398+
config: AgentPreflightConfigInput | undefined,
399+
tokens: string[]
335400
): AgentPreflightCandidateRoute[] {
336401
const routes: AgentPreflightCandidateRoute[] = [];
337402

@@ -344,12 +409,20 @@ function collectCandidateRoutes(
344409

345410
for (const [targetId, target] of Object.entries(config?.productTesting?.targets ?? {})) {
346411
for (const endpoint of target?.apiEndpoints ?? []) {
412+
const endpointText = `${endpoint.id ?? ""} ${endpoint.path}`.toLowerCase();
413+
const matchedTerms = strongScopeTokens(tokens).filter((token) => endpointText.includes(token));
414+
if (matchedTerms.length === 0) {
415+
continue;
416+
}
347417
routes.push({
348418
route: endpoint.path,
349419
kind: "product-api",
350420
methods: [endpoint.method.toUpperCase()],
351421
files: [],
352-
reasons: [`Configured product target \`${targetId}\` includes endpoint \`${endpoint.id ?? endpoint.path}\`.`]
422+
reasons: [
423+
`Configured product target \`${targetId}\` includes endpoint \`${endpoint.id ?? endpoint.path}\`.`,
424+
`Endpoint includes requirement term(s): ${matchedTerms.slice(0, 5).join(", ")}.`
425+
]
353426
});
354427
}
355428
}
@@ -799,8 +872,16 @@ function tokenize(value: string): string[] {
799872
).slice(0, MAX_TASK_TOKENS);
800873
}
801874

802-
function confidenceFor(areaCount: number, candidateFileCount: number, memoryMatches: number): AgentPreflightConfidence {
803-
if (areaCount >= 2 && candidateFileCount >= 2) {
875+
function confidenceFor(
876+
areaCount: number,
877+
candidateFileCount: number,
878+
memoryMatches: number,
879+
requirementConfidence: AgentPreflightConfidence
880+
): AgentPreflightConfidence {
881+
if (
882+
(requirementConfidence === "high" && candidateFileCount >= 1) ||
883+
(areaCount >= 2 && candidateFileCount >= 2)
884+
) {
804885
return "high";
805886
}
806887

@@ -811,6 +892,30 @@ function confidenceFor(areaCount: number, candidateFileCount: number, memoryMatc
811892
return "low";
812893
}
813894

895+
function requirementSearchText(requirements: RequirementContext): string {
896+
return [
897+
requirements.task.text,
898+
...requirements.currentBehavior.map((entry) => entry.text),
899+
...requirements.expectedBehavior.map((entry) => entry.text),
900+
...requirements.acceptanceCriteria.flatMap((entry) => [entry.text, ...entry.requiredProof]),
901+
...requirements.affectedFlows.flatMap((flow) => [flow.name, flow.description ?? ""]),
902+
...requirements.invariants.map((entry) => entry.text),
903+
...requirements.architectureConstraints.map((entry) => entry.text)
904+
].join(" ");
905+
}
906+
907+
function requirementScopeText(requirements: RequirementContext): string {
908+
return [
909+
requirements.task.text,
910+
...requirements.affectedFlows.flatMap((flow) => [flow.name, flow.description ?? ""]),
911+
...requirements.architectureConstraints.map((entry) => entry.text)
912+
].join(" ");
913+
}
914+
915+
function strongScopeTokens(tokens: string[]): string[] {
916+
return tokens.filter((token) => token.length >= 3 && !GENERIC_SCOPE_TOKENS.has(token));
917+
}
918+
814919
function confidenceRank(confidence: AgentPreflightConfidence): number {
815920
if (confidence === "high") {
816921
return 3;

0 commit comments

Comments
 (0)