@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.
Files changed (37) hide show
  1. package/README.md +19 -5
  2. package/README.zh-CN.md +19 -5
  3. package/dist/packages/adapters/src/index.js +3 -2
  4. package/dist/packages/core/src/paths.js +3 -0
  5. package/dist/packages/core/src/store.js +6 -0
  6. package/dist/packages/core/src/types.js +1 -0
  7. package/dist/packages/core/src/version.js +1 -1
  8. package/dist/packages/core/src/workflow.js +51 -20
  9. package/dist/packages/orchestrator/src/index.js +118 -154
  10. package/dist/packages/orchestrator/src/worktree.js +198 -0
  11. package/dist/packages/service/src/stdio.js +19 -3
  12. package/dist/packages/service/src/tools.js +3 -3
  13. package/docs/claude-code.md +1 -1
  14. package/docs/codex.md +6 -2
  15. package/docs/quickstart.md +1 -1
  16. package/docs/superpowers/plans/2026-07-10-p0-foundation-repair.md +939 -0
  17. package/docs/superpowers/specs/2026-07-10-p0-foundation-repair-design.md +182 -0
  18. package/docs/workflow.md +21 -4
  19. package/package.json +1 -1
  20. package/packages/adapters/src/index.ts +4 -3
  21. package/packages/core/src/paths.ts +4 -0
  22. package/packages/core/src/store.ts +7 -0
  23. package/packages/core/src/types.ts +7 -0
  24. package/packages/core/src/version.ts +1 -1
  25. package/packages/core/src/workflow.ts +55 -20
  26. package/packages/orchestrator/src/index.ts +136 -179
  27. package/packages/orchestrator/src/worktree.ts +269 -0
  28. package/packages/service/src/stdio.ts +27 -6
  29. package/packages/service/src/tools.ts +2 -2
  30. package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
  31. package/plugins/devcrew-codex/.mcp.json +1 -1
  32. package/plugins/devcrew-codex/agents/architect.toml +6 -0
  33. package/plugins/devcrew-codex/agents/implementer.toml +6 -0
  34. package/plugins/devcrew-codex/agents/pm.toml +7 -0
  35. package/plugins/devcrew-codex/agents/tester.toml +6 -0
  36. package/plugins/devcrew-codex/assets/logo.png +0 -0
  37. package/scripts/smoke-codex-plugin.mjs +1 -1
@@ -1,11 +1,12 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { dirname, resolve as resolvePath } from "node:path";
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(state.cwd);
301
- const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(state.cwd);
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, state.cwd);
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(state.cwd);
312
- return runCommands(commands, state.cwd);
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
- // Apply-mode implementation starts from a clean tree, so changed files after
374
- // the role runs are attributable to the current DevCrew run.
375
- const applyingImplementation = state.executionMode === "apply" && state.phase === "implementation";
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: state.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 (applyingImplementation) {
399
- state.changedFiles = changedSinceBaseline(implementationBaseline, await listChangedLines(state.cwd));
400
- state.implementationDiff = await collectImplementationDiff(state.cwd);
401
- state.lintResults = await runConfiguredLint(state);
402
- }
403
- if (state.executionMode === "apply" && state.phase === "testing") {
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
- const before = await getWorkflowStatus(input);
441
- const state = await rejectWorkflow(input);
400
+ return rejectWorkflow(input);
401
+ }
442
402
 
443
- // Roll back implementer edits when an apply-mode implementation gate is
444
- // rejected so the next attempt starts from a clean working tree.
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 === "implementation" &&
448
- before.changedFiles.length > 0
408
+ before.phase === "testing" &&
409
+ before.gates.testing === "rejected"
449
410
  ) {
450
- await revertChangedFiles(before.cwd, before.changedFiles);
451
- state.changedFiles = [];
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
+ }