@viccydev/pi-fpa 0.8.0 → 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.
package/README.md CHANGED
@@ -33,7 +33,9 @@ Skill:
33
33
  阶段的请求都必须先查询 Graph catalog,再运行当前第一个满足入口条件的
34
34
  Graph;在此之前不得调用 `fpa_*` 工具或写 Artifact。四个工作流的边界为:
35
35
 
36
- - `fpa-strategy-planning`:规划至 `reviewed_strategy_handoff` 后停止。
36
+ - `fpa-strategy-planning`:在一个组合 Graph 中完成规划至
37
+ `reviewed_strategy_handoff` 后停止。数据/驱动审计与三类策略场景分别并行;
38
+ 并行子节点只读,正式 Artifact 由后续汇总节点顺序写入。
37
39
  - `fpa-forecast-freeze`:只接受精确 handoff,经人工批准后冻结 Forecast。
38
40
  - `fpa-strategy-execution`:只接受精确 committed Forecast ref 和单独执行授权。
39
41
  - `fpa-cycle-review`:只接受精确 Forecast/Execution refs 与新 Actuals。
@@ -47,6 +49,11 @@ Graph 的业务阻塞不能触发父 Agent 降级执行。Graph 工具不可用
47
49
  它从展开后的请求识别多阶段 FP&A 意图,要求目标 Graph 与请求阶段一致,并
48
50
  阻断父 Agent 的提前 `fpa_*` 调用和 `artifacts/` 写入。Graph worker 没有
49
51
  `graph_list`/`graph_run` 时不会激活该护栏,因此节点仍可执行被分配的单一阶段。
52
+ 首次在可信项目进入 FP&A 工作流时,护栏会把 package 自带的 Graph 模板安装到
53
+ `.agent-graph/graphs`;较旧的同名模板和已经退役的拆分 Graph 会先移入
54
+ `.agent-graph/graphs_backup_v*` 再更新,因此 `graph_list` 能直接发现当前组合
55
+ Graph,同时保留可恢复的旧定义。非可信项目、符号链接目录和更高版本的项目
56
+ Graph 均不会被覆盖。
50
57
 
51
58
  ## 数据 Extension(fpa-data)
52
59
 
@@ -75,10 +82,14 @@ Extension 内置的关键防护:
75
82
 
76
83
  `fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
77
84
 
78
- `fpa-dashboard` 提供三个工具:
85
+ `fpa-dashboard` 提供分模块发布工具和兼容的闭环刷新工具:
79
86
 
80
87
  | 工具 | 作用 |
81
88
  | --- | --- |
89
+ | `fpa_dashboard_module_status` | 只读检查各独立模块及当前 Dashboard revision |
90
+ | `fpa_dashboard_publish_review` | 只发布 `period-review`,保留其他模块 |
91
+ | `fpa_dashboard_publish_strategy` | 只发布带确认/修改动作的 `next-strategy`,并绑定当前主会话 |
92
+ | `fpa_dashboard_publish_forecast` | 确认策略并冻结预测后,只发布 `next-forecast` |
82
93
  | `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
83
94
  | `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
84
95
  | `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/Execution,Actuals watermark 按 SLA 轮询并幂等发布 |
@@ -148,12 +159,12 @@ pi list
148
159
  团队分发建议使用固定 Git tag:
149
160
 
150
161
  ```bash
151
- pi install git:github.com/linyqh/pi-fpa@v0.8.0
162
+ pi install git:github.com/linyqh/pi-fpa@v0.8.1
152
163
  ```
153
164
 
154
165
  ## 发布到 npm
155
166
 
156
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.8.0` 对应 `v0.8.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.8.1` 对应 `v0.8.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
157
168
 
158
169
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
159
170
 
@@ -1,3 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
4
  import { Type } from "typebox";
3
5
 
@@ -7,6 +9,7 @@ import {
7
9
  commitArtifactFromPath,
8
10
  readArtifactByRef,
9
11
  readProjectJsonFile,
12
+ stableJson,
10
13
  type ArtifactRefV2,
11
14
  } from "./store.ts";
12
15
 
@@ -115,6 +118,25 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
115
118
  },
116
119
  });
117
120
 
121
+ pi.registerTool({
122
+ name: "fpa_json_fingerprint",
123
+ label: "Fingerprint FP&A JSON Artifacts",
124
+ description: "Read project-local JSON artifacts and return deterministic SHA-256 fingerprints over their canonical JSON form. Use this to bind reviewed handoffs to exact proposal and review bodies.",
125
+ promptSnippet: "Fingerprint exact FP&A JSON artifacts for a version-bound handoff",
126
+ parameters: Type.Object({
127
+ paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 8 }),
128
+ }, { additionalProperties: false }),
129
+ executionMode: "parallel",
130
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
131
+ if (new Set(params.paths).size !== params.paths.length) throw new Error("Fingerprint paths must be unique.");
132
+ const entries = await Promise.all(params.paths.map(async (path) => {
133
+ const value = await readProjectJsonFile(ctx.cwd, path, "path");
134
+ return [path, createHash("sha256").update(stableJson(value)).digest("hex")] as const;
135
+ }));
136
+ return toolResult({ status: "fingerprinted", fingerprints: Object.fromEntries(entries) });
137
+ },
138
+ });
139
+
118
140
  pi.registerTool({
119
141
  name: "fpa_forecast_compose",
120
142
  label: "Compose Approved Forecast",
@@ -114,7 +114,11 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
114
114
  parameters: Type.Object({
115
115
  mode: StringEnum(["preview", "publish"] as const),
116
116
  proposal_path: Type.String({ minLength: 1 }),
117
+ review_path: Type.String({ minLength: 1 }),
117
118
  handoff_path: Type.String({ minLength: 1 }),
119
+ scope_id: Type.String({ minLength: 1, maxLength: 256 }),
120
+ cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
121
+ forecast_role: Type.String({ minLength: 1, maxLength: 64 }),
118
122
  expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
119
123
  expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
120
124
  dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
@@ -122,8 +126,13 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
122
126
  executionMode: "sequential",
123
127
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124
128
  const proposal = await readProjectJsonFile(ctx.cwd, params.proposal_path, "proposal_path");
129
+ const review = await readProjectJsonFile(ctx.cwd, params.review_path, "review_path");
125
130
  const handoff = await readProjectJsonFile(ctx.cwd, params.handoff_path, "handoff_path");
126
- const module = projectStrategyModule(proposal, handoff);
131
+ const module = projectStrategyModule(proposal, handoff, review, {
132
+ scope_id: params.scope_id,
133
+ cycle_id: params.cycle_id,
134
+ forecast_role: params.forecast_role,
135
+ });
127
136
  const previewFingerprint = dashboardModuleBuildFingerprint(module);
128
137
  const current = await readDashboardModuleManifest(ctx.cwd);
129
138
  if (params.mode === "preview") return toolResult({
@@ -6,6 +6,20 @@ import type { TableCell } from "./projector.ts";
6
6
  import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
7
7
  import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
8
8
 
9
+ const STRATEGY_ACTION_LABELS = {
10
+ grow: "增长",
11
+ hold: "保持",
12
+ cut: "削减",
13
+ stop: "止损",
14
+ } as const;
15
+ type StrategyAction = keyof typeof STRATEGY_ACTION_LABELS;
16
+
17
+ export interface StrategyModuleContext {
18
+ scope_id: string;
19
+ cycle_id: string;
20
+ forecast_role: string;
21
+ }
22
+
9
23
  function record(value: unknown, label: string): Record<string, unknown> {
10
24
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
11
25
  return value as Record<string, unknown>;
@@ -37,6 +51,11 @@ function lines(value: unknown): string {
37
51
  return value.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join("\n");
38
52
  }
39
53
 
54
+ function strategyAction(value: unknown, label: string): StrategyAction {
55
+ if (typeof value === "string" && value in STRATEGY_ACTION_LABELS) return value as StrategyAction;
56
+ throw new Error(`${label} must be one of grow, hold, cut, stop.`);
57
+ }
58
+
40
59
  export function projectReviewModule(input: unknown): DashboardModuleBuild {
41
60
  const analysis = record(input, "driver_analysis");
42
61
  if (analysis.artifact_type !== "driver_analysis") throw new Error("Review publication requires artifact_type=driver_analysis.");
@@ -93,32 +112,74 @@ export function projectReviewModule(input: unknown): DashboardModuleBuild {
93
112
  };
94
113
  }
95
114
 
96
- export function projectStrategyModule(proposalInput: unknown, handoffInput: unknown): DashboardModuleBuild {
115
+ export function projectStrategyModule(
116
+ proposalInput: unknown,
117
+ handoffInput: unknown,
118
+ reviewInput: unknown,
119
+ expectedContext: StrategyModuleContext,
120
+ ): DashboardModuleBuild {
97
121
  const proposal = record(proposalInput, "strategy_proposal");
98
122
  const handoff = record(handoffInput, "reviewed_strategy_handoff");
123
+ const review = record(reviewInput, "strategy_review");
99
124
  if (proposal.artifact_type !== "strategy_proposal") throw new Error("Strategy publication requires artifact_type=strategy_proposal.");
100
125
  if (handoff.kind !== "fpa.reviewed-strategy-handoff") throw new Error("Strategy publication requires kind=fpa.reviewed-strategy-handoff.");
126
+ if (review.artifact_type !== "strategy_review") throw new Error("Strategy publication requires artifact_type=strategy_review.");
101
127
  const strategyVersion = requiredString(proposal.strategy_version, "strategy_proposal.strategy_version");
102
128
  if (handoff.strategy_version !== strategyVersion || handoff.reviewed_strategy_version !== strategyVersion) {
103
129
  throw new Error("Reviewed handoff version does not match the strategy proposal version.");
104
130
  }
105
131
  if (handoff.status !== "ready") throw new Error("Reviewed strategy handoff is not ready for a human decision.");
132
+ if (review.reviewed_strategy_version !== strategyVersion) throw new Error("Strategy review version does not match the strategy proposal version.");
133
+ if (review.status !== "complete" && review.status !== "complete_with_limits") throw new Error("Strategy review is not complete.");
134
+ if (review.independence_confirmed !== true || handoff.independence_confirmed !== true) throw new Error("Strategy publication requires an independently reviewed proposal.");
135
+ const reviewerIdentity = requiredString(review.reviewer_identity, "strategy_review.reviewer_identity");
136
+ if (handoff.reviewer_identity !== reviewerIdentity) throw new Error("Reviewed handoff reviewer identity does not match the strategy review.");
137
+ if (review.opinion !== "support" && review.opinion !== "support_with_conditions") throw new Error("Strategy review opinion does not support human approval.");
138
+ if (handoff.review_opinion !== review.opinion) throw new Error("Reviewed handoff opinion does not match the strategy review.");
139
+ if (handoff.next_graph !== "fpa-forecast-freeze") throw new Error("Reviewed handoff next_graph must be fpa-forecast-freeze.");
140
+ for (const key of ["scope_id", "cycle_id", "forecast_role"] as const) {
141
+ const expected = requiredString(expectedContext[key], `strategy publication ${key}`);
142
+ if (handoff[key] !== expected) throw new Error(`Reviewed handoff ${key} does not match the publication context.`);
143
+ }
144
+ if (handoff.proposal_fingerprint !== fingerprint(proposal)) throw new Error("Reviewed handoff proposal_fingerprint does not match the strategy proposal.");
145
+ if (handoff.review_fingerprint !== fingerprint(review)) throw new Error("Reviewed handoff review_fingerprint does not match the strategy review.");
146
+ const reviewConditions = array(review.conditions_for_human_approval ?? [], "strategy_review.conditions_for_human_approval");
147
+ const residualRisks = array(review.residual_risks ?? [], "strategy_review.residual_risks");
148
+ if (stableJson(handoff.review_conditions ?? []) !== stableJson(reviewConditions)) throw new Error("Reviewed handoff conditions do not match the strategy review.");
149
+ if (stableJson(handoff.review_residual_risks ?? []) !== stableJson(residualRisks)) throw new Error("Reviewed handoff residual risks do not match the strategy review.");
106
150
  const allocations = array(proposal.allocation, "strategy_proposal.allocation");
107
151
  const outcomes = record(proposal.expected_outcomes, "strategy_proposal.expected_outcomes");
108
152
  const base = record(outcomes.base ?? {}, "strategy_proposal.expected_outcomes.base");
109
- const totalSpend = allocations.reduce((sum, item, index) => {
153
+ const parsedAllocations = allocations.map((item, index) => {
110
154
  const allocation = record(item, `strategy_proposal.allocation[${index}]`);
111
- if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend)) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite number.`);
112
- return sum + allocation.spend;
113
- }, 0);
155
+ if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend) || allocation.spend < 0) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite non-negative number.`);
156
+ if (typeof allocation.change_from_baseline !== "number" || !Number.isFinite(allocation.change_from_baseline)) throw new Error(`strategy_proposal.allocation[${index}].change_from_baseline must be a finite number.`);
157
+ const action = strategyAction(allocation.action, `strategy_proposal.allocation[${index}].action`);
158
+ const consistent = action === "grow"
159
+ ? allocation.change_from_baseline > 0
160
+ : action === "hold"
161
+ ? allocation.change_from_baseline === 0
162
+ : action === "cut"
163
+ ? allocation.change_from_baseline < 0 && allocation.spend > 0
164
+ : allocation.change_from_baseline <= 0 && allocation.spend === 0;
165
+ if (!consistent) throw new Error(`strategy_proposal.allocation[${index}].action ${action} conflicts with spend and change_from_baseline.`);
166
+ return { allocation, action };
167
+ });
168
+ const totalSpend = parsedAllocations.reduce((sum, { allocation }) => sum + (allocation.spend as number), 0);
169
+ const actionCounts = Object.fromEntries(Object.keys(STRATEGY_ACTION_LABELS).map((action) => [action, 0])) as Record<StrategyAction, number>;
170
+ for (const { action } of parsedAllocations) actionCounts[action] += 1;
171
+ const actionHeadline = (Object.entries(STRATEGY_ACTION_LABELS) as [StrategyAction, string][])
172
+ .map(([action, label]) => `${label} ${actionCounts[action]} 项`)
173
+ .join(";");
114
174
  return {
115
175
  id: "next-strategy",
116
176
  title: "下周期执行策略",
117
177
  status: "awaiting_decision",
118
178
  source: {
119
179
  artifact_type: "reviewed_strategy",
120
- artifact_fingerprint: fingerprint({ proposal, handoff }),
180
+ artifact_fingerprint: fingerprint({ proposal, review, handoff }),
121
181
  proposal_fingerprint: fingerprint(proposal),
182
+ review_fingerprint: fingerprint(review),
122
183
  handoff_fingerprint: fingerprint(handoff),
123
184
  strategy_version: strategyVersion,
124
185
  review_opinion: handoff.review_opinion,
@@ -137,6 +198,7 @@ export function projectStrategyModule(proposalInput: unknown, handoffInput: unkn
137
198
  { metric: "总预算", value: totalSpend.toFixed(2) },
138
199
  { metric: "Base 收入", value: cell(base.revenue) },
139
200
  { metric: "Base ROAS", value: cell(base.roas) },
201
+ { metric: "执行结论", value: actionHeadline },
140
202
  { metric: "决策理由", value: lines(proposal.decision_rationale) },
141
203
  ],
142
204
  },
@@ -150,12 +212,13 @@ export function projectStrategyModule(proposalInput: unknown, handoffInput: unkn
150
212
  label: "预算分配",
151
213
  columns: [
152
214
  { key: "app", label: "App" }, { key: "store", label: "商店" }, { key: "channel", label: "渠道" },
215
+ { key: "action", label: "动作" },
153
216
  { key: "spend", label: "预算", align: "right" }, { key: "change", label: "较基线变化", align: "right" },
154
217
  ],
155
- rows: allocations.map((item, index) => {
156
- const allocation = record(item, `strategy_proposal.allocation[${index}]`);
218
+ rows: parsedAllocations.map(({ allocation, action }) => {
157
219
  return {
158
220
  app: cell(allocation.app_id), store: cell(allocation.store), channel: cell(allocation.channel_group),
221
+ action: STRATEGY_ACTION_LABELS[action],
159
222
  spend: (allocation.spend as number).toFixed(2), change: cell(allocation.change_from_baseline),
160
223
  };
161
224
  }),
@@ -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,10 +5,11 @@ 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
- "fpa-period-analysis",
11
- "fpa-strategy-recommendation",
12
13
  "fpa-strategy-planning",
13
14
  "fpa-forecast-freeze",
14
15
  "fpa-strategy-execution",
@@ -22,6 +23,14 @@ interface RoutingState {
22
23
  availableGraphs: Set<string>;
23
24
  graphRunStarted: boolean;
24
25
  graphRunCompleted: boolean;
26
+ reviewPublished: boolean;
27
+ }
28
+
29
+ interface PersistedRoutingState {
30
+ version: 1;
31
+ requiredGraph: FpaGraph;
32
+ graphRunCompleted: boolean;
33
+ reviewPublished: boolean;
25
34
  }
26
35
 
27
36
  function emptyState(): RoutingState {
@@ -30,6 +39,7 @@ function emptyState(): RoutingState {
30
39
  availableGraphs: new Set(),
31
40
  graphRunStarted: false,
32
41
  graphRunCompleted: false,
42
+ reviewPublished: false,
33
43
  };
34
44
  }
35
45
 
@@ -60,6 +70,10 @@ function isExplicitIsolatedPhase(prompt: string): boolean {
60
70
  const skill = prompt.match(/^<skill name="(fpa-[^"]+)"/);
61
71
  if (skill && skill[1] !== "fpa-apply-core-rules") return true;
62
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;
63
77
  return !/(?:然后|接着|随后|再进入|后再|直至|全流程|完整流程|end[- ]to[- ]end)/i.test(prompt);
64
78
  }
65
79
 
@@ -119,7 +133,7 @@ export function classifyFpaGraph(prompt: string): FpaGraph | undefined {
119
133
  return "fpa-strategy-execution";
120
134
  }
121
135
 
122
- return hasFpaIntent(normalized) ? "fpa-period-analysis" : undefined;
136
+ return hasFpaIntent(normalized) ? "fpa-strategy-planning" : undefined;
123
137
  }
124
138
 
125
139
  function contentText(event: ToolResultEvent): string {
@@ -148,30 +162,93 @@ function artifactWrite(event: ToolCallEvent): boolean {
148
162
  return /artifacts?[\\/]/i.test(command);
149
163
  }
150
164
 
151
- function routingInstruction(graph: FpaGraph): string {
152
- 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 = [
153
186
  "FP&A runtime routing guard is active for this request.",
154
187
  `The first eligible workflow is ${graph}.`,
155
- "Call graph_list first, then graph_run with that exact graph and the complete immutable context.",
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.",
156
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.",
157
- ].join(" ");
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(" ");
158
200
  }
159
201
 
160
202
  export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
161
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
+ });
162
234
 
163
235
  pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
164
- const previousGraph = state.requiredGraph;
236
+ const previousState = state;
165
237
  state = emptyState();
166
238
  const active = new Set(pi.getActiveTools());
167
239
  if (!GRAPH_TOOLS.every((tool) => active.has(tool))) return;
168
240
 
169
241
  const classified = classifyFpaGraph(event.prompt);
170
242
  const isContinuation = /^(?:继续|接着|下一步|continue|go on)[。.!!\s]*$/i.test(event.prompt.trim());
171
- const requiredGraph = classified ?? (isContinuation ? previousGraph : undefined);
243
+ const requiredGraph = classified ?? (isContinuation ? previousState.requiredGraph : undefined);
172
244
  if (!requiredGraph) return;
173
245
  state.requiredGraph = requiredGraph;
174
- 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}` : ""}` };
175
252
  });
176
253
 
177
254
  pi.on("tool_call", (event: ToolCallEvent) => {
@@ -184,15 +261,6 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
184
261
  return { block: true, reason: `Call graph_list before graph_run for ${graph}.` };
185
262
  }
186
263
  const requested = "graph" in event.input ? event.input.graph : undefined;
187
- if (graph === "fpa-period-analysis" && state.graphRunCompleted && requested === "fpa-strategy-recommendation") {
188
- if (!state.availableGraphs.has("fpa-strategy-recommendation")) {
189
- return { block: true, reason: "fpa-strategy-recommendation is unavailable; do not emulate strategy work in the parent Agent." };
190
- }
191
- state.requiredGraph = "fpa-strategy-recommendation";
192
- state.graphRunStarted = true;
193
- state.graphRunCompleted = false;
194
- return;
195
- }
196
264
  if (requested !== graph) {
197
265
  return { block: true, reason: `Run the first eligible ${graph} Graph; received ${String(requested)}.` };
198
266
  }
@@ -202,21 +270,28 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
202
270
  reason: `${graph} is not available. Return blocked; local work requires a new, explicit isolated-phase request.`,
203
271
  };
204
272
  }
273
+ const contextError = graphContextError(event, graph);
274
+ if (contextError) return { block: true, reason: contextError };
205
275
  state.graphRunStarted = true;
206
276
  state.graphRunCompleted = false;
277
+ state.reviewPublished = false;
278
+ persistState();
207
279
  return;
208
280
  }
209
281
 
210
282
  if (event.toolName === "fpa_dashboard_module_status") return;
211
- if ((graph === "fpa-forecast-freeze" || graph === "fpa-strategy-recommendation") && !state.graphRunStarted && event.toolName === "fpa_strategy_decision_commit") return;
212
- const allowedPublication = graph === "fpa-period-analysis"
213
- ? "fpa_dashboard_publish_review"
214
- : graph === "fpa-strategy-recommendation"
215
- ? "fpa_dashboard_publish_strategy"
216
- : graph === "fpa-forecast-freeze"
217
- ? "fpa_dashboard_publish_forecast"
218
- : "fpa_dashboard_refresh_queue";
219
- if (event.toolName === allowedPublication && state.graphRunCompleted) 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
+ }
293
+ return;
294
+ }
220
295
 
221
296
  if (!event.toolName.startsWith("fpa_") && !artifactWrite(event)) return;
222
297
  if (!state.catalogLoaded) {
@@ -249,6 +324,17 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
249
324
  ? (event.details as { status?: unknown }).status
250
325
  : undefined;
251
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
+ }
252
338
  }
253
339
  });
254
340
  }
@@ -1,11 +1,95 @@
1
1
  {
2
+ "description": "FP&A 正式预测冻结:消费已审核策略交接与主 Agent 已提交的精确 strategy_decision,生成、合成并冻结 approved_cycle_forecast;Graph 不请求审批、不发布仪表盘。",
3
+ "maxSteps": 10,
4
+ "mutationPolicy": "mutating",
2
5
  "name": "fpa-forecast-freeze",
3
- "version": "2.0.0",
4
- "start": "load_confirmed_strategy",
5
6
  "nodes": [
6
- { "id": "load_confirmed_strategy", "type": "subagent", "agentName": "load_confirmed_strategy", "next": "draft_forecast", "tools": ["read"], "prompt": "核验主 Agent 已提交的策略 decision 与 reviewed handoff 精确一致;不得审批或发布仪表盘。" },
7
- { "id": "draft_forecast", "type": "subagent", "agentName": "draft_forecast", "next": "compose_forecast", "skills": ["fpa-forecast-approved-strategy"], "tools": ["read", "write", "fpa_calc"], "prompt": "只按已确认策略写预测计划;不得重新优化策略或发布仪表盘。" },
8
- { "id": "compose_forecast", "type": "tool", "tool": "fpa_forecast_compose", "next": "commit_forecast", "args": { "plan_path": "artifacts/forecast_plan.json" } },
9
- { "id": "commit_forecast", "type": "tool", "tool": "fpa_artifact_commit", "mutates": true, "args": { "artifact": "{{data.compose_forecast.details.artifact}}", "context": { "scope_id": "{{context.scope_id}}", "cycle_id": "{{context.cycle_id}}", "forecast_role": "{{context.forecast_role}}" } } }
10
- ]
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"
11
95
  }
@@ -0,0 +1,159 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "fpa-strategy-planning",
4
+ "version": "4.2.0",
5
+ "description": "FP&A 组合式策略规划:在一个 Graph 内完成目标规划、并行只读数据/驱动审计、并行只读场景分析、策略推荐与独立审核,最终形成 reviewed_strategy_handoff。必须提供字符串 context.scope_id、context.cycle_id、context.forecast_role;Graph 不审批、不预测、不发布仪表盘。",
6
+ "mutationPolicy": "mutating",
7
+ "start": "plan_cycle",
8
+ "maxSteps": 24,
9
+ "nodes": [
10
+ {
11
+ "id": "plan_cycle",
12
+ "type": "subagent",
13
+ "agentName": "plan_cycle",
14
+ "label": "目标与范围规划",
15
+ "outputKey": "plan_cycle",
16
+ "next": "parallel_analysis",
17
+ "systemPrompt": "你是 FP&A 目标规划 Agent。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}、forecast_role={{context.forecast_role}}。不得从目标文本猜测或替换这些标识。",
18
+ "prompt": "目标:{{goal}}\n\n按 fpa-plan-cycle 先调用 fpa_data_catalog 实测目标期和对比期字段可用性,再写 artifacts/planning_brief.md。明确目标、预算、周期、in_scope/out_of_scope 维度与指标、App 清单及增长/成熟/收缩角色。正式预测指标白名单仅为 spend、revenue、roas。不得生成策略、预测或调用任何 fpa_dashboard_* 工具。",
19
+ "skills": ["fpa-apply-core-rules", "fpa-plan-cycle"],
20
+ "tools": ["read", "write", "fpa_data_catalog"]
21
+ },
22
+ {
23
+ "id": "parallel_analysis",
24
+ "type": "parallel",
25
+ "label": "并行数据与经营审计",
26
+ "concurrency": 2,
27
+ "next": "synthesize_analysis",
28
+ "children": [
29
+ {
30
+ "id": "actuals_audit",
31
+ "type": "subagent",
32
+ "agentName": "actuals_audit",
33
+ "systemPrompt": "你是只读 FP&A 数据质量审计 Agent。不得写文件或发布仪表盘。",
34
+ "prompt": "目标:{{goal}}\n\n读取 artifacts/planning_brief.md,按 fpa-diagnose-actuals 检查数据窗口、字段覆盖、revenue 回刷成熟度、cohort 可用性、scope reduction 与 blocking issue。只输出结构化 JSON 证据给汇总节点,不写任何 artifact。",
35
+ "skills": ["fpa-apply-core-rules", "fpa-diagnose-actuals"],
36
+ "tools": ["read", "fpa_data_catalog", "fpa_query", "fpa_cohort"]
37
+ },
38
+ {
39
+ "id": "driver_audit",
40
+ "type": "subagent",
41
+ "agentName": "driver_audit",
42
+ "systemPrompt": "你是只读 FP&A 经营驱动审计 Agent。不得写文件或发布仪表盘。",
43
+ "prompt": "目标:{{goal}}\n\n读取 artifacts/planning_brief.md,按 in_scope_dimensions 独立查询 spend、revenue、roas、cpi、installs,分析组合、App、商店、渠道和平台驱动;明确每个 App/切片的增长、保持、削减或止损证据。只输出结构化 JSON,不写任何 artifact。",
44
+ "skills": ["fpa-apply-core-rules", "fpa-analyze-drivers"],
45
+ "tools": ["read", "fpa_query", "fpa_cohort", "fpa_compare", "fpa_calc"]
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "id": "synthesize_analysis",
51
+ "type": "subagent",
52
+ "agentName": "synthesize_analysis",
53
+ "label": "汇总上周期分析",
54
+ "outputKey": "driver_analysis",
55
+ "next": "parallel_scenarios",
56
+ "systemPrompt": "你是 FP&A 分析汇总 Agent。只整合并复核并行审计证据,不补造数据。",
57
+ "prompt": "目标:{{goal}}\n\n并行审计:{{data.parallel_analysis}}\n\n读取 planning_brief,交叉核对两个只读审计结果;冲突时重新用确定性工具复算。写 artifacts/actuals_snapshot.md、artifacts/data_issue_report.md、artifacts/driver_analysis.md 和机器可读 artifacts/driver_analysis.json。JSON 必须符合 driver_analysis contract,包含 artifact_type、status、headline_results、drivers、data_limits/limitations,并对每个 App 或 in-scope 切片给出增长/保持/削减/止损结论与证据。不得生成策略或发布仪表盘。",
58
+ "skills": ["fpa-apply-core-rules", "fpa-diagnose-actuals", "fpa-analyze-drivers"],
59
+ "tools": ["read", "write", "fpa_query", "fpa_compare", "fpa_calc"]
60
+ },
61
+ {
62
+ "id": "parallel_scenarios",
63
+ "type": "parallel",
64
+ "label": "并行策略场景分析",
65
+ "concurrency": 3,
66
+ "next": "synthesize_scenarios",
67
+ "children": [
68
+ {
69
+ "id": "efficiency_lane",
70
+ "type": "subagent",
71
+ "agentName": "efficiency_lane",
72
+ "systemPrompt": "你是只读 FP&A 稳健效率场景 Agent。不得写文件、批准策略或发布仪表盘。",
73
+ "prompt": "读取 planning_brief、actuals_snapshot 与 driver_analysis,构造维持硬 ROAS 下限的稳健/效率候选场景,逐 App/商店/渠道给出完整 allocation、downside/base/upside、假设和止损。只输出结构化 JSON 候选,不写 artifact。",
74
+ "skills": ["fpa-apply-core-rules", "fpa-simulate-strategies"],
75
+ "tools": ["read", "fpa_query", "fpa_calc"]
76
+ },
77
+ {
78
+ "id": "growth_lane",
79
+ "type": "subagent",
80
+ "agentName": "growth_lane",
81
+ "systemPrompt": "你是只读 FP&A 增长与探索场景 Agent。不得写文件、批准策略或发布仪表盘。",
82
+ "prompt": "读取 planning_brief、actuals_snapshot 与 driver_analysis,构造增长/探索候选场景;探索预算约 10%,逐 App/商店/渠道给出完整 allocation、downside/base/upside、假设和止损。只输出结构化 JSON 候选,不写 artifact。",
83
+ "skills": ["fpa-apply-core-rules", "fpa-simulate-strategies"],
84
+ "tools": ["read", "fpa_query", "fpa_calc"]
85
+ },
86
+ {
87
+ "id": "risk_lane",
88
+ "type": "subagent",
89
+ "agentName": "risk_lane",
90
+ "systemPrompt": "你是只读 FP&A 约束与风险挑战 Agent。不得写文件、批准策略或发布仪表盘。",
91
+ "prompt": "读取 planning_brief 与 driver_analysis,独立检查预算上下限、ROAS 硬下限、Revenue 次目标、ROAS<0.5 止损、集中度、外推和数据成熟度。输出每个候选策略必须满足的约束、压力测试区间与淘汰规则,只输出结构化 JSON。",
92
+ "skills": ["fpa-apply-core-rules", "fpa-simulate-strategies"],
93
+ "tools": ["read", "fpa_query", "fpa_calc"]
94
+ }
95
+ ]
96
+ },
97
+ {
98
+ "id": "synthesize_scenarios",
99
+ "type": "subagent",
100
+ "agentName": "synthesize_scenarios",
101
+ "label": "汇总候选策略",
102
+ "outputKey": "strategy_scenarios",
103
+ "next": "recommend_strategy",
104
+ "systemPrompt": "你是 FP&A 场景汇总 Agent。所有加总、ROAS 和约束判断必须确定性复算。",
105
+ "prompt": "目标:{{goal}}\n\n并行场景与风险审计:{{data.parallel_scenarios}}\n\n读取权威上游工件,合并、去重并复算候选场景,至少保留现状、稳健和增长场景。写 artifacts/strategy_scenarios.md 与符合 contract 的 artifacts/strategy_scenarios.json;逐切片完整覆盖,探索/利用分开,所有 ROAS<0.5 切片必须削减或清零。不得推荐、批准、预测或发布仪表盘。",
106
+ "skills": ["fpa-apply-core-rules", "fpa-simulate-strategies"],
107
+ "tools": ["read", "write", "fpa_calc"]
108
+ },
109
+ {
110
+ "id": "recommend_strategy",
111
+ "type": "subagent",
112
+ "agentName": "recommend_strategy",
113
+ "label": "形成执行策略",
114
+ "outputKey": "strategy_proposal",
115
+ "next": "review_strategy",
116
+ "systemPrompt": "你是 FP&A 策略推荐 Agent。不得自我批准、生成预测或发布仪表盘。",
117
+ "prompt": "目标:{{goal}}\n\n读取 planning_brief、driver_analysis 和 strategy_scenarios,按 fpa-recommend-strategy 选择推荐策略。写 artifacts/strategy_proposal.md 与符合 contract 的 artifacts/strategy_proposal.json。allocation 必须逐 App/商店/渠道包含显式 action(grow/hold/cut/stop)、spend 与 change_from_baseline,使 Dashboard 能明确展示增长、保持、削减和止损;保留完整 expected_outcomes、理由、风险、阈值和 required_human_decisions。",
118
+ "skills": ["fpa-apply-core-rules", "fpa-recommend-strategy"],
119
+ "tools": ["read", "write", "fpa_calc"]
120
+ },
121
+ {
122
+ "id": "review_strategy",
123
+ "type": "subagent",
124
+ "agentName": "review_strategy",
125
+ "label": "独立审核",
126
+ "outputKey": "strategy_review",
127
+ "parseJson": true,
128
+ "next": "route_review",
129
+ "systemPrompt": "你是未参与推荐生成的 FP&A 独立审核 Agent。不得批准策略、生成预测或发布仪表盘。",
130
+ "prompt": "读取 proposal 及全部上游机器工件,按 fpa-review-strategy 独立复算覆盖、加总、ROAS、止损、探索占比、外推与数据血缘。写 artifacts/strategy_review.md 和 artifacts/strategy_review.json。最终只输出路由 JSON:{\"status\":\"support|support_with_conditions|reject\",\"summary\":\"...\",\"conditions\":[]}。",
131
+ "skills": ["fpa-apply-core-rules", "fpa-review-strategy"],
132
+ "tools": ["read", "write", "fpa_query", "fpa_calc"]
133
+ },
134
+ {
135
+ "id": "route_review",
136
+ "type": "router",
137
+ "label": "审核结果路由",
138
+ "path": "data.strategy_review.status",
139
+ "cases": [
140
+ { "equals": "reject", "label": "审核拒绝,重新推荐", "to": "recommend_strategy" },
141
+ { "equals": "support", "label": "审核支持", "to": "prepare_strategy_handoff" },
142
+ { "equals": "support_with_conditions", "label": "有条件支持", "to": "prepare_strategy_handoff" }
143
+ ],
144
+ "default": { "label": "其他结果,重新推荐", "to": "recommend_strategy" }
145
+ },
146
+ {
147
+ "id": "prepare_strategy_handoff",
148
+ "type": "subagent",
149
+ "agentName": "prepare_strategy_handoff",
150
+ "label": "形成已审核策略交接",
151
+ "outputKey": "strategy_handoff",
152
+ "parseJson": true,
153
+ "systemPrompt": "你是 FP&A 策略交接 Agent。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}、forecast_role={{context.forecast_role}}。不批准、不预测、不发布仪表盘。",
154
+ "prompt": "读取 planning_brief、driver_analysis.json、strategy_scenarios.json、strategy_proposal.json 与 strategy_review.json,先调用 fpa_json_fingerprint 对 proposal/review 两个 JSON 生成确定性 SHA-256,再严格按 fpa-review-strategy 的 reviewed_strategy_handoff contract 核验并写 artifacts/reviewed_strategy_handoff.json。ready 文件必须包含 kind=fpa.reviewed-strategy-handoff、scope_id/cycle_id/forecast_role、相同的 strategy_version 与 reviewed_strategy_version、工具返回的 proposal/review fingerprint、独立审核身份、review_opinion、条件、剩余风险和 next_graph=fpa-forecast-freeze;失败时写 blocked 结果且不得覆盖已有 ready 文件。最终只输出 fpa.graph-handoff JSON。Graph 到此结束,由主 Agent 依次发布 period-review 与 next-strategy 模块并等待用户决策。",
155
+ "skills": ["fpa-apply-core-rules", "fpa-review-strategy"],
156
+ "tools": ["read", "write", "fpa_json_fingerprint"]
157
+ }
158
+ ]
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",
@@ -31,7 +31,7 @@
31
31
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
32
32
  },
33
33
  "scripts": {
34
- "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
34
+ "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
35
35
  "test:structure": "node tests/package-structure.test.mjs",
36
36
  "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
37
37
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
@@ -17,19 +17,19 @@ argument-hint: "<project-root> <cycle-id> [instructions]"
17
17
  ## 强制 Graph 路由
18
18
 
19
19
  先读取 `fpa-apply-core-rules`。如果当前 Agent 提供 `graph_list` 和
20
- `graph_run`,必须先调用 `graph_list`,确认 `fpa-period-analysis`
21
- `fpa-strategy-recommendation` 后按以下边界执行:
22
-
23
- 1. 以完整用户目标、项目根、周期边界、业务范围和数据上下文运行
24
- `fpa-period-analysis`。Graph 返回 `artifacts/driver_analysis.json` 后,父
25
- Agent 调用 `fpa_dashboard_publish_review` 先 preview,再用精确
26
- fingerprint/revision 发布 `period-review`。
27
- 2. 再运行 `fpa-strategy-recommendation`。Graph 返回
28
- `strategy_proposal.json` `reviewed_strategy_handoff.json` 后,父 Agent
29
- 调用 `fpa_dashboard_publish_strategy` preview/publish,发布
20
+ `graph_run`,必须先调用 `graph_list`,确认 `fpa-strategy-planning` 后按以下边界执行:
21
+
22
+ 1. 以完整用户目标运行一个组合式 `fpa-strategy-planning`。`graph_run.context`
23
+ 的所有值必须是字符串,并明确提供 `scope_id`、`cycle_id`、
24
+ `forecast_role`;对象、数组、数字和其他结构化要求全部留在 `goal`。
25
+ 2. Graph 返回 `driver_analysis.json`、`strategy_proposal.json`、
26
+ `strategy_review.json` `reviewed_strategy_handoff.json` 后,父 Agent 先调用
27
+ `fpa_dashboard_publish_review` preview/publish,取得最新 revision;再调用
28
+ `fpa_dashboard_publish_strategy`,传入 proposal/review/handoff 三个路径及精确
29
+ `scope_id`、`cycle_id`、`forecast_role` preview/publish,发布
30
30
  `next-strategy` 并等待仪表盘决策。
31
31
 
32
- 两个 Graph 都不得拥有或调用任何 `fpa_dashboard_*` 工具。发布是父 Agent
32
+ Graph 不得拥有或调用任何 `fpa_dashboard_*` 工具。发布是父 Agent
33
33
  在 Graph 返回后的职责;父 Agent不得自行模拟 Graph 内的业务阶段。
34
34
 
35
35
  如果匹配 Graph 不在 catalog 中或无法加载,列出缺口并保持 `blocked`;不得把
@@ -39,7 +39,7 @@ Graph 返回业务阻塞同样不构成降级理由。所需上下文缺失时
39
39
  ## 不可跳过的停点
40
40
 
41
41
  - 策略推荐者不得批准或独立复核自己的提案。若宿主不能证明复核上下文与提案作者独立,复核阶段必须报告 `blocked`。
42
- - 独立复核完成后,`fpa-strategy-recommendation` 必须生成精确绑定提案与复核版本的 `reviewed_strategy_handoff`,然后停止。
42
+ - 独立复核完成后,`fpa-strategy-planning` 必须生成精确绑定提案与复核版本的 `reviewed_strategy_handoff`,然后停止。
43
43
  - 聊天中的认可、模型自我确认或 Forecast 授权都不等于策略执行授权。
44
44
  - 仪表盘响应回到原主会话后,先由主 Agent 调用
45
45
  `fpa_strategy_decision_commit`。只有 `confirm` 成功才可在后续运行
@@ -20,18 +20,18 @@ writing artifacts. It applies only when the current agent exposes both
20
20
 
21
21
  | Eligible request and evidence | Required Graph |
22
22
  | --- | --- |
23
- | New or changed objective requiring planning, Actuals diagnosis, and driver analysis | `fpa-period-analysis` |
24
- | Completed `driver_analysis` requiring scenarios, recommendation, and independent review | `fpa-strategy-recommendation` |
23
+ | New or changed objective requiring planning, Actuals diagnosis, driver analysis, scenarios, recommendation, and independent review | `fpa-strategy-planning` |
25
24
  | Ready `reviewed_strategy_handoff` plus an exact confirmed `strategy_decision` and a request for an official forecast | `fpa-forecast-freeze` |
26
25
  | Exact committed approved Forecast ref plus an authorized execution request | `fpa-strategy-execution` |
27
26
  | Exact Forecast and Execution refs plus newly arrived comparable Actuals | `fpa-cycle-review` |
28
27
 
29
- 3. Call `graph_run` with the user's complete goal and the exact immutable
30
- context required by that Graph. The parent agent must not reproduce its
31
- nodes or pre-run their `fpa_*` calls. After a successful Graph handoff, the
32
- main Agent must publish only that stage's module with the matching
33
- `fpa_dashboard_publish_*` tool. Graph nodes must never receive dashboard
34
- publication tools.
28
+ 3. Call `graph_run` with the user's complete goal. Every `context` value must
29
+ be a string; provide exact `scope_id`, `cycle_id`, and `forecast_role`, and
30
+ keep structured planning details in `goal`. The parent agent must not
31
+ reproduce Graph nodes or pre-run their `fpa_*` calls. After the combined
32
+ planning Graph succeeds, the main Agent publishes `period-review` and then
33
+ `next-strategy` with their matching `fpa_dashboard_publish_*` tools. Graph
34
+ nodes must never receive dashboard publication tools.
35
35
  4. If required context is missing or ambiguous, return `blocked` and request
36
36
  the exact identity or ref. Do not fall back to direct phase execution.
37
37
 
@@ -2,20 +2,18 @@
2
2
 
3
3
  ## 1. Workflow and artifacts
4
4
 
5
- Period-analysis workflow (`fpa-period-analysis`):
5
+ Combined strategy-planning workflow (`fpa-strategy-planning`):
6
6
 
7
- `planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis`
7
+ `planning_brief -> parallel(actuals audit, driver audit) -> driver_analysis -> parallel(efficiency, growth, risk) -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
8
8
 
9
- The calling main Agent publishes `period-review`, then starts the separate
10
- strategy-recommendation workflow (`fpa-strategy-recommendation`):
11
-
12
- `driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
13
-
14
- Each Graph stops at its named handoff. Neither Graph may publish dashboard
15
- content. The strategy Graph does not
9
+ The Graph stops at the reviewed strategy handoff and may not publish dashboard
10
+ content. Parallel children are independent and read-only; sequential reducer
11
+ nodes alone write the canonical artifacts. The strategy Graph does not
16
12
  approve the strategy, create an official forecast, or execute the allocation.
17
13
 
18
- The calling main Agent publishes the exact ready handoff through
14
+ The calling main Agent first publishes the exact `driver_analysis` through
15
+ `fpa_dashboard_publish_review`, then publishes the exact proposal, independent
16
+ review, ready handoff, and immutable scope/cycle/role through
19
17
  `fpa_dashboard_publish_strategy`, waits for the dashboard response in the
20
18
  originating main session, and commits it with `fpa_strategy_decision_commit`.
21
19
  Dashboard publishing and decision commit tools are forbidden from every Graph
@@ -27,9 +25,9 @@ ready handoff plus the exact confirmed strategy-decision fingerprint:
27
25
  `reviewed_strategy_handoff + strategy_decision -> forecast_plan -> approved_cycle_forecast`
28
26
 
29
27
  The main Agent owns approval recording; the forecast Graph owns deterministic
30
- composition and artifact freezing. A rejected approval returns to a new
31
- `fpa-strategy-planning` version rather than modifying the reviewed proposal in
32
- place.
28
+ composition and artifact freezing. A rejected approval reruns
29
+ `fpa-strategy-planning` with the exact feedback and produces a new version
30
+ rather than modifying the reviewed proposal in place.
33
31
 
34
32
  Execution is a separate `fpa-strategy-execution` workflow:
35
33
 
@@ -12,7 +12,7 @@ Load `$fpa-apply-core-rules` first. Recommend one plan while keeping alternative
12
12
  1. Consume `planning_brief`, `driver_analysis`, and `strategy_scenarios`.
13
13
  2. Remove scenarios that breach hard constraints.
14
14
  3. Rank remaining scenarios using the objective priorities and declared trade-off rule.
15
- 4. Select one recommendation and specify exact allocation, changes from baseline, expected ranges, assumptions, and risks.
15
+ 4. Select one recommendation and specify exact allocation, an explicit `grow | hold | cut | stop` action for every App/store/channel row, changes from baseline, expected ranges, assumptions, and risks.
16
16
  5. State why rejected alternatives were not chosen.
17
17
  6. Define next-cycle evaluation thresholds; do not create a real-time monitoring obligation.
18
18
  7. Write `strategy_proposal` using [artifact-contract.md](references/artifact-contract.md).
@@ -12,6 +12,7 @@ allocation:
12
12
  - app_id: string
13
13
  store: string
14
14
  channel_group: string
15
+ action: grow | hold | cut | stop
15
16
  spend: number
16
17
  change_from_baseline: number
17
18
  expected_outcomes:
@@ -27,4 +28,4 @@ next_cycle_evaluation_thresholds: []
27
28
  required_human_decisions: []
28
29
  ```
29
30
 
30
- The total allocation must reconcile to the proposed total budget, and every allocation row must map to a simulated scenario row.
31
+ The total allocation must reconcile to the proposed total budget, and every allocation row must map to a simulated scenario row. `action` is explicit and mandatory: use `grow` for a budget increase, `hold` for an unchanged allocation, `cut` for a non-zero reduction, and `stop` when the slice is removed or held at zero under a stop-loss decision.
@@ -22,10 +22,13 @@ preserve every other module revision.
22
22
 
23
23
  1. After the analysis Graph returns `driver_analysis`, preview then publish
24
24
  `period-review` with `fpa_dashboard_publish_review`.
25
- 2. After the strategy Graph returns the exact `strategy_proposal` and
26
- `reviewed_strategy_handoff`, preview then publish `next-strategy` with
27
- `fpa_dashboard_publish_strategy`. Publication binds its decision action to
28
- the current main session; never copy or expose a session id in dashboard data.
25
+ 2. After the strategy Graph returns the exact `strategy_proposal`, independent
26
+ `strategy_review`, and `reviewed_strategy_handoff`, preview then publish
27
+ `next-strategy` with `fpa_dashboard_publish_strategy`. Supply all three paths
28
+ and the exact `scope_id`, `cycle_id`, and `forecast_role`; the tool verifies
29
+ fingerprints, review independence, opinion, conditions, and next-stage
30
+ binding. Publication binds its decision action to the current main session;
31
+ never copy or expose a session id in dashboard data.
29
32
  3. Stop and wait for the dashboard action. The Web host sends a canonical
30
33
  decision message back to the bound original session. In that session call
31
34
  `fpa_strategy_decision_commit` with the exact `action_id`.
@@ -23,3 +23,25 @@ reviewed_at: timestamp
23
23
  ```
24
24
 
25
25
  Set `independence_confirmed: false` and `status: blocked` if the reviewer also authored the proposal. Any unresolved critical finding requires `reject`.
26
+
27
+ # `reviewed_strategy_handoff` contract
28
+
29
+ ```yaml
30
+ kind: fpa.reviewed-strategy-handoff
31
+ status: ready | blocked
32
+ scope_id: string
33
+ cycle_id: string
34
+ forecast_role: string
35
+ strategy_version: string
36
+ reviewed_strategy_version: string
37
+ proposal_fingerprint: sha256
38
+ review_fingerprint: sha256
39
+ reviewer_identity: string
40
+ independence_confirmed: true
41
+ review_opinion: support | support_with_conditions
42
+ review_conditions: []
43
+ review_residual_risks: []
44
+ next_graph: fpa-forecast-freeze
45
+ ```
46
+
47
+ `strategy_version` and `reviewed_strategy_version` must be identical and must bind the exact proposal reviewed. A rejected or non-independent review cannot produce a ready handoff.
@@ -1,10 +0,0 @@
1
- {
2
- "name": "fpa-period-analysis",
3
- "version": "1.0.0",
4
- "start": "plan_cycle",
5
- "nodes": [
6
- { "id": "plan_cycle", "type": "subagent", "agentName": "plan_cycle", "next": "diagnose_actuals", "skills": ["fpa-plan-cycle"], "tools": ["read", "write", "fpa_data_catalog"], "prompt": "规划精确周期、范围、指标与边界;不得推荐策略或调用仪表盘工具。" },
7
- { "id": "diagnose_actuals", "type": "subagent", "agentName": "diagnose_actuals", "next": "analyze_drivers", "skills": ["fpa-diagnose-actuals"], "tools": ["read", "write", "fpa_data_catalog", "fpa_query"], "prompt": "诊断 Actuals 完整性并写入工件;不得调用仪表盘工具。" },
8
- { "id": "analyze_drivers", "type": "subagent", "agentName": "analyze_drivers", "skills": ["fpa-analyze-drivers"], "tools": ["read", "write", "fpa_query", "fpa_calc"], "prompt": "分析上周期驱动因素并写 artifacts/driver_analysis.json;不得推荐策略或调用仪表盘工具。" }
9
- ]
10
- }
@@ -1,11 +0,0 @@
1
- {
2
- "name": "fpa-strategy-recommendation",
3
- "version": "1.0.0",
4
- "start": "simulate_strategies",
5
- "nodes": [
6
- { "id": "simulate_strategies", "type": "subagent", "agentName": "simulate_strategies", "next": "recommend_strategy", "skills": ["fpa-simulate-strategies"], "tools": ["read", "write", "fpa_query", "fpa_calc"], "prompt": "根据分析工件试算候选策略;不得批准、预测或发布仪表盘。" },
7
- { "id": "recommend_strategy", "type": "subagent", "agentName": "recommend_strategy", "next": "review_strategy", "skills": ["fpa-recommend-strategy"], "tools": ["read", "write"], "prompt": "形成完整策略提案;不得自我批准或发布仪表盘。" },
8
- { "id": "review_strategy", "type": "subagent", "agentName": "review_strategy", "next": "prepare_handoff", "skills": ["fpa-review-strategy"], "tools": ["read", "write", "fpa_calc"], "prompt": "独立复核策略并写审核工件;不得批准或发布仪表盘。" },
9
- { "id": "prepare_handoff", "type": "subagent", "agentName": "prepare_handoff", "tools": ["read", "write"], "prompt": "仅在审核通过时形成 reviewed_strategy_handoff;停止,不批准、不预测、不发布仪表盘。" }
10
- ]
11
- }