@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.
Files changed (85) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +127 -0
  3. package/package.json +34 -0
  4. package/scaffold/.agents/flow/archived/tmpl_.gitkeep +0 -0
  5. package/scaffold/.agents/flow/tmpl_README.md +7 -0
  6. package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +11 -0
  7. package/scaffold/.agents/skills/automated-fix/tmpl_SKILL.md +67 -0
  8. package/scaffold/.agents/skills/create-issue/tmpl_SKILL.md +68 -0
  9. package/scaffold/.agents/skills/create-pr-document/tmpl_SKILL.md +125 -0
  10. package/scaffold/.agents/skills/create-project-context/tmpl_SKILL.md +168 -0
  11. package/scaffold/.agents/skills/create-test-plan/tmpl_SKILL.md +86 -0
  12. package/scaffold/.agents/skills/debug/tmpl_SKILL.md +19 -0
  13. package/scaffold/.agents/skills/evaluate/tmpl_SKILL.md +19 -0
  14. package/scaffold/.agents/skills/execute-test-batch/tmpl_SKILL.md +49 -0
  15. package/scaffold/.agents/skills/execute-test-case/tmpl_SKILL.md +47 -0
  16. package/scaffold/.agents/skills/implement-user-story/tmpl_SKILL.md +68 -0
  17. package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +19 -0
  18. package/scaffold/.agents/skills/refactor-prd/tmpl_SKILL.md +19 -0
  19. package/scaffold/.agents/skills/refine-pr-document/tmpl_SKILL.md +108 -0
  20. package/scaffold/.agents/skills/refine-project-context/tmpl_SKILL.md +157 -0
  21. package/scaffold/.agents/skills/refine-test-plan/tmpl_SKILL.md +76 -0
  22. package/scaffold/.agents/tmpl_PROJECT_CONTEXT.md +3 -0
  23. package/scaffold/.agents/tmpl_state.example.json +26 -0
  24. package/scaffold/.agents/tmpl_state_rules.md +29 -0
  25. package/scaffold/docs/nvst-flow/templates/tmpl_CHANGELOG.md +18 -0
  26. package/scaffold/docs/nvst-flow/templates/tmpl_TECHNICAL_DEBT.md +11 -0
  27. package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_evaluation-report.md +19 -0
  28. package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_product-requirement-document.md +19 -0
  29. package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_refactor_plan.md +19 -0
  30. package/scaffold/docs/nvst-flow/templates/tmpl_it_000001_test-plan.md +19 -0
  31. package/scaffold/docs/nvst-flow/tmpl_COMMANDS.md +0 -0
  32. package/scaffold/docs/nvst-flow/tmpl_QUICK_USE.md +0 -0
  33. package/scaffold/docs/tmpl_PLACEHOLDER.md +0 -0
  34. package/scaffold/schemas/node-shims.d.ts +15 -0
  35. package/scaffold/schemas/tmpl_issues.ts +19 -0
  36. package/scaffold/schemas/tmpl_prd.ts +26 -0
  37. package/scaffold/schemas/tmpl_progress.ts +39 -0
  38. package/scaffold/schemas/tmpl_state.ts +81 -0
  39. package/scaffold/schemas/tmpl_test-plan.ts +20 -0
  40. package/scaffold/schemas/tmpl_validate-progress.ts +13 -0
  41. package/scaffold/schemas/tmpl_validate-state.ts +13 -0
  42. package/scaffold/tmpl_AGENTS.md +7 -0
  43. package/schemas/prd.ts +26 -0
  44. package/schemas/progress.ts +39 -0
  45. package/schemas/state.ts +81 -0
  46. package/schemas/test-plan.test.ts +53 -0
  47. package/schemas/test-plan.ts +20 -0
  48. package/schemas/validate-progress.ts +13 -0
  49. package/schemas/validate-state.ts +13 -0
  50. package/src/agent.test.ts +37 -0
  51. package/src/agent.ts +225 -0
  52. package/src/cli-path.ts +4 -0
  53. package/src/cli.ts +578 -0
  54. package/src/commands/approve-project-context.ts +37 -0
  55. package/src/commands/approve-requirement.ts +217 -0
  56. package/src/commands/approve-test-plan.test.ts +193 -0
  57. package/src/commands/approve-test-plan.ts +202 -0
  58. package/src/commands/create-issue.test.ts +484 -0
  59. package/src/commands/create-issue.ts +371 -0
  60. package/src/commands/create-project-context.ts +96 -0
  61. package/src/commands/create-prototype.test.ts +153 -0
  62. package/src/commands/create-prototype.ts +425 -0
  63. package/src/commands/create-test-plan.test.ts +381 -0
  64. package/src/commands/create-test-plan.ts +248 -0
  65. package/src/commands/define-requirement.ts +47 -0
  66. package/src/commands/destroy.ts +113 -0
  67. package/src/commands/execute-automated-fix.test.ts +580 -0
  68. package/src/commands/execute-automated-fix.ts +363 -0
  69. package/src/commands/execute-manual-fix.test.ts +343 -0
  70. package/src/commands/execute-manual-fix.ts +203 -0
  71. package/src/commands/execute-test-plan.test.ts +1891 -0
  72. package/src/commands/execute-test-plan.ts +722 -0
  73. package/src/commands/init.ts +85 -0
  74. package/src/commands/refine-project-context.ts +74 -0
  75. package/src/commands/refine-requirement.ts +60 -0
  76. package/src/commands/refine-test-plan.test.ts +200 -0
  77. package/src/commands/refine-test-plan.ts +93 -0
  78. package/src/commands/start-iteration.test.ts +144 -0
  79. package/src/commands/start-iteration.ts +101 -0
  80. package/src/commands/write-json.ts +136 -0
  81. package/src/install.test.ts +124 -0
  82. package/src/pack.test.ts +103 -0
  83. package/src/state.test.ts +66 -0
  84. package/src/state.ts +52 -0
  85. package/tsconfig.json +15 -0
@@ -0,0 +1,113 @@
1
+ import { access, rm, rmdir } from "node:fs/promises";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { createInterface } from "node:readline/promises";
5
+
6
+ import { getScaffoldEntries } from "./init";
7
+
8
+ const ARCHIVED_DIR = ".agents/flow/archived";
9
+
10
+ interface DestroyOptions {
11
+ clean: boolean;
12
+ }
13
+
14
+ function normalizeRelativePath(absolutePath: string, projectRoot: string): string {
15
+ return relative(projectRoot, absolutePath).replaceAll("\\", "/");
16
+ }
17
+
18
+ function isArchivedPath(absolutePath: string, projectRoot: string): boolean {
19
+ const relPath = normalizeRelativePath(absolutePath, projectRoot);
20
+ return relPath === ARCHIVED_DIR || relPath.startsWith(`${ARCHIVED_DIR}/`);
21
+ }
22
+
23
+ async function exists(path: string): Promise<boolean> {
24
+ try {
25
+ await access(path);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ async function confirmDeletion(clean: boolean, count: number): Promise<boolean> {
33
+ const modeDescription = clean
34
+ ? "This will remove all nvst-generated files, including archived files."
35
+ : "This will remove nvst-generated files, preserving .agents/flow/archived.";
36
+ const rl = createInterface({ input, output });
37
+ try {
38
+ const answer = await rl.question(`${modeDescription}\nDelete ${count} file(s)? [y/N] `);
39
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
40
+ } finally {
41
+ rl.close();
42
+ }
43
+ }
44
+
45
+ function collectDirectories(paths: string[], projectRoot: string): string[] {
46
+ const directories = new Set<string>();
47
+
48
+ for (const filePath of paths) {
49
+ let currentDir = dirname(filePath);
50
+ while (currentDir.startsWith(projectRoot) && currentDir !== projectRoot) {
51
+ directories.add(currentDir);
52
+ currentDir = dirname(currentDir);
53
+ }
54
+ }
55
+
56
+ return [...directories].sort((a, b) => b.length - a.length);
57
+ }
58
+
59
+ async function removeEmptyDirectories(directories: string[]): Promise<void> {
60
+ for (const dirPath of directories) {
61
+ try {
62
+ await rmdir(dirPath);
63
+ console.log(`Removed empty directory: ${dirPath}`);
64
+ } catch {
65
+ // Ignore non-empty or missing directories.
66
+ }
67
+ }
68
+ }
69
+
70
+ export async function runDestroy(options: DestroyOptions): Promise<void> {
71
+ const projectRoot = process.cwd();
72
+ const entries = await getScaffoldEntries(projectRoot);
73
+
74
+ const filesToDelete = entries
75
+ .map((entry) => entry.destinationPath)
76
+ .filter((path) => options.clean || !isArchivedPath(path, projectRoot));
77
+
78
+ if (filesToDelete.length === 0) {
79
+ console.log("Nothing to remove.");
80
+ return;
81
+ }
82
+
83
+ const confirmed = await confirmDeletion(options.clean, filesToDelete.length);
84
+ if (!confirmed) {
85
+ console.log("Destroy canceled.");
86
+ return;
87
+ }
88
+
89
+ const removed: string[] = [];
90
+
91
+ for (const filePath of filesToDelete) {
92
+ if (!(await exists(filePath))) {
93
+ continue;
94
+ }
95
+ await rm(filePath, { force: true });
96
+ removed.push(normalizeRelativePath(filePath, projectRoot));
97
+ console.log(`Removed: ${normalizeRelativePath(filePath, projectRoot)}`);
98
+ }
99
+
100
+ if (options.clean) {
101
+ const archivedAbsolutePath = join(projectRoot, ARCHIVED_DIR);
102
+ if (await exists(archivedAbsolutePath)) {
103
+ await rm(archivedAbsolutePath, { recursive: true, force: true });
104
+ removed.push(ARCHIVED_DIR);
105
+ console.log(`Removed: ${ARCHIVED_DIR}`);
106
+ }
107
+ }
108
+
109
+ const directories = collectDirectories(filesToDelete, projectRoot);
110
+ await removeEmptyDirectories(directories);
111
+
112
+ console.log(`\nDestroy complete. Removed ${removed.length} file(s).`);
113
+ }
@@ -0,0 +1,580 @@
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 { runExecuteAutomatedFix } from "./execute-automated-fix";
8
+
9
+ async function createProjectRoot(): Promise<string> {
10
+ return mkdtemp(join(tmpdir(), "nvst-execute-automated-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 = "000009") {
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-22T00: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 automated-fix", () => {
67
+ test("registers execute automated-fix command in CLI", async () => {
68
+ const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
69
+
70
+ expect(source).toContain('import { runExecuteAutomatedFix } from "./commands/execute-automated-fix";');
71
+ expect(source).toContain('if (subcommand === "automated-fix") {');
72
+ expect(source).toContain("parseOptionalIntegerFlag(postAgentArgs, \"--iterations\", 1);");
73
+ expect(source).toContain("await runExecuteAutomatedFix({ provider, iterations, retryOnFail });");
74
+ expect(source).toContain("execute automated-fix --agent <provider> [--iterations <N>] [--retry-on-fail <N>]");
75
+ });
76
+
77
+ test("CLI exits with code 1 when --agent is missing", async () => {
78
+ const proc = Bun.spawn(
79
+ ["bun", "run", "src/cli.ts", "execute", "automated-fix"],
80
+ { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" },
81
+ );
82
+ const exitCode = await proc.exited;
83
+ const stderr = await new Response(proc.stderr).text();
84
+ expect(exitCode).toBe(1);
85
+ expect(stderr).toContain("Missing required --agent <provider> argument.");
86
+ });
87
+
88
+ test("CLI accepts --agent and rejects unknown providers", async () => {
89
+ const proc = Bun.spawn(
90
+ ["bun", "run", "src/cli.ts", "execute", "automated-fix", "--agent", "invalid-provider"],
91
+ { cwd: process.cwd(), stdout: "pipe", stderr: "pipe" },
92
+ );
93
+ const exitCode = await proc.exited;
94
+ const stderr = await new Response(proc.stderr).text();
95
+ expect(exitCode).toBe(1);
96
+ expect(stderr).toContain("Unknown agent provider");
97
+ });
98
+
99
+ test("throws clear error when current-iteration issues file is missing", async () => {
100
+ const projectRoot = await createProjectRoot();
101
+ createdRoots.push(projectRoot);
102
+
103
+ await seedState(projectRoot, "000009");
104
+
105
+ await withCwd(projectRoot, async () => {
106
+ await expect(runExecuteAutomatedFix({ provider: "codex" })).rejects.toThrow(
107
+ "Issues file not found: expected .agents/flow/it_000009_ISSUES.json. Run `bun nvst create issue --agent <provider>` first.",
108
+ );
109
+ });
110
+ });
111
+
112
+ test("reads current-iteration issues file, processes only open issues sequentially, and commits fixed status", async () => {
113
+ const projectRoot = await createProjectRoot();
114
+ createdRoots.push(projectRoot);
115
+
116
+ await seedState(projectRoot, "000009");
117
+ await writeIssues(projectRoot, "000009", [
118
+ { id: "ISSUE-000009-001", title: "Already fixed", description: "skip", status: "fixed" },
119
+ { id: "ISSUE-000009-002", title: "Open A", description: "first open", status: "open" },
120
+ { id: "ISSUE-000009-003", title: "Open B", description: "second open", status: "open" },
121
+ ]);
122
+
123
+ const prompts: string[] = [];
124
+ const commitMessages: string[] = [];
125
+ const logs: string[] = [];
126
+
127
+ await withCwd(projectRoot, async () => {
128
+ await runExecuteAutomatedFix(
129
+ { provider: "codex", iterations: 2 },
130
+ {
131
+ loadSkillFn: async (_root, name) => {
132
+ expect(name).toBe("automated-fix");
133
+ return "1. Understand the issue.\n2. Reproduce the issue.\n11. Mark as fixed.";
134
+ },
135
+ invokeAgentFn: async (options) => {
136
+ prompts.push(options.prompt);
137
+ return { exitCode: 0, stdout: "ok", stderr: "" };
138
+ },
139
+ runCommitFn: async (_root, message) => {
140
+ commitMessages.push(message);
141
+ return 0;
142
+ },
143
+ logFn: (message) => logs.push(message),
144
+ nowFn: () => new Date("2026-02-22T12:00:00.000Z"),
145
+ },
146
+ );
147
+ });
148
+
149
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
150
+ const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
151
+
152
+ expect(prompts).toHaveLength(2);
153
+ expect(prompts[0]).toContain('"id": "ISSUE-000009-002"');
154
+ expect(prompts[1]).toContain('"id": "ISSUE-000009-003"');
155
+ expect(prompts[0]).toContain("1. Understand the issue.");
156
+ expect(prompts[0]).toContain("11. Mark as fixed.");
157
+
158
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
159
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-002")?.status).toBe("fixed");
160
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("fixed");
161
+
162
+ expect(commitMessages).toHaveLength(2);
163
+ expect(logs).toContain("ISSUE-000009-002: Fixed");
164
+ expect(logs).toContain("ISSUE-000009-003: Fixed");
165
+ expect(logs).toContain("Summary: Fixed=2 Failed=0");
166
+ });
167
+
168
+ test("invokes agent with the provider selected by --agent", async () => {
169
+ const projectRoot = await createProjectRoot();
170
+ createdRoots.push(projectRoot);
171
+
172
+ await seedState(projectRoot, "000009");
173
+ await writeIssues(projectRoot, "000009", [
174
+ { id: "ISSUE-000009-001", title: "Open A", description: "first open", status: "open" },
175
+ ]);
176
+
177
+ const providersUsed: string[] = [];
178
+
179
+ await withCwd(projectRoot, async () => {
180
+ await runExecuteAutomatedFix(
181
+ { provider: "cursor" },
182
+ {
183
+ loadSkillFn: async () => "debug workflow",
184
+ invokeAgentFn: async (options) => {
185
+ providersUsed.push(options.provider);
186
+ return { exitCode: 0, stdout: "ok", stderr: "" };
187
+ },
188
+ runCommitFn: async () => 0,
189
+ },
190
+ );
191
+ });
192
+
193
+ expect(providersUsed).toEqual(["cursor"]);
194
+ });
195
+
196
+ test("logs informative message and exits without changes when zero open issues exist", async () => {
197
+ const projectRoot = await createProjectRoot();
198
+ createdRoots.push(projectRoot);
199
+
200
+ await seedState(projectRoot, "000009");
201
+ await writeIssues(projectRoot, "000009", [
202
+ { id: "ISSUE-000009-001", title: "Already fixed", description: "skip", status: "fixed" },
203
+ { id: "ISSUE-000009-002", title: "Retrying", description: "skip", status: "retry" },
204
+ ]);
205
+
206
+ const logs: string[] = [];
207
+ let invokeCount = 0;
208
+
209
+ await withCwd(projectRoot, async () => {
210
+ await runExecuteAutomatedFix(
211
+ { provider: "codex" },
212
+ {
213
+ loadSkillFn: async () => "debug workflow",
214
+ invokeAgentFn: async () => {
215
+ invokeCount += 1;
216
+ return { exitCode: 0, stdout: "ok", stderr: "" };
217
+ },
218
+ runCommitFn: async () => 0,
219
+ logFn: (message) => logs.push(message),
220
+ },
221
+ );
222
+ });
223
+
224
+ expect(invokeCount).toBe(0);
225
+ expect(logs).toContain("No open issues to process. Exiting without changes.");
226
+ });
227
+
228
+ test("defaults --iterations to 1 and leaves remaining open issues untouched", async () => {
229
+ const projectRoot = await createProjectRoot();
230
+ createdRoots.push(projectRoot);
231
+
232
+ await seedState(projectRoot, "000009");
233
+ await writeIssues(projectRoot, "000009", [
234
+ { id: "ISSUE-000009-001", title: "Open A", description: "first open", status: "open" },
235
+ { id: "ISSUE-000009-002", title: "Open B", description: "second open", status: "open" },
236
+ { id: "ISSUE-000009-003", title: "Open C", description: "third open", status: "open" },
237
+ ]);
238
+
239
+ let invokeCount = 0;
240
+
241
+ await withCwd(projectRoot, async () => {
242
+ await runExecuteAutomatedFix(
243
+ { provider: "codex" },
244
+ {
245
+ loadSkillFn: async () => "debug workflow",
246
+ invokeAgentFn: async () => {
247
+ invokeCount += 1;
248
+ return { exitCode: 0, stdout: "", stderr: "" };
249
+ },
250
+ runCommitFn: async () => 0,
251
+ },
252
+ );
253
+ });
254
+
255
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
256
+ const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
257
+
258
+ expect(invokeCount).toBe(1);
259
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
260
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-002")?.status).toBe("open");
261
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("open");
262
+ });
263
+
264
+ test("processes only the first N open issues when --iterations is provided", async () => {
265
+ const projectRoot = await createProjectRoot();
266
+ createdRoots.push(projectRoot);
267
+
268
+ await seedState(projectRoot, "000009");
269
+ await writeIssues(projectRoot, "000009", [
270
+ { id: "ISSUE-000009-001", title: "Open A", description: "first open", status: "open" },
271
+ { id: "ISSUE-000009-002", title: "Open B", description: "second open", status: "open" },
272
+ { id: "ISSUE-000009-003", title: "Open C", description: "third open", status: "open" },
273
+ ]);
274
+
275
+ let invokeCount = 0;
276
+
277
+ await withCwd(projectRoot, async () => {
278
+ await runExecuteAutomatedFix(
279
+ { provider: "codex", iterations: 2 },
280
+ {
281
+ loadSkillFn: async () => "debug workflow",
282
+ invokeAgentFn: async () => {
283
+ invokeCount += 1;
284
+ return { exitCode: 0, stdout: "", stderr: "" };
285
+ },
286
+ runCommitFn: async () => 0,
287
+ },
288
+ );
289
+ });
290
+
291
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
292
+ const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
293
+
294
+ expect(invokeCount).toBe(2);
295
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
296
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-002")?.status).toBe("fixed");
297
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("open");
298
+ });
299
+
300
+ test("skips issues with missing required fields and continues processing remaining open issues", async () => {
301
+ const projectRoot = await createProjectRoot();
302
+ createdRoots.push(projectRoot);
303
+
304
+ await seedState(projectRoot, "000009");
305
+ await writeIssues(projectRoot, "000009", [
306
+ { id: "ISSUE-000009-001", title: "Open A", description: "first open", status: "open" },
307
+ { id: "ISSUE-000009-002", title: "Missing description", status: "open" },
308
+ { id: "ISSUE-000009-003", title: "Fixed", description: "skip", status: "fixed" },
309
+ ]);
310
+
311
+ const logs: string[] = [];
312
+ const prompts: string[] = [];
313
+
314
+ await withCwd(projectRoot, async () => {
315
+ await runExecuteAutomatedFix(
316
+ { provider: "codex", iterations: 3 },
317
+ {
318
+ loadSkillFn: async () => "debug workflow",
319
+ invokeAgentFn: async (options) => {
320
+ prompts.push(options.prompt);
321
+ return { exitCode: 0, stdout: "", stderr: "" };
322
+ },
323
+ runCommitFn: async () => 0,
324
+ logFn: (message) => logs.push(message),
325
+ },
326
+ );
327
+ });
328
+
329
+ expect(prompts).toHaveLength(1);
330
+ expect(prompts[0]).toContain('"id": "ISSUE-000009-001"');
331
+ expect(logs.some((line) => line.includes("Warning: Skipping issue at index 1"))).toBe(true);
332
+
333
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
334
+ const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
335
+
336
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
337
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("fixed");
338
+ expect(issues.some((issue) => issue.id === "ISSUE-000009-002")).toBe(false);
339
+ });
340
+
341
+ test("marks issue as retry when hypothesis is not confirmed and retries remain", async () => {
342
+ const projectRoot = await createProjectRoot();
343
+ createdRoots.push(projectRoot);
344
+
345
+ await seedState(projectRoot, "000009");
346
+ await writeIssues(projectRoot, "000009", [
347
+ { id: "ISSUE-000009-001", title: "Retry me", description: "test", status: "open" },
348
+ ]);
349
+
350
+ const writtenSnapshots: string[] = [];
351
+ let invokeCount = 0;
352
+
353
+ await withCwd(projectRoot, async () => {
354
+ await runExecuteAutomatedFix(
355
+ { provider: "claude", retryOnFail: 1 },
356
+ {
357
+ loadSkillFn: async () => "debug workflow",
358
+ invokeAgentFn: async () => {
359
+ invokeCount += 1;
360
+ if (invokeCount === 1) {
361
+ return { exitCode: 1, stdout: "", stderr: "no confirmed hypothesis" };
362
+ }
363
+ return { exitCode: 0, stdout: "", stderr: "" };
364
+ },
365
+ runCommitFn: async () => 0,
366
+ writeFileFn: async (path, data, options) => {
367
+ writtenSnapshots.push(String(data));
368
+ return writeFile(path, data, options);
369
+ },
370
+ },
371
+ );
372
+ });
373
+
374
+ expect(invokeCount).toBe(2);
375
+ expect(writtenSnapshots.some((snapshot) => snapshot.includes('"status": "retry"'))).toBe(true);
376
+
377
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
378
+ const issues = JSON.parse(issuesRaw) as Array<{ status: string }>;
379
+ expect(issues[0]?.status).toBe("fixed");
380
+ });
381
+
382
+ test("marks issue as manual-fix and commits when retries are exhausted", async () => {
383
+ const projectRoot = await createProjectRoot();
384
+ createdRoots.push(projectRoot);
385
+
386
+ await seedState(projectRoot, "000009");
387
+ await writeIssues(projectRoot, "000009", [
388
+ { id: "ISSUE-000009-001", title: "Manual fix", description: "test", status: "open" },
389
+ ]);
390
+
391
+ const commitMessages: string[] = [];
392
+
393
+ await withCwd(projectRoot, async () => {
394
+ await runExecuteAutomatedFix(
395
+ { provider: "gemini", retryOnFail: 0 },
396
+ {
397
+ loadSkillFn: async () => "debug workflow",
398
+ invokeAgentFn: async () => ({ exitCode: 1, stdout: "", stderr: "no confirmed hypothesis" }),
399
+ runCommitFn: async (_root, message) => {
400
+ commitMessages.push(message);
401
+ return 0;
402
+ },
403
+ },
404
+ );
405
+ });
406
+
407
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
408
+ const issues = JSON.parse(issuesRaw) as Array<{ status: string }>;
409
+
410
+ expect(issues[0]?.status).toBe("manual-fix");
411
+ expect(commitMessages).toHaveLength(1);
412
+ expect(commitMessages[0]).toContain("manual-fix");
413
+ });
414
+
415
+ test("stops retrying after reaching max retries", async () => {
416
+ const projectRoot = await createProjectRoot();
417
+ createdRoots.push(projectRoot);
418
+
419
+ await seedState(projectRoot, "000009");
420
+ await writeIssues(projectRoot, "000009", [
421
+ { id: "ISSUE-000009-001", title: "Retry limit", description: "test", status: "open" },
422
+ ]);
423
+
424
+ let invokeCount = 0;
425
+
426
+ await withCwd(projectRoot, async () => {
427
+ await runExecuteAutomatedFix(
428
+ { provider: "gemini", retryOnFail: 2 },
429
+ {
430
+ loadSkillFn: async () => "debug workflow",
431
+ invokeAgentFn: async () => {
432
+ invokeCount += 1;
433
+ return { exitCode: 1, stdout: "", stderr: "still failing" };
434
+ },
435
+ runCommitFn: async () => 0,
436
+ },
437
+ );
438
+ });
439
+
440
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
441
+ const issues = JSON.parse(issuesRaw) as Array<{ status: string }>;
442
+
443
+ expect(invokeCount).toBe(3);
444
+ expect(issues[0]?.status).toBe("manual-fix");
445
+ });
446
+
447
+ test("marks issue as manual-fix and does not consume retries on network errors", async () => {
448
+ const projectRoot = await createProjectRoot();
449
+ createdRoots.push(projectRoot);
450
+
451
+ await seedState(projectRoot, "000009");
452
+ await writeIssues(projectRoot, "000009", [
453
+ { id: "ISSUE-000009-001", title: "Network", description: "test", status: "open" },
454
+ ]);
455
+
456
+ let invokeCount = 0;
457
+ let commitCount = 0;
458
+
459
+ await withCwd(projectRoot, async () => {
460
+ await runExecuteAutomatedFix(
461
+ { provider: "cursor", retryOnFail: 3 },
462
+ {
463
+ loadSkillFn: async () => "debug workflow",
464
+ invokeAgentFn: async () => {
465
+ invokeCount += 1;
466
+ throw new Error("ENOTFOUND api.provider.example");
467
+ },
468
+ runCommitFn: async () => {
469
+ commitCount += 1;
470
+ return 0;
471
+ },
472
+ },
473
+ );
474
+ });
475
+
476
+ const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
477
+ const issues = JSON.parse(issuesRaw) as Array<{ status: string }>;
478
+
479
+ expect(invokeCount).toBe(1);
480
+ expect(commitCount).toBe(1);
481
+ expect(issues[0]?.status).toBe("manual-fix");
482
+ });
483
+
484
+ test("continues to next issue and marks failed summary when git commit fails", async () => {
485
+ const projectRoot = await createProjectRoot();
486
+ createdRoots.push(projectRoot);
487
+
488
+ await seedState(projectRoot, "000009");
489
+ await writeIssues(projectRoot, "000009", [
490
+ { id: "ISSUE-000009-001", title: "First", description: "one", status: "open" },
491
+ { id: "ISSUE-000009-002", title: "Second", description: "two", status: "open" },
492
+ ]);
493
+
494
+ const logs: string[] = [];
495
+ let commitAttempt = 0;
496
+ let invokeCount = 0;
497
+
498
+ await withCwd(projectRoot, async () => {
499
+ await runExecuteAutomatedFix(
500
+ { provider: "codex", iterations: 2 },
501
+ {
502
+ loadSkillFn: async () => "debug workflow",
503
+ invokeAgentFn: async () => {
504
+ invokeCount += 1;
505
+ return { exitCode: 0, stdout: "", stderr: "" };
506
+ },
507
+ runCommitFn: async () => {
508
+ commitAttempt += 1;
509
+ return commitAttempt === 1 ? 1 : 0;
510
+ },
511
+ logFn: (message) => logs.push(message),
512
+ },
513
+ );
514
+ });
515
+
516
+ expect(invokeCount).toBe(2);
517
+ expect(logs).toContain("ISSUE-000009-001: Failed");
518
+ expect(logs).toContain("Error: git commit failed for ISSUE-000009-001");
519
+ expect(logs).toContain("ISSUE-000009-002: Fixed");
520
+ expect(logs).toContain("Summary: Fixed=1 Failed=1");
521
+ });
522
+
523
+ // US-005-AC01/AC02/AC03: report per-issue pass/fail and summary in terminal-verifiable output
524
+ test("US-005: logs `{issueId}: Fixed|Failed` lines per issue and a summary with fixed/failed totals", async () => {
525
+ const projectRoot = await createProjectRoot();
526
+ createdRoots.push(projectRoot);
527
+
528
+ await seedState(projectRoot, "000009");
529
+ await writeIssues(projectRoot, "000009", [
530
+ { id: "ISSUE-000009-001", title: "First", description: "one", status: "open" },
531
+ { id: "ISSUE-000009-002", title: "Second", description: "two", status: "open" },
532
+ { id: "ISSUE-000009-003", title: "Third", description: "three", status: "open" },
533
+ ]);
534
+
535
+ const logs: string[] = [];
536
+ let commitAttempt = 0;
537
+
538
+ await withCwd(projectRoot, async () => {
539
+ await runExecuteAutomatedFix(
540
+ { provider: "codex", iterations: 2 },
541
+ {
542
+ loadSkillFn: async () => "debug workflow",
543
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "ok", stderr: "" }),
544
+ runCommitFn: async () => {
545
+ commitAttempt += 1;
546
+ return commitAttempt === 1 ? 0 : 1;
547
+ },
548
+ logFn: (message) => logs.push(message),
549
+ nowFn: () => new Date("2026-02-22T15:00:00.000Z"),
550
+ },
551
+ );
552
+ });
553
+
554
+ // AC01: one status line per processed issue using the required format
555
+ const statusLines = logs.filter((line) => /^ISSUE-\d{6}-\d{3}: (Fixed|Failed)$/.test(line));
556
+ expect(statusLines).toHaveLength(2);
557
+ expect(statusLines).toContain("ISSUE-000009-001: Fixed");
558
+ expect(statusLines).toContain("ISSUE-000009-002: Failed");
559
+
560
+ // AC02: summary includes fixed and failed totals
561
+ expect(logs).toContain("Summary: Fixed=1 Failed=1");
562
+
563
+ // AC03: result is directly verifiable from terminal-style output lines
564
+ expect(logs).toContain("Processed 2 open issue(s) at 2026-02-22T15:00:00.000Z");
565
+ });
566
+
567
+ test("throws deterministic validation error for malformed issues JSON", async () => {
568
+ const projectRoot = await createProjectRoot();
569
+ createdRoots.push(projectRoot);
570
+
571
+ await seedState(projectRoot, "000009");
572
+ await writeFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "{not-json", "utf8");
573
+
574
+ await withCwd(projectRoot, async () => {
575
+ await expect(runExecuteAutomatedFix({ provider: "codex" })).rejects.toThrow(
576
+ "Deterministic validation error: invalid issues JSON in .agents/flow/it_000009_ISSUES.json.",
577
+ );
578
+ });
579
+ });
580
+ });