@quinteroac/agents-coding-toolkit 0.1.1-preview.0 → 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 (32) hide show
  1. package/README.md +29 -15
  2. package/package.json +2 -1
  3. package/scaffold/.agents/flow/tmpl_it_000001_progress.example.json +20 -0
  4. package/scaffold/.agents/skills/execute-refactor-item/tmpl_SKILL.md +5 -5
  5. package/scaffold/schemas/tmpl_prototype-progress.ts +22 -0
  6. package/scaffold/schemas/tmpl_test-execution-progress.ts +17 -0
  7. package/schemas/issues.ts +19 -0
  8. package/schemas/prototype-progress.ts +22 -0
  9. package/schemas/test-execution-progress.ts +17 -0
  10. package/schemas/validate-progress.ts +1 -1
  11. package/schemas/validate-state.ts +1 -1
  12. package/src/cli.ts +51 -6
  13. package/src/commands/approve-prototype.test.ts +427 -0
  14. package/src/commands/approve-prototype.ts +185 -0
  15. package/src/commands/create-prototype.test.ts +459 -7
  16. package/src/commands/create-prototype.ts +168 -56
  17. package/src/commands/execute-automated-fix.test.ts +78 -33
  18. package/src/commands/execute-automated-fix.ts +34 -101
  19. package/src/commands/execute-refactor.test.ts +3 -3
  20. package/src/commands/execute-refactor.ts +8 -12
  21. package/src/commands/execute-test-plan.test.ts +20 -19
  22. package/src/commands/execute-test-plan.ts +19 -52
  23. package/src/commands/flow-config.ts +79 -0
  24. package/src/commands/flow.test.ts +755 -0
  25. package/src/commands/flow.ts +405 -0
  26. package/src/commands/start-iteration.test.ts +52 -0
  27. package/src/commands/start-iteration.ts +5 -0
  28. package/src/flow-cli.test.ts +18 -0
  29. package/src/guardrail.ts +2 -24
  30. package/src/progress-utils.ts +34 -0
  31. package/src/readline.ts +23 -0
  32. package/src/write-json-artifact.ts +33 -0
@@ -0,0 +1,427 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { $ } from "bun";
6
+
7
+ import type { State } from "../../scaffold/schemas/tmpl_state";
8
+ import { readState, writeState } from "../state";
9
+ import { runApprovePrototype } from "./approve-prototype";
10
+
11
+ async function createProjectRoot(): Promise<string> {
12
+ return mkdtemp(join(tmpdir(), "nvst-approve-prototype-"));
13
+ }
14
+
15
+ async function createBareRemoteRoot(): Promise<string> {
16
+ return mkdtemp(join(tmpdir(), "nvst-approve-prototype-remote-"));
17
+ }
18
+
19
+ async function withCwd<T>(cwd: string, fn: () => Promise<T>): Promise<T> {
20
+ const previous = process.cwd();
21
+ process.chdir(cwd);
22
+ try {
23
+ return await fn();
24
+ } finally {
25
+ process.chdir(previous);
26
+ }
27
+ }
28
+
29
+ async function runGit(projectRoot: string, command: string): Promise<string> {
30
+ const result = await $`bash -lc ${command}`.cwd(projectRoot).nothrow().quiet();
31
+ if (result.exitCode !== 0) {
32
+ throw new Error(`git command failed: ${command}\n${result.stderr.toString()}`);
33
+ }
34
+ return result.stdout.toString().trim();
35
+ }
36
+
37
+ interface SeedStateOptions {
38
+ iteration?: string;
39
+ currentPhase?: "define" | "prototype" | "refactor";
40
+ prototypeApproved?: boolean;
41
+ }
42
+
43
+ async function seedState(projectRoot: string, opts: SeedStateOptions = {}): Promise<void> {
44
+ const {
45
+ iteration = "000016",
46
+ currentPhase = "prototype",
47
+ prototypeApproved = false,
48
+ } = opts;
49
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
50
+ await writeState(projectRoot, {
51
+ current_iteration: iteration,
52
+ current_phase: currentPhase,
53
+ phases: {
54
+ define: {
55
+ requirement_definition: { status: "approved", file: "it_000016_product-requirement-document.md" },
56
+ prd_generation: { status: "completed", file: "it_000016_PRD.json" },
57
+ },
58
+ prototype: {
59
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
60
+ test_plan: { status: "created", file: "it_000016_test-plan.md" },
61
+ tp_generation: { status: "created", file: "it_000016_TP.json" },
62
+ prototype_build: { status: "created", file: "it_000016_progress.json" },
63
+ test_execution: { status: "completed", file: "it_000016_test-execution-progress.json" },
64
+ prototype_approved: prototypeApproved,
65
+ },
66
+ refactor: {
67
+ evaluation_report: { status: "pending", file: null },
68
+ refactor_plan: { status: "pending", file: null },
69
+ refactor_execution: { status: "pending", file: null },
70
+ changelog: { status: "pending", file: null },
71
+ },
72
+ },
73
+ last_updated: "2026-02-26T00:00:00.000Z",
74
+ updated_by: "seed",
75
+ history: [],
76
+ });
77
+ }
78
+
79
+ const createdRoots: string[] = [];
80
+
81
+ afterEach(async () => {
82
+ await Promise.all(createdRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
83
+ process.exitCode = 0;
84
+ });
85
+
86
+ describe("approve prototype command", () => {
87
+ test("registers approve prototype command in CLI dispatch", async () => {
88
+ const source = await Bun.file(join(process.cwd(), "src", "cli.ts")).text();
89
+
90
+ expect(source).toContain('import { runApprovePrototype } from "./commands/approve-prototype";');
91
+ expect(source).toContain('if (subcommand === "prototype") {');
92
+ expect(source).toContain("await runApprovePrototype({ force });");
93
+ });
94
+
95
+ test("stages all pending changes, commits/pushes, and marks prototype as approved", async () => {
96
+ const projectRoot = await createProjectRoot();
97
+ const remoteRoot = await createBareRemoteRoot();
98
+ createdRoots.push(projectRoot);
99
+ createdRoots.push(remoteRoot);
100
+
101
+ await seedState(projectRoot, { iteration: "000016" });
102
+ await runGit(projectRoot, "git init");
103
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
104
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
105
+ await runGit(projectRoot, "git branch -M main");
106
+ await runGit(remoteRoot, "git init --bare");
107
+ await runGit(projectRoot, `git remote add origin ${remoteRoot}`);
108
+
109
+ await writeFile(join(projectRoot, "tracked-modified.txt"), "before\n", "utf8");
110
+ await writeFile(join(projectRoot, "tracked-deleted.txt"), "delete me\n", "utf8");
111
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
112
+
113
+ await writeFile(join(projectRoot, "tracked-modified.txt"), "after\n", "utf8");
114
+ await rm(join(projectRoot, "tracked-deleted.txt"));
115
+ await writeFile(join(projectRoot, "untracked-added.txt"), "new\n", "utf8");
116
+
117
+ const beforeState = await Bun.file(join(projectRoot, ".agents", "state.json")).json();
118
+
119
+ await withCwd(projectRoot, async () => {
120
+ await runApprovePrototype();
121
+ });
122
+
123
+ const commitMessage = await runGit(projectRoot, "git log -1 --pretty=%s");
124
+ expect(commitMessage).toBe("feat: approve prototype it_000016");
125
+
126
+ const changedFiles = await runGit(projectRoot, "git show --name-status --pretty=format: HEAD");
127
+ expect(changedFiles).toContain("tracked-modified.txt");
128
+ expect(changedFiles).toContain("tracked-deleted.txt");
129
+ expect(changedFiles).toContain("untracked-added.txt");
130
+
131
+ const upstream = await runGit(projectRoot, "git rev-parse --abbrev-ref --symbolic-full-name @{u}");
132
+ expect(upstream).toBe("origin/main");
133
+
134
+ const afterState = await Bun.file(join(projectRoot, ".agents", "state.json")).json();
135
+ expect(afterState.phases.prototype.prototype_approved).toBe(true);
136
+ expect(afterState.last_updated).not.toBe(beforeState.last_updated);
137
+ });
138
+
139
+ test("prints informative message and skips commit when working tree is clean", async () => {
140
+ const projectRoot = await createProjectRoot();
141
+ createdRoots.push(projectRoot);
142
+
143
+ await seedState(projectRoot, { iteration: "000016" });
144
+ await runGit(projectRoot, "git init");
145
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
146
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
147
+ await writeFile(join(projectRoot, "tracked.txt"), "stable\n", "utf8");
148
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
149
+
150
+ const beforeHead = await runGit(projectRoot, "git rev-parse HEAD");
151
+ const logs: string[] = [];
152
+
153
+ await withCwd(projectRoot, async () => {
154
+ await runApprovePrototype({}, {
155
+ logFn: (message) => {
156
+ logs.push(message);
157
+ },
158
+ });
159
+ });
160
+
161
+ const afterHead = await runGit(projectRoot, "git rev-parse HEAD");
162
+ const commitCount = await runGit(projectRoot, "git rev-list --count HEAD");
163
+
164
+ expect(afterHead).toBe(beforeHead);
165
+ expect(commitCount).toBe("1");
166
+ expect(logs).toContain("No pending changes to commit; working tree is clean.");
167
+ });
168
+
169
+ test("throws Pre-commit hook failed error when commit fails due to pre-commit hook", async () => {
170
+ const projectRoot = await createProjectRoot();
171
+ createdRoots.push(projectRoot);
172
+
173
+ await seedState(projectRoot, { iteration: "000016" });
174
+ await runGit(projectRoot, "git init");
175
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
176
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
177
+ await runGit(projectRoot, "git branch -M main");
178
+
179
+ // Seed an initial commit before installing the hook
180
+ await writeFile(join(projectRoot, "file.txt"), "initial\n", "utf8");
181
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
182
+
183
+ // Install a failing pre-commit hook
184
+ const hookPath = join(projectRoot, ".git", "hooks", "pre-commit");
185
+ await writeFile(hookPath, "#!/bin/sh\necho 'blocked by pre-commit hook'\nexit 1\n", { mode: 0o755 });
186
+
187
+ // Make a change that the command will attempt to stage and commit
188
+ await writeFile(join(projectRoot, "file.txt"), "modified\n", "utf8");
189
+
190
+ const statePath = join(projectRoot, ".agents", "state.json");
191
+ const beforeState = await Bun.file(statePath).text();
192
+ process.exitCode = 0;
193
+
194
+ let caught: unknown = null;
195
+ await withCwd(projectRoot, async () => {
196
+ try {
197
+ await runApprovePrototype();
198
+ } catch (error) {
199
+ caught = error;
200
+ }
201
+ });
202
+
203
+ expect(caught).toBeInstanceOf(Error);
204
+ expect((caught as Error).message).toMatch(/^Pre-commit hook failed:\n/);
205
+ expect(process.exitCode).toBe(1);
206
+
207
+ const afterState = await Bun.file(statePath).text();
208
+ expect(afterState).toBe(beforeState);
209
+ });
210
+
211
+ test("throws descriptive error on push failure, sets process.exitCode, and does not update state.json", async () => {
212
+ const projectRoot = await createProjectRoot();
213
+ createdRoots.push(projectRoot);
214
+
215
+ await seedState(projectRoot, { iteration: "000016" });
216
+ await runGit(projectRoot, "git init");
217
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
218
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
219
+
220
+ await writeFile(join(projectRoot, "tracked.txt"), "before\n", "utf8");
221
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
222
+ await writeFile(join(projectRoot, "tracked.txt"), "after\n", "utf8");
223
+
224
+ const statePath = join(projectRoot, ".agents", "state.json");
225
+ const beforeState = await Bun.file(statePath).text();
226
+ process.exitCode = 0;
227
+
228
+ let caught: unknown = null;
229
+ await withCwd(projectRoot, async () => {
230
+ try {
231
+ await runApprovePrototype();
232
+ } catch (error) {
233
+ caught = error;
234
+ }
235
+ });
236
+
237
+ expect(caught).toBeInstanceOf(Error);
238
+ expect((caught as Error).message).toContain("Failed to push prototype approval commit");
239
+ expect(process.exitCode).toBe(1);
240
+
241
+ const afterState = await Bun.file(statePath).text();
242
+ expect(afterState).toBe(beforeState);
243
+ });
244
+
245
+ test("throws descriptive error when prototype is already approved", async () => {
246
+ const projectRoot = await createProjectRoot();
247
+ createdRoots.push(projectRoot);
248
+ await seedState(projectRoot, { prototypeApproved: true });
249
+
250
+ let caught: unknown = null;
251
+ await withCwd(projectRoot, async () => {
252
+ try {
253
+ await runApprovePrototype();
254
+ } catch (error) {
255
+ caught = error;
256
+ }
257
+ });
258
+
259
+ expect(caught).toBeInstanceOf(Error);
260
+ expect((caught as Error).message).toContain(
261
+ "Cannot approve prototype: phases.prototype.prototype_approved is already true.",
262
+ );
263
+ });
264
+
265
+ test("blocks approval when current phase is not prototype", async () => {
266
+ const projectRoot = await createProjectRoot();
267
+ createdRoots.push(projectRoot);
268
+ await seedState(projectRoot, { currentPhase: "define" });
269
+
270
+ let caught: unknown = null;
271
+ await withCwd(projectRoot, async () => {
272
+ try {
273
+ await runApprovePrototype();
274
+ } catch (error) {
275
+ caught = error;
276
+ }
277
+ });
278
+
279
+ expect(caught).toBeInstanceOf(Error);
280
+ expect((caught as Error).message).toContain(
281
+ "Cannot approve prototype: current_phase must be 'prototype'. Current: 'define'.",
282
+ );
283
+ });
284
+
285
+ describe("gh PR creation behaviour", () => {
286
+ const ITERATION = "000016";
287
+
288
+ test("when gh is available and gh pr create succeeds, runs it with correct title and body and updates state", async () => {
289
+ const projectRoot = await createProjectRoot();
290
+ createdRoots.push(projectRoot);
291
+ await seedState(projectRoot, { iteration: ITERATION });
292
+ await runGit(projectRoot, "git init");
293
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
294
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
295
+ await runGit(projectRoot, "git branch -M main");
296
+ const remoteRoot = await createBareRemoteRoot();
297
+ createdRoots.push(remoteRoot);
298
+ await runGit(remoteRoot, "git init --bare");
299
+ await runGit(projectRoot, `git remote add origin ${remoteRoot}`);
300
+ await writeFile(join(projectRoot, "file.txt"), "initial\n", "utf8");
301
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
302
+ await writeFile(join(projectRoot, "file.txt"), "modified\n", "utf8");
303
+
304
+ const logs: string[] = [];
305
+ const prCreateCalls: Array<{ projectRoot: string; title: string; body: string }> = [];
306
+ let writtenState: State | null = null;
307
+
308
+ await withCwd(projectRoot, async () => {
309
+ await runApprovePrototype(
310
+ {},
311
+ {
312
+ logFn: (msg) => logs.push(msg),
313
+ readStateFn: () => readState(projectRoot),
314
+ writeStateFn: async (_root, state) => {
315
+ writtenState = state;
316
+ await writeState(projectRoot, state);
317
+ },
318
+ checkGhAvailableFn: async () => true,
319
+ runGhPrCreateFn: async (root, title, body) => {
320
+ prCreateCalls.push({ projectRoot: root, title, body });
321
+ return { exitCode: 0, stdout: "https://github.com/owner/repo/pull/1", stderr: "" };
322
+ },
323
+ },
324
+ );
325
+ });
326
+
327
+ expect(prCreateCalls).toHaveLength(1);
328
+ expect(prCreateCalls[0].title).toBe(`feat: prototype it_${ITERATION}`);
329
+ expect(prCreateCalls[0].body).toBe(`Prototype for iteration it_${ITERATION}`);
330
+ expect(writtenState).not.toBeNull();
331
+ expect(writtenState!.phases.prototype.prototype_approved).toBe(true);
332
+ expect(writtenState!.updated_by).toBe("nvst:approve-prototype");
333
+ });
334
+
335
+ test("when gh is not available, prints skip message, exits with code 0, and still updates state", async () => {
336
+ const projectRoot = await createProjectRoot();
337
+ createdRoots.push(projectRoot);
338
+ await seedState(projectRoot, { iteration: ITERATION });
339
+ await runGit(projectRoot, "git init");
340
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
341
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
342
+ await runGit(projectRoot, "git branch -M main");
343
+ const remoteRoot = await createBareRemoteRoot();
344
+ createdRoots.push(remoteRoot);
345
+ await runGit(remoteRoot, "git init --bare");
346
+ await runGit(projectRoot, `git remote add origin ${remoteRoot}`);
347
+ await writeFile(join(projectRoot, "file.txt"), "initial\n", "utf8");
348
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
349
+ await writeFile(join(projectRoot, "file.txt"), "modified\n", "utf8");
350
+
351
+ const logs: string[] = [];
352
+ const prCreateCalls: Array<{ projectRoot: string; title: string; body: string }> = [];
353
+ let writtenState: State | null = null;
354
+ process.exitCode = 0;
355
+
356
+ await withCwd(projectRoot, async () => {
357
+ await runApprovePrototype(
358
+ {},
359
+ {
360
+ logFn: (msg) => logs.push(msg),
361
+ readStateFn: () => readState(projectRoot),
362
+ writeStateFn: async (_root, state) => {
363
+ writtenState = state;
364
+ await writeState(projectRoot, state);
365
+ },
366
+ checkGhAvailableFn: async () => false,
367
+ runGhPrCreateFn: async (root, title, body) => {
368
+ prCreateCalls.push({ projectRoot: root, title, body });
369
+ return { exitCode: 0, stdout: "", stderr: "" };
370
+ },
371
+ },
372
+ );
373
+ });
374
+
375
+ expect(logs).toContain("gh CLI not available; skipping PR creation.");
376
+ expect(prCreateCalls).toHaveLength(0);
377
+ expect(process.exitCode).toBe(0);
378
+ expect(writtenState).not.toBeNull();
379
+ expect(writtenState!.phases.prototype.prototype_approved).toBe(true);
380
+ });
381
+
382
+ test("when gh pr create fails, logs non-fatal warning and still updates state", async () => {
383
+ const projectRoot = await createProjectRoot();
384
+ createdRoots.push(projectRoot);
385
+ await seedState(projectRoot, { iteration: ITERATION });
386
+ await runGit(projectRoot, "git init");
387
+ await runGit(projectRoot, "git config user.email 'nvst@example.com'");
388
+ await runGit(projectRoot, "git config user.name 'NVST Test'");
389
+ await runGit(projectRoot, "git branch -M main");
390
+ const remoteRoot = await createBareRemoteRoot();
391
+ createdRoots.push(remoteRoot);
392
+ await runGit(remoteRoot, "git init --bare");
393
+ await runGit(projectRoot, `git remote add origin ${remoteRoot}`);
394
+ await writeFile(join(projectRoot, "file.txt"), "initial\n", "utf8");
395
+ await runGit(projectRoot, "git add -A && git commit -m 'chore: seed'");
396
+ await writeFile(join(projectRoot, "file.txt"), "modified\n", "utf8");
397
+
398
+ const logs: string[] = [];
399
+ let writtenState: State | null = null;
400
+
401
+ await withCwd(projectRoot, async () => {
402
+ await runApprovePrototype(
403
+ {},
404
+ {
405
+ logFn: (msg) => logs.push(msg),
406
+ readStateFn: () => readState(projectRoot),
407
+ writeStateFn: async (_root, state) => {
408
+ writtenState = state;
409
+ await writeState(projectRoot, state);
410
+ },
411
+ checkGhAvailableFn: async () => true,
412
+ runGhPrCreateFn: async () => ({
413
+ exitCode: 1,
414
+ stdout: "",
415
+ stderr: "a pull request for branch 'main' already exists",
416
+ }),
417
+ },
418
+ );
419
+ });
420
+
421
+ expect(logs.some((m) => m.includes("Warning: gh pr create failed"))).toBe(true);
422
+ expect(logs.some((m) => m.includes("already exists"))).toBe(true);
423
+ expect(writtenState).not.toBeNull();
424
+ expect(writtenState!.phases.prototype.prototype_approved).toBe(true);
425
+ });
426
+ });
427
+ });
@@ -0,0 +1,185 @@
1
+ import { $ as dollar } from "bun";
2
+
3
+ import { assertGuardrail } from "../guardrail";
4
+ import { readState, writeState } from "../state";
5
+
6
+ interface CommandResult {
7
+ exitCode: number;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+
12
+ interface ApprovePrototypeDeps {
13
+ logFn: (message: string) => void;
14
+ readStateFn: typeof readState;
15
+ writeStateFn: typeof writeState;
16
+ nowFn: () => Date;
17
+ runGitStatusFn: (projectRoot: string) => Promise<CommandResult>;
18
+ runStageAndCommitFn: (projectRoot: string, message: string) => Promise<CommandResult>;
19
+ runCheckPreCommitHookFn: (projectRoot: string) => Promise<boolean>;
20
+ runCurrentBranchFn: (projectRoot: string) => Promise<CommandResult>;
21
+ runPushFn: (projectRoot: string, branch: string) => Promise<CommandResult>;
22
+ checkGhAvailableFn: () => Promise<boolean>;
23
+ runGhPrCreateFn: (projectRoot: string, title: string, body: string) => Promise<CommandResult>;
24
+ }
25
+
26
+ const defaultDeps: ApprovePrototypeDeps = {
27
+ logFn: console.log,
28
+ readStateFn: readState,
29
+ writeStateFn: writeState,
30
+ nowFn: () => new Date(),
31
+ runGitStatusFn: async (projectRoot: string): Promise<CommandResult> => {
32
+ const result = await dollar`git status --porcelain`
33
+ .cwd(projectRoot)
34
+ .nothrow()
35
+ .quiet();
36
+ return {
37
+ exitCode: result.exitCode,
38
+ stdout: result.stdout.toString().trim(),
39
+ stderr: result.stderr.toString().trim(),
40
+ };
41
+ },
42
+ runStageAndCommitFn: async (projectRoot: string, message: string): Promise<CommandResult> => {
43
+ const result = await dollar`git add -A && git commit -m ${message}`
44
+ .cwd(projectRoot)
45
+ .nothrow()
46
+ .quiet();
47
+ return {
48
+ exitCode: result.exitCode,
49
+ stdout: result.stdout.toString().trim(),
50
+ stderr: result.stderr.toString().trim(),
51
+ };
52
+ },
53
+ runCheckPreCommitHookFn: async (projectRoot: string): Promise<boolean> => {
54
+ return Bun.file(`${projectRoot}/.git/hooks/pre-commit`).exists();
55
+ },
56
+ runCurrentBranchFn: async (projectRoot: string): Promise<CommandResult> => {
57
+ const result = await dollar`git branch --show-current`
58
+ .cwd(projectRoot)
59
+ .nothrow()
60
+ .quiet();
61
+ return {
62
+ exitCode: result.exitCode,
63
+ stdout: result.stdout.toString().trim(),
64
+ stderr: result.stderr.toString().trim(),
65
+ };
66
+ },
67
+ runPushFn: async (projectRoot: string, branch: string): Promise<CommandResult> => {
68
+ const result = await dollar`git push -u origin ${branch}`
69
+ .cwd(projectRoot)
70
+ .nothrow()
71
+ .quiet();
72
+ return {
73
+ exitCode: result.exitCode,
74
+ stdout: result.stdout.toString().trim(),
75
+ stderr: result.stderr.toString().trim(),
76
+ };
77
+ },
78
+ checkGhAvailableFn: async (): Promise<boolean> => {
79
+ const result = await dollar`gh --version`.nothrow().quiet();
80
+ return result.exitCode === 0;
81
+ },
82
+ runGhPrCreateFn: async (projectRoot: string, title: string, body: string): Promise<CommandResult> => {
83
+ const result = await dollar`gh pr create --title ${title} --body ${body}`
84
+ .cwd(projectRoot)
85
+ .nothrow()
86
+ .quiet();
87
+ return {
88
+ exitCode: result.exitCode,
89
+ stdout: result.stdout.toString().trim(),
90
+ stderr: result.stderr.toString().trim(),
91
+ };
92
+ },
93
+ };
94
+
95
+ export interface ApprovePrototypeOptions {
96
+ force?: boolean;
97
+ }
98
+
99
+ export async function runApprovePrototype(
100
+ opts: ApprovePrototypeOptions = {},
101
+ deps: Partial<ApprovePrototypeDeps> = {},
102
+ ): Promise<void> {
103
+ const mergedDeps = { ...defaultDeps, ...deps };
104
+ const { force = false } = opts;
105
+ const projectRoot = process.cwd();
106
+ const state = await mergedDeps.readStateFn(projectRoot);
107
+ const commitMessage = `feat: approve prototype it_${state.current_iteration}`;
108
+ const currentPhase = state.current_phase;
109
+
110
+ await assertGuardrail(
111
+ state,
112
+ currentPhase !== "prototype",
113
+ `Cannot approve prototype: current_phase must be 'prototype'. Current: '${currentPhase}'.`,
114
+ { force },
115
+ );
116
+
117
+ if (state.phases.prototype.prototype_approved) {
118
+ throw new Error(
119
+ "Cannot approve prototype: phases.prototype.prototype_approved is already true.",
120
+ );
121
+ }
122
+
123
+ const statusResult = await mergedDeps.runGitStatusFn(projectRoot);
124
+ if (statusResult.exitCode !== 0) {
125
+ throw new Error(
126
+ `Failed to inspect git working tree: ${statusResult.stderr || "git status command failed."}`,
127
+ );
128
+ }
129
+
130
+ if (!statusResult.stdout) {
131
+ mergedDeps.logFn("No pending changes to commit; working tree is clean.");
132
+ return;
133
+ }
134
+
135
+ const commitResult = await mergedDeps.runStageAndCommitFn(projectRoot, commitMessage);
136
+ if (commitResult.exitCode !== 0) {
137
+ const hasPreCommitHook = await mergedDeps.runCheckPreCommitHookFn(projectRoot);
138
+ if (hasPreCommitHook) {
139
+ process.exitCode = 1;
140
+ throw new Error(
141
+ `Pre-commit hook failed:\n${commitResult.stderr || commitResult.stdout || "hook exited non-zero."}`,
142
+ );
143
+ }
144
+ process.exitCode = 1;
145
+ throw new Error(
146
+ `Failed to create prototype approval commit: ${commitResult.stderr || "git commit command failed."}`,
147
+ );
148
+ }
149
+
150
+ const branchResult = await mergedDeps.runCurrentBranchFn(projectRoot);
151
+ if (branchResult.exitCode !== 0 || !branchResult.stdout) {
152
+ throw new Error(
153
+ `Failed to detect current branch for push: ${branchResult.stderr || "branch name is empty."}`,
154
+ );
155
+ }
156
+
157
+ const pushResult = await mergedDeps.runPushFn(projectRoot, branchResult.stdout);
158
+ if (pushResult.exitCode !== 0) {
159
+ process.exitCode = 1;
160
+ throw new Error(
161
+ `Failed to push prototype approval commit to origin/${branchResult.stdout}: ${pushResult.stderr || "git push command failed."}`,
162
+ );
163
+ }
164
+
165
+ const ghAvailable = await mergedDeps.checkGhAvailableFn();
166
+ if (ghAvailable) {
167
+ const prTitle = `feat: prototype it_${state.current_iteration}`;
168
+ const prBody = `Prototype for iteration it_${state.current_iteration}`;
169
+ const prResult = await mergedDeps.runGhPrCreateFn(projectRoot, prTitle, prBody);
170
+ if (prResult.exitCode !== 0) {
171
+ mergedDeps.logFn(
172
+ `Warning: gh pr create failed: ${prResult.stderr || prResult.stdout || "unknown error"}`,
173
+ );
174
+ }
175
+ } else {
176
+ mergedDeps.logFn("gh CLI not available; skipping PR creation.");
177
+ }
178
+
179
+ state.phases.prototype.prototype_approved = true;
180
+ state.last_updated = mergedDeps.nowFn().toISOString();
181
+ state.updated_by = "nvst:approve-prototype";
182
+ await mergedDeps.writeStateFn(projectRoot, state);
183
+
184
+ mergedDeps.logFn(`Committed prototype changes with message: ${commitMessage}`);
185
+ }