@viccydev/pi-fpa 0.7.2 → 0.8.1

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.
@@ -0,0 +1,234 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const TEMPLATE_NAMES = ["fpa-strategy-planning", "fpa-forecast-freeze"] as const;
7
+ const RETIRED_NAMES = ["fpa-period-analysis", "fpa-strategy-recommendation"] as const;
8
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
9
+
10
+ interface GraphInstallResult {
11
+ installed: string[];
12
+ upgraded: string[];
13
+ retired: string[];
14
+ backups: string[];
15
+ warnings: string[];
16
+ }
17
+
18
+ interface GraphIdentity {
19
+ name?: string;
20
+ version?: string;
21
+ }
22
+
23
+ async function pathStat(path: string) {
24
+ try {
25
+ return await lstat(path);
26
+ } catch (error) {
27
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ async function secureGraphsDirectory(cwd: string): Promise<{ agentGraph: string; graphs: string }> {
33
+ const cwdReal = await realpath(cwd);
34
+ const agentGraph = join(cwdReal, ".agent-graph");
35
+ const graphs = join(agentGraph, "graphs");
36
+ for (const directory of [agentGraph, graphs]) {
37
+ let existing = await pathStat(directory);
38
+ if (!existing) {
39
+ try {
40
+ await mkdir(directory);
41
+ } catch (error) {
42
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
43
+ }
44
+ existing = await pathStat(directory);
45
+ }
46
+ if (!existing?.isDirectory() || existing.isSymbolicLink()) {
47
+ throw new Error(`FP&A Graph directory must be a real directory: ${directory}`);
48
+ }
49
+ if (await realpath(directory) !== directory) throw new Error(`FP&A Graph directory escapes the trusted cwd: ${directory}`);
50
+ }
51
+ return { agentGraph, graphs };
52
+ }
53
+
54
+ async function secureChildDirectory(parent: string, directory: string): Promise<void> {
55
+ if (dirname(directory) !== parent) throw new Error(`FP&A Graph backup directory escapes its parent: ${directory}`);
56
+ let existing = await pathStat(directory);
57
+ if (!existing) {
58
+ try {
59
+ await mkdir(directory);
60
+ } catch (error) {
61
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
62
+ }
63
+ existing = await pathStat(directory);
64
+ }
65
+ if (!existing?.isDirectory() || existing.isSymbolicLink() || await realpath(directory) !== directory) {
66
+ throw new Error(`FP&A Graph backup directory must be a real directory inside the trusted cwd: ${directory}`);
67
+ }
68
+ }
69
+
70
+ function graphIdentity(content: string): GraphIdentity {
71
+ try {
72
+ const parsed = JSON.parse(content) as Record<string, unknown>;
73
+ return {
74
+ name: typeof parsed.name === "string" ? parsed.name : undefined,
75
+ version: typeof parsed.version === "string" ? parsed.version : undefined,
76
+ };
77
+ } catch {
78
+ return {};
79
+ }
80
+ }
81
+
82
+ function compareVersions(left: string | undefined, right: string | undefined): number | undefined {
83
+ const parse = (value: string | undefined) => value?.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
84
+ const a = parse(left);
85
+ const b = parse(right);
86
+ if (!a || !b) return undefined;
87
+ for (let index = 0; index < 3; index += 1) {
88
+ if (a[index] !== b[index]) return a[index] - b[index];
89
+ }
90
+ return 0;
91
+ }
92
+
93
+ function safeVersion(value: string | undefined): string {
94
+ return value?.match(/^\d+\.\d+\.\d+$/)?.[0] ?? "unknown";
95
+ }
96
+
97
+ async function atomicCreate(path: string, content: string): Promise<void> {
98
+ await writeFile(path, content, { flag: "wx" });
99
+ }
100
+
101
+ async function backupFile(agentGraph: string, path: string, content: string, version: string | undefined, removeSource: boolean): Promise<string> {
102
+ const backupDirectory = join(agentGraph, `graphs_backup_v${safeVersion(version)}`);
103
+ await secureChildDirectory(agentGraph, backupDirectory);
104
+ let destination = join(backupDirectory, basename(path));
105
+ let existing = await pathStat(destination);
106
+ if (existing) {
107
+ if (!existing.isFile() || existing.isSymbolicLink()) throw new Error(`Unsafe FP&A Graph backup target: ${destination}`);
108
+ const existingContent = await readFile(destination, "utf8");
109
+ if (existingContent === content) {
110
+ if (removeSource) await unlink(path);
111
+ return destination;
112
+ }
113
+ const fingerprint = createHash("sha256").update(content).digest("hex").slice(0, 12);
114
+ destination = join(backupDirectory, `${basename(path, ".json")}.${fingerprint}.json`);
115
+ existing = await pathStat(destination);
116
+ if (existing) {
117
+ if (!existing.isFile() || existing.isSymbolicLink() || await readFile(destination, "utf8") !== content) {
118
+ throw new Error(`Conflicting FP&A Graph backup target: ${destination}`);
119
+ }
120
+ if (removeSource) await unlink(path);
121
+ return destination;
122
+ }
123
+ }
124
+ await atomicCreate(destination, content);
125
+ if (removeSource) await unlink(path);
126
+ return destination;
127
+ }
128
+
129
+ async function atomicWrite(path: string, content: string): Promise<void> {
130
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
131
+ await writeFile(temporary, content, { flag: "wx" });
132
+ try {
133
+ await rename(temporary, path);
134
+ } catch (error) {
135
+ await unlink(temporary).catch(() => undefined);
136
+ throw error;
137
+ }
138
+ }
139
+
140
+ async function withInstallerLock<T>(agentGraph: string, timeoutMs: number, action: () => Promise<T>): Promise<T> {
141
+ const lockPath = join(agentGraph, ".fpa-graph-installer.lock");
142
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
143
+ let ownership: { dev: number | bigint; ino: number | bigint } | undefined;
144
+ const deadline = Date.now() + Math.max(0, timeoutMs);
145
+ while (!handle) {
146
+ try {
147
+ const candidate = await open(lockPath, "wx", 0o600);
148
+ try {
149
+ await candidate.writeFile(`${process.pid} ${Date.now()}\n`);
150
+ const stat = await candidate.stat();
151
+ ownership = { dev: stat.dev, ino: stat.ino };
152
+ handle = candidate;
153
+ } catch (error) {
154
+ await candidate.close().catch(() => undefined);
155
+ await unlink(lockPath).catch(() => undefined);
156
+ throw error;
157
+ }
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
160
+ const stat = await pathStat(lockPath);
161
+ if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe FP&A Graph installer lock: ${lockPath}`);
162
+ // Never steal a pathname from an unknown owner: an old holder could
163
+ // otherwise unlink the replacement lock from its own finally block.
164
+ if (Date.now() >= deadline) break;
165
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
166
+ }
167
+ }
168
+ if (!handle) throw new Error(`Timed out waiting for FP&A Graph installer lock: ${lockPath}`);
169
+ try {
170
+ return await action();
171
+ } finally {
172
+ await handle.close();
173
+ const current = await pathStat(lockPath);
174
+ if (
175
+ ownership
176
+ && current?.isFile()
177
+ && !current.isSymbolicLink()
178
+ && current.dev === ownership.dev
179
+ && current.ino === ownership.ino
180
+ ) await unlink(lockPath).catch(() => undefined);
181
+ }
182
+ }
183
+
184
+ export async function ensureFpaProjectGraphs(options: { cwd: string; trusted: boolean; lockTimeoutMs?: number }): Promise<GraphInstallResult> {
185
+ const result: GraphInstallResult = { installed: [], upgraded: [], retired: [], backups: [], warnings: [] };
186
+ if (!options.trusted) return result;
187
+ const { agentGraph, graphs } = await secureGraphsDirectory(options.cwd);
188
+ return withInstallerLock(agentGraph, options.lockTimeoutMs ?? 5_000, async () => {
189
+ for (const name of TEMPLATE_NAMES) {
190
+ const sourcePath = join(PACKAGE_ROOT, "graphs", `${name}.json`);
191
+ const sourceContent = await readFile(sourcePath, "utf8");
192
+ const sourceIdentity = graphIdentity(sourceContent);
193
+ if (sourceIdentity.name !== name || !sourceIdentity.version) throw new Error(`Invalid packaged FP&A Graph template: ${sourcePath}`);
194
+ const targetPath = join(graphs, `${name}.json`);
195
+ const targetStat = await pathStat(targetPath);
196
+ if (!targetStat) {
197
+ await atomicWrite(targetPath, sourceContent);
198
+ result.installed.push(name);
199
+ continue;
200
+ }
201
+ if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
202
+ result.warnings.push(`Preserved unsafe or non-file Graph target: ${targetPath}`);
203
+ continue;
204
+ }
205
+ const targetContent = await readFile(targetPath, "utf8");
206
+ if (targetContent === sourceContent) continue;
207
+ const targetIdentity = graphIdentity(targetContent);
208
+ const comparison = compareVersions(targetIdentity.version, sourceIdentity.version);
209
+ if (targetIdentity.name === name && comparison !== undefined && comparison > 0) {
210
+ result.warnings.push(`Preserved newer project Graph ${name}@${targetIdentity.version}; package provides ${sourceIdentity.version}.`);
211
+ continue;
212
+ }
213
+ const backup = await backupFile(agentGraph, targetPath, targetContent, targetIdentity.version, false);
214
+ result.backups.push(backup);
215
+ await atomicWrite(targetPath, sourceContent);
216
+ result.upgraded.push(name);
217
+ }
218
+
219
+ for (const name of RETIRED_NAMES) {
220
+ const path = join(graphs, `${name}.json`);
221
+ const stat = await pathStat(path);
222
+ if (!stat) continue;
223
+ if (!stat.isFile() || stat.isSymbolicLink()) {
224
+ result.warnings.push(`Preserved unsafe or non-file retired Graph target: ${path}`);
225
+ continue;
226
+ }
227
+ const content = await readFile(path, "utf8");
228
+ const backup = await backupFile(agentGraph, path, content, graphIdentity(content).version, true);
229
+ result.backups.push(backup);
230
+ result.retired.push(name);
231
+ }
232
+ return result;
233
+ });
234
+ }
@@ -5,7 +5,10 @@ import type {
5
5
  ToolResultEvent,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
 
8
+ import { ensureFpaProjectGraphs } from "./graph-installer.ts";
9
+
8
10
  const GRAPH_TOOLS = ["graph_list", "graph_run"] as const;
11
+ const ROUTING_STATE_CUSTOM_TYPE = "fpa-routing-guard-state";
9
12
  const FP_AND_A_GRAPHS = [
10
13
  "fpa-strategy-planning",
11
14
  "fpa-forecast-freeze",
@@ -19,6 +22,15 @@ interface RoutingState {
19
22
  catalogLoaded: boolean;
20
23
  availableGraphs: Set<string>;
21
24
  graphRunStarted: boolean;
25
+ graphRunCompleted: boolean;
26
+ reviewPublished: boolean;
27
+ }
28
+
29
+ interface PersistedRoutingState {
30
+ version: 1;
31
+ requiredGraph: FpaGraph;
32
+ graphRunCompleted: boolean;
33
+ reviewPublished: boolean;
22
34
  }
23
35
 
24
36
  function emptyState(): RoutingState {
@@ -26,6 +38,8 @@ function emptyState(): RoutingState {
26
38
  catalogLoaded: false,
27
39
  availableGraphs: new Set(),
28
40
  graphRunStarted: false,
41
+ graphRunCompleted: false,
42
+ reviewPublished: false,
29
43
  };
30
44
  }
31
45
 
@@ -56,6 +70,10 @@ function isExplicitIsolatedPhase(prompt: string): boolean {
56
70
  const skill = prompt.match(/^<skill name="(fpa-[^"]+)"/);
57
71
  if (skill && skill[1] !== "fpa-apply-core-rules") return true;
58
72
  if (!/(?:^|[。;;]\s*)(?:请)?(?:本次)?(?:只|仅)|\bonly\b/i.test(prompt)) return false;
73
+ if (
74
+ FP_AND_A_GRAPHS.some((graph) => prompt.toLowerCase().includes(graph))
75
+ && /(?:运行|执行|启动|调用|使用|通过|\brun\b|\bexecute\b|\bstart\b)/i.test(prompt)
76
+ ) return false;
59
77
  return !/(?:然后|接着|随后|再进入|后再|直至|全流程|完整流程|end[- ]to[- ]end)/i.test(prompt);
60
78
  }
61
79
 
@@ -144,30 +162,93 @@ function artifactWrite(event: ToolCallEvent): boolean {
144
162
  return /artifacts?[\\/]/i.test(command);
145
163
  }
146
164
 
147
- function routingInstruction(graph: FpaGraph): string {
148
- return [
165
+ function graphContextError(event: ToolCallEvent, graph: FpaGraph): string | undefined {
166
+ const context = "context" in event.input ? event.input.context : undefined;
167
+ if (!context || typeof context !== "object" || Array.isArray(context)) {
168
+ return "graph_run requires a context object whose values are strings.";
169
+ }
170
+ const values = context as Record<string, unknown>;
171
+ const required = graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze"
172
+ ? ["scope_id", "cycle_id", "forecast_role"]
173
+ : [];
174
+ const missing = required.filter((key) => !(key in values) || (typeof values[key] === "string" && values[key].trim().length === 0));
175
+ if (missing.length > 0) return `graph_run requires non-empty string context keys: ${missing.join(", ")}.`;
176
+ const nonStrings = Object.entries(values)
177
+ .filter(([, value]) => typeof value !== "string")
178
+ .map(([key]) => key);
179
+ return nonStrings.length > 0
180
+ ? `All graph_run context values must be strings; invalid keys: ${nonStrings.join(", ")}. Put structured data in goal.`
181
+ : undefined;
182
+ }
183
+
184
+ function routingInstruction(graph: FpaGraph, resumeCompletedGraph = false): string {
185
+ const instructions = [
149
186
  "FP&A runtime routing guard is active for this request.",
150
187
  `The first eligible workflow is ${graph}.`,
151
- "Call graph_list first, then graph_run with that exact graph and the complete immutable context.",
152
- "Do not call fpa_* tools or write artifacts in the parent agent; Graph nodes own phase execution.",
153
- ].join(" ");
188
+ "Call graph_list first, then graph_run with that exact graph. All graph_run context values must be strings; never pass arrays, objects, numbers, booleans, or null.",
189
+ "Graph nodes own business phase execution. After a successful Graph handoff, only the main Agent may call the matching fpa_dashboard_publish_* tool; Graphs must never publish dashboard data.",
190
+ ];
191
+ if (graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze") {
192
+ instructions.push("Provide the immutable non-empty string context keys scope_id, cycle_id, and forecast_role. Put structured planning details in goal, not context.");
193
+ }
194
+ if (graph === "fpa-strategy-planning") {
195
+ instructions.push(resumeCompletedGraph
196
+ ? "A completed combined Graph handoff was restored from this session. Continue modular publication from its persisted artifacts; do not rerun the Graph unless those artifacts are unavailable or the user changed the planning requirements. Publish period-review before next-strategy."
197
+ : "After this combined Graph completes, the main Agent must publish period-review first from artifacts/driver_analysis.json, then publish next-strategy from artifacts/strategy_proposal.json, artifacts/strategy_review.json, and artifacts/reviewed_strategy_handoff.json with the exact scope_id, cycle_id, forecast_role, and a fresh dashboard revision. Stop for the dashboard decision; do not run forecast yet.");
198
+ }
199
+ return instructions.join(" ");
154
200
  }
155
201
 
156
202
  export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
157
203
  let state = emptyState();
204
+ let installWarning: string | undefined;
205
+
206
+ function persistState(): void {
207
+ if (!state.requiredGraph) return;
208
+ pi.appendEntry(ROUTING_STATE_CUSTOM_TYPE, {
209
+ version: 1,
210
+ requiredGraph: state.requiredGraph,
211
+ graphRunCompleted: state.graphRunCompleted,
212
+ reviewPublished: state.reviewPublished,
213
+ } satisfies PersistedRoutingState);
214
+ }
215
+
216
+ pi.on("session_start", async (_event, ctx) => {
217
+ state = emptyState();
218
+ installWarning = undefined;
219
+ try {
220
+ const installed = await ensureFpaProjectGraphs({ cwd: ctx.cwd, trusted: ctx.isProjectTrusted() });
221
+ if (installed.warnings.length > 0) installWarning = installed.warnings.join(" ");
222
+ } catch (error) {
223
+ installWarning = error instanceof Error ? error.message : String(error);
224
+ }
225
+ const restored = [...ctx.sessionManager.getBranch()].reverse().find((entry) => entry.type === "custom" && entry.customType === ROUTING_STATE_CUSTOM_TYPE);
226
+ if (!restored || restored.type !== "custom" || !restored.data || typeof restored.data !== "object" || Array.isArray(restored.data)) return;
227
+ const data = restored.data as Partial<PersistedRoutingState>;
228
+ if (data.version !== 1 || !FP_AND_A_GRAPHS.includes(data.requiredGraph as FpaGraph)) return;
229
+ state.requiredGraph = data.requiredGraph;
230
+ state.graphRunStarted = data.graphRunCompleted === true;
231
+ state.graphRunCompleted = data.graphRunCompleted === true;
232
+ state.reviewPublished = data.graphRunCompleted === true && data.reviewPublished === true;
233
+ });
158
234
 
159
235
  pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
160
- const previousGraph = state.requiredGraph;
236
+ const previousState = state;
161
237
  state = emptyState();
162
238
  const active = new Set(pi.getActiveTools());
163
239
  if (!GRAPH_TOOLS.every((tool) => active.has(tool))) return;
164
240
 
165
241
  const classified = classifyFpaGraph(event.prompt);
166
242
  const isContinuation = /^(?:继续|接着|下一步|continue|go on)[。.!!\s]*$/i.test(event.prompt.trim());
167
- const requiredGraph = classified ?? (isContinuation ? previousGraph : undefined);
243
+ const requiredGraph = classified ?? (isContinuation ? previousState.requiredGraph : undefined);
168
244
  if (!requiredGraph) return;
169
245
  state.requiredGraph = requiredGraph;
170
- return { systemPrompt: `${event.systemPrompt}\n\n${routingInstruction(requiredGraph)}` };
246
+ if (isContinuation && previousState.requiredGraph === requiredGraph) {
247
+ state.graphRunStarted = previousState.graphRunCompleted;
248
+ state.graphRunCompleted = previousState.graphRunCompleted;
249
+ state.reviewPublished = previousState.reviewPublished;
250
+ }
251
+ return { systemPrompt: `${event.systemPrompt}\n\n${routingInstruction(requiredGraph, state.graphRunCompleted)}${installWarning ? `\n\nFP&A Graph provisioning warning: ${installWarning}` : ""}` };
171
252
  });
172
253
 
173
254
  pi.on("tool_call", (event: ToolCallEvent) => {
@@ -189,7 +270,26 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
189
270
  reason: `${graph} is not available. Return blocked; local work requires a new, explicit isolated-phase request.`,
190
271
  };
191
272
  }
273
+ const contextError = graphContextError(event, graph);
274
+ if (contextError) return { block: true, reason: contextError };
192
275
  state.graphRunStarted = true;
276
+ state.graphRunCompleted = false;
277
+ state.reviewPublished = false;
278
+ persistState();
279
+ return;
280
+ }
281
+
282
+ if (event.toolName === "fpa_dashboard_module_status") return;
283
+ if ((graph === "fpa-forecast-freeze" || graph === "fpa-strategy-planning") && !state.graphRunStarted && event.toolName === "fpa_strategy_decision_commit") return;
284
+ const allowedPublications = graph === "fpa-strategy-planning"
285
+ ? new Set(["fpa_dashboard_publish_review", "fpa_dashboard_publish_strategy"])
286
+ : graph === "fpa-forecast-freeze"
287
+ ? new Set(["fpa_dashboard_publish_forecast"])
288
+ : new Set(["fpa_dashboard_refresh_queue"]);
289
+ if (allowedPublications.has(event.toolName) && state.graphRunCompleted) {
290
+ if (graph === "fpa-strategy-planning" && event.toolName === "fpa_dashboard_publish_strategy" && !state.reviewPublished) {
291
+ return { block: true, reason: "Publish the period-review module successfully before previewing or publishing next-strategy." };
292
+ }
193
293
  return;
194
294
  }
195
295
 
@@ -213,8 +313,28 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
213
313
  });
214
314
 
215
315
  pi.on("tool_result", (event: ToolResultEvent) => {
216
- if (!state.requiredGraph || event.toolName !== "graph_list" || event.isError) return;
217
- state.catalogLoaded = true;
218
- state.availableGraphs = new Set(catalogNames(event));
316
+ if (!state.requiredGraph || event.isError) return;
317
+ if (event.toolName === "graph_list") {
318
+ state.catalogLoaded = true;
319
+ state.availableGraphs = new Set(catalogNames(event));
320
+ return;
321
+ }
322
+ if (event.toolName === "graph_run" && state.graphRunStarted) {
323
+ const status = event.details && typeof event.details === "object" && "status" in event.details
324
+ ? (event.details as { status?: unknown }).status
325
+ : undefined;
326
+ state.graphRunCompleted = status !== "failed" && status !== "blocked" && status !== "cancelled";
327
+ persistState();
328
+ return;
329
+ }
330
+ if (event.toolName === "fpa_dashboard_publish_review" && state.graphRunCompleted) {
331
+ const status = event.details && typeof event.details === "object" && "status" in event.details
332
+ ? (event.details as { status?: unknown }).status
333
+ : undefined;
334
+ if (status === "published") {
335
+ state.reviewPublished = true;
336
+ persistState();
337
+ }
338
+ }
219
339
  });
220
340
  }
@@ -0,0 +1,95 @@
1
+ {
2
+ "description": "FP&A 正式预测冻结:消费已审核策略交接与主 Agent 已提交的精确 strategy_decision,生成、合成并冻结 approved_cycle_forecast;Graph 不请求审批、不发布仪表盘。",
3
+ "maxSteps": 10,
4
+ "mutationPolicy": "mutating",
5
+ "name": "fpa-forecast-freeze",
6
+ "nodes": [
7
+ {
8
+ "agentName": "load_confirmed_strategy",
9
+ "id": "load_confirmed_strategy",
10
+ "label": "核验已确认策略",
11
+ "next": "route_confirmed_strategy",
12
+ "outputKey": "confirmed_strategy",
13
+ "parseJson": true,
14
+ "prompt": "操作备注:{{goal}}\n\n读取 artifacts/reviewed_strategy_handoff.json、其中绑定的 strategy_proposal 与 strategy_review,以及操作备注明确给出的 strategy_decision path。必须核验 handoff status=ready;scope_id/cycle_id/forecast_role 与 Graph context 一致;proposal、review、handoff 的 strategy_version 一致;decision.kind=fpa.strategy.decision、decision=confirm;decision 的 strategy_version、handoff_fingerprint、decision_fingerprint 与操作备注和当前 handoff 精确一致。任一不一致即 blocked。不得请求或记录第二次审批,不得调用任何 fpa_dashboard_* 工具。\n\n只输出 JSON:{\"status\":\"ready|blocked\",\"strategy_version\":\"...\",\"strategy_decision_path\":\"...\",\"strategy_decision_fingerprint\":\"...\",\"review_conditions\":[],\"blockers\":[]}",
15
+ "skills": ["fpa-apply-core-rules"],
16
+ "systemPrompt": "你是 FP&A 已确认策略核验 Agent。只读核验主 Agent 已提交的不可变决策,不批准、不预测、不发布仪表盘。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}、forecast_role={{context.forecast_role}}。",
17
+ "tools": ["read"],
18
+ "type": "subagent"
19
+ },
20
+ {
21
+ "cases": [
22
+ { "equals": "ready", "label": "确认有效", "to": "draft_forecast" },
23
+ { "equals": "blocked", "label": "确认无效", "to": "report_blocked" }
24
+ ],
25
+ "default": { "label": "其他结果", "to": "report_blocked" },
26
+ "id": "route_confirmed_strategy",
27
+ "label": "判断是否可预测",
28
+ "path": "data.confirmed_strategy.status",
29
+ "type": "router"
30
+ },
31
+ {
32
+ "id": "report_blocked",
33
+ "label": "报告预测阻断",
34
+ "outputKey": "forecast_blocked",
35
+ "parseJson": true,
36
+ "prompt": "核验结果:{{data.confirmed_strategy}}\n\n只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"由原主会话提交与当前 handoff 精确匹配的策略确认\"}",
37
+ "systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
38
+ "type": "prompt"
39
+ },
40
+ {
41
+ "agentName": "draft_forecast",
42
+ "id": "draft_forecast",
43
+ "label": "正式预测决策计划",
44
+ "next": "compose_forecast",
45
+ "outputKey": "draft_forecast",
46
+ "prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。切片键必须使用 ua_spend 的 app_code/platform/media_source 合法值。不得调用任何 fpa_dashboard_* 工具。",
47
+ "skills": ["fpa-apply-core-rules", "fpa-forecast-approved-strategy"],
48
+ "systemPrompt": "你是 FP&A 正式预测 Agent。只按已确认策略写预测计划,不请求审批、不发布仪表盘、不手算派生值。",
49
+ "tools": ["read", "write", "fpa_calc"],
50
+ "type": "subagent"
51
+ },
52
+ {
53
+ "args": { "plan_path": "artifacts/forecast_plan.json" },
54
+ "failure": { "maxAttempts": 2, "onError": "repair_forecast_plan" },
55
+ "id": "compose_forecast",
56
+ "label": "合成正式预测",
57
+ "next": "commit_forecast",
58
+ "outputKey": "compose_forecast",
59
+ "tool": "fpa_forecast_compose",
60
+ "type": "tool"
61
+ },
62
+ {
63
+ "agentName": "repair_forecast_plan",
64
+ "id": "repair_forecast_plan",
65
+ "label": "定点修正预测计划",
66
+ "next": "compose_forecast",
67
+ "outputKey": "repair_forecast_plan",
68
+ "prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式、合法切片键或 forecast plan 契约问题。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,报告 blocked。不得调用任何 fpa_dashboard_* 工具。",
69
+ "skills": ["fpa-forecast-approved-strategy"],
70
+ "systemPrompt": "你是预测计划修复 Agent,只按确定性合成报错做最小修正。",
71
+ "tools": ["read", "write", "edit"],
72
+ "type": "subagent"
73
+ },
74
+ {
75
+ "args": {
76
+ "artifact": "{{data.compose_forecast.details.artifact}}",
77
+ "context": {
78
+ "cycle_id": "{{context.cycle_id}}",
79
+ "forecast_role": "{{context.forecast_role}}",
80
+ "scope_id": "{{context.scope_id}}"
81
+ }
82
+ },
83
+ "id": "commit_forecast",
84
+ "label": "冻结正式预测",
85
+ "mutates": true,
86
+ "outputKey": "commit_forecast",
87
+ "tool": "fpa_artifact_commit",
88
+ "type": "tool"
89
+ }
90
+ ],
91
+ "schemaVersion": 1,
92
+ "start": "load_confirmed_strategy",
93
+ "transitionLabels": { "default": "其他情况", "error": "失败", "next": "继续" },
94
+ "version": "2.1.0"
95
+ }