@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,254 @@
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 { RefactorPrdSchema } from "../../scaffold/schemas/tmpl_refactor-prd";
7
+ import { readState, writeState } from "../state";
8
+ import { parseRefactorPlan, runApproveRefactorPlan } from "./approve-refactor-plan";
9
+
10
+ async function createProjectRoot(): Promise<string> {
11
+ return mkdtemp(join(tmpdir(), "nvst-approve-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
+ ) {
29
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
30
+ await writeState(projectRoot, {
31
+ current_iteration: "000013",
32
+ current_phase: "refactor",
33
+ phases: {
34
+ define: {
35
+ requirement_definition: { status: "approved", file: "it_000013_product-requirement-document.md" },
36
+ prd_generation: { status: "completed", file: "it_000013_PRD.json" },
37
+ },
38
+ prototype: {
39
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
40
+ test_plan: { status: "created", file: "it_000013_test-plan.md" },
41
+ tp_generation: { status: "created", file: "it_000013_TP.json" },
42
+ prototype_build: { status: "created", file: "it_000013_progress.json" },
43
+ test_execution: { status: "completed", file: "it_000013_test-execution-report.json" },
44
+ prototype_approved: true,
45
+ },
46
+ refactor: {
47
+ evaluation_report: { status: "created", file: "it_000013_evaluation-report.md" },
48
+ refactor_plan: { status, file },
49
+ refactor_execution: { status: "pending", file: null },
50
+ changelog: { status: "pending", file: null },
51
+ },
52
+ },
53
+ last_updated: "2026-02-26T00:00:00.000Z",
54
+ updated_by: "seed",
55
+ history: [],
56
+ });
57
+ }
58
+
59
+ const createdRoots: string[] = [];
60
+
61
+ afterEach(async () => {
62
+ process.exitCode = 0;
63
+ await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
64
+ });
65
+
66
+ describe("approve refactor-plan command", () => {
67
+ test("registers approve 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 { runApproveRefactorPlan } from "./commands/approve-refactor-plan";');
71
+ expect(source).toContain('if (subcommand === "refactor-plan") {');
72
+ expect(source).toContain("await runApproveRefactorPlan({ force });");
73
+ });
74
+
75
+ test("requires refactor.refactor_plan.status to be pending_approval", async () => {
76
+ const projectRoot = await createProjectRoot();
77
+ createdRoots.push(projectRoot);
78
+ await seedState(projectRoot, "pending", "it_000013_refactor-plan.md");
79
+
80
+ await withCwd(projectRoot, async () => {
81
+ await expect(runApproveRefactorPlan()).rejects.toThrow(
82
+ "Cannot approve refactor plan from status 'pending'. Expected pending_approval.",
83
+ );
84
+ });
85
+ });
86
+
87
+ test("rejects when refactor.refactor_plan.file is missing", async () => {
88
+ const projectRoot = await createProjectRoot();
89
+ createdRoots.push(projectRoot);
90
+ await seedState(projectRoot, "pending_approval", null);
91
+
92
+ await withCwd(projectRoot, async () => {
93
+ await expect(runApproveRefactorPlan()).rejects.toThrow(
94
+ "Cannot approve refactor plan: refactor.refactor_plan.file is missing.",
95
+ );
96
+ });
97
+ });
98
+
99
+ test("rejects when refactor plan file does not exist on disk", async () => {
100
+ const projectRoot = await createProjectRoot();
101
+ createdRoots.push(projectRoot);
102
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
103
+
104
+ await withCwd(projectRoot, async () => {
105
+ await expect(runApproveRefactorPlan()).rejects.toThrow(
106
+ "Cannot approve refactor plan: file not found at",
107
+ );
108
+ });
109
+ });
110
+
111
+ test("approves refactor plan, generates refactor-prd JSON via write-json, and updates state fields", async () => {
112
+ const projectRoot = await createProjectRoot();
113
+ createdRoots.push(projectRoot);
114
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
115
+
116
+ const refactorPlanPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-plan.md");
117
+ await writeFile(
118
+ refactorPlanPath,
119
+ [
120
+ "# Refactor Plan",
121
+ "## Refactor Items",
122
+ "### RI-001: Consolidate state updates",
123
+ "**Description:** Move scattered state mutation into a single helper.",
124
+ "**Rationale:** Reduces duplication and lowers the risk of inconsistent transitions.",
125
+ "### RI-002: Improve command dependency injection",
126
+ "**Description:**",
127
+ "Allow command helpers to accept overrideable I/O dependencies for tests.",
128
+ "**Rationale:**",
129
+ "Improves testability and keeps behavior deterministic in unit tests.",
130
+ ].join("\n"),
131
+ "utf8",
132
+ );
133
+
134
+ let capturedSchema = "";
135
+ let capturedOutPath = "";
136
+ let capturedPayload = "";
137
+
138
+ await withCwd(projectRoot, async () => {
139
+ await runApproveRefactorPlan({
140
+ invokeWriteJsonFn: async (_root, schemaName, outPath, data) => {
141
+ capturedSchema = schemaName;
142
+ capturedOutPath = outPath;
143
+ capturedPayload = data;
144
+ await writeFile(join(projectRoot, outPath), data, "utf8");
145
+ return { exitCode: 0, stderr: "" };
146
+ },
147
+ nowFn: () => new Date("2026-02-26T12:00:00.000Z"),
148
+ });
149
+ });
150
+
151
+ expect(capturedSchema).toBe("refactor-prd");
152
+ expect(capturedOutPath).toBe(".agents/flow/it_000013_refactor-prd.json");
153
+
154
+ const parsedPayload = JSON.parse(capturedPayload) as unknown;
155
+ const validation = RefactorPrdSchema.safeParse(parsedPayload);
156
+ expect(validation.success).toBe(true);
157
+ if (!validation.success) {
158
+ throw new Error("Expected refactor-prd payload to be valid");
159
+ }
160
+ expect(validation.data.refactorItems).toEqual([
161
+ {
162
+ id: "RI-001",
163
+ title: "Consolidate state updates",
164
+ description: "Move scattered state mutation into a single helper.",
165
+ rationale: "Reduces duplication and lowers the risk of inconsistent transitions.",
166
+ },
167
+ {
168
+ id: "RI-002",
169
+ title: "Improve command dependency injection",
170
+ description: "Allow command helpers to accept overrideable I/O dependencies for tests.",
171
+ rationale: "Improves testability and keeps behavior deterministic in unit tests.",
172
+ },
173
+ ]);
174
+
175
+ const state = await readState(projectRoot);
176
+ expect(state.phases.refactor.refactor_plan.status).toBe("approved");
177
+ expect(state.last_updated).toBe("2026-02-26T12:00:00.000Z");
178
+ expect(state.updated_by).toBe("nvst:approve-refactor-plan");
179
+ });
180
+
181
+ test("prints an error and keeps refactor plan pending_approval when write-json fails", async () => {
182
+ const projectRoot = await createProjectRoot();
183
+ createdRoots.push(projectRoot);
184
+ await seedState(projectRoot, "pending_approval", "it_000013_refactor-plan.md");
185
+
186
+ const refactorPlanPath = join(projectRoot, ".agents", "flow", "it_000013_refactor-plan.md");
187
+ await writeFile(
188
+ refactorPlanPath,
189
+ [
190
+ "# Refactor Plan",
191
+ "## Refactor Items",
192
+ "### RI-001: Keep pending on write-json failure",
193
+ "**Description:** Simulate a downstream write-json error.",
194
+ "**Rationale:** State must not mutate when serialization/validation fails.",
195
+ ].join("\n"),
196
+ "utf8",
197
+ );
198
+
199
+ const capturedErrors: string[] = [];
200
+ const originalConsoleError = console.error;
201
+ console.error = (...args: unknown[]) => {
202
+ capturedErrors.push(args.map((arg) => String(arg)).join(" "));
203
+ };
204
+
205
+ try {
206
+ await withCwd(projectRoot, async () => {
207
+ await runApproveRefactorPlan({
208
+ invokeWriteJsonFn: async () => ({
209
+ exitCode: 1,
210
+ stderr: "mock write-json failure",
211
+ }),
212
+ });
213
+ });
214
+ } finally {
215
+ console.error = originalConsoleError;
216
+ }
217
+
218
+ expect(process.exitCode).toBe(1);
219
+ expect(capturedErrors[0]).toContain(
220
+ "Refactor PRD JSON generation failed. Refactor plan remains pending_approval.",
221
+ );
222
+ expect(capturedErrors[1]).toContain("mock write-json failure");
223
+
224
+ const state = await readState(projectRoot);
225
+ expect(state.phases.refactor.refactor_plan.status).toBe("pending_approval");
226
+ expect(state.last_updated).toBe("2026-02-26T00:00:00.000Z");
227
+ expect(state.updated_by).toBe("seed");
228
+ });
229
+
230
+ test("parseRefactorPlan extracts items from markdown sections", () => {
231
+ const parsed = parseRefactorPlan(
232
+ [
233
+ "# Refactor Plan",
234
+ "## Refactor Items",
235
+ "### ri-007: Normalize line endings",
236
+ "**Description:** Convert generated markdown outputs to LF only.",
237
+ "**Rationale:** Avoid cross-platform diff noise in CI.",
238
+ ].join("\n"),
239
+ );
240
+
241
+ expect(parsed).toEqual({
242
+ refactorItems: [
243
+ {
244
+ id: "RI-007",
245
+ title: "Normalize line endings",
246
+ description: "Convert generated markdown outputs to LF only.",
247
+ rationale: "Avoid cross-platform diff noise in CI.",
248
+ },
249
+ ],
250
+ });
251
+ const validation = RefactorPrdSchema.safeParse(parsed);
252
+ expect(validation.success).toBe(true);
253
+ });
254
+ });
@@ -0,0 +1,200 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { $ } from "bun";
4
+
5
+ import { CLI_PATH } from "../cli-path";
6
+ import { assertGuardrail } from "../guardrail";
7
+ import type { RefactorPrd } from "../../scaffold/schemas/tmpl_refactor-prd";
8
+ import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
9
+
10
+ interface WriteJsonResult {
11
+ exitCode: number;
12
+ stderr: string;
13
+ }
14
+
15
+ interface ApproveRefactorPlanDeps {
16
+ existsFn: (path: string) => Promise<boolean>;
17
+ invokeWriteJsonFn: (
18
+ projectRoot: string,
19
+ schemaName: string,
20
+ outPath: string,
21
+ data: string,
22
+ ) => Promise<WriteJsonResult>;
23
+ nowFn: () => Date;
24
+ readFileFn: typeof readFile;
25
+ }
26
+
27
+ const defaultDeps: ApproveRefactorPlanDeps = {
28
+ existsFn: exists,
29
+ invokeWriteJsonFn: runWriteJsonCommand,
30
+ nowFn: () => new Date(),
31
+ readFileFn: readFile,
32
+ };
33
+
34
+ export function parseRefactorPlan(markdown: string): RefactorPrd {
35
+ const refactorItems: RefactorPrd["refactorItems"] = [];
36
+ const lines = markdown.split("\n");
37
+
38
+ let inRefactorItems = false;
39
+ let currentField: "description" | "rationale" | null = null;
40
+ let currentItem: RefactorPrd["refactorItems"][number] | null = null;
41
+
42
+ const flushCurrentItem = () => {
43
+ if (!currentItem) return;
44
+ refactorItems.push(currentItem);
45
+ currentItem = null;
46
+ currentField = null;
47
+ };
48
+
49
+ for (const line of lines) {
50
+ const trimmed = line.trim();
51
+
52
+ if (/^##\s+Refactor Items$/i.test(trimmed)) {
53
+ inRefactorItems = true;
54
+ currentField = null;
55
+ continue;
56
+ }
57
+
58
+ if (!inRefactorItems) {
59
+ continue;
60
+ }
61
+
62
+ if (/^##\s+/.test(trimmed) && !/^##\s+Refactor Items$/i.test(trimmed)) {
63
+ flushCurrentItem();
64
+ inRefactorItems = false;
65
+ continue;
66
+ }
67
+
68
+ const itemMatch = trimmed.match(/^###\s+(RI-\d{3}):\s+(.+)$/i);
69
+ if (itemMatch) {
70
+ flushCurrentItem();
71
+ currentItem = {
72
+ id: itemMatch[1].toUpperCase(),
73
+ title: itemMatch[2].trim(),
74
+ description: "",
75
+ rationale: "",
76
+ };
77
+ currentField = null;
78
+ continue;
79
+ }
80
+
81
+ if (!currentItem) {
82
+ continue;
83
+ }
84
+
85
+ const descriptionMatch = trimmed.match(/^\*\*Description:\*\*\s*(.*)$/i);
86
+ if (descriptionMatch) {
87
+ currentItem.description = descriptionMatch[1].trim();
88
+ currentField = "description";
89
+ continue;
90
+ }
91
+
92
+ const rationaleMatch = trimmed.match(/^\*\*Rationale:\*\*\s*(.*)$/i);
93
+ if (rationaleMatch) {
94
+ currentItem.rationale = rationaleMatch[1].trim();
95
+ currentField = "rationale";
96
+ continue;
97
+ }
98
+
99
+ if (trimmed.length === 0 || !currentField) {
100
+ continue;
101
+ }
102
+
103
+ if (currentField === "description") {
104
+ currentItem.description = [currentItem.description, trimmed].filter(Boolean).join(" ");
105
+ continue;
106
+ }
107
+
108
+ currentItem.rationale = [currentItem.rationale, trimmed].filter(Boolean).join(" ");
109
+ }
110
+
111
+ flushCurrentItem();
112
+
113
+ return { refactorItems };
114
+ }
115
+
116
+ async function runWriteJsonCommand(
117
+ projectRoot: string,
118
+ schemaName: string,
119
+ outPath: string,
120
+ data: string,
121
+ ): Promise<WriteJsonResult> {
122
+ const result =
123
+ await $`bun ${CLI_PATH} write-json --schema ${schemaName} --out ${outPath} --data ${data}`
124
+ .cwd(projectRoot)
125
+ .nothrow()
126
+ .quiet();
127
+
128
+ return {
129
+ exitCode: result.exitCode,
130
+ stderr: result.stderr.toString().trim(),
131
+ };
132
+ }
133
+
134
+ export async function runApproveRefactorPlan(
135
+ optsOrDeps: { force?: boolean } | Partial<ApproveRefactorPlanDeps> = {},
136
+ maybeDeps: Partial<ApproveRefactorPlanDeps> = {},
137
+ ): Promise<void> {
138
+ const isDepsArg =
139
+ typeof optsOrDeps === "object"
140
+ && optsOrDeps !== null
141
+ && (
142
+ "existsFn" in optsOrDeps
143
+ || "invokeWriteJsonFn" in optsOrDeps
144
+ || "nowFn" in optsOrDeps
145
+ || "readFileFn" in optsOrDeps
146
+ );
147
+ const force = isDepsArg ? false : ((optsOrDeps as { force?: boolean }).force ?? false);
148
+ const projectRoot = process.cwd();
149
+ const state = await readState(projectRoot);
150
+ const deps = isDepsArg ? optsOrDeps : maybeDeps;
151
+ const mergedDeps: ApproveRefactorPlanDeps = { ...defaultDeps, ...deps };
152
+
153
+ const refactorPlan = state.phases.refactor.refactor_plan;
154
+ await assertGuardrail(
155
+ state,
156
+ refactorPlan.status !== "pending_approval",
157
+ `Cannot approve refactor plan from status '${refactorPlan.status}'. Expected pending_approval.`,
158
+ { force },
159
+ );
160
+
161
+ const refactorPlanFile = refactorPlan.file;
162
+ if (!refactorPlanFile) {
163
+ throw new Error("Cannot approve refactor plan: refactor.refactor_plan.file is missing.");
164
+ }
165
+
166
+ const refactorPlanPath = join(projectRoot, FLOW_REL_DIR, refactorPlanFile);
167
+ if (!(await mergedDeps.existsFn(refactorPlanPath))) {
168
+ throw new Error(`Cannot approve refactor plan: file not found at ${refactorPlanPath}`);
169
+ }
170
+
171
+ const markdown = await mergedDeps.readFileFn(refactorPlanPath, "utf-8");
172
+ const refactorPrdData = parseRefactorPlan(markdown);
173
+ const refactorPrdJsonFileName = `it_${state.current_iteration}_refactor-prd.json`;
174
+ const refactorPrdJsonRelPath = join(FLOW_REL_DIR, refactorPrdJsonFileName);
175
+
176
+ const writeResult = await mergedDeps.invokeWriteJsonFn(
177
+ projectRoot,
178
+ "refactor-prd",
179
+ refactorPrdJsonRelPath,
180
+ JSON.stringify(refactorPrdData),
181
+ );
182
+
183
+ if (writeResult.exitCode !== 0) {
184
+ console.error("Refactor PRD JSON generation failed. Refactor plan remains pending_approval.");
185
+ if (writeResult.stderr) {
186
+ console.error(writeResult.stderr);
187
+ }
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+
192
+ refactorPlan.status = "approved";
193
+ state.last_updated = mergedDeps.nowFn().toISOString();
194
+ state.updated_by = "nvst:approve-refactor-plan";
195
+
196
+ await writeState(projectRoot, state);
197
+
198
+ console.log("Refactor plan approved.");
199
+ console.log(`Refactor PRD JSON written to ${refactorPrdJsonRelPath}`);
200
+ }
@@ -0,0 +1,224 @@
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 { PrdSchema } from "../../scaffold/schemas/tmpl_prd";
7
+ import { readState, writeState } from "../state";
8
+ import { runApproveRequirement } from "./approve-requirement";
9
+
10
+ async function createProjectRoot(): Promise<string> {
11
+ return mkdtemp(join(tmpdir(), "nvst-approve-requirement-"));
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" | "in_progress" | "approved",
27
+ file: string | null,
28
+ ) {
29
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
30
+ await writeState(projectRoot, {
31
+ current_iteration: "000001",
32
+ current_phase: "define",
33
+ phases: {
34
+ define: {
35
+ requirement_definition: { status, file },
36
+ prd_generation: { status: "pending", file: null },
37
+ },
38
+ prototype: {
39
+ project_context: { status: "pending", file: null },
40
+ test_plan: { status: "pending", file: null },
41
+ tp_generation: { status: "pending", file: null },
42
+ prototype_build: { status: "pending", file: null },
43
+ test_execution: { status: "pending", file: null },
44
+ prototype_approved: false,
45
+ },
46
+ refactor: {
47
+ evaluation_report: { status: "pending", file: null },
48
+ refactor_plan: { status: "pending", file: null },
49
+ refactor_execution: { status: "pending", file: null },
50
+ changelog: { status: "pending", file: null },
51
+ },
52
+ },
53
+ last_updated: "2026-02-20T00:00:00.000Z",
54
+ updated_by: "seed",
55
+ history: [],
56
+ });
57
+ }
58
+
59
+ const MINIMAL_PRD_MARKDOWN = [
60
+ "# Product Requirement Document",
61
+ "## Goals",
62
+ "- Ship the feature",
63
+ "## User Stories",
64
+ "### US-001: Core feature",
65
+ "As a developer, I want to build the feature.",
66
+ "**Acceptance Criteria:**",
67
+ "- [ ] Feature is built",
68
+ "## Functional Requirements",
69
+ "- FR-1: The system must do something",
70
+ ].join("\n");
71
+
72
+ const createdRoots: string[] = [];
73
+
74
+ afterEach(async () => {
75
+ process.exitCode = 0;
76
+ await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
77
+ });
78
+
79
+ describe("approve requirement command", () => {
80
+ test("registers approve requirement command in CLI dispatch", async () => {
81
+ const source = await readFile(join(process.cwd(), "src", "cli.ts"), "utf8");
82
+
83
+ expect(source).toContain('import { runApproveRequirement } from "./commands/approve-requirement";');
84
+ expect(source).toContain('if (subcommand === "requirement") {');
85
+ expect(source).toContain("await runApproveRequirement({ force });");
86
+ });
87
+
88
+ test("rejects when requirement_definition.status is not in_progress", async () => {
89
+ const projectRoot = await createProjectRoot();
90
+ createdRoots.push(projectRoot);
91
+ await seedState(projectRoot, "pending", "it_000001_product-requirement-document.md");
92
+
93
+ await withCwd(projectRoot, async () => {
94
+ await expect(runApproveRequirement()).rejects.toThrow(
95
+ "Cannot approve requirement from status 'pending'. Expected in_progress.",
96
+ );
97
+ });
98
+ });
99
+
100
+ test("rejects when requirement_definition.file is null", async () => {
101
+ const projectRoot = await createProjectRoot();
102
+ createdRoots.push(projectRoot);
103
+ await seedState(projectRoot, "in_progress", null);
104
+
105
+ await withCwd(projectRoot, async () => {
106
+ await expect(runApproveRequirement()).rejects.toThrow(
107
+ "Cannot approve requirement: define.requirement_definition.file is missing.",
108
+ );
109
+ });
110
+ });
111
+
112
+ test("rejects when requirement file does not exist on disk", async () => {
113
+ const projectRoot = await createProjectRoot();
114
+ createdRoots.push(projectRoot);
115
+ await seedState(projectRoot, "in_progress", "it_000001_product-requirement-document.md");
116
+
117
+ await withCwd(projectRoot, async () => {
118
+ await expect(runApproveRequirement()).rejects.toThrow(
119
+ "Cannot approve requirement: file not found at",
120
+ );
121
+ });
122
+ });
123
+
124
+ test("approves requirement, generates PRD JSON via write-json, and updates state fields", async () => {
125
+ const projectRoot = await createProjectRoot();
126
+ createdRoots.push(projectRoot);
127
+ await seedState(projectRoot, "in_progress", "it_000001_product-requirement-document.md");
128
+
129
+ const requirementPath = join(
130
+ projectRoot,
131
+ ".agents",
132
+ "flow",
133
+ "it_000001_product-requirement-document.md",
134
+ );
135
+ await writeFile(requirementPath, MINIMAL_PRD_MARKDOWN, "utf8");
136
+
137
+ let capturedSchema = "";
138
+ let capturedOutPath = "";
139
+ let capturedPayload = "";
140
+
141
+ await withCwd(projectRoot, async () => {
142
+ await runApproveRequirement({
143
+ invokeWriteJsonFn: async (_root, schemaName, outPath, data) => {
144
+ capturedSchema = schemaName;
145
+ capturedOutPath = outPath;
146
+ capturedPayload = data;
147
+ await writeFile(join(projectRoot, outPath), data, "utf8");
148
+ return { exitCode: 0, stderr: "" };
149
+ },
150
+ nowFn: () => new Date("2026-02-20T08:00:00.000Z"),
151
+ });
152
+ });
153
+
154
+ expect(capturedSchema).toBe("prd");
155
+ expect(capturedOutPath).toBe(".agents/flow/it_000001_PRD.json");
156
+
157
+ const parsedPayload = JSON.parse(capturedPayload) as unknown;
158
+ const validation = PrdSchema.safeParse(parsedPayload);
159
+ expect(validation.success).toBe(true);
160
+ if (!validation.success) {
161
+ throw new Error("Expected PRD payload to be valid");
162
+ }
163
+ expect(validation.data.goals).toEqual(["Ship the feature"]);
164
+ expect(validation.data.userStories[0]).toMatchObject({
165
+ id: "US-001",
166
+ title: "Core feature",
167
+ });
168
+ expect(validation.data.functionalRequirements[0]).toEqual({
169
+ id: "FR-1",
170
+ description: "The system must do something",
171
+ });
172
+
173
+ const state = await readState(projectRoot);
174
+ expect(state.phases.define.requirement_definition.status).toBe("approved");
175
+ expect(state.phases.define.prd_generation.status).toBe("completed");
176
+ expect(state.phases.define.prd_generation.file).toBe("it_000001_PRD.json");
177
+ expect(state.last_updated).toBe("2026-02-20T08:00:00.000Z");
178
+ expect(state.updated_by).toBe("nvst:approve-requirement");
179
+ });
180
+
181
+ test("prints error and keeps status in_progress when write-json fails", async () => {
182
+ const projectRoot = await createProjectRoot();
183
+ createdRoots.push(projectRoot);
184
+ await seedState(projectRoot, "in_progress", "it_000001_product-requirement-document.md");
185
+
186
+ const requirementPath = join(
187
+ projectRoot,
188
+ ".agents",
189
+ "flow",
190
+ "it_000001_product-requirement-document.md",
191
+ );
192
+ await writeFile(requirementPath, MINIMAL_PRD_MARKDOWN, "utf8");
193
+
194
+ const capturedErrors: string[] = [];
195
+ const originalConsoleError = console.error;
196
+ console.error = (...args: unknown[]) => {
197
+ capturedErrors.push(args.map((arg) => String(arg)).join(" "));
198
+ };
199
+
200
+ try {
201
+ await withCwd(projectRoot, async () => {
202
+ await runApproveRequirement({
203
+ invokeWriteJsonFn: async () => ({
204
+ exitCode: 1,
205
+ stderr: "mock write-json failure",
206
+ }),
207
+ });
208
+ });
209
+ } finally {
210
+ console.error = originalConsoleError;
211
+ }
212
+
213
+ expect(process.exitCode).toBe(1);
214
+ expect(capturedErrors[0]).toContain(
215
+ "PRD JSON generation failed. Requirement remains in_progress.",
216
+ );
217
+ expect(capturedErrors[1]).toContain("mock write-json failure");
218
+
219
+ const state = await readState(projectRoot);
220
+ expect(state.phases.define.requirement_definition.status).toBe("in_progress");
221
+ expect(state.last_updated).toBe("2026-02-20T00:00:00.000Z");
222
+ expect(state.updated_by).toBe("seed");
223
+ });
224
+ });