@guanlin4924/devflow-pi 0.1.98 → 0.1.99
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +560 -262
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +3 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_traceability.py +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +6 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +11 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +14 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +12 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +8 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +25 -3
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +55 -28
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +41 -14
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +2 -2
- package/src/constants.ts +1 -1
- package/src/mcp/core.ts +23 -24
- package/src/mcp/server.ts +11 -12
- package/src/project-context.ts +9 -1
- package/src/project-initializer.ts +4 -4
- package/src/session-rollover.ts +10 -0
- package/src/workflow-diagnosis.ts +407 -0
package/src/mcp/core.ts
CHANGED
|
@@ -45,33 +45,32 @@ export const runPython: CoreRunner = async (script, args, cwd, stdin, timeoutMs
|
|
|
45
45
|
const timeoutPromise = new Promise<"timeout">(resolve => {
|
|
46
46
|
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
47
47
|
});
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
// The parent can exit while descendants still hold its output pipes open.
|
|
49
|
+
// Keep the deadline active until both exit and output collection complete.
|
|
50
|
+
const completion = Promise.all([child.exited, stdoutPromise, stderrPromise])
|
|
51
|
+
.then(([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr }));
|
|
52
|
+
try {
|
|
53
|
+
const winner = await Promise.race([completion, timeoutPromise]);
|
|
54
|
+
if (winner === "timeout") {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
57
|
+
try { child.kill("SIGTERM"); } catch { /* already exited */ }
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, TERMINATION_GRACE_MS));
|
|
59
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
60
|
+
try { child.kill("SIGKILL"); } catch { /* already exited */ }
|
|
61
|
+
return {
|
|
62
|
+
exitCode: null,
|
|
63
|
+
stdout: await settleText(stdoutPromise, 100),
|
|
64
|
+
stderr: await settleText(stderrPromise, 100),
|
|
65
|
+
timedOut,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { ...winner, timedOut };
|
|
70
|
+
} finally {
|
|
57
71
|
if (progressTimer !== undefined) clearInterval(progressTimer);
|
|
58
72
|
clearTimeout(timeoutHandle);
|
|
59
|
-
return {
|
|
60
|
-
exitCode: null,
|
|
61
|
-
stdout: await settleText(stdoutPromise, 100),
|
|
62
|
-
stderr: await settleText(stderrPromise, 100),
|
|
63
|
-
timedOut,
|
|
64
|
-
};
|
|
65
73
|
}
|
|
66
|
-
|
|
67
|
-
if (progressTimer !== undefined) clearInterval(progressTimer);
|
|
68
|
-
clearTimeout(timeoutHandle);
|
|
69
|
-
return {
|
|
70
|
-
exitCode: winner.exitCode,
|
|
71
|
-
stdout: await stdoutPromise,
|
|
72
|
-
stderr: await stderrPromise,
|
|
73
|
-
timedOut,
|
|
74
|
-
};
|
|
75
74
|
};
|
|
76
75
|
|
|
77
76
|
export async function invokeCore(
|
package/src/mcp/server.ts
CHANGED
|
@@ -114,20 +114,19 @@ export async function createMcpServer(options: McpServerOptions) {
|
|
|
114
114
|
if (tool === "run_feature" && running) throw new McpError("mcp_busy", "another run_feature is in progress");
|
|
115
115
|
|
|
116
116
|
if (tool === "run_feature") {
|
|
117
|
-
const bindingFile = path.join(auth.project, ".devflow/planning/features", String(feature), "acceptance-test-bindings.json");
|
|
118
|
-
let bindings: unknown;
|
|
119
|
-
try {
|
|
120
|
-
bindings = JSON.parse(await readFile(bindingFile, "utf8"));
|
|
121
|
-
} catch {
|
|
122
|
-
throw new McpError("mcp_input_invalid", "acceptance-test-bindings.json is missing or invalid");
|
|
123
|
-
}
|
|
124
|
-
const profiles = collectEnvironmentProfiles(bindings);
|
|
125
|
-
if ([...profiles].some(profile => !auth.grant.environment_profiles.includes(profile))) {
|
|
126
|
-
throw new McpError("mcp_environment_not_allowed", "environment profile is not authorized");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
117
|
running = true;
|
|
130
118
|
try {
|
|
119
|
+
const bindingFile = path.join(auth.project, ".devflow/planning/features", String(feature), "acceptance-test-bindings.json");
|
|
120
|
+
let bindings: unknown;
|
|
121
|
+
try {
|
|
122
|
+
bindings = JSON.parse(await readFile(bindingFile, "utf8"));
|
|
123
|
+
} catch {
|
|
124
|
+
throw new McpError("mcp_input_invalid", "acceptance-test-bindings.json is missing or invalid");
|
|
125
|
+
}
|
|
126
|
+
const profiles = collectEnvironmentProfiles(bindings);
|
|
127
|
+
if ([...profiles].some(profile => !auth.grant.environment_profiles.includes(profile))) {
|
|
128
|
+
throw new McpError("mcp_environment_not_allowed", "environment profile is not authorized");
|
|
129
|
+
}
|
|
131
130
|
const runResult = await invokeCore(
|
|
132
131
|
runner,
|
|
133
132
|
"run",
|
package/src/project-context.ts
CHANGED
|
@@ -138,6 +138,10 @@ export async function assertPlanningBoundary(root: string): Promise<string> {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
export async function loadProjectContext(root: string): Promise<ProjectContext> {
|
|
141
|
+
return readProjectContext(root, false);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readProjectContext(root: string, registerWorkspace: boolean): Promise<ProjectContext> {
|
|
141
145
|
const canonicalRoot = await canonicalDirectory(root);
|
|
142
146
|
const manifestPath = projectLayout(canonicalRoot).manifestPath;
|
|
143
147
|
const manifestValue = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
|
|
@@ -150,6 +154,10 @@ export async function loadProjectContext(root: string): Promise<ProjectContext>
|
|
|
150
154
|
const manifest = validateManifest(manifestValue, manifestPath);
|
|
151
155
|
await mkdir(projectLayout(canonicalRoot).planningRoot, { recursive: true });
|
|
152
156
|
const planningRoot = await assertPlanningBoundary(canonicalRoot);
|
|
157
|
+
if (registerWorkspace) {
|
|
158
|
+
const store = await InitializationStore.open({ root: canonicalRoot, manifest });
|
|
159
|
+
try { await store.registerWorkspace(); } finally { store.close(); }
|
|
160
|
+
}
|
|
153
161
|
if (!(await exists(projectControlDatabasePath(manifest.projectId)))) throw new Error("Project initialization database is not registered; run explicit project initialization first");
|
|
154
162
|
return attachInitializationStore({ root: canonicalRoot, manifestPath, manifest, planningRoot });
|
|
155
163
|
}
|
|
@@ -198,7 +206,7 @@ export async function initializeProject(root: string): Promise<{ context: Projec
|
|
|
198
206
|
await mkdir(devflowDir, { recursive: true });
|
|
199
207
|
await assertPlanningBoundary(canonicalRoot);
|
|
200
208
|
if (await exists(manifestPath)) {
|
|
201
|
-
const context = await
|
|
209
|
+
const context = await readProjectContext(canonicalRoot, true);
|
|
202
210
|
await ensureTestRuntimeLayout(canonicalRoot, context.planningRoot);
|
|
203
211
|
return { context, created: false };
|
|
204
212
|
}
|
|
@@ -42,7 +42,7 @@ function source(file: SourceFile, kind: FactKind, objectKey: string, value: Reco
|
|
|
42
42
|
return { kind, objectKey, repository: file.repository, value, evidenceLevel: "observed", sourcePath: file.path, sourceLine: line, sourceFingerprint: file.contentHash, extractor, confidence: "heuristic" };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], relative = ""): Promise<SourceFile[]> {
|
|
45
|
+
async function walk(repository: string, repositoryRoot: string, includes: string[], excludes: string[], diagnostics: ScanDiagnostic[], maxFileSize: number, relative = ""): Promise<SourceFile[]> {
|
|
46
46
|
const directory = path.join(repositoryRoot, relative);
|
|
47
47
|
let entries;
|
|
48
48
|
try { entries = await readdir(directory, { withFileTypes: true }); }
|
|
@@ -54,11 +54,11 @@ async function walk(repository: string, repositoryRoot: string, includes: string
|
|
|
54
54
|
const child = path.join(relative, entry.name);
|
|
55
55
|
const normalized = child.replaceAll("\\", "/");
|
|
56
56
|
if (excludes.some(pattern => globMatches(normalized, pattern) || globMatches(`${normalized}/`, pattern))) { if (diagnostics.length < 1000) diagnostics.push({ repository, path: normalized, reason: "excluded" }); continue; }
|
|
57
|
-
if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, child)); continue; }
|
|
57
|
+
if (entry.isDirectory()) { result.push(...await walk(repository, repositoryRoot, includes, excludes, diagnostics, maxFileSize, child)); continue; }
|
|
58
58
|
if (!entry.isFile()) continue;
|
|
59
59
|
if (includes.length && !includes.some(pattern => globMatches(normalized, pattern))) continue;
|
|
60
60
|
const absolute = path.join(repositoryRoot, child); const info = await stat(absolute);
|
|
61
|
-
if (info.size >
|
|
61
|
+
if (info.size > maxFileSize) { diagnostics.push({ repository, path: normalized, reason: "too_large" }); continue; }
|
|
62
62
|
let bytes: Buffer;
|
|
63
63
|
try { bytes = await readFile(absolute); }
|
|
64
64
|
catch (error) { diagnostics.push({ repository, path: normalized, reason: "unreadable", message: error instanceof Error ? error.message : String(error) }); continue; }
|
|
@@ -84,7 +84,7 @@ async function scan(context: ProjectContext, config: ScanConfigInput): Promise<{
|
|
|
84
84
|
const root = path.resolve(context.root, repository.relativePath);
|
|
85
85
|
if (!path.isAbsolute(repository.relativePath) && root !== context.root && !root.startsWith(`${context.root}${path.sep}`)) throw new Error(`Repository escapes project root: ${repository.relativePath}`);
|
|
86
86
|
repositoryVersions.push({ repository: repository.name, revision: await gitRevision(root) });
|
|
87
|
-
all.push(...await walk(repository.name, root, includes, excludes, diagnostics));
|
|
87
|
+
all.push(...await walk(repository.name, root, includes, excludes, diagnostics, config.maxFileSize ?? 2 * 1024 * 1024));
|
|
88
88
|
}
|
|
89
89
|
return { files: all.sort((a, b) => `${a.repository}/${a.path}`.localeCompare(`${b.repository}/${b.path}`)), diagnostics, repositoryVersions };
|
|
90
90
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The current prepared revision differs from the immutable handoff's source revision after recovery. */
|
|
2
|
+
export function preparedRolloverRevision(state: Record<string, unknown>): number | undefined {
|
|
3
|
+
const rollover = state.rollover as Record<string, unknown> | undefined;
|
|
4
|
+
if (rollover?.status !== "prepared") return undefined;
|
|
5
|
+
const revision = rollover.prepared_state_revision ?? (
|
|
6
|
+
typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision + 1 : undefined
|
|
7
|
+
);
|
|
8
|
+
return typeof revision === "number" && Number.isSafeInteger(revision) && revision === state.revision
|
|
9
|
+
? revision : undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User-facing workflow diagnosis for the single "next step" entry.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions only: the runtime gathers Core facts (inspector snapshot,
|
|
5
|
+
* rollover state, output readiness) and this module turns them into a closed
|
|
6
|
+
* set of Chinese diagnoses with fix actions. Fix actions are executed by
|
|
7
|
+
* runtime code, never by the model.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type FixAction =
|
|
11
|
+
| "create-feature"
|
|
12
|
+
| "review-and-advance"
|
|
13
|
+
| "rerun-step"
|
|
14
|
+
| "revise-outputs"
|
|
15
|
+
| "repair-registry"
|
|
16
|
+
| "resume-rollover"
|
|
17
|
+
| "help-bundle"
|
|
18
|
+
| "cancel-feature";
|
|
19
|
+
|
|
20
|
+
export type DiagnosisCode =
|
|
21
|
+
| "INSPECT_FAILED"
|
|
22
|
+
| "NO_ACTIVE_FEATURE"
|
|
23
|
+
| "WORKFLOW_COMPLETE"
|
|
24
|
+
| "ROLLOVER_PENDING"
|
|
25
|
+
| "REGISTRY_DRIFT"
|
|
26
|
+
| "CHECKPOINT_WAITING"
|
|
27
|
+
| "STEP_OUTPUTS_READY"
|
|
28
|
+
| "STATE_INVALID"
|
|
29
|
+
| "CHECKPOINT_OUTPUTS_MISSING"
|
|
30
|
+
| "TRACK_BOUNDARY"
|
|
31
|
+
| "UNKNOWN_NEXT_STEP"
|
|
32
|
+
| "WORKFLOW_BLOCKED";
|
|
33
|
+
|
|
34
|
+
export type Diagnosis = {
|
|
35
|
+
code: DiagnosisCode;
|
|
36
|
+
title: string;
|
|
37
|
+
evidence: string[];
|
|
38
|
+
fixes: FixAction[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DiagnosisKind = "no-feature" | "complete" | "execute" | "review" | "repair" | "blocked";
|
|
42
|
+
|
|
43
|
+
export type WorkflowDiagnosis = {
|
|
44
|
+
kind: DiagnosisKind;
|
|
45
|
+
feature?: string;
|
|
46
|
+
stepId?: string;
|
|
47
|
+
stepLabel?: string;
|
|
48
|
+
progress?: { index: number; total: number };
|
|
49
|
+
diagnoses: Diagnosis[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type DiagnosisInput = {
|
|
53
|
+
inspect: { ok: boolean; error?: string };
|
|
54
|
+
snapshot: Record<string, unknown> | null;
|
|
55
|
+
disposition: "execute" | "review" | "blocked" | "complete" | "idle";
|
|
56
|
+
/** Every required output of the current step exists and is not marked invalid; `planned` allowed. */
|
|
57
|
+
outputsReady: boolean;
|
|
58
|
+
rolloverPrepared: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const FIX_LABELS: Record<FixAction, { label: string; description: string }> = {
|
|
62
|
+
"create-feature": { label: "创建需求", description: "输入需求名称,由 Devflow 创建工作流状态" },
|
|
63
|
+
"review-and-advance": { label: "审查后推进", description: "弹出推进确认;确认后由 Core 校验产物并推进到下一步" },
|
|
64
|
+
"rerun-step": { label: "重新执行这一步", description: "按当前步骤重新启动 AI(会先激活执行合同)" },
|
|
65
|
+
"revise-outputs": { label: "按意见修改产物", description: "回到审查状态;你直接说明要改什么,AI 修改后再推进" },
|
|
66
|
+
"repair-registry": { label: "修复登记表", description: "由 Core 按当前状态重建产物登记表;不推进工作流" },
|
|
67
|
+
"resume-rollover": { label: "继续会话交接", description: "完成未走完的会话切换,然后在新会话中继续" },
|
|
68
|
+
"help-bundle": { label: "生成求助包", description: "把诊断信息写到 .devflow/diagnostics/,不含源码内容" },
|
|
69
|
+
"cancel-feature": { label: "弃用当前需求", description: "结束当前需求并保留现有产物;之后可以创建新需求" },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const REASON_TITLES: Record<string, string> = {
|
|
73
|
+
state_validation_failed: "状态文件校验失败,系统无法安全地自动修复",
|
|
74
|
+
missing_checkpoint_outputs: "检查点需要的产物不完整",
|
|
75
|
+
unknown_next_step: "找不到下一个可执行的步骤",
|
|
76
|
+
current_output_registry_drift: "产物登记表和实际文件不一致",
|
|
77
|
+
track_scope_complete: "当前轨道的步骤已完成,需要切换轨道",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function text(value: unknown): string | undefined {
|
|
81
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
85
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function currentStep(snapshot: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
89
|
+
return record(snapshot.current_step) ?? record(snapshot.next_step);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function outputPaths(step: Record<string, unknown> | undefined): string[] {
|
|
93
|
+
const outputs = Array.isArray(step?.outputs) ? step.outputs : [];
|
|
94
|
+
return outputs.flatMap(output => {
|
|
95
|
+
const item = record(output);
|
|
96
|
+
if (!item || item.required === false) return [];
|
|
97
|
+
const resolved = text(item.resolved_path) ?? text(item.path);
|
|
98
|
+
return resolved ? [`${resolved}${item.exists === true ? " ✓" : "(缺失)"}`] : [];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validationEvidence(snapshot: Record<string, unknown>): string[] {
|
|
103
|
+
const validation = record(snapshot.state_validation);
|
|
104
|
+
const errors = Array.isArray(validation?.errors) ? validation.errors : [];
|
|
105
|
+
return errors.slice(0, 5).map(error => {
|
|
106
|
+
const item = record(error);
|
|
107
|
+
const code = text(item?.code) ?? "unknown";
|
|
108
|
+
const message = text(item?.message) ?? text(item?.path) ?? "";
|
|
109
|
+
return message ? `${code}:${message}` : code;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function progressOf(snapshot: Record<string, unknown>, stepId: string | undefined): WorkflowDiagnosis["progress"] {
|
|
114
|
+
const steps = Array.isArray(snapshot.workflow_steps) ? snapshot.workflow_steps : [];
|
|
115
|
+
const index = steps.findIndex(step => record(step)?.id === stepId);
|
|
116
|
+
return index >= 0 ? { index: index + 1, total: steps.length } : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function diagnoseWorkflow(input: DiagnosisInput): WorkflowDiagnosis {
|
|
120
|
+
const { snapshot } = input;
|
|
121
|
+
if (!input.inspect.ok || !snapshot) {
|
|
122
|
+
return {
|
|
123
|
+
kind: "blocked",
|
|
124
|
+
diagnoses: [{
|
|
125
|
+
code: "INSPECT_FAILED",
|
|
126
|
+
title: "无法读取工作流状态",
|
|
127
|
+
evidence: input.inspect.error ? [firstMeaningfulLine(input.inspect.error)] : [],
|
|
128
|
+
fixes: ["help-bundle"],
|
|
129
|
+
}],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const feature = text(snapshot.current_feature);
|
|
133
|
+
const step = currentStep(snapshot);
|
|
134
|
+
const stepId = text(step?.id);
|
|
135
|
+
const base = {
|
|
136
|
+
feature,
|
|
137
|
+
stepId,
|
|
138
|
+
stepLabel: text(step?.label),
|
|
139
|
+
progress: progressOf(snapshot, stepId),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (input.rolloverPrepared) {
|
|
143
|
+
return {
|
|
144
|
+
...base,
|
|
145
|
+
kind: "repair",
|
|
146
|
+
diagnoses: [{ code: "ROLLOVER_PENDING", title: "上一次的会话交接没有走完", evidence: [], fixes: ["resume-rollover"] }],
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (snapshot.reason === "missing_state_file" || !feature) {
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
kind: "no-feature",
|
|
153
|
+
diagnoses: [{ code: "NO_ACTIVE_FEATURE", title: "还没有进行中的需求", evidence: [], fixes: ["create-feature"] }],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (input.disposition === "complete") {
|
|
157
|
+
return {
|
|
158
|
+
...base,
|
|
159
|
+
kind: "complete",
|
|
160
|
+
diagnoses: [{ code: "WORKFLOW_COMPLETE", title: `需求「${feature}」已经结束`, evidence: [], fixes: ["create-feature"] }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const plan = record(snapshot.recovery_plan);
|
|
164
|
+
if (plan?.classification === "safe_repairable") {
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
kind: "repair",
|
|
168
|
+
diagnoses: [{
|
|
169
|
+
code: "REGISTRY_DRIFT",
|
|
170
|
+
title: "产物登记表和当前状态不一致",
|
|
171
|
+
evidence: text(snapshot.reason) ? [`原因代码:${String(snapshot.reason)}`] : [],
|
|
172
|
+
fixes: ["repair-registry"],
|
|
173
|
+
}],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (snapshot.action === "await_user_confirmation" && input.disposition === "review") {
|
|
177
|
+
return {
|
|
178
|
+
...base,
|
|
179
|
+
kind: "review",
|
|
180
|
+
diagnoses: [{
|
|
181
|
+
code: "CHECKPOINT_WAITING",
|
|
182
|
+
title: `检查点${text(snapshot.checkpoint) ? ` ${String(snapshot.checkpoint)}` : ""}正在等待你确认`,
|
|
183
|
+
evidence: outputPaths(step),
|
|
184
|
+
fixes: ["review-and-advance", "revise-outputs"],
|
|
185
|
+
}],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (snapshot.action === "blocked" || input.disposition === "blocked" || input.disposition === "idle") {
|
|
189
|
+
const reason = text(snapshot.reason) ?? "unknown";
|
|
190
|
+
const code: DiagnosisCode = reason === "state_validation_failed"
|
|
191
|
+
? "STATE_INVALID"
|
|
192
|
+
: reason === "missing_checkpoint_outputs"
|
|
193
|
+
? "CHECKPOINT_OUTPUTS_MISSING"
|
|
194
|
+
: reason === "unknown_next_step"
|
|
195
|
+
? "UNKNOWN_NEXT_STEP"
|
|
196
|
+
: reason === "track_scope_complete"
|
|
197
|
+
? "TRACK_BOUNDARY"
|
|
198
|
+
: "WORKFLOW_BLOCKED";
|
|
199
|
+
const evidence = [
|
|
200
|
+
...(text(snapshot.message) ? [String(snapshot.message)] : []),
|
|
201
|
+
...validationEvidence(snapshot),
|
|
202
|
+
...(code === "CHECKPOINT_OUTPUTS_MISSING" ? outputPaths(step) : []),
|
|
203
|
+
];
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
kind: "blocked",
|
|
207
|
+
diagnoses: [{
|
|
208
|
+
code,
|
|
209
|
+
title: REASON_TITLES[reason] ?? `工作流被阻塞(原因代码:${reason})`,
|
|
210
|
+
evidence,
|
|
211
|
+
fixes: ["help-bundle", "cancel-feature"],
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (snapshot.action === "run_next_step" && input.outputsReady) {
|
|
216
|
+
return {
|
|
217
|
+
...base,
|
|
218
|
+
kind: "review",
|
|
219
|
+
diagnoses: [{
|
|
220
|
+
code: "STEP_OUTPUTS_READY",
|
|
221
|
+
title: "这一步的产物已经写好,但还没有确认完成",
|
|
222
|
+
evidence: outputPaths(step),
|
|
223
|
+
fixes: ["review-and-advance", "revise-outputs", "rerun-step"],
|
|
224
|
+
}],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return { ...base, kind: "execute", diagnoses: [] };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function renderDiagnosis(result: WorkflowDiagnosis): string {
|
|
231
|
+
const lines: string[] = [];
|
|
232
|
+
if (result.feature) {
|
|
233
|
+
const progress = result.progress ? `第 ${result.progress.index}/${result.progress.total} 步` : "当前步骤";
|
|
234
|
+
const step = result.stepLabel && result.stepId ? `「${result.stepLabel}」(${result.stepId})` : result.stepId ? `(${result.stepId})` : "";
|
|
235
|
+
lines.push(`需求「${result.feature}」,${progress}${step}。`);
|
|
236
|
+
}
|
|
237
|
+
if (result.diagnoses.length === 0) {
|
|
238
|
+
lines.push(result.kind === "execute" ? "没有发现阻塞,可以开始执行这一步。" : "没有发现阻塞。");
|
|
239
|
+
return lines.join("\n");
|
|
240
|
+
}
|
|
241
|
+
for (const diagnosis of result.diagnoses) {
|
|
242
|
+
lines.push(`发现问题:${diagnosis.title}(${diagnosis.code})`);
|
|
243
|
+
for (const item of diagnosis.evidence) lines.push(`- ${item}`);
|
|
244
|
+
lines.push(`可选处理:${diagnosis.fixes.map(fix => FIX_LABELS[fix].label).join(" / ")}`);
|
|
245
|
+
}
|
|
246
|
+
return lines.join("\n");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Short phrases that mean "advance the workflow". Exact match only, so design
|
|
250
|
+
* discussions such as "下一步怎么设计比较好" are never intercepted. */
|
|
251
|
+
const NEXT_STEP_PHRASES = new Set([
|
|
252
|
+
"下一步", "下一步吧", "进入下一步", "开始下一步", "继续下一步", "执行下一步",
|
|
253
|
+
"可以下一步不", "可以下一步吗", "可以下一步了吗", "能下一步吗", "往下走", "继续往下",
|
|
254
|
+
]);
|
|
255
|
+
const CONTINUE_PHRASES = new Set(["继续", "继续吧", "继续执行"]);
|
|
256
|
+
|
|
257
|
+
export function classifyNextStepPhrase(input: string): "next" | "continue" | null {
|
|
258
|
+
const normalized = input.trim().replace(/[\s。!!??,,~~.]+$/u, "").replace(/\s+/gu, "");
|
|
259
|
+
if (NEXT_STEP_PHRASES.has(normalized)) return "next";
|
|
260
|
+
if (CONTINUE_PHRASES.has(normalized)) return "continue";
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
type FailureResult = { ok: boolean; exitCode: number | null; stdout: string; stderr: string };
|
|
265
|
+
|
|
266
|
+
const STACK_FRAME = /^\s*(?:from\s+\S+:\d+:in\b|File\s+".*",\s+line\s+\d+|at\s+\S+\s+\(|\^+\s*$|Traceback \(most recent call last\):)/u;
|
|
267
|
+
|
|
268
|
+
export function firstMeaningfulLine(raw: string): string {
|
|
269
|
+
const lines = raw.split(/\r?\n/u).map(line => line.trim()).filter(Boolean);
|
|
270
|
+
const pythonError = [...lines].reverse().find(line => /^[A-Za-z_.]+(?:Error|Exception):\s/u.test(line));
|
|
271
|
+
if (lines.some(line => line.startsWith("Traceback")) && pythonError) return pythonError.slice(0, 300);
|
|
272
|
+
const line = lines.find(item => !STACK_FRAME.test(item)) ?? lines[0] ?? "";
|
|
273
|
+
// Ruby: "/abs/path/script.rb:17:in `read': message (ErrClass)"
|
|
274
|
+
const ruby = /^\S+\.rb:\d+:in\s+[`'][^']*':\s*(.+)$/u.exec(line);
|
|
275
|
+
return (ruby ? ruby[1] : line).slice(0, 300);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function relativeDevflowPath(value: string): string {
|
|
279
|
+
const index = value.indexOf(".devflow/");
|
|
280
|
+
return index >= 0 ? value.slice(index) : value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Chinese summary for a failed or empty non-JSON tool result. Returns null for
|
|
285
|
+
* JSON output, which the model and tests parse as-is.
|
|
286
|
+
*/
|
|
287
|
+
export function summarizeToolFailure(result: FailureResult): string | null {
|
|
288
|
+
const raw = (result.stderr || result.stdout || "").trim();
|
|
289
|
+
if (result.ok) return raw ? null : "工具执行成功,但没有返回任何结果。";
|
|
290
|
+
if (!raw) return `工具执行失败,没有输出任何信息(退出码 ${String(result.exitCode)})。`;
|
|
291
|
+
try {
|
|
292
|
+
JSON.parse(result.stdout || result.stderr);
|
|
293
|
+
return null;
|
|
294
|
+
} catch {
|
|
295
|
+
// Plain text or a stack trace: summarize below.
|
|
296
|
+
}
|
|
297
|
+
const line = firstMeaningfulLine(raw);
|
|
298
|
+
const missing = /No such file or directory(?:\s*@\s*\w+)?\s*-\s*(\S+)/u.exec(raw)?.[1];
|
|
299
|
+
if (missing && /\.devflow\/planning\/STATE\.md$/u.test(missing)) {
|
|
300
|
+
return "还没有创建需求:项目中没有 .devflow/planning/STATE.md。请告诉用户先创建需求(说「下一步」,或运行 /devflow start <需求名>),不要自行创建该文件。";
|
|
301
|
+
}
|
|
302
|
+
if (missing) return `需要的文件不存在:${relativeDevflowPath(missing)}`;
|
|
303
|
+
const activeStep = /requires the active ([\w-]+) step/u.exec(raw)?.[1];
|
|
304
|
+
if (activeStep) {
|
|
305
|
+
return `该操作只能在「${activeStep}」步骤中执行,当前不在该步骤。请告诉用户说「下一步」,由 Devflow 推进到对应步骤后再执行。`;
|
|
306
|
+
}
|
|
307
|
+
const required = /^([\w-]+(?:\s*,\s*[\w-]+)*)\s+(?:is|are)\s+required\b/u.exec(line)?.[1]
|
|
308
|
+
?? /\brequires?\s+(?:a\s+|an\s+)?([\w-]+)\s*$/u.exec(line)?.[1];
|
|
309
|
+
if (required) return `缺少参数:${required}(原始信息:${line})`;
|
|
310
|
+
return `工具执行失败:${line}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const CONTROL_REASONS: Record<string, string> = {
|
|
314
|
+
stale_revision: "状态刚被其他操作更新过(版本号不一致)。请重新说「下一步」,按最新状态处理。",
|
|
315
|
+
missing_state_file: "还没有创建需求。",
|
|
316
|
+
missing_state_front_matter: "状态文件格式损坏,请运行 /devflow fix 生成求助包。",
|
|
317
|
+
unknown_step: "要推进的步骤和当前步骤对不上。请重新说「下一步」。",
|
|
318
|
+
gate_current_step_mismatch: "要推进的步骤和当前步骤对不上。请重新说「下一步」。",
|
|
319
|
+
gate_target_step_unknown: "目标步骤不在当前工作流中。",
|
|
320
|
+
feature_not_active: "产物所属的需求不是当前进行中的需求。",
|
|
321
|
+
session_rollover_in_progress: "会话交接还没完成。请说「下一步」继续交接。",
|
|
322
|
+
state_validation_failed: "状态文件校验失败。请运行 /devflow fix 查看详情。",
|
|
323
|
+
not_waiting_checkpoint: "当前不在等待确认的检查点。",
|
|
324
|
+
no_pending_step: "已经没有待执行的步骤了。",
|
|
325
|
+
acceptance_results_not_passed: "验收测试结果没有全部通过,请先处理失败的用例。",
|
|
326
|
+
dsl_not_approved: "有验收脚本尚未批准,请在 /devflow control 中批准。",
|
|
327
|
+
environment_profile_not_approved: "测试环境配置尚未批准,请在 /devflow control 中批准。",
|
|
328
|
+
approval_invalid: "批准记录无效或已过期,请在 /devflow control 中重新批准。",
|
|
329
|
+
human_result_unapproved: "人工验收结论尚未批准,请在 /devflow control 中处理。",
|
|
330
|
+
cancel_reason_required: "弃用需求需要填写原因。",
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/** Chinese explanation of a failed control-plane transition (advance_state.rb and friends). */
|
|
334
|
+
export function describeControlFailure(result: FailureResult): string {
|
|
335
|
+
let payload: Record<string, unknown> | undefined;
|
|
336
|
+
try {
|
|
337
|
+
payload = record(JSON.parse(result.stdout || result.stderr || ""));
|
|
338
|
+
} catch {
|
|
339
|
+
payload = undefined;
|
|
340
|
+
}
|
|
341
|
+
if (!payload) return summarizeToolFailure(result) ?? "未知错误。";
|
|
342
|
+
const reason = text(payload.reason) ?? text(record(payload.error)?.code) ?? "unknown";
|
|
343
|
+
const known = CONTROL_REASONS[reason];
|
|
344
|
+
const details = payload.details ?? payload.errors ?? payload.issues;
|
|
345
|
+
const detailText = details === undefined ? "" : JSON.stringify(details).slice(0, 400);
|
|
346
|
+
return [
|
|
347
|
+
known ?? `未通过推进校验(原因代码:${reason})。`,
|
|
348
|
+
...(detailText && detailText !== "{}" && detailText !== "[]" ? [`详情:${detailText}`] : []),
|
|
349
|
+
].join("\n");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export type ToolFailureRecord = { at: string; tool: string; exitCode: number | null; summary: string; raw: string };
|
|
353
|
+
|
|
354
|
+
export type HelpBundleInput = {
|
|
355
|
+
version: string;
|
|
356
|
+
createdAt: string;
|
|
357
|
+
diagnosis: WorkflowDiagnosis;
|
|
358
|
+
snapshot: Record<string, unknown> | null;
|
|
359
|
+
inspectError?: string;
|
|
360
|
+
doctor?: Array<{ id: string; status: string; message: string }>;
|
|
361
|
+
rollover?: Record<string, unknown> | null;
|
|
362
|
+
failures: ToolFailureRecord[];
|
|
363
|
+
/** Absolute prefixes replaced by "." and "~" so the bundle stays project-relative. */
|
|
364
|
+
root?: string;
|
|
365
|
+
home?: string;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
function sanitizePaths(value: string, root: string | undefined, home: string | undefined): string {
|
|
369
|
+
let result = value;
|
|
370
|
+
if (root) result = result.split(root.endsWith("/") ? root : `${root}/`).join("./").split(root).join(".");
|
|
371
|
+
if (home) result = result.split(home).join("~");
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Bounded, project-relative diagnostic bundle. Never includes source contents. */
|
|
376
|
+
export function buildHelpBundle(input: HelpBundleInput): Record<string, unknown> {
|
|
377
|
+
const snapshot = input.snapshot;
|
|
378
|
+
const rollover = record(input.rollover?.rollover);
|
|
379
|
+
return {
|
|
380
|
+
schema_version: 1,
|
|
381
|
+
created_at: input.createdAt,
|
|
382
|
+
devflow_pi_version: input.version,
|
|
383
|
+
diagnosis: input.diagnosis,
|
|
384
|
+
state: snapshot ? {
|
|
385
|
+
feature: snapshot.current_feature ?? null,
|
|
386
|
+
flow: snapshot.current_flow ?? null,
|
|
387
|
+
phase: snapshot.current_phase ?? null,
|
|
388
|
+
status: snapshot.status ?? null,
|
|
389
|
+
action: snapshot.action ?? null,
|
|
390
|
+
reason: snapshot.reason ?? null,
|
|
391
|
+
revision: snapshot.revision ?? null,
|
|
392
|
+
checkpoint: snapshot.checkpoint ?? null,
|
|
393
|
+
current_step: record(snapshot.current_step)?.id ?? null,
|
|
394
|
+
completed_steps: Array.isArray(snapshot.completed_steps) ? snapshot.completed_steps.length : null,
|
|
395
|
+
pending_steps: Array.isArray(snapshot.pending_steps) ? snapshot.pending_steps.length : null,
|
|
396
|
+
state_validation_errors: validationEvidence(snapshot),
|
|
397
|
+
} : null,
|
|
398
|
+
inspect_error: input.inspectError ? sanitizePaths(firstMeaningfulLine(input.inspectError), input.root, input.home) : null,
|
|
399
|
+
doctor: input.doctor ?? null,
|
|
400
|
+
rollover: rollover ? { id: rollover.id ?? null, status: rollover.status ?? null } : null,
|
|
401
|
+
recent_tool_failures: input.failures.slice(-20).map(failure => ({
|
|
402
|
+
...failure,
|
|
403
|
+
summary: sanitizePaths(failure.summary, input.root, input.home),
|
|
404
|
+
raw: sanitizePaths(failure.raw, input.root, input.home).slice(0, 2000),
|
|
405
|
+
})),
|
|
406
|
+
};
|
|
407
|
+
}
|