@shenlee/devcrew 0.1.3 → 0.1.4

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 (39) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/packages/adapters/src/index.js +147 -6
  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/superpowers/plans/2026-07-14-p0-remediation.md +213 -0
  18. package/docs/superpowers/plans/2026-07-14-reliability-recovery.md +208 -0
  19. package/docs/superpowers/plans/2026-07-14-structured-role-results.md +231 -0
  20. package/docs/superpowers/specs/2026-07-14-p0-remediation-design.md +52 -0
  21. package/docs/superpowers/specs/2026-07-14-reliability-recovery-design.md +92 -0
  22. package/docs/superpowers/specs/2026-07-14-structured-role-results-design.md +96 -0
  23. package/docs/workflow.md +11 -2
  24. package/package.json +1 -1
  25. package/packages/adapters/src/index.ts +168 -6
  26. package/packages/core/src/active-run.ts +13 -1
  27. package/packages/core/src/config.ts +96 -17
  28. package/packages/core/src/index.ts +1 -0
  29. package/packages/core/src/lock.ts +104 -0
  30. package/packages/core/src/paths.ts +4 -0
  31. package/packages/core/src/store.ts +12 -1
  32. package/packages/core/src/types.ts +47 -1
  33. package/packages/core/src/version.ts +1 -1
  34. package/packages/core/src/workflow.ts +22 -3
  35. package/packages/orchestrator/src/index.ts +109 -17
  36. package/packages/service/src/tools.ts +86 -16
  37. package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
  38. package/plugins/devcrew-codex/.mcp.json +1 -1
  39. package/scripts/smoke-codex-plugin.mjs +1 -1
package/docs/workflow.md CHANGED
@@ -42,11 +42,20 @@ The implementation-plan approval advances the run to `execution`. With the defau
42
42
 
43
43
  Apply requires Git and a clean requester worktree both when execution starts and when the reviewed patch is promoted. The requester repository remains unchanged until testing approval. A failed verification enters `awaiting_input`, rather than a promotable testing gate; it can be revised with `devcrew_answer` or deliberately reopened only through `devcrew_waive_verification` with a non-empty risk reason. If testing is rejected, `devcrew_answer` records the response and returns the run to `execution/ready` with the isolated worktree intact.
44
44
 
45
+ `devcrew_abort` stops a nonterminal run with a required reason. It preserves the
46
+ run state and artifacts for audit, removes its isolated worktree when possible,
47
+ and clears the active-run pointer only when it refers to that run. An aborted
48
+ run cannot continue. `devcrew_recover` never runs an agent: it explicitly
49
+ clears only a confirmed stale repository lock and retries cleanup for a
50
+ terminal run that retained an isolated worktree.
51
+
45
52
  The execution review is also structured: the architect must return either
46
53
  `Decision: approved` or `Decision: changes_required` in its
47
54
  `architecture-review` artifact. A `changes_required` decision rejects the
48
- implementation-review gate and keeps the run at `awaiting_input`; it cannot
49
- enter testing until the feedback is addressed and a later review approves it.
55
+ implementation-review gate and keeps the run at `awaiting_input`. Submitting
56
+ `devcrew_answer` then returns the run to `execution/ready` in the same
57
+ isolated worktree, resets that review gate, and requires a new implementation
58
+ pass followed by a later approving review before testing can start.
50
59
 
51
60
  `devcrew_start` records the created run as the active run for the repository.
52
61
  Subsequent MCP calls can omit `runId`; DevCrew resolves it from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shenlee/devcrew",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Multi-host agent workflow service for Codex and Claude Code.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -6,6 +6,7 @@ import type {
6
6
  ExecutionPolicy,
7
7
  Host,
8
8
  Phase,
9
+ StructuredRoleData,
9
10
  RoleResult,
10
11
  RunState,
11
12
  WorkflowMode,
@@ -18,7 +19,7 @@ export interface BackendResolutionInput {
18
19
 
19
20
  export interface RoleRunInput {
20
21
  backend: BackendName;
21
- role: RoleResult["role"];
22
+ role: Exclude<RoleResult["role"], "conductor">;
22
23
  phase: Phase;
23
24
  request: string;
24
25
  mode: WorkflowMode;
@@ -101,6 +102,156 @@ export function extractArchitectureReviewDecision(markdown: string): "approved"
101
102
  return match?.[1] as "approved" | "changes_required" | undefined;
102
103
  }
103
104
 
105
+ const STRUCTURED_RESULT_MARKER = "<!-- devcrew-role-result -->";
106
+ const STRUCTURED_RESULT_BLOCK = /<!--\s*devcrew-role-result\s*-->\s*```json\s*\r?\n([\s\S]*?)\r?\n```/g;
107
+
108
+ function structuredOutputError(role: RoleResult["role"], reason: string): RoleOutputValidationError {
109
+ return new RoleOutputValidationError(role, [`marked structured role result: ${reason}`]);
110
+ }
111
+
112
+ function asRecord(value: unknown, role: RoleResult["role"], field: string): Record<string, unknown> {
113
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
114
+ throw structuredOutputError(role, `${field} must be an object`);
115
+ }
116
+ return value as Record<string, unknown>;
117
+ }
118
+
119
+ function asNonEmptyString(value: unknown, role: RoleResult["role"], field: string): string {
120
+ if (typeof value !== "string" || value.trim().length === 0) {
121
+ throw structuredOutputError(role, `${field} must be a non-empty string`);
122
+ }
123
+ return value.trim();
124
+ }
125
+
126
+ function asStringArray(value: unknown, role: RoleResult["role"], field: string): string[] {
127
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
128
+ throw structuredOutputError(role, `${field} must be an array of non-empty strings`);
129
+ }
130
+ return value.map((entry) => (entry as string).trim());
131
+ }
132
+
133
+ function asEvidence(value: unknown, role: RoleResult["role"]): StructuredRoleData["evidence"] {
134
+ if (!Array.isArray(value)) {
135
+ throw structuredOutputError(role, "evidence must be an array");
136
+ }
137
+ return value.map((entry, index) => {
138
+ const evidence = asRecord(entry, role, `evidence[${index}]`);
139
+ const command = asNonEmptyString(evidence.command, role, `evidence[${index}].command`);
140
+ if (!Number.isInteger(evidence.exitCode)) {
141
+ throw structuredOutputError(role, `evidence[${index}].exitCode must be an integer`);
142
+ }
143
+ if (evidence.output !== undefined && typeof evidence.output !== "string") {
144
+ throw structuredOutputError(role, `evidence[${index}].output must be a string`);
145
+ }
146
+ return { command, exitCode: evidence.exitCode as number, output: evidence.output as string | undefined };
147
+ });
148
+ }
149
+
150
+ function asQuestions(value: unknown, role: RoleResult["role"]): NonNullable<StructuredRoleData["questions"]> {
151
+ if (!Array.isArray(value)) {
152
+ throw structuredOutputError(role, "questions must be an array");
153
+ }
154
+ const ids = new Set<string>();
155
+ return value.map((entry, index) => {
156
+ const question = asRecord(entry, role, `questions[${index}]`);
157
+ const id = asNonEmptyString(question.id, role, `questions[${index}].id`);
158
+ if (ids.has(id)) {
159
+ throw structuredOutputError(role, `questions[${index}].id must be unique`);
160
+ }
161
+ ids.add(id);
162
+ const prompt = asNonEmptyString(question.prompt, role, `questions[${index}].prompt`);
163
+ if (question.context !== undefined && typeof question.context !== "string") {
164
+ throw structuredOutputError(role, `questions[${index}].context must be a string`);
165
+ }
166
+ return { id, prompt, context: question.context as string | undefined };
167
+ });
168
+ }
169
+
170
+ function parseStructuredRoleData(
171
+ role: Exclude<RoleResult["role"], "conductor">,
172
+ phase: Phase,
173
+ raw: unknown,
174
+ ): StructuredRoleData {
175
+ const value = asRecord(raw, role, "result");
176
+ if (value.schemaVersion !== 1) {
177
+ throw structuredOutputError(role, "schemaVersion must be 1");
178
+ }
179
+ if (value.role !== role) {
180
+ throw structuredOutputError(role, `role must be ${role}`);
181
+ }
182
+ const data: StructuredRoleData = {
183
+ schemaVersion: 1,
184
+ role,
185
+ summary: asNonEmptyString(value.summary, role, "summary"),
186
+ risks: asStringArray(value.risks, role, "risks"),
187
+ evidence: asEvidence(value.evidence, role),
188
+ };
189
+ if (role === "pm") {
190
+ data.questions = asQuestions(value.questions, role);
191
+ } else if (role === "architect") {
192
+ data.decisions = asStringArray(value.decisions, role, "decisions");
193
+ if (phase === "review") {
194
+ if (value.reviewDecision !== "approved" && value.reviewDecision !== "changes_required") {
195
+ throw structuredOutputError(role, "reviewDecision must be approved or changes_required");
196
+ }
197
+ data.reviewDecision = value.reviewDecision;
198
+ }
199
+ } else if (role === "implementer") {
200
+ data.changedFiles = asStringArray(value.changedFiles, role, "changedFiles");
201
+ } else {
202
+ if (!Array.isArray(value.testCases)) {
203
+ throw structuredOutputError(role, "testCases must be an array");
204
+ }
205
+ data.testCases = value.testCases.map((entry, index) => {
206
+ const testCase = asRecord(entry, role, `testCases[${index}]`);
207
+ const type = testCase.type;
208
+ if (type !== "happy" && type !== "edge" && type !== "failure" && type !== "regression") {
209
+ throw structuredOutputError(role, `testCases[${index}].type is invalid`);
210
+ }
211
+ return {
212
+ id: asNonEmptyString(testCase.id, role, `testCases[${index}].id`),
213
+ scenario: asNonEmptyString(testCase.scenario, role, `testCases[${index}].scenario`),
214
+ type,
215
+ expected: asNonEmptyString(testCase.expected, role, `testCases[${index}].expected`),
216
+ };
217
+ });
218
+ }
219
+ return data;
220
+ }
221
+
222
+ export function parseRoleResultOutput(
223
+ role: Exclude<RoleResult["role"], "conductor">,
224
+ phase: Phase,
225
+ output: string,
226
+ ): Pick<RoleResult, "format" | "markdown" | "structured" | "questions" | "reviewDecision"> {
227
+ if (!output.includes(STRUCTURED_RESULT_MARKER)) {
228
+ return {
229
+ format: "legacy",
230
+ markdown: output,
231
+ questions: role === "pm" ? extractOpenQuestions(output) : undefined,
232
+ reviewDecision: phase === "review" ? extractArchitectureReviewDecision(output) : undefined,
233
+ };
234
+ }
235
+ const blocks = [...output.matchAll(STRUCTURED_RESULT_BLOCK)];
236
+ if (blocks.length !== 1) {
237
+ throw structuredOutputError(role, blocks.length === 0 ? "must use a JSON fenced block" : "must appear exactly once");
238
+ }
239
+ let raw: unknown;
240
+ try {
241
+ raw = JSON.parse(blocks[0]?.[1] ?? "");
242
+ } catch {
243
+ throw structuredOutputError(role, "contains invalid JSON");
244
+ }
245
+ const structured = parseStructuredRoleData(role, phase, raw);
246
+ return {
247
+ format: "structured",
248
+ markdown: output.slice(0, blocks[0]?.index).concat(output.slice((blocks[0]?.index ?? 0) + (blocks[0]?.[0].length ?? 0))).trim(),
249
+ structured,
250
+ questions: structured.questions?.map((question) => question.prompt),
251
+ reviewDecision: structured.reviewDecision,
252
+ };
253
+ }
254
+
104
255
  export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">): string {
105
256
  const executionMode = input.executionMode ?? "plan";
106
257
  const answers = input.answers ?? [];
@@ -148,6 +299,13 @@ export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">):
148
299
  "Keep scope aligned with the approved gates and the selected DevCrew execution policy.",
149
300
  permissionInstruction,
150
301
  "",
302
+ "Return this protocol block first:",
303
+ STRUCTURED_RESULT_MARKER,
304
+ "```json",
305
+ `{\"schemaVersion\":1,\"role\":\"${input.role}\",\"summary\":\"...\",\"risks\":[],\"evidence\":[]}`,
306
+ "```",
307
+ "Then return the required Markdown H2 sections. Do not include a second marked result block.",
308
+ "",
151
309
  "Required Sections:",
152
310
  ...roleGuidance(input.role),
153
311
  );
@@ -410,6 +568,7 @@ function fallbackResult(
410
568
  summary,
411
569
  markdown: `# ${title}\n\n${summary}\n`,
412
570
  usedFallback: true,
571
+ format: "legacy",
413
572
  };
414
573
  }
415
574
 
@@ -443,18 +602,21 @@ export async function runRole(input: RoleRunInput, deps: RunRoleDeps = {}): Prom
443
602
  input.backend === "codex"
444
603
  ? await runWithCodex(input, prompt, loadModule)
445
604
  : await runWithClaude(input, prompt, loadModule);
446
- assertRoleSections(input.role, markdown);
447
- const reviewDecision = input.phase === "review" ? extractArchitectureReviewDecision(markdown) : undefined;
605
+ const parsed = parseRoleResultOutput(input.role, input.phase, markdown);
606
+ assertRoleSections(input.role, parsed.markdown);
607
+ const reviewDecision = parsed.reviewDecision;
448
608
  if (input.phase === "review" && !reviewDecision) {
449
609
  throw new RoleOutputValidationError(input.role, ["Review Decision"]);
450
610
  }
451
611
  return {
452
612
  role: input.role,
453
613
  backend: input.backend,
454
- summary: `${input.role} produced ${title} using the ${input.backend} SDK.`,
455
- markdown,
614
+ summary: parsed.structured?.summary ?? `${input.role} produced ${title} using the ${input.backend} SDK.`,
615
+ markdown: parsed.markdown,
456
616
  usedFallback: false,
457
- questions: input.role === "pm" ? extractOpenQuestions(markdown) : undefined,
617
+ format: parsed.format,
618
+ structured: parsed.structured,
619
+ questions: parsed.questions,
458
620
  reviewDecision,
459
621
  };
460
622
  } 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
@@ -35,13 +35,47 @@ export type Phase = (typeof PHASES)[number];
35
35
  export type GateName = (typeof GATES)[number];
36
36
  export type ArtifactName = (typeof ARTIFACTS)[number];
37
37
  export type GateStatus = "not_started" | "pending" | "approved" | "rejected";
38
- export type RunStatus = "ready" | "awaiting_input" | "awaiting_approval" | "awaiting_execution" | "complete";
38
+ export type RunStatus = "ready" | "awaiting_input" | "awaiting_approval" | "awaiting_execution" | "complete" | "aborted";
39
39
 
40
40
  export interface RoleSection {
41
41
  heading: string;
42
42
  description: string;
43
43
  }
44
44
 
45
+ export type RoleResultFormat = "legacy" | "structured";
46
+
47
+ export interface CommandEvidence {
48
+ command: string;
49
+ exitCode: number;
50
+ output?: string;
51
+ }
52
+
53
+ export interface RoleQuestion {
54
+ id: string;
55
+ prompt: string;
56
+ context?: string;
57
+ }
58
+
59
+ export interface TestCaseEvidence {
60
+ id: string;
61
+ scenario: string;
62
+ type: "happy" | "edge" | "failure" | "regression";
63
+ expected: string;
64
+ }
65
+
66
+ export interface StructuredRoleData {
67
+ schemaVersion: 1;
68
+ role: "pm" | "architect" | "implementer" | "tester";
69
+ summary: string;
70
+ risks: string[];
71
+ evidence: CommandEvidence[];
72
+ questions?: RoleQuestion[];
73
+ decisions?: string[];
74
+ reviewDecision?: ArchitectureReviewDecision;
75
+ changedFiles?: string[];
76
+ testCases?: TestCaseEvidence[];
77
+ }
78
+
45
79
  export const ROLE_SECTIONS: Record<Exclude<RoleResult["role"], "conductor">, RoleSection[]> = {
46
80
  pm: [
47
81
  { heading: "Functional Scope", description: "explicit In Scope and Out of Scope lists" },
@@ -94,6 +128,8 @@ export interface RoleResult {
94
128
  summary: string;
95
129
  markdown: string;
96
130
  usedFallback: boolean;
131
+ format: RoleResultFormat;
132
+ structured?: StructuredRoleData;
97
133
  questions?: string[];
98
134
  reviewDecision?: ArchitectureReviewDecision;
99
135
  }
@@ -126,6 +162,11 @@ export interface VerificationWaiver {
126
162
  createdAt: string;
127
163
  }
128
164
 
165
+ export interface RunAbort {
166
+ reason: string;
167
+ abortedAt: string;
168
+ }
169
+
129
170
  // Reused for both verification and lint results — the command shape is identical.
130
171
  export interface VerificationResult {
131
172
  command: string;
@@ -179,6 +220,7 @@ export interface RunState {
179
220
  verification: VerificationResult[];
180
221
  verificationStatus: VerificationStatus;
181
222
  verificationWaiver?: VerificationWaiver;
223
+ abort?: RunAbort;
182
224
  // VerificationResult is reused for lint output — same shape, different semantics.
183
225
  lintResults: VerificationResult[];
184
226
  }
@@ -216,6 +258,10 @@ export interface WaiveVerificationInput extends RunRef {
216
258
  reason: string;
217
259
  }
218
260
 
261
+ export interface AbortWorkflowInput extends RunRef {
262
+ reason: string;
263
+ }
264
+
219
265
  export interface CompleteExecutionInput extends RunRef {
220
266
  summary: string;
221
267
  verification?: VerificationResult[];
@@ -1,2 +1,2 @@
1
- export const DEVCREW_VERSION = "0.1.3";
1
+ export const DEVCREW_VERSION = "0.1.4";
2
2
  export const DEVCREW_NPM_PACKAGE = "@shenlee/devcrew";