@quinteroac/agents-coding-toolkit 0.1.0-preview
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -0
- package/README.md +127 -0
- package/package.json +34 -0
- package/scaffold/.agents/flow/archived/tmpl_.gitkeep +0 -0
- package/scaffold/.agents/flow/tmpl_README.md +7 -0
- package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +11 -0
- package/scaffold/.agents/skills/automated-fix/tmpl_SKILL.md +67 -0
- package/scaffold/.agents/skills/create-issue/tmpl_SKILL.md +68 -0
- package/scaffold/.agents/skills/create-pr-document/tmpl_SKILL.md +125 -0
- package/scaffold/.agents/skills/create-project-context/tmpl_SKILL.md +168 -0
- package/scaffold/.agents/skills/create-test-plan/tmpl_SKILL.md +86 -0
- package/scaffold/.agents/skills/debug/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/evaluate/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/execute-test-batch/tmpl_SKILL.md +49 -0
- package/scaffold/.agents/skills/execute-test-case/tmpl_SKILL.md +47 -0
- package/scaffold/.agents/skills/implement-user-story/tmpl_SKILL.md +68 -0
- package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/refactor-prd/tmpl_SKILL.md +19 -0
- package/scaffold/.agents/skills/refine-pr-document/tmpl_SKILL.md +108 -0
- package/scaffold/.agents/skills/refine-project-context/tmpl_SKILL.md +157 -0
- package/scaffold/.agents/skills/refine-test-plan/tmpl_SKILL.md +76 -0
- package/scaffold/.agents/tmpl_PROJECT_CONTEXT.md +3 -0
- package/scaffold/.agents/tmpl_state.example.json +26 -0
- package/scaffold/.agents/tmpl_state_rules.md +29 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_CHANGELOG.md +18 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_TECHNICAL_DEBT.md +11 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_evaluation-report.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_product-requirement-document.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_refactor_plan.md +19 -0
- package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_test-plan.md +19 -0
- package/scaffold/docs/nvst-flow/tmpl_COMMANDS.md +0 -0
- package/scaffold/docs/nvst-flow/tmpl_QUICK_USE.md +0 -0
- package/scaffold/docs/tmpl_PLACEHOLDER.md +0 -0
- package/scaffold/schemas/node-shims.d.ts +15 -0
- package/scaffold/schemas/tmpl_issues.ts +19 -0
- package/scaffold/schemas/tmpl_prd.ts +26 -0
- package/scaffold/schemas/tmpl_progress.ts +39 -0
- package/scaffold/schemas/tmpl_state.ts +81 -0
- package/scaffold/schemas/tmpl_test-plan.ts +20 -0
- package/scaffold/schemas/tmpl_validate-progress.ts +13 -0
- package/scaffold/schemas/tmpl_validate-state.ts +13 -0
- package/scaffold/tmpl_AGENTS.md +7 -0
- package/schemas/prd.ts +26 -0
- package/schemas/progress.ts +39 -0
- package/schemas/state.ts +81 -0
- package/schemas/test-plan.test.ts +53 -0
- package/schemas/test-plan.ts +20 -0
- package/schemas/validate-progress.ts +13 -0
- package/schemas/validate-state.ts +13 -0
- package/src/agent.test.ts +37 -0
- package/src/agent.ts +225 -0
- package/src/cli-path.ts +4 -0
- package/src/cli.ts +578 -0
- package/src/commands/approve-project-context.ts +37 -0
- package/src/commands/approve-requirement.ts +217 -0
- package/src/commands/approve-test-plan.test.ts +193 -0
- package/src/commands/approve-test-plan.ts +202 -0
- package/src/commands/create-issue.test.ts +484 -0
- package/src/commands/create-issue.ts +371 -0
- package/src/commands/create-project-context.ts +96 -0
- package/src/commands/create-prototype.test.ts +153 -0
- package/src/commands/create-prototype.ts +425 -0
- package/src/commands/create-test-plan.test.ts +381 -0
- package/src/commands/create-test-plan.ts +248 -0
- package/src/commands/define-requirement.ts +47 -0
- package/src/commands/destroy.ts +113 -0
- package/src/commands/execute-automated-fix.test.ts +580 -0
- package/src/commands/execute-automated-fix.ts +363 -0
- package/src/commands/execute-manual-fix.test.ts +343 -0
- package/src/commands/execute-manual-fix.ts +203 -0
- package/src/commands/execute-test-plan.test.ts +1891 -0
- package/src/commands/execute-test-plan.ts +722 -0
- package/src/commands/init.ts +85 -0
- package/src/commands/refine-project-context.ts +74 -0
- package/src/commands/refine-requirement.ts +60 -0
- package/src/commands/refine-test-plan.test.ts +200 -0
- package/src/commands/refine-test-plan.ts +93 -0
- package/src/commands/start-iteration.test.ts +144 -0
- package/src/commands/start-iteration.ts +101 -0
- package/src/commands/write-json.ts +136 -0
- package/src/install.test.ts +124 -0
- package/src/pack.test.ts +103 -0
- package/src/state.test.ts +66 -0
- package/src/state.ts +52 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { $ as dollar } from "bun";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildPrompt,
|
|
7
|
+
invokeAgent,
|
|
8
|
+
loadSkill,
|
|
9
|
+
type AgentInvokeOptions,
|
|
10
|
+
type AgentProvider,
|
|
11
|
+
type AgentResult,
|
|
12
|
+
} from "../agent";
|
|
13
|
+
import { exists, FLOW_REL_DIR, readState } from "../state";
|
|
14
|
+
import { type Issue } from "../../scaffold/schemas/tmpl_issues";
|
|
15
|
+
|
|
16
|
+
export interface ExecuteAutomatedFixOptions {
|
|
17
|
+
provider: AgentProvider;
|
|
18
|
+
iterations?: number;
|
|
19
|
+
retryOnFail?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ExecuteAutomatedFixDeps {
|
|
23
|
+
existsFn: (path: string) => Promise<boolean>;
|
|
24
|
+
invokeAgentFn: (options: AgentInvokeOptions) => Promise<AgentResult>;
|
|
25
|
+
loadSkillFn: (projectRoot: string, skillName: string) => Promise<string>;
|
|
26
|
+
logFn: (message: string) => void;
|
|
27
|
+
nowFn: () => Date;
|
|
28
|
+
readFileFn: typeof readFile;
|
|
29
|
+
runCommitFn: (projectRoot: string, message: string) => Promise<number>;
|
|
30
|
+
writeFileFn: typeof writeFile;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultDeps: ExecuteAutomatedFixDeps = {
|
|
34
|
+
existsFn: exists,
|
|
35
|
+
invokeAgentFn: invokeAgent,
|
|
36
|
+
loadSkillFn: loadSkill,
|
|
37
|
+
logFn: console.log,
|
|
38
|
+
nowFn: () => new Date(),
|
|
39
|
+
readFileFn: readFile,
|
|
40
|
+
runCommitFn: async (projectRoot: string, message: string) => {
|
|
41
|
+
const result = await dollar`git add -A && git commit -m ${message}`
|
|
42
|
+
.cwd(projectRoot)
|
|
43
|
+
.nothrow()
|
|
44
|
+
.quiet();
|
|
45
|
+
return result.exitCode;
|
|
46
|
+
},
|
|
47
|
+
writeFileFn: writeFile,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function isNetworkErrorText(text: string): boolean {
|
|
51
|
+
const normalized = text.toLowerCase();
|
|
52
|
+
return [
|
|
53
|
+
"network",
|
|
54
|
+
"econnrefused",
|
|
55
|
+
"enotfound",
|
|
56
|
+
"eai_again",
|
|
57
|
+
"timed out",
|
|
58
|
+
"timeout",
|
|
59
|
+
"connection reset",
|
|
60
|
+
"connection refused",
|
|
61
|
+
].some((token) => normalized.includes(token));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isNetworkError(error: unknown): boolean {
|
|
65
|
+
if (error instanceof Error) {
|
|
66
|
+
return isNetworkErrorText(error.message);
|
|
67
|
+
}
|
|
68
|
+
return isNetworkErrorText(String(error));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sortIssuesById(issues: Issue[]): Issue[] {
|
|
72
|
+
return [...issues].sort((left, right) => left.id.localeCompare(right.id));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ALLOWED_ISSUE_STATUSES: Set<Issue["status"]> = new Set([
|
|
76
|
+
"open",
|
|
77
|
+
"fixed",
|
|
78
|
+
"retry",
|
|
79
|
+
"manual-fix",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
83
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return value as Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseIssuesForProcessing(
|
|
90
|
+
raw: unknown,
|
|
91
|
+
flowRelativePath: string,
|
|
92
|
+
logFn: (message: string) => void,
|
|
93
|
+
): Issue[] {
|
|
94
|
+
if (!Array.isArray(raw)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Deterministic validation error: issues schema mismatch in ${flowRelativePath}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parsedIssues: Issue[] = [];
|
|
101
|
+
const seenIds = new Set<string>();
|
|
102
|
+
|
|
103
|
+
for (const [index, item] of raw.entries()) {
|
|
104
|
+
const issue = asRecord(item);
|
|
105
|
+
if (!issue) {
|
|
106
|
+
logFn(
|
|
107
|
+
`Warning: Skipping invalid issue at index ${index} in ${flowRelativePath}: expected an object.`,
|
|
108
|
+
);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const id = issue.id;
|
|
113
|
+
const title = issue.title;
|
|
114
|
+
const description = issue.description;
|
|
115
|
+
const status = issue.status;
|
|
116
|
+
|
|
117
|
+
const missingFields: string[] = [];
|
|
118
|
+
if (typeof id !== "string") {
|
|
119
|
+
missingFields.push("id");
|
|
120
|
+
}
|
|
121
|
+
if (typeof title !== "string") {
|
|
122
|
+
missingFields.push("title");
|
|
123
|
+
}
|
|
124
|
+
if (typeof description !== "string") {
|
|
125
|
+
missingFields.push("description");
|
|
126
|
+
}
|
|
127
|
+
if (typeof status !== "string") {
|
|
128
|
+
missingFields.push("status");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (missingFields.length > 0) {
|
|
132
|
+
logFn(
|
|
133
|
+
`Warning: Skipping issue at index ${index} in ${flowRelativePath}: missing required field(s): ${missingFields.join(", ")}.`,
|
|
134
|
+
);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const validId = id as string;
|
|
139
|
+
const validTitle = title as string;
|
|
140
|
+
const validDescription = description as string;
|
|
141
|
+
const validStatus = status as Issue["status"];
|
|
142
|
+
|
|
143
|
+
if (!ALLOWED_ISSUE_STATUSES.has(validStatus)) {
|
|
144
|
+
logFn(
|
|
145
|
+
`Warning: Skipping issue ${validId} in ${flowRelativePath}: invalid status '${status}'.`,
|
|
146
|
+
);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (seenIds.has(validId)) {
|
|
151
|
+
logFn(
|
|
152
|
+
`Warning: Skipping duplicate issue id '${validId}' in ${flowRelativePath}.`,
|
|
153
|
+
);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
seenIds.add(validId);
|
|
158
|
+
parsedIssues.push({
|
|
159
|
+
id: validId,
|
|
160
|
+
title: validTitle,
|
|
161
|
+
description: validDescription,
|
|
162
|
+
status: validStatus,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return parsedIssues;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function writeIssuesFile(
|
|
170
|
+
issuesPath: string,
|
|
171
|
+
issues: Issue[],
|
|
172
|
+
deps: ExecuteAutomatedFixDeps,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
await deps.writeFileFn(issuesPath, `${JSON.stringify(issues, null, 2)}\n`, "utf8");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function commitIssueUpdate(
|
|
178
|
+
projectRoot: string,
|
|
179
|
+
issueId: string,
|
|
180
|
+
issueStatus: Issue["status"],
|
|
181
|
+
deps: ExecuteAutomatedFixDeps,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
const commitMessage = `fix: automated-fix ${issueId} -> ${issueStatus}`;
|
|
184
|
+
const exitCode = await deps.runCommitFn(projectRoot, commitMessage);
|
|
185
|
+
return exitCode === 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function runExecuteAutomatedFix(
|
|
189
|
+
opts: ExecuteAutomatedFixOptions,
|
|
190
|
+
deps: Partial<ExecuteAutomatedFixDeps> = {},
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
if (opts.iterations !== undefined && (!Number.isInteger(opts.iterations) || opts.iterations < 1)) {
|
|
193
|
+
throw new Error("Invalid --iterations value. Expected an integer >= 1.");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
opts.retryOnFail !== undefined &&
|
|
198
|
+
(!Number.isInteger(opts.retryOnFail) || opts.retryOnFail < 0)
|
|
199
|
+
) {
|
|
200
|
+
throw new Error("Invalid --retry-on-fail value. Expected an integer >= 0.");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const mergedDeps = { ...defaultDeps, ...deps };
|
|
204
|
+
const projectRoot = process.cwd();
|
|
205
|
+
const state = await readState(projectRoot);
|
|
206
|
+
|
|
207
|
+
const iteration = state.current_iteration;
|
|
208
|
+
const fileName = `it_${iteration}_ISSUES.json`;
|
|
209
|
+
const issuesPath = join(projectRoot, FLOW_REL_DIR, fileName);
|
|
210
|
+
|
|
211
|
+
if (!(await mergedDeps.existsFn(issuesPath))) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Issues file not found: expected ${join(FLOW_REL_DIR, fileName)}. Run \`bun nvst create issue --agent <provider>\` first.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let parsedIssuesRaw: unknown;
|
|
218
|
+
const flowRelativePath = join(FLOW_REL_DIR, fileName);
|
|
219
|
+
try {
|
|
220
|
+
parsedIssuesRaw = JSON.parse(await mergedDeps.readFileFn(issuesPath, "utf8"));
|
|
221
|
+
} catch {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`Deterministic validation error: invalid issues JSON in ${flowRelativePath}.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const issues = sortIssuesById(parseIssuesForProcessing(parsedIssuesRaw, flowRelativePath, mergedDeps.logFn));
|
|
228
|
+
const openIssues = issues.filter((issue) => issue.status === "open");
|
|
229
|
+
|
|
230
|
+
if (openIssues.length === 0) {
|
|
231
|
+
mergedDeps.logFn("No open issues to process. Exiting without changes.");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const skillTemplate = await mergedDeps.loadSkillFn(projectRoot, "automated-fix");
|
|
236
|
+
const maxIssuesToProcess = opts.iterations ?? 1;
|
|
237
|
+
const issuesToProcess = openIssues.slice(0, maxIssuesToProcess);
|
|
238
|
+
const maxRetries = opts.retryOnFail ?? 0;
|
|
239
|
+
|
|
240
|
+
let fixedCount = 0;
|
|
241
|
+
let failedCount = 0;
|
|
242
|
+
|
|
243
|
+
for (const issue of issuesToProcess) {
|
|
244
|
+
let retriesRemaining = maxRetries;
|
|
245
|
+
|
|
246
|
+
while (true) {
|
|
247
|
+
const prompt = buildPrompt(skillTemplate, {
|
|
248
|
+
iteration,
|
|
249
|
+
issue: JSON.stringify(issue, null, 2),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
let result: AgentResult | null = null;
|
|
253
|
+
let invocationError: unknown = null;
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
result = await mergedDeps.invokeAgentFn({
|
|
257
|
+
provider: opts.provider,
|
|
258
|
+
prompt,
|
|
259
|
+
cwd: projectRoot,
|
|
260
|
+
interactive: false,
|
|
261
|
+
});
|
|
262
|
+
} catch (error) {
|
|
263
|
+
invocationError = error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (invocationError) {
|
|
267
|
+
if (isNetworkError(invocationError)) {
|
|
268
|
+
issue.status = "manual-fix";
|
|
269
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
270
|
+
|
|
271
|
+
const committed = await commitIssueUpdate(projectRoot, issue.id, issue.status, mergedDeps);
|
|
272
|
+
if (!committed) {
|
|
273
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
274
|
+
mergedDeps.logFn(`Error: git commit failed for ${issue.id}`);
|
|
275
|
+
} else {
|
|
276
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
277
|
+
}
|
|
278
|
+
failedCount += 1;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (retriesRemaining > 0) {
|
|
283
|
+
retriesRemaining -= 1;
|
|
284
|
+
issue.status = "retry";
|
|
285
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
issue.status = "manual-fix";
|
|
290
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
291
|
+
|
|
292
|
+
const committed = await commitIssueUpdate(projectRoot, issue.id, issue.status, mergedDeps);
|
|
293
|
+
if (!committed) {
|
|
294
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
295
|
+
mergedDeps.logFn(`Error: git commit failed for ${issue.id}`);
|
|
296
|
+
} else {
|
|
297
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
failedCount += 1;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (result === null) {
|
|
305
|
+
throw new Error("Agent invocation produced no result.");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (result.exitCode === 0) {
|
|
309
|
+
issue.status = "fixed";
|
|
310
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
311
|
+
|
|
312
|
+
const committed = await commitIssueUpdate(projectRoot, issue.id, issue.status, mergedDeps);
|
|
313
|
+
if (!committed) {
|
|
314
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
315
|
+
mergedDeps.logFn(`Error: git commit failed for ${issue.id}`);
|
|
316
|
+
failedCount += 1;
|
|
317
|
+
} else {
|
|
318
|
+
mergedDeps.logFn(`${issue.id}: Fixed`);
|
|
319
|
+
fixedCount += 1;
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isNetworkErrorText(`${result.stderr}\n${result.stdout}`)) {
|
|
325
|
+
issue.status = "manual-fix";
|
|
326
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
327
|
+
|
|
328
|
+
const committed = await commitIssueUpdate(projectRoot, issue.id, issue.status, mergedDeps);
|
|
329
|
+
if (!committed) {
|
|
330
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
331
|
+
mergedDeps.logFn(`Error: git commit failed for ${issue.id}`);
|
|
332
|
+
} else {
|
|
333
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
334
|
+
}
|
|
335
|
+
failedCount += 1;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (retriesRemaining > 0) {
|
|
340
|
+
retriesRemaining -= 1;
|
|
341
|
+
issue.status = "retry";
|
|
342
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
issue.status = "manual-fix";
|
|
347
|
+
await writeIssuesFile(issuesPath, issues, mergedDeps);
|
|
348
|
+
|
|
349
|
+
const committed = await commitIssueUpdate(projectRoot, issue.id, issue.status, mergedDeps);
|
|
350
|
+
if (!committed) {
|
|
351
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
352
|
+
mergedDeps.logFn(`Error: git commit failed for ${issue.id}`);
|
|
353
|
+
} else {
|
|
354
|
+
mergedDeps.logFn(`${issue.id}: Failed`);
|
|
355
|
+
}
|
|
356
|
+
failedCount += 1;
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
mergedDeps.logFn(`Summary: Fixed=${fixedCount} Failed=${failedCount}`);
|
|
362
|
+
mergedDeps.logFn(`Processed ${issuesToProcess.length} open issue(s) at ${mergedDeps.nowFn().toISOString()}`);
|
|
363
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { writeState } from "../state";
|
|
7
|
+
import { buildManualFixGuidancePrompt, runExecuteManualFix } from "./execute-manual-fix";
|
|
8
|
+
|
|
9
|
+
async function createProjectRoot(): Promise<string> {
|
|
10
|
+
return mkdtemp(join(tmpdir(), "nvst-execute-manual-fix-"));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function withCwd<T>(cwd: string, fn: () => Promise<T>): Promise<T> {
|
|
14
|
+
const previous = process.cwd();
|
|
15
|
+
process.chdir(cwd);
|
|
16
|
+
try {
|
|
17
|
+
return await fn();
|
|
18
|
+
} finally {
|
|
19
|
+
process.chdir(previous);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function seedState(projectRoot: string, iteration = "000010") {
|
|
24
|
+
await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
|
|
25
|
+
await writeState(projectRoot, {
|
|
26
|
+
current_iteration: iteration,
|
|
27
|
+
current_phase: "prototype",
|
|
28
|
+
phases: {
|
|
29
|
+
define: {
|
|
30
|
+
requirement_definition: { status: "approved", file: `it_${iteration}_product-requirement-document.md` },
|
|
31
|
+
prd_generation: { status: "completed", file: `it_${iteration}_PRD.json` },
|
|
32
|
+
},
|
|
33
|
+
prototype: {
|
|
34
|
+
project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
|
|
35
|
+
test_plan: { status: "pending", file: null },
|
|
36
|
+
tp_generation: { status: "pending", file: null },
|
|
37
|
+
prototype_build: { status: "pending", file: null },
|
|
38
|
+
test_execution: { status: "pending", file: null },
|
|
39
|
+
prototype_approved: false,
|
|
40
|
+
},
|
|
41
|
+
refactor: {
|
|
42
|
+
evaluation_report: { status: "pending", file: null },
|
|
43
|
+
refactor_plan: { status: "pending", file: null },
|
|
44
|
+
refactor_execution: { status: "pending", file: null },
|
|
45
|
+
changelog: { status: "pending", file: null },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
last_updated: "2026-02-23T00:00:00.000Z",
|
|
49
|
+
updated_by: "seed",
|
|
50
|
+
history: [],
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function writeIssues(projectRoot: string, iteration: string, data: unknown) {
|
|
55
|
+
const issuesPath = join(projectRoot, ".agents", "flow", `it_${iteration}_ISSUES.json`);
|
|
56
|
+
await writeFile(issuesPath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
57
|
+
return issuesPath;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const createdRoots: string[] = [];
|
|
61
|
+
|
|
62
|
+
afterEach(async () => {
|
|
63
|
+
await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("execute manual-fix command", () => {
|
|
67
|
+
test("registers execute manual-fix command in CLI", async () => {
|
|
68
|
+
const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
|
|
69
|
+
|
|
70
|
+
expect(source).toContain('import { runExecuteManualFix } from "./commands/execute-manual-fix";');
|
|
71
|
+
expect(source).toContain('if (subcommand === "manual-fix") {');
|
|
72
|
+
expect(source).toContain("await runExecuteManualFix({ provider });");
|
|
73
|
+
expect(source).toContain("execute manual-fix --agent <provider>");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("CLI exits with code 1 when --agent is missing", async () => {
|
|
77
|
+
const proc = Bun.spawn(
|
|
78
|
+
["bun", "run", "src/cli.ts", "execute", "manual-fix"],
|
|
79
|
+
{ cwd: process.cwd(), stdout: "pipe", stderr: "pipe" },
|
|
80
|
+
);
|
|
81
|
+
const exitCode = await proc.exited;
|
|
82
|
+
const stderr = await new Response(proc.stderr).text();
|
|
83
|
+
expect(exitCode).toBe(1);
|
|
84
|
+
expect(stderr).toContain("Missing required --agent <provider> argument.");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("CLI accepts --agent and rejects unknown providers", async () => {
|
|
88
|
+
const proc = Bun.spawn(
|
|
89
|
+
["bun", "run", "src/cli.ts", "execute", "manual-fix", "--agent", "invalid-provider"],
|
|
90
|
+
{ cwd: process.cwd(), stdout: "pipe", stderr: "pipe" },
|
|
91
|
+
);
|
|
92
|
+
const exitCode = await proc.exited;
|
|
93
|
+
const stderr = await new Response(proc.stderr).text();
|
|
94
|
+
expect(exitCode).toBe(1);
|
|
95
|
+
expect(stderr).toContain("Unknown agent provider");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("scans issues for current iteration, filters manual-fix status, prints count, and prompts to proceed", async () => {
|
|
99
|
+
const projectRoot = await createProjectRoot();
|
|
100
|
+
createdRoots.push(projectRoot);
|
|
101
|
+
|
|
102
|
+
await seedState(projectRoot, "000010");
|
|
103
|
+
await writeIssues(projectRoot, "000010", [
|
|
104
|
+
{ id: "ISSUE-000010-001", title: "Open", description: "skip", status: "open" },
|
|
105
|
+
{ id: "ISSUE-000010-002", title: "Manual A", description: "take", status: "manual-fix" },
|
|
106
|
+
{ id: "ISSUE-000010-003", title: "Retry", description: "skip", status: "retry" },
|
|
107
|
+
{ id: "ISSUE-000010-004", title: "Manual B", description: "take", status: "manual-fix" },
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
const logs: string[] = [];
|
|
111
|
+
const prompts: string[] = [];
|
|
112
|
+
|
|
113
|
+
await withCwd(projectRoot, async () => {
|
|
114
|
+
await runExecuteManualFix(
|
|
115
|
+
{ provider: "codex" },
|
|
116
|
+
{
|
|
117
|
+
logFn: (message) => logs.push(message),
|
|
118
|
+
promptProceedFn: async (question) => {
|
|
119
|
+
prompts.push(question);
|
|
120
|
+
return false;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(logs).toContain(
|
|
127
|
+
"Found 2 issue(s) with status 'manual-fix' in .agents/flow/it_000010_ISSUES.json.",
|
|
128
|
+
);
|
|
129
|
+
expect(prompts).toHaveLength(1);
|
|
130
|
+
expect(prompts[0]).toContain("Proceed with manual-fix processing for 2 issue(s)");
|
|
131
|
+
expect(logs).toContain("Manual-fix execution cancelled.");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("presents manual-fix issues one by one and runs interactive guidance for each issue", async () => {
|
|
135
|
+
const projectRoot = await createProjectRoot();
|
|
136
|
+
createdRoots.push(projectRoot);
|
|
137
|
+
|
|
138
|
+
await seedState(projectRoot, "000010");
|
|
139
|
+
await writeIssues(projectRoot, "000010", [
|
|
140
|
+
{ id: "ISSUE-000010-001", title: "Open", description: "skip", status: "open" },
|
|
141
|
+
{ id: "ISSUE-000010-002", title: "Manual A", description: "first manual", status: "manual-fix" },
|
|
142
|
+
{ id: "ISSUE-000010-003", title: "Retry", description: "skip", status: "retry" },
|
|
143
|
+
{ id: "ISSUE-000010-004", title: "Manual B", description: "second manual", status: "manual-fix" },
|
|
144
|
+
]);
|
|
145
|
+
|
|
146
|
+
const logs: string[] = [];
|
|
147
|
+
const prompts: string[] = [];
|
|
148
|
+
const outcomePrompts: string[] = [];
|
|
149
|
+
const interactiveFlags: Array<boolean | undefined> = [];
|
|
150
|
+
const providersUsed: string[] = [];
|
|
151
|
+
const outcomes: Array<"fixed" | "skip" | "exit"> = ["skip", "skip"];
|
|
152
|
+
|
|
153
|
+
await withCwd(projectRoot, async () => {
|
|
154
|
+
await runExecuteManualFix(
|
|
155
|
+
{ provider: "codex" },
|
|
156
|
+
{
|
|
157
|
+
logFn: (message) => logs.push(message),
|
|
158
|
+
promptProceedFn: async () => true,
|
|
159
|
+
promptIssueOutcomeFn: async (question) => {
|
|
160
|
+
outcomePrompts.push(question);
|
|
161
|
+
return outcomes.shift() ?? "skip";
|
|
162
|
+
},
|
|
163
|
+
invokeAgentFn: async (options) => {
|
|
164
|
+
prompts.push(options.prompt);
|
|
165
|
+
interactiveFlags.push(options.interactive);
|
|
166
|
+
providersUsed.push(options.provider);
|
|
167
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(logs).toContain(
|
|
174
|
+
"Found 2 issue(s) with status 'manual-fix' in .agents/flow/it_000010_ISSUES.json.",
|
|
175
|
+
);
|
|
176
|
+
expect(logs).toContain("Ready to process 2 manual-fix issue(s).");
|
|
177
|
+
expect(logs).toContain("Issue 1/2: ISSUE-000010-002 - Manual A");
|
|
178
|
+
expect(logs).toContain("Issue 2/2: ISSUE-000010-004 - Manual B");
|
|
179
|
+
|
|
180
|
+
expect(prompts).toHaveLength(2);
|
|
181
|
+
expect(prompts[0]).toContain("Issue ID: ISSUE-000010-002");
|
|
182
|
+
expect(prompts[1]).toContain("Issue ID: ISSUE-000010-004");
|
|
183
|
+
|
|
184
|
+
expect(interactiveFlags).toEqual([true, true]);
|
|
185
|
+
expect(providersUsed).toEqual(["codex", "codex"]);
|
|
186
|
+
expect(outcomePrompts).toEqual([
|
|
187
|
+
"Action? (f)ixed, (s)kip, (e)xit: ",
|
|
188
|
+
"Action? (f)ixed, (s)kip, (e)xit: ",
|
|
189
|
+
]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("buildManualFixGuidancePrompt requires summary, reproduction strategy, fixes, and interactive Q&A loop", () => {
|
|
193
|
+
const prompt = buildManualFixGuidancePrompt(
|
|
194
|
+
{
|
|
195
|
+
id: "ISSUE-000010-002",
|
|
196
|
+
title: "Manual A",
|
|
197
|
+
description: "Service returns 500 on malformed payload.",
|
|
198
|
+
status: "manual-fix",
|
|
199
|
+
},
|
|
200
|
+
"000010",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
expect(prompt).toContain("Iteration: 000010");
|
|
204
|
+
expect(prompt).toContain("Issue ID: ISSUE-000010-002");
|
|
205
|
+
expect(prompt).toContain("Start with a concise summary/analysis of the problem.");
|
|
206
|
+
expect(prompt).toContain("Suggest a concrete reproduction strategy or test case.");
|
|
207
|
+
expect(prompt).toContain("Suggest potential fixes or code changes with rationale.");
|
|
208
|
+
expect(prompt).toContain("continue in an interactive chat loop");
|
|
209
|
+
expect(prompt).toContain("Answer clarifying questions.");
|
|
210
|
+
expect(prompt).toContain("Provide code snippets when requested.");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("US-003-AC02/AC04: marks issue fixed and persists updated status immediately", async () => {
|
|
214
|
+
const projectRoot = await createProjectRoot();
|
|
215
|
+
createdRoots.push(projectRoot);
|
|
216
|
+
|
|
217
|
+
await seedState(projectRoot, "000010");
|
|
218
|
+
await writeIssues(projectRoot, "000010", [
|
|
219
|
+
{ id: "ISSUE-000010-001", title: "Manual A", description: "fix me", status: "manual-fix" },
|
|
220
|
+
{ id: "ISSUE-000010-002", title: "Manual B", description: "keep", status: "manual-fix" },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const writes: string[] = [];
|
|
224
|
+
await withCwd(projectRoot, async () => {
|
|
225
|
+
await runExecuteManualFix(
|
|
226
|
+
{ provider: "codex" },
|
|
227
|
+
{
|
|
228
|
+
promptProceedFn: async () => true,
|
|
229
|
+
promptIssueOutcomeFn: async () => "fixed",
|
|
230
|
+
invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
|
|
231
|
+
writeFileFn: async (path, data, options) => {
|
|
232
|
+
writes.push(String(data));
|
|
233
|
+
await writeFile(path, data, options);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(writes.length).toBeGreaterThan(0);
|
|
240
|
+
expect(writes[0]).toContain('"id": "ISSUE-000010-001"');
|
|
241
|
+
expect(writes[0]).toContain('"status": "fixed"');
|
|
242
|
+
|
|
243
|
+
const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000010_ISSUES.json"), "utf8");
|
|
244
|
+
const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
|
|
245
|
+
expect(issues.find((issue) => issue.id === "ISSUE-000010-001")?.status).toBe("fixed");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("US-003-AC03: skip leaves issue status as manual-fix and continues", async () => {
|
|
249
|
+
const projectRoot = await createProjectRoot();
|
|
250
|
+
createdRoots.push(projectRoot);
|
|
251
|
+
|
|
252
|
+
await seedState(projectRoot, "000010");
|
|
253
|
+
await writeIssues(projectRoot, "000010", [
|
|
254
|
+
{ id: "ISSUE-000010-001", title: "Manual A", description: "first", status: "manual-fix" },
|
|
255
|
+
{ id: "ISSUE-000010-002", title: "Manual B", description: "second", status: "manual-fix" },
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
let invokeCount = 0;
|
|
259
|
+
const responses: Array<"fixed" | "skip" | "exit"> = ["skip", "skip"];
|
|
260
|
+
await withCwd(projectRoot, async () => {
|
|
261
|
+
await runExecuteManualFix(
|
|
262
|
+
{ provider: "codex" },
|
|
263
|
+
{
|
|
264
|
+
promptProceedFn: async () => true,
|
|
265
|
+
promptIssueOutcomeFn: async () => responses.shift() ?? "skip",
|
|
266
|
+
invokeAgentFn: async () => {
|
|
267
|
+
invokeCount += 1;
|
|
268
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(invokeCount).toBe(2);
|
|
275
|
+
const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000010_ISSUES.json"), "utf8");
|
|
276
|
+
const issues = JSON.parse(issuesRaw) as Array<{ status: string }>;
|
|
277
|
+
expect(issues[0]?.status).toBe("manual-fix");
|
|
278
|
+
expect(issues[1]?.status).toBe("manual-fix");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("US-003-AC01: prompts Mark as fixed/Skip/Exit and supports Exit to stop processing", async () => {
|
|
282
|
+
const projectRoot = await createProjectRoot();
|
|
283
|
+
createdRoots.push(projectRoot);
|
|
284
|
+
|
|
285
|
+
await seedState(projectRoot, "000010");
|
|
286
|
+
await writeIssues(projectRoot, "000010", [
|
|
287
|
+
{ id: "ISSUE-000010-001", title: "Manual A", description: "first", status: "manual-fix" },
|
|
288
|
+
{ id: "ISSUE-000010-002", title: "Manual B", description: "second", status: "manual-fix" },
|
|
289
|
+
]);
|
|
290
|
+
|
|
291
|
+
const outcomePrompts: string[] = [];
|
|
292
|
+
let invokeCount = 0;
|
|
293
|
+
await withCwd(projectRoot, async () => {
|
|
294
|
+
await runExecuteManualFix(
|
|
295
|
+
{ provider: "codex" },
|
|
296
|
+
{
|
|
297
|
+
promptProceedFn: async () => true,
|
|
298
|
+
promptIssueOutcomeFn: async (question) => {
|
|
299
|
+
outcomePrompts.push(question);
|
|
300
|
+
return "exit";
|
|
301
|
+
},
|
|
302
|
+
invokeAgentFn: async () => {
|
|
303
|
+
invokeCount += 1;
|
|
304
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(outcomePrompts).toEqual(["Action? (f)ixed, (s)kip, (e)xit: "]);
|
|
311
|
+
expect(invokeCount).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("TC-003: handles 0 manual-fix issues gracefully", async () => {
|
|
315
|
+
const projectRoot = await createProjectRoot();
|
|
316
|
+
createdRoots.push(projectRoot);
|
|
317
|
+
|
|
318
|
+
await seedState(projectRoot, "000010");
|
|
319
|
+
await writeIssues(projectRoot, "000010", [
|
|
320
|
+
{ id: "ISSUE-000010-001", title: "Open", description: "skip", status: "open" },
|
|
321
|
+
]);
|
|
322
|
+
|
|
323
|
+
const logs: string[] = [];
|
|
324
|
+
const prompts: string[] = [];
|
|
325
|
+
|
|
326
|
+
await withCwd(projectRoot, async () => {
|
|
327
|
+
await runExecuteManualFix(
|
|
328
|
+
{ provider: "codex" },
|
|
329
|
+
{
|
|
330
|
+
logFn: (message) => logs.push(message),
|
|
331
|
+
promptProceedFn: async (question) => {
|
|
332
|
+
prompts.push(question);
|
|
333
|
+
return true; // Say yes to proceed
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(logs).toContain("Found 0 issue(s) with status 'manual-fix' in .agents/flow/it_000010_ISSUES.json.");
|
|
340
|
+
expect(prompts[0]).toContain("Proceed with manual-fix processing for 0 issue(s)");
|
|
341
|
+
expect(logs).toContain("No manual-fix issues to process. Exiting without changes.");
|
|
342
|
+
});
|
|
343
|
+
});
|