@guanlin4924/devflow-pi 0.1.95 → 0.1.97
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 +42 -2
- package/package.json +2 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +679 -91
- package/plugins/devflow-core/runtime/hooks/hook_common.py +245 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +18 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +98 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +6 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +64 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/explore_preflight.py +58 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_workflow.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/locator_map.py +156 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +50 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +60 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/startup_preflight.py +167 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +5 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +262 -56
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/regression-acceptance.yaml +126 -0
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +2 -0
- package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +103 -88
- package/plugins/devflow-quality/runtime/playwright/explore-host.mjs +879 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +297 -15
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +3 -2
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +4 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +10 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +6 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +4 -2
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +98 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/locator_report.py +73 -0
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +3 -1
- package/plugins/devflow-quality/skills/workflow-regression-test-design/SKILL.md +69 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +1 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +4 -2
- package/src/cli.ts +10 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +9 -0
- package/src/mcp/auth.ts +103 -0
- package/src/mcp/core.ts +102 -0
- package/src/mcp/server.ts +193 -0
- package/src/mcp/types.ts +5 -0
- package/src/startup-briefing.ts +109 -0
package/src/mcp/core.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { packageRoot } from "../paths.ts";
|
|
4
|
+
import type { CoreRunner, CoreResult } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export const runnerScript = path.join(packageRoot(), "plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py");
|
|
7
|
+
export const dslScript = path.join(packageRoot(), "plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 7_200_000;
|
|
10
|
+
const TERMINATION_GRACE_MS = 5_000;
|
|
11
|
+
const PROGRESS_INTERVAL_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
function terminateProcessGroup(pid: number | undefined, signal: "SIGTERM" | "SIGKILL"): void {
|
|
14
|
+
if (pid === undefined) return;
|
|
15
|
+
try { process.kill(-pid, signal); } catch { /* process group may already be gone */ }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function settleText(value: Promise<string>, timeoutMs: number): Promise<string> {
|
|
19
|
+
return Promise.race([
|
|
20
|
+
value,
|
|
21
|
+
new Promise<string>(resolve => setTimeout(() => resolve(""), timeoutMs)),
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const runPython: CoreRunner = async (script, args, cwd, stdin, timeoutMs = DEFAULT_TIMEOUT_MS, onProgress) => {
|
|
26
|
+
// -E/-s ignore PYTHON* variables and user site-packages. Not -I: isolated mode also drops the
|
|
27
|
+
// script directory from sys.path, and the Core scripts import their sibling modules.
|
|
28
|
+
const child = Bun.spawn(["python3", "-E", "-s", script, ...args], {
|
|
29
|
+
cwd,
|
|
30
|
+
detached: true,
|
|
31
|
+
stdin: "pipe",
|
|
32
|
+
stdout: "pipe",
|
|
33
|
+
stderr: "pipe",
|
|
34
|
+
});
|
|
35
|
+
const stdoutPromise = new Response(child.stdout).text();
|
|
36
|
+
const stderrPromise = new Response(child.stderr).text();
|
|
37
|
+
if (stdin !== undefined) child.stdin.write(stdin);
|
|
38
|
+
child.stdin.end();
|
|
39
|
+
const progressTimer = onProgress === undefined ? undefined : setInterval(() => {
|
|
40
|
+
void Promise.resolve(onProgress()).catch(() => undefined);
|
|
41
|
+
}, PROGRESS_INTERVAL_MS);
|
|
42
|
+
|
|
43
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
let timedOut = false;
|
|
45
|
+
const timeoutPromise = new Promise<"timeout">(resolve => {
|
|
46
|
+
timeoutHandle = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
47
|
+
});
|
|
48
|
+
const exitPromise = child.exited.then(exitCode => ({ exitCode }));
|
|
49
|
+
const winner = await Promise.race([exitPromise, timeoutPromise]);
|
|
50
|
+
if (winner === "timeout") {
|
|
51
|
+
timedOut = true;
|
|
52
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
53
|
+
try { child.kill("SIGTERM"); } catch { /* already exited */ }
|
|
54
|
+
await new Promise(resolve => setTimeout(resolve, TERMINATION_GRACE_MS));
|
|
55
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
56
|
+
try { child.kill("SIGKILL"); } catch { /* already exited */ }
|
|
57
|
+
if (progressTimer !== undefined) clearInterval(progressTimer);
|
|
58
|
+
clearTimeout(timeoutHandle);
|
|
59
|
+
return {
|
|
60
|
+
exitCode: null,
|
|
61
|
+
stdout: await settleText(stdoutPromise, 100),
|
|
62
|
+
stderr: await settleText(stderrPromise, 100),
|
|
63
|
+
timedOut,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
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
|
+
};
|
|
76
|
+
|
|
77
|
+
export async function invokeCore(
|
|
78
|
+
runner: CoreRunner,
|
|
79
|
+
action: string,
|
|
80
|
+
project: string,
|
|
81
|
+
feature: string,
|
|
82
|
+
timeoutMs?: number,
|
|
83
|
+
onProgress?: () => void | Promise<void>,
|
|
84
|
+
): Promise<CoreResult> {
|
|
85
|
+
const result = await runner(
|
|
86
|
+
runnerScript,
|
|
87
|
+
[action, "--feature", feature, "--json", ...(action === "run" ? ["--write"] : [])],
|
|
88
|
+
project,
|
|
89
|
+
undefined,
|
|
90
|
+
timeoutMs,
|
|
91
|
+
onProgress,
|
|
92
|
+
);
|
|
93
|
+
if (result.timedOut) return { ok: false, reason: "mcp_run_timeout", error_code: "mcp_run_timeout" };
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(result.stdout) as CoreResult;
|
|
96
|
+
} catch {
|
|
97
|
+
// Keep the tail of the diagnostic (where Python tracebacks end), without project paths.
|
|
98
|
+
const detail = (result.stderr || result.stdout || `Core exited ${result.exitCode}`).slice(-1000)
|
|
99
|
+
.replace(/(?:[A-Za-z]:)?[^\s,;()"']+[\/][^\s,;()"']+/g, "<path>");
|
|
100
|
+
return { ok: false, reason: "core_failed", error_code: "core_failed", errors: [detail] };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import { devflowHome } from "../paths.ts";
|
|
9
|
+
import { authorize, FEATURE_RE, loadAuthorization, MAX_DSL_BYTES, McpError, requestHash } from "./auth.ts";
|
|
10
|
+
import { dslScript, invokeCore, runPython } from "./core.ts";
|
|
11
|
+
import type { CoreRunner, McpServerOptions, McpToolName } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const RESPONSE_LIMIT = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
type ProgressCallback = () => void | Promise<void>;
|
|
16
|
+
|
|
17
|
+
function bounded(value: unknown): string {
|
|
18
|
+
const text = JSON.stringify(value);
|
|
19
|
+
if (text.length <= RESPONSE_LIMIT) return text;
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
ok: false,
|
|
22
|
+
reason: "response_truncated",
|
|
23
|
+
truncated: true,
|
|
24
|
+
output: text.slice(0, RESPONSE_LIMIT - 80),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toolResult(value: unknown) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text" as const, text: bounded(value) }],
|
|
31
|
+
isError: Boolean(value && typeof value === "object" && (value as { ok?: unknown }).ok === false),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function audit(home: string, row: Record<string, unknown>): Promise<void> {
|
|
36
|
+
const directory = path.join(home, "mcp-audit");
|
|
37
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
38
|
+
const file = path.join(directory, `${new Date().toISOString().slice(0, 7)}.jsonl`);
|
|
39
|
+
await appendFile(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function collectEnvironmentProfiles(value: unknown): Set<string> {
|
|
43
|
+
const profiles = new Set<string>();
|
|
44
|
+
const visit = (current: unknown): void => {
|
|
45
|
+
if (Array.isArray(current)) {
|
|
46
|
+
current.forEach(visit);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!current || typeof current !== "object") return;
|
|
50
|
+
const record = current as Record<string, unknown>;
|
|
51
|
+
if ((record.kind === "devflow_playwright" || record.adapter_kind === "devflow_playwright") && typeof record.environment_profile === "string") {
|
|
52
|
+
profiles.add(record.environment_profile);
|
|
53
|
+
}
|
|
54
|
+
Object.values(record).forEach(visit);
|
|
55
|
+
};
|
|
56
|
+
visit(value);
|
|
57
|
+
return profiles;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MAX_FAILURE_ERRORS = 50;
|
|
61
|
+
const MAX_FAILURE_ERROR_CHARS = 500;
|
|
62
|
+
|
|
63
|
+
// Failure details must not expose project or run locations (same rule as the Core report).
|
|
64
|
+
function scrubPaths(text: string): string {
|
|
65
|
+
return text
|
|
66
|
+
.replace(/(?:[A-Za-z]:)?[^\s,;()"']+[\/][^\s,;()"']+/g, "<path>")
|
|
67
|
+
.slice(0, MAX_FAILURE_ERROR_CHARS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A run that executed TCs always answers with the Core report, even when TCs failed or are
|
|
71
|
+
// blocked: the report's gate says whether acceptance passed. Only runs that never produced
|
|
72
|
+
// results (lock held, invalid bindings/approvals, Core crash) short-circuit with an error.
|
|
73
|
+
function runFailure(value: Record<string, unknown>): Record<string, unknown> {
|
|
74
|
+
const code = typeof value.error_code === "string" ? value.error_code
|
|
75
|
+
: typeof value.reason === "string" && value.reason ? value.reason
|
|
76
|
+
: Array.isArray(value.errors) && value.errors.length > 0 ? "acceptance_validation_failed"
|
|
77
|
+
: "core_failed";
|
|
78
|
+
const errors = Array.isArray(value.errors) ? value.errors.slice(0, MAX_FAILURE_ERRORS).map(item => scrubPaths(String(item))) : [];
|
|
79
|
+
return { ok: false, error_code: code, reason: code, errors };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function createMcpServer(options: McpServerOptions) {
|
|
83
|
+
const home = options.home ?? devflowHome();
|
|
84
|
+
const config = await loadAuthorization(options.client, home);
|
|
85
|
+
const runner: CoreRunner = options.runner ?? runPython;
|
|
86
|
+
let running = false;
|
|
87
|
+
|
|
88
|
+
const server = new McpServer({ name: "devflow-acceptance", version: "1.0.0" });
|
|
89
|
+
|
|
90
|
+
const call = async (tool: McpToolName, args: Record<string, unknown>, onProgress?: ProgressCallback): Promise<Record<string, unknown>> => {
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
let ok = false;
|
|
93
|
+
let reason: string | null = null;
|
|
94
|
+
try {
|
|
95
|
+
if (tool === "dsl_draft_validate") {
|
|
96
|
+
const dslText = JSON.stringify(args.dsl);
|
|
97
|
+
if (typeof dslText !== "string" || Buffer.byteLength(dslText) > MAX_DSL_BYTES) {
|
|
98
|
+
throw new McpError("mcp_input_invalid", "DSL exceeds 2 MiB or is not JSON");
|
|
99
|
+
}
|
|
100
|
+
const auth = await authorize(config, options.client, tool, args.project ?? process.cwd());
|
|
101
|
+
const raw = await runner(dslScript, [], auth.project, JSON.stringify({ dsl: args.dsl, tc_data: args.tc_data }));
|
|
102
|
+
if (raw.timedOut) return { ok: false, error_code: "core_failed", reason: "core_failed" };
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(raw.stdout) as Record<string, unknown>;
|
|
105
|
+
ok = parsed.ok === true;
|
|
106
|
+
return parsed;
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, error_code: "core_failed", reason: raw.stderr || raw.stdout };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const feature = args.feature;
|
|
113
|
+
const auth = await authorize(config, options.client, tool, args.project, feature);
|
|
114
|
+
if (tool === "run_feature" && running) throw new McpError("mcp_busy", "another run_feature is in progress");
|
|
115
|
+
|
|
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
|
+
running = true;
|
|
130
|
+
try {
|
|
131
|
+
const runResult = await invokeCore(
|
|
132
|
+
runner,
|
|
133
|
+
"run",
|
|
134
|
+
auth.project,
|
|
135
|
+
String(feature),
|
|
136
|
+
Math.min(7_200_000, Math.max(1, options.runTimeoutSeconds ?? 7200) * 1000),
|
|
137
|
+
onProgress,
|
|
138
|
+
);
|
|
139
|
+
if (!Array.isArray(runResult.results)) return runFailure(runResult);
|
|
140
|
+
const report = await invokeCore(runner, "report", auth.project, String(feature));
|
|
141
|
+
ok = report.ok === true;
|
|
142
|
+
return report;
|
|
143
|
+
} finally {
|
|
144
|
+
running = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const coreResult = await invokeCore(runner, tool === "validate_feature" ? "validate" : "report", auth.project, String(feature));
|
|
149
|
+
ok = coreResult.ok === true;
|
|
150
|
+
return coreResult;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
const mcpError = error instanceof McpError
|
|
153
|
+
? error
|
|
154
|
+
: new McpError("core_failed", error instanceof Error ? error.message : String(error));
|
|
155
|
+
reason = mcpError.code;
|
|
156
|
+
return { ok: false, error_code: mcpError.code, reason: mcpError.message };
|
|
157
|
+
} finally {
|
|
158
|
+
await audit(home, {
|
|
159
|
+
ts: new Date().toISOString(),
|
|
160
|
+
client: options.client,
|
|
161
|
+
project_id: typeof args.project === "string" ? requestHash(path.resolve(args.project)) : null,
|
|
162
|
+
tool,
|
|
163
|
+
feature: args.feature ?? null,
|
|
164
|
+
ok,
|
|
165
|
+
reason,
|
|
166
|
+
duration_ms: Date.now() - started,
|
|
167
|
+
request_sha256: requestHash(args),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const input = z.object({ project: z.string(), feature: z.string().regex(FEATURE_RE) });
|
|
173
|
+
const withProgress = (tool: McpToolName) => (args: any, extra: any) => {
|
|
174
|
+
const token = extra._meta?.progressToken;
|
|
175
|
+
const progress = token === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: () => extra.sendNotification({ method: "notifications/progress", params: { progressToken: token, progress: Date.now(), message: "run_feature is still running" } });
|
|
178
|
+
return call(tool, args, progress).then(toolResult);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
server.registerTool("validate_feature", { description: "Validate a feature; no artifacts are returned.", inputSchema: input.shape }, withProgress("validate_feature"));
|
|
182
|
+
server.registerTool("run_feature", { description: "Run a feature, then return only the Core report. Approval is never confirmed here; blocked tests require /devflow.", inputSchema: input.shape }, withProgress("run_feature"));
|
|
183
|
+
server.registerTool("get_feature_report", { description: "Get the Core feature report; no artifacts are returned.", inputSchema: input.shape }, withProgress("get_feature_report"));
|
|
184
|
+
server.registerTool("dsl_draft_validate", { description: "Validate DSL schema and coverage only; this does not make the draft executable and does not check approvals or manifests.", inputSchema: { project: z.string(), dsl: z.unknown(), tc_data: z.unknown() } }, withProgress("dsl_draft_validate"));
|
|
185
|
+
|
|
186
|
+
Object.assign(server, { invoke: call, boundedForTest: bounded });
|
|
187
|
+
return server;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function startMcpServer(options: McpServerOptions): Promise<void> {
|
|
191
|
+
const server = await createMcpServer(options);
|
|
192
|
+
await server.connect(new StdioServerTransport());
|
|
193
|
+
}
|
package/src/mcp/types.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type McpToolName = "validate_feature" | "run_feature" | "get_feature_report" | "dsl_draft_validate";
|
|
2
|
+
export type CoreResult = { ok: boolean; [key: string]: unknown };
|
|
3
|
+
export type CoreRunnerResult = { exitCode: number | null; stdout: string; stderr: string; timedOut?: boolean };
|
|
4
|
+
export type CoreRunner = (script: string, args: string[], cwd: string, stdin?: string, timeoutMs?: number, onProgress?: () => void | Promise<void>) => Promise<CoreRunnerResult>;
|
|
5
|
+
export type McpServerOptions = { client: string; runTimeoutSeconds?: number; runner?: CoreRunner; home?: string };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export type StartupIssue = { code?: unknown; detail?: unknown; path?: unknown };
|
|
2
|
+
|
|
3
|
+
type StartupFacts = {
|
|
4
|
+
feature?: unknown;
|
|
5
|
+
phase?: unknown;
|
|
6
|
+
step?: { id?: unknown; label?: unknown; skill?: unknown; description?: unknown };
|
|
7
|
+
progress?: { index?: unknown; total?: unknown };
|
|
8
|
+
inputs?: Array<{ path?: unknown; required?: unknown; exists?: unknown }>;
|
|
9
|
+
outputs?: unknown[];
|
|
10
|
+
allowed_paths?: unknown[];
|
|
11
|
+
contract_id?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type StartupHandoff = {
|
|
15
|
+
previous_step?: { id?: unknown; label?: unknown; outputs?: Array<{ path?: unknown }> };
|
|
16
|
+
carry?: { user_notes?: unknown[]; agent_report?: unknown };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type StartupMeta = { revision?: unknown; handoffProvided?: boolean };
|
|
20
|
+
|
|
21
|
+
const issueDescriptions: Record<string, (issue: StartupIssue) => string> = {
|
|
22
|
+
PREVIOUS_OUTPUT_CHANGED: issue => `上一步的产物 ${String(issue.path ?? "未知路径")} 在交接后被修改过`,
|
|
23
|
+
PREVIOUS_OUTPUT_MISSING: issue => `上一步的产物 ${String(issue.path ?? "未知路径")} 不存在`,
|
|
24
|
+
HANDOFF_UNAVAILABLE: issue => `无法读取或校验交接单${issue.path ? ` ${String(issue.path)}` : ""}`,
|
|
25
|
+
STEP_UNAVAILABLE: () => "当前步骤不在工作流中",
|
|
26
|
+
MISSING_INPUT_FACT: issue => `本步需要的输入 ${String(issue.path ?? "未知路径")} 不存在`,
|
|
27
|
+
STALE_EXECUTION_CONTRACT: () => "执行合同已过期,请重新激活",
|
|
28
|
+
STALE_WORKFLOW_SNAPSHOT: () => "工作流快照已变化,执行合同不再有效",
|
|
29
|
+
STALE_STATE_SNAPSHOT: () => "STATE 快照已变化,执行合同不再有效",
|
|
30
|
+
UNREACHABLE_OBLIGATION: issue => `执行合同不允许产出 ${String(issue.path ?? "该必需产物")}`,
|
|
31
|
+
CONTRACT_TARGET_MISMATCH: () => "执行合同与当前 Feature 或步骤不匹配",
|
|
32
|
+
STARTUP_PREFLIGHT_INPUT_UNAVAILABLE: issue => `开工预检输入不可用${issue.detail ? `:${String(issue.detail)}` : ""}`,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function describeStartupIssue(issue: StartupIssue): string {
|
|
36
|
+
const code = typeof issue.code === "string" && issue.code ? issue.code : "UNKNOWN_ISSUE";
|
|
37
|
+
return issueDescriptions[code]?.(issue) ?? `${code}:${String(issue.detail ?? "无详细信息")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function strings(values: unknown[] | undefined): string[] {
|
|
41
|
+
return Array.isArray(values) ? values.filter((value): value is string => typeof value === "string" && value.length > 0) : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function truncate(value: string, limit: number): string {
|
|
45
|
+
if (value.length <= limit) return value;
|
|
46
|
+
return limit <= 1 ? "…".slice(0, limit) : `${value.slice(0, limit - 1)}…`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderStartupBriefing(facts: StartupFacts, handoff: StartupHandoff = {}, meta: StartupMeta = {}): string {
|
|
50
|
+
const render = (report: string, notes: string[]): string => {
|
|
51
|
+
const sections: string[] = [];
|
|
52
|
+
const step = facts.step;
|
|
53
|
+
const progress = facts.progress;
|
|
54
|
+
if (facts.feature && step?.id && step.label && progress?.index !== undefined && progress.total !== undefined) {
|
|
55
|
+
sections.push(`【需求】${String(facts.feature)} · 第 ${String(progress.index)} / ${String(progress.total)} 步:${String(step.label)}(${String(step.id)})`);
|
|
56
|
+
}
|
|
57
|
+
if (step?.description) sections.push(`【本步目标】${String(step.description)}`);
|
|
58
|
+
|
|
59
|
+
const previous = handoff.previous_step;
|
|
60
|
+
const previousOutputs = previous?.outputs?.flatMap(item => typeof item?.path === "string" ? [item.path] : []) ?? [];
|
|
61
|
+
const handoffLines: string[] = [];
|
|
62
|
+
if (previous?.label) {
|
|
63
|
+
handoffLines.push(`- 上一步「${String(previous.label)}」已完成,产物:${previousOutputs.join("、") || "无已记录产物"}`);
|
|
64
|
+
}
|
|
65
|
+
if (notes.length > 0) {
|
|
66
|
+
handoffLines.push("- 用户在上一步审查期间的意见:", ...notes.map(note => `- ${note}`));
|
|
67
|
+
}
|
|
68
|
+
if (report) handoffLines.push(`- 上一步 AI 报告摘要:${report}`);
|
|
69
|
+
if (handoffLines.length > 0) sections.push(["【上一棒交接】", ...handoffLines].join("\n"));
|
|
70
|
+
|
|
71
|
+
const work: string[] = [];
|
|
72
|
+
if (step?.skill) work.push(`- 使用技能:skill://${String(step.skill)}`);
|
|
73
|
+
const inputText = facts.inputs?.flatMap(input => {
|
|
74
|
+
if (typeof input?.path !== "string") return [];
|
|
75
|
+
if (input.required === true && input.exists === true) return [`${input.path} ✓`];
|
|
76
|
+
if (input.required === false && input.exists !== true) return [`${input.path}(可选,缺失)`];
|
|
77
|
+
return [input.path];
|
|
78
|
+
}) ?? [];
|
|
79
|
+
if (inputText.length > 0) work.push(`- 已核对的输入:${inputText.join("/")}`);
|
|
80
|
+
const outputs = strings(facts.outputs);
|
|
81
|
+
if (outputs.length > 0) work.push(`- 必须产出:${outputs.join("、")}`);
|
|
82
|
+
const allowedPaths = strings(facts.allowed_paths);
|
|
83
|
+
if (allowedPaths.length > 0) work.push(`- 只允许写入:${allowedPaths.join("、")}`);
|
|
84
|
+
if (facts.contract_id) work.push(`- 执行合同已由 Runtime 激活:${String(facts.contract_id)}(不要再次 inspect 或 activate)`);
|
|
85
|
+
if (work.length > 0) sections.push(["【这一步你要做】", ...work].join("\n"));
|
|
86
|
+
|
|
87
|
+
sections.push("【完成标准】产出完成后,报告证据、未决项和验证结果并停止,等待用户回复\"批准执行\"。");
|
|
88
|
+
if (meta.revision !== undefined || facts.contract_id) {
|
|
89
|
+
sections.push(`【预检记录】STATE revision ${String(meta.revision ?? "未知")} · 环境体检通过 · 交接单校验${meta.handoffProvided ? "通过" : "不适用"} · 合同 ${String(facts.contract_id ?? "无")}`);
|
|
90
|
+
}
|
|
91
|
+
return sections.join("\n");
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let report = typeof handoff.carry?.agent_report === "string" ? handoff.carry.agent_report : "";
|
|
95
|
+
let notes = handoff.carry?.user_notes?.flatMap(note => typeof note === "string" ? [note] : []) ?? [];
|
|
96
|
+
let result = render(report, notes);
|
|
97
|
+
if (result.length > 6000 && report) {
|
|
98
|
+
report = truncate(report, Math.max(0, report.length - (result.length - 6000)));
|
|
99
|
+
result = render(report, notes);
|
|
100
|
+
}
|
|
101
|
+
while (result.length > 6000 && notes.length > 0) {
|
|
102
|
+
const last = notes.at(-1)!;
|
|
103
|
+
const excess = result.length - 6000;
|
|
104
|
+
if (last.length > excess + 1) notes = [...notes.slice(0, -1), truncate(last, last.length - excess)];
|
|
105
|
+
else notes = notes.slice(0, -1);
|
|
106
|
+
result = render(report, notes);
|
|
107
|
+
}
|
|
108
|
+
return result.length <= 6000 ? result : truncate(result, 6000);
|
|
109
|
+
}
|