@shenlee/devcrew 0.1.3 → 0.1.5
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/packages/adapters/src/index.js +188 -7
- package/dist/packages/core/src/active-run.js +13 -1
- package/dist/packages/core/src/config.js +87 -16
- package/dist/packages/core/src/index.js +1 -0
- package/dist/packages/core/src/lock.js +89 -0
- package/dist/packages/core/src/paths.js +3 -0
- package/dist/packages/core/src/store.js +10 -0
- package/dist/packages/core/src/version.js +1 -1
- package/dist/packages/core/src/workflow.js +20 -3
- package/dist/packages/orchestrator/src/index.js +100 -16
- package/dist/packages/service/src/tools.js +79 -18
- package/docs/claude-code.md +14 -1
- package/docs/codex.md +15 -2
- package/docs/quickstart.md +1 -1
- package/docs/workflow.md +11 -2
- package/package.json +2 -2
- package/packages/adapters/src/index.ts +220 -9
- package/packages/core/src/active-run.ts +13 -1
- package/packages/core/src/config.ts +96 -17
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/lock.ts +104 -0
- package/packages/core/src/paths.ts +4 -0
- package/packages/core/src/store.ts +12 -1
- package/packages/core/src/types.ts +47 -1
- package/packages/core/src/version.ts +1 -1
- package/packages/core/src/workflow.ts +22 -3
- package/packages/orchestrator/src/index.ts +109 -17
- package/packages/service/src/tools.ts +86 -16
- package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/devcrew-codex/.mcp.json +1 -1
- package/scripts/smoke-codex-plugin.mjs +1 -1
- package/docs/superpowers/plans/2026-07-10-p0-foundation-repair.md +0 -939
- package/docs/superpowers/plans/2026-07-13-safety-semantics.md +0 -313
- package/docs/superpowers/specs/2026-07-10-p0-foundation-repair-design.md +0 -182
- package/docs/superpowers/specs/2026-07-13-execution-boundaries-design.md +0 -126
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync } from "node:fs";
|
|
2
|
+
import { delimiter, join, sep } from "node:path";
|
|
3
|
+
|
|
1
4
|
import { DEVCREW_NPM_PACKAGE, ROLE_SECTIONS } from "../../core/src/index.js";
|
|
2
5
|
import type {
|
|
3
6
|
ArtifactName,
|
|
@@ -6,6 +9,7 @@ import type {
|
|
|
6
9
|
ExecutionPolicy,
|
|
7
10
|
Host,
|
|
8
11
|
Phase,
|
|
12
|
+
StructuredRoleData,
|
|
9
13
|
RoleResult,
|
|
10
14
|
RunState,
|
|
11
15
|
WorkflowMode,
|
|
@@ -18,7 +22,7 @@ export interface BackendResolutionInput {
|
|
|
18
22
|
|
|
19
23
|
export interface RoleRunInput {
|
|
20
24
|
backend: BackendName;
|
|
21
|
-
role: RoleResult["role"]
|
|
25
|
+
role: Exclude<RoleResult["role"], "conductor">;
|
|
22
26
|
phase: Phase;
|
|
23
27
|
request: string;
|
|
24
28
|
mode: WorkflowMode;
|
|
@@ -101,6 +105,156 @@ export function extractArchitectureReviewDecision(markdown: string): "approved"
|
|
|
101
105
|
return match?.[1] as "approved" | "changes_required" | undefined;
|
|
102
106
|
}
|
|
103
107
|
|
|
108
|
+
const STRUCTURED_RESULT_MARKER = "<!-- devcrew-role-result -->";
|
|
109
|
+
const STRUCTURED_RESULT_BLOCK = /<!--\s*devcrew-role-result\s*-->\s*```json\s*\r?\n([\s\S]*?)\r?\n```/g;
|
|
110
|
+
|
|
111
|
+
function structuredOutputError(role: RoleResult["role"], reason: string): RoleOutputValidationError {
|
|
112
|
+
return new RoleOutputValidationError(role, [`marked structured role result: ${reason}`]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function asRecord(value: unknown, role: RoleResult["role"], field: string): Record<string, unknown> {
|
|
116
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
117
|
+
throw structuredOutputError(role, `${field} must be an object`);
|
|
118
|
+
}
|
|
119
|
+
return value as Record<string, unknown>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function asNonEmptyString(value: unknown, role: RoleResult["role"], field: string): string {
|
|
123
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
124
|
+
throw structuredOutputError(role, `${field} must be a non-empty string`);
|
|
125
|
+
}
|
|
126
|
+
return value.trim();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asStringArray(value: unknown, role: RoleResult["role"], field: string): string[] {
|
|
130
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
|
|
131
|
+
throw structuredOutputError(role, `${field} must be an array of non-empty strings`);
|
|
132
|
+
}
|
|
133
|
+
return value.map((entry) => (entry as string).trim());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function asEvidence(value: unknown, role: RoleResult["role"]): StructuredRoleData["evidence"] {
|
|
137
|
+
if (!Array.isArray(value)) {
|
|
138
|
+
throw structuredOutputError(role, "evidence must be an array");
|
|
139
|
+
}
|
|
140
|
+
return value.map((entry, index) => {
|
|
141
|
+
const evidence = asRecord(entry, role, `evidence[${index}]`);
|
|
142
|
+
const command = asNonEmptyString(evidence.command, role, `evidence[${index}].command`);
|
|
143
|
+
if (!Number.isInteger(evidence.exitCode)) {
|
|
144
|
+
throw structuredOutputError(role, `evidence[${index}].exitCode must be an integer`);
|
|
145
|
+
}
|
|
146
|
+
if (evidence.output !== undefined && typeof evidence.output !== "string") {
|
|
147
|
+
throw structuredOutputError(role, `evidence[${index}].output must be a string`);
|
|
148
|
+
}
|
|
149
|
+
return { command, exitCode: evidence.exitCode as number, output: evidence.output as string | undefined };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function asQuestions(value: unknown, role: RoleResult["role"]): NonNullable<StructuredRoleData["questions"]> {
|
|
154
|
+
if (!Array.isArray(value)) {
|
|
155
|
+
throw structuredOutputError(role, "questions must be an array");
|
|
156
|
+
}
|
|
157
|
+
const ids = new Set<string>();
|
|
158
|
+
return value.map((entry, index) => {
|
|
159
|
+
const question = asRecord(entry, role, `questions[${index}]`);
|
|
160
|
+
const id = asNonEmptyString(question.id, role, `questions[${index}].id`);
|
|
161
|
+
if (ids.has(id)) {
|
|
162
|
+
throw structuredOutputError(role, `questions[${index}].id must be unique`);
|
|
163
|
+
}
|
|
164
|
+
ids.add(id);
|
|
165
|
+
const prompt = asNonEmptyString(question.prompt, role, `questions[${index}].prompt`);
|
|
166
|
+
if (question.context !== undefined && typeof question.context !== "string") {
|
|
167
|
+
throw structuredOutputError(role, `questions[${index}].context must be a string`);
|
|
168
|
+
}
|
|
169
|
+
return { id, prompt, context: question.context as string | undefined };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseStructuredRoleData(
|
|
174
|
+
role: Exclude<RoleResult["role"], "conductor">,
|
|
175
|
+
phase: Phase,
|
|
176
|
+
raw: unknown,
|
|
177
|
+
): StructuredRoleData {
|
|
178
|
+
const value = asRecord(raw, role, "result");
|
|
179
|
+
if (value.schemaVersion !== 1) {
|
|
180
|
+
throw structuredOutputError(role, "schemaVersion must be 1");
|
|
181
|
+
}
|
|
182
|
+
if (value.role !== role) {
|
|
183
|
+
throw structuredOutputError(role, `role must be ${role}`);
|
|
184
|
+
}
|
|
185
|
+
const data: StructuredRoleData = {
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
role,
|
|
188
|
+
summary: asNonEmptyString(value.summary, role, "summary"),
|
|
189
|
+
risks: asStringArray(value.risks, role, "risks"),
|
|
190
|
+
evidence: asEvidence(value.evidence, role),
|
|
191
|
+
};
|
|
192
|
+
if (role === "pm") {
|
|
193
|
+
data.questions = asQuestions(value.questions, role);
|
|
194
|
+
} else if (role === "architect") {
|
|
195
|
+
data.decisions = asStringArray(value.decisions, role, "decisions");
|
|
196
|
+
if (phase === "review") {
|
|
197
|
+
if (value.reviewDecision !== "approved" && value.reviewDecision !== "changes_required") {
|
|
198
|
+
throw structuredOutputError(role, "reviewDecision must be approved or changes_required");
|
|
199
|
+
}
|
|
200
|
+
data.reviewDecision = value.reviewDecision;
|
|
201
|
+
}
|
|
202
|
+
} else if (role === "implementer") {
|
|
203
|
+
data.changedFiles = asStringArray(value.changedFiles, role, "changedFiles");
|
|
204
|
+
} else {
|
|
205
|
+
if (!Array.isArray(value.testCases)) {
|
|
206
|
+
throw structuredOutputError(role, "testCases must be an array");
|
|
207
|
+
}
|
|
208
|
+
data.testCases = value.testCases.map((entry, index) => {
|
|
209
|
+
const testCase = asRecord(entry, role, `testCases[${index}]`);
|
|
210
|
+
const type = testCase.type;
|
|
211
|
+
if (type !== "happy" && type !== "edge" && type !== "failure" && type !== "regression") {
|
|
212
|
+
throw structuredOutputError(role, `testCases[${index}].type is invalid`);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
id: asNonEmptyString(testCase.id, role, `testCases[${index}].id`),
|
|
216
|
+
scenario: asNonEmptyString(testCase.scenario, role, `testCases[${index}].scenario`),
|
|
217
|
+
type,
|
|
218
|
+
expected: asNonEmptyString(testCase.expected, role, `testCases[${index}].expected`),
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function parseRoleResultOutput(
|
|
226
|
+
role: Exclude<RoleResult["role"], "conductor">,
|
|
227
|
+
phase: Phase,
|
|
228
|
+
output: string,
|
|
229
|
+
): Pick<RoleResult, "format" | "markdown" | "structured" | "questions" | "reviewDecision"> {
|
|
230
|
+
if (!output.includes(STRUCTURED_RESULT_MARKER)) {
|
|
231
|
+
return {
|
|
232
|
+
format: "legacy",
|
|
233
|
+
markdown: output,
|
|
234
|
+
questions: role === "pm" ? extractOpenQuestions(output) : undefined,
|
|
235
|
+
reviewDecision: phase === "review" ? extractArchitectureReviewDecision(output) : undefined,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const blocks = [...output.matchAll(STRUCTURED_RESULT_BLOCK)];
|
|
239
|
+
if (blocks.length !== 1) {
|
|
240
|
+
throw structuredOutputError(role, blocks.length === 0 ? "must use a JSON fenced block" : "must appear exactly once");
|
|
241
|
+
}
|
|
242
|
+
let raw: unknown;
|
|
243
|
+
try {
|
|
244
|
+
raw = JSON.parse(blocks[0]?.[1] ?? "");
|
|
245
|
+
} catch {
|
|
246
|
+
throw structuredOutputError(role, "contains invalid JSON");
|
|
247
|
+
}
|
|
248
|
+
const structured = parseStructuredRoleData(role, phase, raw);
|
|
249
|
+
return {
|
|
250
|
+
format: "structured",
|
|
251
|
+
markdown: output.slice(0, blocks[0]?.index).concat(output.slice((blocks[0]?.index ?? 0) + (blocks[0]?.[0].length ?? 0))).trim(),
|
|
252
|
+
structured,
|
|
253
|
+
questions: structured.questions?.map((question) => question.prompt),
|
|
254
|
+
reviewDecision: structured.reviewDecision,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
104
258
|
export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">): string {
|
|
105
259
|
const executionMode = input.executionMode ?? "plan";
|
|
106
260
|
const answers = input.answers ?? [];
|
|
@@ -148,6 +302,13 @@ export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">):
|
|
|
148
302
|
"Keep scope aligned with the approved gates and the selected DevCrew execution policy.",
|
|
149
303
|
permissionInstruction,
|
|
150
304
|
"",
|
|
305
|
+
"Return this protocol block first:",
|
|
306
|
+
STRUCTURED_RESULT_MARKER,
|
|
307
|
+
"```json",
|
|
308
|
+
`{\"schemaVersion\":1,\"role\":\"${input.role}\",\"summary\":\"...\",\"risks\":[],\"evidence\":[]}`,
|
|
309
|
+
"```",
|
|
310
|
+
"Then return the required Markdown H2 sections. Do not include a second marked result block.",
|
|
311
|
+
"",
|
|
151
312
|
"Required Sections:",
|
|
152
313
|
...roleGuidance(input.role),
|
|
153
314
|
);
|
|
@@ -180,7 +341,7 @@ function titleForPhase(phase: Phase): string {
|
|
|
180
341
|
// --- Host SDK contracts -----------------------------------------------------
|
|
181
342
|
// These types pin the published surface of the optional host SDKs so the
|
|
182
343
|
// adapter logic is type-checked even though the packages are not installed.
|
|
183
|
-
// Verified against @openai/codex-sdk 0.
|
|
344
|
+
// Verified against @openai/codex-sdk 0.144.5 (sdk/typescript/src/threadOptions.ts
|
|
184
345
|
// and thread.ts) and @anthropic-ai/claude-agent-sdk (Agent SDK TypeScript
|
|
185
346
|
// reference). Update these when the upstream contracts change.
|
|
186
347
|
|
|
@@ -210,7 +371,11 @@ interface CodexClient {
|
|
|
210
371
|
startThread: (options?: CodexThreadOptions) => CodexThread;
|
|
211
372
|
}
|
|
212
373
|
|
|
213
|
-
|
|
374
|
+
interface CodexClientOptions {
|
|
375
|
+
codexPathOverride?: string;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
type CodexConstructor = new (options?: CodexClientOptions) => CodexClient;
|
|
214
379
|
|
|
215
380
|
export type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "dontAsk" | "plan";
|
|
216
381
|
|
|
@@ -377,10 +542,52 @@ function claudeOptionsForRole(input: RoleRunInput): ClaudeQueryOptions {
|
|
|
377
542
|
return buildClaudeOptions(input.cwd, "dontAsk", allowedTools);
|
|
378
543
|
}
|
|
379
544
|
|
|
545
|
+
function resolveHostCodexExecutable(): string | undefined {
|
|
546
|
+
const explicitPath = process.env.DEVCREW_CODEX_PATH?.trim();
|
|
547
|
+
if (explicitPath) {
|
|
548
|
+
return explicitPath;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const pathValue = process.env.PATH ?? Object.entries(process.env)
|
|
552
|
+
.find(([key]) => key.toLowerCase() === "path")?.[1];
|
|
553
|
+
if (!pathValue) {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const executableNames = process.platform === "win32"
|
|
558
|
+
? ["codex.exe", "codex.cmd", "codex.bat", "codex"]
|
|
559
|
+
: ["codex"];
|
|
560
|
+
for (const entry of pathValue.split(delimiter)) {
|
|
561
|
+
const directory = entry.trim().replace(/^"|"$/g, "");
|
|
562
|
+
if (!directory) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
for (const executableName of executableNames) {
|
|
566
|
+
const candidate = join(directory, executableName);
|
|
567
|
+
try {
|
|
568
|
+
accessSync(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
569
|
+
const normalizedCandidate = candidate.split(sep).join("/");
|
|
570
|
+
const normalizedRealPath = realpathSync(candidate).split(sep).join("/");
|
|
571
|
+
if (
|
|
572
|
+
normalizedCandidate.includes("/node_modules/.bin/")
|
|
573
|
+
|| normalizedRealPath.includes("/node_modules/@openai/codex/")
|
|
574
|
+
) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
return candidate;
|
|
578
|
+
} catch {
|
|
579
|
+
// Keep searching PATH; the SDK's packaged runtime remains the fallback.
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
|
|
380
586
|
async function runWithCodex(input: RoleRunInput, prompt: string, loadModule: ModuleLoader): Promise<string> {
|
|
381
587
|
const mod = await loadModule(HOST_SDK_PACKAGES.codex);
|
|
382
588
|
const CodexClass = mod.Codex as CodexConstructor;
|
|
383
|
-
const
|
|
589
|
+
const codexPathOverride = resolveHostCodexExecutable();
|
|
590
|
+
const codex = new CodexClass(codexPathOverride ? { codexPathOverride } : undefined);
|
|
384
591
|
const thread = codex.startThread(codexOptionsForRole(input));
|
|
385
592
|
const turn = await thread.run(prompt);
|
|
386
593
|
return extractCodexText(turn);
|
|
@@ -410,6 +617,7 @@ function fallbackResult(
|
|
|
410
617
|
summary,
|
|
411
618
|
markdown: `# ${title}\n\n${summary}\n`,
|
|
412
619
|
usedFallback: true,
|
|
620
|
+
format: "legacy",
|
|
413
621
|
};
|
|
414
622
|
}
|
|
415
623
|
|
|
@@ -443,18 +651,21 @@ export async function runRole(input: RoleRunInput, deps: RunRoleDeps = {}): Prom
|
|
|
443
651
|
input.backend === "codex"
|
|
444
652
|
? await runWithCodex(input, prompt, loadModule)
|
|
445
653
|
: await runWithClaude(input, prompt, loadModule);
|
|
446
|
-
|
|
447
|
-
|
|
654
|
+
const parsed = parseRoleResultOutput(input.role, input.phase, markdown);
|
|
655
|
+
assertRoleSections(input.role, parsed.markdown);
|
|
656
|
+
const reviewDecision = parsed.reviewDecision;
|
|
448
657
|
if (input.phase === "review" && !reviewDecision) {
|
|
449
658
|
throw new RoleOutputValidationError(input.role, ["Review Decision"]);
|
|
450
659
|
}
|
|
451
660
|
return {
|
|
452
661
|
role: input.role,
|
|
453
662
|
backend: input.backend,
|
|
454
|
-
summary: `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
455
|
-
markdown,
|
|
663
|
+
summary: parsed.structured?.summary ?? `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
664
|
+
markdown: parsed.markdown,
|
|
456
665
|
usedFallback: false,
|
|
457
|
-
|
|
666
|
+
format: parsed.format,
|
|
667
|
+
structured: parsed.structured,
|
|
668
|
+
questions: parsed.questions,
|
|
458
669
|
reviewDecision,
|
|
459
670
|
};
|
|
460
671
|
} catch (error) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
|
|
3
3
|
import { activeRunPath, ensureProjectDirectories } from "./paths.js";
|
|
4
4
|
|
|
@@ -29,3 +29,15 @@ export async function getActiveRunId(cwd: string): Promise<string> {
|
|
|
29
29
|
}
|
|
30
30
|
throw new Error("No active DevCrew run. Pass runId or start a workflow first.");
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
export async function clearActiveRunIfMatches(cwd: string, runId: string): Promise<boolean> {
|
|
34
|
+
try {
|
|
35
|
+
if ((await getActiveRunId(cwd)) !== runId) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
await unlink(activeRunPath(cwd));
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
3
|
|
|
3
4
|
import { configPath, ensureProjectDirectories } from "./paths.js";
|
|
4
|
-
import type
|
|
5
|
+
import { BACKENDS, EXECUTION_MODES, GATES, type BackendName, type DevCrewConfig, type ExecutionMode, type GateName } from "./types.js";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_CONFIG: DevCrewConfig = {
|
|
7
8
|
version: 1,
|
|
@@ -25,6 +26,99 @@ async function exists(path: string): Promise<boolean> {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
type JsonRecord = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown, field: string): JsonRecord {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
33
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be an object`);
|
|
34
|
+
}
|
|
35
|
+
return value as JsonRecord;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function assertKnownKeys(value: JsonRecord, field: string, allowed: readonly string[]): void {
|
|
39
|
+
for (const key of Object.keys(value)) {
|
|
40
|
+
if (!allowed.includes(key)) {
|
|
41
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} has unknown key ${key}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requiredString(value: unknown, field: string): string {
|
|
47
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
48
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be a non-empty string`);
|
|
49
|
+
}
|
|
50
|
+
return value.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseCommandList(value: unknown, field: string): string[] {
|
|
54
|
+
if (value === undefined) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(value)) {
|
|
58
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be an array`);
|
|
59
|
+
}
|
|
60
|
+
return value.map((entry, index) => requiredString(entry, `${field}[${index}]`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseGates(value: unknown): GateName[] {
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
throw new Error("Invalid .devcrew/config.json: workflow.gates must be an array");
|
|
66
|
+
}
|
|
67
|
+
const gates: GateName[] = [];
|
|
68
|
+
for (const [index, entry] of value.entries()) {
|
|
69
|
+
if (typeof entry !== "string" || !GATES.includes(entry as GateName)) {
|
|
70
|
+
throw new Error(`Invalid .devcrew/config.json: workflow.gates[${index}] must be a known gate, received ${String(entry)}`);
|
|
71
|
+
}
|
|
72
|
+
if (gates.includes(entry as GateName)) {
|
|
73
|
+
throw new Error(`Invalid .devcrew/config.json: workflow.gates contains duplicate ${entry}`);
|
|
74
|
+
}
|
|
75
|
+
gates.push(entry as GateName);
|
|
76
|
+
}
|
|
77
|
+
return gates;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseArtifactDirectory(cwd: string, value: unknown): string {
|
|
81
|
+
const artifactDirectory = requiredString(value, "workflow.artifactDirectory");
|
|
82
|
+
if (isAbsolute(artifactDirectory)) {
|
|
83
|
+
throw new Error("Invalid .devcrew/config.json: workflow.artifactDirectory must be relative to the repository");
|
|
84
|
+
}
|
|
85
|
+
const projectRoot = resolve(cwd);
|
|
86
|
+
const target = resolve(projectRoot, artifactDirectory);
|
|
87
|
+
const fromProject = relative(projectRoot, target);
|
|
88
|
+
if (fromProject === ".." || fromProject.startsWith(`..${sep}`) || isAbsolute(fromProject)) {
|
|
89
|
+
throw new Error("Invalid .devcrew/config.json: workflow.artifactDirectory must resolve inside the repository");
|
|
90
|
+
}
|
|
91
|
+
return artifactDirectory;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseConfig(cwd: string, value: unknown): DevCrewConfig {
|
|
95
|
+
const parsed = asRecord(value, "root");
|
|
96
|
+
assertKnownKeys(parsed, "root", ["version", "defaultBackend", "executionMode", "verifyCommands", "lintCommands", "coverageCommands", "workflow"]);
|
|
97
|
+
if (parsed.version !== 1) {
|
|
98
|
+
throw new Error("Unsupported .devcrew/config.json version");
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed.defaultBackend !== "string" || (parsed.defaultBackend !== "host-preferred" && !BACKENDS.includes(parsed.defaultBackend as BackendName))) {
|
|
101
|
+
throw new Error("Invalid .devcrew/config.json: defaultBackend must be host-preferred, codex, claude, or local");
|
|
102
|
+
}
|
|
103
|
+
if (typeof parsed.executionMode !== "string" || !EXECUTION_MODES.includes(parsed.executionMode as ExecutionMode)) {
|
|
104
|
+
throw new Error("Invalid .devcrew/config.json: executionMode must be plan or apply");
|
|
105
|
+
}
|
|
106
|
+
const workflow = asRecord(parsed.workflow, "workflow");
|
|
107
|
+
assertKnownKeys(workflow, "workflow", ["gates", "artifactDirectory"]);
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
defaultBackend: parsed.defaultBackend as DevCrewConfig["defaultBackend"],
|
|
111
|
+
executionMode: parsed.executionMode as ExecutionMode,
|
|
112
|
+
verifyCommands: parseCommandList(parsed.verifyCommands, "verifyCommands"),
|
|
113
|
+
lintCommands: parseCommandList(parsed.lintCommands, "lintCommands"),
|
|
114
|
+
coverageCommands: parseCommandList(parsed.coverageCommands, "coverageCommands"),
|
|
115
|
+
workflow: {
|
|
116
|
+
gates: parseGates(workflow.gates),
|
|
117
|
+
artifactDirectory: parseArtifactDirectory(cwd, workflow.artifactDirectory),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
28
122
|
export async function ensureConfig(cwd: string): Promise<DevCrewConfig> {
|
|
29
123
|
await ensureProjectDirectories(cwd);
|
|
30
124
|
const path = configPath(cwd);
|
|
@@ -37,20 +131,5 @@ export async function ensureConfig(cwd: string): Promise<DevCrewConfig> {
|
|
|
37
131
|
|
|
38
132
|
export async function readConfig(cwd: string): Promise<DevCrewConfig> {
|
|
39
133
|
const raw = await readFile(configPath(cwd), "utf8");
|
|
40
|
-
|
|
41
|
-
if (parsed.version !== 1) {
|
|
42
|
-
throw new Error("Unsupported .devcrew/config.json version");
|
|
43
|
-
}
|
|
44
|
-
return {
|
|
45
|
-
...DEFAULT_CONFIG,
|
|
46
|
-
...parsed,
|
|
47
|
-
executionMode: parsed.executionMode ?? "plan",
|
|
48
|
-
verifyCommands: Array.isArray(parsed.verifyCommands) ? parsed.verifyCommands : [],
|
|
49
|
-
lintCommands: Array.isArray(parsed.lintCommands) ? parsed.lintCommands : [],
|
|
50
|
-
coverageCommands: Array.isArray(parsed.coverageCommands) ? parsed.coverageCommands : [],
|
|
51
|
-
workflow: {
|
|
52
|
-
...DEFAULT_CONFIG.workflow,
|
|
53
|
-
...parsed.workflow,
|
|
54
|
-
},
|
|
55
|
-
};
|
|
134
|
+
return parseConfig(cwd, JSON.parse(raw) as unknown);
|
|
56
135
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { devcrewDir, repositoryLockPath } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
interface RepositoryLockMetadata {
|
|
7
|
+
ownerId: string;
|
|
8
|
+
pid: number;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function metadataPath(cwd: string): string {
|
|
13
|
+
return `${repositoryLockPath(cwd)}/lock.json`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function readMetadata(cwd: string): Promise<RepositoryLockMetadata | undefined> {
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(await readFile(metadataPath(cwd), "utf8")) as Partial<RepositoryLockMetadata>;
|
|
19
|
+
const pid = parsed.pid;
|
|
20
|
+
if (
|
|
21
|
+
typeof parsed.ownerId !== "string" ||
|
|
22
|
+
typeof pid !== "number" ||
|
|
23
|
+
!Number.isInteger(pid) ||
|
|
24
|
+
typeof parsed.createdAt !== "string"
|
|
25
|
+
) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
ownerId: parsed.ownerId,
|
|
30
|
+
pid,
|
|
31
|
+
createdAt: parsed.createdAt,
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function processIsLive(pid: number): boolean {
|
|
39
|
+
if (pid <= 0) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function releaseRepositoryLock(cwd: string, ownerId: string): Promise<void> {
|
|
51
|
+
const metadata = await readMetadata(cwd);
|
|
52
|
+
if (metadata?.ownerId === ownerId) {
|
|
53
|
+
await rm(repositoryLockPath(cwd), { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function withRepositoryLock<T>(cwd: string, action: () => Promise<T>): Promise<T> {
|
|
58
|
+
await mkdir(devcrewDir(cwd), { recursive: true });
|
|
59
|
+
const ownerId = randomUUID();
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(repositoryLockPath(cwd));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
const metadata = await readMetadata(cwd);
|
|
67
|
+
if (metadata && processIsLive(metadata.pid)) {
|
|
68
|
+
throw new Error("A DevCrew operation is already in progress for this repository");
|
|
69
|
+
}
|
|
70
|
+
throw new Error("A stale DevCrew repository lock exists; use devcrew_recover for explicit recovery");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await writeFile(
|
|
74
|
+
metadataPath(cwd),
|
|
75
|
+
`${JSON.stringify({ ownerId, pid: process.pid, createdAt: new Date().toISOString() } satisfies RepositoryLockMetadata, null, 2)}\n`,
|
|
76
|
+
"utf8",
|
|
77
|
+
);
|
|
78
|
+
try {
|
|
79
|
+
return await action();
|
|
80
|
+
} finally {
|
|
81
|
+
await releaseRepositoryLock(cwd, ownerId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function recoverRepositoryLock(cwd: string): Promise<boolean> {
|
|
86
|
+
const lockPath = repositoryLockPath(cwd);
|
|
87
|
+
const metadata = await readMetadata(cwd);
|
|
88
|
+
if (!metadata) {
|
|
89
|
+
try {
|
|
90
|
+
await rm(lockPath, { recursive: true, force: false });
|
|
91
|
+
return true;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (processIsLive(metadata.pid)) {
|
|
100
|
+
throw new Error("Cannot recover a DevCrew repository lock held by a live process");
|
|
101
|
+
}
|
|
102
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
@@ -31,6 +31,10 @@ export function activeRunPath(cwd: string): string {
|
|
|
31
31
|
return join(devcrewDir(cwd), "active-run.json");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export function repositoryLockPath(cwd: string): string {
|
|
35
|
+
return join(devcrewDir(cwd), "lock");
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export function standardsPath(cwd: string): string {
|
|
35
39
|
return join(devcrewDir(cwd), "standards.md");
|
|
36
40
|
}
|
|
@@ -2,7 +2,7 @@ import { readFile, rename, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { ensureRunDirectories, statePath } from "./paths.js";
|
|
5
|
-
import { GATES, type GateName, type RunState } from "./types.js";
|
|
5
|
+
import { GATES, type GateName, type RoleResult, type RunState } from "./types.js";
|
|
6
6
|
|
|
7
7
|
function enabledGatesFromState(value: unknown): GateName[] {
|
|
8
8
|
const configured = Array.isArray(value)
|
|
@@ -11,6 +11,16 @@ function enabledGatesFromState(value: unknown): GateName[] {
|
|
|
11
11
|
return [...new Set<GateName>([...configured, "implementation-review", "testing"])];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function roleResultsFromState(value: unknown): RoleResult[] {
|
|
15
|
+
if (!Array.isArray(value)) {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
return value.filter((role): role is RoleResult => typeof role === "object" && role !== null).map((role) => ({
|
|
19
|
+
...role,
|
|
20
|
+
format: role.format === "structured" ? "structured" : "legacy",
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
export async function saveState(state: RunState): Promise<RunState> {
|
|
15
25
|
state.updatedAt = new Date().toISOString();
|
|
16
26
|
await ensureRunDirectories(state.cwd, state.runId);
|
|
@@ -31,6 +41,7 @@ export async function loadState(cwd: string, runId: string): Promise<RunState> {
|
|
|
31
41
|
...parsed,
|
|
32
42
|
executionMode: parsed.executionMode ?? "plan",
|
|
33
43
|
executionPolicy: parsed.executionPolicy ?? "interactive-host",
|
|
44
|
+
roles: roleResultsFromState(parsed.roles),
|
|
34
45
|
enabledGates: enabledGatesFromState(parsed.enabledGates),
|
|
35
46
|
artifactDirectory: typeof parsed.artifactDirectory === "string" && parsed.artifactDirectory.trim().length > 0
|
|
36
47
|
? parsed.artifactDirectory
|