@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,8 +1,9 @@
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
  import { runRole } from "../../adapters/src/index.js";
5
- import { answerWorkflow, artifactForPhase, artifactPath, ARTIFACTS, discoverCoverageCommands, discoverLintCommands, discoverVerifyCommands, gateForPhase, getWorkflowStatus, readConfig, rejectWorkflow, renderArtifact, saveState, startWorkflow, } from "../../core/src/index.js";
5
+ import { answerWorkflow, approveWorkflow, artifactForPhase, artifactPath, ARTIFACTS, discoverCoverageCommands, discoverLintCommands, discoverVerifyCommands, gateForPhase, getWorkflowStatus, readConfig, rejectWorkflow, renderArtifact, saveState, startWorkflow, validateWorkflowApproval, } from "../../core/src/index.js";
6
+ import { captureExecutionChanges, cleanupExecutionWorkspace, ensureExecutionWorkspace, promoteExecutionChanges, rollbackPromotedExecutionChanges, } from "./worktree.js";
6
7
  // Hard cap so a hung apply/verify command cannot block the serialized MCP loop.
7
8
  const COMMAND_TIMEOUT_MS = 300_000;
8
9
  function roleForPhase(phase) {
@@ -10,6 +11,7 @@ function roleForPhase(phase) {
10
11
  requirements: "pm",
11
12
  architecture: "architect",
12
13
  implementation: "implementer",
14
+ execution: "implementer",
13
15
  testing: "tester",
14
16
  };
15
17
  return roles[phase];
@@ -113,100 +115,6 @@ export async function runShellCommand(command, cwd, timeoutMs = COMMAND_TIMEOUT_
113
115
  });
114
116
  });
115
117
  }
116
- async function runGit(args, cwd) {
117
- return new Promise((resolveResult) => {
118
- const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
119
- let stdout = "";
120
- let stderr = "";
121
- child.stdout.on("data", (chunk) => {
122
- stdout += chunk.toString("utf8");
123
- });
124
- child.stderr.on("data", (chunk) => {
125
- stderr += chunk.toString("utf8");
126
- });
127
- child.on("error", () => resolveResult({ exitCode: 1, stdout: "" }));
128
- child.on("close", (code) => resolveResult({ exitCode: code ?? 1, stdout: stdout || stderr }));
129
- });
130
- }
131
- async function listChangedLines(cwd, options = {}) {
132
- const result = await runShellCommand("git status --porcelain -uall", cwd, 30_000);
133
- if (result.exitCode !== 0) {
134
- if (options.requireGit) {
135
- throw new Error("DevCrew apply mode requires a git repository so implementation changes can be reviewed and reverted.");
136
- }
137
- return [];
138
- }
139
- return result.output
140
- .split("\n")
141
- .map((line) => line.trimEnd())
142
- .filter(Boolean)
143
- .filter((line) => {
144
- const path = line.slice(3);
145
- return !path.startsWith(".devcrew/") && !path.startsWith("docs/devcrew/");
146
- });
147
- }
148
- async function assertCleanApplyWorkspace(cwd) {
149
- const changedLines = await listChangedLines(cwd, { requireGit: true });
150
- if (changedLines.length === 0) {
151
- return;
152
- }
153
- const visibleLines = changedLines.slice(0, 20).join(", ");
154
- const suffix = changedLines.length > 20 ? `, and ${changedLines.length - 20} more` : "";
155
- throw new Error(`DevCrew apply mode requires a clean working tree before implementation. Commit, stash, or remove these files: ${visibleLines}${suffix}`);
156
- }
157
- async function collectImplementationDiff(cwd) {
158
- const result = await runShellCommand("git diff --no-ext-diff --", cwd, 30_000);
159
- if (result.exitCode !== 0) {
160
- return "";
161
- }
162
- return result.output;
163
- }
164
- // Files attributable to this run are the porcelain lines that appeared (or
165
- // changed status) since the baseline captured before the role executed. This
166
- // keeps a user's pre-existing uncommitted edits out of the changed-files list.
167
- // The porcelain status prefix is preserved for review (?? = new, M = modified).
168
- export function changedSinceBaseline(baseline, current) {
169
- const baselineLines = new Set(baseline);
170
- return current.filter((line) => !baselineLines.has(line));
171
- }
172
- // Strip the two-character porcelain status plus its separating space. For
173
- // rename entries ("R old -> new") the destination path is what remains
174
- // relevant, so keep only the post-arrow path when present.
175
- export function porcelainPath(line) {
176
- const raw = line.slice(3).trim();
177
- const arrow = raw.indexOf(" -> ");
178
- return arrow >= 0 ? raw.slice(arrow + 4) : raw;
179
- }
180
- // Roll back implementer edits when an apply-mode gate is rejected.
181
- // Only the files this run introduced/changed are reverted, so unrelated work in
182
- // the repository is preserved. Tracked files are restored from HEAD; files that
183
- // did not exist in HEAD are deleted. Failures are surfaced to the MCP caller.
184
- export async function revertChangedFiles(cwd, changedFiles, deps = {}) {
185
- const git = deps.runGit ?? runGit;
186
- const removeFile = deps.removeFile ?? ((absolutePath) => rm(absolutePath, { force: true }));
187
- for (const line of changedFiles) {
188
- const file = porcelainPath(line);
189
- if (!file) {
190
- continue;
191
- }
192
- const tracked = await git(["cat-file", "-e", `HEAD:${file}`], cwd);
193
- if (tracked.exitCode === 0) {
194
- const restored = await git(["restore", "--source=HEAD", "--", file], cwd);
195
- if (restored.exitCode !== 0) {
196
- throw new Error(`Failed to restore ${file}: ${restored.stdout || "git restore failed"}`);
197
- }
198
- }
199
- else {
200
- try {
201
- await removeFile(resolvePath(cwd, file));
202
- }
203
- catch (error) {
204
- const message = error instanceof Error ? error.message : String(error);
205
- throw new Error(`Failed to remove ${file}: ${message}`);
206
- }
207
- }
208
- }
209
- }
210
118
  async function runCommands(commands, cwd) {
211
119
  const results = [];
212
120
  for (const command of commands) {
@@ -229,28 +137,22 @@ function uniqueCommands(commands) {
229
137
  // Tester verification runs the normal verification path first, then coverage
230
138
  // as supplemental evidence. Configured commands win per category; otherwise
231
139
  // DevCrew discovers common project commands.
232
- async function runConfiguredVerification(state) {
140
+ async function runConfiguredVerification(state, commandCwd) {
233
141
  const config = await readConfig(state.cwd);
234
142
  const configuredVerify = config.verifyCommands.filter((command) => command.trim().length > 0);
235
143
  const configuredCoverage = (config.coverageCommands ?? []).filter((command) => command.trim().length > 0);
236
- const verifyCommands = configuredVerify.length > 0 ? configuredVerify : await discoverVerifyCommands(state.cwd);
237
- const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(state.cwd);
144
+ const verifyCommands = configuredVerify.length > 0 ? configuredVerify : await discoverVerifyCommands(commandCwd);
145
+ const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(commandCwd);
238
146
  const commands = uniqueCommands([...verifyCommands, ...coverageCommands]);
239
- return runCommands(commands, state.cwd);
147
+ return runCommands(commands, commandCwd);
240
148
  }
241
149
  // Implementer apply runs lint/format/typecheck so reviewers see standards
242
150
  // compliance evidence. Configured lintCommands win, otherwise discover them.
243
- async function runConfiguredLint(state) {
151
+ async function runConfiguredLint(state, commandCwd) {
244
152
  const config = await readConfig(state.cwd);
245
153
  const configuredLint = (config.lintCommands ?? []).filter((command) => command.trim().length > 0);
246
- const commands = configuredLint.length > 0 ? configuredLint : await discoverLintCommands(state.cwd);
247
- return runCommands(commands, state.cwd);
248
- }
249
- function changedFilesBlock(changedFiles) {
250
- if (changedFiles.length === 0) {
251
- return "No changed files were detected.";
252
- }
253
- return changedFiles.map((file) => `- ${file}`).join("\n");
154
+ const commands = configuredLint.length > 0 ? configuredLint : await discoverLintCommands(commandCwd);
155
+ return runCommands(commands, commandCwd);
254
156
  }
255
157
  function verificationBlock(results) {
256
158
  if (results.length === 0) {
@@ -260,29 +162,50 @@ function verificationBlock(results) {
260
162
  .map((result) => `### ${result.command}\n\nExit Code: ${result.exitCode}\n\nOutput:\n\n\`\`\`text\n${result.output || "(no output)"}\n\`\`\``)
261
163
  .join("\n\n");
262
164
  }
263
- function lintBlock(results) {
264
- if (results.length === 0) {
265
- return "No lint or format commands were detected.";
266
- }
267
- return verificationBlock(results);
268
- }
269
165
  function appendExecutionSections(artifact, markdown, state) {
270
- if (artifact === "implementation-plan") {
271
- let next = markdown.trim();
272
- if (!next.includes("## Recorded Changes")) {
273
- next = `${next}\n\n## Recorded Changes\n\n${changedFilesBlock(state.changedFiles)}`;
274
- }
275
- if (!next.includes("## Lint Results")) {
276
- next = `${next}\n\n## Lint Results\n\n${lintBlock(state.lintResults)}`;
277
- }
278
- return `${next}\n`;
279
- }
280
166
  if (artifact === "test-report" && !markdown.includes("## Acceptance Evidence")) {
281
167
  return `${markdown.trim()}\n\n## Acceptance Evidence\n\n${verificationBlock(state.verification)}\n`;
282
168
  }
283
169
  return markdown;
284
170
  }
171
+ async function writeImplementationReview(state) {
172
+ state.artifacts["implementation-review"] = await writeMarkdownArtifact(state, "implementation-review", renderArtifact("implementation-review", state));
173
+ }
285
174
  async function runCurrentPhaseRole(state, runner = runRole) {
175
+ if (state.phase === "execution") {
176
+ if (state.executionMode !== "apply") {
177
+ throw new Error("DevCrew execution phase requires apply mode");
178
+ }
179
+ const workspace = await ensureExecutionWorkspace(state);
180
+ state.executionWorkspace = workspace;
181
+ state.verification = [];
182
+ delete state.artifacts["test-report"];
183
+ await saveState(state);
184
+ const result = await runner({
185
+ backend: state.backend,
186
+ role: "implementer",
187
+ phase: "execution",
188
+ request: state.request,
189
+ mode: state.mode,
190
+ executionMode: "apply",
191
+ cwd: workspace.path,
192
+ standards: state.standards.combined,
193
+ artifactPath: artifactPath(state.cwd, state.runId, "implementation-review"),
194
+ answers: state.answers.map((entry) => entry.answer),
195
+ feedback: state.feedback.map((entry) => `${entry.gate}: ${entry.message}`),
196
+ priorArtifacts: await readPriorArtifacts(state),
197
+ });
198
+ await captureExecutionChanges(workspace);
199
+ state.lintResults = await runConfiguredLint(state, workspace.path);
200
+ const captured = await captureExecutionChanges(workspace);
201
+ state.changedFiles = captured.changedFiles;
202
+ state.implementationDiff = captured.patch;
203
+ state.roles.push(result);
204
+ await writeImplementationReview(state);
205
+ state.phase = "testing";
206
+ state.status = "ready";
207
+ return saveState(state);
208
+ }
286
209
  const gate = gateForPhase(state.phase);
287
210
  const role = roleForPhase(state.phase);
288
211
  if (!gate || !role) {
@@ -295,12 +218,10 @@ async function runCurrentPhaseRole(state, runner = runRole) {
295
218
  }
296
219
  const artifact = artifactForPhase(state.phase);
297
220
  const path = artifactPath(state.cwd, state.runId, artifact);
298
- // Apply-mode implementation starts from a clean tree, so changed files after
299
- // the role runs are attributable to the current DevCrew run.
300
- const applyingImplementation = state.executionMode === "apply" && state.phase === "implementation";
301
- const implementationBaseline = [];
302
- if (applyingImplementation) {
303
- await assertCleanApplyWorkspace(state.cwd);
221
+ const applyingTesting = state.executionMode === "apply" && state.phase === "testing";
222
+ const roleCwd = applyingTesting ? state.executionWorkspace?.path : state.cwd;
223
+ if (!roleCwd) {
224
+ throw new Error("DevCrew apply testing requires an execution workspace");
304
225
  }
305
226
  state.roles.push(conductorDecision(state, role, gate));
306
227
  const result = await runner({
@@ -310,20 +231,19 @@ async function runCurrentPhaseRole(state, runner = runRole) {
310
231
  request: state.request,
311
232
  mode: state.mode,
312
233
  executionMode: state.executionMode,
313
- cwd: state.cwd,
234
+ cwd: roleCwd,
314
235
  standards: state.standards.combined,
315
236
  artifactPath: path,
316
237
  answers: state.answers.map((entry) => entry.answer),
317
238
  feedback: state.feedback.map((entry) => `${entry.gate}: ${entry.message}`),
318
239
  priorArtifacts: await readPriorArtifacts(state),
319
240
  });
320
- if (applyingImplementation) {
321
- state.changedFiles = changedSinceBaseline(implementationBaseline, await listChangedLines(state.cwd));
322
- state.implementationDiff = await collectImplementationDiff(state.cwd);
323
- state.lintResults = await runConfiguredLint(state);
324
- }
325
- if (state.executionMode === "apply" && state.phase === "testing") {
326
- state.verification = await runConfiguredVerification(state);
241
+ if (applyingTesting && state.executionWorkspace) {
242
+ state.verification = await runConfiguredVerification(state, roleCwd);
243
+ const captured = await captureExecutionChanges(state.executionWorkspace);
244
+ state.changedFiles = captured.changedFiles;
245
+ state.implementationDiff = captured.patch;
246
+ await writeImplementationReview(state);
327
247
  }
328
248
  // When the backend cannot run a real SDK we keep a single deterministic
329
249
  // artifact source by rendering the rich phase template from the core layer.
@@ -331,9 +251,6 @@ async function runCurrentPhaseRole(state, runner = runRole) {
331
251
  const markdown = appendExecutionSections(artifact, baseMarkdown, state);
332
252
  state.roles.push({ ...result, markdown });
333
253
  state.artifacts[artifact] = await writeMarkdownArtifact(state, artifact, markdown);
334
- if (artifact === "implementation-plan") {
335
- state.artifacts["implementation-review"] = await writeMarkdownArtifact(state, "implementation-review", renderArtifact("implementation-review", state));
336
- }
337
254
  state.gates[gate] = "pending";
338
255
  state.status = "awaiting_approval";
339
256
  return saveState(state);
@@ -349,22 +266,69 @@ export async function continueOrchestratedWorkflow(input, runner = runRole) {
349
266
  }
350
267
  return runCurrentPhaseRole(state, runner);
351
268
  }
269
+ export async function approveOrchestratedWorkflow(input) {
270
+ const before = await validateWorkflowApproval(input);
271
+ const promotingTesting = input.gate === "testing" &&
272
+ before.executionMode === "apply" &&
273
+ before.phase === "testing" &&
274
+ before.status === "awaiting_approval" &&
275
+ before.gates.testing === "pending";
276
+ const cleaningCompletedPromotion = input.gate === "testing" &&
277
+ before.executionMode === "apply" &&
278
+ before.gates.testing === "approved" &&
279
+ before.executionWorkspace !== undefined;
280
+ async function cleanupAfterApproval(state) {
281
+ try {
282
+ await cleanupExecutionWorkspace(state);
283
+ }
284
+ catch {
285
+ return state;
286
+ }
287
+ state.executionWorkspace = undefined;
288
+ try {
289
+ return await saveState(state);
290
+ }
291
+ catch {
292
+ return state;
293
+ }
294
+ }
295
+ if (cleaningCompletedPromotion) {
296
+ return cleanupAfterApproval(before);
297
+ }
298
+ if (promotingTesting) {
299
+ await promoteExecutionChanges(before);
300
+ let approved;
301
+ try {
302
+ approved = await approveWorkflow(input);
303
+ }
304
+ catch (error) {
305
+ try {
306
+ await rollbackPromotedExecutionChanges(before);
307
+ }
308
+ catch (rollbackError) {
309
+ const detail = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
310
+ throw new Error(`DevCrew approval failed and promoted patch rollback failed: ${detail}`, { cause: error });
311
+ }
312
+ throw error;
313
+ }
314
+ return cleanupAfterApproval(approved);
315
+ }
316
+ return approveWorkflow(input);
317
+ }
352
318
  export async function rejectOrchestratedWorkflow(input) {
319
+ return rejectWorkflow(input);
320
+ }
321
+ export async function answerOrchestratedWorkflow(input, runner = runRole) {
353
322
  const before = await getWorkflowStatus(input);
354
- const state = await rejectWorkflow(input);
355
- // Roll back implementer edits when an apply-mode implementation gate is
356
- // rejected so the next attempt starts from a clean working tree.
323
+ const state = await answerWorkflow(input, { skipArtifactWrite: true });
357
324
  if (before.executionMode === "apply" &&
358
- before.phase === "implementation" &&
359
- before.changedFiles.length > 0) {
360
- await revertChangedFiles(before.cwd, before.changedFiles);
361
- state.changedFiles = [];
325
+ before.phase === "testing" &&
326
+ before.gates.testing === "rejected") {
327
+ state.phase = "execution";
328
+ state.status = "ready";
329
+ state.gates.testing = "not_started";
362
330
  return saveState(state);
363
331
  }
364
- return state;
365
- }
366
- export async function answerOrchestratedWorkflow(input, runner = runRole) {
367
- const state = await answerWorkflow(input, { skipArtifactWrite: true });
368
332
  const gate = gateForPhase(state.phase);
369
333
  const role = roleForPhase(state.phase);
370
334
  if (!gate || !role) {
@@ -0,0 +1,198 @@
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
+ import { executionWorktreePath, runDir, } from "../../core/src/index.js";
5
+ const REPOSITORY_PATHS = [
6
+ ".",
7
+ ":(exclude).devcrew",
8
+ ":(exclude).devcrew/**",
9
+ ":(exclude)docs/devcrew",
10
+ ":(exclude)docs/devcrew/**",
11
+ ];
12
+ async function runGit(args, cwd, stdin, env) {
13
+ return new Promise((resolveResult, rejectResult) => {
14
+ const child = spawn("git", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] });
15
+ let stdout = "";
16
+ let stderr = "";
17
+ let settled = false;
18
+ const rejectOnce = (error) => {
19
+ if (settled) {
20
+ return;
21
+ }
22
+ settled = true;
23
+ rejectResult(error);
24
+ };
25
+ child.stdout.on("data", (chunk) => {
26
+ stdout += chunk.toString("utf8");
27
+ });
28
+ child.stderr.on("data", (chunk) => {
29
+ stderr += chunk.toString("utf8");
30
+ });
31
+ child.on("error", rejectOnce);
32
+ child.on("close", (code) => {
33
+ if (settled) {
34
+ return;
35
+ }
36
+ settled = true;
37
+ if (code === 0) {
38
+ resolveResult(stdout);
39
+ return;
40
+ }
41
+ rejectResult(new Error(`git ${args[0]} failed: ${(stderr || stdout).trim()}`));
42
+ });
43
+ child.stdin.end(stdin);
44
+ });
45
+ }
46
+ async function pathExists(path) {
47
+ try {
48
+ await access(path);
49
+ return true;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ async function assertCleanRequester(cwd) {
56
+ const status = await runGit([
57
+ "status",
58
+ "--porcelain",
59
+ "-uall",
60
+ "--",
61
+ ...REPOSITORY_PATHS,
62
+ ], cwd);
63
+ if (status.trim()) {
64
+ throw new Error("DevCrew apply promotion requires a clean working tree");
65
+ }
66
+ }
67
+ function nulPaths(output) {
68
+ return output.split("\0").filter(Boolean);
69
+ }
70
+ function parseChangedFiles(output) {
71
+ const fields = nulPaths(output);
72
+ const paths = [];
73
+ for (let index = 0; index < fields.length;) {
74
+ const status = fields[index++];
75
+ const firstPath = fields[index++];
76
+ if (!status || !firstPath) {
77
+ throw new Error("DevCrew could not parse git diff name status output");
78
+ }
79
+ paths.push(firstPath);
80
+ if (status.startsWith("R") || status.startsWith("C")) {
81
+ const secondPath = fields[index++];
82
+ if (!secondPath) {
83
+ throw new Error("DevCrew could not parse renamed git diff path");
84
+ }
85
+ paths.push(secondPath);
86
+ }
87
+ }
88
+ return [...new Set(paths)];
89
+ }
90
+ async function markUntrackedIntentToAdd(cwd, env) {
91
+ const untracked = nulPaths(await runGit(["ls-files", "--others", "--exclude-standard", "-z", "--", ...REPOSITORY_PATHS], cwd, undefined, env));
92
+ if (untracked.length > 0) {
93
+ await runGit(["add", "-N", "--", ...untracked], cwd, undefined, env);
94
+ }
95
+ }
96
+ async function capturePatch(workspace, options = {}) {
97
+ await markUntrackedIntentToAdd(workspace.path, options.env);
98
+ const changedFiles = parseChangedFiles(await runGit(["diff", "--name-status", "-z", workspace.baseCommit, "--", ...REPOSITORY_PATHS], workspace.path, undefined, options.env));
99
+ const patch = await runGit(["diff", "--binary", "--no-ext-diff", workspace.baseCommit, "--", ...REPOSITORY_PATHS], workspace.path, undefined, options.env);
100
+ if (!options.allowEmpty && !patch.trim()) {
101
+ throw new Error("DevCrew apply implementer produced no repository changes");
102
+ }
103
+ return { changedFiles, patch };
104
+ }
105
+ async function captureRequesterChanges(state, workspace) {
106
+ const indexPath = join(runDir(state.cwd, state.runId), "promotion.index");
107
+ await mkdir(dirname(indexPath), { recursive: true });
108
+ await rm(indexPath, { force: true });
109
+ const env = { ...process.env, GIT_INDEX_FILE: indexPath };
110
+ try {
111
+ await runGit(["read-tree", workspace.baseCommit], state.cwd, undefined, env);
112
+ return await capturePatch({ path: state.cwd, baseCommit: workspace.baseCommit }, { allowEmpty: true, env });
113
+ }
114
+ finally {
115
+ await rm(indexPath, { force: true });
116
+ }
117
+ }
118
+ export async function ensureExecutionWorkspace(state) {
119
+ if (state.executionWorkspace && (await pathExists(state.executionWorkspace.path))) {
120
+ return state.executionWorkspace;
121
+ }
122
+ await assertCleanRequester(state.cwd);
123
+ const baseCommit = (await runGit(["rev-parse", "HEAD"], state.cwd)).trim();
124
+ const path = executionWorktreePath(state.cwd, state.runId);
125
+ await mkdir(dirname(path), { recursive: true });
126
+ await runGit(["worktree", "add", "--detach", path, baseCommit], state.cwd);
127
+ return { path, baseCommit };
128
+ }
129
+ export async function captureExecutionChanges(workspace) {
130
+ const head = (await runGit(["rev-parse", "HEAD"], workspace.path)).trim();
131
+ if (head !== workspace.baseCommit) {
132
+ await runGit(["reset", "--mixed", workspace.baseCommit], workspace.path);
133
+ }
134
+ return capturePatch(workspace);
135
+ }
136
+ export async function promoteExecutionChanges(state) {
137
+ const workspace = state.executionWorkspace;
138
+ if (!workspace || !state.implementationDiff.trim()) {
139
+ throw new Error("DevCrew apply promotion requires reviewed execution changes");
140
+ }
141
+ const requesterHead = (await runGit(["rev-parse", "HEAD"], state.cwd)).trim();
142
+ if (requesterHead !== workspace.baseCommit) {
143
+ throw new Error("DevCrew apply promotion refused because requester HEAD changed");
144
+ }
145
+ const patchPath = join(runDir(state.cwd, state.runId), "implementation.patch");
146
+ await mkdir(dirname(patchPath), { recursive: true });
147
+ await writeFile(patchPath, state.implementationDiff);
148
+ const current = await captureRequesterChanges(state, workspace);
149
+ if (current.patch === state.implementationDiff) {
150
+ return;
151
+ }
152
+ if (current.patch.trim()) {
153
+ throw new Error("DevCrew apply promotion requires a clean working tree");
154
+ }
155
+ await assertCleanRequester(state.cwd);
156
+ await runGit(["apply", "--check", "--binary", patchPath], state.cwd);
157
+ await runGit(["apply", "--binary", patchPath], state.cwd);
158
+ const promoted = await captureRequesterChanges(state, workspace);
159
+ if (promoted.patch !== state.implementationDiff) {
160
+ try {
161
+ await runGit(["apply", "--reverse", "--binary", patchPath], state.cwd);
162
+ }
163
+ catch (error) {
164
+ const detail = error instanceof Error ? `: ${error.message}` : "";
165
+ throw new Error(`DevCrew promoted patch verification failed and rollback failed${detail}`);
166
+ }
167
+ throw new Error("DevCrew promoted patch differs from the reviewed implementation diff");
168
+ }
169
+ }
170
+ export async function rollbackPromotedExecutionChanges(state) {
171
+ const workspace = state.executionWorkspace;
172
+ if (!workspace || !state.implementationDiff.trim()) {
173
+ throw new Error("DevCrew apply rollback requires reviewed execution changes");
174
+ }
175
+ const patchPath = join(runDir(state.cwd, state.runId), "implementation.patch");
176
+ await runGit(["apply", "--reverse", "--check", "--binary", patchPath], state.cwd);
177
+ await runGit(["apply", "--reverse", "--binary", patchPath], state.cwd);
178
+ const remaining = await captureRequesterChanges(state, workspace);
179
+ if (remaining.patch.trim()) {
180
+ throw new Error("DevCrew apply rollback left requester repository changes");
181
+ }
182
+ }
183
+ export async function cleanupExecutionWorkspace(state) {
184
+ const workspace = state.executionWorkspace;
185
+ if (!workspace) {
186
+ return;
187
+ }
188
+ if (await pathExists(workspace.path)) {
189
+ await runGit(["worktree", "remove", "--force", workspace.path], state.cwd);
190
+ }
191
+ await runGit(["worktree", "prune"], state.cwd);
192
+ }
193
+ export async function executionCwd(state) {
194
+ if (state.executionMode !== "apply") {
195
+ return state.cwd;
196
+ }
197
+ return (await ensureExecutionWorkspace(state)).path;
198
+ }
@@ -1,5 +1,12 @@
1
1
  import { callDevCrewTool, listDevCrewTools } from "./tools.js";
2
2
  import { DEVCREW_VERSION } from "../../core/src/index.js";
3
+ function isJsonRpcRequest(value) {
4
+ return (typeof value === "object" &&
5
+ value !== null &&
6
+ !Array.isArray(value) &&
7
+ value.jsonrpc === "2.0" &&
8
+ typeof value.method === "string");
9
+ }
3
10
  function writeJson(message) {
4
11
  process.stdout.write(`${JSON.stringify(message)}\n`);
5
12
  }
@@ -45,7 +52,7 @@ async function handleRequest(request, write) {
45
52
  });
46
53
  return;
47
54
  }
48
- throw new Error(`Unsupported JSON-RPC method: ${request.method ?? "unknown"}`);
55
+ throw new Error(`Unsupported JSON-RPC method: ${request.method}`);
49
56
  }
50
57
  catch (error) {
51
58
  write({
@@ -77,8 +84,17 @@ export function createStdioLineProcessor(write = writeJson, handler = (request)
77
84
  });
78
85
  return queue;
79
86
  }
80
- queue = queue.then(() => handler(request));
81
- return queue;
87
+ if (!isJsonRpcRequest(request)) {
88
+ write({
89
+ jsonrpc: "2.0",
90
+ id: null,
91
+ error: { code: -32600, message: "Invalid Request" },
92
+ });
93
+ return queue;
94
+ }
95
+ const task = queue.then(() => handler(request));
96
+ queue = task.catch(() => undefined);
97
+ return task;
82
98
  };
83
99
  }
84
100
  export function runStdioServer() {
@@ -1,5 +1,5 @@
1
- import { approveWorkflow, getArtifact, getActiveRunId, getWorkflowStatus, setActiveRun, } from "../../core/src/index.js";
2
- import { answerOrchestratedWorkflow, continueOrchestratedWorkflow, rejectOrchestratedWorkflow, startOrchestratedWorkflow, } from "../../orchestrator/src/index.js";
1
+ import { getArtifact, getActiveRunId, getWorkflowStatus, setActiveRun, } from "../../core/src/index.js";
2
+ import { answerOrchestratedWorkflow, approveOrchestratedWorkflow, continueOrchestratedWorkflow, rejectOrchestratedWorkflow, startOrchestratedWorkflow, } from "../../orchestrator/src/index.js";
3
3
  const cwdProperty = { type: "string", description: "Repository working directory." };
4
4
  const runIdProperty = { type: "string", description: "DevCrew run id." };
5
5
  const hostValues = ["codex", "claude"];
@@ -154,7 +154,7 @@ export async function callDevCrewTool(name, args) {
154
154
  return success(`${summarizeState(state)}. Answer recorded.`, { state });
155
155
  }
156
156
  if (name === "devcrew_approve") {
157
- const state = await approveWorkflow((await withActiveRun(args)));
157
+ const state = await approveOrchestratedWorkflow((await withActiveRun(args)));
158
158
  return success(`${summarizeState(state)}. Gate approved.`, { state });
159
159
  }
160
160
  if (name === "devcrew_reject") {
@@ -26,7 +26,7 @@ For local plugin testing:
26
26
  claude --plugin-dir plugins/devcrew-claude
27
27
  ```
28
28
 
29
- Then ask Claude Code to use DevCrew for a feature or product workflow. The generated `.mcp.json` starts the version-locked npm package with an `npm exec --package=@shenlee/devcrew@0.1.1` wrapper.
29
+ Then ask Claude Code to use DevCrew for a feature or product workflow. The generated `.mcp.json` starts the version-locked npm package with an `npm exec --package=@shenlee/devcrew@0.1.2` wrapper.
30
30
 
31
31
  Claude Code permissions, hooks, and approval settings remain authoritative. DevCrew inherits the host boundary.
32
32
 
package/docs/codex.md CHANGED
@@ -13,7 +13,7 @@ Restart Codex, open the plugin directory, select the DevCrew marketplace, and in
13
13
  The plugin launches the MCP server with:
14
14
 
15
15
  ```bash
16
- npm exec --silent --yes --package=@shenlee/devcrew@0.1.1 -- node -e "<DevCrew CLI wrapper>" -- serve --stdio
16
+ npm exec --silent --yes --package=@shenlee/devcrew@0.1.2 -- node -e "<DevCrew CLI wrapper>" -- serve --stdio
17
17
  ```
18
18
 
19
19
  Use this path when you want to use DevCrew without cloning the repository first. The version is locked to the published npm package that matches the plugin manifest.
@@ -54,6 +54,10 @@ The DevCrew skill tells Codex to use these MCP tools:
54
54
 
55
55
  Codex sandbox and approval settings remain authoritative. DevCrew does not bypass them.
56
56
 
57
+ Apply mode keeps implementation planning read-only. After the implementation gate is approved, call `devcrew_continue` once to run the implementer in `.devcrew/worktrees/<run-id>`, then call it again to run the tester and verification commands in that worktree. Testing approval promotes the exact reviewed patch to the requester repository. Rejecting testing and answering the feedback returns the isolated run to execution without touching the requester repository.
58
+
59
+ Apply mode requires a Git repository, a clean requester worktree at execution and promotion, and a real Codex SDK backend.
60
+
57
61
  For apply mode, `@openai/codex-sdk` must be resolvable from the installed DevCrew package. Published DevCrew packages declare it as an optional dependency, which npm installs by default. If `devcrew doctor` reports it as missing, reinstall DevCrew with optional dependencies enabled:
58
62
 
59
63
  ```bash
@@ -62,7 +66,7 @@ npm install -g @shenlee/devcrew --include=optional
62
66
 
63
67
  ## Marketplace smoke test
64
68
 
65
- After publishing the npm package version referenced by the plugin, run the real marketplace smoke test:
69
+ After publishing `@shenlee/devcrew@0.1.2`, run the real marketplace smoke test. Do not treat this as a pre-publication check because the plugin is locked to that npm version:
66
70
 
67
71
  ```bash
68
72
  npm run smoke:codex-plugin
@@ -37,7 +37,7 @@ devcrew serve --stdio
37
37
  ```
38
38
 
39
39
  Normally the generated plugin starts this command for the host agent.
40
- Codex and Claude plugin bundles use a version-locked `npm exec --package=@shenlee/devcrew@0.1.1` wrapper so the MCP service is locked to the published package version.
40
+ Codex and Claude plugin bundles use a version-locked `npm exec --package=@shenlee/devcrew@0.1.2` wrapper so the MCP service is locked to the published package version.
41
41
 
42
42
  ## 4. Run A Workflow
43
43