@shenlee/devcrew 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +186 -0
- package/README.md +142 -0
- package/README.zh-CN.md +156 -0
- package/SECURITY.md +19 -0
- package/dist/packages/adapters/src/index.js +234 -0
- package/dist/packages/cli/src/index.js +76 -0
- package/dist/packages/core/src/active-run.js +24 -0
- package/dist/packages/core/src/artifacts.js +120 -0
- package/dist/packages/core/src/config.js +51 -0
- package/dist/packages/core/src/index.js +11 -0
- package/dist/packages/core/src/paths.js +51 -0
- package/dist/packages/core/src/standards.js +61 -0
- package/dist/packages/core/src/store.js +24 -0
- package/dist/packages/core/src/types.js +48 -0
- package/dist/packages/core/src/validation.js +52 -0
- package/dist/packages/core/src/verification.js +157 -0
- package/dist/packages/core/src/version.js +2 -0
- package/dist/packages/core/src/workflow.js +166 -0
- package/dist/packages/orchestrator/src/index.js +376 -0
- package/dist/packages/plugins/src/index.js +173 -0
- package/dist/packages/service/src/index.js +2 -0
- package/dist/packages/service/src/stdio.js +100 -0
- package/dist/packages/service/src/tools.js +177 -0
- package/docs/claude-code.md +37 -0
- package/docs/codex.md +80 -0
- package/docs/quickstart.md +64 -0
- package/docs/roles.md +43 -0
- package/docs/workflow.md +70 -0
- package/examples/README.md +11 -0
- package/examples/feature-request.md +13 -0
- package/examples/greenfield-request.md +14 -0
- package/package.json +60 -0
- package/packages/adapters/src/index.ts +404 -0
- package/packages/cli/src/index.ts +88 -0
- package/packages/core/src/active-run.ts +31 -0
- package/packages/core/src/artifacts.ts +148 -0
- package/packages/core/src/config.ts +56 -0
- package/packages/core/src/index.ts +11 -0
- package/packages/core/src/paths.ts +66 -0
- package/packages/core/src/standards.ts +70 -0
- package/packages/core/src/store.ts +28 -0
- package/packages/core/src/types.ts +182 -0
- package/packages/core/src/validation.ts +79 -0
- package/packages/core/src/verification.ts +163 -0
- package/packages/core/src/version.ts +2 -0
- package/packages/core/src/workflow.ts +214 -0
- package/packages/orchestrator/src/index.ts +469 -0
- package/packages/plugins/assets/composer-icon.png +0 -0
- package/packages/plugins/assets/logo.png +0 -0
- package/packages/plugins/src/index.ts +211 -0
- package/packages/service/src/index.ts +2 -0
- package/packages/service/src/stdio.ts +121 -0
- package/packages/service/src/tools.ts +215 -0
- package/plugins/devcrew-codex/.codex-plugin/plugin.json +41 -0
- package/plugins/devcrew-codex/.mcp.json +16 -0
- 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 +6 -0
- package/plugins/devcrew-codex/agents/tester.toml +6 -0
- package/plugins/devcrew-codex/assets/composer-icon.png +0 -0
- package/plugins/devcrew-codex/assets/logo.png +0 -0
- package/plugins/devcrew-codex/skills/devcrew/SKILL.md +17 -0
- package/scripts/smoke-codex-plugin.mjs +331 -0
|
@@ -0,0 +1,376 @@
|
|
|
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";
|
|
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";
|
|
6
|
+
// Hard cap so a hung apply/verify command cannot block the serialized MCP loop.
|
|
7
|
+
const COMMAND_TIMEOUT_MS = 300_000;
|
|
8
|
+
function roleForPhase(phase) {
|
|
9
|
+
const roles = {
|
|
10
|
+
requirements: "pm",
|
|
11
|
+
architecture: "architect",
|
|
12
|
+
implementation: "implementer",
|
|
13
|
+
testing: "tester",
|
|
14
|
+
};
|
|
15
|
+
return roles[phase];
|
|
16
|
+
}
|
|
17
|
+
function conductorDecision(state, role, gate) {
|
|
18
|
+
const summary = `Conductor routed ${state.phase} phase to ${role} and prepared the ${gate} gate.`;
|
|
19
|
+
return {
|
|
20
|
+
role: "conductor",
|
|
21
|
+
backend: state.backend,
|
|
22
|
+
summary,
|
|
23
|
+
markdown: `# Conductor Decision\n\n${summary}\n`,
|
|
24
|
+
usedFallback: false,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function priorArtifactNamesForPhase(phase) {
|
|
28
|
+
const current = artifactForPhase(phase);
|
|
29
|
+
const currentIndex = ARTIFACTS.indexOf(current);
|
|
30
|
+
if (currentIndex <= 0) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
return ARTIFACTS.slice(0, currentIndex);
|
|
34
|
+
}
|
|
35
|
+
async function writeMarkdownArtifact(state, artifact, markdown) {
|
|
36
|
+
const path = artifactPath(state.cwd, state.runId, artifact);
|
|
37
|
+
await mkdir(dirname(path), { recursive: true });
|
|
38
|
+
await writeFile(path, markdown, "utf8");
|
|
39
|
+
return path;
|
|
40
|
+
}
|
|
41
|
+
function now() {
|
|
42
|
+
return new Date().toISOString();
|
|
43
|
+
}
|
|
44
|
+
function fallbackNotice(result) {
|
|
45
|
+
if (!result.usedFallback) {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
if (result.backend === "local") {
|
|
49
|
+
return `> DevCrew local fallback: this artifact uses the deterministic local planning template because the local backend does not call an external SDK.\n\n`;
|
|
50
|
+
}
|
|
51
|
+
const reason = result.summary.includes("output failed validation")
|
|
52
|
+
? `the ${result.backend} SDK did not return a valid artifact`
|
|
53
|
+
: `the ${result.backend} SDK was unavailable`;
|
|
54
|
+
return `> DevCrew SDK fallback: this artifact uses the deterministic planning template because ${reason}.\n> Reason: ${result.summary}\n\n`;
|
|
55
|
+
}
|
|
56
|
+
async function readPriorArtifacts(state) {
|
|
57
|
+
const priorArtifacts = {};
|
|
58
|
+
for (const name of priorArtifactNamesForPhase(state.phase)) {
|
|
59
|
+
const path = state.artifacts[name];
|
|
60
|
+
if (!path) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
priorArtifacts[name] = await readFile(path, "utf8");
|
|
64
|
+
}
|
|
65
|
+
return priorArtifacts;
|
|
66
|
+
}
|
|
67
|
+
export async function runShellCommand(command, cwd, timeoutMs = COMMAND_TIMEOUT_MS) {
|
|
68
|
+
const startedAt = now();
|
|
69
|
+
return new Promise((resolveResult) => {
|
|
70
|
+
const child = spawn(command, {
|
|
71
|
+
cwd,
|
|
72
|
+
shell: true,
|
|
73
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
74
|
+
});
|
|
75
|
+
const chunks = [];
|
|
76
|
+
const maxOutputBytes = 64_000;
|
|
77
|
+
let collected = 0;
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
let settled = false;
|
|
80
|
+
function collect(chunk) {
|
|
81
|
+
if (collected >= maxOutputBytes) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const slice = chunk.subarray(0, Math.max(0, maxOutputBytes - collected));
|
|
85
|
+
chunks.push(slice);
|
|
86
|
+
collected += slice.length;
|
|
87
|
+
}
|
|
88
|
+
const killTimer = setTimeout(() => {
|
|
89
|
+
timedOut = true;
|
|
90
|
+
child.kill("SIGTERM");
|
|
91
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
92
|
+
}, timeoutMs);
|
|
93
|
+
killTimer.unref();
|
|
94
|
+
function finish(result) {
|
|
95
|
+
if (settled) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
settled = true;
|
|
99
|
+
clearTimeout(killTimer);
|
|
100
|
+
resolveResult(result);
|
|
101
|
+
}
|
|
102
|
+
child.stdout.on("data", collect);
|
|
103
|
+
child.stderr.on("data", collect);
|
|
104
|
+
child.on("error", (error) => {
|
|
105
|
+
finish({ command, exitCode: 1, output: error.message, startedAt, completedAt: now() });
|
|
106
|
+
});
|
|
107
|
+
child.on("close", (code) => {
|
|
108
|
+
const base = Buffer.concat(chunks).toString("utf8").replace(/\s+$/u, "");
|
|
109
|
+
const output = timedOut
|
|
110
|
+
? `${base}\n[devcrew] command timed out after ${timeoutMs}ms`.trim()
|
|
111
|
+
: base;
|
|
112
|
+
finish({ command, exitCode: timedOut ? 124 : code ?? 1, output, startedAt, completedAt: now() });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
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
|
+
async function runCommands(commands, cwd) {
|
|
211
|
+
const results = [];
|
|
212
|
+
for (const command of commands) {
|
|
213
|
+
results.push(await runShellCommand(command, cwd));
|
|
214
|
+
}
|
|
215
|
+
return results;
|
|
216
|
+
}
|
|
217
|
+
function uniqueCommands(commands) {
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
const unique = [];
|
|
220
|
+
for (const command of commands) {
|
|
221
|
+
if (seen.has(command)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
seen.add(command);
|
|
225
|
+
unique.push(command);
|
|
226
|
+
}
|
|
227
|
+
return unique;
|
|
228
|
+
}
|
|
229
|
+
// Tester verification runs the normal verification path first, then coverage
|
|
230
|
+
// as supplemental evidence. Configured commands win per category; otherwise
|
|
231
|
+
// DevCrew discovers common project commands.
|
|
232
|
+
async function runConfiguredVerification(state) {
|
|
233
|
+
const config = await readConfig(state.cwd);
|
|
234
|
+
const configuredVerify = config.verifyCommands.filter((command) => command.trim().length > 0);
|
|
235
|
+
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);
|
|
238
|
+
const commands = uniqueCommands([...verifyCommands, ...coverageCommands]);
|
|
239
|
+
return runCommands(commands, state.cwd);
|
|
240
|
+
}
|
|
241
|
+
// Implementer apply runs lint/format/typecheck so reviewers see standards
|
|
242
|
+
// compliance evidence. Configured lintCommands win, otherwise discover them.
|
|
243
|
+
async function runConfiguredLint(state) {
|
|
244
|
+
const config = await readConfig(state.cwd);
|
|
245
|
+
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");
|
|
254
|
+
}
|
|
255
|
+
function verificationBlock(results) {
|
|
256
|
+
if (results.length === 0) {
|
|
257
|
+
return "No verification commands were configured.";
|
|
258
|
+
}
|
|
259
|
+
return results
|
|
260
|
+
.map((result) => `### ${result.command}\n\nExit Code: ${result.exitCode}\n\nOutput:\n\n\`\`\`text\n${result.output || "(no output)"}\n\`\`\``)
|
|
261
|
+
.join("\n\n");
|
|
262
|
+
}
|
|
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
|
+
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
|
+
if (artifact === "test-report" && !markdown.includes("## Acceptance Evidence")) {
|
|
281
|
+
return `${markdown.trim()}\n\n## Acceptance Evidence\n\n${verificationBlock(state.verification)}\n`;
|
|
282
|
+
}
|
|
283
|
+
return markdown;
|
|
284
|
+
}
|
|
285
|
+
async function runCurrentPhaseRole(state, runner = runRole) {
|
|
286
|
+
const gate = gateForPhase(state.phase);
|
|
287
|
+
const role = roleForPhase(state.phase);
|
|
288
|
+
if (!gate || !role) {
|
|
289
|
+
const artifact = artifactForPhase(state.phase);
|
|
290
|
+
const markdown = renderArtifact(artifact, state);
|
|
291
|
+
state.artifacts[artifact] = await writeMarkdownArtifact(state, artifact, markdown);
|
|
292
|
+
state.phase = "complete";
|
|
293
|
+
state.status = "complete";
|
|
294
|
+
return saveState(state);
|
|
295
|
+
}
|
|
296
|
+
const artifact = artifactForPhase(state.phase);
|
|
297
|
+
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);
|
|
304
|
+
}
|
|
305
|
+
state.roles.push(conductorDecision(state, role, gate));
|
|
306
|
+
const result = await runner({
|
|
307
|
+
backend: state.backend,
|
|
308
|
+
role,
|
|
309
|
+
phase: state.phase,
|
|
310
|
+
request: state.request,
|
|
311
|
+
mode: state.mode,
|
|
312
|
+
executionMode: state.executionMode,
|
|
313
|
+
cwd: state.cwd,
|
|
314
|
+
standards: state.standards.combined,
|
|
315
|
+
artifactPath: path,
|
|
316
|
+
answers: state.answers.map((entry) => entry.answer),
|
|
317
|
+
feedback: state.feedback.map((entry) => `${entry.gate}: ${entry.message}`),
|
|
318
|
+
priorArtifacts: await readPriorArtifacts(state),
|
|
319
|
+
});
|
|
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);
|
|
327
|
+
}
|
|
328
|
+
// When the backend cannot run a real SDK we keep a single deterministic
|
|
329
|
+
// artifact source by rendering the rich phase template from the core layer.
|
|
330
|
+
const baseMarkdown = result.usedFallback ? `${fallbackNotice(result)}${renderArtifact(artifact, state)}` : result.markdown;
|
|
331
|
+
const markdown = appendExecutionSections(artifact, baseMarkdown, state);
|
|
332
|
+
state.roles.push({ ...result, markdown });
|
|
333
|
+
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
|
+
state.gates[gate] = "pending";
|
|
338
|
+
state.status = "awaiting_approval";
|
|
339
|
+
return saveState(state);
|
|
340
|
+
}
|
|
341
|
+
export async function startOrchestratedWorkflow(input, runner = runRole) {
|
|
342
|
+
const state = await startWorkflow(input, { skipArtifactWrite: true });
|
|
343
|
+
return runCurrentPhaseRole(state, runner);
|
|
344
|
+
}
|
|
345
|
+
export async function continueOrchestratedWorkflow(input, runner = runRole) {
|
|
346
|
+
const state = await getWorkflowStatus(input);
|
|
347
|
+
if (state.status === "awaiting_approval" || state.status === "awaiting_input" || state.status === "complete") {
|
|
348
|
+
return state;
|
|
349
|
+
}
|
|
350
|
+
return runCurrentPhaseRole(state, runner);
|
|
351
|
+
}
|
|
352
|
+
export async function rejectOrchestratedWorkflow(input) {
|
|
353
|
+
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.
|
|
357
|
+
if (before.executionMode === "apply" &&
|
|
358
|
+
before.phase === "implementation" &&
|
|
359
|
+
before.changedFiles.length > 0) {
|
|
360
|
+
await revertChangedFiles(before.cwd, before.changedFiles);
|
|
361
|
+
state.changedFiles = [];
|
|
362
|
+
return saveState(state);
|
|
363
|
+
}
|
|
364
|
+
return state;
|
|
365
|
+
}
|
|
366
|
+
export async function answerOrchestratedWorkflow(input, runner = runRole) {
|
|
367
|
+
const state = await answerWorkflow(input, { skipArtifactWrite: true });
|
|
368
|
+
const gate = gateForPhase(state.phase);
|
|
369
|
+
const role = roleForPhase(state.phase);
|
|
370
|
+
if (!gate || !role) {
|
|
371
|
+
return state;
|
|
372
|
+
}
|
|
373
|
+
// Re-run the current phase role so the artifact reflects the new answer and
|
|
374
|
+
// any rejection feedback, instead of reverting to the static template.
|
|
375
|
+
return runCurrentPhaseRole(state, runner);
|
|
376
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { DEFAULT_CONFIG, DEVCREW_NPM_PACKAGE, DEVCREW_VERSION, ROLE_SECTIONS } from "../../core/src/index.js";
|
|
6
|
+
async function writeJson(path, value) {
|
|
7
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
8
|
+
}
|
|
9
|
+
async function bundledAssetPath(name) {
|
|
10
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const candidates = [
|
|
12
|
+
join(moduleDir, "..", "assets", name),
|
|
13
|
+
join(moduleDir, "..", "..", "..", "..", "packages", "plugins", "assets", name),
|
|
14
|
+
];
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
try {
|
|
17
|
+
await access(candidate, constants.R_OK);
|
|
18
|
+
return candidate;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Try the next layout: source tree first, compiled package second.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error(`Missing bundled DevCrew plugin asset: ${name}`);
|
|
25
|
+
}
|
|
26
|
+
async function writeCodexAssets(pluginRoot) {
|
|
27
|
+
const assetDir = join(pluginRoot, "assets");
|
|
28
|
+
await mkdir(assetDir, { recursive: true });
|
|
29
|
+
await copyFile(await bundledAssetPath("logo.png"), join(assetDir, "logo.png"));
|
|
30
|
+
await copyFile(await bundledAssetPath("composer-icon.png"), join(assetDir, "composer-icon.png"));
|
|
31
|
+
}
|
|
32
|
+
function roleExpectations(name) {
|
|
33
|
+
const sections = ROLE_SECTIONS[name];
|
|
34
|
+
if (!sections || sections.length === 0) {
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
return sections.map((s) => `- ${s.heading} (${s.description})`).join("\n");
|
|
38
|
+
}
|
|
39
|
+
async function writeRoleAgents(root, format) {
|
|
40
|
+
const roles = [
|
|
41
|
+
["pm", "Product manager. Clarifies requirements, scope boundaries, success criteria, and requester approvals."],
|
|
42
|
+
["architect", "technical architecture specialist. Designs implementation, deployment, interfaces, and review criteria."],
|
|
43
|
+
["implementer", "Implementation engineer. Writes code according to approved architecture and discovered standards."],
|
|
44
|
+
["tester", "Testing and acceptance specialist. Verifies functionality, regressions, and acceptance evidence."],
|
|
45
|
+
];
|
|
46
|
+
if (format === "claude") {
|
|
47
|
+
const agentDir = join(root, "agents");
|
|
48
|
+
await mkdir(agentDir, { recursive: true });
|
|
49
|
+
for (const [name, description] of roles) {
|
|
50
|
+
await writeFile(join(agentDir, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\ntools: Read, Grep, Glob, Bash\n---\n\nYou are the DevCrew ${name} role. ${description} Return concise Markdown and keep inherited host permissions.\n\nProduce these required sections:\n\n${roleExpectations(name)}\n`, "utf8");
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const agentDir = join(root, "agents");
|
|
55
|
+
await mkdir(agentDir, { recursive: true });
|
|
56
|
+
for (const [name, description] of roles) {
|
|
57
|
+
await writeFile(join(agentDir, `${name}.toml`), `name = "${name}"\ndescription = "${description}"\ndeveloper_instructions = """\nYou are the DevCrew ${name} role. ${description}\nReturn concise Markdown and keep inherited host permissions.\n\nProduce these required sections:\n${roleExpectations(name)}\n"""\n`, "utf8");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function entrySkill() {
|
|
61
|
+
return `---\nname: devcrew\ndescription: Run the DevCrew PM -> architecture -> implementation -> testing workflow. Use when the user asks for structured feature or product development, requirements clarification, architecture review, implementation planning, testing acceptance, or Chinese requests such as 完整研发流程, 需求澄清, 产品经理, 架构师, 开发测试流程.\n---\n\nUse the DevCrew MCP tools to manage the workflow:\n\n1. Start with \`devcrew_start\` using the current repository cwd, mode, request, and optional executionMode. Host is inferred from the plugin's \`DEVCREW_HOST\`; pass host only for an explicit override. Omit executionMode unless the requester explicitly asks DevCrew to apply changes; the default safe mode is \`plan\`.\n2. After start, DevCrew records the active run for this repository. For follow-up tools, omit runId unless you need to target a different run explicitly.\n3. Use \`executionMode: "apply"\` only when the requester explicitly wants DevCrew to write code or run validation commands. This still inherits host sandbox, approval, and tool permissions.\n4. Use \`devcrew_status\` to show the current phase and pending gate.\n5. Use \`devcrew_answer\` when the requester gives clarification.\n6. Use \`devcrew_approve\` or \`devcrew_reject\` for each gate.\n7. Use \`devcrew_continue\` after approvals. This executes the next phase role, writes the phase artifact, and opens the next gate. The implementation phase also writes \`implementation-review\` for diff and architecture compliance review.\n8. Use \`devcrew_artifact\` to read generated requirements, architecture, implementation-plan, implementation-review, test-report, or acceptance files.\n\nDo not bypass host sandbox, approval, or tool permissions.\n`;
|
|
62
|
+
}
|
|
63
|
+
function npmPackageSpecifier() {
|
|
64
|
+
return `${DEVCREW_NPM_PACKAGE}@${DEVCREW_VERSION}`;
|
|
65
|
+
}
|
|
66
|
+
export async function generateCodexPlugin(root) {
|
|
67
|
+
const pluginRoot = join(root, "plugins", "devcrew-codex");
|
|
68
|
+
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
|
|
69
|
+
await mkdir(join(pluginRoot, "skills", "devcrew"), { recursive: true });
|
|
70
|
+
await writeCodexAssets(pluginRoot);
|
|
71
|
+
await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), {
|
|
72
|
+
name: "devcrew",
|
|
73
|
+
version: DEVCREW_VERSION,
|
|
74
|
+
description: "DevCrew gated multi-role workflow service for Codex.",
|
|
75
|
+
author: {
|
|
76
|
+
name: "DevCrew Contributors",
|
|
77
|
+
url: "https://github.com/lishen802/devcrew",
|
|
78
|
+
},
|
|
79
|
+
homepage: "https://github.com/lishen802/devcrew#readme",
|
|
80
|
+
repository: "https://github.com/lishen802/devcrew",
|
|
81
|
+
license: "Apache-2.0",
|
|
82
|
+
keywords: ["codex", "agents", "workflow", "mcp", "skills"],
|
|
83
|
+
skills: "./skills/",
|
|
84
|
+
mcpServers: "./.mcp.json",
|
|
85
|
+
interface: {
|
|
86
|
+
displayName: "DevCrew",
|
|
87
|
+
shortDescription: "Run gated PM, architecture, implementation, and testing workflows.",
|
|
88
|
+
longDescription: "DevCrew helps Codex run feature and product development through explicit requirements, architecture, implementation planning, and testing gates.",
|
|
89
|
+
developerName: "DevCrew Contributors",
|
|
90
|
+
category: "Productivity",
|
|
91
|
+
capabilities: ["Interactive", "Write"],
|
|
92
|
+
websiteURL: "https://github.com/lishen802/devcrew",
|
|
93
|
+
defaultPrompt: [
|
|
94
|
+
"Use DevCrew to plan this feature.",
|
|
95
|
+
"Use DevCrew to build this product.",
|
|
96
|
+
"Use DevCrew to review this implementation plan.",
|
|
97
|
+
],
|
|
98
|
+
brandColor: "#2563EB",
|
|
99
|
+
composerIcon: "./assets/composer-icon.png",
|
|
100
|
+
logo: "./assets/logo.png",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
await writeFile(join(pluginRoot, "skills", "devcrew", "SKILL.md"), entrySkill(), "utf8");
|
|
104
|
+
await writeJson(join(pluginRoot, ".mcp.json"), {
|
|
105
|
+
mcpServers: {
|
|
106
|
+
devcrew: {
|
|
107
|
+
command: "npx",
|
|
108
|
+
args: ["-y", npmPackageSpecifier(), "serve", "--stdio"],
|
|
109
|
+
env: { DEVCREW_HOST: "codex" },
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
await writeRoleAgents(pluginRoot, "codex");
|
|
114
|
+
return { name: "devcrew", path: pluginRoot };
|
|
115
|
+
}
|
|
116
|
+
export async function generateCodexMarketplace(root) {
|
|
117
|
+
const marketplacePath = join(root, ".agents", "plugins", "marketplace.json");
|
|
118
|
+
await mkdir(join(root, ".agents", "plugins"), { recursive: true });
|
|
119
|
+
await writeJson(marketplacePath, {
|
|
120
|
+
name: "devcrew",
|
|
121
|
+
interface: {
|
|
122
|
+
displayName: "DevCrew",
|
|
123
|
+
},
|
|
124
|
+
plugins: [
|
|
125
|
+
{
|
|
126
|
+
name: "devcrew",
|
|
127
|
+
source: {
|
|
128
|
+
source: "local",
|
|
129
|
+
path: "./plugins/devcrew-codex",
|
|
130
|
+
},
|
|
131
|
+
policy: {
|
|
132
|
+
installation: "AVAILABLE",
|
|
133
|
+
authentication: "ON_INSTALL",
|
|
134
|
+
},
|
|
135
|
+
category: "Productivity",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
return { name: "devcrew", path: marketplacePath };
|
|
140
|
+
}
|
|
141
|
+
export async function generateClaudePlugin(root) {
|
|
142
|
+
const pluginRoot = join(root, "plugins", "devcrew-claude");
|
|
143
|
+
await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true });
|
|
144
|
+
await mkdir(join(pluginRoot, "skills", "devcrew"), { recursive: true });
|
|
145
|
+
await writeJson(join(pluginRoot, ".claude-plugin", "plugin.json"), {
|
|
146
|
+
name: "devcrew",
|
|
147
|
+
description: "DevCrew gated multi-role workflow service for Claude Code.",
|
|
148
|
+
version: DEVCREW_VERSION,
|
|
149
|
+
author: { name: "DevCrew Contributors" },
|
|
150
|
+
});
|
|
151
|
+
await writeFile(join(pluginRoot, "skills", "devcrew", "SKILL.md"), entrySkill(), "utf8");
|
|
152
|
+
await writeJson(join(pluginRoot, ".mcp.json"), {
|
|
153
|
+
mcpServers: {
|
|
154
|
+
devcrew: {
|
|
155
|
+
command: "npx",
|
|
156
|
+
args: ["-y", npmPackageSpecifier(), "serve", "--stdio"],
|
|
157
|
+
env: { DEVCREW_HOST: "claude" },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
await writeRoleAgents(pluginRoot, "claude");
|
|
162
|
+
return { name: "devcrew", path: pluginRoot };
|
|
163
|
+
}
|
|
164
|
+
export async function initProject(root) {
|
|
165
|
+
await mkdir(join(root, ".devcrew"), { recursive: true });
|
|
166
|
+
await mkdir(join(root, "docs", "devcrew"), { recursive: true });
|
|
167
|
+
await writeJson(join(root, ".devcrew", "config.json"), DEFAULT_CONFIG);
|
|
168
|
+
await writeFile(join(root, ".devcrew", "standards.md"), "# DevCrew Standards\n\nAdd project-specific coding, testing, documentation, and deployment rules here.\n", "utf8");
|
|
169
|
+
const codex = await generateCodexPlugin(root);
|
|
170
|
+
await generateCodexMarketplace(root);
|
|
171
|
+
const claude = await generateClaudePlugin(root);
|
|
172
|
+
return { codex, claude };
|
|
173
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { callDevCrewTool, listDevCrewTools } from "./tools.js";
|
|
2
|
+
import { DEVCREW_VERSION } from "../../core/src/index.js";
|
|
3
|
+
function writeJson(message) {
|
|
4
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
5
|
+
}
|
|
6
|
+
async function handleRequest(request, write) {
|
|
7
|
+
if (request.method === "notifications/initialized") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (request.id === undefined || request.id === null) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
if (request.method === "initialize") {
|
|
15
|
+
write({
|
|
16
|
+
jsonrpc: "2.0",
|
|
17
|
+
id: request.id,
|
|
18
|
+
result: {
|
|
19
|
+
protocolVersion: "2025-03-26",
|
|
20
|
+
capabilities: { tools: {} },
|
|
21
|
+
serverInfo: { name: "devcrew", version: DEVCREW_VERSION },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (request.method === "tools/list") {
|
|
27
|
+
write({
|
|
28
|
+
jsonrpc: "2.0",
|
|
29
|
+
id: request.id,
|
|
30
|
+
result: { tools: listDevCrewTools() },
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (request.method === "tools/call") {
|
|
35
|
+
const params = request.params ?? {};
|
|
36
|
+
const name = params.name;
|
|
37
|
+
if (typeof name !== "string") {
|
|
38
|
+
throw new Error("tools/call params.name must be a string");
|
|
39
|
+
}
|
|
40
|
+
const result = await callDevCrewTool(name, (params.arguments ?? {}));
|
|
41
|
+
write({
|
|
42
|
+
jsonrpc: "2.0",
|
|
43
|
+
id: request.id,
|
|
44
|
+
result,
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
throw new Error(`Unsupported JSON-RPC method: ${request.method ?? "unknown"}`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
write({
|
|
52
|
+
jsonrpc: "2.0",
|
|
53
|
+
id: request.id,
|
|
54
|
+
error: {
|
|
55
|
+
code: -32000,
|
|
56
|
+
message: error instanceof Error ? error.message : String(error),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function createStdioLineProcessor(write = writeJson, handler = (request) => handleRequest(request, write)) {
|
|
62
|
+
let queue = Promise.resolve();
|
|
63
|
+
return async (line) => {
|
|
64
|
+
const trimmed = line.trim();
|
|
65
|
+
if (!trimmed) {
|
|
66
|
+
return queue;
|
|
67
|
+
}
|
|
68
|
+
let request;
|
|
69
|
+
try {
|
|
70
|
+
request = JSON.parse(trimmed);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
write({
|
|
74
|
+
jsonrpc: "2.0",
|
|
75
|
+
id: null,
|
|
76
|
+
error: { code: -32700, message: "Parse error" },
|
|
77
|
+
});
|
|
78
|
+
return queue;
|
|
79
|
+
}
|
|
80
|
+
queue = queue.then(() => handler(request));
|
|
81
|
+
return queue;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
export function runStdioServer() {
|
|
85
|
+
let buffer = "";
|
|
86
|
+
const processLine = createStdioLineProcessor();
|
|
87
|
+
process.stdin.setEncoding("utf8");
|
|
88
|
+
process.stdin.on("data", (chunk) => {
|
|
89
|
+
buffer += chunk;
|
|
90
|
+
const lines = buffer.split("\n");
|
|
91
|
+
buffer = lines.pop() ?? "";
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
void processLine(trimmed);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|