@quinteroac/agents-coding-toolkit 0.1.0-preview → 0.2.0

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 (71) hide show
  1. package/README.md +29 -15
  2. package/package.json +14 -4
  3. package/scaffold/.agents/flow/tmpl_it_000001_progress.example.json +20 -0
  4. package/scaffold/.agents/skills/execute-refactor-item/tmpl_SKILL.md +59 -0
  5. package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +89 -9
  6. package/scaffold/.agents/skills/refine-refactor-plan/tmpl_SKILL.md +30 -0
  7. package/scaffold/.agents/tmpl_state_rules.md +0 -1
  8. package/scaffold/schemas/tmpl_prototype-progress.ts +22 -0
  9. package/scaffold/schemas/tmpl_refactor-execution-progress.ts +16 -0
  10. package/scaffold/schemas/tmpl_refactor-prd.ts +14 -0
  11. package/scaffold/schemas/tmpl_state.ts +1 -0
  12. package/scaffold/schemas/tmpl_test-execution-progress.ts +17 -0
  13. package/schemas/issues.ts +19 -0
  14. package/schemas/prototype-progress.ts +22 -0
  15. package/schemas/refactor-execution-progress.ts +16 -0
  16. package/schemas/refactor-prd.ts +14 -0
  17. package/schemas/state.test.ts +58 -0
  18. package/schemas/state.ts +1 -0
  19. package/schemas/test-execution-progress.ts +17 -0
  20. package/schemas/test-plan.test.ts +1 -1
  21. package/schemas/validate-progress.ts +1 -1
  22. package/schemas/validate-state.ts +1 -1
  23. package/src/cli.test.ts +57 -0
  24. package/src/cli.ts +227 -58
  25. package/src/commands/approve-project-context.ts +13 -6
  26. package/src/commands/approve-prototype.test.ts +427 -0
  27. package/src/commands/approve-prototype.ts +185 -0
  28. package/src/commands/approve-refactor-plan.test.ts +254 -0
  29. package/src/commands/approve-refactor-plan.ts +200 -0
  30. package/src/commands/approve-requirement.test.ts +224 -0
  31. package/src/commands/approve-requirement.ts +75 -16
  32. package/src/commands/approve-test-plan.test.ts +2 -2
  33. package/src/commands/approve-test-plan.ts +21 -7
  34. package/src/commands/create-issue.test.ts +2 -2
  35. package/src/commands/create-project-context.ts +31 -25
  36. package/src/commands/create-prototype.test.ts +488 -18
  37. package/src/commands/create-prototype.ts +185 -63
  38. package/src/commands/create-test-plan.ts +8 -6
  39. package/src/commands/define-refactor-plan.test.ts +208 -0
  40. package/src/commands/define-refactor-plan.ts +96 -0
  41. package/src/commands/define-requirement.ts +15 -9
  42. package/src/commands/execute-automated-fix.test.ts +78 -33
  43. package/src/commands/execute-automated-fix.ts +34 -101
  44. package/src/commands/execute-refactor.test.ts +954 -0
  45. package/src/commands/execute-refactor.ts +332 -0
  46. package/src/commands/execute-test-plan.test.ts +24 -16
  47. package/src/commands/execute-test-plan.ts +29 -55
  48. package/src/commands/flow-config.ts +79 -0
  49. package/src/commands/flow.test.ts +755 -0
  50. package/src/commands/flow.ts +405 -0
  51. package/src/commands/refine-project-context.ts +9 -7
  52. package/src/commands/refine-refactor-plan.test.ts +210 -0
  53. package/src/commands/refine-refactor-plan.ts +95 -0
  54. package/src/commands/refine-requirement.ts +9 -6
  55. package/src/commands/refine-test-plan.test.ts +2 -2
  56. package/src/commands/refine-test-plan.ts +9 -6
  57. package/src/commands/start-iteration.test.ts +52 -0
  58. package/src/commands/start-iteration.ts +5 -0
  59. package/src/commands/write-json.ts +102 -97
  60. package/src/flow-cli.test.ts +18 -0
  61. package/src/force-flag.test.ts +144 -0
  62. package/src/guardrail.test.ts +411 -0
  63. package/src/guardrail.ts +82 -0
  64. package/src/install.test.ts +7 -5
  65. package/src/pack.test.ts +2 -1
  66. package/src/progress-utils.ts +34 -0
  67. package/src/readline.ts +23 -0
  68. package/src/write-json-artifact.ts +33 -0
  69. package/scaffold/.agents/flow/tmpl_README.md +0 -7
  70. package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +0 -11
  71. package/schemas/test-plan.ts +0 -20
@@ -0,0 +1,954 @@
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 { type AgentResult } from "../agent";
7
+ import { readState, writeState } from "../state";
8
+ import { runExecuteRefactor, RefactorExecutionProgressSchema, buildRefactorExecutionReport } from "./execute-refactor";
9
+
10
+ async function createProjectRoot(): Promise<string> {
11
+ return mkdtemp(join(tmpdir(), "nvst-execute-refactor-"));
12
+ }
13
+
14
+ async function withCwd<T>(cwd: string, fn: () => Promise<T>): Promise<T> {
15
+ const previous = process.cwd();
16
+ process.chdir(cwd);
17
+ try {
18
+ return await fn();
19
+ } finally {
20
+ process.chdir(previous);
21
+ }
22
+ }
23
+
24
+ async function seedState(
25
+ projectRoot: string,
26
+ opts: {
27
+ phase?: "define" | "prototype" | "refactor";
28
+ refactorPlanStatus?: "pending" | "pending_approval" | "approved";
29
+ refactorExecutionStatus?: "pending" | "in_progress" | "completed";
30
+ iteration?: string;
31
+ } = {},
32
+ ) {
33
+ const {
34
+ phase = "refactor",
35
+ refactorPlanStatus = "approved",
36
+ refactorExecutionStatus = "pending",
37
+ iteration = "000013",
38
+ } = opts;
39
+
40
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
41
+ await writeState(projectRoot, {
42
+ current_iteration: iteration,
43
+ current_phase: phase,
44
+ phases: {
45
+ define: {
46
+ requirement_definition: { status: "approved", file: `it_${iteration}_product-requirement-document.md` },
47
+ prd_generation: { status: "completed", file: `it_${iteration}_PRD.json` },
48
+ },
49
+ prototype: {
50
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
51
+ test_plan: { status: "created", file: `it_${iteration}_test-plan.md` },
52
+ tp_generation: { status: "created", file: `it_${iteration}_TP.json` },
53
+ prototype_build: { status: "created", file: `it_${iteration}_progress.json` },
54
+ test_execution: { status: "completed", file: `it_${iteration}_test-execution-report.json` },
55
+ prototype_approved: true,
56
+ },
57
+ refactor: {
58
+ evaluation_report: { status: "created", file: `it_${iteration}_evaluation-report.md` },
59
+ refactor_plan: { status: refactorPlanStatus, file: refactorPlanStatus === "approved" ? `it_${iteration}_refactor-plan.md` : null },
60
+ refactor_execution: { status: refactorExecutionStatus, file: null },
61
+ changelog: { status: "pending", file: null },
62
+ },
63
+ },
64
+ last_updated: "2026-02-26T00:00:00.000Z",
65
+ updated_by: "seed",
66
+ history: [],
67
+ });
68
+ }
69
+
70
+ async function writeRefactorPrd(
71
+ projectRoot: string,
72
+ iteration: string,
73
+ items: Array<{ id: string; title: string; description: string; rationale: string }>,
74
+ ) {
75
+ const fileName = `it_${iteration}_refactor-prd.json`;
76
+ const filePath = join(projectRoot, ".agents", "flow", fileName);
77
+ await writeFile(
78
+ filePath,
79
+ `${JSON.stringify({ refactorItems: items }, null, 2)}\n`,
80
+ "utf8",
81
+ );
82
+ return fileName;
83
+ }
84
+
85
+ function makeAgentResult(exitCode: number): AgentResult {
86
+ return { exitCode, stdout: "", stderr: "" };
87
+ }
88
+
89
+ function makeSkillFn(content = "# Execute Refactor Item\nApply the refactor item.") {
90
+ return async (_projectRoot: string, _skillName: string) => content;
91
+ }
92
+
93
+ const createdRoots: string[] = [];
94
+
95
+ afterEach(async () => {
96
+ process.exitCode = 0;
97
+ await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
98
+ });
99
+
100
+ describe("execute refactor command", () => {
101
+ test("registers execute refactor command in CLI dispatch", async () => {
102
+ const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
103
+
104
+ expect(source).toContain('import { runExecuteRefactor } from "./commands/execute-refactor";');
105
+ expect(source).toContain('if (subcommand === "refactor") {');
106
+ expect(source).toContain("await runExecuteRefactor({ provider, force });");
107
+ expect(source).toContain("execute refactor --agent <provider>");
108
+ });
109
+
110
+ // AC02: Rejects if current_phase !== "refactor"
111
+ test("rejects with error when current_phase is not refactor", async () => {
112
+ const projectRoot = await createProjectRoot();
113
+ createdRoots.push(projectRoot);
114
+ await seedState(projectRoot, { phase: "prototype" });
115
+
116
+ await withCwd(projectRoot, async () => {
117
+ await expect(
118
+ runExecuteRefactor(
119
+ { provider: "claude" },
120
+ { loadSkillFn: makeSkillFn() },
121
+ ),
122
+ ).rejects.toThrow("Cannot execute refactor: current_phase must be 'refactor'. Current phase: 'prototype'.");
123
+ });
124
+ });
125
+
126
+ // AC03: Rejects if refactor_plan.status !== "approved"
127
+ test("rejects with error when refactor_plan.status is not approved", async () => {
128
+ const projectRoot = await createProjectRoot();
129
+ createdRoots.push(projectRoot);
130
+ await seedState(projectRoot, { refactorPlanStatus: "pending" });
131
+
132
+ await withCwd(projectRoot, async () => {
133
+ await expect(
134
+ runExecuteRefactor(
135
+ { provider: "claude" },
136
+ { loadSkillFn: makeSkillFn() },
137
+ ),
138
+ ).rejects.toThrow("Cannot execute refactor: refactor_plan.status must be 'approved'. Current status: 'pending'.");
139
+ });
140
+ });
141
+
142
+ // AC03: pending_approval variant
143
+ test("rejects with error when refactor_plan.status is pending_approval", async () => {
144
+ const projectRoot = await createProjectRoot();
145
+ createdRoots.push(projectRoot);
146
+ await seedState(projectRoot, { refactorPlanStatus: "pending_approval" });
147
+
148
+ await withCwd(projectRoot, async () => {
149
+ await expect(
150
+ runExecuteRefactor(
151
+ { provider: "claude" },
152
+ { loadSkillFn: makeSkillFn() },
153
+ ),
154
+ ).rejects.toThrow("Cannot execute refactor: refactor_plan.status must be 'approved'. Current status: 'pending_approval'.");
155
+ });
156
+ });
157
+
158
+ // AC04: Rejects if refactor_execution.status is already "completed"
159
+ test("rejects with error when refactor_execution.status is already completed", async () => {
160
+ const projectRoot = await createProjectRoot();
161
+ createdRoots.push(projectRoot);
162
+ await seedState(projectRoot, { refactorExecutionStatus: "completed" });
163
+
164
+ await withCwd(projectRoot, async () => {
165
+ await expect(
166
+ runExecuteRefactor(
167
+ { provider: "claude" },
168
+ { loadSkillFn: makeSkillFn() },
169
+ ),
170
+ ).rejects.toThrow("Cannot execute refactor: refactor_execution.status is already 'completed'.");
171
+ });
172
+ });
173
+
174
+ // AC05: Rejects if refactor-prd.json is missing
175
+ test("rejects with error when refactor-prd.json is missing", async () => {
176
+ const projectRoot = await createProjectRoot();
177
+ createdRoots.push(projectRoot);
178
+ await seedState(projectRoot);
179
+
180
+ await withCwd(projectRoot, async () => {
181
+ await expect(
182
+ runExecuteRefactor(
183
+ { provider: "claude" },
184
+ { loadSkillFn: makeSkillFn() },
185
+ ),
186
+ ).rejects.toThrow("Refactor PRD file missing: expected .agents/flow/it_000013_refactor-prd.json.");
187
+ });
188
+ });
189
+
190
+ // AC05: Rejects on invalid JSON
191
+ test("rejects with error when refactor-prd.json contains invalid JSON", async () => {
192
+ const projectRoot = await createProjectRoot();
193
+ createdRoots.push(projectRoot);
194
+ await seedState(projectRoot);
195
+ const prdPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-prd.json");
196
+ await writeFile(prdPath, "not-valid-json", "utf8");
197
+
198
+ await withCwd(projectRoot, async () => {
199
+ await expect(
200
+ runExecuteRefactor(
201
+ { provider: "claude" },
202
+ { loadSkillFn: makeSkillFn() },
203
+ ),
204
+ ).rejects.toThrow("Invalid refactor PRD JSON in .agents/flow/it_000013_refactor-prd.json.");
205
+ });
206
+ });
207
+
208
+ // AC05: Rejects on schema mismatch
209
+ test("rejects with error when refactor-prd.json fails schema validation", async () => {
210
+ const projectRoot = await createProjectRoot();
211
+ createdRoots.push(projectRoot);
212
+ await seedState(projectRoot);
213
+ const prdPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-prd.json");
214
+ await writeFile(prdPath, JSON.stringify({ refactorItems: [] }), "utf8");
215
+
216
+ await withCwd(projectRoot, async () => {
217
+ await expect(
218
+ runExecuteRefactor(
219
+ { provider: "claude" },
220
+ { loadSkillFn: makeSkillFn() },
221
+ ),
222
+ ).rejects.toThrow("Refactor PRD schema mismatch in .agents/flow/it_000013_refactor-prd.json.");
223
+ });
224
+ });
225
+
226
+ // AC06: Sets refactor_execution.status = "in_progress" before processing
227
+ test("sets refactor_execution.status to in_progress before invoking agent", async () => {
228
+ const projectRoot = await createProjectRoot();
229
+ createdRoots.push(projectRoot);
230
+ await seedState(projectRoot);
231
+ await writeRefactorPrd(projectRoot, "000013", [
232
+ { id: "RI-001", title: "Refactor A", description: "Do A", rationale: "Because A" },
233
+ ]);
234
+
235
+ let statusBeforeAgent: string | undefined;
236
+
237
+ await withCwd(projectRoot, async () => {
238
+ await runExecuteRefactor(
239
+ { provider: "claude" },
240
+ {
241
+ loadSkillFn: makeSkillFn(),
242
+ invokeAgentFn: async () => {
243
+ const s = await readState(projectRoot);
244
+ statusBeforeAgent = s.phases.refactor.refactor_execution.status;
245
+ return makeAgentResult(0);
246
+ },
247
+ },
248
+ );
249
+ });
250
+
251
+ expect(statusBeforeAgent).toBe("in_progress");
252
+ });
253
+
254
+ // AC07: Invokes agent with prompt built from skill and item fields
255
+ test("invokes agent with prompt containing refactor item fields", async () => {
256
+ const projectRoot = await createProjectRoot();
257
+ createdRoots.push(projectRoot);
258
+ await seedState(projectRoot);
259
+ await writeRefactorPrd(projectRoot, "000013", [
260
+ { id: "RI-001", title: "My Title", description: "My Description", rationale: "My Rationale" },
261
+ ]);
262
+
263
+ const capturedPrompts: string[] = [];
264
+
265
+ await withCwd(projectRoot, async () => {
266
+ await runExecuteRefactor(
267
+ { provider: "claude" },
268
+ {
269
+ loadSkillFn: makeSkillFn("SKILL_BODY"),
270
+ invokeAgentFn: async (opts) => {
271
+ capturedPrompts.push(opts.prompt);
272
+ return makeAgentResult(0);
273
+ },
274
+ },
275
+ );
276
+ });
277
+
278
+ expect(capturedPrompts).toHaveLength(1);
279
+ expect(capturedPrompts[0]).toContain("SKILL_BODY");
280
+ expect(capturedPrompts[0]).toContain("RI-001");
281
+ expect(capturedPrompts[0]).toContain("My Title");
282
+ expect(capturedPrompts[0]).toContain("My Description");
283
+ expect(capturedPrompts[0]).toContain("My Rationale");
284
+ });
285
+
286
+ // US-002-AC01: Agent invoked in non-interactive mode
287
+ test("invokes agent with interactive: false (non-interactive mode)", async () => {
288
+ const projectRoot = await createProjectRoot();
289
+ createdRoots.push(projectRoot);
290
+ await seedState(projectRoot);
291
+ await writeRefactorPrd(projectRoot, "000013", [
292
+ { id: "RI-001", title: "T", description: "D", rationale: "R" },
293
+ ]);
294
+
295
+ const capturedOptions: Array<{ interactive?: boolean; provider: string }> = [];
296
+
297
+ await withCwd(projectRoot, async () => {
298
+ await runExecuteRefactor(
299
+ { provider: "codex" },
300
+ {
301
+ loadSkillFn: makeSkillFn(),
302
+ invokeAgentFn: async (opts) => {
303
+ capturedOptions.push({ interactive: opts.interactive, provider: opts.provider });
304
+ return makeAgentResult(0);
305
+ },
306
+ },
307
+ );
308
+ });
309
+
310
+ expect(capturedOptions).toHaveLength(1);
311
+ expect(capturedOptions[0].interactive).toBe(false);
312
+ expect(capturedOptions[0].provider).toBe("codex");
313
+ });
314
+
315
+ // AC09 & AC10: Records result after each invocation, continues on failure
316
+ test("records completed status and continues after each successful item", async () => {
317
+ const projectRoot = await createProjectRoot();
318
+ createdRoots.push(projectRoot);
319
+ await seedState(projectRoot);
320
+ await writeRefactorPrd(projectRoot, "000013", [
321
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
322
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
323
+ ]);
324
+
325
+ const agentCallOrder: string[] = [];
326
+
327
+ await withCwd(projectRoot, async () => {
328
+ await runExecuteRefactor(
329
+ { provider: "claude" },
330
+ {
331
+ loadSkillFn: makeSkillFn(),
332
+ invokeAgentFn: async (opts) => {
333
+ agentCallOrder.push(opts.prompt.includes("RI-001") ? "RI-001" : "RI-002");
334
+ return makeAgentResult(0);
335
+ },
336
+ },
337
+ );
338
+ });
339
+
340
+ expect(agentCallOrder).toEqual(["RI-001", "RI-002"]);
341
+
342
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
343
+ const progress = RefactorExecutionProgressSchema.parse(
344
+ JSON.parse(await readFile(progressPath, "utf8")),
345
+ );
346
+ expect(progress.entries[0].status).toBe("completed");
347
+ expect(progress.entries[1].status).toBe("completed");
348
+ });
349
+
350
+ // AC10: Non-zero exit code marks item as failed, continues to next
351
+ test("marks item as failed on non-zero exit code and continues to next item", async () => {
352
+ const projectRoot = await createProjectRoot();
353
+ createdRoots.push(projectRoot);
354
+ await seedState(projectRoot);
355
+ await writeRefactorPrd(projectRoot, "000013", [
356
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
357
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
358
+ ]);
359
+
360
+ const agentCallOrder: string[] = [];
361
+
362
+ await withCwd(projectRoot, async () => {
363
+ await runExecuteRefactor(
364
+ { provider: "claude" },
365
+ {
366
+ loadSkillFn: makeSkillFn(),
367
+ invokeAgentFn: async (opts) => {
368
+ const id = opts.prompt.includes("RI-001") ? "RI-001" : "RI-002";
369
+ agentCallOrder.push(id);
370
+ // RI-001 fails, RI-002 succeeds
371
+ return makeAgentResult(id === "RI-001" ? 1 : 0);
372
+ },
373
+ },
374
+ );
375
+ });
376
+
377
+ // Both items are attempted
378
+ expect(agentCallOrder).toEqual(["RI-001", "RI-002"]);
379
+
380
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
381
+ const progress = RefactorExecutionProgressSchema.parse(
382
+ JSON.parse(await readFile(progressPath, "utf8")),
383
+ );
384
+ expect(progress.entries[0].status).toBe("failed");
385
+ expect(progress.entries[0].last_agent_exit_code).toBe(1);
386
+ expect(progress.entries[1].status).toBe("completed");
387
+ expect(progress.entries[1].last_agent_exit_code).toBe(0);
388
+ });
389
+
390
+ // AC09: Progress file is written after each agent invocation (via write-json)
391
+ test("writes progress file after each agent invocation", async () => {
392
+ const projectRoot = await createProjectRoot();
393
+ createdRoots.push(projectRoot);
394
+ await seedState(projectRoot);
395
+ await writeRefactorPrd(projectRoot, "000013", [
396
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
397
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
398
+ ]);
399
+
400
+ const progressSnapshots: Array<ReturnType<typeof RefactorExecutionProgressSchema.parse>> = [];
401
+
402
+ await withCwd(projectRoot, async () => {
403
+ await runExecuteRefactor(
404
+ { provider: "claude" },
405
+ {
406
+ loadSkillFn: makeSkillFn(),
407
+ invokeAgentFn: async (opts) => {
408
+ const id = opts.prompt.includes("RI-001") ? "RI-001" : "RI-002";
409
+ return makeAgentResult(id === "RI-001" ? 1 : 0);
410
+ },
411
+ invokeWriteJsonFn: async (_root, schemaName, _outPath, data) => {
412
+ if (schemaName === "refactor-execution-progress") {
413
+ const parsed = JSON.parse(data) as unknown;
414
+ const validation = RefactorExecutionProgressSchema.safeParse(parsed);
415
+ if (validation.success) {
416
+ progressSnapshots.push(validation.data);
417
+ }
418
+ }
419
+ return { exitCode: 0, stderr: "" };
420
+ },
421
+ },
422
+ );
423
+ });
424
+
425
+ // Two snapshots: one per item (after RI-001, after RI-002)
426
+ expect(progressSnapshots.length).toBeGreaterThanOrEqual(2);
427
+ // After RI-001 write: RI-001 failed
428
+ const afterRi001 = progressSnapshots.find((s) =>
429
+ s.entries.some((e) => e.id === "RI-001" && e.status === "failed"),
430
+ );
431
+ expect(afterRi001).toBeDefined();
432
+ });
433
+
434
+ // AC11: All items complete → refactor_execution.status = "completed"
435
+ test("sets refactor_execution.status to completed when all items succeed", async () => {
436
+ const projectRoot = await createProjectRoot();
437
+ createdRoots.push(projectRoot);
438
+ await seedState(projectRoot);
439
+ await writeRefactorPrd(projectRoot, "000013", [
440
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
441
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
442
+ ]);
443
+
444
+ await withCwd(projectRoot, async () => {
445
+ await runExecuteRefactor(
446
+ { provider: "claude" },
447
+ {
448
+ loadSkillFn: makeSkillFn(),
449
+ invokeAgentFn: async () => makeAgentResult(0),
450
+ },
451
+ );
452
+ });
453
+
454
+ const state = await readState(projectRoot);
455
+ expect(state.phases.refactor.refactor_execution.status).toBe("completed");
456
+ });
457
+
458
+ // AC12: Any failure → status remains "in_progress"
459
+ test("leaves refactor_execution.status as in_progress when any item fails", async () => {
460
+ const projectRoot = await createProjectRoot();
461
+ createdRoots.push(projectRoot);
462
+ await seedState(projectRoot);
463
+ await writeRefactorPrd(projectRoot, "000013", [
464
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
465
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
466
+ ]);
467
+
468
+ await withCwd(projectRoot, async () => {
469
+ await runExecuteRefactor(
470
+ { provider: "claude" },
471
+ {
472
+ loadSkillFn: makeSkillFn(),
473
+ invokeAgentFn: async (opts) => {
474
+ // RI-001 fails
475
+ return makeAgentResult(opts.prompt.includes("RI-001") ? 1 : 0);
476
+ },
477
+ },
478
+ );
479
+ });
480
+
481
+ const state = await readState(projectRoot);
482
+ expect(state.phases.refactor.refactor_execution.status).toBe("in_progress");
483
+ });
484
+
485
+ // AC13: refactor_execution.file is set to progress file name
486
+ test("sets refactor_execution.file to the progress file name", async () => {
487
+ const projectRoot = await createProjectRoot();
488
+ createdRoots.push(projectRoot);
489
+ await seedState(projectRoot);
490
+ await writeRefactorPrd(projectRoot, "000013", [
491
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
492
+ ]);
493
+
494
+ await withCwd(projectRoot, async () => {
495
+ await runExecuteRefactor(
496
+ { provider: "claude" },
497
+ {
498
+ loadSkillFn: makeSkillFn(),
499
+ invokeAgentFn: async () => makeAgentResult(0),
500
+ },
501
+ );
502
+ });
503
+
504
+ const state = await readState(projectRoot);
505
+ expect(state.phases.refactor.refactor_execution.file).toBe(
506
+ "it_000013_refactor-execution-progress.json",
507
+ );
508
+ });
509
+
510
+ // Full happy path: all items completed, state and progress correct
511
+ test("happy path: all items completed, progress and state updated correctly", async () => {
512
+ const projectRoot = await createProjectRoot();
513
+ createdRoots.push(projectRoot);
514
+ await seedState(projectRoot);
515
+ await writeRefactorPrd(projectRoot, "000013", [
516
+ { id: "RI-001", title: "Refactor One", description: "First thing", rationale: "R1" },
517
+ { id: "RI-002", title: "Refactor Two", description: "Second thing", rationale: "R2" },
518
+ ]);
519
+
520
+ const logs: string[] = [];
521
+
522
+ await withCwd(projectRoot, async () => {
523
+ await runExecuteRefactor(
524
+ { provider: "claude" },
525
+ {
526
+ loadSkillFn: makeSkillFn(),
527
+ invokeAgentFn: async () => makeAgentResult(0),
528
+ logFn: (msg) => logs.push(msg),
529
+ nowFn: () => new Date("2026-02-26T12:00:00.000Z"),
530
+ },
531
+ );
532
+ });
533
+
534
+ const state = await readState(projectRoot);
535
+ expect(state.phases.refactor.refactor_execution.status).toBe("completed");
536
+ expect(state.phases.refactor.refactor_execution.file).toBe(
537
+ "it_000013_refactor-execution-progress.json",
538
+ );
539
+ expect(state.updated_by).toBe("nvst:execute-refactor");
540
+ expect(state.last_updated).toBe("2026-02-26T12:00:00.000Z");
541
+
542
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
543
+ const progress = RefactorExecutionProgressSchema.parse(
544
+ JSON.parse(await readFile(progressPath, "utf8")),
545
+ );
546
+ expect(progress.entries).toHaveLength(2);
547
+ expect(progress.entries[0]).toMatchObject({ id: "RI-001", status: "completed", last_agent_exit_code: 0 });
548
+ expect(progress.entries[1]).toMatchObject({ id: "RI-002", status: "completed", last_agent_exit_code: 0 });
549
+
550
+ expect(logs).toContain("iteration=it_000013 item=RI-001 outcome=completed");
551
+ expect(logs).toContain("iteration=it_000013 item=RI-002 outcome=completed");
552
+ expect(logs).toContain("Refactor execution completed for all items.");
553
+ });
554
+
555
+ // AC02: Progress schema mismatch on resume rejects with clear error
556
+ test("rejects with error when progress file schema is invalid on resume", async () => {
557
+ const projectRoot = await createProjectRoot();
558
+ createdRoots.push(projectRoot);
559
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
560
+ await writeRefactorPrd(projectRoot, "000013", [
561
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
562
+ ]);
563
+
564
+ // Write a progress file with invalid schema (missing required fields)
565
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
566
+ await writeFile(
567
+ progressPath,
568
+ JSON.stringify({ entries: [{ id: "RI-001", bad_field: "value" }] }, null, 2) + "\n",
569
+ "utf8",
570
+ );
571
+
572
+ await withCwd(projectRoot, async () => {
573
+ await expect(
574
+ runExecuteRefactor(
575
+ { provider: "claude" },
576
+ { loadSkillFn: makeSkillFn() },
577
+ ),
578
+ ).rejects.toThrow("Progress schema mismatch in .agents/flow/it_000013_refactor-execution-progress.json.");
579
+ });
580
+ });
581
+
582
+ // AC04: Re-attempts items with "pending" status when resuming
583
+ test("re-attempts pending items when resuming execution", async () => {
584
+ const projectRoot = await createProjectRoot();
585
+ createdRoots.push(projectRoot);
586
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
587
+ await writeRefactorPrd(projectRoot, "000013", [
588
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
589
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
590
+ ]);
591
+
592
+ // Write a progress file with both items pending
593
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
594
+ await writeFile(
595
+ progressPath,
596
+ JSON.stringify({
597
+ entries: [
598
+ { id: "RI-001", title: "T1", status: "pending", attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-02-26T00:00:00.000Z" },
599
+ { id: "RI-002", title: "T2", status: "pending", attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-02-26T00:00:00.000Z" },
600
+ ],
601
+ }, null, 2) + "\n",
602
+ "utf8",
603
+ );
604
+
605
+ const invokedItems: string[] = [];
606
+
607
+ await withCwd(projectRoot, async () => {
608
+ await runExecuteRefactor(
609
+ { provider: "claude" },
610
+ {
611
+ loadSkillFn: makeSkillFn(),
612
+ invokeAgentFn: async (opts) => {
613
+ invokedItems.push(opts.prompt.includes("RI-001") ? "RI-001" : "RI-002");
614
+ return makeAgentResult(0);
615
+ },
616
+ },
617
+ );
618
+ });
619
+
620
+ expect(invokedItems).toEqual(["RI-001", "RI-002"]);
621
+ });
622
+
623
+ // AC04: Re-attempts items with "failed" status when resuming
624
+ test("re-attempts failed items when resuming execution", async () => {
625
+ const projectRoot = await createProjectRoot();
626
+ createdRoots.push(projectRoot);
627
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
628
+ await writeRefactorPrd(projectRoot, "000013", [
629
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
630
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
631
+ ]);
632
+
633
+ // Write a progress file with RI-001 completed, RI-002 failed
634
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
635
+ await writeFile(
636
+ progressPath,
637
+ JSON.stringify({
638
+ entries: [
639
+ { id: "RI-001", title: "T1", status: "completed", attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-02-26T00:00:00.000Z" },
640
+ { id: "RI-002", title: "T2", status: "failed", attempt_count: 1, last_agent_exit_code: 1, updated_at: "2026-02-26T00:00:00.000Z" },
641
+ ],
642
+ }, null, 2) + "\n",
643
+ "utf8",
644
+ );
645
+
646
+ const invokedItems: string[] = [];
647
+
648
+ await withCwd(projectRoot, async () => {
649
+ await runExecuteRefactor(
650
+ { provider: "claude" },
651
+ {
652
+ loadSkillFn: makeSkillFn(),
653
+ invokeAgentFn: async (opts) => {
654
+ invokedItems.push(opts.prompt.includes("RI-001") ? "RI-001" : "RI-002");
655
+ return makeAgentResult(0);
656
+ },
657
+ },
658
+ );
659
+ });
660
+
661
+ // Only RI-002 (failed) should be re-attempted; RI-001 (completed) is skipped
662
+ expect(invokedItems).toEqual(["RI-002"]);
663
+ });
664
+
665
+ // AC05: Rejects when progress item IDs do not match refactor PRD item IDs
666
+ test("rejects with error when progress item IDs do not match refactor PRD item IDs", async () => {
667
+ const projectRoot = await createProjectRoot();
668
+ createdRoots.push(projectRoot);
669
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
670
+ await writeRefactorPrd(projectRoot, "000013", [
671
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
672
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
673
+ ]);
674
+
675
+ // Write a progress file with different IDs (stale/mismatched)
676
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
677
+ await writeFile(
678
+ progressPath,
679
+ JSON.stringify({
680
+ entries: [
681
+ { id: "RI-001", title: "T1", status: "completed", attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-02-26T00:00:00.000Z" },
682
+ { id: "RI-999", title: "STALE", status: "pending", attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-02-26T00:00:00.000Z" },
683
+ ],
684
+ }, null, 2) + "\n",
685
+ "utf8",
686
+ );
687
+
688
+ await withCwd(projectRoot, async () => {
689
+ await expect(
690
+ runExecuteRefactor(
691
+ { provider: "claude" },
692
+ { loadSkillFn: makeSkillFn() },
693
+ ),
694
+ ).rejects.toThrow(
695
+ "Refactor execution progress file out of sync: entry ids do not match refactor PRD item ids.",
696
+ );
697
+ });
698
+ });
699
+
700
+ // AC05: Rejects when progress has different number of items than PRD
701
+ test("rejects with error when progress has different number of items than refactor PRD", async () => {
702
+ const projectRoot = await createProjectRoot();
703
+ createdRoots.push(projectRoot);
704
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
705
+ await writeRefactorPrd(projectRoot, "000013", [
706
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
707
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
708
+ ]);
709
+
710
+ // Write a progress file with only one entry (missing RI-002)
711
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
712
+ await writeFile(
713
+ progressPath,
714
+ JSON.stringify({
715
+ entries: [
716
+ { id: "RI-001", title: "T1", status: "completed", attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-02-26T00:00:00.000Z" },
717
+ ],
718
+ }, null, 2) + "\n",
719
+ "utf8",
720
+ );
721
+
722
+ await withCwd(projectRoot, async () => {
723
+ await expect(
724
+ runExecuteRefactor(
725
+ { provider: "claude" },
726
+ { loadSkillFn: makeSkillFn() },
727
+ ),
728
+ ).rejects.toThrow(
729
+ "Refactor execution progress file out of sync: entry ids do not match refactor PRD item ids.",
730
+ );
731
+ });
732
+ });
733
+
734
+ // TC-001-17: Progress file schema has entries with id, title, status, attempt_count, last_agent_exit_code, updated_at
735
+ test("TC-001-17: RefactorExecutionProgressSchema accepts valid payload with attempt_count and last_agent_exit_code", () => {
736
+ const validPayload = {
737
+ entries: [
738
+ { id: "RI-001", title: "T1", status: "pending", attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-01-01T00:00:00.000Z" },
739
+ { id: "RI-002", title: "T2", status: "completed", attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-01-01T00:00:00.000Z" },
740
+ { id: "RI-003", title: "T3", status: "failed", attempt_count: 2, last_agent_exit_code: 1, updated_at: "2026-01-01T00:00:00.000Z" },
741
+ ],
742
+ };
743
+
744
+ const result = RefactorExecutionProgressSchema.safeParse(validPayload);
745
+ expect(result.success).toBe(true);
746
+ if (result.success) {
747
+ const entry = result.data.entries[0];
748
+ expect(entry).toHaveProperty("id");
749
+ expect(entry).toHaveProperty("title");
750
+ expect(entry).toHaveProperty("status");
751
+ expect(entry).toHaveProperty("attempt_count");
752
+ expect(entry).toHaveProperty("last_agent_exit_code");
753
+ expect(entry).toHaveProperty("updated_at");
754
+ expect(entry.attempt_count).toBe(0);
755
+ expect(result.data.entries[1].attempt_count).toBe(1);
756
+ expect(result.data.entries[1].last_agent_exit_code).toBe(0);
757
+ expect(result.data.entries[2].last_agent_exit_code).toBe(1);
758
+ }
759
+ });
760
+
761
+ test("TC-001-17 / FR-4: RefactorExecutionProgressSchema accepts status in_progress", () => {
762
+ const payloadWithInProgress = {
763
+ entries: [
764
+ { id: "RI-001", title: "T1", status: "in_progress", attempt_count: 1, last_agent_exit_code: null, updated_at: "2026-01-01T00:00:00.000Z" },
765
+ ],
766
+ };
767
+ const result = RefactorExecutionProgressSchema.safeParse(payloadWithInProgress);
768
+ expect(result.success).toBe(true);
769
+ if (result.success) {
770
+ expect(result.data.entries[0].status).toBe("in_progress");
771
+ }
772
+ });
773
+
774
+ test("TC-001-17: RefactorExecutionProgressSchema rejects payload missing attempt_count", () => {
775
+ const invalidPayload = {
776
+ entries: [
777
+ { id: "RI-001", title: "T1", status: "pending", last_agent_exit_code: null, updated_at: "2026-01-01T00:00:00.000Z" },
778
+ ],
779
+ };
780
+ const result = RefactorExecutionProgressSchema.safeParse(invalidPayload);
781
+ expect(result.success).toBe(false);
782
+ });
783
+
784
+ test("TC-001-17: RefactorExecutionProgressSchema rejects payload with agent_exit_code instead of last_agent_exit_code", () => {
785
+ const invalidPayload = {
786
+ entries: [
787
+ { id: "RI-001", title: "T1", status: "pending", attempt_count: 0, agent_exit_code: null, updated_at: "2026-01-01T00:00:00.000Z" },
788
+ ],
789
+ };
790
+ const result = RefactorExecutionProgressSchema.safeParse(invalidPayload);
791
+ expect(result.success).toBe(false);
792
+ });
793
+
794
+ // Skips already-completed entries on re-run
795
+ test("skips completed entries when resuming execution", async () => {
796
+ const projectRoot = await createProjectRoot();
797
+ createdRoots.push(projectRoot);
798
+ await seedState(projectRoot, { refactorExecutionStatus: "in_progress" });
799
+ await writeRefactorPrd(projectRoot, "000013", [
800
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
801
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
802
+ ]);
803
+
804
+ // Write a pre-existing progress file with RI-001 already completed
805
+ const progressPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-progress.json");
806
+ await writeFile(
807
+ progressPath,
808
+ JSON.stringify({
809
+ entries: [
810
+ { id: "RI-001", title: "T1", status: "completed", attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-02-26T00:00:00.000Z" },
811
+ { id: "RI-002", title: "T2", status: "pending", attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-02-26T00:00:00.000Z" },
812
+ ],
813
+ }, null, 2) + "\n",
814
+ "utf8",
815
+ );
816
+
817
+ const invokedItems: string[] = [];
818
+
819
+ await withCwd(projectRoot, async () => {
820
+ await runExecuteRefactor(
821
+ { provider: "claude" },
822
+ {
823
+ loadSkillFn: makeSkillFn(),
824
+ invokeAgentFn: async (opts) => {
825
+ invokedItems.push(opts.prompt.includes("RI-001") ? "RI-001" : "RI-002");
826
+ return makeAgentResult(0);
827
+ },
828
+ },
829
+ );
830
+ });
831
+
832
+ // Only RI-002 should be invoked (RI-001 was already completed)
833
+ expect(invokedItems).toEqual(["RI-002"]);
834
+ });
835
+ });
836
+
837
+ describe("US-003: generate refactor execution report", () => {
838
+ // AC01: Report file is written to .agents/flow/ after all items are processed
839
+ test("writes refactor-execution-report.md to .agents/flow/ after processing", async () => {
840
+ const projectRoot = await createProjectRoot();
841
+ createdRoots.push(projectRoot);
842
+ await seedState(projectRoot);
843
+ await writeRefactorPrd(projectRoot, "000013", [
844
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
845
+ ]);
846
+
847
+ await withCwd(projectRoot, async () => {
848
+ await runExecuteRefactor(
849
+ { provider: "claude" },
850
+ {
851
+ loadSkillFn: makeSkillFn(),
852
+ invokeAgentFn: async () => makeAgentResult(0),
853
+ },
854
+ );
855
+ });
856
+
857
+ const reportPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-report.md");
858
+ const content = await readFile(reportPath, "utf8");
859
+ expect(content).toBeTruthy();
860
+ });
861
+
862
+ // AC02: Report includes iteration, total, completed, failed, and table
863
+ test("report contains iteration number, totals, and table with required columns", async () => {
864
+ const projectRoot = await createProjectRoot();
865
+ createdRoots.push(projectRoot);
866
+ await seedState(projectRoot);
867
+ await writeRefactorPrd(projectRoot, "000013", [
868
+ { id: "RI-001", title: "First Refactor", description: "D1", rationale: "R1" },
869
+ { id: "RI-002", title: "Second Refactor", description: "D2", rationale: "R2" },
870
+ ]);
871
+
872
+ await withCwd(projectRoot, async () => {
873
+ await runExecuteRefactor(
874
+ { provider: "claude" },
875
+ {
876
+ loadSkillFn: makeSkillFn(),
877
+ invokeAgentFn: async (opts) =>
878
+ makeAgentResult(opts.prompt.includes("RI-001") ? 1 : 0),
879
+ },
880
+ );
881
+ });
882
+
883
+ const reportPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-report.md");
884
+ const content = await readFile(reportPath, "utf8");
885
+
886
+ // Iteration number
887
+ expect(content).toContain("it_000013");
888
+ // Total, completed, failed counts
889
+ expect(content).toContain("**Total:** 2");
890
+ expect(content).toContain("**Completed:** 1");
891
+ expect(content).toContain("**Failed:** 1");
892
+ // Table header columns
893
+ expect(content).toContain("RI ID");
894
+ expect(content).toContain("Title");
895
+ expect(content).toContain("Status");
896
+ expect(content).toContain("Agent Exit Code");
897
+ // Table rows with item data
898
+ expect(content).toContain("RI-001");
899
+ expect(content).toContain("First Refactor");
900
+ expect(content).toContain("failed");
901
+ expect(content).toContain("RI-002");
902
+ expect(content).toContain("Second Refactor");
903
+ expect(content).toContain("completed");
904
+ });
905
+
906
+ // AC03: Report is written even when items fail
907
+ test("writes report regardless of whether items failed", async () => {
908
+ const projectRoot = await createProjectRoot();
909
+ createdRoots.push(projectRoot);
910
+ await seedState(projectRoot);
911
+ await writeRefactorPrd(projectRoot, "000013", [
912
+ { id: "RI-001", title: "T1", description: "D1", rationale: "R1" },
913
+ { id: "RI-002", title: "T2", description: "D2", rationale: "R2" },
914
+ ]);
915
+
916
+ await withCwd(projectRoot, async () => {
917
+ await runExecuteRefactor(
918
+ { provider: "claude" },
919
+ {
920
+ loadSkillFn: makeSkillFn(),
921
+ invokeAgentFn: async () => makeAgentResult(1), // all items fail
922
+ },
923
+ );
924
+ });
925
+
926
+ const reportPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-execution-report.md");
927
+ const content = await readFile(reportPath, "utf8");
928
+ expect(content).toContain("**Failed:** 2");
929
+ expect(content).toContain("**Completed:** 0");
930
+ });
931
+
932
+ // Unit: buildRefactorExecutionReport renders correctly
933
+ test("buildRefactorExecutionReport produces correct markdown for mixed results", () => {
934
+ const progress = {
935
+ entries: [
936
+ { id: "RI-001", title: "Alpha", status: "completed" as const, attempt_count: 1, last_agent_exit_code: 0, updated_at: "2026-01-01T00:00:00.000Z" },
937
+ { id: "RI-002", title: "Beta", status: "failed" as const, attempt_count: 1, last_agent_exit_code: 2, updated_at: "2026-01-01T00:00:00.000Z" },
938
+ { id: "RI-003", title: "Gamma", status: "pending" as const, attempt_count: 0, last_agent_exit_code: null, updated_at: "2026-01-01T00:00:00.000Z" },
939
+ ],
940
+ };
941
+
942
+ const report = buildRefactorExecutionReport("000014", progress);
943
+
944
+ expect(report).toContain("it_000014");
945
+ expect(report).toContain("**Total:** 3");
946
+ expect(report).toContain("**Completed:** 1");
947
+ expect(report).toContain("**Failed:** 1");
948
+ expect(report).toContain("| RI-001 | Alpha | completed | 0 |");
949
+ expect(report).toContain("| RI-002 | Beta | failed | 2 |");
950
+ expect(report).toContain("| RI-003 | Gamma | pending | N/A |");
951
+ // Table header
952
+ expect(report).toContain("| RI ID | Title | Status | Agent Exit Code |");
953
+ });
954
+ });