@quinteroac/agents-coding-toolkit 0.1.0-preview → 0.1.1-preview.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 (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -4
  3. package/scaffold/.agents/skills/execute-refactor-item/tmpl_SKILL.md +59 -0
  4. package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +89 -9
  5. package/scaffold/.agents/skills/refine-refactor-plan/tmpl_SKILL.md +30 -0
  6. package/scaffold/.agents/tmpl_state_rules.md +0 -1
  7. package/scaffold/schemas/tmpl_refactor-execution-progress.ts +16 -0
  8. package/scaffold/schemas/tmpl_refactor-prd.ts +14 -0
  9. package/scaffold/schemas/tmpl_state.ts +1 -0
  10. package/schemas/refactor-execution-progress.ts +16 -0
  11. package/schemas/refactor-prd.ts +14 -0
  12. package/schemas/state.test.ts +58 -0
  13. package/schemas/state.ts +1 -0
  14. package/schemas/test-plan.test.ts +1 -1
  15. package/src/cli.test.ts +57 -0
  16. package/src/cli.ts +180 -56
  17. package/src/commands/approve-project-context.ts +13 -6
  18. package/src/commands/approve-refactor-plan.test.ts +254 -0
  19. package/src/commands/approve-refactor-plan.ts +200 -0
  20. package/src/commands/approve-requirement.test.ts +224 -0
  21. package/src/commands/approve-requirement.ts +75 -16
  22. package/src/commands/approve-test-plan.test.ts +2 -2
  23. package/src/commands/approve-test-plan.ts +21 -7
  24. package/src/commands/create-issue.test.ts +2 -2
  25. package/src/commands/create-project-context.ts +31 -25
  26. package/src/commands/create-prototype.test.ts +31 -13
  27. package/src/commands/create-prototype.ts +17 -7
  28. package/src/commands/create-test-plan.ts +8 -6
  29. package/src/commands/define-refactor-plan.test.ts +208 -0
  30. package/src/commands/define-refactor-plan.ts +96 -0
  31. package/src/commands/define-requirement.ts +15 -9
  32. package/src/commands/execute-refactor.test.ts +954 -0
  33. package/src/commands/execute-refactor.ts +336 -0
  34. package/src/commands/execute-test-plan.test.ts +9 -2
  35. package/src/commands/execute-test-plan.ts +13 -6
  36. package/src/commands/refine-project-context.ts +9 -7
  37. package/src/commands/refine-refactor-plan.test.ts +210 -0
  38. package/src/commands/refine-refactor-plan.ts +95 -0
  39. package/src/commands/refine-requirement.ts +9 -6
  40. package/src/commands/refine-test-plan.test.ts +2 -2
  41. package/src/commands/refine-test-plan.ts +9 -6
  42. package/src/commands/write-json.ts +102 -97
  43. package/src/force-flag.test.ts +144 -0
  44. package/src/guardrail.test.ts +411 -0
  45. package/src/guardrail.ts +104 -0
  46. package/src/install.test.ts +7 -5
  47. package/src/pack.test.ts +2 -1
  48. package/scaffold/.agents/flow/tmpl_README.md +0 -7
  49. package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +0 -11
  50. package/schemas/test-plan.ts +0 -20
@@ -0,0 +1,336 @@
1
+ import { $ } from "bun";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { RefactorPrdSchema } from "../../scaffold/schemas/tmpl_refactor-prd";
6
+ import {
7
+ RefactorExecutionProgressSchema,
8
+ type RefactorExecutionProgress,
9
+ } from "../../scaffold/schemas/tmpl_refactor-execution-progress";
10
+ import {
11
+ buildPrompt,
12
+ invokeAgent,
13
+ loadSkill,
14
+ type AgentInvokeOptions,
15
+ type AgentProvider,
16
+ type AgentResult,
17
+ } from "../agent";
18
+ import { CLI_PATH } from "../cli-path";
19
+ import { assertGuardrail } from "../guardrail";
20
+ import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
21
+
22
+ export interface ExecuteRefactorOptions {
23
+ provider: AgentProvider;
24
+ force?: boolean;
25
+ }
26
+
27
+ export { RefactorExecutionProgressSchema };
28
+ export type { RefactorExecutionProgress };
29
+
30
+ interface WriteJsonResult {
31
+ exitCode: number;
32
+ stderr: string;
33
+ }
34
+
35
+ interface ExecuteRefactorDeps {
36
+ existsFn: (path: string) => Promise<boolean>;
37
+ invokeAgentFn: (options: AgentInvokeOptions) => Promise<AgentResult>;
38
+ invokeWriteJsonFn: (
39
+ projectRoot: string,
40
+ schemaName: string,
41
+ outPath: string,
42
+ data: string,
43
+ ) => Promise<WriteJsonResult>;
44
+ loadSkillFn: (projectRoot: string, skillName: string) => Promise<string>;
45
+ logFn: (message: string) => void;
46
+ nowFn: () => Date;
47
+ readFileFn: typeof readFile;
48
+ writeFileFn: typeof writeFile;
49
+ }
50
+
51
+ async function runWriteJsonCommand(
52
+ projectRoot: string,
53
+ schemaName: string,
54
+ outPath: string,
55
+ data: string,
56
+ ): Promise<WriteJsonResult> {
57
+ const result =
58
+ await $`bun ${CLI_PATH} write-json --schema ${schemaName} --out ${outPath} --data ${data}`
59
+ .cwd(projectRoot)
60
+ .nothrow()
61
+ .quiet();
62
+ return {
63
+ exitCode: result.exitCode,
64
+ stderr: result.stderr.toString().trim(),
65
+ };
66
+ }
67
+
68
+ const defaultDeps: ExecuteRefactorDeps = {
69
+ existsFn: exists,
70
+ invokeAgentFn: invokeAgent,
71
+ invokeWriteJsonFn: runWriteJsonCommand,
72
+ loadSkillFn: loadSkill,
73
+ logFn: console.log,
74
+ nowFn: () => new Date(),
75
+ readFileFn: readFile,
76
+ writeFileFn: writeFile,
77
+ };
78
+
79
+ export async function runExecuteRefactor(
80
+ opts: ExecuteRefactorOptions,
81
+ deps: Partial<ExecuteRefactorDeps> = {},
82
+ ): Promise<void> {
83
+ const mergedDeps: ExecuteRefactorDeps = { ...defaultDeps, ...deps };
84
+ const force = opts.force ?? false;
85
+ const projectRoot = process.cwd();
86
+ const state = await readState(projectRoot);
87
+
88
+ // AC02: Reject if current_phase !== "refactor"
89
+ await assertGuardrail(
90
+ state,
91
+ state.current_phase !== "refactor",
92
+ `Cannot execute refactor: current_phase must be 'refactor'. Current phase: '${state.current_phase}'.`,
93
+ { force },
94
+ );
95
+
96
+ // AC03: Reject if refactor_plan.status !== "approved"
97
+ await assertGuardrail(
98
+ state,
99
+ state.phases.refactor.refactor_plan.status !== "approved",
100
+ `Cannot execute refactor: refactor_plan.status must be 'approved'. Current status: '${state.phases.refactor.refactor_plan.status}'. Run \`bun nvst approve refactor-plan\` first.`,
101
+ { force },
102
+ );
103
+
104
+ // AC04: Reject if refactor_execution.status is already "completed"
105
+ await assertGuardrail(
106
+ state,
107
+ state.phases.refactor.refactor_execution.status === "completed",
108
+ "Cannot execute refactor: refactor_execution.status is already 'completed'.",
109
+ { force },
110
+ );
111
+
112
+ // AC05: Read and validate refactor-prd.json
113
+ const iteration = state.current_iteration;
114
+ const refactorPrdFileName = `it_${iteration}_refactor-prd.json`;
115
+ const refactorPrdPath = join(projectRoot, FLOW_REL_DIR, refactorPrdFileName);
116
+
117
+ if (!(await mergedDeps.existsFn(refactorPrdPath))) {
118
+ throw new Error(
119
+ `Refactor PRD file missing: expected ${join(FLOW_REL_DIR, refactorPrdFileName)}. Run \`bun nvst approve refactor-plan\` first.`,
120
+ );
121
+ }
122
+
123
+ let parsedPrd: unknown;
124
+ try {
125
+ parsedPrd = JSON.parse(await mergedDeps.readFileFn(refactorPrdPath, "utf8"));
126
+ } catch {
127
+ throw new Error(
128
+ `Invalid refactor PRD JSON in ${join(FLOW_REL_DIR, refactorPrdFileName)}.`,
129
+ );
130
+ }
131
+
132
+ const prdValidation = RefactorPrdSchema.safeParse(parsedPrd);
133
+ if (!prdValidation.success) {
134
+ throw new Error(
135
+ `Refactor PRD schema mismatch in ${join(FLOW_REL_DIR, refactorPrdFileName)}.`,
136
+ );
137
+ }
138
+
139
+ const refactorItems = prdValidation.data.refactorItems;
140
+
141
+ // Load skill
142
+ let skillTemplate: string;
143
+ try {
144
+ skillTemplate = await mergedDeps.loadSkillFn(projectRoot, "execute-refactor-item");
145
+ } catch {
146
+ throw new Error(
147
+ "Required skill missing: expected .agents/skills/execute-refactor-item/SKILL.md.",
148
+ );
149
+ }
150
+
151
+ // AC13: Progress file name
152
+ const progressFileName = `it_${iteration}_refactor-execution-progress.json`;
153
+ const progressPath = join(projectRoot, FLOW_REL_DIR, progressFileName);
154
+
155
+ // AC06: Set refactor_execution.status = "in_progress" before processing
156
+ // AC13: Set refactor_execution.file
157
+ state.phases.refactor.refactor_execution.status = "in_progress";
158
+ state.phases.refactor.refactor_execution.file = progressFileName;
159
+ state.last_updated = mergedDeps.nowFn().toISOString();
160
+ state.updated_by = "nvst:execute-refactor";
161
+ await writeState(projectRoot, state);
162
+
163
+ // Initialize or load progress file
164
+ let progressData: RefactorExecutionProgress;
165
+
166
+ if (await mergedDeps.existsFn(progressPath)) {
167
+ let parsedProgress: unknown;
168
+ try {
169
+ parsedProgress = JSON.parse(await mergedDeps.readFileFn(progressPath, "utf8"));
170
+ } catch {
171
+ throw new Error(
172
+ `Invalid progress JSON in ${join(FLOW_REL_DIR, progressFileName)}.`,
173
+ );
174
+ }
175
+
176
+ const progressValidation = RefactorExecutionProgressSchema.safeParse(parsedProgress);
177
+ if (!progressValidation.success) {
178
+ throw new Error(
179
+ `Progress schema mismatch in ${join(FLOW_REL_DIR, progressFileName)}.`,
180
+ );
181
+ }
182
+
183
+ // AC05: Verify progress item IDs match refactor PRD item IDs
184
+ const expectedIds = [...refactorItems.map((item) => item.id)].sort((a, b) => a.localeCompare(b));
185
+ const existingIds = [...progressValidation.data.entries.map((entry) => entry.id)].sort((a, b) => a.localeCompare(b));
186
+ if (
187
+ expectedIds.length !== existingIds.length ||
188
+ expectedIds.some((id, i) => id !== existingIds[i])
189
+ ) {
190
+ throw new Error(
191
+ "Refactor execution progress file out of sync: entry ids do not match refactor PRD item ids.",
192
+ );
193
+ }
194
+
195
+ progressData = progressValidation.data;
196
+ } else {
197
+ const now = mergedDeps.nowFn().toISOString();
198
+ progressData = {
199
+ entries: refactorItems.map((item) => ({
200
+ id: item.id,
201
+ title: item.title,
202
+ status: "pending" as const,
203
+ attempt_count: 0,
204
+ last_agent_exit_code: null,
205
+ updated_at: now,
206
+ })),
207
+ };
208
+ const writeResult = await mergedDeps.invokeWriteJsonFn(
209
+ projectRoot,
210
+ "refactor-execution-progress",
211
+ join(FLOW_REL_DIR, progressFileName),
212
+ JSON.stringify(progressData),
213
+ );
214
+ if (writeResult.exitCode !== 0) {
215
+ throw new Error(
216
+ `Failed to write refactor execution progress: ${writeResult.stderr || "write-json exited non-zero"}.`,
217
+ );
218
+ }
219
+ }
220
+
221
+ // AC07, AC08, AC09, AC10: Process each item in order
222
+ for (const item of refactorItems) {
223
+ const entry = progressData.entries.find((e) => e.id === item.id);
224
+ if (!entry || entry.status === "completed") {
225
+ continue;
226
+ }
227
+
228
+ // Set current item to in_progress before invoking agent (FR-4; observability on interrupt)
229
+ entry.status = "in_progress";
230
+ entry.updated_at = mergedDeps.nowFn().toISOString();
231
+ const writeInProgressResult = await mergedDeps.invokeWriteJsonFn(
232
+ projectRoot,
233
+ "refactor-execution-progress",
234
+ join(FLOW_REL_DIR, progressFileName),
235
+ JSON.stringify(progressData),
236
+ );
237
+ if (writeInProgressResult.exitCode !== 0) {
238
+ throw new Error(
239
+ `Failed to write refactor execution progress: ${writeInProgressResult.stderr || "write-json exited non-zero"}.`,
240
+ );
241
+ }
242
+
243
+ // AC07: Build prompt with skill and item context (FR-6 variable names)
244
+ const prompt = buildPrompt(skillTemplate, {
245
+ current_iteration: iteration,
246
+ item_id: item.id,
247
+ item_title: item.title,
248
+ item_description: item.description,
249
+ item_rationale: item.rationale,
250
+ });
251
+
252
+ // AC08: Invoke agent in interactive mode
253
+ const agentResult = await mergedDeps.invokeAgentFn({
254
+ provider: opts.provider,
255
+ prompt,
256
+ cwd: projectRoot,
257
+ interactive: true,
258
+ });
259
+
260
+ // AC09 & AC10: Record result after each invocation, continue on failure
261
+ const succeeded = agentResult.exitCode === 0;
262
+ entry.status = succeeded ? "completed" : "failed";
263
+ entry.attempt_count = entry.attempt_count + 1;
264
+ entry.last_agent_exit_code = agentResult.exitCode;
265
+ entry.updated_at = mergedDeps.nowFn().toISOString();
266
+
267
+ const writeResult = await mergedDeps.invokeWriteJsonFn(
268
+ projectRoot,
269
+ "refactor-execution-progress",
270
+ join(FLOW_REL_DIR, progressFileName),
271
+ JSON.stringify(progressData),
272
+ );
273
+ if (writeResult.exitCode !== 0) {
274
+ throw new Error(
275
+ `Failed to write refactor execution progress: ${writeResult.stderr || "write-json exited non-zero"}.`,
276
+ );
277
+ }
278
+
279
+ mergedDeps.logFn(
280
+ `iteration=it_${iteration} item=${item.id} outcome=${entry.status}`,
281
+ );
282
+ }
283
+
284
+ // US-003: Generate markdown execution report (written regardless of failures)
285
+ const reportFileName = `it_${iteration}_refactor-execution-report.md`;
286
+ const reportPath = join(projectRoot, FLOW_REL_DIR, reportFileName);
287
+ const reportContent = buildRefactorExecutionReport(iteration, progressData);
288
+ await mergedDeps.writeFileFn(reportPath, reportContent, "utf8");
289
+
290
+ // AC11 & AC12: Update state based on overall result
291
+ const allCompleted = progressData.entries.every((entry) => entry.status === "completed");
292
+
293
+ if (allCompleted) {
294
+ // AC11: All completed → set status to "completed"
295
+ state.phases.refactor.refactor_execution.status = "completed";
296
+ }
297
+ // AC12: Any failure → stays "in_progress" (already set above)
298
+
299
+ state.last_updated = mergedDeps.nowFn().toISOString();
300
+ state.updated_by = "nvst:execute-refactor";
301
+ await writeState(projectRoot, state);
302
+
303
+ if (allCompleted) {
304
+ mergedDeps.logFn("Refactor execution completed for all items.");
305
+ } else {
306
+ mergedDeps.logFn("Refactor execution paused with remaining pending or failed items.");
307
+ }
308
+ }
309
+
310
+ export function buildRefactorExecutionReport(
311
+ iteration: string,
312
+ progress: RefactorExecutionProgress,
313
+ ): string {
314
+ const total = progress.entries.length;
315
+ const completed = progress.entries.filter((e) => e.status === "completed").length;
316
+ const failed = progress.entries.filter((e) => e.status === "failed").length;
317
+
318
+ const tableRows = progress.entries
319
+ .map((e) => {
320
+ const exitCode = e.last_agent_exit_code === null ? "N/A" : String(e.last_agent_exit_code);
321
+ return `| ${e.id} | ${e.title} | ${e.status} | ${exitCode} |`;
322
+ })
323
+ .join("\n");
324
+
325
+ return `# Refactor Execution Report
326
+
327
+ **Iteration:** it_${iteration}
328
+ **Total:** ${total}
329
+ **Completed:** ${completed}
330
+ **Failed:** ${failed}
331
+
332
+ | RI ID | Title | Status | Agent Exit Code |
333
+ |-------|-------|--------|-----------------|
334
+ ${tableRows}
335
+ `;
336
+ }
@@ -114,7 +114,7 @@ describe("execute test-plan command", () => {
114
114
  expect(source).toContain("if (command === \"execute\") {");
115
115
  expect(source).toContain('if (subcommand === "test-plan") {');
116
116
  expect(source).toContain("const { provider, remainingArgs: postAgentArgs } = parseAgentArg(args.slice(1));");
117
- expect(source).toContain("await runExecuteTestPlan({ provider });");
117
+ expect(source).toContain("await runExecuteTestPlan({ provider, force });");
118
118
  expect(source).toContain("execute test-plan --agent <provider>");
119
119
  });
120
120
 
@@ -324,6 +324,7 @@ describe("execute test-plan command", () => {
324
324
 
325
325
  const state = await readState(projectRoot);
326
326
  expect(state.phases.prototype.test_execution.status).toBe("completed");
327
+ expect(state.phases.prototype.prototype_approved).toBe(true);
327
328
  expect(state.updated_by).toBe("nvst:execute-test-plan");
328
329
  });
329
330
 
@@ -537,6 +538,10 @@ describe("execute test-plan command", () => {
537
538
  expect(rerunBatchPrompt).not.toContain("TC-US001-01");
538
539
  });
539
540
 
541
+ // After retry, all pass -> prototype approved
542
+ const stateAfterRetry = await readState(projectRoot);
543
+ expect(stateAfterRetry.phases.prototype.prototype_approved).toBe(true);
544
+
540
545
  const progressRaw = await readFile(
541
546
  join(projectRoot, ".agents", "flow", "it_000005_test-execution-progress.json"),
542
547
  "utf8",
@@ -1736,10 +1741,11 @@ describe("US-004: preserve report and state tracking compatibility", () => {
1736
1741
  expect(stateSnapshots[0]!.status).toBe("in_progress");
1737
1742
  expect(stateSnapshots[0]!.file).toBe("it_000005_test-execution-progress.json");
1738
1743
 
1739
- // After execution (all passed): completed
1744
+ // After execution (all passed): completed and prototype approved
1740
1745
  const finalState = await readState(projectRoot);
1741
1746
  expect(finalState.phases.prototype.test_execution.status).toBe("completed");
1742
1747
  expect(finalState.phases.prototype.test_execution.file).toBe("it_000005_test-execution-progress.json");
1748
+ expect(finalState.phases.prototype.prototype_approved).toBe(true);
1743
1749
  expect(finalState.updated_by).toBe("nvst:execute-test-plan");
1744
1750
  });
1745
1751
 
@@ -1779,6 +1785,7 @@ describe("US-004: preserve report and state tracking compatibility", () => {
1779
1785
 
1780
1786
  const finalState = await readState(projectRoot);
1781
1787
  expect(finalState.phases.prototype.test_execution.status).toBe("failed");
1788
+ expect(finalState.phases.prototype.prototype_approved).toBe(false);
1782
1789
  expect(finalState.phases.prototype.test_execution.file).toBe("it_000005_test-execution-progress.json");
1783
1790
  expect(finalState.updated_by).toBe("nvst:execute-test-plan");
1784
1791
  });
@@ -11,12 +11,14 @@ import {
11
11
  type AgentProvider,
12
12
  type AgentResult,
13
13
  } from "../agent";
14
+ import { assertGuardrail } from "../guardrail";
14
15
  import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
15
- import { TestPlanSchema, type TestPlan } from "../../schemas/test-plan";
16
+ import { TestPlanSchema, type TestPlan } from "../../scaffold/schemas/tmpl_test-plan";
16
17
  import { extractJson } from "./create-issue";
17
18
 
18
19
  export interface ExecuteTestPlanOptions {
19
20
  provider: AgentProvider;
21
+ force?: boolean;
20
22
  }
21
23
 
22
24
  const ExecutionPayloadSchema = z.object({
@@ -321,13 +323,15 @@ export async function runExecuteTestPlan(
321
323
  const projectRoot = process.cwd();
322
324
  const mergedDeps: ExecuteTestPlanDeps = { ...defaultDeps, ...deps };
323
325
  const state = await readState(projectRoot);
326
+ const force = opts.force ?? false;
324
327
 
325
328
  const tpGeneration = state.phases.prototype.tp_generation;
326
- if (tpGeneration.status !== "created") {
327
- throw new Error(
328
- `Cannot execute test plan: prototype.tp_generation.status must be created. Current status: '${tpGeneration.status}'. Run \`bun nvst approve test-plan\` first.`,
329
- );
330
- }
329
+ await assertGuardrail(
330
+ state,
331
+ tpGeneration.status !== "created",
332
+ `Cannot execute test plan: prototype.tp_generation.status must be created. Current status: '${tpGeneration.status}'. Run \`bun nvst approve test-plan\` first.`,
333
+ { force },
334
+ );
331
335
 
332
336
  if (!tpGeneration.file) {
333
337
  throw new Error("Cannot execute test plan: prototype.tp_generation.file is missing.");
@@ -710,6 +714,9 @@ export async function runExecuteTestPlan(
710
714
  const hasFailedTests = progress.entries.some((entry) => entry.status === "failed");
711
715
  state.phases.prototype.test_execution.status = hasFailedTests ? "failed" : "completed";
712
716
  state.phases.prototype.test_execution.file = progressFileName;
717
+ if (!hasFailedTests) {
718
+ state.phases.prototype.prototype_approved = true;
719
+ }
713
720
  state.last_updated = mergedDeps.nowFn().toISOString();
714
721
  state.updated_by = "nvst:execute-test-plan";
715
722
  await writeState(projectRoot, state);
@@ -2,26 +2,28 @@ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { buildPrompt, invokeAgent, loadSkill, type AgentProvider } from "../agent";
5
+ import { assertGuardrail } from "../guardrail";
5
6
  import { exists, readState, writeState } from "../state";
6
7
 
7
8
  export interface RefineProjectContextOptions {
8
9
  provider: AgentProvider;
9
10
  challenge: boolean;
11
+ force?: boolean;
10
12
  }
11
13
 
12
14
  export async function runRefineProjectContext(opts: RefineProjectContextOptions): Promise<void> {
13
- const { provider, challenge } = opts;
15
+ const { provider, challenge, force = false } = opts;
14
16
  const projectRoot = process.cwd();
15
17
  const state = await readState(projectRoot);
16
18
 
17
19
  // US-003-AC01: Validate status is pending_approval or created
18
20
  const projectContext = state.phases.prototype.project_context;
19
- if (projectContext.status !== "pending_approval" && projectContext.status !== "created") {
20
- throw new Error(
21
- `Cannot refine project context from status '${projectContext.status}'. ` +
22
- "Expected pending_approval or created.",
23
- );
24
- }
21
+ await assertGuardrail(
22
+ state,
23
+ projectContext.status !== "pending_approval" && projectContext.status !== "created",
24
+ `Cannot refine project context from status '${projectContext.status}'. Expected pending_approval or created.`,
25
+ { force },
26
+ );
25
27
 
26
28
  // Validate file reference exists in state
27
29
  const contextFile = projectContext.file;
@@ -0,0 +1,210 @@
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 { runRefineRefactorPlan } from "./refine-refactor-plan";
9
+
10
+ async function createProjectRoot(): Promise<string> {
11
+ return mkdtemp(join(tmpdir(), "nvst-refine-refactor-plan-"));
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
+ status: "pending" | "pending_approval" | "approved",
27
+ file: string | null,
28
+ ): Promise<void> {
29
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
30
+
31
+ await writeState(projectRoot, {
32
+ current_iteration: "000013",
33
+ current_phase: "refactor",
34
+ phases: {
35
+ define: {
36
+ requirement_definition: { status: "approved", file: "it_000013_product-requirement-document.md" },
37
+ prd_generation: { status: "completed", file: "it_000013_PRD.json" },
38
+ },
39
+ prototype: {
40
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
41
+ test_plan: { status: "created", file: "it_000013_test-plan.md" },
42
+ tp_generation: { status: "created", file: "it_000013_TEST-PLAN.json" },
43
+ prototype_build: { status: "created", file: "it_000013_progress.json" },
44
+ test_execution: { status: "completed", file: "it_000013_test-execution-report.json" },
45
+ prototype_approved: true,
46
+ },
47
+ refactor: {
48
+ evaluation_report: { status: "created", file: "it_000013_evaluation-report.md" },
49
+ refactor_plan: { status, file },
50
+ refactor_execution: { status: "pending", file: null },
51
+ changelog: { status: "pending", file: null },
52
+ },
53
+ },
54
+ last_updated: "2026-02-26T00:00:00.000Z",
55
+ updated_by: "seed",
56
+ history: [],
57
+ });
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("refine refactor-plan command", () => {
67
+ test("registers refine refactor-plan command in CLI dispatch", async () => {
68
+ const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
69
+
70
+ expect(source).toContain('import { runRefineRefactorPlan } from "./commands/refine-refactor-plan";');
71
+ expect(source).toContain('if (subcommand === "refactor-plan") {');
72
+ expect(source).toContain('const challenge = postForceArgs.includes("--challenge");');
73
+ expect(source).toContain("await runRefineRefactorPlan({ provider, challenge, force });");
74
+ });
75
+
76
+ test("requires refactor.refactor_plan.status to be pending_approval", async () => {
77
+ const projectRoot = await createProjectRoot();
78
+ createdRoots.push(projectRoot);
79
+ await seedState(projectRoot, "approved", "it_000013_refactor-plan.md");
80
+
81
+ await withCwd(projectRoot, async () => {
82
+ await expect(
83
+ runRefineRefactorPlan(
84
+ { provider: "codex", challenge: false },
85
+ {
86
+ loadSkillFn: async () => "unused",
87
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
88
+ },
89
+ ),
90
+ ).rejects.toThrow(
91
+ "Cannot refine refactor plan from status 'approved'. Expected pending_approval.",
92
+ );
93
+ });
94
+ });
95
+
96
+ test("rejects when refactor.refactor_plan.file is missing", async () => {
97
+ const projectRoot = await createProjectRoot();
98
+ createdRoots.push(projectRoot);
99
+ await seedState(projectRoot, "pending_approval", null);
100
+
101
+ await withCwd(projectRoot, async () => {
102
+ await expect(
103
+ runRefineRefactorPlan(
104
+ { provider: "codex", challenge: false },
105
+ {
106
+ loadSkillFn: async () => "unused",
107
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
108
+ },
109
+ ),
110
+ ).rejects.toThrow("Cannot refine refactor plan: refactor.refactor_plan.file is missing.");
111
+ });
112
+ });
113
+
114
+ test("rejects when refactor plan file does not exist on disk", async () => {
115
+ const projectRoot = await createProjectRoot();
116
+ createdRoots.push(projectRoot);
117
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
118
+
119
+ await withCwd(projectRoot, async () => {
120
+ await expect(
121
+ runRefineRefactorPlan(
122
+ { provider: "codex", challenge: false },
123
+ {
124
+ loadSkillFn: async () => "unused",
125
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
126
+ },
127
+ ),
128
+ ).rejects.toThrow("Cannot refine refactor plan: file not found at");
129
+ });
130
+ });
131
+
132
+ test("loads refine-refactor-plan skill, reads file context, invokes interactively, and does not mutate state", async () => {
133
+ const projectRoot = await createProjectRoot();
134
+ createdRoots.push(projectRoot);
135
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
136
+
137
+ const refactorPlanPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-plan.md");
138
+ await writeFile(refactorPlanPath, "# Current Refactor Plan\n- Refactor module A\n", "utf8");
139
+
140
+ let loadedSkill = "";
141
+ let invocation: { interactive: boolean | undefined; prompt: string } | undefined;
142
+ const stateBefore = JSON.stringify(await readState(projectRoot));
143
+
144
+ await withCwd(projectRoot, async () => {
145
+ await runRefineRefactorPlan(
146
+ { provider: "codex", challenge: false },
147
+ {
148
+ loadSkillFn: async (_root, skillName) => {
149
+ loadedSkill = skillName;
150
+ return "Refine refactor plan skill";
151
+ },
152
+ invokeAgentFn: async (options): Promise<AgentResult> => {
153
+ invocation = {
154
+ interactive: options.interactive,
155
+ prompt: options.prompt,
156
+ };
157
+ return { exitCode: 0, stdout: "", stderr: "" };
158
+ },
159
+ },
160
+ );
161
+ });
162
+
163
+ expect(loadedSkill).toBe("refine-refactor-plan");
164
+ if (invocation === undefined) {
165
+ throw new Error("Agent invocation was not captured");
166
+ }
167
+
168
+ expect(invocation.interactive).toBe(true);
169
+ expect(invocation.prompt).toContain("### current_iteration");
170
+ expect(invocation.prompt).toContain("000013");
171
+ expect(invocation.prompt).toContain("### refactor_plan_file");
172
+ expect(invocation.prompt).toContain("it_000013_refactor-plan.md");
173
+ expect(invocation.prompt).toContain("### refactor_plan_content");
174
+ expect(invocation.prompt).toContain("# Current Refactor Plan");
175
+
176
+ const stateAfter = JSON.stringify(await readState(projectRoot));
177
+ expect(stateAfter).toBe(stateBefore);
178
+ });
179
+
180
+ test("passes mode=challenger in prompt context when challenge mode is enabled", async () => {
181
+ const projectRoot = await createProjectRoot();
182
+ createdRoots.push(projectRoot);
183
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
184
+
185
+ const refactorPlanPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-plan.md");
186
+ await writeFile(refactorPlanPath, "# Current Refactor Plan\n- Refactor module B\n", "utf8");
187
+
188
+ let invocationPrompt = "";
189
+ const stateBefore = JSON.stringify(await readState(projectRoot));
190
+
191
+ await withCwd(projectRoot, async () => {
192
+ await runRefineRefactorPlan(
193
+ { provider: "codex", challenge: true },
194
+ {
195
+ loadSkillFn: async () => "Refine refactor plan skill",
196
+ invokeAgentFn: async (options): Promise<AgentResult> => {
197
+ invocationPrompt = options.prompt;
198
+ return { exitCode: 0, stdout: "", stderr: "" };
199
+ },
200
+ },
201
+ );
202
+ });
203
+
204
+ expect(invocationPrompt).toContain("### mode");
205
+ expect(invocationPrompt).toContain("challenger");
206
+
207
+ const stateAfter = JSON.stringify(await readState(projectRoot));
208
+ expect(stateAfter).toBe(stateBefore);
209
+ });
210
+ });