@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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/packages/adapters/src/index.js +147 -6
- 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/superpowers/plans/2026-07-14-p0-remediation.md +213 -0
- package/docs/superpowers/plans/2026-07-14-reliability-recovery.md +208 -0
- package/docs/superpowers/plans/2026-07-14-structured-role-results.md +231 -0
- package/docs/superpowers/specs/2026-07-14-p0-remediation-design.md +52 -0
- package/docs/superpowers/specs/2026-07-14-reliability-recovery-design.md +92 -0
- package/docs/superpowers/specs/2026-07-14-structured-role-results-design.md +96 -0
- package/docs/workflow.md +11 -2
- package/package.json +1 -1
- package/packages/adapters/src/index.ts +168 -6
- 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/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
|
|
49
|
-
|
|
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
|
@@ -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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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.
|
|
1
|
+
export const DEVCREW_VERSION = "0.1.4";
|
|
2
2
|
export const DEVCREW_NPM_PACKAGE = "@shenlee/devcrew";
|