@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/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Restart Codex, open the plugin directory, choose the DevCrew marketplace, and in
|
|
|
35
35
|
The plugin starts the DevCrew MCP server with:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
npm exec --silent --yes --package=@shenlee/devcrew@0.1.
|
|
38
|
+
npm exec --silent --yes --package=@shenlee/devcrew@0.1.4 -- node -e "<DevCrew CLI wrapper>" -- serve --stdio
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
The plugin locks the MCP server to the published npm package version, so users do not need to clone the source or build TypeScript at install time. You only need Node.js and network access the first time Codex starts the MCP server.
|
package/README.zh-CN.md
CHANGED
|
@@ -35,7 +35,7 @@ codex plugin marketplace add lishen802/devcrew
|
|
|
35
35
|
插件会用下面的命令启动 DevCrew MCP 服务:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
npm exec --silent --yes --package=@shenlee/devcrew@0.1.
|
|
38
|
+
npm exec --silent --yes --package=@shenlee/devcrew@0.1.4 -- node -e "<DevCrew CLI wrapper>" -- serve --stdio
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
插件会锁定到已发布的 npm 包版本,因此用户不需要克隆源码,也不需要在安装时编译 TypeScript;只需要本机有 Node.js,并且 Codex 第一次启动 MCP 服务时可以访问网络。
|
|
@@ -60,6 +60,143 @@ export function extractArchitectureReviewDecision(markdown) {
|
|
|
60
60
|
const match = /^Decision:\s*(approved|changes_required)\s*$/im.exec(section);
|
|
61
61
|
return match?.[1];
|
|
62
62
|
}
|
|
63
|
+
const STRUCTURED_RESULT_MARKER = "<!-- devcrew-role-result -->";
|
|
64
|
+
const STRUCTURED_RESULT_BLOCK = /<!--\s*devcrew-role-result\s*-->\s*```json\s*\r?\n([\s\S]*?)\r?\n```/g;
|
|
65
|
+
function structuredOutputError(role, reason) {
|
|
66
|
+
return new RoleOutputValidationError(role, [`marked structured role result: ${reason}`]);
|
|
67
|
+
}
|
|
68
|
+
function asRecord(value, role, field) {
|
|
69
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
70
|
+
throw structuredOutputError(role, `${field} must be an object`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function asNonEmptyString(value, role, field) {
|
|
75
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
76
|
+
throw structuredOutputError(role, `${field} must be a non-empty string`);
|
|
77
|
+
}
|
|
78
|
+
return value.trim();
|
|
79
|
+
}
|
|
80
|
+
function asStringArray(value, role, field) {
|
|
81
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim().length === 0)) {
|
|
82
|
+
throw structuredOutputError(role, `${field} must be an array of non-empty strings`);
|
|
83
|
+
}
|
|
84
|
+
return value.map((entry) => entry.trim());
|
|
85
|
+
}
|
|
86
|
+
function asEvidence(value, role) {
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
throw structuredOutputError(role, "evidence must be an array");
|
|
89
|
+
}
|
|
90
|
+
return value.map((entry, index) => {
|
|
91
|
+
const evidence = asRecord(entry, role, `evidence[${index}]`);
|
|
92
|
+
const command = asNonEmptyString(evidence.command, role, `evidence[${index}].command`);
|
|
93
|
+
if (!Number.isInteger(evidence.exitCode)) {
|
|
94
|
+
throw structuredOutputError(role, `evidence[${index}].exitCode must be an integer`);
|
|
95
|
+
}
|
|
96
|
+
if (evidence.output !== undefined && typeof evidence.output !== "string") {
|
|
97
|
+
throw structuredOutputError(role, `evidence[${index}].output must be a string`);
|
|
98
|
+
}
|
|
99
|
+
return { command, exitCode: evidence.exitCode, output: evidence.output };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function asQuestions(value, role) {
|
|
103
|
+
if (!Array.isArray(value)) {
|
|
104
|
+
throw structuredOutputError(role, "questions must be an array");
|
|
105
|
+
}
|
|
106
|
+
const ids = new Set();
|
|
107
|
+
return value.map((entry, index) => {
|
|
108
|
+
const question = asRecord(entry, role, `questions[${index}]`);
|
|
109
|
+
const id = asNonEmptyString(question.id, role, `questions[${index}].id`);
|
|
110
|
+
if (ids.has(id)) {
|
|
111
|
+
throw structuredOutputError(role, `questions[${index}].id must be unique`);
|
|
112
|
+
}
|
|
113
|
+
ids.add(id);
|
|
114
|
+
const prompt = asNonEmptyString(question.prompt, role, `questions[${index}].prompt`);
|
|
115
|
+
if (question.context !== undefined && typeof question.context !== "string") {
|
|
116
|
+
throw structuredOutputError(role, `questions[${index}].context must be a string`);
|
|
117
|
+
}
|
|
118
|
+
return { id, prompt, context: question.context };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
function parseStructuredRoleData(role, phase, raw) {
|
|
122
|
+
const value = asRecord(raw, role, "result");
|
|
123
|
+
if (value.schemaVersion !== 1) {
|
|
124
|
+
throw structuredOutputError(role, "schemaVersion must be 1");
|
|
125
|
+
}
|
|
126
|
+
if (value.role !== role) {
|
|
127
|
+
throw structuredOutputError(role, `role must be ${role}`);
|
|
128
|
+
}
|
|
129
|
+
const data = {
|
|
130
|
+
schemaVersion: 1,
|
|
131
|
+
role,
|
|
132
|
+
summary: asNonEmptyString(value.summary, role, "summary"),
|
|
133
|
+
risks: asStringArray(value.risks, role, "risks"),
|
|
134
|
+
evidence: asEvidence(value.evidence, role),
|
|
135
|
+
};
|
|
136
|
+
if (role === "pm") {
|
|
137
|
+
data.questions = asQuestions(value.questions, role);
|
|
138
|
+
}
|
|
139
|
+
else if (role === "architect") {
|
|
140
|
+
data.decisions = asStringArray(value.decisions, role, "decisions");
|
|
141
|
+
if (phase === "review") {
|
|
142
|
+
if (value.reviewDecision !== "approved" && value.reviewDecision !== "changes_required") {
|
|
143
|
+
throw structuredOutputError(role, "reviewDecision must be approved or changes_required");
|
|
144
|
+
}
|
|
145
|
+
data.reviewDecision = value.reviewDecision;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else if (role === "implementer") {
|
|
149
|
+
data.changedFiles = asStringArray(value.changedFiles, role, "changedFiles");
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
if (!Array.isArray(value.testCases)) {
|
|
153
|
+
throw structuredOutputError(role, "testCases must be an array");
|
|
154
|
+
}
|
|
155
|
+
data.testCases = value.testCases.map((entry, index) => {
|
|
156
|
+
const testCase = asRecord(entry, role, `testCases[${index}]`);
|
|
157
|
+
const type = testCase.type;
|
|
158
|
+
if (type !== "happy" && type !== "edge" && type !== "failure" && type !== "regression") {
|
|
159
|
+
throw structuredOutputError(role, `testCases[${index}].type is invalid`);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
id: asNonEmptyString(testCase.id, role, `testCases[${index}].id`),
|
|
163
|
+
scenario: asNonEmptyString(testCase.scenario, role, `testCases[${index}].scenario`),
|
|
164
|
+
type,
|
|
165
|
+
expected: asNonEmptyString(testCase.expected, role, `testCases[${index}].expected`),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return data;
|
|
170
|
+
}
|
|
171
|
+
export function parseRoleResultOutput(role, phase, output) {
|
|
172
|
+
if (!output.includes(STRUCTURED_RESULT_MARKER)) {
|
|
173
|
+
return {
|
|
174
|
+
format: "legacy",
|
|
175
|
+
markdown: output,
|
|
176
|
+
questions: role === "pm" ? extractOpenQuestions(output) : undefined,
|
|
177
|
+
reviewDecision: phase === "review" ? extractArchitectureReviewDecision(output) : undefined,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const blocks = [...output.matchAll(STRUCTURED_RESULT_BLOCK)];
|
|
181
|
+
if (blocks.length !== 1) {
|
|
182
|
+
throw structuredOutputError(role, blocks.length === 0 ? "must use a JSON fenced block" : "must appear exactly once");
|
|
183
|
+
}
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = JSON.parse(blocks[0]?.[1] ?? "");
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw structuredOutputError(role, "contains invalid JSON");
|
|
190
|
+
}
|
|
191
|
+
const structured = parseStructuredRoleData(role, phase, raw);
|
|
192
|
+
return {
|
|
193
|
+
format: "structured",
|
|
194
|
+
markdown: output.slice(0, blocks[0]?.index).concat(output.slice((blocks[0]?.index ?? 0) + (blocks[0]?.[0].length ?? 0))).trim(),
|
|
195
|
+
structured,
|
|
196
|
+
questions: structured.questions?.map((question) => question.prompt),
|
|
197
|
+
reviewDecision: structured.reviewDecision,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
63
200
|
export function renderRolePrompt(input) {
|
|
64
201
|
const executionMode = input.executionMode ?? "plan";
|
|
65
202
|
const answers = input.answers ?? [];
|
|
@@ -95,7 +232,7 @@ export function renderRolePrompt(input) {
|
|
|
95
232
|
? "You may run validation commands needed for the approved scope and report exact evidence."
|
|
96
233
|
: "You may modify repository files needed for the approved scope and report changed files."
|
|
97
234
|
: "Do not modify repository files. Return only the Markdown document content for the artifact.";
|
|
98
|
-
lines.push("", "Instructions:", `Act as the DevCrew ${input.role} role and produce a complete, well-structured Markdown document for the ${input.phase} phase.`, "Keep scope aligned with the approved gates and the selected DevCrew execution policy.", permissionInstruction, "", "Required Sections:", ...roleGuidance(input.role));
|
|
235
|
+
lines.push("", "Instructions:", `Act as the DevCrew ${input.role} role and produce a complete, well-structured Markdown document for the ${input.phase} phase.`, "Keep scope aligned with the approved gates and the selected DevCrew execution policy.", permissionInstruction, "", "Return this protocol block first:", STRUCTURED_RESULT_MARKER, "```json", `{\"schemaVersion\":1,\"role\":\"${input.role}\",\"summary\":\"...\",\"risks\":[],\"evidence\":[]}`, "```", "Then return the required Markdown H2 sections. Do not include a second marked result block.", "", "Required Sections:", ...roleGuidance(input.role));
|
|
99
236
|
if (input.phase === "review") {
|
|
100
237
|
lines.push("", "Architecture Review Decision:", "Add an exact H2 `## Review Decision` section containing exactly `Decision: approved` or `Decision: changes_required`.", "Use `changes_required` for any mismatch with the approved architecture; summarize the blocking mismatch in that section.");
|
|
101
238
|
}
|
|
@@ -238,6 +375,7 @@ function fallbackResult(role, backend, title, summary) {
|
|
|
238
375
|
summary,
|
|
239
376
|
markdown: `# ${title}\n\n${summary}\n`,
|
|
240
377
|
usedFallback: true,
|
|
378
|
+
format: "legacy",
|
|
241
379
|
};
|
|
242
380
|
}
|
|
243
381
|
function shouldFailOnSdkError(input) {
|
|
@@ -257,18 +395,21 @@ export async function runRole(input, deps = {}) {
|
|
|
257
395
|
const markdown = input.backend === "codex"
|
|
258
396
|
? await runWithCodex(input, prompt, loadModule)
|
|
259
397
|
: await runWithClaude(input, prompt, loadModule);
|
|
260
|
-
|
|
261
|
-
|
|
398
|
+
const parsed = parseRoleResultOutput(input.role, input.phase, markdown);
|
|
399
|
+
assertRoleSections(input.role, parsed.markdown);
|
|
400
|
+
const reviewDecision = parsed.reviewDecision;
|
|
262
401
|
if (input.phase === "review" && !reviewDecision) {
|
|
263
402
|
throw new RoleOutputValidationError(input.role, ["Review Decision"]);
|
|
264
403
|
}
|
|
265
404
|
return {
|
|
266
405
|
role: input.role,
|
|
267
406
|
backend: input.backend,
|
|
268
|
-
summary: `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
269
|
-
markdown,
|
|
407
|
+
summary: parsed.structured?.summary ?? `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
408
|
+
markdown: parsed.markdown,
|
|
270
409
|
usedFallback: false,
|
|
271
|
-
|
|
410
|
+
format: parsed.format,
|
|
411
|
+
structured: parsed.structured,
|
|
412
|
+
questions: parsed.questions,
|
|
272
413
|
reviewDecision,
|
|
273
414
|
};
|
|
274
415
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { activeRunPath, ensureProjectDirectories } from "./paths.js";
|
|
3
3
|
export async function setActiveRun(cwd, runId) {
|
|
4
4
|
await ensureProjectDirectories(cwd);
|
|
@@ -22,3 +22,15 @@ export async function getActiveRunId(cwd) {
|
|
|
22
22
|
}
|
|
23
23
|
throw new Error("No active DevCrew run. Pass runId or start a workflow first.");
|
|
24
24
|
}
|
|
25
|
+
export async function clearActiveRunIfMatches(cwd, runId) {
|
|
26
|
+
try {
|
|
27
|
+
if ((await getActiveRunId(cwd)) !== runId) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
await unlink(activeRunPath(cwd));
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
3
|
import { configPath, ensureProjectDirectories } from "./paths.js";
|
|
4
|
+
import { BACKENDS, EXECUTION_MODES, GATES } from "./types.js";
|
|
3
5
|
export const DEFAULT_CONFIG = {
|
|
4
6
|
version: 1,
|
|
5
7
|
defaultBackend: "host-preferred",
|
|
@@ -21,6 +23,90 @@ async function exists(path) {
|
|
|
21
23
|
return false;
|
|
22
24
|
}
|
|
23
25
|
}
|
|
26
|
+
function asRecord(value, field) {
|
|
27
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
28
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be an object`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function assertKnownKeys(value, field, allowed) {
|
|
33
|
+
for (const key of Object.keys(value)) {
|
|
34
|
+
if (!allowed.includes(key)) {
|
|
35
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} has unknown key ${key}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function requiredString(value, field) {
|
|
40
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
41
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
return value.trim();
|
|
44
|
+
}
|
|
45
|
+
function parseCommandList(value, field) {
|
|
46
|
+
if (value === undefined) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!Array.isArray(value)) {
|
|
50
|
+
throw new Error(`Invalid .devcrew/config.json: ${field} must be an array`);
|
|
51
|
+
}
|
|
52
|
+
return value.map((entry, index) => requiredString(entry, `${field}[${index}]`));
|
|
53
|
+
}
|
|
54
|
+
function parseGates(value) {
|
|
55
|
+
if (!Array.isArray(value)) {
|
|
56
|
+
throw new Error("Invalid .devcrew/config.json: workflow.gates must be an array");
|
|
57
|
+
}
|
|
58
|
+
const gates = [];
|
|
59
|
+
for (const [index, entry] of value.entries()) {
|
|
60
|
+
if (typeof entry !== "string" || !GATES.includes(entry)) {
|
|
61
|
+
throw new Error(`Invalid .devcrew/config.json: workflow.gates[${index}] must be a known gate, received ${String(entry)}`);
|
|
62
|
+
}
|
|
63
|
+
if (gates.includes(entry)) {
|
|
64
|
+
throw new Error(`Invalid .devcrew/config.json: workflow.gates contains duplicate ${entry}`);
|
|
65
|
+
}
|
|
66
|
+
gates.push(entry);
|
|
67
|
+
}
|
|
68
|
+
return gates;
|
|
69
|
+
}
|
|
70
|
+
function parseArtifactDirectory(cwd, value) {
|
|
71
|
+
const artifactDirectory = requiredString(value, "workflow.artifactDirectory");
|
|
72
|
+
if (isAbsolute(artifactDirectory)) {
|
|
73
|
+
throw new Error("Invalid .devcrew/config.json: workflow.artifactDirectory must be relative to the repository");
|
|
74
|
+
}
|
|
75
|
+
const projectRoot = resolve(cwd);
|
|
76
|
+
const target = resolve(projectRoot, artifactDirectory);
|
|
77
|
+
const fromProject = relative(projectRoot, target);
|
|
78
|
+
if (fromProject === ".." || fromProject.startsWith(`..${sep}`) || isAbsolute(fromProject)) {
|
|
79
|
+
throw new Error("Invalid .devcrew/config.json: workflow.artifactDirectory must resolve inside the repository");
|
|
80
|
+
}
|
|
81
|
+
return artifactDirectory;
|
|
82
|
+
}
|
|
83
|
+
function parseConfig(cwd, value) {
|
|
84
|
+
const parsed = asRecord(value, "root");
|
|
85
|
+
assertKnownKeys(parsed, "root", ["version", "defaultBackend", "executionMode", "verifyCommands", "lintCommands", "coverageCommands", "workflow"]);
|
|
86
|
+
if (parsed.version !== 1) {
|
|
87
|
+
throw new Error("Unsupported .devcrew/config.json version");
|
|
88
|
+
}
|
|
89
|
+
if (typeof parsed.defaultBackend !== "string" || (parsed.defaultBackend !== "host-preferred" && !BACKENDS.includes(parsed.defaultBackend))) {
|
|
90
|
+
throw new Error("Invalid .devcrew/config.json: defaultBackend must be host-preferred, codex, claude, or local");
|
|
91
|
+
}
|
|
92
|
+
if (typeof parsed.executionMode !== "string" || !EXECUTION_MODES.includes(parsed.executionMode)) {
|
|
93
|
+
throw new Error("Invalid .devcrew/config.json: executionMode must be plan or apply");
|
|
94
|
+
}
|
|
95
|
+
const workflow = asRecord(parsed.workflow, "workflow");
|
|
96
|
+
assertKnownKeys(workflow, "workflow", ["gates", "artifactDirectory"]);
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
defaultBackend: parsed.defaultBackend,
|
|
100
|
+
executionMode: parsed.executionMode,
|
|
101
|
+
verifyCommands: parseCommandList(parsed.verifyCommands, "verifyCommands"),
|
|
102
|
+
lintCommands: parseCommandList(parsed.lintCommands, "lintCommands"),
|
|
103
|
+
coverageCommands: parseCommandList(parsed.coverageCommands, "coverageCommands"),
|
|
104
|
+
workflow: {
|
|
105
|
+
gates: parseGates(workflow.gates),
|
|
106
|
+
artifactDirectory: parseArtifactDirectory(cwd, workflow.artifactDirectory),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
24
110
|
export async function ensureConfig(cwd) {
|
|
25
111
|
await ensureProjectDirectories(cwd);
|
|
26
112
|
const path = configPath(cwd);
|
|
@@ -32,20 +118,5 @@ export async function ensureConfig(cwd) {
|
|
|
32
118
|
}
|
|
33
119
|
export async function readConfig(cwd) {
|
|
34
120
|
const raw = await readFile(configPath(cwd), "utf8");
|
|
35
|
-
|
|
36
|
-
if (parsed.version !== 1) {
|
|
37
|
-
throw new Error("Unsupported .devcrew/config.json version");
|
|
38
|
-
}
|
|
39
|
-
return {
|
|
40
|
-
...DEFAULT_CONFIG,
|
|
41
|
-
...parsed,
|
|
42
|
-
executionMode: parsed.executionMode ?? "plan",
|
|
43
|
-
verifyCommands: Array.isArray(parsed.verifyCommands) ? parsed.verifyCommands : [],
|
|
44
|
-
lintCommands: Array.isArray(parsed.lintCommands) ? parsed.lintCommands : [],
|
|
45
|
-
coverageCommands: Array.isArray(parsed.coverageCommands) ? parsed.coverageCommands : [],
|
|
46
|
-
workflow: {
|
|
47
|
-
...DEFAULT_CONFIG.workflow,
|
|
48
|
-
...parsed.workflow,
|
|
49
|
-
},
|
|
50
|
-
};
|
|
121
|
+
return parseConfig(cwd, JSON.parse(raw));
|
|
51
122
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { devcrewDir, repositoryLockPath } from "./paths.js";
|
|
4
|
+
function metadataPath(cwd) {
|
|
5
|
+
return `${repositoryLockPath(cwd)}/lock.json`;
|
|
6
|
+
}
|
|
7
|
+
async function readMetadata(cwd) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(await readFile(metadataPath(cwd), "utf8"));
|
|
10
|
+
const pid = parsed.pid;
|
|
11
|
+
if (typeof parsed.ownerId !== "string" ||
|
|
12
|
+
typeof pid !== "number" ||
|
|
13
|
+
!Number.isInteger(pid) ||
|
|
14
|
+
typeof parsed.createdAt !== "string") {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
ownerId: parsed.ownerId,
|
|
19
|
+
pid,
|
|
20
|
+
createdAt: parsed.createdAt,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function processIsLive(pid) {
|
|
28
|
+
if (pid <= 0) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return error.code === "EPERM";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function releaseRepositoryLock(cwd, ownerId) {
|
|
40
|
+
const metadata = await readMetadata(cwd);
|
|
41
|
+
if (metadata?.ownerId === ownerId) {
|
|
42
|
+
await rm(repositoryLockPath(cwd), { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function withRepositoryLock(cwd, action) {
|
|
46
|
+
await mkdir(devcrewDir(cwd), { recursive: true });
|
|
47
|
+
const ownerId = randomUUID();
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(repositoryLockPath(cwd));
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "EEXIST") {
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
const metadata = await readMetadata(cwd);
|
|
56
|
+
if (metadata && processIsLive(metadata.pid)) {
|
|
57
|
+
throw new Error("A DevCrew operation is already in progress for this repository");
|
|
58
|
+
}
|
|
59
|
+
throw new Error("A stale DevCrew repository lock exists; use devcrew_recover for explicit recovery");
|
|
60
|
+
}
|
|
61
|
+
await writeFile(metadataPath(cwd), `${JSON.stringify({ ownerId, pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
|
|
62
|
+
try {
|
|
63
|
+
return await action();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await releaseRepositoryLock(cwd, ownerId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function recoverRepositoryLock(cwd) {
|
|
70
|
+
const lockPath = repositoryLockPath(cwd);
|
|
71
|
+
const metadata = await readMetadata(cwd);
|
|
72
|
+
if (!metadata) {
|
|
73
|
+
try {
|
|
74
|
+
await rm(lockPath, { recursive: true, force: false });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error.code === "ENOENT") {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (processIsLive(metadata.pid)) {
|
|
85
|
+
throw new Error("Cannot recover a DevCrew repository lock held by a live process");
|
|
86
|
+
}
|
|
87
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
@@ -21,6 +21,9 @@ export function configPath(cwd) {
|
|
|
21
21
|
export function activeRunPath(cwd) {
|
|
22
22
|
return join(devcrewDir(cwd), "active-run.json");
|
|
23
23
|
}
|
|
24
|
+
export function repositoryLockPath(cwd) {
|
|
25
|
+
return join(devcrewDir(cwd), "lock");
|
|
26
|
+
}
|
|
24
27
|
export function standardsPath(cwd) {
|
|
25
28
|
return join(devcrewDir(cwd), "standards.md");
|
|
26
29
|
}
|
|
@@ -8,6 +8,15 @@ function enabledGatesFromState(value) {
|
|
|
8
8
|
: [...GATES];
|
|
9
9
|
return [...new Set([...configured, "implementation-review", "testing"])];
|
|
10
10
|
}
|
|
11
|
+
function roleResultsFromState(value) {
|
|
12
|
+
if (!Array.isArray(value)) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return value.filter((role) => typeof role === "object" && role !== null).map((role) => ({
|
|
16
|
+
...role,
|
|
17
|
+
format: role.format === "structured" ? "structured" : "legacy",
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
11
20
|
export async function saveState(state) {
|
|
12
21
|
state.updatedAt = new Date().toISOString();
|
|
13
22
|
await ensureRunDirectories(state.cwd, state.runId);
|
|
@@ -27,6 +36,7 @@ export async function loadState(cwd, runId) {
|
|
|
27
36
|
...parsed,
|
|
28
37
|
executionMode: parsed.executionMode ?? "plan",
|
|
29
38
|
executionPolicy: parsed.executionPolicy ?? "interactive-host",
|
|
39
|
+
roles: roleResultsFromState(parsed.roles),
|
|
30
40
|
enabledGates: enabledGatesFromState(parsed.enabledGates),
|
|
31
41
|
artifactDirectory: typeof parsed.artifactDirectory === "string" && parsed.artifactDirectory.trim().length > 0
|
|
32
42
|
? parsed.artifactDirectory
|
|
@@ -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";
|
|
@@ -135,7 +135,8 @@ export async function continueWorkflow(input) {
|
|
|
135
135
|
if (state.status === "awaiting_approval" ||
|
|
136
136
|
state.status === "awaiting_input" ||
|
|
137
137
|
state.status === "awaiting_execution" ||
|
|
138
|
-
state.status === "complete"
|
|
138
|
+
state.status === "complete" ||
|
|
139
|
+
state.status === "aborted") {
|
|
139
140
|
return state;
|
|
140
141
|
}
|
|
141
142
|
if (state.phase === "acceptance") {
|
|
@@ -163,6 +164,22 @@ export async function continueWorkflow(input) {
|
|
|
163
164
|
await writeCurrentArtifact(state);
|
|
164
165
|
return saveState(state);
|
|
165
166
|
}
|
|
167
|
+
export async function abortWorkflow(input) {
|
|
168
|
+
const state = await getWorkflowStatus(input);
|
|
169
|
+
if (state.status === "aborted") {
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
if (state.status === "complete") {
|
|
173
|
+
throw new Error("Completed DevCrew runs cannot be aborted");
|
|
174
|
+
}
|
|
175
|
+
state.abort = {
|
|
176
|
+
reason: parseWaiverReason(input.reason),
|
|
177
|
+
abortedAt: now(),
|
|
178
|
+
};
|
|
179
|
+
delete state.executionInstruction;
|
|
180
|
+
state.status = "aborted";
|
|
181
|
+
return saveState(state);
|
|
182
|
+
}
|
|
166
183
|
export async function validateWorkflowApproval(input) {
|
|
167
184
|
const state = await getWorkflowStatus(input);
|
|
168
185
|
const gate = parseGate(input.gate);
|
|
@@ -238,8 +255,8 @@ export async function waiveVerificationWorkflow(input) {
|
|
|
238
255
|
state.phase !== "testing" ||
|
|
239
256
|
state.status !== "awaiting_input" ||
|
|
240
257
|
state.gates.testing !== "rejected" ||
|
|
241
|
-
state.verificationStatus !== "failed") {
|
|
242
|
-
throw new Error("A verification waiver is only available after failed apply-mode
|
|
258
|
+
(state.verificationStatus !== "failed" && state.verificationStatus !== "not_run")) {
|
|
259
|
+
throw new Error("A verification waiver is only available after failed or missing apply-mode verification");
|
|
243
260
|
}
|
|
244
261
|
state.verificationWaiver = {
|
|
245
262
|
reason: parseWaiverReason(input.reason),
|