@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 +15 -4
- package/extensions/fpa-artifacts/index.ts +22 -0
- package/extensions/fpa-dashboard/index.ts +10 -1
- package/extensions/fpa-dashboard/stage-projector.ts +71 -8
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +114 -28
- package/graphs/fpa-forecast-freeze.json +91 -7
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +2 -2
- package/prompts/fpa-plan-cycle.md +12 -12
- package/skills/fpa-apply-core-rules/SKILL.md +8 -8
- package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
- package/skills/fpa-recommend-strategy/SKILL.md +1 -1
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
- package/skills/fpa-refresh-dashboard/SKILL.md +7 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
- package/graphs/fpa-period-analysis.json +0 -10
- package/graphs/fpa-strategy-recommendation.json +0 -11
package/README.md
CHANGED
|
@@ -33,7 +33,9 @@ Skill:
|
|
|
33
33
|
阶段的请求都必须先查询 Graph catalog,再运行当前第一个满足入口条件的
|
|
34
34
|
Graph;在此之前不得调用 `fpa_*` 工具或写 Artifact。四个工作流的边界为:
|
|
35
35
|
|
|
36
|
-
- `fpa-strategy-planning
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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:
|
|
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-
|
|
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
|
|
152
|
-
|
|
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
|
|
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
|
-
]
|
|
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
|
|
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 ?
|
|
243
|
+
const requiredGraph = classified ?? (isContinuation ? previousState.requiredGraph : undefined);
|
|
172
244
|
if (!requiredGraph) return;
|
|
173
245
|
state.requiredGraph = requiredGraph;
|
|
174
|
-
|
|
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-
|
|
212
|
-
const
|
|
213
|
-
? "fpa_dashboard_publish_review"
|
|
214
|
-
: graph === "fpa-
|
|
215
|
-
? "
|
|
216
|
-
:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
{
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
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-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
29
|
-
|
|
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
|
-
|
|
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-
|
|
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,
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
5
|
+
Combined strategy-planning workflow (`fpa-strategy-planning`):
|
|
6
6
|
|
|
7
|
-
`planning_brief ->
|
|
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
|
|
10
|
-
|
|
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
|
|
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
|
|
31
|
-
`fpa-strategy-planning`
|
|
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
|
|
26
|
-
`reviewed_strategy_handoff`, preview then publish
|
|
27
|
-
`fpa_dashboard_publish_strategy`.
|
|
28
|
-
the
|
|
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
|
-
}
|