@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,8 +1,9 @@
|
|
|
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
|
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(
|
|
237
|
-
const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(
|
|
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,
|
|
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(
|
|
247
|
-
return runCommands(commands,
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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:
|
|
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 (
|
|
321
|
-
state.
|
|
322
|
-
|
|
323
|
-
state.
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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 === "
|
|
359
|
-
before.
|
|
360
|
-
|
|
361
|
-
state.
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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 {
|
|
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
|
|
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") {
|
package/docs/claude-code.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
package/docs/quickstart.md
CHANGED
|
@@ -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.
|
|
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
|
|