@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,755 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import type { State } from "../../scaffold/schemas/tmpl_state";
6
+ import { StateSchema } from "../../scaffold/schemas/tmpl_state";
7
+ import { GuardrailAbortError } from "../guardrail";
8
+ import {
9
+ buildApprovalGateMessage,
10
+ FLOW_APPROVAL_GATE_PREFIX,
11
+ FLOW_APPROVAL_TARGETS,
12
+ FLOW_STEPS,
13
+ } from "./flow-config";
14
+ import { detectNextFlowDecision, runFlow } from "./flow";
15
+
16
+ function createBaseState(): State {
17
+ return {
18
+ current_iteration: "000019",
19
+ current_phase: "prototype",
20
+ flow_guardrail: "strict",
21
+ phases: {
22
+ define: {
23
+ requirement_definition: { status: "approved", file: "it_000019_product-requirement-document.md" },
24
+ prd_generation: { status: "completed", file: "it_000019_PRD.json" },
25
+ },
26
+ prototype: {
27
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
28
+ test_plan: { status: "pending", file: null },
29
+ tp_generation: { status: "pending", file: null },
30
+ prototype_build: { status: "pending", file: null },
31
+ test_execution: { status: "pending", file: null },
32
+ prototype_approved: false,
33
+ },
34
+ refactor: {
35
+ evaluation_report: { status: "pending", file: null },
36
+ refactor_plan: { status: "pending", file: null },
37
+ refactor_execution: { status: "pending", file: null },
38
+ changelog: { status: "pending", file: null },
39
+ },
40
+ },
41
+ last_updated: "2026-02-27T00:00:00.000Z",
42
+ };
43
+ }
44
+
45
+ function withState(base: State, mutate: (state: State) => void): State {
46
+ const cloned = structuredClone(base);
47
+ mutate(cloned);
48
+ return cloned;
49
+ }
50
+
51
+ describe("US-001: flow command", () => {
52
+ let previousExitCode: typeof process.exitCode;
53
+
54
+ beforeEach(() => {
55
+ previousExitCode = process.exitCode;
56
+ process.exitCode = undefined;
57
+ });
58
+
59
+ afterEach(() => {
60
+ process.exitCode = previousExitCode ?? 0;
61
+ });
62
+
63
+ test("AC01: detectNextFlowDecision identifies next pending step from phase/status", () => {
64
+ const state = createBaseState();
65
+ const decision = detectNextFlowDecision(state);
66
+
67
+ expect(decision.kind).toBe("step");
68
+ if (decision.kind === "step") {
69
+ expect(decision.step.id).toBe(FLOW_STEPS["create-test-plan"].id);
70
+ }
71
+ });
72
+
73
+ test("AC01: resolves from canonical phase order even when current_phase is stale", () => {
74
+ const state = withState(createBaseState(), (s) => {
75
+ s.current_phase = "define";
76
+ });
77
+ const decision = detectNextFlowDecision(state);
78
+
79
+ expect(decision.kind).toBe("step");
80
+ if (decision.kind === "step") {
81
+ expect(decision.step.id).toBe(FLOW_STEPS["create-test-plan"].id);
82
+ }
83
+ });
84
+
85
+ test("AC02 + AC03: delegates to existing handlers and re-reads state between chained steps", async () => {
86
+ const base = createBaseState();
87
+ const s1 = withState(base, (s) => {
88
+ s.current_phase = "prototype";
89
+ s.phases.prototype.prototype_build.status = "pending";
90
+ s.phases.prototype.test_plan.status = "pending";
91
+ });
92
+ const s2 = withState(base, (s) => {
93
+ s.current_phase = "prototype";
94
+ s.phases.prototype.prototype_build.status = "pending";
95
+ s.phases.prototype.test_plan.status = "created";
96
+ s.phases.prototype.tp_generation.status = "created";
97
+ });
98
+ const s3 = withState(base, (s) => {
99
+ s.current_phase = "prototype";
100
+ s.phases.prototype.prototype_build.status = "created";
101
+ s.phases.prototype.test_plan.status = "created";
102
+ s.phases.prototype.tp_generation.status = "created";
103
+ s.phases.prototype.test_execution.status = "completed";
104
+ s.phases.prototype.prototype_approved = false;
105
+ });
106
+
107
+ const reads: State[] = [s1, s2, s3];
108
+ let readCount = 0;
109
+ const called: string[] = [];
110
+
111
+ await runFlow(
112
+ { provider: "codex" },
113
+ {
114
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
115
+ runCreatePrototypeFn: async () => {
116
+ called.push(FLOW_STEPS["create-prototype"].id);
117
+ },
118
+ runCreateTestPlanFn: async () => {
119
+ called.push(FLOW_STEPS["create-test-plan"].id);
120
+ },
121
+ runCreateProjectContextFn: async () => {
122
+ called.push(FLOW_STEPS["create-project-context"].id);
123
+ },
124
+ runDefineRequirementFn: async () => {
125
+ called.push(FLOW_STEPS["define-requirement"].id);
126
+ },
127
+ runExecuteTestPlanFn: async () => {
128
+ called.push(FLOW_STEPS["execute-test-plan"].id);
129
+ },
130
+ runDefineRefactorPlanFn: async () => {
131
+ called.push(FLOW_STEPS["define-refactor-plan"].id);
132
+ },
133
+ runExecuteRefactorFn: async () => {
134
+ called.push(FLOW_STEPS["execute-refactor"].id);
135
+ },
136
+ stdoutWriteFn: () => {},
137
+ stderrWriteFn: () => {},
138
+ },
139
+ );
140
+
141
+ expect(called).toEqual([FLOW_STEPS["create-test-plan"].id, FLOW_STEPS["create-prototype"].id]);
142
+ expect(readCount).toBe(3);
143
+ });
144
+
145
+ test("AC04: stops at approval gate and when iteration is complete", async () => {
146
+ const approvalGateState = withState(createBaseState(), (s) => {
147
+ s.current_phase = "prototype";
148
+ s.phases.prototype.test_plan.status = "pending_approval";
149
+ s.phases.prototype.test_plan.file = "it_000019_test-plan.md";
150
+ });
151
+
152
+ const completeState = withState(createBaseState(), (s) => {
153
+ s.current_phase = "refactor";
154
+ s.phases.prototype.test_plan.status = "created";
155
+ s.phases.prototype.tp_generation.status = "created";
156
+ s.phases.prototype.prototype_build.status = "created";
157
+ s.phases.prototype.test_execution.status = "completed";
158
+ s.phases.prototype.prototype_approved = true;
159
+ s.phases.refactor.evaluation_report.status = "created";
160
+ s.phases.refactor.refactor_plan.status = "approved";
161
+ s.phases.refactor.refactor_execution.status = "completed";
162
+ });
163
+
164
+ const logs: string[] = [];
165
+ await runFlow(
166
+ { provider: "codex" },
167
+ {
168
+ readStateFn: async () => approvalGateState,
169
+ stdoutWriteFn: (message) => logs.push(message),
170
+ stderrWriteFn: () => {},
171
+ },
172
+ );
173
+ expect(logs.some((line) => line.includes("Waiting for approval"))).toBe(true);
174
+
175
+ logs.length = 0;
176
+ await runFlow(
177
+ { provider: "codex" },
178
+ {
179
+ readStateFn: async () => completeState,
180
+ stdoutWriteFn: (message) => logs.push(message),
181
+ stderrWriteFn: () => {},
182
+ },
183
+ );
184
+ expect(logs).toContain("Iteration 000019 complete. All phases finished.");
185
+ });
186
+
187
+ test("AC05: prompts for provider from stdin when --agent is not provided", async () => {
188
+ const base = createBaseState();
189
+ const s1 = withState(base, (s) => {
190
+ s.current_phase = "define";
191
+ s.phases.define.requirement_definition.status = "pending";
192
+ s.phases.define.prd_generation.status = "pending";
193
+ });
194
+ const s2 = withState(base, (s) => {
195
+ s.current_phase = "define";
196
+ s.phases.define.requirement_definition.status = "in_progress";
197
+ s.phases.define.prd_generation.status = "pending";
198
+ });
199
+
200
+ const reads: State[] = [s1, s2];
201
+ let readCount = 0;
202
+ let delegatedProvider = "";
203
+ const out: string[] = [];
204
+
205
+ await runFlow(
206
+ {},
207
+ {
208
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
209
+ readLineFn: async () => "codex",
210
+ runDefineRequirementFn: async (opts) => {
211
+ delegatedProvider = opts.provider;
212
+ },
213
+ stdoutWriteFn: (message) => out.push(message),
214
+ stderrWriteFn: () => {},
215
+ },
216
+ );
217
+
218
+ expect(out).toContain("Enter agent provider:");
219
+ expect(delegatedProvider).toBe("codex");
220
+ });
221
+
222
+ test("AC06: passes --force through to delegated handlers (guardrail behavior parity)", async () => {
223
+ const base = createBaseState();
224
+ const s1 = withState(base, (s) => {
225
+ s.current_phase = "prototype";
226
+ s.phases.prototype.test_plan.status = "created";
227
+ s.phases.prototype.tp_generation.status = "created";
228
+ s.phases.prototype.prototype_build.status = "pending";
229
+ });
230
+ const s2 = withState(base, (s) => {
231
+ s.current_phase = "prototype";
232
+ s.phases.prototype.test_plan.status = "created";
233
+ s.phases.prototype.tp_generation.status = "created";
234
+ s.phases.prototype.prototype_build.status = "created";
235
+ s.phases.prototype.test_execution.status = "completed";
236
+ });
237
+
238
+ const reads: State[] = [s1, s2];
239
+ let readCount = 0;
240
+ let receivedForce = false;
241
+
242
+ await runFlow(
243
+ { provider: "codex", force: true },
244
+ {
245
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
246
+ runCreatePrototypeFn: async (opts) => {
247
+ receivedForce = opts.force ?? false;
248
+ },
249
+ stdoutWriteFn: () => {},
250
+ stderrWriteFn: () => {},
251
+ },
252
+ );
253
+
254
+ expect(receivedForce).toBe(true);
255
+ });
256
+
257
+ test("AC07: stops immediately on delegated command error, writes to stderr, and sets non-zero exit", async () => {
258
+ const base = createBaseState();
259
+ const s1 = withState(base, (s) => {
260
+ s.current_phase = "prototype";
261
+ s.phases.prototype.test_plan.status = "created";
262
+ s.phases.prototype.tp_generation.status = "created";
263
+ s.phases.prototype.prototype_build.status = "pending";
264
+ });
265
+ const s2 = withState(base, (s) => {
266
+ s.current_phase = "prototype";
267
+ s.phases.prototype.prototype_build.status = "created";
268
+ s.phases.prototype.test_plan.status = "created";
269
+ s.phases.prototype.tp_generation.status = "created";
270
+ s.phases.prototype.test_execution.status = "pending";
271
+ });
272
+
273
+ const reads: State[] = [s1, s2];
274
+ let readCount = 0;
275
+ let nextCalled = false;
276
+ const errors: string[] = [];
277
+
278
+ await runFlow(
279
+ { provider: "codex" },
280
+ {
281
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
282
+ runCreatePrototypeFn: async () => {
283
+ throw new Error("boom");
284
+ },
285
+ runCreateTestPlanFn: async () => {
286
+ nextCalled = true;
287
+ },
288
+ stdoutWriteFn: () => {},
289
+ stderrWriteFn: (message) => errors.push(message),
290
+ },
291
+ );
292
+
293
+ expect(nextCalled).toBe(false);
294
+ expect(errors).toContain("boom");
295
+ expect(process.exitCode).toBe(1);
296
+ });
297
+
298
+ test("AC07: rethrows GuardrailAbortError without duplicate stderr output or exit mutation", async () => {
299
+ const base = createBaseState();
300
+ const state = withState(base, (s) => {
301
+ s.current_phase = "prototype";
302
+ s.phases.prototype.test_plan.status = "created";
303
+ s.phases.prototype.tp_generation.status = "created";
304
+ s.phases.prototype.prototype_build.status = "pending";
305
+ });
306
+
307
+ const errors: string[] = [];
308
+ process.exitCode = undefined;
309
+
310
+ await expect(
311
+ runFlow(
312
+ { provider: "codex" },
313
+ {
314
+ readStateFn: async () => state,
315
+ runCreatePrototypeFn: async () => {
316
+ throw new GuardrailAbortError();
317
+ },
318
+ stdoutWriteFn: () => {},
319
+ stderrWriteFn: (message) => errors.push(message),
320
+ },
321
+ ),
322
+ ).rejects.toBeInstanceOf(GuardrailAbortError);
323
+
324
+ expect(errors).toEqual([]);
325
+ expect(process.exitCode === undefined || process.exitCode === 0).toBe(true);
326
+ });
327
+
328
+ test("AC08: treats prototype_build in_progress as resumable and re-executes create-prototype", async () => {
329
+ const base = createBaseState();
330
+ const s1 = withState(base, (s) => {
331
+ s.current_phase = "prototype";
332
+ s.phases.prototype.prototype_build.status = "in_progress";
333
+ s.phases.prototype.project_context.status = "created";
334
+ s.phases.prototype.test_plan.status = "created";
335
+ s.phases.prototype.tp_generation.status = "created";
336
+ });
337
+ const s2 = withState(base, (s) => {
338
+ s.current_phase = "prototype";
339
+ s.phases.prototype.test_plan.status = "created";
340
+ s.phases.prototype.tp_generation.status = "created";
341
+ s.phases.prototype.project_context.status = "created";
342
+ s.phases.prototype.prototype_build.status = "created";
343
+ s.phases.prototype.test_execution.status = "completed";
344
+ });
345
+
346
+ const reads: State[] = [s1, s2];
347
+ let readCount = 0;
348
+ let rerunCount = 0;
349
+
350
+ await runFlow(
351
+ { provider: "codex" },
352
+ {
353
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
354
+ runCreatePrototypeFn: async () => {
355
+ rerunCount += 1;
356
+ },
357
+ stdoutWriteFn: () => {},
358
+ stderrWriteFn: () => {},
359
+ },
360
+ );
361
+
362
+ expect(rerunCount).toBe(1);
363
+ });
364
+
365
+ test("AC08: treats test_execution in_progress as resumable and re-executes execute-test-plan", async () => {
366
+ const base = createBaseState();
367
+ const s1 = withState(base, (s) => {
368
+ s.current_phase = "prototype";
369
+ s.phases.prototype.project_context.status = "created";
370
+ s.phases.prototype.test_plan.status = "created";
371
+ s.phases.prototype.tp_generation.status = "created";
372
+ s.phases.prototype.prototype_build.status = "created";
373
+ s.phases.prototype.test_execution.status = "in_progress";
374
+ s.phases.prototype.test_execution.file = "it_000019_test-execution-results.json";
375
+ });
376
+ const s2 = withState(base, (s) => {
377
+ s.current_phase = "prototype";
378
+ s.phases.prototype.project_context.status = "created";
379
+ s.phases.prototype.test_plan.status = "created";
380
+ s.phases.prototype.tp_generation.status = "created";
381
+ s.phases.prototype.prototype_build.status = "created";
382
+ s.phases.prototype.test_execution.status = "completed";
383
+ });
384
+
385
+ const reads: State[] = [s1, s2];
386
+ let readCount = 0;
387
+ let rerunCount = 0;
388
+
389
+ await runFlow(
390
+ { provider: "codex" },
391
+ {
392
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
393
+ runExecuteTestPlanFn: async () => {
394
+ rerunCount += 1;
395
+ },
396
+ stdoutWriteFn: () => {},
397
+ stderrWriteFn: () => {},
398
+ },
399
+ );
400
+
401
+ expect(rerunCount).toBe(1);
402
+ });
403
+
404
+ test("AC08: treats refactor_execution in_progress as resumable and re-executes execute-refactor", async () => {
405
+ const base = createBaseState();
406
+ const s1 = withState(base, (s) => {
407
+ s.current_phase = "refactor";
408
+ s.phases.prototype.project_context.status = "created";
409
+ s.phases.prototype.test_plan.status = "created";
410
+ s.phases.prototype.tp_generation.status = "created";
411
+ s.phases.prototype.prototype_build.status = "created";
412
+ s.phases.prototype.test_execution.status = "completed";
413
+ s.phases.prototype.prototype_approved = true;
414
+ s.phases.refactor.evaluation_report.status = "created";
415
+ s.phases.refactor.refactor_plan.status = "approved";
416
+ s.phases.refactor.refactor_execution.status = "in_progress";
417
+ s.phases.refactor.refactor_execution.file = "it_000019_refactor-execution-progress.json";
418
+ });
419
+ const s2 = withState(base, (s) => {
420
+ s.current_phase = "refactor";
421
+ s.phases.prototype.project_context.status = "created";
422
+ s.phases.prototype.test_plan.status = "created";
423
+ s.phases.prototype.tp_generation.status = "created";
424
+ s.phases.prototype.prototype_build.status = "created";
425
+ s.phases.prototype.test_execution.status = "completed";
426
+ s.phases.prototype.prototype_approved = true;
427
+ s.phases.refactor.evaluation_report.status = "created";
428
+ s.phases.refactor.refactor_plan.status = "approved";
429
+ s.phases.refactor.refactor_execution.status = "completed";
430
+ });
431
+
432
+ const reads: State[] = [s1, s2];
433
+ let readCount = 0;
434
+ let rerunCount = 0;
435
+
436
+ await runFlow(
437
+ { provider: "codex" },
438
+ {
439
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
440
+ runExecuteRefactorFn: async () => {
441
+ rerunCount += 1;
442
+ },
443
+ stdoutWriteFn: () => {},
444
+ stderrWriteFn: () => {},
445
+ },
446
+ );
447
+
448
+ expect(rerunCount).toBe(1);
449
+ });
450
+
451
+ test("AC09: delegated handlers used by flow do not call process.exit()", async () => {
452
+ const commandFiles = [
453
+ "define-requirement.ts",
454
+ "create-project-context.ts",
455
+ "create-prototype.ts",
456
+ "create-test-plan.ts",
457
+ "execute-test-plan.ts",
458
+ "define-refactor-plan.ts",
459
+ "execute-refactor.ts",
460
+ ];
461
+
462
+ for (const fileName of commandFiles) {
463
+ const source = await readFile(join(import.meta.dir, fileName), "utf8");
464
+ expect(source).not.toContain("process.exit(");
465
+ }
466
+ });
467
+ });
468
+
469
+ describe("US-003: completion message when iteration is finished", () => {
470
+ let previousExitCode: typeof process.exitCode;
471
+
472
+ beforeEach(() => {
473
+ previousExitCode = process.exitCode;
474
+ process.exitCode = undefined;
475
+ });
476
+
477
+ afterEach(() => {
478
+ process.exitCode = previousExitCode ?? 0;
479
+ });
480
+
481
+ test("US-003-AC01 + US-003-AC02: prints completion summary, exits 0, and does not attempt further steps", async () => {
482
+ const completeState = withState(createBaseState(), (s) => {
483
+ s.current_iteration = "000019";
484
+ s.current_phase = "refactor";
485
+ s.phases.prototype.test_plan.status = "created";
486
+ s.phases.prototype.tp_generation.status = "created";
487
+ s.phases.prototype.prototype_build.status = "created";
488
+ s.phases.prototype.test_execution.status = "completed";
489
+ s.phases.prototype.prototype_approved = true;
490
+ s.phases.refactor.evaluation_report.status = "created";
491
+ s.phases.refactor.refactor_plan.status = "approved";
492
+ s.phases.refactor.refactor_execution.status = "completed";
493
+ });
494
+
495
+ const logs: string[] = [];
496
+ let delegatedCalls = 0;
497
+
498
+ await runFlow(
499
+ { provider: "codex" },
500
+ {
501
+ readStateFn: async () => completeState,
502
+ runCreateProjectContextFn: async () => {
503
+ delegatedCalls += 1;
504
+ },
505
+ runCreatePrototypeFn: async () => {
506
+ delegatedCalls += 1;
507
+ },
508
+ runCreateTestPlanFn: async () => {
509
+ delegatedCalls += 1;
510
+ },
511
+ runDefineRefactorPlanFn: async () => {
512
+ delegatedCalls += 1;
513
+ },
514
+ runDefineRequirementFn: async () => {
515
+ delegatedCalls += 1;
516
+ },
517
+ runExecuteRefactorFn: async () => {
518
+ delegatedCalls += 1;
519
+ },
520
+ runExecuteTestPlanFn: async () => {
521
+ delegatedCalls += 1;
522
+ },
523
+ stdoutWriteFn: (message) => logs.push(message),
524
+ stderrWriteFn: () => {},
525
+ },
526
+ );
527
+
528
+ expect(logs).toContain("Iteration 000019 complete. All phases finished.");
529
+ expect(delegatedCalls).toBe(0);
530
+ expect(process.exitCode === undefined || process.exitCode === 0).toBe(true);
531
+ });
532
+ });
533
+
534
+ describe("US-002: approval gate messaging", () => {
535
+ test("US-002-AC01 + US-002-AC02: requirement in_progress is an approval gate and prints exact next command", () => {
536
+ const state = withState(createBaseState(), (s) => {
537
+ s.current_phase = "define";
538
+ s.phases.define.requirement_definition.status = "in_progress";
539
+ });
540
+
541
+ const decision = detectNextFlowDecision(state);
542
+ expect(decision.kind).toBe("approval_gate");
543
+ if (decision.kind === "approval_gate") {
544
+ expect(decision.message).toBe(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.requirement));
545
+ expect(decision.message.startsWith(FLOW_APPROVAL_GATE_PREFIX)).toBe(true);
546
+ }
547
+ });
548
+
549
+ test("US-002-AC01 + US-002-AC02: test-plan approval gate prints exact next command", () => {
550
+ const state = withState(createBaseState(), (s) => {
551
+ s.current_phase = "prototype";
552
+ s.phases.prototype.test_plan.status = "pending_approval";
553
+ s.phases.prototype.test_plan.file = "it_000019_test-plan.md";
554
+ });
555
+
556
+ const decision = detectNextFlowDecision(state);
557
+ expect(decision.kind).toBe("approval_gate");
558
+ if (decision.kind === "approval_gate") {
559
+ expect(decision.message).toBe(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.testPlan));
560
+ expect(decision.message.startsWith(FLOW_APPROVAL_GATE_PREFIX)).toBe(true);
561
+ }
562
+ });
563
+
564
+ test("US-002-AC01 + US-002-AC02: prototype approval gate prints exact next command", () => {
565
+ const state = withState(createBaseState(), (s) => {
566
+ s.current_phase = "prototype";
567
+ s.phases.prototype.prototype_build.status = "created";
568
+ s.phases.prototype.test_plan.status = "created";
569
+ s.phases.prototype.tp_generation.status = "created";
570
+ s.phases.prototype.test_execution.status = "completed";
571
+ s.phases.prototype.prototype_approved = false;
572
+ });
573
+
574
+ const decision = detectNextFlowDecision(state);
575
+ expect(decision.kind).toBe("approval_gate");
576
+ if (decision.kind === "approval_gate") {
577
+ expect(decision.message).toBe(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.prototype));
578
+ expect(decision.message.startsWith(FLOW_APPROVAL_GATE_PREFIX)).toBe(true);
579
+ }
580
+ });
581
+
582
+ test("US-002-AC01 + US-002-AC02: refactor-plan approval gate prints exact next command", () => {
583
+ const state = withState(createBaseState(), (s) => {
584
+ s.current_phase = "refactor";
585
+ s.phases.prototype.test_plan.status = "created";
586
+ s.phases.prototype.tp_generation.status = "created";
587
+ s.phases.prototype.prototype_build.status = "created";
588
+ s.phases.prototype.test_execution.status = "completed";
589
+ s.phases.prototype.prototype_approved = true;
590
+ s.phases.refactor.refactor_plan.status = "pending_approval";
591
+ s.phases.refactor.refactor_plan.file = "it_000019_refactor-plan.md";
592
+ });
593
+
594
+ const decision = detectNextFlowDecision(state);
595
+ expect(decision.kind).toBe("approval_gate");
596
+ if (decision.kind === "approval_gate") {
597
+ expect(decision.message).toBe(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.refactorPlan));
598
+ expect(decision.message.startsWith(FLOW_APPROVAL_GATE_PREFIX)).toBe(true);
599
+ }
600
+ });
601
+
602
+ test("US-002-AC01: runFlow stops at approval gate with exit code 0", async () => {
603
+ const gateState = withState(createBaseState(), (s) => {
604
+ s.current_phase = "prototype";
605
+ s.phases.prototype.test_plan.status = "pending_approval";
606
+ s.phases.prototype.test_plan.file = "it_000019_test-plan.md";
607
+ });
608
+
609
+ const logs: string[] = [];
610
+ process.exitCode = undefined;
611
+ await runFlow(
612
+ { provider: "codex" },
613
+ {
614
+ readStateFn: async () => gateState,
615
+ stdoutWriteFn: (message) => logs.push(message),
616
+ stderrWriteFn: () => {},
617
+ },
618
+ );
619
+
620
+ expect(logs).toContain(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.testPlan));
621
+ expect(process.exitCode === undefined || process.exitCode === 0).toBe(true);
622
+ });
623
+
624
+ test("US-002-AC03: flow resumes on re-run after approval", async () => {
625
+ const gateState = withState(createBaseState(), (s) => {
626
+ s.current_phase = "prototype";
627
+ s.phases.prototype.test_plan.status = "pending_approval";
628
+ s.phases.prototype.test_plan.file = "it_000019_test-plan.md";
629
+ });
630
+ const resumedState = withState(createBaseState(), (s) => {
631
+ s.current_phase = "prototype";
632
+ s.phases.prototype.prototype_build.status = "created";
633
+ s.phases.prototype.test_plan.status = "created";
634
+ s.phases.prototype.tp_generation.status = "created";
635
+ s.phases.prototype.test_execution.status = "in_progress";
636
+ s.phases.prototype.test_execution.file = "it_000019_test-execution-results.json";
637
+ s.phases.prototype.prototype_approved = false;
638
+ });
639
+ const nextGateState = withState(createBaseState(), (s) => {
640
+ s.current_phase = "prototype";
641
+ s.phases.prototype.prototype_build.status = "created";
642
+ s.phases.prototype.test_plan.status = "created";
643
+ s.phases.prototype.tp_generation.status = "created";
644
+ s.phases.prototype.test_execution.status = "completed";
645
+ s.phases.prototype.prototype_approved = false;
646
+ });
647
+
648
+ const firstRunLogs: string[] = [];
649
+ await runFlow(
650
+ { provider: "codex" },
651
+ {
652
+ readStateFn: async () => gateState,
653
+ stdoutWriteFn: (message) => firstRunLogs.push(message),
654
+ stderrWriteFn: () => {},
655
+ },
656
+ );
657
+ expect(firstRunLogs).toContain(buildApprovalGateMessage(FLOW_APPROVAL_TARGETS.testPlan));
658
+
659
+ const reads: State[] = [resumedState, nextGateState];
660
+ let readCount = 0;
661
+ let executedTestPlan = 0;
662
+ await runFlow(
663
+ { provider: "codex" },
664
+ {
665
+ readStateFn: async () => reads[Math.min(readCount++, reads.length - 1)],
666
+ runExecuteTestPlanFn: async () => {
667
+ executedTestPlan += 1;
668
+ },
669
+ stdoutWriteFn: () => {},
670
+ stderrWriteFn: () => {},
671
+ },
672
+ );
673
+
674
+ expect(executedTestPlan).toBe(1);
675
+ });
676
+ });
677
+
678
+ describe("US-004: schema alignment and edge-case flow resolution", () => {
679
+ test("US-004-AC01: resolver accepts every schema status for refactor.evaluation_report", () => {
680
+ const evaluationStatuses = StateSchema.shape.phases.shape.refactor.shape.evaluation_report.shape.status.options;
681
+
682
+ for (const status of evaluationStatuses) {
683
+ const state = withState(createBaseState(), (s) => {
684
+ s.current_phase = "refactor";
685
+ s.phases.prototype.project_context.status = "created";
686
+ s.phases.prototype.test_plan.status = "created";
687
+ s.phases.prototype.tp_generation.status = "created";
688
+ s.phases.prototype.prototype_build.status = "created";
689
+ s.phases.prototype.test_execution.status = "completed";
690
+ s.phases.prototype.prototype_approved = true;
691
+ s.phases.refactor.evaluation_report.status = status;
692
+ s.phases.refactor.refactor_plan.status = "pending";
693
+ });
694
+
695
+ const decision = detectNextFlowDecision(state);
696
+ if (decision.kind === "blocked") {
697
+ expect(decision.message).not.toContain("phases.refactor.evaluation_report");
698
+ }
699
+ }
700
+ });
701
+
702
+ test("US-004-AC01: resolver accepts every schema status for refactor.changelog", () => {
703
+ const changelogStatuses = StateSchema.shape.phases.shape.refactor.shape.changelog.shape.status.options;
704
+
705
+ for (const status of changelogStatuses) {
706
+ const state = withState(createBaseState(), (s) => {
707
+ s.current_phase = "refactor";
708
+ s.phases.prototype.project_context.status = "created";
709
+ s.phases.prototype.test_plan.status = "created";
710
+ s.phases.prototype.tp_generation.status = "created";
711
+ s.phases.prototype.prototype_build.status = "created";
712
+ s.phases.prototype.test_execution.status = "completed";
713
+ s.phases.prototype.prototype_approved = true;
714
+ s.phases.refactor.evaluation_report.status = "created";
715
+ s.phases.refactor.refactor_plan.status = "approved";
716
+ s.phases.refactor.refactor_execution.status = "completed";
717
+ s.phases.refactor.changelog.status = status;
718
+ });
719
+
720
+ const decision = detectNextFlowDecision(state);
721
+ expect(decision.kind).toBe("complete");
722
+ }
723
+ });
724
+
725
+ test("US-004-AC02: ignores unexpected current_phase values and resolves from canonical phases", () => {
726
+ const state = withState(createBaseState(), (s) => {
727
+ s.current_phase = "define";
728
+ s.phases.define.requirement_definition.status = "pending";
729
+ s.phases.define.prd_generation.status = "pending";
730
+ });
731
+ (state as unknown as { current_phase: string }).current_phase = "unexpected_phase";
732
+
733
+ const decision = detectNextFlowDecision(state);
734
+ expect(decision.kind).toBe("step");
735
+ if (decision.kind === "step") {
736
+ expect(decision.step.id).toBe(FLOW_STEPS["define-requirement"].id);
737
+ }
738
+ });
739
+
740
+ test("US-004-AC03: partially-updated prototype state is blocked with deterministic message", () => {
741
+ const state = withState(createBaseState(), (s) => {
742
+ s.current_phase = "prototype";
743
+ s.phases.prototype.project_context.status = "created";
744
+ s.phases.prototype.test_plan.status = "created";
745
+ s.phases.prototype.tp_generation.status = "pending";
746
+ s.phases.prototype.prototype_build.status = "pending";
747
+ });
748
+
749
+ const decision = detectNextFlowDecision(state);
750
+ expect(decision.kind).toBe("blocked");
751
+ if (decision.kind === "blocked") {
752
+ expect(decision.message).toBe("No runnable flow step found for phases.prototype.tp_generation.");
753
+ }
754
+ });
755
+ });