@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,214 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { ensureConfig } from "./config.js";
|
|
4
|
+
import { readArtifact, writeArtifact } from "./artifacts.js";
|
|
5
|
+
import { discoverStandards } from "./standards.js";
|
|
6
|
+
import { loadState, saveState } from "./store.js";
|
|
7
|
+
import {
|
|
8
|
+
parseAnswer,
|
|
9
|
+
parseArtifactName,
|
|
10
|
+
parseBackend,
|
|
11
|
+
parseCwd,
|
|
12
|
+
parseExecutionMode,
|
|
13
|
+
parseFeedback,
|
|
14
|
+
parseGate,
|
|
15
|
+
parseHost,
|
|
16
|
+
parseOptionalNote,
|
|
17
|
+
parseRequest,
|
|
18
|
+
parseRunId,
|
|
19
|
+
parseWorkflowMode,
|
|
20
|
+
} from "./validation.js";
|
|
21
|
+
import type {
|
|
22
|
+
AnswerWorkflowInput,
|
|
23
|
+
ApproveWorkflowInput,
|
|
24
|
+
ArtifactName,
|
|
25
|
+
ArtifactReadResult,
|
|
26
|
+
ArtifactRef,
|
|
27
|
+
GateName,
|
|
28
|
+
RejectWorkflowInput,
|
|
29
|
+
RunRef,
|
|
30
|
+
RunState,
|
|
31
|
+
StartWorkflowInput,
|
|
32
|
+
} from "./types.js";
|
|
33
|
+
|
|
34
|
+
function now(): string {
|
|
35
|
+
return new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function newRunId(): string {
|
|
39
|
+
return `dc-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function nextPhaseAfterGate(gate: GateName): RunState["phase"] {
|
|
43
|
+
const nextByGate: Record<GateName, RunState["phase"]> = {
|
|
44
|
+
requirements: "architecture",
|
|
45
|
+
architecture: "implementation",
|
|
46
|
+
implementation: "testing",
|
|
47
|
+
testing: "acceptance",
|
|
48
|
+
};
|
|
49
|
+
return nextByGate[gate];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function artifactForPhase(phase: RunState["phase"]): ArtifactName {
|
|
53
|
+
const artifactByPhase: Record<RunState["phase"], ArtifactName> = {
|
|
54
|
+
requirements: "requirements",
|
|
55
|
+
architecture: "architecture",
|
|
56
|
+
implementation: "implementation-plan",
|
|
57
|
+
testing: "test-report",
|
|
58
|
+
acceptance: "acceptance",
|
|
59
|
+
complete: "acceptance",
|
|
60
|
+
};
|
|
61
|
+
return artifactByPhase[phase];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function gateForPhase(phase: RunState["phase"]): GateName | undefined {
|
|
65
|
+
if (phase === "requirements" || phase === "architecture" || phase === "implementation" || phase === "testing") {
|
|
66
|
+
return phase;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertCurrentGate(state: RunState, gate: GateName): void {
|
|
72
|
+
const expected = gateForPhase(state.phase);
|
|
73
|
+
if (expected && expected !== gate) {
|
|
74
|
+
throw new Error(`Cannot act on ${gate} while current gate is ${expected}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function writeCurrentArtifact(state: RunState): Promise<RunState> {
|
|
79
|
+
const artifact = artifactForPhase(state.phase);
|
|
80
|
+
const path = await writeArtifact(artifact, state);
|
|
81
|
+
state.artifacts[artifact] = path;
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WorkflowMutationOptions {
|
|
86
|
+
skipArtifactWrite?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function startWorkflow(input: StartWorkflowInput, options: WorkflowMutationOptions = {}): Promise<RunState> {
|
|
90
|
+
const cwd = parseCwd(input.cwd);
|
|
91
|
+
const host = parseHost(input.host);
|
|
92
|
+
const mode = parseWorkflowMode(input.mode);
|
|
93
|
+
const request = parseRequest(input.request);
|
|
94
|
+
const config = await ensureConfig(cwd);
|
|
95
|
+
const backend = input.backend ? parseBackend(input.backend) : config.defaultBackend === "host-preferred" ? host : config.defaultBackend;
|
|
96
|
+
const executionMode = input.executionMode ? parseExecutionMode(input.executionMode) : config.executionMode;
|
|
97
|
+
const createdAt = now();
|
|
98
|
+
const state: RunState = {
|
|
99
|
+
version: 1,
|
|
100
|
+
runId: newRunId(),
|
|
101
|
+
cwd,
|
|
102
|
+
host,
|
|
103
|
+
mode,
|
|
104
|
+
executionMode,
|
|
105
|
+
backend,
|
|
106
|
+
request,
|
|
107
|
+
phase: "requirements",
|
|
108
|
+
status: "awaiting_approval",
|
|
109
|
+
createdAt,
|
|
110
|
+
updatedAt: createdAt,
|
|
111
|
+
gates: {
|
|
112
|
+
requirements: "pending",
|
|
113
|
+
architecture: "not_started",
|
|
114
|
+
implementation: "not_started",
|
|
115
|
+
testing: "not_started",
|
|
116
|
+
},
|
|
117
|
+
artifacts: {},
|
|
118
|
+
roles: [],
|
|
119
|
+
answers: [],
|
|
120
|
+
approvals: [],
|
|
121
|
+
feedback: [],
|
|
122
|
+
standards: await discoverStandards(cwd),
|
|
123
|
+
changedFiles: [],
|
|
124
|
+
implementationDiff: "",
|
|
125
|
+
verification: [],
|
|
126
|
+
lintResults: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (!options.skipArtifactWrite) {
|
|
130
|
+
await writeCurrentArtifact(state);
|
|
131
|
+
}
|
|
132
|
+
return saveState(state);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function getWorkflowStatus(input: RunRef): Promise<RunState> {
|
|
136
|
+
return loadState(parseCwd(input.cwd), parseRunId(input.runId));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function continueWorkflow(input: RunRef): Promise<RunState> {
|
|
140
|
+
const state = await getWorkflowStatus(input);
|
|
141
|
+
if (state.status === "awaiting_approval" || state.status === "awaiting_input" || state.status === "complete") {
|
|
142
|
+
return state;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (state.phase === "acceptance") {
|
|
146
|
+
await writeCurrentArtifact(state);
|
|
147
|
+
state.phase = "complete";
|
|
148
|
+
state.status = "complete";
|
|
149
|
+
return saveState(state);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const gate = gateForPhase(state.phase);
|
|
153
|
+
if (!gate) {
|
|
154
|
+
state.status = "complete";
|
|
155
|
+
return saveState(state);
|
|
156
|
+
}
|
|
157
|
+
state.gates[gate] = "pending";
|
|
158
|
+
state.status = "awaiting_approval";
|
|
159
|
+
await writeCurrentArtifact(state);
|
|
160
|
+
return saveState(state);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function approveWorkflow(input: ApproveWorkflowInput): Promise<RunState> {
|
|
164
|
+
const state = await getWorkflowStatus(input);
|
|
165
|
+
const gate = parseGate(input.gate);
|
|
166
|
+
assertCurrentGate(state, gate);
|
|
167
|
+
state.gates[gate] = "approved";
|
|
168
|
+
state.approvals.push({
|
|
169
|
+
gate,
|
|
170
|
+
note: parseOptionalNote(input.note),
|
|
171
|
+
createdAt: now(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const nextPhase = nextPhaseAfterGate(gate);
|
|
175
|
+
state.phase = nextPhase;
|
|
176
|
+
state.status = "ready";
|
|
177
|
+
return saveState(state);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function rejectWorkflow(input: RejectWorkflowInput): Promise<RunState> {
|
|
181
|
+
const state = await getWorkflowStatus(input);
|
|
182
|
+
const gate = parseGate(input.gate);
|
|
183
|
+
assertCurrentGate(state, gate);
|
|
184
|
+
state.gates[gate] = "rejected";
|
|
185
|
+
state.status = "awaiting_input";
|
|
186
|
+
state.feedback.push({
|
|
187
|
+
gate,
|
|
188
|
+
message: parseFeedback(input.feedback),
|
|
189
|
+
createdAt: now(),
|
|
190
|
+
});
|
|
191
|
+
return saveState(state);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function answerWorkflow(input: AnswerWorkflowInput, options: WorkflowMutationOptions = {}): Promise<RunState> {
|
|
195
|
+
const state = await getWorkflowStatus(input);
|
|
196
|
+
state.answers.push({
|
|
197
|
+
answer: parseAnswer(input.answer),
|
|
198
|
+
createdAt: now(),
|
|
199
|
+
});
|
|
200
|
+
const gate = gateForPhase(state.phase);
|
|
201
|
+
if (gate) {
|
|
202
|
+
state.gates[gate] = "pending";
|
|
203
|
+
state.status = "awaiting_approval";
|
|
204
|
+
}
|
|
205
|
+
if (!options.skipArtifactWrite) {
|
|
206
|
+
await writeCurrentArtifact(state);
|
|
207
|
+
}
|
|
208
|
+
return saveState(state);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function getArtifact(input: ArtifactRef): Promise<ArtifactReadResult> {
|
|
212
|
+
const state = await getWorkflowStatus(input);
|
|
213
|
+
return readArtifact(state, parseArtifactName(input.name));
|
|
214
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
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
|
+
|
|
5
|
+
import { runRole } from "../../adapters/src/index.js";
|
|
6
|
+
import type { RoleRunInput } from "../../adapters/src/index.js";
|
|
7
|
+
import {
|
|
8
|
+
answerWorkflow,
|
|
9
|
+
artifactForPhase,
|
|
10
|
+
artifactPath,
|
|
11
|
+
ARTIFACTS,
|
|
12
|
+
discoverCoverageCommands,
|
|
13
|
+
discoverLintCommands,
|
|
14
|
+
discoverVerifyCommands,
|
|
15
|
+
gateForPhase,
|
|
16
|
+
getWorkflowStatus,
|
|
17
|
+
readConfig,
|
|
18
|
+
rejectWorkflow,
|
|
19
|
+
renderArtifact,
|
|
20
|
+
saveState,
|
|
21
|
+
startWorkflow,
|
|
22
|
+
type AnswerWorkflowInput,
|
|
23
|
+
type ArtifactName,
|
|
24
|
+
type Phase,
|
|
25
|
+
type RejectWorkflowInput,
|
|
26
|
+
type RoleResult,
|
|
27
|
+
type RunRef,
|
|
28
|
+
type RunState,
|
|
29
|
+
type StartWorkflowInput,
|
|
30
|
+
type VerificationResult,
|
|
31
|
+
} from "../../core/src/index.js";
|
|
32
|
+
|
|
33
|
+
// Hard cap so a hung apply/verify command cannot block the serialized MCP loop.
|
|
34
|
+
const COMMAND_TIMEOUT_MS = 300_000;
|
|
35
|
+
|
|
36
|
+
type RoleRunner = (input: RoleRunInput) => Promise<RoleResult>;
|
|
37
|
+
|
|
38
|
+
function roleForPhase(phase: Phase): RoleResult["role"] | undefined {
|
|
39
|
+
const roles: Partial<Record<Phase, RoleResult["role"]>> = {
|
|
40
|
+
requirements: "pm",
|
|
41
|
+
architecture: "architect",
|
|
42
|
+
implementation: "implementer",
|
|
43
|
+
testing: "tester",
|
|
44
|
+
};
|
|
45
|
+
return roles[phase];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function conductorDecision(state: RunState, role: RoleResult["role"], gate: string): RoleResult {
|
|
49
|
+
const summary = `Conductor routed ${state.phase} phase to ${role} and prepared the ${gate} gate.`;
|
|
50
|
+
return {
|
|
51
|
+
role: "conductor",
|
|
52
|
+
backend: state.backend,
|
|
53
|
+
summary,
|
|
54
|
+
markdown: `# Conductor Decision\n\n${summary}\n`,
|
|
55
|
+
usedFallback: false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function priorArtifactNamesForPhase(phase: Phase): ArtifactName[] {
|
|
60
|
+
const current = artifactForPhase(phase);
|
|
61
|
+
const currentIndex = ARTIFACTS.indexOf(current);
|
|
62
|
+
if (currentIndex <= 0) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return ARTIFACTS.slice(0, currentIndex);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writeMarkdownArtifact(state: RunState, artifact: ArtifactName, markdown: string): Promise<string> {
|
|
69
|
+
const path = artifactPath(state.cwd, state.runId, artifact);
|
|
70
|
+
await mkdir(dirname(path), { recursive: true });
|
|
71
|
+
await writeFile(path, markdown, "utf8");
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function now(): string {
|
|
76
|
+
return new Date().toISOString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fallbackNotice(result: RoleResult): string {
|
|
80
|
+
if (!result.usedFallback) {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
if (result.backend === "local") {
|
|
84
|
+
return `> DevCrew local fallback: this artifact uses the deterministic local planning template because the local backend does not call an external SDK.\n\n`;
|
|
85
|
+
}
|
|
86
|
+
const reason = result.summary.includes("output failed validation")
|
|
87
|
+
? `the ${result.backend} SDK did not return a valid artifact`
|
|
88
|
+
: `the ${result.backend} SDK was unavailable`;
|
|
89
|
+
return `> DevCrew SDK fallback: this artifact uses the deterministic planning template because ${reason}.\n> Reason: ${result.summary}\n\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function readPriorArtifacts(state: RunState): Promise<Record<string, string>> {
|
|
93
|
+
const priorArtifacts: Record<string, string> = {};
|
|
94
|
+
for (const name of priorArtifactNamesForPhase(state.phase)) {
|
|
95
|
+
const path = state.artifacts[name];
|
|
96
|
+
if (!path) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
priorArtifacts[name] = await readFile(path, "utf8");
|
|
100
|
+
}
|
|
101
|
+
return priorArtifacts;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function runShellCommand(
|
|
105
|
+
command: string,
|
|
106
|
+
cwd: string,
|
|
107
|
+
timeoutMs: number = COMMAND_TIMEOUT_MS,
|
|
108
|
+
): Promise<VerificationResult> {
|
|
109
|
+
const startedAt = now();
|
|
110
|
+
return new Promise((resolveResult) => {
|
|
111
|
+
const child = spawn(command, {
|
|
112
|
+
cwd,
|
|
113
|
+
shell: true,
|
|
114
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
115
|
+
});
|
|
116
|
+
const chunks: Buffer[] = [];
|
|
117
|
+
const maxOutputBytes = 64_000;
|
|
118
|
+
let collected = 0;
|
|
119
|
+
let timedOut = false;
|
|
120
|
+
let settled = false;
|
|
121
|
+
|
|
122
|
+
function collect(chunk: Buffer): void {
|
|
123
|
+
if (collected >= maxOutputBytes) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const slice = chunk.subarray(0, Math.max(0, maxOutputBytes - collected));
|
|
127
|
+
chunks.push(slice);
|
|
128
|
+
collected += slice.length;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const killTimer = setTimeout(() => {
|
|
132
|
+
timedOut = true;
|
|
133
|
+
child.kill("SIGTERM");
|
|
134
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
killTimer.unref();
|
|
137
|
+
|
|
138
|
+
function finish(result: VerificationResult): void {
|
|
139
|
+
if (settled) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
settled = true;
|
|
143
|
+
clearTimeout(killTimer);
|
|
144
|
+
resolveResult(result);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
child.stdout.on("data", collect);
|
|
148
|
+
child.stderr.on("data", collect);
|
|
149
|
+
child.on("error", (error) => {
|
|
150
|
+
finish({ command, exitCode: 1, output: error.message, startedAt, completedAt: now() });
|
|
151
|
+
});
|
|
152
|
+
child.on("close", (code) => {
|
|
153
|
+
const base = Buffer.concat(chunks).toString("utf8").replace(/\s+$/u, "");
|
|
154
|
+
const output = timedOut
|
|
155
|
+
? `${base}\n[devcrew] command timed out after ${timeoutMs}ms`.trim()
|
|
156
|
+
: base;
|
|
157
|
+
finish({ command, exitCode: timedOut ? 124 : code ?? 1, output, startedAt, completedAt: now() });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runGit(args: string[], cwd: string): Promise<{ exitCode: number; stdout: string }> {
|
|
163
|
+
return new Promise((resolveResult) => {
|
|
164
|
+
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
165
|
+
let stdout = "";
|
|
166
|
+
let stderr = "";
|
|
167
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
168
|
+
stdout += chunk.toString("utf8");
|
|
169
|
+
});
|
|
170
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
171
|
+
stderr += chunk.toString("utf8");
|
|
172
|
+
});
|
|
173
|
+
child.on("error", () => resolveResult({ exitCode: 1, stdout: "" }));
|
|
174
|
+
child.on("close", (code) => resolveResult({ exitCode: code ?? 1, stdout: stdout || stderr }));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function listChangedLines(cwd: string, options: { requireGit?: boolean } = {}): Promise<string[]> {
|
|
179
|
+
const result = await runShellCommand("git status --porcelain -uall", cwd, 30_000);
|
|
180
|
+
if (result.exitCode !== 0) {
|
|
181
|
+
if (options.requireGit) {
|
|
182
|
+
throw new Error("DevCrew apply mode requires a git repository so implementation changes can be reviewed and reverted.");
|
|
183
|
+
}
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
return result.output
|
|
187
|
+
.split("\n")
|
|
188
|
+
.map((line) => line.trimEnd())
|
|
189
|
+
.filter(Boolean)
|
|
190
|
+
.filter((line) => {
|
|
191
|
+
const path = line.slice(3);
|
|
192
|
+
return !path.startsWith(".devcrew/") && !path.startsWith("docs/devcrew/");
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function assertCleanApplyWorkspace(cwd: string): Promise<void> {
|
|
197
|
+
const changedLines = await listChangedLines(cwd, { requireGit: true });
|
|
198
|
+
if (changedLines.length === 0) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const visibleLines = changedLines.slice(0, 20).join(", ");
|
|
202
|
+
const suffix = changedLines.length > 20 ? `, and ${changedLines.length - 20} more` : "";
|
|
203
|
+
throw new Error(
|
|
204
|
+
`DevCrew apply mode requires a clean working tree before implementation. Commit, stash, or remove these files: ${visibleLines}${suffix}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function collectImplementationDiff(cwd: string): Promise<string> {
|
|
209
|
+
const result = await runShellCommand("git diff --no-ext-diff --", cwd, 30_000);
|
|
210
|
+
if (result.exitCode !== 0) {
|
|
211
|
+
return "";
|
|
212
|
+
}
|
|
213
|
+
return result.output;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Files attributable to this run are the porcelain lines that appeared (or
|
|
217
|
+
// changed status) since the baseline captured before the role executed. This
|
|
218
|
+
// keeps a user's pre-existing uncommitted edits out of the changed-files list.
|
|
219
|
+
// The porcelain status prefix is preserved for review (?? = new, M = modified).
|
|
220
|
+
export function changedSinceBaseline(baseline: string[], current: string[]): string[] {
|
|
221
|
+
const baselineLines = new Set(baseline);
|
|
222
|
+
return current.filter((line) => !baselineLines.has(line));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Strip the two-character porcelain status plus its separating space. For
|
|
226
|
+
// rename entries ("R old -> new") the destination path is what remains
|
|
227
|
+
// relevant, so keep only the post-arrow path when present.
|
|
228
|
+
export function porcelainPath(line: string): string {
|
|
229
|
+
const raw = line.slice(3).trim();
|
|
230
|
+
const arrow = raw.indexOf(" -> ");
|
|
231
|
+
return arrow >= 0 ? raw.slice(arrow + 4) : raw;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface RevertDeps {
|
|
235
|
+
runGit?: (args: string[], cwd: string) => Promise<{ exitCode: number; stdout: string }>;
|
|
236
|
+
removeFile?: (absolutePath: string) => Promise<void>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Roll back implementer edits when an apply-mode gate is rejected.
|
|
240
|
+
// Only the files this run introduced/changed are reverted, so unrelated work in
|
|
241
|
+
// the repository is preserved. Tracked files are restored from HEAD; files that
|
|
242
|
+
// did not exist in HEAD are deleted. Failures are surfaced to the MCP caller.
|
|
243
|
+
export async function revertChangedFiles(
|
|
244
|
+
cwd: string,
|
|
245
|
+
changedFiles: string[],
|
|
246
|
+
deps: RevertDeps = {},
|
|
247
|
+
): Promise<void> {
|
|
248
|
+
const git = deps.runGit ?? runGit;
|
|
249
|
+
const removeFile = deps.removeFile ?? ((absolutePath: string) => rm(absolutePath, { force: true }));
|
|
250
|
+
for (const line of changedFiles) {
|
|
251
|
+
const file = porcelainPath(line);
|
|
252
|
+
if (!file) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const tracked = await git(["cat-file", "-e", `HEAD:${file}`], cwd);
|
|
256
|
+
if (tracked.exitCode === 0) {
|
|
257
|
+
const restored = await git(["restore", "--source=HEAD", "--", file], cwd);
|
|
258
|
+
if (restored.exitCode !== 0) {
|
|
259
|
+
throw new Error(`Failed to restore ${file}: ${restored.stdout || "git restore failed"}`);
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
try {
|
|
263
|
+
await removeFile(resolvePath(cwd, file));
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
266
|
+
throw new Error(`Failed to remove ${file}: ${message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function runCommands(commands: string[], cwd: string): Promise<VerificationResult[]> {
|
|
273
|
+
const results: VerificationResult[] = [];
|
|
274
|
+
for (const command of commands) {
|
|
275
|
+
results.push(await runShellCommand(command, cwd));
|
|
276
|
+
}
|
|
277
|
+
return results;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function uniqueCommands(commands: string[]): string[] {
|
|
281
|
+
const seen = new Set<string>();
|
|
282
|
+
const unique: string[] = [];
|
|
283
|
+
for (const command of commands) {
|
|
284
|
+
if (seen.has(command)) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
seen.add(command);
|
|
288
|
+
unique.push(command);
|
|
289
|
+
}
|
|
290
|
+
return unique;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Tester verification runs the normal verification path first, then coverage
|
|
294
|
+
// as supplemental evidence. Configured commands win per category; otherwise
|
|
295
|
+
// DevCrew discovers common project commands.
|
|
296
|
+
async function runConfiguredVerification(state: RunState): Promise<VerificationResult[]> {
|
|
297
|
+
const config = await readConfig(state.cwd);
|
|
298
|
+
const configuredVerify = config.verifyCommands.filter((command) => command.trim().length > 0);
|
|
299
|
+
const configuredCoverage = (config.coverageCommands ?? []).filter((command) => command.trim().length > 0);
|
|
300
|
+
const verifyCommands = configuredVerify.length > 0 ? configuredVerify : await discoverVerifyCommands(state.cwd);
|
|
301
|
+
const coverageCommands = configuredCoverage.length > 0 ? configuredCoverage : await discoverCoverageCommands(state.cwd);
|
|
302
|
+
const commands = uniqueCommands([...verifyCommands, ...coverageCommands]);
|
|
303
|
+
return runCommands(commands, state.cwd);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Implementer apply runs lint/format/typecheck so reviewers see standards
|
|
307
|
+
// compliance evidence. Configured lintCommands win, otherwise discover them.
|
|
308
|
+
async function runConfiguredLint(state: RunState): Promise<VerificationResult[]> {
|
|
309
|
+
const config = await readConfig(state.cwd);
|
|
310
|
+
const configuredLint = (config.lintCommands ?? []).filter((command) => command.trim().length > 0);
|
|
311
|
+
const commands = configuredLint.length > 0 ? configuredLint : await discoverLintCommands(state.cwd);
|
|
312
|
+
return runCommands(commands, state.cwd);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function changedFilesBlock(changedFiles: string[]): string {
|
|
316
|
+
if (changedFiles.length === 0) {
|
|
317
|
+
return "No changed files were detected.";
|
|
318
|
+
}
|
|
319
|
+
return changedFiles.map((file) => `- ${file}`).join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function verificationBlock(results: VerificationResult[]): string {
|
|
323
|
+
if (results.length === 0) {
|
|
324
|
+
return "No verification commands were configured.";
|
|
325
|
+
}
|
|
326
|
+
return results
|
|
327
|
+
.map(
|
|
328
|
+
(result) =>
|
|
329
|
+
`### ${result.command}\n\nExit Code: ${result.exitCode}\n\nOutput:\n\n\`\`\`text\n${result.output || "(no output)"}\n\`\`\``,
|
|
330
|
+
)
|
|
331
|
+
.join("\n\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function lintBlock(results: VerificationResult[]): string {
|
|
335
|
+
if (results.length === 0) {
|
|
336
|
+
return "No lint or format commands were detected.";
|
|
337
|
+
}
|
|
338
|
+
return verificationBlock(results);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function appendExecutionSections(artifact: ArtifactName, markdown: string, state: RunState): string {
|
|
342
|
+
if (artifact === "implementation-plan") {
|
|
343
|
+
let next = markdown.trim();
|
|
344
|
+
if (!next.includes("## Recorded Changes")) {
|
|
345
|
+
next = `${next}\n\n## Recorded Changes\n\n${changedFilesBlock(state.changedFiles)}`;
|
|
346
|
+
}
|
|
347
|
+
if (!next.includes("## Lint Results")) {
|
|
348
|
+
next = `${next}\n\n## Lint Results\n\n${lintBlock(state.lintResults)}`;
|
|
349
|
+
}
|
|
350
|
+
return `${next}\n`;
|
|
351
|
+
}
|
|
352
|
+
if (artifact === "test-report" && !markdown.includes("## Acceptance Evidence")) {
|
|
353
|
+
return `${markdown.trim()}\n\n## Acceptance Evidence\n\n${verificationBlock(state.verification)}\n`;
|
|
354
|
+
}
|
|
355
|
+
return markdown;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function runCurrentPhaseRole(state: RunState, runner: RoleRunner = runRole): Promise<RunState> {
|
|
359
|
+
const gate = gateForPhase(state.phase);
|
|
360
|
+
const role = roleForPhase(state.phase);
|
|
361
|
+
if (!gate || !role) {
|
|
362
|
+
const artifact = artifactForPhase(state.phase);
|
|
363
|
+
const markdown = renderArtifact(artifact, state);
|
|
364
|
+
state.artifacts[artifact] = await writeMarkdownArtifact(state, artifact, markdown);
|
|
365
|
+
state.phase = "complete";
|
|
366
|
+
state.status = "complete";
|
|
367
|
+
return saveState(state);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const artifact = artifactForPhase(state.phase);
|
|
371
|
+
const path = artifactPath(state.cwd, state.runId, artifact);
|
|
372
|
+
|
|
373
|
+
// Apply-mode implementation starts from a clean tree, so changed files after
|
|
374
|
+
// the role runs are attributable to the current DevCrew run.
|
|
375
|
+
const applyingImplementation = state.executionMode === "apply" && state.phase === "implementation";
|
|
376
|
+
const implementationBaseline: string[] = [];
|
|
377
|
+
if (applyingImplementation) {
|
|
378
|
+
await assertCleanApplyWorkspace(state.cwd);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
state.roles.push(conductorDecision(state, role, gate));
|
|
382
|
+
|
|
383
|
+
const result = await runner({
|
|
384
|
+
backend: state.backend,
|
|
385
|
+
role,
|
|
386
|
+
phase: state.phase,
|
|
387
|
+
request: state.request,
|
|
388
|
+
mode: state.mode,
|
|
389
|
+
executionMode: state.executionMode,
|
|
390
|
+
cwd: state.cwd,
|
|
391
|
+
standards: state.standards.combined,
|
|
392
|
+
artifactPath: path,
|
|
393
|
+
answers: state.answers.map((entry) => entry.answer),
|
|
394
|
+
feedback: state.feedback.map((entry) => `${entry.gate}: ${entry.message}`),
|
|
395
|
+
priorArtifacts: await readPriorArtifacts(state),
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (applyingImplementation) {
|
|
399
|
+
state.changedFiles = changedSinceBaseline(implementationBaseline, await listChangedLines(state.cwd));
|
|
400
|
+
state.implementationDiff = await collectImplementationDiff(state.cwd);
|
|
401
|
+
state.lintResults = await runConfiguredLint(state);
|
|
402
|
+
}
|
|
403
|
+
if (state.executionMode === "apply" && state.phase === "testing") {
|
|
404
|
+
state.verification = await runConfiguredVerification(state);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// When the backend cannot run a real SDK we keep a single deterministic
|
|
408
|
+
// artifact source by rendering the rich phase template from the core layer.
|
|
409
|
+
const baseMarkdown = result.usedFallback ? `${fallbackNotice(result)}${renderArtifact(artifact, state)}` : result.markdown;
|
|
410
|
+
const markdown = appendExecutionSections(artifact, baseMarkdown, state);
|
|
411
|
+
state.roles.push({ ...result, markdown });
|
|
412
|
+
state.artifacts[artifact] = await writeMarkdownArtifact(state, artifact, markdown);
|
|
413
|
+
if (artifact === "implementation-plan") {
|
|
414
|
+
state.artifacts["implementation-review"] = await writeMarkdownArtifact(
|
|
415
|
+
state,
|
|
416
|
+
"implementation-review",
|
|
417
|
+
renderArtifact("implementation-review", state),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
state.gates[gate] = "pending";
|
|
421
|
+
state.status = "awaiting_approval";
|
|
422
|
+
return saveState(state);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function startOrchestratedWorkflow(input: StartWorkflowInput, runner: RoleRunner = runRole): Promise<RunState> {
|
|
426
|
+
const state = await startWorkflow(input, { skipArtifactWrite: true });
|
|
427
|
+
return runCurrentPhaseRole(state, runner);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function continueOrchestratedWorkflow(input: RunRef, runner: RoleRunner = runRole): Promise<RunState> {
|
|
431
|
+
const state = await getWorkflowStatus(input);
|
|
432
|
+
if (state.status === "awaiting_approval" || state.status === "awaiting_input" || state.status === "complete") {
|
|
433
|
+
return state;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return runCurrentPhaseRole(state, runner);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export async function rejectOrchestratedWorkflow(input: RejectWorkflowInput): Promise<RunState> {
|
|
440
|
+
const before = await getWorkflowStatus(input);
|
|
441
|
+
const state = await rejectWorkflow(input);
|
|
442
|
+
|
|
443
|
+
// Roll back implementer edits when an apply-mode implementation gate is
|
|
444
|
+
// rejected so the next attempt starts from a clean working tree.
|
|
445
|
+
if (
|
|
446
|
+
before.executionMode === "apply" &&
|
|
447
|
+
before.phase === "implementation" &&
|
|
448
|
+
before.changedFiles.length > 0
|
|
449
|
+
) {
|
|
450
|
+
await revertChangedFiles(before.cwd, before.changedFiles);
|
|
451
|
+
state.changedFiles = [];
|
|
452
|
+
return saveState(state);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return state;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function answerOrchestratedWorkflow(input: AnswerWorkflowInput, runner: RoleRunner = runRole): Promise<RunState> {
|
|
459
|
+
const state = await answerWorkflow(input, { skipArtifactWrite: true });
|
|
460
|
+
const gate = gateForPhase(state.phase);
|
|
461
|
+
const role = roleForPhase(state.phase);
|
|
462
|
+
if (!gate || !role) {
|
|
463
|
+
return state;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Re-run the current phase role so the artifact reflects the new answer and
|
|
467
|
+
// any rejection feedback, instead of reverting to the static template.
|
|
468
|
+
return runCurrentPhaseRole(state, runner);
|
|
469
|
+
}
|
|
Binary file
|
|
Binary file
|