@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
@@ -1,17 +1,25 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { $ as dollar } from "bun";
4
- import { z } from "zod";
5
4
 
6
5
  import { PrdSchema } from "../../scaffold/schemas/tmpl_prd";
6
+ import {
7
+ PrototypeProgressSchema,
8
+ type PrototypeProgress,
9
+ } from "../../scaffold/schemas/tmpl_prototype-progress";
7
10
  import {
8
11
  buildPrompt,
9
12
  invokeAgent,
10
13
  loadSkill,
14
+ type AgentInvokeOptions,
11
15
  type AgentProvider,
16
+ type AgentResult,
12
17
  } from "../agent";
13
18
  import { assertGuardrail } from "../guardrail";
19
+ import { defaultReadLine } from "../readline";
20
+ import { idsMatchExactly, sortedValues } from "../progress-utils";
14
21
  import { exists, FLOW_REL_DIR, readState, writeState } from "../state";
22
+ import { writeJsonArtifact, type WriteJsonArtifactFn } from "../write-json-artifact";
15
23
 
16
24
  export interface CreatePrototypeOptions {
17
25
  provider: AgentProvider;
@@ -21,41 +29,114 @@ export interface CreatePrototypeOptions {
21
29
  force?: boolean;
22
30
  }
23
31
 
24
- const ProgressEntrySchema = z.object({
25
- use_case_id: z.string(),
26
- status: z.enum(["pending", "failed", "completed"]),
27
- attempt_count: z.number().int().nonnegative(),
28
- last_agent_exit_code: z.number().int().nullable(),
29
- quality_checks: z.array(
30
- z.object({
31
- command: z.string(),
32
- exit_code: z.number().int(),
33
- }),
34
- ),
35
- last_error_summary: z.string(),
36
- updated_at: z.string(),
37
- });
38
-
39
- export const PrototypeProgressSchema = z.object({
40
- entries: z.array(ProgressEntrySchema),
41
- });
42
-
43
- function sortedValues(values: string[]): string[] {
44
- return [...values].sort((a, b) => a.localeCompare(b));
32
+ const DECLINE_DIRTY_TREE_ABORT_MESSAGE = "Aborted. Commit or discard your changes and re-run `bun nvst create prototype`.";
33
+ const DIRTY_TREE_COMMIT_PROMPT = "Working tree has uncommitted changes. Stage and commit them now to proceed? [y/N]";
34
+
35
+ interface CreatePrototypeDeps {
36
+ invokeAgentFn: (options: AgentInvokeOptions) => Promise<AgentResult>;
37
+ loadSkillFn: (projectRoot: string, skillName: string) => Promise<string>;
38
+ checkGhAvailableFn: (projectRoot: string) => Promise<boolean>;
39
+ createPullRequestFn: (
40
+ projectRoot: string,
41
+ title: string,
42
+ body: string,
43
+ ) => Promise<{ exitCode: number; stderr: string }>;
44
+ logFn: (message: string) => void;
45
+ warnFn: (message: string) => void;
46
+ promptDirtyTreeCommitFn: (question: string) => Promise<boolean>;
47
+ gitAddAndCommitFn: (projectRoot: string, commitMessage: string) => Promise<void>;
48
+ writeJsonArtifactFn: WriteJsonArtifactFn;
49
+ }
50
+
51
+ const defaultDeps: CreatePrototypeDeps = {
52
+ invokeAgentFn: invokeAgent,
53
+ loadSkillFn: loadSkill,
54
+ checkGhAvailableFn: async (projectRoot) => {
55
+ const proc = Bun.spawn(["gh", "--version"], {
56
+ cwd: projectRoot,
57
+ stdout: "ignore",
58
+ stderr: "ignore",
59
+ });
60
+ return (await proc.exited) === 0;
61
+ },
62
+ createPullRequestFn: async (projectRoot, title, body) => {
63
+ const result = await dollar`gh pr create --title ${title} --body ${body}`
64
+ .cwd(projectRoot)
65
+ .nothrow()
66
+ .quiet();
67
+ return {
68
+ exitCode: result.exitCode,
69
+ stderr: result.stderr.toString().trim(),
70
+ };
71
+ },
72
+ logFn: console.log,
73
+ warnFn: console.warn,
74
+ promptDirtyTreeCommitFn: promptForDirtyTreeCommit,
75
+ gitAddAndCommitFn: runGitAddAndCommit,
76
+ writeJsonArtifactFn: writeJsonArtifact,
77
+ };
78
+
79
+ type ReadLineFn = () => Promise<string | null>;
80
+ type WriteFn = (message: string) => void;
81
+ type IsTTYFn = () => boolean;
82
+
83
+ function defaultWrite(message: string): void {
84
+ process.stdout.write(`${message}\n`);
85
+ }
86
+
87
+ function defaultIsTTY(): boolean {
88
+ return process.stdin.isTTY === true;
45
89
  }
46
90
 
47
- function idsMatchExactly(left: string[], right: string[]): boolean {
48
- if (left.length !== right.length) {
91
+ export async function promptForDirtyTreeCommit(
92
+ question: string,
93
+ readLineFn: ReadLineFn = defaultReadLine,
94
+ writeFn: WriteFn = defaultWrite,
95
+ isTTYFn: IsTTYFn = defaultIsTTY,
96
+ ): Promise<boolean> {
97
+ if (!isTTYFn()) {
49
98
  return false;
50
99
  }
51
100
 
52
- for (let i = 0; i < left.length; i += 1) {
53
- if (left[i] !== right[i]) {
54
- return false;
55
- }
101
+ writeFn(question);
102
+
103
+ let line: string | null;
104
+ try {
105
+ line = await readLineFn();
106
+ } catch {
107
+ line = null;
56
108
  }
57
109
 
58
- return true;
110
+ return line !== null && (line.trim() === "y" || line.trim() === "Y");
111
+ }
112
+
113
+ async function runGitAddAndCommit(projectRoot: string, commitMessage: string): Promise<void> {
114
+ const addResult = await dollar`git add -A`.cwd(projectRoot).nothrow().quiet();
115
+ if (addResult.exitCode !== 0) {
116
+ throw new Error(`Pre-prototype commit failed:\n${addResult.stderr.toString().trim()}`);
117
+ }
118
+
119
+ const commitResult = await dollar`git commit -m ${commitMessage}`.cwd(projectRoot).nothrow().quiet();
120
+ if (commitResult.exitCode !== 0) {
121
+ throw new Error(`Pre-prototype commit failed:\n${commitResult.stderr.toString().trim()}`);
122
+ }
123
+ }
124
+
125
+ export async function runPrePrototypeCommit(
126
+ projectRoot: string,
127
+ iteration: string,
128
+ gitAddAndCommitFn: (projectRoot: string, commitMessage: string) => Promise<void> = runGitAddAndCommit,
129
+ ): Promise<void> {
130
+ const commitMessage = `chore: pre-prototype commit it_${iteration}`;
131
+ try {
132
+ await gitAddAndCommitFn(projectRoot, commitMessage);
133
+ } catch (error) {
134
+ const reason = error instanceof Error ? error.message : String(error);
135
+ if (reason.startsWith("Pre-prototype commit failed:\n")) {
136
+ throw error;
137
+ }
138
+ throw new Error(`Pre-prototype commit failed:\n${reason}`);
139
+ }
59
140
  }
60
141
 
61
142
  function parseQualityChecks(projectContextContent: string): string[] {
@@ -105,7 +186,11 @@ function parseQualityChecks(projectContextContent: string): string[] {
105
186
  .filter((line) => line.length > 0);
106
187
  }
107
188
 
108
- export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<void> {
189
+ export async function runCreatePrototype(
190
+ opts: CreatePrototypeOptions,
191
+ deps: Partial<CreatePrototypeDeps> = {},
192
+ ): Promise<void> {
193
+ const mergedDeps: CreatePrototypeDeps = { ...defaultDeps, ...deps };
109
194
  const projectRoot = process.cwd();
110
195
  const state = await readState(projectRoot);
111
196
  const force = opts.force ?? false;
@@ -151,6 +236,8 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
151
236
  );
152
237
  }
153
238
 
239
+ let prePrototypeCommitDone = false;
240
+
154
241
  if (state.current_phase === "define") {
155
242
  if (
156
243
  state.phases.define.prd_generation.status === "completed" &&
@@ -163,9 +250,15 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
163
250
  );
164
251
  }
165
252
  if (workingTreeBeforeTransition.stdout.toString().trim().length > 0) {
166
- throw new Error(
167
- "Git working tree is dirty. Commit your changes or discard them before running `bun nvst create prototype` again.",
253
+ const shouldProceed = await mergedDeps.promptDirtyTreeCommitFn(
254
+ DIRTY_TREE_COMMIT_PROMPT,
168
255
  );
256
+ if (!shouldProceed) {
257
+ mergedDeps.logFn(DECLINE_DIRTY_TREE_ABORT_MESSAGE);
258
+ return;
259
+ }
260
+ await runPrePrototypeCommit(projectRoot, iteration, mergedDeps.gitAddAndCommitFn);
261
+ prePrototypeCommitDone = true;
169
262
  }
170
263
  state.current_phase = "prototype";
171
264
  await writeState(projectRoot, state);
@@ -193,16 +286,23 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
193
286
  { force },
194
287
  );
195
288
 
196
- const workingTreeAfterPhase = await dollar`git status --porcelain`.cwd(projectRoot).nothrow().quiet();
197
- if (workingTreeAfterPhase.exitCode !== 0) {
198
- throw new Error(
199
- "Unable to verify git working tree status. Ensure this directory is a git repository and git is installed.",
200
- );
201
- }
202
- if (workingTreeAfterPhase.stdout.toString().trim().length > 0) {
203
- throw new Error(
204
- "Git working tree is dirty. Commit your changes or discard them before running `bun nvst create prototype` again.",
205
- );
289
+ if (!prePrototypeCommitDone) {
290
+ const workingTreeAfterPhase = await dollar`git status --porcelain`.cwd(projectRoot).nothrow().quiet();
291
+ if (workingTreeAfterPhase.exitCode !== 0) {
292
+ throw new Error(
293
+ "Unable to verify git working tree status. Ensure this directory is a git repository and git is installed.",
294
+ );
295
+ }
296
+ if (workingTreeAfterPhase.stdout.toString().trim().length > 0) {
297
+ const shouldProceed = await mergedDeps.promptDirtyTreeCommitFn(
298
+ DIRTY_TREE_COMMIT_PROMPT,
299
+ );
300
+ if (!shouldProceed) {
301
+ mergedDeps.logFn(DECLINE_DIRTY_TREE_ABORT_MESSAGE);
302
+ return;
303
+ }
304
+ await runPrePrototypeCommit(projectRoot, iteration, mergedDeps.gitAddAndCommitFn);
305
+ }
206
306
  }
207
307
 
208
308
  const branchName = `feature/it_${iteration}`;
@@ -230,7 +330,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
230
330
  const progressFileName = `it_${iteration}_progress.json`;
231
331
  const progressPath = join(projectRoot, FLOW_REL_DIR, progressFileName);
232
332
  const storyIds = sortedValues(prdValidation.data.userStories.map((story) => story.id));
233
- let progressData: z.infer<typeof PrototypeProgressSchema>;
333
+ let progressData: PrototypeProgress;
234
334
 
235
335
  if (await exists(progressPath)) {
236
336
  let parsedProgress: unknown;
@@ -273,7 +373,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
273
373
  })),
274
374
  };
275
375
 
276
- await writeFile(progressPath, `${JSON.stringify(progress, null, 2)}\n`, "utf8");
376
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progress);
277
377
  progressData = progress;
278
378
  }
279
379
 
@@ -283,7 +383,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
283
383
  });
284
384
 
285
385
  if (eligibleStories.length === 0) {
286
- console.log("No pending or failed user stories to implement. Exiting without changes.");
386
+ mergedDeps.logFn("No pending or failed user stories to implement. Exiting without changes.");
287
387
  return;
288
388
  }
289
389
 
@@ -295,7 +395,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
295
395
 
296
396
  let skillTemplate: string;
297
397
  try {
298
- skillTemplate = await loadSkill(projectRoot, "implement-user-story");
398
+ skillTemplate = await mergedDeps.loadSkillFn(projectRoot, "implement-user-story");
299
399
  } catch {
300
400
  throw new Error(
301
401
  "Required skill missing: expected .agents/skills/implement-user-story/SKILL.md.",
@@ -334,7 +434,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
334
434
  user_story: JSON.stringify(story, null, 2),
335
435
  });
336
436
 
337
- const agentResult = await invokeAgent({
437
+ const agentResult = await mergedDeps.invokeAgentFn({
338
438
  provider: opts.provider,
339
439
  prompt,
340
440
  cwd: projectRoot,
@@ -368,7 +468,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
368
468
  entry.last_error_summary = "Agent or quality check failed";
369
469
  }
370
470
 
371
- await writeFile(progressPath, `${JSON.stringify(progressData, null, 2)}\n`, "utf8");
471
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progressData);
372
472
 
373
473
  if (allPassed) {
374
474
  const commitMessage = `feat: implement ${story.id} - ${story.title}`;
@@ -381,9 +481,9 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
381
481
  entry.status = "failed";
382
482
  entry.last_error_summary = "Git commit failed";
383
483
  entry.updated_at = new Date().toISOString();
384
- await writeFile(progressPath, `${JSON.stringify(progressData, null, 2)}\n`, "utf8");
484
+ await mergedDeps.writeJsonArtifactFn(progressPath, PrototypeProgressSchema, progressData);
385
485
 
386
- console.log(
486
+ mergedDeps.logFn(
387
487
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=commit_failed`,
388
488
  );
389
489
 
@@ -391,7 +491,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
391
491
  haltedByCritical = true;
392
492
  }
393
493
  } else {
394
- console.log(
494
+ mergedDeps.logFn(
395
495
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=passed`,
396
496
  );
397
497
  }
@@ -399,7 +499,7 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
399
499
  break;
400
500
  }
401
501
 
402
- console.log(
502
+ mergedDeps.logFn(
403
503
  `iteration=it_${iteration} story=${story.id} attempt=${entry.attempt_count} outcome=failed`,
404
504
  );
405
505
 
@@ -423,13 +523,25 @@ export async function runCreatePrototype(opts: CreatePrototypeOptions): Promise<
423
523
  await writeState(projectRoot, state);
424
524
 
425
525
  if (storiesAttempted === 0) {
426
- console.log("No user stories attempted.");
526
+ mergedDeps.logFn("No user stories attempted.");
427
527
  return;
428
528
  }
429
529
 
430
530
  if (allCompleted) {
431
- console.log("Prototype implementation completed for all user stories.");
531
+ const ghAvailable = await mergedDeps.checkGhAvailableFn(projectRoot);
532
+ if (!ghAvailable) {
533
+ mergedDeps.logFn("gh CLI not found — skipping PR creation");
534
+ } else {
535
+ const prTitle = `feat: prototype it_${iteration}`;
536
+ const prBody = `Prototype for iteration it_${iteration}`;
537
+ const prResult = await mergedDeps.createPullRequestFn(projectRoot, prTitle, prBody);
538
+ if (prResult.exitCode !== 0) {
539
+ const suffix = prResult.stderr.length > 0 ? `: ${prResult.stderr}` : "";
540
+ mergedDeps.warnFn(`gh pr create failed (non-fatal)${suffix}`);
541
+ }
542
+ }
543
+ mergedDeps.logFn("Prototype implementation completed for all user stories.");
432
544
  } else {
433
- console.log("Prototype implementation paused with remaining pending or failed stories.");
545
+ mergedDeps.logFn("Prototype implementation paused with remaining pending or failed stories.");
434
546
  }
435
547
  }
@@ -193,6 +193,66 @@ describe("execute automated-fix", () => {
193
193
  expect(providersUsed).toEqual(["cursor"]);
194
194
  });
195
195
 
196
+ // RI-005: execute-automated-fix is a phase-independent exception to the guardrail system.
197
+ // It must process issues regardless of current_phase because issues can arise and need
198
+ // fixing at any point in the workflow (prototype OR refactor phases).
199
+ test("RI-005: processes open issues regardless of current_phase (phase-independent guardrail exception)", async () => {
200
+ const projectRoot = await createProjectRoot();
201
+ createdRoots.push(projectRoot);
202
+
203
+ // Seed state with current_phase = "refactor", not the typical "prototype"
204
+ await mkdir(join(projectRoot, ".agents", "flow"), { recursive: true });
205
+ const { writeState } = await import("../state");
206
+ await writeState(projectRoot, {
207
+ current_iteration: "000009",
208
+ current_phase: "refactor",
209
+ phases: {
210
+ define: {
211
+ requirement_definition: { status: "approved", file: "it_000009_product-requirement-document.md" },
212
+ prd_generation: { status: "completed", file: "it_000009_PRD.json" },
213
+ },
214
+ prototype: {
215
+ project_context: { status: "created", file: ".agents/PROJECT_CONTEXT.md" },
216
+ test_plan: { status: "created", file: "it_000009_test-plan.md" },
217
+ tp_generation: { status: "created", file: "it_000009_test-plan.json" },
218
+ prototype_build: { status: "created", file: null },
219
+ test_execution: { status: "completed", file: null },
220
+ prototype_approved: true,
221
+ },
222
+ refactor: {
223
+ evaluation_report: { status: "created", file: null },
224
+ refactor_plan: { status: "approved", file: null },
225
+ refactor_execution: { status: "in_progress", file: null },
226
+ changelog: { status: "pending", file: null },
227
+ },
228
+ },
229
+ last_updated: "2026-02-22T00:00:00.000Z",
230
+ updated_by: "seed",
231
+ history: [],
232
+ });
233
+ await writeIssues(projectRoot, "000009", [
234
+ { id: "ISSUE-000009-001", title: "Refactor-phase issue", description: "fix me", status: "open" },
235
+ ]);
236
+
237
+ let invokeCount = 0;
238
+
239
+ await withCwd(projectRoot, async () => {
240
+ await runExecuteAutomatedFix(
241
+ { provider: "codex" },
242
+ {
243
+ loadSkillFn: async () => "debug workflow",
244
+ invokeAgentFn: async () => {
245
+ invokeCount += 1;
246
+ return { exitCode: 0, stdout: "ok", stderr: "" };
247
+ },
248
+ runCommitFn: async () => 0,
249
+ },
250
+ );
251
+ });
252
+
253
+ expect(invokeCount).toBe(1);
254
+ });
255
+
196
256
  test("logs informative message and exits without changes when zero open issues exist", async () => {
197
257
  const projectRoot = await createProjectRoot();
198
258
  createdRoots.push(projectRoot);
@@ -225,7 +285,8 @@ describe("execute automated-fix", () => {
225
285
  expect(logs).toContain("No open issues to process. Exiting without changes.");
226
286
  });
227
287
 
228
- test("defaults --iterations to 1 and leaves remaining open issues untouched", async () => {
288
+ // US-001-AC01: When --iterations is not provided, all open issues are processed
289
+ test("US-001-AC01: processes all open issues when --iterations is not provided", async () => {
229
290
  const projectRoot = await createProjectRoot();
230
291
  createdRoots.push(projectRoot);
231
292
 
@@ -237,6 +298,7 @@ describe("execute automated-fix", () => {
237
298
  ]);
238
299
 
239
300
  let invokeCount = 0;
301
+ const logs: string[] = [];
240
302
 
241
303
  await withCwd(projectRoot, async () => {
242
304
  await runExecuteAutomatedFix(
@@ -248,6 +310,8 @@ describe("execute automated-fix", () => {
248
310
  return { exitCode: 0, stdout: "", stderr: "" };
249
311
  },
250
312
  runCommitFn: async () => 0,
313
+ logFn: (message) => logs.push(message),
314
+ nowFn: () => new Date("2026-02-22T12:00:00.000Z"),
251
315
  },
252
316
  );
253
317
  });
@@ -255,10 +319,14 @@ describe("execute automated-fix", () => {
255
319
  const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
256
320
  const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
257
321
 
258
- expect(invokeCount).toBe(1);
322
+ // All 3 open issues should be processed
323
+ expect(invokeCount).toBe(3);
259
324
  expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
260
- expect(issues.find((issue) => issue.id === "ISSUE-000009-002")?.status).toBe("open");
261
- expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("open");
325
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-002")?.status).toBe("fixed");
326
+ expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("fixed");
327
+ // AC04: summary reflects all processed issues
328
+ expect(logs).toContain("Summary: Fixed=3 Failed=0");
329
+ expect(logs).toContain("Processed 3 open issue(s) at 2026-02-22T12:00:00.000Z");
262
330
  });
263
331
 
264
332
  test("processes only the first N open issues when --iterations is provided", async () => {
@@ -297,7 +365,7 @@ describe("execute automated-fix", () => {
297
365
  expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("open");
298
366
  });
299
367
 
300
- test("skips issues with missing required fields and continues processing remaining open issues", async () => {
368
+ test("throws deterministic validation error when issues file contains entries with missing required fields", async () => {
301
369
  const projectRoot = await createProjectRoot();
302
370
  createdRoots.push(projectRoot);
303
371
 
@@ -308,34 +376,11 @@ describe("execute automated-fix", () => {
308
376
  { id: "ISSUE-000009-003", title: "Fixed", description: "skip", status: "fixed" },
309
377
  ]);
310
378
 
311
- const logs: string[] = [];
312
- const prompts: string[] = [];
313
-
314
379
  await withCwd(projectRoot, async () => {
315
- await runExecuteAutomatedFix(
316
- { provider: "codex", iterations: 3 },
317
- {
318
- loadSkillFn: async () => "debug workflow",
319
- invokeAgentFn: async (options) => {
320
- prompts.push(options.prompt);
321
- return { exitCode: 0, stdout: "", stderr: "" };
322
- },
323
- runCommitFn: async () => 0,
324
- logFn: (message) => logs.push(message),
325
- },
380
+ await expect(runExecuteAutomatedFix({ provider: "codex" })).rejects.toThrow(
381
+ "Deterministic validation error: issues schema mismatch in .agents/flow/it_000009_ISSUES.json.",
326
382
  );
327
383
  });
328
-
329
- expect(prompts).toHaveLength(1);
330
- expect(prompts[0]).toContain('"id": "ISSUE-000009-001"');
331
- expect(logs.some((line) => line.includes("Warning: Skipping issue at index 1"))).toBe(true);
332
-
333
- const issuesRaw = await readFile(join(projectRoot, ".agents", "flow", "it_000009_ISSUES.json"), "utf8");
334
- const issues = JSON.parse(issuesRaw) as Array<{ id: string; status: string }>;
335
-
336
- expect(issues.find((issue) => issue.id === "ISSUE-000009-001")?.status).toBe("fixed");
337
- expect(issues.find((issue) => issue.id === "ISSUE-000009-003")?.status).toBe("fixed");
338
- expect(issues.some((issue) => issue.id === "ISSUE-000009-002")).toBe(false);
339
384
  });
340
385
 
341
386
  test("marks issue as retry when hypothesis is not confirmed and retries remain", async () => {
@@ -363,9 +408,9 @@ describe("execute automated-fix", () => {
363
408
  return { exitCode: 0, stdout: "", stderr: "" };
364
409
  },
365
410
  runCommitFn: async () => 0,
366
- writeFileFn: async (path, data, options) => {
367
- writtenSnapshots.push(String(data));
368
- return writeFile(path, data, options);
411
+ writeJsonArtifactFn: async (path, _schema, data) => {
412
+ writtenSnapshots.push(JSON.stringify(data, null, 2));
413
+ await writeFile(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
369
414
  },
370
415
  },
371
416
  );