@shenlee/devcrew 0.1.1 → 0.1.2
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 +19 -5
- package/README.zh-CN.md +19 -5
- package/dist/packages/adapters/src/index.js +3 -2
- package/dist/packages/core/src/paths.js +3 -0
- package/dist/packages/core/src/store.js +6 -0
- package/dist/packages/core/src/types.js +1 -0
- package/dist/packages/core/src/version.js +1 -1
- package/dist/packages/core/src/workflow.js +51 -20
- package/dist/packages/orchestrator/src/index.js +118 -154
- package/dist/packages/orchestrator/src/worktree.js +198 -0
- package/dist/packages/service/src/stdio.js +19 -3
- package/dist/packages/service/src/tools.js +3 -3
- package/docs/claude-code.md +1 -1
- package/docs/codex.md +6 -2
- package/docs/quickstart.md +1 -1
- package/docs/superpowers/plans/2026-07-10-p0-foundation-repair.md +939 -0
- package/docs/superpowers/specs/2026-07-10-p0-foundation-repair-design.md +182 -0
- package/docs/workflow.md +21 -4
- package/package.json +1 -1
- package/packages/adapters/src/index.ts +4 -3
- package/packages/core/src/paths.ts +4 -0
- package/packages/core/src/store.ts +7 -0
- package/packages/core/src/types.ts +7 -0
- package/packages/core/src/version.ts +1 -1
- package/packages/core/src/workflow.ts +55 -20
- package/packages/orchestrator/src/index.ts +136 -179
- package/packages/orchestrator/src/worktree.ts +269 -0
- package/packages/service/src/stdio.ts +27 -6
- package/packages/service/src/tools.ts +2 -2
- package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/devcrew-codex/.mcp.json +1 -1
- package/plugins/devcrew-codex/agents/architect.toml +6 -0
- package/plugins/devcrew-codex/agents/implementer.toml +6 -0
- package/plugins/devcrew-codex/agents/pm.toml +7 -0
- package/plugins/devcrew-codex/agents/tester.toml +6 -0
- package/plugins/devcrew-codex/assets/logo.png +0 -0
- package/scripts/smoke-codex-plugin.mjs +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { mkdir, readFile,
|
|
3
|
-
import { dirname
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { runRole } from "../../adapters/src/index.js";
|
|
6
6
|
import type { RoleRunInput } from "../../adapters/src/index.js";
|
|
7
7
|
import {
|
|
8
8
|
answerWorkflow,
|
|
9
|
+
approveWorkflow,
|
|
9
10
|
artifactForPhase,
|
|
10
11
|
artifactPath,
|
|
11
12
|
ARTIFACTS,
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
renderArtifact,
|
|
20
21
|
saveState,
|
|
21
22
|
startWorkflow,
|
|
23
|
+
validateWorkflowApproval,
|
|
22
24
|
type AnswerWorkflowInput,
|
|
25
|
+
type ApproveWorkflowInput,
|
|
23
26
|
type ArtifactName,
|
|
24
27
|
type Phase,
|
|
25
28
|
type RejectWorkflowInput,
|
|
@@ -29,6 +32,13 @@ import {
|
|
|
29
32
|
type StartWorkflowInput,
|
|
30
33
|
type VerificationResult,
|
|
31
34
|
} from "../../core/src/index.js";
|
|
35
|
+
import {
|
|
36
|
+
captureExecutionChanges,
|
|
37
|
+
cleanupExecutionWorkspace,
|
|
38
|
+
ensureExecutionWorkspace,
|
|
39
|
+
promoteExecutionChanges,
|
|
40
|
+
rollbackPromotedExecutionChanges,
|
|
41
|
+
} from "./worktree.js";
|
|
32
42
|
|
|
33
43
|
// Hard cap so a hung apply/verify command cannot block the serialized MCP loop.
|
|
34
44
|
const COMMAND_TIMEOUT_MS = 300_000;
|
|
@@ -40,6 +50,7 @@ function roleForPhase(phase: Phase): RoleResult["role"] | undefined {
|
|
|
40
50
|
requirements: "pm",
|
|
41
51
|
architecture: "architect",
|
|
42
52
|
implementation: "implementer",
|
|
53
|
+
execution: "implementer",
|
|
43
54
|
testing: "tester",
|
|
44
55
|
};
|
|
45
56
|
return roles[phase];
|
|
@@ -159,116 +170,6 @@ export async function runShellCommand(
|
|
|
159
170
|
});
|
|
160
171
|
}
|
|
161
172
|
|
|
162
|
-
async function runGit(args: string[], cwd: string): Promise<{ exitCode: number; stdout: string }> {
|
|
163
|
-
return new Promise((resolveResult) => {
|
|
164
|
-
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
165
|
-
let stdout = "";
|
|
166
|
-
let stderr = "";
|
|
167
|
-
child.stdout.on("data", (chunk: Buffer) => {
|
|
168
|
-
stdout += chunk.toString("utf8");
|
|
169
|
-
});
|
|
170
|
-
child.stderr.on("data", (chunk: Buffer) => {
|
|
171
|
-
stderr += chunk.toString("utf8");
|
|
172
|
-
});
|
|
173
|
-
child.on("error", () => resolveResult({ exitCode: 1, stdout: "" }));
|
|
174
|
-
child.on("close", (code) => resolveResult({ exitCode: code ?? 1, stdout: stdout || stderr }));
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function listChangedLines(cwd: string, options: { requireGit?: boolean } = {}): Promise<string[]> {
|
|
179
|
-
const result = await runShellCommand("git status --porcelain -uall", cwd, 30_000);
|
|
180
|
-
if (result.exitCode !== 0) {
|
|
181
|
-
if (options.requireGit) {
|
|
182
|
-
throw new Error("DevCrew apply mode requires a git repository so implementation changes can be reviewed and reverted.");
|
|
183
|
-
}
|
|
184
|
-
return [];
|
|
185
|
-
}
|
|
186
|
-
return result.output
|
|
187
|
-
.split("\n")
|
|
188
|
-
.map((line) => line.trimEnd())
|
|
189
|
-
.filter(Boolean)
|
|
190
|
-
.filter((line) => {
|
|
191
|
-
const path = line.slice(3);
|
|
192
|
-
return !path.startsWith(".devcrew/") && !path.startsWith("docs/devcrew/");
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function assertCleanApplyWorkspace(cwd: string): Promise<void> {
|
|
197
|
-
const changedLines = await listChangedLines(cwd, { requireGit: true });
|
|
198
|
-
if (changedLines.length === 0) {
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const visibleLines = changedLines.slice(0, 20).join(", ");
|
|
202
|
-
const suffix = changedLines.length > 20 ? `, and ${changedLines.length - 20} more` : "";
|
|
203
|
-
throw new Error(
|
|
204
|
-
`DevCrew apply mode requires a clean working tree before implementation. Commit, stash, or remove these files: ${visibleLines}${suffix}`,
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
async function collectImplementationDiff(cwd: string): Promise<string> {
|
|
209
|
-
const result = await runShellCommand("git diff --no-ext-diff --", cwd, 30_000);
|
|
210
|
-
if (result.exitCode !== 0) {
|
|
211
|
-
return "";
|
|
212
|
-
}
|
|
213
|
-
return result.output;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Files attributable to this run are the porcelain lines that appeared (or
|
|
217
|
-
// changed status) since the baseline captured before the role executed. This
|
|
218
|
-
// keeps a user's pre-existing uncommitted edits out of the changed-files list.
|
|
219
|
-
// The porcelain status prefix is preserved for review (?? = new, M = modified).
|
|
220
|
-
export function changedSinceBaseline(baseline: string[], current: string[]): string[] {
|
|
221
|
-
const baselineLines = new Set(baseline);
|
|
222
|
-
return current.filter((line) => !baselineLines.has(line));
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Strip the two-character porcelain status plus its separating space. For
|
|
226
|
-
// rename entries ("R old -> new") the destination path is what remains
|
|
227
|
-
// relevant, so keep only the post-arrow path when present.
|
|
228
|
-
export function porcelainPath(line: string): string {
|
|
229
|
-
const raw = line.slice(3).trim();
|
|
230
|
-
const arrow = raw.indexOf(" -> ");
|
|
231
|
-
return arrow >= 0 ? raw.slice(arrow + 4) : raw;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export interface RevertDeps {
|
|
235
|
-
runGit?: (args: string[], cwd: string) => Promise<{ exitCode: number; stdout: string }>;
|
|
236
|
-
removeFile?: (absolutePath: string) => Promise<void>;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Roll back implementer edits when an apply-mode gate is rejected.
|
|
240
|
-
// Only the files this run introduced/changed are reverted, so unrelated work in
|
|
241
|
-
// the repository is preserved. Tracked files are restored from HEAD; files that
|
|
242
|
-
// did not exist in HEAD are deleted. Failures are surfaced to the MCP caller.
|
|
243
|
-
export async function revertChangedFiles(
|
|
244
|
-
cwd: string,
|
|
245
|
-
changedFiles: string[],
|
|
246
|
-
deps: RevertDeps = {},
|
|
247
|
-
): Promise<void> {
|
|
248
|
-
const git = deps.runGit ?? runGit;
|
|
249
|
-
const removeFile = deps.removeFile ?? ((absolutePath: string) => rm(absolutePath, { force: true }));
|
|
250
|
-
for (const line of changedFiles) {
|
|
251
|
-
const file = porcelainPath(line);
|
|
252
|
-
if (!file) {
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
const tracked = await git(["cat-file", "-e", `HEAD:${file}`], cwd);
|
|
256
|
-
if (tracked.exitCode === 0) {
|
|
257
|
-
const restored = await git(["restore", "--source=HEAD", "--", file], cwd);
|
|
258
|
-
if (restored.exitCode !== 0) {
|
|
259
|
-
throw new Error(`Failed to restore ${file}: ${restored.stdout || "git restore failed"}`);
|
|
260
|
-
}
|
|
261
|
-
} else {
|
|
262
|
-
try {
|
|
263
|
-
await removeFile(resolvePath(cwd, file));
|
|
264
|
-
} catch (error) {
|
|
265
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
266
|
-
throw new Error(`Failed to remove ${file}: ${message}`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
173
|
async function runCommands(commands: string[], cwd: string): Promise<VerificationResult[]> {
|
|
273
174
|
const results: VerificationResult[] = [];
|
|
274
175
|
for (const command of commands) {
|
|
@@ -293,30 +194,23 @@ function uniqueCommands(commands: string[]): string[] {
|
|
|
293
194
|
// Tester verification runs the normal verification path first, then coverage
|
|
294
195
|
// as supplemental evidence. Configured commands win per category; otherwise
|
|
295
196
|
// DevCrew discovers common project commands.
|
|
296
|
-
async function runConfiguredVerification(state: RunState): Promise<VerificationResult[]> {
|
|
197
|
+
async function runConfiguredVerification(state: RunState, commandCwd: string): Promise<VerificationResult[]> {
|
|
297
198
|
const config = await readConfig(state.cwd);
|
|
298
199
|
const configuredVerify = config.verifyCommands.filter((command) => command.trim().length > 0);
|
|
299
200
|
const configuredCoverage = (config.coverageCommands ?? []).filter((command) => command.trim().length > 0);
|
|
300
|
-
const verifyCommands = configuredVerify.length > 0 ? configuredVerify : await discoverVerifyCommands(
|
|
301
|
-
const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(
|
|
201
|
+
const verifyCommands = configuredVerify.length > 0 ? configuredVerify : await discoverVerifyCommands(commandCwd);
|
|
202
|
+
const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(commandCwd);
|
|
302
203
|
const commands = uniqueCommands([...verifyCommands, ...coverageCommands]);
|
|
303
|
-
return runCommands(commands,
|
|
204
|
+
return runCommands(commands, commandCwd);
|
|
304
205
|
}
|
|
305
206
|
|
|
306
207
|
// Implementer apply runs lint/format/typecheck so reviewers see standards
|
|
307
208
|
// compliance evidence. Configured lintCommands win, otherwise discover them.
|
|
308
|
-
async function runConfiguredLint(state: RunState): Promise<VerificationResult[]> {
|
|
209
|
+
async function runConfiguredLint(state: RunState, commandCwd: string): Promise<VerificationResult[]> {
|
|
309
210
|
const config = await readConfig(state.cwd);
|
|
310
211
|
const configuredLint = (config.lintCommands ?? []).filter((command) => command.trim().length > 0);
|
|
311
|
-
const commands = configuredLint.length > 0 ? configuredLint : await discoverLintCommands(
|
|
312
|
-
return runCommands(commands,
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function changedFilesBlock(changedFiles: string[]): string {
|
|
316
|
-
if (changedFiles.length === 0) {
|
|
317
|
-
return "No changed files were detected.";
|
|
318
|
-
}
|
|
319
|
-
return changedFiles.map((file) => `- ${file}`).join("\n");
|
|
212
|
+
const commands = configuredLint.length > 0 ? configuredLint : await discoverLintCommands(commandCwd);
|
|
213
|
+
return runCommands(commands, commandCwd);
|
|
320
214
|
}
|
|
321
215
|
|
|
322
216
|
function verificationBlock(results: VerificationResult[]): string {
|
|
@@ -331,31 +225,58 @@ function verificationBlock(results: VerificationResult[]): string {
|
|
|
331
225
|
.join("\n\n");
|
|
332
226
|
}
|
|
333
227
|
|
|
334
|
-
function lintBlock(results: VerificationResult[]): string {
|
|
335
|
-
if (results.length === 0) {
|
|
336
|
-
return "No lint or format commands were detected.";
|
|
337
|
-
}
|
|
338
|
-
return verificationBlock(results);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
228
|
function appendExecutionSections(artifact: ArtifactName, markdown: string, state: RunState): string {
|
|
342
|
-
if (artifact === "implementation-plan") {
|
|
343
|
-
let next = markdown.trim();
|
|
344
|
-
if (!next.includes("## Recorded Changes")) {
|
|
345
|
-
next = `${next}\n\n## Recorded Changes\n\n${changedFilesBlock(state.changedFiles)}`;
|
|
346
|
-
}
|
|
347
|
-
if (!next.includes("## Lint Results")) {
|
|
348
|
-
next = `${next}\n\n## Lint Results\n\n${lintBlock(state.lintResults)}`;
|
|
349
|
-
}
|
|
350
|
-
return `${next}\n`;
|
|
351
|
-
}
|
|
352
229
|
if (artifact === "test-report" && !markdown.includes("## Acceptance Evidence")) {
|
|
353
230
|
return `${markdown.trim()}\n\n## Acceptance Evidence\n\n${verificationBlock(state.verification)}\n`;
|
|
354
231
|
}
|
|
355
232
|
return markdown;
|
|
356
233
|
}
|
|
357
234
|
|
|
235
|
+
async function writeImplementationReview(state: RunState): Promise<void> {
|
|
236
|
+
state.artifacts["implementation-review"] = await writeMarkdownArtifact(
|
|
237
|
+
state,
|
|
238
|
+
"implementation-review",
|
|
239
|
+
renderArtifact("implementation-review", state),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
358
243
|
async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole): Promise<RunState> {
|
|
244
|
+
if (state.phase === "execution") {
|
|
245
|
+
if (state.executionMode !== "apply") {
|
|
246
|
+
throw new Error("DevCrew execution phase requires apply mode");
|
|
247
|
+
}
|
|
248
|
+
const workspace = await ensureExecutionWorkspace(state);
|
|
249
|
+
state.executionWorkspace = workspace;
|
|
250
|
+
state.verification = [];
|
|
251
|
+
delete state.artifacts["test-report"];
|
|
252
|
+
await saveState(state);
|
|
253
|
+
|
|
254
|
+
const result = await runner({
|
|
255
|
+
backend: state.backend,
|
|
256
|
+
role: "implementer",
|
|
257
|
+
phase: "execution",
|
|
258
|
+
request: state.request,
|
|
259
|
+
mode: state.mode,
|
|
260
|
+
executionMode: "apply",
|
|
261
|
+
cwd: workspace.path,
|
|
262
|
+
standards: state.standards.combined,
|
|
263
|
+
artifactPath: artifactPath(state.cwd, state.runId, "implementation-review"),
|
|
264
|
+
answers: state.answers.map((entry) => entry.answer),
|
|
265
|
+
feedback: state.feedback.map((entry) => `${entry.gate}: ${entry.message}`),
|
|
266
|
+
priorArtifacts: await readPriorArtifacts(state),
|
|
267
|
+
});
|
|
268
|
+
await captureExecutionChanges(workspace);
|
|
269
|
+
state.lintResults = await runConfiguredLint(state, workspace.path);
|
|
270
|
+
const captured = await captureExecutionChanges(workspace);
|
|
271
|
+
state.changedFiles = captured.changedFiles;
|
|
272
|
+
state.implementationDiff = captured.patch;
|
|
273
|
+
state.roles.push(result);
|
|
274
|
+
await writeImplementationReview(state);
|
|
275
|
+
state.phase = "testing";
|
|
276
|
+
state.status = "ready";
|
|
277
|
+
return saveState(state);
|
|
278
|
+
}
|
|
279
|
+
|
|
359
280
|
const gate = gateForPhase(state.phase);
|
|
360
281
|
const role = roleForPhase(state.phase);
|
|
361
282
|
if (!gate || !role) {
|
|
@@ -369,15 +290,11 @@ async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole
|
|
|
369
290
|
|
|
370
291
|
const artifact = artifactForPhase(state.phase);
|
|
371
292
|
const path = artifactPath(state.cwd, state.runId, artifact);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const implementationBaseline: string[] = [];
|
|
377
|
-
if (applyingImplementation) {
|
|
378
|
-
await assertCleanApplyWorkspace(state.cwd);
|
|
293
|
+
const applyingTesting = state.executionMode === "apply" && state.phase === "testing";
|
|
294
|
+
const roleCwd = applyingTesting ? state.executionWorkspace?.path : state.cwd;
|
|
295
|
+
if (!roleCwd) {
|
|
296
|
+
throw new Error("DevCrew apply testing requires an execution workspace");
|
|
379
297
|
}
|
|
380
|
-
|
|
381
298
|
state.roles.push(conductorDecision(state, role, gate));
|
|
382
299
|
|
|
383
300
|
const result = await runner({
|
|
@@ -387,7 +304,7 @@ async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole
|
|
|
387
304
|
request: state.request,
|
|
388
305
|
mode: state.mode,
|
|
389
306
|
executionMode: state.executionMode,
|
|
390
|
-
cwd:
|
|
307
|
+
cwd: roleCwd,
|
|
391
308
|
standards: state.standards.combined,
|
|
392
309
|
artifactPath: path,
|
|
393
310
|
answers: state.answers.map((entry) => entry.answer),
|
|
@@ -395,13 +312,12 @@ async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole
|
|
|
395
312
|
priorArtifacts: await readPriorArtifacts(state),
|
|
396
313
|
});
|
|
397
314
|
|
|
398
|
-
if (
|
|
399
|
-
state.
|
|
400
|
-
|
|
401
|
-
state.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
state.verification = await runConfiguredVerification(state);
|
|
315
|
+
if (applyingTesting && state.executionWorkspace) {
|
|
316
|
+
state.verification = await runConfiguredVerification(state, roleCwd);
|
|
317
|
+
const captured = await captureExecutionChanges(state.executionWorkspace);
|
|
318
|
+
state.changedFiles = captured.changedFiles;
|
|
319
|
+
state.implementationDiff = captured.patch;
|
|
320
|
+
await writeImplementationReview(state);
|
|
405
321
|
}
|
|
406
322
|
|
|
407
323
|
// When the backend cannot run a real SDK we keep a single deterministic
|
|
@@ -410,13 +326,6 @@ async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole
|
|
|
410
326
|
const markdown = appendExecutionSections(artifact, baseMarkdown, state);
|
|
411
327
|
state.roles.push({ ...result, markdown });
|
|
412
328
|
state.artifacts[artifact] = await writeMarkdownArtifact(state, artifact, markdown);
|
|
413
|
-
if (artifact === "implementation-plan") {
|
|
414
|
-
state.artifacts["implementation-review"] = await writeMarkdownArtifact(
|
|
415
|
-
state,
|
|
416
|
-
"implementation-review",
|
|
417
|
-
renderArtifact("implementation-review", state),
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
329
|
state.gates[gate] = "pending";
|
|
421
330
|
state.status = "awaiting_approval";
|
|
422
331
|
return saveState(state);
|
|
@@ -436,27 +345,75 @@ export async function continueOrchestratedWorkflow(input: RunRef, runner: RoleRu
|
|
|
436
345
|
return runCurrentPhaseRole(state, runner);
|
|
437
346
|
}
|
|
438
347
|
|
|
348
|
+
export async function approveOrchestratedWorkflow(input: ApproveWorkflowInput): Promise<RunState> {
|
|
349
|
+
const before = await validateWorkflowApproval(input);
|
|
350
|
+
const promotingTesting =
|
|
351
|
+
input.gate === "testing" &&
|
|
352
|
+
before.executionMode === "apply" &&
|
|
353
|
+
before.phase === "testing" &&
|
|
354
|
+
before.status === "awaiting_approval" &&
|
|
355
|
+
before.gates.testing === "pending";
|
|
356
|
+
const cleaningCompletedPromotion =
|
|
357
|
+
input.gate === "testing" &&
|
|
358
|
+
before.executionMode === "apply" &&
|
|
359
|
+
before.gates.testing === "approved" &&
|
|
360
|
+
before.executionWorkspace !== undefined;
|
|
361
|
+
|
|
362
|
+
async function cleanupAfterApproval(state: RunState): Promise<RunState> {
|
|
363
|
+
try {
|
|
364
|
+
await cleanupExecutionWorkspace(state);
|
|
365
|
+
} catch {
|
|
366
|
+
return state;
|
|
367
|
+
}
|
|
368
|
+
state.executionWorkspace = undefined;
|
|
369
|
+
try {
|
|
370
|
+
return await saveState(state);
|
|
371
|
+
} catch {
|
|
372
|
+
return state;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (cleaningCompletedPromotion) {
|
|
377
|
+
return cleanupAfterApproval(before);
|
|
378
|
+
}
|
|
379
|
+
if (promotingTesting) {
|
|
380
|
+
await promoteExecutionChanges(before);
|
|
381
|
+
let approved: RunState;
|
|
382
|
+
try {
|
|
383
|
+
approved = await approveWorkflow(input);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
try {
|
|
386
|
+
await rollbackPromotedExecutionChanges(before);
|
|
387
|
+
} catch (rollbackError) {
|
|
388
|
+
const detail = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
|
|
389
|
+
throw new Error(`DevCrew approval failed and promoted patch rollback failed: ${detail}`, { cause: error });
|
|
390
|
+
}
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
return cleanupAfterApproval(approved);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return approveWorkflow(input);
|
|
397
|
+
}
|
|
398
|
+
|
|
439
399
|
export async function rejectOrchestratedWorkflow(input: RejectWorkflowInput): Promise<RunState> {
|
|
440
|
-
|
|
441
|
-
|
|
400
|
+
return rejectWorkflow(input);
|
|
401
|
+
}
|
|
442
402
|
|
|
443
|
-
|
|
444
|
-
|
|
403
|
+
export async function answerOrchestratedWorkflow(input: AnswerWorkflowInput, runner: RoleRunner = runRole): Promise<RunState> {
|
|
404
|
+
const before = await getWorkflowStatus(input);
|
|
405
|
+
const state = await answerWorkflow(input, { skipArtifactWrite: true });
|
|
445
406
|
if (
|
|
446
407
|
before.executionMode === "apply" &&
|
|
447
|
-
before.phase === "
|
|
448
|
-
before.
|
|
408
|
+
before.phase === "testing" &&
|
|
409
|
+
before.gates.testing === "rejected"
|
|
449
410
|
) {
|
|
450
|
-
|
|
451
|
-
state.
|
|
411
|
+
state.phase = "execution";
|
|
412
|
+
state.status = "ready";
|
|
413
|
+
state.gates.testing = "not_started";
|
|
452
414
|
return saveState(state);
|
|
453
415
|
}
|
|
454
416
|
|
|
455
|
-
return state;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export async function answerOrchestratedWorkflow(input: AnswerWorkflowInput, runner: RoleRunner = runRole): Promise<RunState> {
|
|
459
|
-
const state = await answerWorkflow(input, { skipArtifactWrite: true });
|
|
460
417
|
const gate = gateForPhase(state.phase);
|
|
461
418
|
const role = roleForPhase(state.phase);
|
|
462
419
|
if (!gate || !role) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
executionWorktreePath,
|
|
7
|
+
runDir,
|
|
8
|
+
type ExecutionWorkspace,
|
|
9
|
+
type RunState,
|
|
10
|
+
} from "../../core/src/index.js";
|
|
11
|
+
|
|
12
|
+
export interface CapturedExecutionChanges {
|
|
13
|
+
changedFiles: string[];
|
|
14
|
+
patch: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const REPOSITORY_PATHS = [
|
|
18
|
+
".",
|
|
19
|
+
":(exclude).devcrew",
|
|
20
|
+
":(exclude).devcrew/**",
|
|
21
|
+
":(exclude)docs/devcrew",
|
|
22
|
+
":(exclude)docs/devcrew/**",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
async function runGit(
|
|
26
|
+
args: string[],
|
|
27
|
+
cwd: string,
|
|
28
|
+
stdin?: string,
|
|
29
|
+
env?: NodeJS.ProcessEnv,
|
|
30
|
+
): Promise<string> {
|
|
31
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
32
|
+
const child = spawn("git", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
33
|
+
let stdout = "";
|
|
34
|
+
let stderr = "";
|
|
35
|
+
let settled = false;
|
|
36
|
+
|
|
37
|
+
const rejectOnce = (error: Error): void => {
|
|
38
|
+
if (settled) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
settled = true;
|
|
42
|
+
rejectResult(error);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
46
|
+
stdout += chunk.toString("utf8");
|
|
47
|
+
});
|
|
48
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
49
|
+
stderr += chunk.toString("utf8");
|
|
50
|
+
});
|
|
51
|
+
child.on("error", rejectOnce);
|
|
52
|
+
child.on("close", (code) => {
|
|
53
|
+
if (settled) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
settled = true;
|
|
57
|
+
if (code === 0) {
|
|
58
|
+
resolveResult(stdout);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
rejectResult(new Error(`git ${args[0]} failed: ${(stderr || stdout).trim()}`));
|
|
62
|
+
});
|
|
63
|
+
child.stdin.end(stdin);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
68
|
+
try {
|
|
69
|
+
await access(path);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function assertCleanRequester(cwd: string): Promise<void> {
|
|
77
|
+
const status = await runGit(
|
|
78
|
+
[
|
|
79
|
+
"status",
|
|
80
|
+
"--porcelain",
|
|
81
|
+
"-uall",
|
|
82
|
+
"--",
|
|
83
|
+
...REPOSITORY_PATHS,
|
|
84
|
+
],
|
|
85
|
+
cwd,
|
|
86
|
+
);
|
|
87
|
+
if (status.trim()) {
|
|
88
|
+
throw new Error("DevCrew apply promotion requires a clean working tree");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nulPaths(output: string): string[] {
|
|
93
|
+
return output.split("\0").filter(Boolean);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseChangedFiles(output: string): string[] {
|
|
97
|
+
const fields = nulPaths(output);
|
|
98
|
+
const paths: string[] = [];
|
|
99
|
+
for (let index = 0; index < fields.length; ) {
|
|
100
|
+
const status = fields[index++];
|
|
101
|
+
const firstPath = fields[index++];
|
|
102
|
+
if (!status || !firstPath) {
|
|
103
|
+
throw new Error("DevCrew could not parse git diff name status output");
|
|
104
|
+
}
|
|
105
|
+
paths.push(firstPath);
|
|
106
|
+
if (status.startsWith("R") || status.startsWith("C")) {
|
|
107
|
+
const secondPath = fields[index++];
|
|
108
|
+
if (!secondPath) {
|
|
109
|
+
throw new Error("DevCrew could not parse renamed git diff path");
|
|
110
|
+
}
|
|
111
|
+
paths.push(secondPath);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...new Set(paths)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function markUntrackedIntentToAdd(cwd: string, env?: NodeJS.ProcessEnv): Promise<void> {
|
|
118
|
+
const untracked = nulPaths(
|
|
119
|
+
await runGit(
|
|
120
|
+
["ls-files", "--others", "--exclude-standard", "-z", "--", ...REPOSITORY_PATHS],
|
|
121
|
+
cwd,
|
|
122
|
+
undefined,
|
|
123
|
+
env,
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
if (untracked.length > 0) {
|
|
127
|
+
await runGit(["add", "-N", "--", ...untracked], cwd, undefined, env);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface CapturePatchOptions {
|
|
132
|
+
allowEmpty?: boolean;
|
|
133
|
+
env?: NodeJS.ProcessEnv;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function capturePatch(
|
|
137
|
+
workspace: ExecutionWorkspace,
|
|
138
|
+
options: CapturePatchOptions = {},
|
|
139
|
+
): Promise<CapturedExecutionChanges> {
|
|
140
|
+
await markUntrackedIntentToAdd(workspace.path, options.env);
|
|
141
|
+
const changedFiles = parseChangedFiles(
|
|
142
|
+
await runGit(
|
|
143
|
+
["diff", "--name-status", "-z", workspace.baseCommit, "--", ...REPOSITORY_PATHS],
|
|
144
|
+
workspace.path,
|
|
145
|
+
undefined,
|
|
146
|
+
options.env,
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
const patch = await runGit(
|
|
150
|
+
["diff", "--binary", "--no-ext-diff", workspace.baseCommit, "--", ...REPOSITORY_PATHS],
|
|
151
|
+
workspace.path,
|
|
152
|
+
undefined,
|
|
153
|
+
options.env,
|
|
154
|
+
);
|
|
155
|
+
if (!options.allowEmpty && !patch.trim()) {
|
|
156
|
+
throw new Error("DevCrew apply implementer produced no repository changes");
|
|
157
|
+
}
|
|
158
|
+
return { changedFiles, patch };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function captureRequesterChanges(state: RunState, workspace: ExecutionWorkspace): Promise<CapturedExecutionChanges> {
|
|
162
|
+
const indexPath = join(runDir(state.cwd, state.runId), "promotion.index");
|
|
163
|
+
await mkdir(dirname(indexPath), { recursive: true });
|
|
164
|
+
await rm(indexPath, { force: true });
|
|
165
|
+
const env = { ...process.env, GIT_INDEX_FILE: indexPath };
|
|
166
|
+
try {
|
|
167
|
+
await runGit(["read-tree", workspace.baseCommit], state.cwd, undefined, env);
|
|
168
|
+
return await capturePatch(
|
|
169
|
+
{ path: state.cwd, baseCommit: workspace.baseCommit },
|
|
170
|
+
{ allowEmpty: true, env },
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
await rm(indexPath, { force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function ensureExecutionWorkspace(state: RunState): Promise<ExecutionWorkspace> {
|
|
178
|
+
if (state.executionWorkspace && (await pathExists(state.executionWorkspace.path))) {
|
|
179
|
+
return state.executionWorkspace;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await assertCleanRequester(state.cwd);
|
|
183
|
+
const baseCommit = (await runGit(["rev-parse", "HEAD"], state.cwd)).trim();
|
|
184
|
+
const path = executionWorktreePath(state.cwd, state.runId);
|
|
185
|
+
await mkdir(dirname(path), { recursive: true });
|
|
186
|
+
await runGit(["worktree", "add", "--detach", path, baseCommit], state.cwd);
|
|
187
|
+
return { path, baseCommit };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function captureExecutionChanges(
|
|
191
|
+
workspace: ExecutionWorkspace,
|
|
192
|
+
): Promise<CapturedExecutionChanges> {
|
|
193
|
+
const head = (await runGit(["rev-parse", "HEAD"], workspace.path)).trim();
|
|
194
|
+
if (head !== workspace.baseCommit) {
|
|
195
|
+
await runGit(["reset", "--mixed", workspace.baseCommit], workspace.path);
|
|
196
|
+
}
|
|
197
|
+
return capturePatch(workspace);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function promoteExecutionChanges(state: RunState): Promise<void> {
|
|
201
|
+
const workspace = state.executionWorkspace;
|
|
202
|
+
if (!workspace || !state.implementationDiff.trim()) {
|
|
203
|
+
throw new Error("DevCrew apply promotion requires reviewed execution changes");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const requesterHead = (await runGit(["rev-parse", "HEAD"], state.cwd)).trim();
|
|
207
|
+
if (requesterHead !== workspace.baseCommit) {
|
|
208
|
+
throw new Error("DevCrew apply promotion refused because requester HEAD changed");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const patchPath = join(runDir(state.cwd, state.runId), "implementation.patch");
|
|
212
|
+
await mkdir(dirname(patchPath), { recursive: true });
|
|
213
|
+
await writeFile(patchPath, state.implementationDiff);
|
|
214
|
+
|
|
215
|
+
const current = await captureRequesterChanges(state, workspace);
|
|
216
|
+
if (current.patch === state.implementationDiff) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (current.patch.trim()) {
|
|
220
|
+
throw new Error("DevCrew apply promotion requires a clean working tree");
|
|
221
|
+
}
|
|
222
|
+
await assertCleanRequester(state.cwd);
|
|
223
|
+
|
|
224
|
+
await runGit(["apply", "--check", "--binary", patchPath], state.cwd);
|
|
225
|
+
await runGit(["apply", "--binary", patchPath], state.cwd);
|
|
226
|
+
|
|
227
|
+
const promoted = await captureRequesterChanges(state, workspace);
|
|
228
|
+
if (promoted.patch !== state.implementationDiff) {
|
|
229
|
+
try {
|
|
230
|
+
await runGit(["apply", "--reverse", "--binary", patchPath], state.cwd);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const detail = error instanceof Error ? `: ${error.message}` : "";
|
|
233
|
+
throw new Error(`DevCrew promoted patch verification failed and rollback failed${detail}`);
|
|
234
|
+
}
|
|
235
|
+
throw new Error("DevCrew promoted patch differs from the reviewed implementation diff");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function rollbackPromotedExecutionChanges(state: RunState): Promise<void> {
|
|
240
|
+
const workspace = state.executionWorkspace;
|
|
241
|
+
if (!workspace || !state.implementationDiff.trim()) {
|
|
242
|
+
throw new Error("DevCrew apply rollback requires reviewed execution changes");
|
|
243
|
+
}
|
|
244
|
+
const patchPath = join(runDir(state.cwd, state.runId), "implementation.patch");
|
|
245
|
+
await runGit(["apply", "--reverse", "--check", "--binary", patchPath], state.cwd);
|
|
246
|
+
await runGit(["apply", "--reverse", "--binary", patchPath], state.cwd);
|
|
247
|
+
const remaining = await captureRequesterChanges(state, workspace);
|
|
248
|
+
if (remaining.patch.trim()) {
|
|
249
|
+
throw new Error("DevCrew apply rollback left requester repository changes");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function cleanupExecutionWorkspace(state: RunState): Promise<void> {
|
|
254
|
+
const workspace = state.executionWorkspace;
|
|
255
|
+
if (!workspace) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (await pathExists(workspace.path)) {
|
|
259
|
+
await runGit(["worktree", "remove", "--force", workspace.path], state.cwd);
|
|
260
|
+
}
|
|
261
|
+
await runGit(["worktree", "prune"], state.cwd);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function executionCwd(state: RunState): Promise<string> {
|
|
265
|
+
if (state.executionMode !== "apply") {
|
|
266
|
+
return state.cwd;
|
|
267
|
+
}
|
|
268
|
+
return (await ensureExecutionWorkspace(state)).path;
|
|
269
|
+
}
|