@shenlee/devcrew 0.1.2 → 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 +15 -7
- package/README.zh-CN.md +12 -7
- package/dist/packages/adapters/src/index.js +204 -10
- package/dist/packages/core/src/active-run.js +13 -1
- package/dist/packages/core/src/artifacts.js +14 -4
- package/dist/packages/core/src/config.js +88 -17
- 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 +10 -6
- package/dist/packages/core/src/store.js +40 -0
- package/dist/packages/core/src/types.js +4 -1
- package/dist/packages/core/src/validation.js +7 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/packages/core/src/workflow.js +87 -12
- package/dist/packages/orchestrator/src/index.js +317 -18
- package/dist/packages/plugins/src/index.js +2 -32
- package/dist/packages/service/src/tools.js +117 -17
- package/docs/claude-code.md +18 -3
- package/docs/codex.md +22 -5
- package/docs/quickstart.md +1 -1
- package/docs/superpowers/plans/2026-07-13-safety-semantics.md +313 -0
- 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-13-execution-boundaries-design.md +126 -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 +28 -4
- package/package.json +1 -1
- package/packages/adapters/src/index.ts +250 -13
- package/packages/core/src/active-run.ts +13 -1
- package/packages/core/src/artifacts.ts +16 -4
- package/packages/core/src/config.ts +97 -18
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/lock.ts +104 -0
- package/packages/core/src/paths.ts +11 -6
- package/packages/core/src/store.ts +45 -1
- package/packages/core/src/types.ts +92 -2
- package/packages/core/src/validation.ts +10 -0
- package/packages/core/src/version.ts +1 -1
- package/packages/core/src/workflow.ts +101 -11
- package/packages/orchestrator/src/index.ts +349 -19
- package/packages/plugins/src/index.ts +2 -44
- package/packages/service/src/tools.ts +126 -15
- package/plugins/devcrew-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/devcrew-codex/.mcp.json +1 -1
- package/plugins/devcrew-codex/skills/devcrew/SKILL.md +8 -7
- package/scripts/smoke-codex-plugin.mjs +1 -1
- package/plugins/devcrew-codex/agents/architect.toml +0 -12
- package/plugins/devcrew-codex/agents/implementer.toml +0 -12
- package/plugins/devcrew-codex/agents/pm.toml +0 -13
- package/plugins/devcrew-codex/agents/tester.toml +0 -12
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { DEVCREW_NPM_PACKAGE, ROLE_SECTIONS } from "../../core/src/index.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ArtifactName,
|
|
4
|
+
BackendName,
|
|
5
|
+
ExecutionMode,
|
|
6
|
+
ExecutionPolicy,
|
|
7
|
+
Host,
|
|
8
|
+
Phase,
|
|
9
|
+
StructuredRoleData,
|
|
10
|
+
RoleResult,
|
|
11
|
+
RunState,
|
|
12
|
+
WorkflowMode,
|
|
13
|
+
} from "../../core/src/index.js";
|
|
3
14
|
|
|
4
15
|
export interface BackendResolutionInput {
|
|
5
16
|
host: Host;
|
|
@@ -8,11 +19,12 @@ export interface BackendResolutionInput {
|
|
|
8
19
|
|
|
9
20
|
export interface RoleRunInput {
|
|
10
21
|
backend: BackendName;
|
|
11
|
-
role: RoleResult["role"]
|
|
22
|
+
role: Exclude<RoleResult["role"], "conductor">;
|
|
12
23
|
phase: Phase;
|
|
13
24
|
request: string;
|
|
14
25
|
mode: WorkflowMode;
|
|
15
26
|
executionMode?: ExecutionMode;
|
|
27
|
+
executionPolicy?: ExecutionPolicy;
|
|
16
28
|
cwd: string;
|
|
17
29
|
standards: string;
|
|
18
30
|
artifactPath: string;
|
|
@@ -69,6 +81,177 @@ export function assertRoleSections(role: RoleResult["role"], markdown: string):
|
|
|
69
81
|
}
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
export function extractOpenQuestions(markdown: string): string[] {
|
|
85
|
+
const match = /^##\s+Open Questions\s*$(.*?)(?=^##\s|(?![\s\S]))/ims.exec(markdown);
|
|
86
|
+
if (!match) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const questions: string[] = [];
|
|
90
|
+
for (const line of match[1].split("\n")) {
|
|
91
|
+
const question = /^\s*[-*]\s+(.+?)\s*$/u.exec(line)?.[1]?.trim();
|
|
92
|
+
if (question && !/^(none|n\/a|no open questions)\.?$/i.test(question)) {
|
|
93
|
+
questions.push(question);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return questions;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractArchitectureReviewDecision(markdown: string): "approved" | "changes_required" | undefined {
|
|
100
|
+
const section = /^##\s+Review Decision\s*$(.*?)(?=^##\s|(?![\s\S]))/ims.exec(markdown)?.[1] ?? "";
|
|
101
|
+
const match = /^Decision:\s*(approved|changes_required)\s*$/im.exec(section);
|
|
102
|
+
return match?.[1] as "approved" | "changes_required" | undefined;
|
|
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
|
+
|
|
72
255
|
export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">): string {
|
|
73
256
|
const executionMode = input.executionMode ?? "plan";
|
|
74
257
|
const answers = input.answers ?? [];
|
|
@@ -113,13 +296,29 @@ export function renderRolePrompt(input: Omit<RoleRunInput, "backend" | "cwd">):
|
|
|
113
296
|
"",
|
|
114
297
|
"Instructions:",
|
|
115
298
|
`Act as the DevCrew ${input.role} role and produce a complete, well-structured Markdown document for the ${input.phase} phase.`,
|
|
116
|
-
"Keep scope aligned with the approved gates and
|
|
299
|
+
"Keep scope aligned with the approved gates and the selected DevCrew execution policy.",
|
|
117
300
|
permissionInstruction,
|
|
118
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
|
+
"",
|
|
119
309
|
"Required Sections:",
|
|
120
310
|
...roleGuidance(input.role),
|
|
121
311
|
);
|
|
122
312
|
|
|
313
|
+
if (input.phase === "review") {
|
|
314
|
+
lines.push(
|
|
315
|
+
"",
|
|
316
|
+
"Architecture Review Decision:",
|
|
317
|
+
"Add an exact H2 `## Review Decision` section containing exactly `Decision: approved` or `Decision: changes_required`.",
|
|
318
|
+
"Use `changes_required` for any mismatch with the approved architecture; summarize the blocking mismatch in that section.",
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
123
322
|
return lines.join("\n");
|
|
124
323
|
}
|
|
125
324
|
|
|
@@ -129,6 +328,7 @@ function titleForPhase(phase: Phase): string {
|
|
|
129
328
|
architecture: "Architecture",
|
|
130
329
|
implementation: "Implementation Plan",
|
|
131
330
|
execution: "Implementation Review",
|
|
331
|
+
review: "Architecture Review",
|
|
132
332
|
testing: "Test Report",
|
|
133
333
|
acceptance: "Acceptance",
|
|
134
334
|
complete: "Acceptance",
|
|
@@ -170,13 +370,14 @@ interface CodexClient {
|
|
|
170
370
|
|
|
171
371
|
type CodexConstructor = new () => CodexClient;
|
|
172
372
|
|
|
173
|
-
export type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan";
|
|
373
|
+
export type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "dontAsk" | "plan";
|
|
174
374
|
|
|
175
375
|
export interface ClaudeQueryOptions {
|
|
176
376
|
cwd?: string;
|
|
177
377
|
permissionMode?: ClaudePermissionMode;
|
|
178
378
|
allowedTools?: string[];
|
|
179
379
|
disallowedTools?: string[];
|
|
380
|
+
allowDangerouslySkipPermissions?: boolean;
|
|
180
381
|
}
|
|
181
382
|
|
|
182
383
|
export type ClaudeResultSubtype = "success" | "error_max_turns" | "error_during_execution";
|
|
@@ -294,28 +495,51 @@ export function extractClaudeResult(message: ClaudeResultMessage | undefined): s
|
|
|
294
495
|
return text;
|
|
295
496
|
}
|
|
296
497
|
|
|
297
|
-
function
|
|
498
|
+
function isApplyPhase(input: Pick<RoleRunInput, "executionMode" | "phase">): boolean {
|
|
298
499
|
return input.executionMode === "apply" && (input.phase === "execution" || input.phase === "testing");
|
|
299
500
|
}
|
|
300
501
|
|
|
301
|
-
function
|
|
302
|
-
return
|
|
502
|
+
function effectiveExecutionPolicy(input: Pick<RoleRunInput, "executionPolicy">): ExecutionPolicy {
|
|
503
|
+
return input.executionPolicy ?? "interactive-host";
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function roleCanApply(input: Pick<RoleRunInput, "executionMode" | "executionPolicy" | "phase">): boolean {
|
|
507
|
+
return isApplyPhase(input) && effectiveExecutionPolicy(input) !== "interactive-host";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function codexOptionsForRole(input: RoleRunInput): CodexThreadOptions {
|
|
511
|
+
if (!roleCanApply(input)) {
|
|
512
|
+
return buildCodexThreadOptions(input.cwd);
|
|
513
|
+
}
|
|
514
|
+
const executionPolicy = effectiveExecutionPolicy(input);
|
|
515
|
+
return {
|
|
516
|
+
...buildCodexThreadOptions(input.cwd, "workspace-write"),
|
|
517
|
+
approvalPolicy: executionPolicy === "headless-unattended" ? "never" : "on-request",
|
|
518
|
+
networkAccessEnabled: false,
|
|
519
|
+
};
|
|
303
520
|
}
|
|
304
521
|
|
|
305
522
|
function claudeOptionsForRole(input: RoleRunInput): ClaudeQueryOptions {
|
|
306
523
|
if (!roleCanApply(input)) {
|
|
307
524
|
return buildClaudeOptions(input.cwd);
|
|
308
525
|
}
|
|
526
|
+
if (effectiveExecutionPolicy(input) === "headless-unattended") {
|
|
527
|
+
return {
|
|
528
|
+
cwd: input.cwd,
|
|
529
|
+
permissionMode: "bypassPermissions",
|
|
530
|
+
allowDangerouslySkipPermissions: true,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
309
533
|
const allowedTools =
|
|
310
|
-
input.role === "implementer" ? ["Read", "Grep", "Glob", "Edit", "Write"
|
|
311
|
-
return buildClaudeOptions(input.cwd, "
|
|
534
|
+
input.role === "implementer" ? ["Read", "Grep", "Glob", "Edit", "Write"] : ["Read", "Grep", "Glob"];
|
|
535
|
+
return buildClaudeOptions(input.cwd, "dontAsk", allowedTools);
|
|
312
536
|
}
|
|
313
537
|
|
|
314
538
|
async function runWithCodex(input: RoleRunInput, prompt: string, loadModule: ModuleLoader): Promise<string> {
|
|
315
539
|
const mod = await loadModule(HOST_SDK_PACKAGES.codex);
|
|
316
540
|
const CodexClass = mod.Codex as CodexConstructor;
|
|
317
541
|
const codex = new CodexClass();
|
|
318
|
-
const thread = codex.startThread(
|
|
542
|
+
const thread = codex.startThread(codexOptionsForRole(input));
|
|
319
543
|
const turn = await thread.run(prompt);
|
|
320
544
|
return extractCodexText(turn);
|
|
321
545
|
}
|
|
@@ -344,6 +568,7 @@ function fallbackResult(
|
|
|
344
568
|
summary,
|
|
345
569
|
markdown: `# ${title}\n\n${summary}\n`,
|
|
346
570
|
usedFallback: true,
|
|
571
|
+
format: "legacy",
|
|
347
572
|
};
|
|
348
573
|
}
|
|
349
574
|
|
|
@@ -356,6 +581,9 @@ export interface RunRoleDeps {
|
|
|
356
581
|
}
|
|
357
582
|
|
|
358
583
|
export async function runRole(input: RoleRunInput, deps: RunRoleDeps = {}): Promise<RoleResult> {
|
|
584
|
+
if (isApplyPhase(input) && effectiveExecutionPolicy(input) === "interactive-host") {
|
|
585
|
+
throw new Error("DevCrew interactive-host execution must be performed by the host, not a nested SDK");
|
|
586
|
+
}
|
|
359
587
|
const loadModule = deps.loadModule ?? importOptional;
|
|
360
588
|
const title = titleForPhase(input.phase);
|
|
361
589
|
const prompt = renderRolePrompt(input);
|
|
@@ -374,13 +602,22 @@ export async function runRole(input: RoleRunInput, deps: RunRoleDeps = {}): Prom
|
|
|
374
602
|
input.backend === "codex"
|
|
375
603
|
? await runWithCodex(input, prompt, loadModule)
|
|
376
604
|
: await runWithClaude(input, prompt, loadModule);
|
|
377
|
-
|
|
605
|
+
const parsed = parseRoleResultOutput(input.role, input.phase, markdown);
|
|
606
|
+
assertRoleSections(input.role, parsed.markdown);
|
|
607
|
+
const reviewDecision = parsed.reviewDecision;
|
|
608
|
+
if (input.phase === "review" && !reviewDecision) {
|
|
609
|
+
throw new RoleOutputValidationError(input.role, ["Review Decision"]);
|
|
610
|
+
}
|
|
378
611
|
return {
|
|
379
612
|
role: input.role,
|
|
380
613
|
backend: input.backend,
|
|
381
|
-
summary: `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
382
|
-
markdown,
|
|
614
|
+
summary: parsed.structured?.summary ?? `${input.role} produced ${title} using the ${input.backend} SDK.`,
|
|
615
|
+
markdown: parsed.markdown,
|
|
383
616
|
usedFallback: false,
|
|
617
|
+
format: parsed.format,
|
|
618
|
+
structured: parsed.structured,
|
|
619
|
+
questions: parsed.questions,
|
|
620
|
+
reviewDecision,
|
|
384
621
|
};
|
|
385
622
|
} catch (error) {
|
|
386
623
|
const reason = errorMessage(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
|
+
}
|
|
@@ -10,6 +10,7 @@ function headingForArtifact(name: ArtifactName): string {
|
|
|
10
10
|
architecture: "Architecture",
|
|
11
11
|
"implementation-plan": "Implementation Plan",
|
|
12
12
|
"implementation-review": "Implementation Review",
|
|
13
|
+
"architecture-review": "Architecture Review",
|
|
13
14
|
"test-report": "Test Report",
|
|
14
15
|
acceptance: "Acceptance",
|
|
15
16
|
}[name];
|
|
@@ -60,6 +61,13 @@ function verificationBlock(state: RunState): string {
|
|
|
60
61
|
.join("\n\n");
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
function verificationWaiverBlock(state: RunState): string {
|
|
65
|
+
if (!state.verificationWaiver) {
|
|
66
|
+
return "No verification waiver has been recorded.";
|
|
67
|
+
}
|
|
68
|
+
return `Reason: ${state.verificationWaiver.reason}\nRecorded At: ${state.verificationWaiver.createdAt}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
63
71
|
function lintResultsBlock(state: RunState): string {
|
|
64
72
|
if (state.lintResults.length === 0) {
|
|
65
73
|
return "No lint or format commands have been executed.";
|
|
@@ -99,7 +107,7 @@ function architectureComplianceStatus(state: RunState): string {
|
|
|
99
107
|
|
|
100
108
|
export function renderArtifact(name: ArtifactName, state: RunState): string {
|
|
101
109
|
const title = headingForArtifact(name);
|
|
102
|
-
const common = `# ${title}\n\nRun: ${state.runId}\nMode: ${state.mode}\nExecution Mode: ${state.executionMode}\nHost: ${state.host}\nBackend: ${state.backend}\nRequest: ${state.request}\n\n`;
|
|
110
|
+
const common = `# ${title}\n\nRun: ${state.runId}\nMode: ${state.mode}\nExecution Mode: ${state.executionMode}\nExecution Policy: ${state.executionPolicy}\nVerification Status: ${state.verificationStatus}\nHost: ${state.host}\nBackend: ${state.backend}\nRequest: ${state.request}\n\n`;
|
|
103
111
|
|
|
104
112
|
if (name === "requirements") {
|
|
105
113
|
return `${common}## Functional Scope\n\n### In Scope\n\n- Capture the requested outcome in user-facing terms.\n\n### Out of Scope\n\n- Make explicit what is intentionally excluded before implementation.\n\n## Users and Scenarios\n\n- Identify the primary users and their key scenarios.\n\n## Acceptance Criteria\n\n- Express each criterion as Given / When / Then so it stays testable.\n\n## Priorities\n\n- Classify each requirement as Must / Should / Could / Won't (MoSCoW).\n\n## Current Requester Answers\n\n${answerBlock(state)}\n\n## Discovered Standards\n\n${standardsExcerpt(state)}\n\n## Open Questions\n\n- Confirm primary users and success criteria.\n- Confirm scope boundaries and non-goals.\n- Confirm deployment or environment constraints.\n\n## Rejection Feedback\n\n${feedbackBlock(state)}\n`;
|
|
@@ -117,17 +125,21 @@ export function renderArtifact(name: ArtifactName, state: RunState): string {
|
|
|
117
125
|
return `${common}${workflowContextBlock(state)}\n## Implementation Diff Review\n\n### Changed Files\n\n${changedFilesBlock(state)}\n\n### Captured Diff\n\n${implementationDiffBlock(state)}\n\n## Lint Results\n\n${lintResultsBlock(state)}\n\n## Architecture Compliance Inputs\n\n${architectureComplianceInputsBlock(state)}\n\n## Architecture Compliance Review\n\nStatus: ${architectureComplianceStatus(state)}\n\n- Confirm changed files map back to the approved architecture artifact.\n- Confirm public interfaces, data flow, and deployment assumptions remain consistent with the architecture.\n- Confirm implementation scope does not include unapproved requirements or unrelated refactors.\n- Record any mismatch as rejection feedback before approving the implementation gate.\n`;
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
if (name === "architecture-review") {
|
|
129
|
+
return `${common}${workflowContextBlock(state)}\n## Technical Decisions\n\nReview the captured implementation diff against the approved architecture. State whether the implementation keeps its approved interfaces, data flow, deployment, and rollback assumptions.\n\n## Interface Contracts\n\nIdentify each changed public interface and whether it conforms to the approved architecture artifact.\n\n## Data Flow and Deployment\n\nReview the implementation diff for data-flow, deployment, migration, and rollback deviations.\n\n## Architecture Review Checklist\n\n- Review the captured implementation diff and changed-files list.\n- Trace each material change to an approved architectural decision.\n- Record any mismatch as rejection feedback before the testing phase begins.\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
if (name === "test-report") {
|
|
121
|
-
return `${common}${workflowContextBlock(state)}\n## Test Cases\n\nEnumerate cases as a table covering happy, edge, failure, and regression paths.\n\n| ID | Scenario | Type | Expected |\n| --- | --- | --- | --- |\n| TC-1 | Primary success path | happy | Behaves as specified |\n| TC-2 | Boundary input | edge | Handled without error |\n| TC-3 | Invalid input | failure | Fails safely with clear error |\n\n## Coverage\n\nRun the coverage command and report the coverage summary plus any gaps. Evidence is captured under Acceptance Evidence.\n\n## Acceptance Evidence\n\n${verificationBlock(state)}\n\n## Known Risks\n\n-
|
|
133
|
+
return `${common}${workflowContextBlock(state)}\n## Test Cases\n\nEnumerate cases as a table covering happy, edge, failure, and regression paths.\n\n| ID | Scenario | Type | Expected |\n| --- | --- | --- | --- |\n| TC-1 | Primary success path | happy | Behaves as specified |\n| TC-2 | Boundary input | edge | Handled without error |\n| TC-3 | Invalid input | failure | Fails safely with clear error |\n\n## Coverage\n\nRun the coverage command and report the coverage summary plus any gaps. Evidence is captured under Acceptance Evidence.\n\n## Acceptance Evidence\n\n${verificationBlock(state)}\n\n## Verification Waiver\n\n${verificationWaiverBlock(state)}\n\n## Known Risks\n\n- Headless SDK capabilities are governed by the recorded DevCrew execution policy.\n- Interactive-host work is performed through the host's native agent controls.\n`;
|
|
122
134
|
}
|
|
123
135
|
|
|
124
|
-
return `${common}${workflowContextBlock(state)}\n## Acceptance Summary\n\nThe requester approved requirements, architecture, implementation planning, and test reporting gates for this run.\n\n## Approved Gates\n\n${Object.entries(state.gates)
|
|
136
|
+
return `${common}${workflowContextBlock(state)}\n## Acceptance Summary\n\nThe requester approved requirements, architecture, implementation planning, and test reporting gates for this run.\n\n## Verification Waiver\n\n${verificationWaiverBlock(state)}\n\n## Approved Gates\n\n${Object.entries(state.gates)
|
|
125
137
|
.map(([gate, status]) => `- ${gate}: ${status}`)
|
|
126
138
|
.join("\n")}\n`;
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
export async function writeArtifact(name: ArtifactName, state: RunState): Promise<string> {
|
|
130
|
-
const path = artifactPath(state.cwd, state.runId, name);
|
|
142
|
+
const path = artifactPath(state.cwd, state.runId, name, state.artifactDirectory);
|
|
131
143
|
await mkdir(dirname(path), { recursive: true });
|
|
132
144
|
await writeFile(path, renderArtifact(name, state), "utf8");
|
|
133
145
|
return path;
|
|
@@ -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,
|
|
@@ -11,7 +12,7 @@ export const DEFAULT_CONFIG: DevCrewConfig = {
|
|
|
11
12
|
lintCommands: [],
|
|
12
13
|
coverageCommands: [],
|
|
13
14
|
workflow: {
|
|
14
|
-
gates: ["requirements", "architecture", "implementation", "testing"],
|
|
15
|
+
gates: ["requirements", "architecture", "implementation", "implementation-review", "testing"],
|
|
15
16
|
artifactDirectory: "docs/devcrew",
|
|
16
17
|
},
|
|
17
18
|
};
|
|
@@ -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
|
}
|