@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.
- package/README.md +29 -15
- package/package.json +14 -4
- package/scaffold/.agents/flow/tmpl_it_000001_progress.example.json +20 -0
- package/scaffold/.agents/skills/execute-refactor-item/tmpl_SKILL.md +59 -0
- package/scaffold/.agents/skills/plan-refactor/tmpl_SKILL.md +89 -9
- package/scaffold/.agents/skills/refine-refactor-plan/tmpl_SKILL.md +30 -0
- package/scaffold/.agents/tmpl_state_rules.md +0 -1
- package/scaffold/schemas/tmpl_prototype-progress.ts +22 -0
- package/scaffold/schemas/tmpl_refactor-execution-progress.ts +16 -0
- package/scaffold/schemas/tmpl_refactor-prd.ts +14 -0
- package/scaffold/schemas/tmpl_state.ts +1 -0
- package/scaffold/schemas/tmpl_test-execution-progress.ts +17 -0
- package/schemas/issues.ts +19 -0
- package/schemas/prototype-progress.ts +22 -0
- package/schemas/refactor-execution-progress.ts +16 -0
- package/schemas/refactor-prd.ts +14 -0
- package/schemas/state.test.ts +58 -0
- package/schemas/state.ts +1 -0
- package/schemas/test-execution-progress.ts +17 -0
- package/schemas/test-plan.test.ts +1 -1
- package/schemas/validate-progress.ts +1 -1
- package/schemas/validate-state.ts +1 -1
- package/src/cli.test.ts +57 -0
- package/src/cli.ts +227 -58
- package/src/commands/approve-project-context.ts +13 -6
- package/src/commands/approve-prototype.test.ts +427 -0
- package/src/commands/approve-prototype.ts +185 -0
- package/src/commands/approve-refactor-plan.test.ts +254 -0
- package/src/commands/approve-refactor-plan.ts +200 -0
- package/src/commands/approve-requirement.test.ts +224 -0
- package/src/commands/approve-requirement.ts +75 -16
- package/src/commands/approve-test-plan.test.ts +2 -2
- package/src/commands/approve-test-plan.ts +21 -7
- package/src/commands/create-issue.test.ts +2 -2
- package/src/commands/create-project-context.ts +31 -25
- package/src/commands/create-prototype.test.ts +488 -18
- package/src/commands/create-prototype.ts +185 -63
- package/src/commands/create-test-plan.ts +8 -6
- package/src/commands/define-refactor-plan.test.ts +208 -0
- package/src/commands/define-refactor-plan.ts +96 -0
- package/src/commands/define-requirement.ts +15 -9
- package/src/commands/execute-automated-fix.test.ts +78 -33
- package/src/commands/execute-automated-fix.ts +34 -101
- package/src/commands/execute-refactor.test.ts +954 -0
- package/src/commands/execute-refactor.ts +332 -0
- package/src/commands/execute-test-plan.test.ts +24 -16
- package/src/commands/execute-test-plan.ts +29 -55
- package/src/commands/flow-config.ts +79 -0
- package/src/commands/flow.test.ts +755 -0
- package/src/commands/flow.ts +405 -0
- package/src/commands/refine-project-context.ts +9 -7
- package/src/commands/refine-refactor-plan.test.ts +210 -0
- package/src/commands/refine-refactor-plan.ts +95 -0
- package/src/commands/refine-requirement.ts +9 -6
- package/src/commands/refine-test-plan.test.ts +2 -2
- package/src/commands/refine-test-plan.ts +9 -6
- package/src/commands/start-iteration.test.ts +52 -0
- package/src/commands/start-iteration.ts +5 -0
- package/src/commands/write-json.ts +102 -97
- package/src/flow-cli.test.ts +18 -0
- package/src/force-flag.test.ts +144 -0
- package/src/guardrail.test.ts +411 -0
- package/src/guardrail.ts +82 -0
- package/src/install.test.ts +7 -5
- package/src/pack.test.ts +2 -1
- package/src/progress-utils.ts +34 -0
- package/src/readline.ts +23 -0
- package/src/write-json-artifact.ts +33 -0
- package/scaffold/.agents/flow/tmpl_README.md +0 -7
- package/scaffold/.agents/flow/tmpl_iteration_close_checklist.example.md +0 -11
- 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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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", "
|
|
112
|
-
|
|
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(
|
|
121
|
-
|
|
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
|
-
|
|
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
|
});
|