@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,12 +1,12 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { mkdtemp } from "node:fs/promises";
6
5
 
6
+ import type { AgentResult } from "../agent";
7
7
  import { readState, writeState } from "../state";
8
8
  import type { State } from "../../scaffold/schemas/tmpl_state";
9
- import { runCreatePrototype } from "./create-prototype";
9
+ import { promptForDirtyTreeCommit, runCreatePrototype, runPrePrototypeCommit } from "./create-prototype";
10
10
 
11
11
  async function createProjectRoot(): Promise<string> {
12
12
  return mkdtemp(join(tmpdir(), "nvst-create-prototype-"));
@@ -63,6 +63,36 @@ async function seedState(projectRoot: string, state: State): Promise<void> {
63
63
  await writeState(projectRoot, state);
64
64
  }
65
65
 
66
+ const MINIMAL_PRD = {
67
+ goals: [] as string[],
68
+ userStories: [{ id: "US-001", title: "T", description: "D", acceptanceCriteria: [{ id: "AC1", text: "T" }] }],
69
+ functionalRequirements: [] as Array<{ id?: string; description: string }>,
70
+ };
71
+
72
+ async function seedPrd(projectRoot: string, iteration: string): Promise<void> {
73
+ await writeFile(
74
+ join(projectRoot, ".agents", "flow", `it_${iteration}_PRD.json`),
75
+ JSON.stringify(MINIMAL_PRD),
76
+ "utf8",
77
+ );
78
+ }
79
+
80
+ async function seedProjectContext(projectRoot: string): Promise<void> {
81
+ await mkdir(join(projectRoot, ".agents"), { recursive: true });
82
+ await writeFile(join(projectRoot, ".agents", "PROJECT_CONTEXT.md"), "# Project Context\n", "utf8");
83
+ }
84
+
85
+ async function initGitRepo(projectRoot: string): Promise<void> {
86
+ const { $ } = await import("bun");
87
+ await $`git init`.cwd(projectRoot).nothrow().quiet();
88
+ await $`git config user.email "test@test" && git config user.name "Test"`.cwd(projectRoot).nothrow().quiet();
89
+ await $`git add -A && git commit -m init`.cwd(projectRoot).nothrow().quiet();
90
+ }
91
+
92
+ function makeAgentResult(exitCode: number): AgentResult {
93
+ return { exitCode, stdout: "", stderr: "" };
94
+ }
95
+
66
96
  const createdRoots: string[] = [];
67
97
 
68
98
  afterEach(async () => {
@@ -73,7 +103,9 @@ describe("create prototype phase validation", () => {
73
103
  test("throws when current_phase is define and PRD is not completed", async () => {
74
104
  const root = await createProjectRoot();
75
105
  createdRoots.push(root);
76
- await seedState(root, makeState({ currentPhase: "define", prdStatus: "pending", projectContextStatus: "pending" }));
106
+ const iteration = "000009";
107
+ await seedState(root, makeState({ currentPhase: "define", prdStatus: "pending", projectContextStatus: "pending", iteration }));
108
+ await seedPrd(root, iteration);
77
109
 
78
110
  await withCwd(root, async () => {
79
111
  await expect(runCreatePrototype({ provider: "claude" })).rejects.toThrow(
@@ -85,7 +117,9 @@ describe("create prototype phase validation", () => {
85
117
  test("throws when current_phase is define and project_context is not created", async () => {
86
118
  const root = await createProjectRoot();
87
119
  createdRoots.push(root);
88
- await seedState(root, makeState({ currentPhase: "define", prdStatus: "completed", projectContextStatus: "pending" }));
120
+ const iteration = "000009";
121
+ await seedState(root, makeState({ currentPhase: "define", prdStatus: "completed", projectContextStatus: "pending", iteration }));
122
+ await seedPrd(root, iteration);
89
123
 
90
124
  await withCwd(root, async () => {
91
125
  await expect(runCreatePrototype({ provider: "claude" })).rejects.toThrow(
@@ -94,34 +128,88 @@ describe("create prototype phase validation", () => {
94
128
  });
95
129
  });
96
130
 
97
- test("auto-transitions from define to prototype and starts build in same run when PRD and git are ready", async () => {
131
+ test("prompts and returns when dirty during define-to-prototype transition and confirmation is denied", async () => {
98
132
  const root = await createProjectRoot();
99
133
  createdRoots.push(root);
100
134
  const iteration = "000009";
101
135
  await seedState(root, makeState({ currentPhase: "define", prdStatus: "completed", projectContextStatus: "created", iteration }));
136
+ await seedPrd(root, iteration);
102
137
 
103
- const prdContent = {
104
- goals: ["Test"],
105
- userStories: [
106
- { id: "US-001", title: "One", description: "D", acceptanceCriteria: [{ id: "AC1", text: "T" }] },
107
- ],
108
- functionalRequirements: [{ id: "FR-001", description: "F" }],
109
- };
138
+ await mkdir(join(root, ".agents"), { recursive: true });
110
139
  await writeFile(
111
- join(root, ".agents", "flow", `it_${iteration}_PRD.json`),
112
- JSON.stringify(prdContent),
140
+ join(root, ".agents", "PROJECT_CONTEXT.md"),
141
+ "# Project\n## Testing Strategy\n### Quality Checks\n```\nbun test\n```\n",
113
142
  "utf8",
114
143
  );
115
144
 
116
145
  const { $ } = await import("bun");
117
146
  await $`git init`.cwd(root).nothrow().quiet();
147
+ await $`git config user.email "test@test" && git config user.name "Test"`.cwd(root).nothrow().quiet();
148
+ await $`git add -A && git commit -m init`.cwd(root).nothrow().quiet();
149
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
150
+
151
+ const prompts: string[] = [];
152
+ const logs: string[] = [];
118
153
 
119
154
  await withCwd(root, async () => {
120
- await expect(runCreatePrototype({ provider: "claude" })).rejects.toThrow(
121
- "Required skill missing",
155
+ await expect(runCreatePrototype(
156
+ { provider: "claude" },
157
+ {
158
+ promptDirtyTreeCommitFn: async (question) => {
159
+ prompts.push(question);
160
+ return false;
161
+ },
162
+ logFn: (message) => {
163
+ logs.push(message);
164
+ },
165
+ },
166
+ )).resolves.toBeUndefined();
167
+ });
168
+
169
+ expect(prompts).toEqual([
170
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
171
+ ]);
172
+ expect(logs).toContain("Aborted. Commit or discard your changes and re-run `bun nvst create prototype`.");
173
+
174
+ // No transition happens when confirmation is denied.
175
+ const updatedState = await readState(root);
176
+ expect(updatedState.current_phase).toBe("define");
177
+ });
178
+
179
+ test("commits dirty tree on confirmation during define-to-prototype transition and transitions state to prototype", async () => {
180
+ const root = await createProjectRoot();
181
+ createdRoots.push(root);
182
+ const iteration = "000009";
183
+ await seedState(root, makeState({ currentPhase: "define", prdStatus: "completed", projectContextStatus: "created", iteration }));
184
+ await seedPrd(root, iteration);
185
+ await seedProjectContext(root);
186
+ await initGitRepo(root);
187
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
188
+
189
+ const commitMessages: string[] = [];
190
+
191
+ await withCwd(root, async () => {
192
+ await runCreatePrototype(
193
+ { provider: "claude" },
194
+ {
195
+ promptDirtyTreeCommitFn: async (_question) => true,
196
+ gitAddAndCommitFn: async (projectRoot, commitMessage) => {
197
+ commitMessages.push(commitMessage);
198
+ const { $ } = await import("bun");
199
+ await $`git add -A && git commit -m ${commitMessage}`.cwd(projectRoot).nothrow().quiet();
200
+ },
201
+ loadSkillFn: async () => "Implement story",
202
+ invokeAgentFn: async ({ cwd }) => {
203
+ if (!cwd) throw new Error("Expected cwd");
204
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
205
+ return makeAgentResult(0);
206
+ },
207
+ checkGhAvailableFn: async () => false,
208
+ },
122
209
  );
123
210
  });
124
211
 
212
+ expect(commitMessages).toEqual(["chore: pre-prototype commit it_000009"]);
125
213
  const updatedState = await readState(root);
126
214
  expect(updatedState.current_phase).toBe("prototype");
127
215
  });
@@ -129,7 +217,9 @@ describe("create prototype phase validation", () => {
129
217
  test("throws when current_phase is refactor", async () => {
130
218
  const root = await createProjectRoot();
131
219
  createdRoots.push(root);
132
- await seedState(root, makeState({ currentPhase: "refactor" }));
220
+ const iteration = "000009";
221
+ await seedState(root, makeState({ currentPhase: "refactor", iteration }));
222
+ await seedPrd(root, iteration);
133
223
 
134
224
  await withCwd(root, async () => {
135
225
  await expect(runCreatePrototype({ provider: "claude" })).rejects.toThrow(
@@ -150,4 +240,384 @@ describe("create prototype phase validation", () => {
150
240
  );
151
241
  });
152
242
  });
243
+
244
+ test("prompts at the post-transition dirty-tree check when phase is already prototype", async () => {
245
+ const root = await createProjectRoot();
246
+ createdRoots.push(root);
247
+ const iteration = "000009";
248
+ await seedState(root, makeState({ currentPhase: "prototype", prdStatus: "completed", projectContextStatus: "created", iteration }));
249
+ await seedPrd(root, iteration);
250
+ await seedProjectContext(root);
251
+ await initGitRepo(root);
252
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
253
+
254
+ const prompts: string[] = [];
255
+ const logs: string[] = [];
256
+ let gitAddAndCommitCalled = false;
257
+ const statePath = join(root, ".agents", "state.json");
258
+ const stateBefore = await readFile(statePath, "utf8");
259
+ process.exitCode = 0;
260
+
261
+ await withCwd(root, async () => {
262
+ await expect(runCreatePrototype(
263
+ { provider: "claude" },
264
+ {
265
+ promptDirtyTreeCommitFn: async (question) => {
266
+ prompts.push(question);
267
+ return false;
268
+ },
269
+ logFn: (message) => {
270
+ logs.push(message);
271
+ },
272
+ gitAddAndCommitFn: async () => {
273
+ gitAddAndCommitCalled = true;
274
+ },
275
+ },
276
+ )).resolves.toBeUndefined();
277
+ });
278
+
279
+ expect(prompts).toEqual([
280
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
281
+ ]);
282
+ expect(logs).toContain("Aborted. Commit or discard your changes and re-run `bun nvst create prototype`.");
283
+ expect(gitAddAndCommitCalled).toBe(false);
284
+ expect(process.exitCode).not.toBe(1);
285
+ const stateAfter = await readFile(statePath, "utf8");
286
+ expect(stateAfter).toBe(stateBefore);
287
+ });
288
+
289
+ test("continues build when dirty-tree prompt is confirmed and calls gitAddAndCommitFn with commit message", async () => {
290
+ const root = await createProjectRoot();
291
+ createdRoots.push(root);
292
+ const iteration = "000018";
293
+ await seedState(root, makeState({ currentPhase: "prototype", prdStatus: "completed", projectContextStatus: "created", iteration }));
294
+ await seedPrd(root, iteration);
295
+ await seedProjectContext(root);
296
+ await initGitRepo(root);
297
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
298
+
299
+ const prompts: string[] = [];
300
+ const commitMessages: string[] = [];
301
+
302
+ await withCwd(root, async () => {
303
+ await expect(runCreatePrototype(
304
+ { provider: "claude" },
305
+ {
306
+ promptDirtyTreeCommitFn: async (question) => {
307
+ prompts.push(question);
308
+ return true;
309
+ },
310
+ gitAddAndCommitFn: async (_projectRoot, commitMessage) => {
311
+ commitMessages.push(commitMessage);
312
+ },
313
+ loadSkillFn: async () => "Implement story",
314
+ invokeAgentFn: async ({ cwd }) => {
315
+ if (!cwd) {
316
+ throw new Error("Expected cwd");
317
+ }
318
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
319
+ return makeAgentResult(0);
320
+ },
321
+ checkGhAvailableFn: async () => false,
322
+ },
323
+ )).resolves.toBeUndefined();
324
+ });
325
+
326
+ expect(prompts).toEqual([
327
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
328
+ ]);
329
+ expect(commitMessages).toEqual(["chore: pre-prototype commit it_000018"]);
330
+ });
331
+
332
+ test("commits dirty tree on confirmation and proceeds with prototype build", async () => {
333
+ const root = await createProjectRoot();
334
+ createdRoots.push(root);
335
+ const iteration = "000018";
336
+ await seedState(root, makeState({ currentPhase: "prototype", prdStatus: "completed", projectContextStatus: "created", iteration }));
337
+ await seedPrd(root, iteration);
338
+ await seedProjectContext(root);
339
+ await initGitRepo(root);
340
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
341
+
342
+ let promptCount = 0;
343
+
344
+ await withCwd(root, async () => {
345
+ await runCreatePrototype(
346
+ { provider: "claude" },
347
+ {
348
+ promptDirtyTreeCommitFn: async () => {
349
+ promptCount += 1;
350
+ return true;
351
+ },
352
+ loadSkillFn: async () => "Implement story",
353
+ invokeAgentFn: async ({ cwd }) => {
354
+ if (!cwd) {
355
+ throw new Error("Expected cwd");
356
+ }
357
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
358
+ return makeAgentResult(0);
359
+ },
360
+ checkGhAvailableFn: async () => false,
361
+ },
362
+ );
363
+ });
364
+
365
+ const { $ } = await import("bun");
366
+ const preCommitCountResult = await $`git log --oneline --grep "chore: pre-prototype commit it_000018"`.cwd(root).nothrow().quiet();
367
+ expect(preCommitCountResult.exitCode).toBe(0);
368
+ expect(preCommitCountResult.stdout.toString().trim().length).toBeGreaterThan(0);
369
+
370
+ expect(promptCount).toBe(1);
371
+
372
+ const updatedState = await readState(root);
373
+ expect(updatedState.phases.prototype.prototype_build.status).toBe("created");
374
+ });
375
+
376
+ test("throws pre-prototype commit failure and does not continue build", async () => {
377
+ const root = await createProjectRoot();
378
+ createdRoots.push(root);
379
+ const iteration = "000018";
380
+ await seedState(root, makeState({ currentPhase: "prototype", prdStatus: "completed", projectContextStatus: "created", iteration }));
381
+ await seedPrd(root, iteration);
382
+ await seedProjectContext(root);
383
+ await initGitRepo(root);
384
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
385
+
386
+ let agentCalled = false;
387
+
388
+ await withCwd(root, async () => {
389
+ await expect(runCreatePrototype(
390
+ { provider: "claude" },
391
+ {
392
+ promptDirtyTreeCommitFn: async () => true,
393
+ gitAddAndCommitFn: async () => {
394
+ throw new Error("hook rejected");
395
+ },
396
+ invokeAgentFn: async () => {
397
+ agentCalled = true;
398
+ return makeAgentResult(0);
399
+ },
400
+ },
401
+ )).rejects.toThrow("Pre-prototype commit failed:\nhook rejected");
402
+ });
403
+
404
+ expect(agentCalled).toBe(false);
405
+ });
406
+ });
407
+
408
+ describe("promptForDirtyTreeCommit", () => {
409
+ test("returns true for lowercase y and uppercase Y", async () => {
410
+ expect(
411
+ await promptForDirtyTreeCommit(
412
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
413
+ async () => "y",
414
+ () => {},
415
+ () => true,
416
+ ),
417
+ ).toBe(true);
418
+
419
+ expect(
420
+ await promptForDirtyTreeCommit(
421
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
422
+ async () => "Y",
423
+ () => {},
424
+ () => true,
425
+ ),
426
+ ).toBe(true);
427
+ });
428
+
429
+ test("treats other input, empty input, and no input as no", async () => {
430
+ expect(
431
+ await promptForDirtyTreeCommit(
432
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
433
+ async () => "n",
434
+ () => {},
435
+ () => true,
436
+ ),
437
+ ).toBe(false);
438
+
439
+ expect(
440
+ await promptForDirtyTreeCommit(
441
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
442
+ async () => "",
443
+ () => {},
444
+ () => true,
445
+ ),
446
+ ).toBe(false);
447
+
448
+ expect(
449
+ await promptForDirtyTreeCommit(
450
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
451
+ async () => null,
452
+ () => {},
453
+ () => true,
454
+ ),
455
+ ).toBe(false);
456
+ });
457
+
458
+ test("TC-005: returns false immediately without reading input when stdin is not a TTY", async () => {
459
+ let readLineCalled = false;
460
+ let writeCalled = false;
461
+
462
+ const result = await promptForDirtyTreeCommit(
463
+ "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]",
464
+ async () => {
465
+ readLineCalled = true;
466
+ return "y";
467
+ },
468
+ () => {
469
+ writeCalled = true;
470
+ },
471
+ () => false,
472
+ );
473
+
474
+ expect(result).toBe(false);
475
+ expect(readLineCalled).toBe(false);
476
+ expect(writeCalled).toBe(false);
477
+ });
478
+ });
479
+
480
+ describe("runPrePrototypeCommit", () => {
481
+ test("runs git add and creates commit with required message", async () => {
482
+ const root = await createProjectRoot();
483
+ createdRoots.push(root);
484
+ await initGitRepo(root);
485
+ await writeFile(join(root, "dirty.txt"), "dirty\n", "utf8");
486
+
487
+ await runPrePrototypeCommit(root, "000018");
488
+
489
+ const { $ } = await import("bun");
490
+ const commitMsg = await $`git log -1 --pretty=%s`.cwd(root).nothrow().quiet();
491
+ expect(commitMsg.exitCode).toBe(0);
492
+ expect(commitMsg.stdout.toString().trim()).toBe("chore: pre-prototype commit it_000018");
493
+
494
+ const status = await $`git status --porcelain`.cwd(root).nothrow().quiet();
495
+ expect(status.exitCode).toBe(0);
496
+ expect(status.stdout.toString().trim()).toBe("");
497
+ });
498
+ });
499
+
500
+ describe("create prototype gh PR creation", () => {
501
+ test("runs gh pr create with generated title/body when gh is available", async () => {
502
+ const root = await createProjectRoot();
503
+ createdRoots.push(root);
504
+ const iteration = "000016";
505
+ await seedState(root, makeState({ currentPhase: "prototype", projectContextStatus: "created", iteration }));
506
+ await seedPrd(root, iteration);
507
+ await seedProjectContext(root);
508
+ await initGitRepo(root);
509
+
510
+ let prTitle = "";
511
+ let prBody = "";
512
+
513
+ await withCwd(root, async () => {
514
+ await runCreatePrototype(
515
+ { provider: "claude" },
516
+ {
517
+ promptDirtyTreeCommitFn: async () => true,
518
+ loadSkillFn: async () => "Implement story",
519
+ invokeAgentFn: async ({ cwd }) => {
520
+ if (!cwd) {
521
+ throw new Error("Expected cwd");
522
+ }
523
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
524
+ return makeAgentResult(0);
525
+ },
526
+ checkGhAvailableFn: async () => true,
527
+ createPullRequestFn: async (_projectRoot, title, body) => {
528
+ prTitle = title;
529
+ prBody = body;
530
+ return { exitCode: 0, stderr: "" };
531
+ },
532
+ },
533
+ );
534
+ });
535
+
536
+ expect(prTitle).toBe("feat: prototype it_000016");
537
+ expect(prBody).toBe("Prototype for iteration it_000016");
538
+ });
539
+
540
+ test("logs skip message and exits cleanly when gh is unavailable", async () => {
541
+ const root = await createProjectRoot();
542
+ createdRoots.push(root);
543
+ const iteration = "000016";
544
+ await seedState(root, makeState({ currentPhase: "prototype", projectContextStatus: "created", iteration }));
545
+ await seedPrd(root, iteration);
546
+ await seedProjectContext(root);
547
+ await initGitRepo(root);
548
+
549
+ let createPrCalled = false;
550
+ const logs: string[] = [];
551
+
552
+ await withCwd(root, async () => {
553
+ await runCreatePrototype(
554
+ { provider: "claude" },
555
+ {
556
+ promptDirtyTreeCommitFn: async () => true,
557
+ loadSkillFn: async () => "Implement story",
558
+ invokeAgentFn: async ({ cwd }) => {
559
+ if (!cwd) {
560
+ throw new Error("Expected cwd");
561
+ }
562
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
563
+ return makeAgentResult(0);
564
+ },
565
+ checkGhAvailableFn: async () => false,
566
+ createPullRequestFn: async () => {
567
+ createPrCalled = true;
568
+ return { exitCode: 0, stderr: "" };
569
+ },
570
+ logFn: (message) => {
571
+ logs.push(message);
572
+ },
573
+ },
574
+ );
575
+ });
576
+
577
+ expect(createPrCalled).toBe(false);
578
+ expect(logs).toContain("gh CLI not found — skipping PR creation");
579
+ });
580
+
581
+ test("treats gh pr create failures as non-fatal warnings and still updates state", async () => {
582
+ const root = await createProjectRoot();
583
+ createdRoots.push(root);
584
+ const iteration = "000016";
585
+ await seedState(root, makeState({ currentPhase: "prototype", projectContextStatus: "created", iteration }));
586
+ await seedPrd(root, iteration);
587
+ await seedProjectContext(root);
588
+ await initGitRepo(root);
589
+
590
+ const warnings: string[] = [];
591
+
592
+ await withCwd(root, async () => {
593
+ await runCreatePrototype(
594
+ { provider: "claude" },
595
+ {
596
+ promptDirtyTreeCommitFn: async () => true,
597
+ loadSkillFn: async () => "Implement story",
598
+ invokeAgentFn: async ({ cwd }) => {
599
+ if (!cwd) {
600
+ throw new Error("Expected cwd");
601
+ }
602
+ await writeFile(join(cwd, "story.txt"), "implemented\n", "utf8");
603
+ return makeAgentResult(0);
604
+ },
605
+ checkGhAvailableFn: async () => true,
606
+ createPullRequestFn: async () => ({
607
+ exitCode: 1,
608
+ stderr: "a pull request for branch already exists",
609
+ }),
610
+ warnFn: (message) => {
611
+ warnings.push(message);
612
+ },
613
+ },
614
+ );
615
+ });
616
+
617
+ expect(warnings).toHaveLength(1);
618
+ expect(warnings[0]).toContain("gh pr create failed (non-fatal)");
619
+
620
+ const updatedState = await readState(root);
621
+ expect(updatedState.phases.prototype.prototype_build.status).toBe("created");
622
+ });
153
623
  });