@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
@@ -1,59 +1,142 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { $ as dollar } from "bun";
4
- import { z } from "zod";
5
4
 
6
5
  import { PrdSchema } from "../../scaffold/schemas/tmpl_prd";
6
+ import {
7
+ PrototypeProgressSchema,
8
+ type PrototypeProgress,
9
+ } from "../../scaffold/schemas/tmpl_prototype-progress";
7
10
  import {
8
11
  buildPrompt,
9
12
  invokeAgent,
10
13
  loadSkill,
14
+ type AgentInvokeOptions,
11
15
  type AgentProvider,
16
+ type AgentResult,
12
17
  } from "../agent";
18
+ import { assertGuardrail } from "../guardrail";
19
+ import { defaultReadLine } from "../readline";
20
+ import { idsMatchExactly, sortedValues } from "../progress-utils";
13
21
  import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
22
+ import { writeJsonArtifact, type WriteJsonArtifactFn } from "../write-json-artifact";
14
23
 
15
24
  export interface CreatePrototypeOptions {
16
25
  provider: AgentProvider;
17
26
  iterations?: number;
18
27
  retryOnFail?: number;
19
28
  stopOnCritical?: boolean;
29
+ force?: boolean;
30
+ }
31
+
32
+ const DECLINE_DIRTY_TREE_ABORT_MESSAGE = "Aborted. Commit or discard your changes and re-run `bun nvst create prototype`.";
33
+ const DIRTY_TREE_COMMIT_PROMPT = "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]";
34
+
35
+ interface CreatePrototypeDeps {
36
+ invokeAgentFn: (options: AgentInvokeOptions) => Promise<AgentResult>;
37
+ loadSkillFn: (projectRoot: string, skillName: string) => Promise<string>;
38
+ checkGhAvailableFn: (projectRoot: string) => Promise<boolean>;
39
+ createPullRequestFn: (
40
+ projectRoot: string,
41
+ title: string,
42
+ body: string,
43
+ ) => Promise<{ exitCode: number; stderr: string }>;
44
+ logFn: (message: string) => void;
45
+ warnFn: (message: string) => void;
46
+ promptDirtyTreeCommitFn: (question: string) => Promise<boolean>;
47
+ gitAddAndCommitFn: (projectRoot: string, commitMessage: string) => Promise<void>;
48
+ writeJsonArtifactFn: WriteJsonArtifactFn;
49
+ }
50
+
51
+ const defaultDeps: CreatePrototypeDeps = {
52
+ invokeAgentFn: invokeAgent,
53
+ loadSkillFn: loadSkill,
54
+ checkGhAvailableFn: async (projectRoot) => {
55
+ const proc = Bun.spawn(["gh", "--version"], {
56
+ cwd: projectRoot,
57
+ stdout: "ignore",
58
+ stderr: "ignore",
59
+ });
60
+ return (await proc.exited) === 0;
61
+ },
62
+ createPullRequestFn: async (projectRoot, title, body) => {
63
+ const result = await dollar`gh pr create --title ${title} --body ${body}`
64
+ .cwd(projectRoot)
65
+ .nothrow()
66
+ .quiet();
67
+ return {
68
+ exitCode: result.exitCode,
69
+ stderr: result.stderr.toString().trim(),
70
+ };
71
+ },
72
+ logFn: console.log,
73
+ warnFn: console.warn,
74
+ promptDirtyTreeCommitFn: promptForDirtyTreeCommit,
75
+ gitAddAndCommitFn: runGitAddAndCommit,
76
+ writeJsonArtifactFn: writeJsonArtifact,
77
+ };
78
+
79
+ type ReadLineFn = () => Promise<string | null>;
80
+ type WriteFn = (message: string) => void;
81
+ type IsTTYFn = () => boolean;
82
+
83
+ function defaultWrite(message: string): void {
84
+ process.stdout.write(`${message}\n`);
20
85
  }
21
86
 
22
- const ProgressEntrySchema = z.object({
23
- use_case_id: z.string(),
24
- status: z.enum(["pending", "failed", "completed"]),
25
- attempt_count: z.number().int().nonnegative(),
26
- last_agent_exit_code: z.number().int().nullable(),
27
- quality_checks: z.array(
28
- z.object({
29
- command: z.string(),
30
- exit_code: z.number().int(),
31
- }),
32
- ),
33
- last_error_summary: z.string(),
34
- updated_at: z.string(),
35
- });
36
-
37
- export const PrototypeProgressSchema = z.object({
38
- entries: z.array(ProgressEntrySchema),
39
- });
40
-
41
- function sortedValues(values: string[]): string[] {
42
- return [...values].sort((a, b) => a.localeCompare(b));
87
+ function defaultIsTTY(): boolean {
88
+ return process.stdin.isTTY === true;
43
89
  }
44
90
 
45
- function idsMatchExactly(left: string[], right: string[]): boolean {
46
- if (left.length !== right.length) {
91
+ export async function promptForDirtyTreeCommit(
92
+ question: string,
93
+ readLineFn: ReadLineFn = defaultReadLine,
94
+ writeFn: WriteFn = defaultWrite,
95
+ isTTYFn: IsTTYFn = defaultIsTTY,
96
+ ): Promise<boolean> {
97
+ if (!isTTYFn()) {
47
98
  return false;
48
99
  }
49
100
 
50
- for (let i = 0; i < left.length; i += 1) {
51
- if (left[i] !== right[i]) {
52
- return false;
53
- }
101
+ writeFn(question);
102
+
103
+ let line: string | null;
104
+ try {
105
+ line = await readLineFn();
106
+ } catch {
107
+ line = null;
108
+ }
109
+
110
+ return line !== null && (line.trim() === "y" || line.trim() === "Y");
111
+ }
112
+
113
+ async function runGitAddAndCommit(projectRoot: string, commitMessage: string): Promise<void> {
114
+ const addResult = await dollar`git add -A`.cwd(projectRoot).nothrow().quiet();
115
+ if (addResult.exitCode !== 0) {
116
+ throw new Error(`Pre-prototype commit failed:\n${addResult.stderr.toString().trim()}`);
54
117
  }
55
118
 
56
- return true;
119
+ const commitResult = await dollar`git commit -m ${commitMessage}`.cwd(projectRoot).nothrow().quiet();
120
+ if (commitResult.exitCode !== 0) {
121
+ throw new Error(`Pre-prototype commit failed:\n${commitResult.stderr.toString().trim()}`);
122
+ }
123
+ }
124
+
125
+ export async function runPrePrototypeCommit(
126
+ projectRoot: string,
127
+ iteration: string,
128
+ gitAddAndCommitFn: (projectRoot: string, commitMessage: string) => Promise<void> = runGitAddAndCommit,
129
+ ): Promise<void> {
130
+ const commitMessage = `chore: pre-prototype commit it_${iteration}`;
131
+ try {
132
+ await gitAddAndCommitFn(projectRoot, commitMessage);
133
+ } catch (error) {
134
+ const reason = error instanceof Error ? error.message : String(error);
135
+ if (reason.startsWith("Pre-prototype commit failed:\n")) {
136
+ throw error;
137
+ }
138
+ throw new Error(`Pre-prototype commit failed:\n${reason}`);
139
+ }
57
140
  }
58
141
 
59
142
  function parseQualityChecks(projectContextContent: string): string[] {
@@ -103,9 +186,14 @@ function parseQualityChecks(projectContextContent: string): string[] {
103
186
  .filter((line) => line.length > 0);
104
187
  }
105
188
 
106
- export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<void> {
189
+ export async function runCreatePrototype(
190
+ opts: CreatePrototypeOptions,
191
+ deps: Partial<CreatePrototypeDeps> = {},
192
+ ): Promise<void> {
193
+ const mergedDeps: CreatePrototypeDeps = { ...defaultDeps, ...deps };
107
194
  const projectRoot = process.cwd();
108
195
  const state = await readState(projectRoot);
196
+ const force = opts.force ?? false;
109
197
 
110
198
  if (opts.iterations !== undefined && (!Number.isInteger(opts.iterations) || opts.iterations < 1)) {
111
199
  throw new Error(
@@ -148,6 +236,8 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
148
236
  );
149
237
  }
150
238
 
239
+ let prePrototypeCommitDone = false;
240
+
151
241
  if (state.current_phase === "define") {
152
242
  if (
153
243
  state.phases.define.prd_generation.status === "completed" &&
@@ -160,39 +250,59 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
160
250
  );
161
251
  }
162
252
  if (workingTreeBeforeTransition.stdout.toString().trim().length > 0) {
163
- throw new Error(
164
- "Git working tree is dirty. Commit your changes or discard them before running `bun nvst create prototype` again.",
253
+ const shouldProceed = await mergedDeps.promptDirtyTreeCommitFn(
254
+ DIRTY_TREE_COMMIT_PROMPT,
165
255
  );
256
+ if (!shouldProceed) {
257
+ mergedDeps.logFn(DECLINE_DIRTY_TREE_ABORT_MESSAGE);
258
+ return;
259
+ }
260
+ await runPrePrototypeCommit(projectRoot, iteration, mergedDeps.gitAddAndCommitFn);
261
+ prePrototypeCommitDone = true;
166
262
  }
167
263
  state.current_phase = "prototype";
168
264
  await writeState(projectRoot, state);
169
265
  } else {
170
- throw new Error(
266
+ await assertGuardrail(
267
+ state,
268
+ true,
171
269
  "Cannot create prototype: current_phase is define and prerequisites are not met. Complete define phase and run `bun nvst create project-context --agent <provider>` then `bun nvst approve project-context` first.",
270
+ { force },
172
271
  );
173
272
  }
174
273
  } else if (state.current_phase !== "prototype") {
175
- throw new Error(
274
+ await assertGuardrail(
275
+ state,
276
+ true,
176
277
  "Cannot create prototype: current_phase must be define (with approved PRD) or prototype. Complete define phase and run `bun nvst create project-context --agent <provider>` then `bun nvst approve project-context` first.",
278
+ { force },
177
279
  );
178
280
  }
179
281
 
180
- if (state.phases.prototype.project_context.status !== "created") {
181
- throw new Error(
182
- "Cannot create prototype: prototype.project_context.status must be created. Run `bun nvst create project-context --agent <provider>` and `bun nvst approve project-context` first.",
183
- );
184
- }
282
+ await assertGuardrail(
283
+ state,
284
+ state.phases.prototype.project_context.status !== "created",
285
+ "Cannot create prototype: prototype.project_context.status must be created. Run `bun nvst create project-context --agent <provider>` and `bun nvst approve project-context` first.",
286
+ { force },
287
+ );
185
288
 
186
- const workingTreeAfterPhase = await dollar`git status --porcelain`.cwd(projectRoot).nothrow().quiet();
187
- if (workingTreeAfterPhase.exitCode !== 0) {
188
- throw new Error(
189
- "Unable to verify git working tree status. Ensure this directory is a git repository and git is installed.",
190
- );
191
- }
192
- if (workingTreeAfterPhase.stdout.toString().trim().length > 0) {
193
- throw new Error(
194
- "Git working tree is dirty. Commit your changes or discard them before running `bun nvst create prototype` again.",
195
- );
289
+ if (!prePrototypeCommitDone) {
290
+ const workingTreeAfterPhase = await dollar`git status --porcelain`.cwd(projectRoot).nothrow().quiet();
291
+ if (workingTreeAfterPhase.exitCode !== 0) {
292
+ throw new Error(
293
+ "Unable to verify git working tree status. Ensure this directory is a git repository and git is installed.",
294
+ );
295
+ }
296
+ if (workingTreeAfterPhase.stdout.toString().trim().length > 0) {
297
+ const shouldProceed = await mergedDeps.promptDirtyTreeCommitFn(
298
+ DIRTY_TREE_COMMIT_PROMPT,
299
+ );
300
+ if (!shouldProceed) {
301
+ mergedDeps.logFn(DECLINE_DIRTY_TREE_ABORT_MESSAGE);
302
+ return;
303
+ }
304
+ await runPrePrototypeCommit(projectRoot, iteration, mergedDeps.gitAddAndCommitFn);
305
+ }
196
306
  }
197
307
 
198
308
  const branchName = `feature/it_${iteration}`;
@@ -220,7 +330,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
220
330
  const progressFileName = `it_${iteration}_progress.json`;
221
331
  const progressPath = join(projectRoot, FLOW_REL_DIR, progressFileName);
222
332
  const storyIds = sortedValues(prdValidation.data.userStories.map((story) => story.id));
223
- let progressData: z.infer<typeof PrototypeProgressSchema>;
333
+ let progressData: PrototypeProgress;
224
334
 
225
335
  if (await exists(progressPath)) {
226
336
  let parsedProgress: unknown;
@@ -263,7 +373,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
263
373
  })),
264
374
  };
265
375
 
266
- await writeFile(progressPath, `${JSON.stringify(progress, null, 2)}\n`, "utf8");
376
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progress);
267
377
  progressData = progress;
268
378
  }
269
379
 
@@ -273,7 +383,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
273
383
  });
274
384
 
275
385
  if (eligibleStories.length === 0) {
276
- console.log("No pending or failed user stories to implement. Exiting without changes.");
386
+ mergedDeps.logFn("No pending or failed user stories to implement. Exiting without changes.");
277
387
  return;
278
388
  }
279
389
 
@@ -285,7 +395,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
285
395
 
286
396
  let skillTemplate: string;
287
397
  try {
288
- skillTemplate = await loadSkill(projectRoot, "implement-user-story");
398
+ skillTemplate = await mergedDeps.loadSkillFn(projectRoot, "implement-user-story");
289
399
  } catch {
290
400
  throw new Error(
291
401
  "Required skill missing: expected .agents/skills/implement-user-story/SKILL.md.",
@@ -324,7 +434,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
324
434
  user_story: JSON.stringify(story, null, 2),
325
435
  });
326
436
 
327
- const agentResult = await invokeAgent({
437
+ const agentResult = await mergedDeps.invokeAgentFn({
328
438
  provider: opts.provider,
329
439
  prompt,
330
440
  cwd: projectRoot,
@@ -358,7 +468,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
358
468
  entry.last_error_summary = "Agent or quality check failed";
359
469
  }
360
470
 
361
- await writeFile(progressPath, `${JSON.stringify(progressData, null, 2)}\n`, "utf8");
471
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progressData);
362
472
 
363
473
  if (allPassed) {
364
474
  const commitMessage = `feat: implement ${story.id} - ${story.title}`;
@@ -371,9 +481,9 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
371
481
  entry.status = "failed";
372
482
  entry.last_error_summary = "Git commit failed";
373
483
  entry.updated_at = new Date().toISOString();
374
- await writeFile(progressPath, `${JSON.stringify(progressData, null, 2)}\n`, "utf8");
484
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progressData);
375
485
 
376
- console.log(
486
+ mergedDeps.logFn(
377
487
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=commit_failed`,
378
488
  );
379
489
 
@@ -381,7 +491,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
381
491
  haltedByCritical = true;
382
492
  }
383
493
  } else {
384
- console.log(
494
+ mergedDeps.logFn(
385
495
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=passed`,
386
496
  );
387
497
  }
@@ -389,7 +499,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
389
499
  break;
390
500
  }
391
501
 
392
- console.log(
502
+ mergedDeps.logFn(
393
503
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=failed`,
394
504
  );
395
505
 
@@ -413,13 +523,25 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
413
523
  await writeState(projectRoot, state);
414
524
 
415
525
  if (storiesAttempted === 0) {
416
- console.log("No user stories attempted.");
526
+ mergedDeps.logFn("No user stories attempted.");
417
527
  return;
418
528
  }
419
529
 
420
530
  if (allCompleted) {
421
- console.log("Prototype implementation completed for all user stories.");
531
+ const ghAvailable = await mergedDeps.checkGhAvailableFn(projectRoot);
532
+ if (!ghAvailable) {
533
+ mergedDeps.logFn("gh CLI not found — skipping PR creation");
534
+ } else {
535
+ const prTitle = `feat: prototype it_${iteration}`;
536
+ const prBody = `Prototype for iteration it_${iteration}`;
537
+ const prResult = await mergedDeps.createPullRequestFn(projectRoot, prTitle, prBody);
538
+ if (prResult.exitCode !== 0) {
539
+ const suffix = prResult.stderr.length > 0 ? `: ${prResult.stderr}` : "";
540
+ mergedDeps.warnFn(`gh pr create failed (non-fatal)${suffix}`);
541
+ }
542
+ }
543
+ mergedDeps.logFn("Prototype implementation completed for all user stories.");
422
544
  } else {
423
- console.log("Prototype implementation paused with remaining pending or failed stories.");
545
+ mergedDeps.logFn("Prototype implementation paused with remaining pending or failed stories.");
424
546
  }
425
547
  }
@@ -10,8 +10,9 @@ import {
10
10
  type AgentProvider,
11
11
  type AgentResult,
12
12
  } from "../agent";
13
+ import { assertGuardrail } from "../guardrail";
13
14
  import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
14
- import { TestPlanSchema, type TestPlan } from "../../schemas/test-plan";
15
+ import { TestPlanSchema, type TestPlan } from "../../scaffold/schemas/tmpl_test-plan";
15
16
 
16
17
  export interface CreateTestPlanOptions {
17
18
  provider: AgentProvider;
@@ -155,11 +156,12 @@ export async function runCreateTestPlan(
155
156
  const state = await readState(projectRoot);
156
157
  const mergedDeps: CreateTestPlanDeps = { ...defaultDeps, ...deps };
157
158
 
158
- if (state.phases.prototype.project_context.status !== "created") {
159
- throw new Error(
160
- "Cannot create test plan: prototype.project_context.status must be created. Run `bun nvst approve project-context` first.",
161
- );
162
- }
159
+ await assertGuardrail(
160
+ state,
161
+ state.phases.prototype.project_context.status !== "created",
162
+ "Cannot create test plan: prototype.project_context.status must be created. Run `bun nvst approve project-context` first.",
163
+ { force: opts.force },
164
+ );
163
165
 
164
166
  const iteration = state.current_iteration;
165
167
  const fileName = `it_${iteration}_test-plan.md`;
@@ -0,0 +1,208 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, readFile, rm } 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 { runDefineRefactorPlan } from "./define-refactor-plan";
9
+
10
+ async function createProjectRoot(): Promise<string> {
11
+ return mkdtemp(join(tmpdir(), "nvst-define-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
+ stateOverrides: {
27
+ currentPhase?: "define" | "prototype" | "refactor";
28
+ refactorPlanStatus?: "pending" | "pending_approval" | "approved";
29
+ prototypeApproved?: boolean;
30
+ } = {},
31
+ ): Promise<void> {
32
+ const currentPhase = stateOverrides.currentPhase ?? "refactor";
33
+ const refactorPlanStatus = stateOverrides.refactorPlanStatus ?? "pending";
34
+ const prototypeApproved = stateOverrides.prototypeApproved ?? true;
35
+
36
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
37
+
38
+ await writeState(projectRoot, {
39
+ current_iteration: "000013",
40
+ current_phase: currentPhase,
41
+ phases: {
42
+ define: {
43
+ requirement_definition: { status: "approved", file: "it_000013_product-requirement-document.md" },
44
+ prd_generation: { status: "completed", file: "it_000013_PRD.json" },
45
+ },
46
+ prototype: {
47
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
48
+ test_plan: { status: "created", file: "it_000013_test-plan.md" },
49
+ tp_generation: { status: "created", file: "it_000013_TEST-PLAN.json" },
50
+ prototype_build: { status: "created", file: "it_000013_progress.json" },
51
+ test_execution: { status: "completed", file: "it_000013_test-execution-report.json" },
52
+ prototype_approved: prototypeApproved,
53
+ },
54
+ refactor: {
55
+ evaluation_report: { status: "pending", file: null },
56
+ refactor_plan: { status: refactorPlanStatus, file: null },
57
+ refactor_execution: { status: "pending", file: null },
58
+ changelog: { status: "pending", file: null },
59
+ },
60
+ },
61
+ last_updated: "2026-02-26T00:00:00.000Z",
62
+ updated_by: "seed",
63
+ history: [],
64
+ });
65
+ }
66
+
67
+ const createdRoots: string[] = [];
68
+
69
+ afterEach(async () => {
70
+ await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
71
+ });
72
+
73
+ describe("define refactor-plan command", () => {
74
+ test("registers define refactor-plan command in CLI dispatch", async () => {
75
+ const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
76
+
77
+ expect(source).toContain('import { runDefineRefactorPlan } from "./commands/define-refactor-plan";');
78
+ expect(source).toContain('if (subcommand === "refactor-plan") {');
79
+ expect(source).toContain("await runDefineRefactorPlan({ provider, force });");
80
+ });
81
+
82
+ test("rejects when prototype_approved is false", async () => {
83
+ const projectRoot = await createProjectRoot();
84
+ createdRoots.push(projectRoot);
85
+ await seedState(projectRoot, { prototypeApproved: false });
86
+
87
+ await withCwd(projectRoot, async () => {
88
+ await expect(runDefineRefactorPlan({ provider: "codex" })).rejects.toThrow(
89
+ "Cannot define refactor plan: phases.prototype.prototype_approved must be true",
90
+ );
91
+ });
92
+ });
93
+
94
+ test("allows bypassing prototype_approved guard with force", async () => {
95
+ const projectRoot = await createProjectRoot();
96
+ createdRoots.push(projectRoot);
97
+ await seedState(projectRoot, { prototypeApproved: false });
98
+
99
+ await withCwd(projectRoot, async () => {
100
+ await runDefineRefactorPlan(
101
+ { provider: "codex", force: true },
102
+ {
103
+ loadSkillFn: async () => "Refactor planning instructions",
104
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
105
+ nowFn: () => new Date("2026-02-26T10:00:00.000Z"),
106
+ },
107
+ );
108
+ });
109
+
110
+ const state = await readState(projectRoot);
111
+ expect(state.phases.refactor.evaluation_report.status).toBe("created");
112
+ expect(state.phases.refactor.refactor_plan.status).toBe("pending_approval");
113
+ });
114
+
115
+ test("rejects when current_phase is define", async () => {
116
+ const projectRoot = await createProjectRoot();
117
+ createdRoots.push(projectRoot);
118
+ await seedState(projectRoot, { currentPhase: "define", prototypeApproved: true });
119
+
120
+ await withCwd(projectRoot, async () => {
121
+ await expect(runDefineRefactorPlan({ provider: "codex" })).rejects.toThrow(
122
+ "Cannot define refactor plan: current_phase must be 'prototype' or 'refactor'",
123
+ );
124
+ });
125
+ });
126
+
127
+ test("accepts when current_phase is prototype and prototype_approved is true, transitions to refactor", async () => {
128
+ const projectRoot = await createProjectRoot();
129
+ createdRoots.push(projectRoot);
130
+ await seedState(projectRoot, { currentPhase: "prototype" });
131
+
132
+ await withCwd(projectRoot, async () => {
133
+ await runDefineRefactorPlan(
134
+ { provider: "codex" },
135
+ {
136
+ loadSkillFn: async () => "Refactor planning instructions",
137
+ invokeAgentFn: async () => ({ exitCode: 0, stdout: "", stderr: "" }),
138
+ nowFn: () => new Date("2026-02-26T10:00:00.000Z"),
139
+ },
140
+ );
141
+ });
142
+
143
+ const state = await readState(projectRoot);
144
+ expect(state.current_phase).toBe("refactor");
145
+ expect(state.phases.refactor.evaluation_report.status).toBe("created");
146
+ expect(state.phases.refactor.refactor_plan.status).toBe("pending_approval");
147
+ });
148
+
149
+ test("rejects when refactor.refactor_plan.status is not pending", async () => {
150
+ const projectRoot = await createProjectRoot();
151
+ createdRoots.push(projectRoot);
152
+ await seedState(projectRoot, { refactorPlanStatus: "approved" });
153
+
154
+ await withCwd(projectRoot, async () => {
155
+ await expect(runDefineRefactorPlan({ provider: "codex" })).rejects.toThrow(
156
+ "Cannot define refactor plan from status 'approved'. Expected pending.",
157
+ );
158
+ });
159
+ });
160
+
161
+ test("loads plan-refactor skill, invokes interactive agent, and persists pending_approval state", async () => {
162
+ const projectRoot = await createProjectRoot();
163
+ createdRoots.push(projectRoot);
164
+ await seedState(projectRoot);
165
+
166
+ let loadedSkill = "";
167
+ let invocation: { interactive: boolean | undefined; prompt: string } | undefined;
168
+
169
+ await withCwd(projectRoot, async () => {
170
+ await runDefineRefactorPlan(
171
+ { provider: "codex" },
172
+ {
173
+ loadSkillFn: async (_root, skillName) => {
174
+ loadedSkill = skillName;
175
+ return "Refactor planning instructions from SKILL.md";
176
+ },
177
+ invokeAgentFn: async (options): Promise<AgentResult> => {
178
+ invocation = {
179
+ interactive: options.interactive,
180
+ prompt: options.prompt,
181
+ };
182
+ return { exitCode: 0, stdout: "", stderr: "" };
183
+ },
184
+ nowFn: () => new Date("2026-02-26T10:00:00.000Z"),
185
+ },
186
+ );
187
+ });
188
+
189
+ expect(loadedSkill).toBe("plan-refactor");
190
+ if (invocation === undefined) {
191
+ throw new Error("Agent invocation was not captured");
192
+ }
193
+ expect(invocation.interactive).toBe(true);
194
+ expect(invocation.prompt).toContain("Refactor planning instructions from SKILL.md");
195
+ expect(invocation.prompt).toContain("### current_iteration");
196
+ expect(invocation.prompt).toContain("000013");
197
+
198
+ const state = await readState(projectRoot);
199
+ expect(state.phases.refactor.evaluation_report.status).toBe("created");
200
+ expect(state.phases.refactor.evaluation_report.file).toBe(
201
+ "it_000013_evaluation-report.md",
202
+ );
203
+ expect(state.phases.refactor.refactor_plan.status).toBe("pending_approval");
204
+ expect(state.phases.refactor.refactor_plan.file).toBe("it_000013_refactor-plan.md");
205
+ expect(state.last_updated).toBe("2026-02-26T10:00:00.000Z");
206
+ expect(state.updated_by).toBe("nvst:define-refactor-plan");
207
+ });
208
+ });