@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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/packages/adapters/src/index.js +188 -7
  4. package/dist/packages/core/src/active-run.js +13 -1
  5. package/dist/packages/core/src/config.js +87 -16
  6. package/dist/packages/core/src/index.js +1 -0
  7. package/dist/packages/core/src/lock.js +89 -0
  8. package/dist/packages/core/src/paths.js +3 -0
  9. package/dist/packages/core/src/store.js +10 -0
  10. package/dist/packages/core/src/version.js +1 -1
  11. package/dist/packages/core/src/workflow.js +20 -3
  12. package/dist/packages/orchestrator/src/index.js +100 -16
  13. package/dist/packages/service/src/tools.js +79 -18
  14. package/docs/claude-code.md +14 -1
  15. package/docs/codex.md +15 -2
  16. package/docs/quickstart.md +1 -1
  17. package/docs/workflow.md +11 -2
  18. package/package.json +2 -2
  19. package/packages/adapters/src/index.ts +220 -9
  20. package/packages/core/src/active-run.ts +13 -1
  21. package/packages/core/src/config.ts +96 -17
  22. package/packages/core/src/index.ts +1 -0
  23. package/packages/core/src/lock.ts +104 -0
  24. package/packages/core/src/paths.ts +4 -0
  25. package/packages/core/src/store.ts +12 -1
  26. package/packages/core/src/types.ts +47 -1
  27. package/packages/core/src/version.ts +1 -1
  28. package/packages/core/src/workflow.ts +22 -3
  29. package/packages/orchestrator/src/index.ts +109 -17
  30. package/packages/service/src/tools.ts +86 -16
  31. package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
  32. package/plugins/devcrew-codex/.mcp.json +1 -1
  33. package/scripts/smoke-codex-plugin.mjs +1 -1
  34. package/docs/superpowers/plans/2026-07-10-p0-foundation-repair.md +0 -939
  35. package/docs/superpowers/plans/2026-07-13-safety-semantics.md +0 -313
  36. package/docs/superpowers/specs/2026-07-10-p0-foundation-repair-design.md +0 -182
  37. 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.136.0 (sdk/typescript/src/threadOptions.ts
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
- type CodexConstructor = new () => CodexClient;
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 codex = new CodexClass();
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
- assertRoleSections(input.role, markdown);
447
- const reviewDecision = input.phase === "review" ? extractArchitectureReviewDecision(markdown) : undefined;
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
- questions: input.role === "pm" ? extractOpenQuestions(markdown) : undefined,
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 { DevCrewConfig } from "./types.js";
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
- const parsed = JSON.parse(raw) as DevCrewConfig;
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
  }
@@ -1,6 +1,7 @@
1
1
  export * from "./active-run.js";
2
2
  export * from "./artifacts.js";
3
3
  export * from "./config.js";
4
+ export * from "./lock.js";
4
5
  export * from "./paths.js";
5
6
  export * from "./standards.js";
6
7
  export * from "./store.js";
@@ -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