@viccydev/pi-fpa 0.7.0 → 0.7.2

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
@@ -2,14 +2,14 @@
2
2
 
3
3
  面向 Pi 的完整 FP&A 周期资源包,目标运行时为 `@earendil-works/pi-coding-agent` 0.84.1。
4
4
 
5
- 本包分发 Prompt Template、Skill、参考合同,以及数据读取、规范化 Artifact 和看板投影三个 Extension。包内不含业务数据与模型凭证,也不实现工作流状态机。多 Agent 隔离、人工审批和真实外部执行仍由宿主运行时或独立工作流承担。
5
+ 本包分发 Prompt Template、Skill、参考合同,以及 Graph 路由护栏、数据读取、规范化 Artifact 和看板投影四个 Extension。包内不含业务数据与模型凭证,也不实现工作流状态机;它声明并在工具调用边界执行主 Agent 必须遵守的 Graph-first 路由契约。Graph 的定义、状态、多 Agent 隔离、人工审批和真实外部执行仍由宿主运行时或独立工作流承担。
6
6
 
7
7
  ## 包含的资源
8
8
 
9
9
  Prompt Template:
10
10
 
11
- - `/fpa-plan-cycle`:从周期规划开始,经过 Actuals 诊断、驱动分析、策略模拟、推荐和独立复核;只有拿到匹配版本的外部人工批准后,才生成并冻结正式预测。
12
- - `/fpa-review-cycle`:新周期 Actuals 到达后,对比冻结的正式预测并形成周期复盘。
11
+ - `/fpa-plan-cycle`:通过 `fpa-strategy-planning` Graph 完成 Actuals 诊断、驱动分析、策略模拟、推荐和独立复核,形成 `reviewed_strategy_handoff` 后停止;不跨越人工批准或冻结正式预测。
12
+ - `/fpa-review-cycle`:在精确 Forecast/Execution refs 和新周期 Actuals 到达后,通过 `fpa-cycle-review` Graph 形成周期复盘。
13
13
 
14
14
  Skill:
15
15
 
@@ -27,6 +27,27 @@ Skill:
27
27
 
28
28
  执行 Skill 设置了 `disable-model-invocation: true`,不会出现在模型可主动调用的 Skill 摘要中,也没有对应 Prompt。只有用户显式输入 `/skill:fpa-execute-approved-strategy` 才能加载它;即使显式加载,缺少精确批准、单独执行授权、真实 adapter、幂等键或成功 preflight 时也必须保持 `blocked`,不得产生外部变更。
29
29
 
30
+ ## Graph-first 路由
31
+
32
+ 主 Agent 同时具备 `graph_list` 和 `graph_run` 时,任何跨两个或更多 FP&A
33
+ 阶段的请求都必须先查询 Graph catalog,再运行当前第一个满足入口条件的
34
+ Graph;在此之前不得调用 `fpa_*` 工具或写 Artifact。四个工作流的边界为:
35
+
36
+ - `fpa-strategy-planning`:规划至 `reviewed_strategy_handoff` 后停止。
37
+ - `fpa-forecast-freeze`:只接受精确 handoff,经人工批准后冻结 Forecast。
38
+ - `fpa-strategy-execution`:只接受精确 committed Forecast ref 和单独执行授权。
39
+ - `fpa-cycle-review`:只接受精确 Forecast/Execution refs 与新 Actuals。
40
+
41
+ Graph 的业务阻塞不能触发父 Agent 降级执行。Graph 工具不可用、catalog 不存在
42
+ 匹配 Graph 或匹配 Graph 无法加载时,多阶段入口保持 `blocked`;用户必须另行
43
+ 显式请求一个隔离阶段,父 Agent 不能自动把完整工作流改成本地串行执行。Graph
44
+ 节点只执行分配给自己的阶段,不得递归启动另一个 Graph。
45
+
46
+ `fpa-routing-guard` Extension 会在 Pi 的 `tool_call` 执行前落实这条边界:
47
+ 它从展开后的请求识别多阶段 FP&A 意图,要求目标 Graph 与请求阶段一致,并
48
+ 阻断父 Agent 的提前 `fpa_*` 调用和 `artifacts/` 写入。Graph worker 没有
49
+ `graph_list`/`graph_run` 时不会激活该护栏,因此节点仍可执行被分配的单一阶段。
50
+
30
51
  ## 数据 Extension(fpa-data)
31
52
 
32
53
  `extensions/fpa-data` 注册五个只读工具,连接 Supabase 数据集市:
@@ -127,12 +148,12 @@ pi list
127
148
  团队分发建议使用固定 Git tag:
128
149
 
129
150
  ```bash
130
- pi install git:github.com/linyqh/pi-fpa@v0.7.0
151
+ pi install git:github.com/linyqh/pi-fpa@v0.7.2
131
152
  ```
132
153
 
133
154
  ## 发布到 npm
134
155
 
135
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.7.0` 对应 `v0.7.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
156
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.7.2` 对应 `v0.7.2`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
136
157
 
137
158
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
138
159
 
@@ -148,14 +169,14 @@ pi install git:github.com/linyqh/pi-fpa@v0.7.0
148
169
 
149
170
  ```text
150
171
  /fpa-plan-cycle /path/to/project 2026-Q3 "按 App、Store、Channel Group 规划;预算上限见 planning input"
151
- /fpa-review-cycle /path/to/project 2026-Q3 "使用已冻结 forecast 和新到达的 Actuals snapshot"
172
+ /fpa-review-cycle /path/to/project 2026-Q3 "使用精确 forecast/execution refs 和新到达的 Actuals snapshot"
152
173
  /skill:fpa-refresh-dashboard "预览并发布当前项目的预测闭环看板"
153
174
  ```
154
175
 
155
- 规划入口不会进入真实策略执行。需要执行获批策略时,必须在单独、已授权的运行中显式调用:
176
+ 规划入口停在 `reviewed_strategy_handoff`,不会等待人工批准、冻结 Forecast 或进入真实策略执行。后续分别由 `fpa-forecast-freeze` 和 `fpa-strategy-execution` Graph 承担。若宿主没有 Graph 能力、且用户只请求一个隔离执行阶段,才可在单独、已授权的运行中显式调用:
156
177
 
157
178
  ```text
158
- /skill:fpa-execute-approved-strategy <exact strategy version and execution scope>
179
+ /skill:fpa-execute-approved-strategy <exact committed forecast ref and execution scope>
159
180
  ```
160
181
 
161
182
  ## 资源迁移注意
@@ -58,11 +58,13 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
58
58
  label: "Refresh FP&A Dashboard",
59
59
  description:
60
60
  "Build the forecast closed-loop dashboard from a committed approved forecast, optional committed execution receipt, and current read-only FP&A Actuals. " +
61
- "Preview computes and validates without publishing; publish requires the exact preview fingerprint and atomically promotes a content-addressed generation.",
61
+ "Preview computes and validates without publishing; publish requires the exact preview fingerprint and atomically promotes a content-addressed generation. " +
62
+ "A backtest can be displayed only from an exact committed artifact when the user explicitly requests it.",
62
63
  promptSnippet: "Preview or atomically publish the forecast closed-loop FP&A dashboard",
63
64
  promptGuidelines: [
64
65
  "Always call fpa_dashboard_refresh with mode=preview before mode=publish and carry forward the exact preview_fingerprint.",
65
66
  "Never write .fpa-dashboard files with generic file or shell tools; use fpa_dashboard_refresh so calculations, lineage, and atomic publication stay consistent.",
67
+ "Set allow_backtest_display=true only when the user explicitly asks to display or publish an exact committed backtest; never infer this authorization from Graph completion.",
66
68
  ],
67
69
  parameters: Type.Object(
68
70
  {
@@ -70,6 +72,7 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
70
72
  mode: StringEnum(["preview", "publish"] as const),
71
73
  locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
72
74
  expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
75
+ allow_backtest_display: Type.Optional(Type.Boolean()),
73
76
  scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
74
77
  cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
75
78
  forecast_ref: Type.Optional(artifactRefSchema),
@@ -86,6 +89,9 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
86
89
  if (hasAnyExactField && exactFields.some((value) => value === undefined)) {
87
90
  throw new Error("scope_id, cycle_id, and forecast_ref must be supplied together; exact refresh never falls back to current artifacts.");
88
91
  }
92
+ if (params.allow_backtest_display && !hasAnyExactField) {
93
+ throw new Error("allow_backtest_display requires an exact scope_id, cycle_id, and forecast_ref.");
94
+ }
89
95
  const exact = hasAnyExactField ? {
90
96
  scopeId: params.scope_id as string,
91
97
  cycleId: params.cycle_id as string,
@@ -96,19 +102,24 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
96
102
  } : undefined;
97
103
  if (exact) {
98
104
  const exactForecast = await readArtifactByRef(ctx.cwd, exact.forecastRef);
99
- if (exactForecast.ledgerContext?.forecast_role === "backtest") {
100
- throw new Error("A backtest forecast cannot be previewed or published as the operating dashboard.");
105
+ const exactRole = exactForecast.ledgerContext?.forecast_role;
106
+ if (exactRole === "backtest" && params.allow_backtest_display !== true) {
107
+ throw new Error("Displaying a backtest requires explicit user authorization through allow_backtest_display=true.");
108
+ }
109
+ if (params.allow_backtest_display === true && exactRole !== "backtest") {
110
+ throw new Error("allow_backtest_display is valid only for an exact committed backtest.");
101
111
  }
102
112
  }
103
113
  const { build, forecast, actuals, sliceKeyMismatch, projector, runtime } = await buildDashboardProjection(ctx.cwd, params.preset, params.locale ?? "zh-CN", exact, signal);
104
- if (build.source.forecast_role === "backtest") {
105
- throw new Error("A backtest forecast cannot be previewed or published as the operating dashboard.");
114
+ if (build.source.forecast_role === "backtest" && params.allow_backtest_display !== true) {
115
+ throw new Error("Displaying a backtest requires explicit user authorization through allow_backtest_display=true.");
106
116
  }
107
117
  const previewFingerprint = dashboardBuildFingerprint(build, projector);
108
118
  const summary = {
109
119
  preset: params.preset,
110
120
  preview_fingerprint: previewFingerprint,
111
121
  forecast_version: forecast.forecast_version,
122
+ forecast_role: build.source.forecast_role,
112
123
  forecast_fingerprint: build.source.forecast_fingerprint,
113
124
  execution_fingerprint: build.source.execution_fingerprint,
114
125
  data_as_of: build.source.data_as_of,
@@ -146,7 +157,14 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
146
157
  if (params.expected_preview_fingerprint !== previewFingerprint) {
147
158
  throw new Error("Dashboard inputs changed after preview; run preview again before publishing.");
148
159
  }
149
- const published = await publishDashboard({ cwd: ctx.cwd, build, projector, runtime, actualsSnapshot: actuals });
160
+ const published = await publishDashboard({
161
+ cwd: ctx.cwd,
162
+ build,
163
+ projector,
164
+ runtime,
165
+ actualsSnapshot: actuals,
166
+ allowBacktestDisplay: params.allow_backtest_display === true,
167
+ });
150
168
  return toolResult({ status: "published", ...summary, dashboard_dir: published.dashboardDir, published_datasets: published.publishedDatasets });
151
169
  },
152
170
  });
@@ -22,6 +22,7 @@ export interface PublishDashboardOptions {
22
22
  runtime: DashboardProjectorRuntime;
23
23
  publishedAt?: string;
24
24
  actualsSnapshot?: DashboardActualsSnapshot;
25
+ allowBacktestDisplay?: boolean;
25
26
  }
26
27
 
27
28
  export interface PublishDashboardResult {
@@ -177,8 +178,8 @@ export function dashboardBuildFingerprint(build: DashboardBuild, projector: Dash
177
178
 
178
179
  export async function publishDashboard(options: PublishDashboardOptions): Promise<PublishDashboardResult> {
179
180
  assertBuild(options.build);
180
- if (options.build.source.forecast_role === "backtest") {
181
- throw new Error("A backtest forecast cannot replace the operating dashboard.");
181
+ if (options.build.source.forecast_role === "backtest" && options.allowBacktestDisplay !== true) {
182
+ throw new Error("A backtest cannot replace the operating dashboard without explicit user authorization.");
182
183
  }
183
184
  const projector = validateProjectorProvenance(options.projector);
184
185
  const runtime = validateProjectorRuntime(options.runtime);
@@ -0,0 +1,220 @@
1
+ import type {
2
+ BeforeAgentStartEvent,
3
+ ExtensionAPI,
4
+ ToolCallEvent,
5
+ ToolResultEvent,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ const GRAPH_TOOLS = ["graph_list", "graph_run"] as const;
9
+ const FP_AND_A_GRAPHS = [
10
+ "fpa-strategy-planning",
11
+ "fpa-forecast-freeze",
12
+ "fpa-strategy-execution",
13
+ "fpa-cycle-review",
14
+ ] as const;
15
+ type FpaGraph = (typeof FP_AND_A_GRAPHS)[number];
16
+
17
+ interface RoutingState {
18
+ requiredGraph?: FpaGraph;
19
+ catalogLoaded: boolean;
20
+ availableGraphs: Set<string>;
21
+ graphRunStarted: boolean;
22
+ }
23
+
24
+ function emptyState(): RoutingState {
25
+ return {
26
+ catalogLoaded: false,
27
+ availableGraphs: new Set(),
28
+ graphRunStarted: false,
29
+ };
30
+ }
31
+
32
+ function includesAny(prompt: string, patterns: RegExp[]): boolean {
33
+ return patterns.some((pattern) => pattern.test(prompt));
34
+ }
35
+
36
+ function isMetaRequest(prompt: string): boolean {
37
+ return includesAny(prompt, [
38
+ /(?:解释|说明).*(?:为何|为什么)/,
39
+ /(?:为何|为什么).*(?:未|没|没有|不)/,
40
+ /(?:排查|修复|分析).*(?:bug|路由|工作流未运行|工具顺序|代码问题)/i,
41
+ /(?:比较|对比|说明|解释).*(?:graph|工作流).*(?:职责|区别|差异|定义|作用|用途)/i,
42
+ /(?:修改|编辑|更新|创建|设计|删除).*(?:graph|工作流|fpa-(?:strategy|forecast|cycle)).*(?:定义|节点|配置|spec|graph)?/i,
43
+ /(?:查询|查看|解释|计算).*(?:不做|不要|无需).*(?:规划|预测|执行|复盘|推荐)/i,
44
+ /(?:不要|无需|不需|禁止)(?:执行|运行|调用).*(?:工作流|graph|fpa_)/i,
45
+ /(?:do not|don't|without) (?:run|execute|call)/i,
46
+ ]);
47
+ }
48
+
49
+ function isSingleInformationalRequest(prompt: string): boolean {
50
+ const asksForOneFact = /(?:是什么|什么意思|定义|查询|查看).*(?:ROAS|Revenue|Actuals?|Forecast|FP&A|\bFPA\b|\bUA\b)|(?:ROAS|Revenue|Actuals?|Forecast|FP&A|\bFPA\b|\bUA\b).*(?:是什么|什么意思|定义)/i.test(prompt);
51
+ if (!asksForOneFact) return false;
52
+ return !/(?:然后|接着|随后|后(?:再)?|并(?:且)?|制定|规划|预算方案|分析驱动|策略建议|独立审核|冻结|执行|复盘)/i.test(prompt);
53
+ }
54
+
55
+ function isExplicitIsolatedPhase(prompt: string): boolean {
56
+ const skill = prompt.match(/^<skill name="(fpa-[^"]+)"/);
57
+ if (skill && skill[1] !== "fpa-apply-core-rules") return true;
58
+ if (!/(?:^|[。;;]\s*)(?:请)?(?:本次)?(?:只|仅)|\bonly\b/i.test(prompt)) return false;
59
+ return !/(?:然后|接着|随后|再进入|后再|直至|全流程|完整流程|end[- ]to[- ]end)/i.test(prompt);
60
+ }
61
+
62
+ function hasFpaIntent(prompt: string): boolean {
63
+ if (includesAny(prompt, [/分析数据.*(?:方案|计划)/, /下一周期.*(?:方案|计划)/, /完整.*(?:流程|工作流)/])) return true;
64
+ const businessSignals = [
65
+ /FP&A|\bFPA\b/i,
66
+ /\bUA\b/i,
67
+ /ROAS/i,
68
+ /Actuals?/i,
69
+ /\bspend\b/i,
70
+ /预算|budget/i,
71
+ /规划|\bplan(?:ning)?\b/i,
72
+ /投放|campaign/i,
73
+ /策略|strategy/i,
74
+ /预测|forecast/i,
75
+ /复盘|variance/i,
76
+ /归因|attribution/i,
77
+ /收入|revenue/i,
78
+ /花费|cost/i,
79
+ ];
80
+ return businessSignals.filter((pattern) => pattern.test(prompt)).length >= 2;
81
+ }
82
+
83
+ export function classifyFpaGraph(prompt: string): FpaGraph | undefined {
84
+ const normalized = prompt.toLowerCase();
85
+ if (isMetaRequest(normalized) || isExplicitIsolatedPhase(prompt) || isSingleInformationalRequest(prompt)) return undefined;
86
+
87
+ for (const graph of FP_AND_A_GRAPHS) {
88
+ if (
89
+ normalized.includes(graph) &&
90
+ /(?:运行|执行|启动|调用|使用|通过|\brun\b|\bexecute\b|\bstart\b)/i.test(normalized)
91
+ ) return graph;
92
+ }
93
+
94
+ const exactRef = /(?:精确|exact|immutable|committed|已提交|不可变).{0,24}(?:ref|引用)/i;
95
+ if (
96
+ includesAny(normalized, [/复盘/, /cycle[ -]?review/, /variance/, /偏差/]) &&
97
+ includesAny(normalized, [/actuals?/, /实际/]) &&
98
+ /(?:forecast|预测).{0,32}(?:execution|执行).{0,32}(?:refs?|引用)|(?:refs?|引用).{0,32}(?:forecast|预测).{0,32}(?:execution|执行)/i.test(normalized)
99
+ ) {
100
+ return "fpa-cycle-review";
101
+ }
102
+
103
+ if (
104
+ /reviewed_strategy_handoff|(?:精确|exact|不可变).{0,24}handoff/i.test(normalized) &&
105
+ includesAny(normalized, [/冻结.*预测/, /正式预测/, /approved.*forecast/, /forecast.*freeze/])
106
+ ) {
107
+ return "fpa-forecast-freeze";
108
+ }
109
+
110
+ if (
111
+ includesAny(normalized, [/执行/, /execute/, /投放变更/, /external mutation/]) &&
112
+ includesAny(normalized, [/授权/, /获批/, /approved/, /批准/]) &&
113
+ /(?:forecast|预测)/i.test(normalized) && exactRef.test(normalized)
114
+ ) {
115
+ return "fpa-strategy-execution";
116
+ }
117
+
118
+ return hasFpaIntent(normalized) ? "fpa-strategy-planning" : undefined;
119
+ }
120
+
121
+ function contentText(event: ToolResultEvent): string {
122
+ return event.content
123
+ .filter((item): item is Extract<(typeof event.content)[number], { type: "text" }> => item.type === "text")
124
+ .map((item) => item.text)
125
+ .join("\n");
126
+ }
127
+
128
+ function catalogNames(event: ToolResultEvent): string[] {
129
+ const details = event.details;
130
+ if (details && typeof details === "object" && "names" in details) {
131
+ const names = (details as { names?: unknown }).names;
132
+ if (Array.isArray(names)) return names.filter((name): name is string => typeof name === "string");
133
+ }
134
+ return FP_AND_A_GRAPHS.filter((graph) => contentText(event).includes(graph));
135
+ }
136
+
137
+ function artifactWrite(event: ToolCallEvent): boolean {
138
+ if (event.toolName === "write" || event.toolName === "edit") {
139
+ const path = "path" in event.input && typeof event.input.path === "string" ? event.input.path : "";
140
+ return /(?:^|[\\/])artifacts?[\\/]/i.test(path);
141
+ }
142
+ if (event.toolName !== "bash") return false;
143
+ const command = "command" in event.input && typeof event.input.command === "string" ? event.input.command : "";
144
+ return /artifacts?[\\/]/i.test(command);
145
+ }
146
+
147
+ function routingInstruction(graph: FpaGraph): string {
148
+ return [
149
+ "FP&A runtime routing guard is active for this request.",
150
+ `The first eligible workflow is ${graph}.`,
151
+ "Call graph_list first, then graph_run with that exact graph and the complete immutable context.",
152
+ "Do not call fpa_* tools or write artifacts in the parent agent; Graph nodes own phase execution.",
153
+ ].join(" ");
154
+ }
155
+
156
+ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
157
+ let state = emptyState();
158
+
159
+ pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
160
+ const previousGraph = state.requiredGraph;
161
+ state = emptyState();
162
+ const active = new Set(pi.getActiveTools());
163
+ if (!GRAPH_TOOLS.every((tool) => active.has(tool))) return;
164
+
165
+ const classified = classifyFpaGraph(event.prompt);
166
+ const isContinuation = /^(?:继续|接着|下一步|continue|go on)[。.!!\s]*$/i.test(event.prompt.trim());
167
+ const requiredGraph = classified ?? (isContinuation ? previousGraph : undefined);
168
+ if (!requiredGraph) return;
169
+ state.requiredGraph = requiredGraph;
170
+ return { systemPrompt: `${event.systemPrompt}\n\n${routingInstruction(requiredGraph)}` };
171
+ });
172
+
173
+ pi.on("tool_call", (event: ToolCallEvent) => {
174
+ const graph = state.requiredGraph;
175
+ if (!graph) return;
176
+
177
+ if (event.toolName === "graph_list") return;
178
+ if (event.toolName === "graph_run") {
179
+ if (!state.catalogLoaded) {
180
+ return { block: true, reason: `Call graph_list before graph_run for ${graph}.` };
181
+ }
182
+ const requested = "graph" in event.input ? event.input.graph : undefined;
183
+ if (requested !== graph) {
184
+ return { block: true, reason: `Run the first eligible ${graph} Graph; received ${String(requested)}.` };
185
+ }
186
+ if (!state.availableGraphs.has(graph)) {
187
+ return {
188
+ block: true,
189
+ reason: `${graph} is not available. Return blocked; local work requires a new, explicit isolated-phase request.`,
190
+ };
191
+ }
192
+ state.graphRunStarted = true;
193
+ return;
194
+ }
195
+
196
+ if (!event.toolName.startsWith("fpa_") && !artifactWrite(event)) return;
197
+ if (!state.catalogLoaded) {
198
+ return { block: true, reason: `Call graph_list before any FP&A phase tool for ${graph}.` };
199
+ }
200
+ if (!state.availableGraphs.has(graph)) {
201
+ return {
202
+ block: true,
203
+ reason: `${graph} is unavailable. Return blocked; do not automatically fall back to a multi-stage local workflow.`,
204
+ };
205
+ }
206
+ if (!state.graphRunStarted) {
207
+ return { block: true, reason: `Call graph_run with ${graph} before any FP&A phase tool.` };
208
+ }
209
+ return {
210
+ block: true,
211
+ reason: `The ${graph} Graph owns FP&A phase work; the parent agent must not execute FP&A phase tools or write artifacts.`,
212
+ };
213
+ });
214
+
215
+ pi.on("tool_result", (event: ToolResultEvent) => {
216
+ if (!state.requiredGraph || event.toolName !== "graph_list" || event.isError) return;
217
+ state.catalogLoaded = true;
218
+ state.availableGraphs = new Set(catalogNames(event));
219
+ });
220
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",
@@ -30,7 +30,7 @@
30
30
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
31
31
  },
32
32
  "scripts": {
33
- "test": "node tests/package-structure.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/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",
33
+ "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.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/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
34
  "test:structure": "node tests/package-structure.test.mjs",
35
35
  "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/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",
36
36
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
@@ -58,6 +58,7 @@
58
58
  "./skills"
59
59
  ],
60
60
  "extensions": [
61
+ "./extensions/fpa-routing-guard/index.ts",
61
62
  "./extensions/fpa-data/index.ts",
62
63
  "./extensions/fpa-artifacts/index.ts",
63
64
  "./extensions/fpa-dashboard/index.ts"
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: 启动完整 FP&A 策略规划,并在匹配版本获得外部人工批准后形成正式预测
2
+ description: 通过 Graph 启动 FP&A 策略规划,并在复核交接点停止
3
3
  argument-hint: "<project-root> <cycle-id> [instructions]"
4
4
  ---
5
5
 
@@ -14,25 +14,25 @@ argument-hint: "<project-root> <cycle-id> [instructions]"
14
14
  - 明确周期的开始时间、结束时间、业务时区、报告币种、比较基线,以及 App、Store、Product Group、Channel Group 范围。不能从 `$2` 的名称暗自推断日期边界。
15
15
  - 关键输入缺失时列出缺口并保持 `blocked`;不得编造字段、表、数据、批准或 Artifact。
16
16
 
17
- ## 能力路由
17
+ ## 强制 Graph 路由
18
18
 
19
- 先读取可用 Skill 中的 `fpa-apply-core-rules`,再从 `fpa-plan-cycle` 开始。根据当前阶段依次按需读取:
19
+ 先读取 `fpa-apply-core-rules`。如果当前 Agent 提供 `graph_list`
20
+ `graph_run`,必须先调用 `graph_list`,确认 `fpa-strategy-planning` 后再以
21
+ 完整用户目标、已解析的项目根、周期边界、业务范围和数据上下文调用
22
+ `graph_run`。在这两步之前不得直接调用任何 `fpa_*` 工具或写 Artifact,
23
+ 父 Agent 也不得自行串行模拟 Graph 内的阶段。
20
24
 
21
- 1. `fpa-diagnose-actuals`
22
- 2. `fpa-analyze-drivers`
23
- 3. `fpa-simulate-strategies`
24
- 4. `fpa-recommend-strategy`
25
- 5. `fpa-review-strategy`
26
- 6. `fpa-forecast-approved-strategy`
27
-
28
- 不要一次性加载所有 Skill,也不要在本 Prompt 中复述它们的完整规则。每一阶段只消费已完成的上游 Artifact,并按对应 `references/artifact-contract.md` 输出。
25
+ 如果匹配 Graph 不在 catalog 中或无法加载,列出缺口并保持 `blocked`;不得把
26
+ 本多阶段入口自动降级为父 Agent 本地执行。用户之后可另行显式请求一个隔离阶段。
27
+ Graph 返回业务阻塞同样不构成降级理由。所需上下文缺失时也保持 `blocked`。
29
28
 
30
29
  ## 不可跳过的停点
31
30
 
32
31
  - 策略推荐者不得批准或独立复核自己的提案。若宿主不能证明复核上下文与提案作者独立,复核阶段必须报告 `blocked`。
33
- - 独立复核完成后停止并等待外部人工批准。聊天中的认可、模型自我确认或 Forecast 授权都不等于策略执行授权。
34
- - 只有人工批准明确引用同一 `strategy_proposal` 版本且批准条件已满足,才读取 `fpa-forecast-approved-strategy` 生成正式预测。
35
- - 本入口的终点是已确认持久化的 `approved_cycle_forecast`。不得加载或调用策略执行 Skill,不得声称已经修改任何外部投放状态。
32
+ - 独立复核完成后,`fpa-strategy-planning` 必须生成精确绑定提案与复核版本的 `reviewed_strategy_handoff`,然后停止。
33
+ - 聊天中的认可、模型自我确认或 Forecast 授权都不等于策略执行授权。
34
+ - 人工批准与正式预测属于后续独立的 `fpa-forecast-freeze` Graph;父 Agent 不得在本入口中继续执行。
35
+ - 本入口不得加载 Forecast 或策略执行 Skill,不得声称已经冻结 Forecast 或修改外部投放状态。
36
36
 
37
37
  ## 全局计算与证据底线
38
38
 
@@ -41,4 +41,4 @@ argument-hint: "<project-root> <cycle-id> [instructions]"
41
41
  - 区分 observed、assumed、modeled、judgment 和 approved;相关性不得冒充因果关系。
42
42
  - 每个结论记录实际表、字段、grain、join key、时间范围、`data_as_of`、版本和质量限制。
43
43
 
44
- 最终交付应包含 `planning_brief`、`actuals_snapshot`、`data_issue_report`、`driver_analysis`、`strategy_scenarios`、`strategy_proposal`、独立 `strategy_review`、外部 `strategy_approval` 证据,以及批准后的 `approved_cycle_forecast`。任何未完成项都必须标注状态和阻塞原因。
44
+ 最终交付应包含 `planning_brief`、`actuals_snapshot`、`data_issue_report`、`driver_analysis`、`strategy_scenarios`、`strategy_proposal`、独立 `strategy_review` `reviewed_strategy_handoff`。任何未完成项都必须标注状态和阻塞原因。
@@ -10,17 +10,21 @@ argument-hint: "<project-root> <cycle-id> [instructions]"
10
10
  ## 输入解析
11
11
 
12
12
  - 把 `$1` 作为项目根并确认它真实存在。
13
- - 找到该周期不可变的 `approved_cycle_forecast`、对应策略与批准证据,以及覆盖同一业务周期的新 Actuals snapshot
13
+ - 找到该周期精确不可变的 `approved_cycle_forecast` ref、同一周期精确不可变的 `execution_receipt` ref,以及覆盖同一业务周期的新 Actuals snapshot;不得用 mutable current pointer 或复制的 payload 代替。
14
14
  - 默认从项目根解析 `LOCAL_FPA_MART_FIELD_CATALOG.md` 和 `LOCAL_FPA_MART_SCHEMA.sql`;补充要求提供其他显式路径时使用显式路径。
15
15
  - 明确复盘周期的精确起止时间、业务时区、币种和业务范围。若 Forecast 与 Actuals 不可比,缩窄结论或保持 `blocked`,不得修写历史 Forecast。
16
16
 
17
- ## 能力路由
17
+ ## 强制 Graph 路由
18
18
 
19
- 1. 先读取 `fpa-apply-core-rules`。
20
- 2. 使用 `fpa-diagnose-actuals` 验证新 Actuals 的 grain、完整性、成熟度和可比性。
21
- 3. 使用 `fpa-review-cycle` 对比冻结 Forecast Actuals,生成其 Artifact 合同要求的 `cycle_review`。
19
+ 先读取 `fpa-apply-core-rules`。如果当前 Agent 提供 `graph_list` 和
20
+ `graph_run`,必须先调用 `graph_list`,确认 `fpa-cycle-review` 后再以完整用户
21
+ 目标、精确 Forecast ref、精确 Execution ref 和新 Actuals 上下文调用
22
+ `graph_run`。在这两步之前不得直接调用任何 `fpa_*` 工具或写 Artifact,
23
+ 父 Agent 不得自行执行 Actuals 诊断或复盘阶段。
22
24
 
23
- 只按需读取上述 Skill,不要把 Skill 正文复制到结果中。
25
+ 如果匹配 Graph 不在 catalog 中或无法加载,列出缺口并保持 `blocked`;不得把
26
+ 本多阶段入口自动降级为父 Agent 本地执行。用户之后可另行显式请求一个隔离阶段。
27
+ Graph 返回业务阻塞同样不构成降级理由。关键 ref 或上下文缺失时保持 `blocked`。
24
28
 
25
29
  ## 复盘底线
26
30
 
@@ -7,9 +7,43 @@ description: Apply the shared period, source-grain, metric, quality, approval, a
7
7
 
8
8
  Use this skill together with the phase-specific FP&A skill. It is the common contract, not a complete workflow by itself.
9
9
 
10
+ ## Main-Agent Graph routing gate
11
+
12
+ Apply this gate before reading phase references, calling business tools, or
13
+ writing artifacts. It applies only when the current agent exposes both
14
+ `graph_list` and `graph_run`.
15
+
16
+ 1. If the user's goal spans two or more FP&A phases, call `graph_list` before
17
+ any `fpa_*` tool or artifact write. A phase handoff in a Skill is not
18
+ permission for the parent agent to emulate the remaining phases itself.
19
+ 2. Select the first currently eligible workflow from the catalog:
20
+
21
+ | Eligible request and evidence | Required Graph |
22
+ | --- | --- |
23
+ | New or changed objective requiring planning, Actuals diagnosis, driver analysis, scenarios, recommendation, or independent review | `fpa-strategy-planning` |
24
+ | Ready `reviewed_strategy_handoff` plus a request for human approval and an official forecast | `fpa-forecast-freeze` |
25
+ | Exact committed approved Forecast ref plus an authorized execution request | `fpa-strategy-execution` |
26
+ | Exact Forecast and Execution refs plus newly arrived comparable Actuals | `fpa-cycle-review` |
27
+
28
+ 3. Call `graph_run` with the user's complete goal and the exact immutable
29
+ context required by that Graph. The parent agent must not reproduce its
30
+ nodes, pre-run their `fpa_*` calls, or automatically cross the next approval
31
+ or artifact handoff after the Graph returns.
32
+ 4. If required context is missing or ambiguous, return `blocked` and request
33
+ the exact identity or ref. Do not fall back to direct phase execution.
34
+
35
+ Local phase execution is allowed when the user explicitly requests only one
36
+ isolated phase, or inside a Graph node. If Graph tools are unavailable or the
37
+ matching Graph is absent/unloadable for a multi-stage request, return `blocked`;
38
+ do not automatically execute the workflow locally. The user may then submit a
39
+ new, explicit isolated-phase request. A business validation failure or a
40
+ blocked Graph result is never permission to fall back. A Graph node normally
41
+ receives only its assigned phase tools and Skills; it must execute that phase
42
+ locally and must not recursively start another Graph.
43
+
10
44
  ## Required procedure
11
45
 
12
- 1. Read [core-rules.md](references/core-rules.md) before calculating or judging anything.
46
+ 1. After the routing gate permits local phase work, read [core-rules.md](references/core-rules.md) before calculating or judging anything.
13
47
  2. Identify the workflow phase and consume only artifacts that precede it.
14
48
  3. Bind the requested business period to exact start and end timestamps, timezone, currency, App, Store, and Channel scope.
15
49
  4. Check that every source grain can support the requested period. Never manufacture finer-grained results by prorating coarse facts.
@@ -2,19 +2,35 @@
2
2
 
3
3
  ## 1. Workflow and artifacts
4
4
 
5
- Planning workflow:
5
+ Strategy-planning workflow (`fpa-strategy-planning`):
6
6
 
7
- `planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> human strategy_approval -> approved_cycle_forecast`
7
+ `planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
8
8
 
9
- The planning workflow ends after the approved forecast is frozen.
9
+ This Graph stops after an independently reviewed strategy handoff. It does not
10
+ approve the strategy, create an official forecast, or execute the allocation.
10
11
 
11
- Execution is a separate workflow:
12
+ Forecast-freeze workflow (`fpa-forecast-freeze`), entered only with that exact
13
+ ready handoff:
12
14
 
13
- `strategy_approval + strategy_proposal -> execution_receipt`
15
+ `reviewed_strategy_handoff -> human strategy_approval -> forecast_plan -> approved_cycle_forecast`
14
16
 
15
- Next-cycle review is another workflow, triggered only after new Actuals arrive:
17
+ The forecast Graph owns approval recording, deterministic composition, and
18
+ artifact freezing. A rejected approval returns to a new
19
+ `fpa-strategy-planning` version rather than modifying the reviewed proposal in
20
+ place.
16
21
 
17
- `approved_cycle_forecast + next actuals_snapshot -> cycle_review -> optional new planning_brief`
22
+ Execution is a separate `fpa-strategy-execution` workflow:
23
+
24
+ `exact approved_cycle_forecast ref -> execution_plan -> human confirmation -> execution_receipt`
25
+
26
+ Next-cycle review is a separate `fpa-cycle-review` workflow, triggered only
27
+ after new Actuals arrive:
28
+
29
+ `exact approved_cycle_forecast ref + exact execution_receipt ref + next actuals_snapshot -> cycle_review -> optional new planning cycle`
30
+
31
+ The main agent routes to only the currently eligible Graph. It must not emulate
32
+ multiple phases itself or automatically cross a human approval or immutable
33
+ artifact handoff.
18
34
 
19
35
  Artifact ownership belongs to the workflow runtime or artifact store. An agent produces content and metadata; it must not invent a successful save, approval, or external action.
20
36
 
@@ -12,6 +12,8 @@ Load `$fpa-apply-core-rules` first. This skill is intentionally fail-closed and
12
12
 
13
13
  Require all of the following before any external mutation:
14
14
 
15
+ - the exact committed `approved_cycle_forecast` ref returned by
16
+ `fpa-forecast-freeze`, not a mutable current pointer or a copied payload;
15
17
  - explicit human `strategy_approval` for the exact `strategy_proposal` version;
16
18
  - exact target accounts, App, Store, Channel, budget, period, and permitted operations;
17
19
  - a named, currently available execution adapter/tool and its authentication context;
@@ -19,7 +21,9 @@ Require all of the following before any external mutation:
19
21
  - an idempotency key or adapter-supported equivalent;
20
22
  - a successful read-only preflight and, where supported, dry-run/preview.
21
23
 
22
- If any requirement is missing, return `blocked` and make no external call. Never simulate an adapter or fabricate a receipt.
24
+ If any requirement is missing or the committed Forecast ref does not match the
25
+ approved scope and proposal lineage, return `blocked` and make no external
26
+ call. Never simulate an adapter or fabricate a receipt.
23
27
 
24
28
  ## Procedure
25
29
 
@@ -5,18 +5,24 @@ description: Produce and freeze the official operating forecast for the next bus
5
5
 
6
6
  # FP&A Approved Strategy Forecast
7
7
 
8
- Load `$fpa-apply-core-rules` first. This is the final stage of the planning workflow.
8
+ Load `$fpa-apply-core-rules` first. This is a post-approval phase inside the
9
+ separate forecast-freeze workflow, not a continuation that the planning parent
10
+ agent may start by itself.
9
11
 
10
12
  ## Entry gate
11
13
 
12
14
  Require all of the following:
13
15
 
16
+ - the exact `reviewed_strategy_handoff` reference produced by
17
+ `fpa-strategy-planning`, including its bound proposal and review identities;
14
18
  - the exact `strategy_proposal` version;
15
19
  - a completed independent `strategy_review` supporting that version;
16
20
  - explicit human `strategy_approval` identifying the same version and any conditions;
17
21
  - the eligible data snapshot, assumptions, and model version used for forecasting.
18
22
 
19
23
  If approval evidence is absent, ambiguous, expired, conditional but unmet, or refers to another strategy version, return `blocked` without forecasting.
24
+ If the handoff is missing or any supplied proposal/review identity differs
25
+ from it, return `blocked`; never reconstruct or substitute the handoff.
20
26
 
21
27
  ## Procedure
22
28
 
@@ -34,7 +40,9 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
34
40
  period start and is the approved direct successor of the active cycle. Use
35
41
  `backtest` for a historical rerun frozen after its target
36
42
  period; it is immutable evidence, not an operating plan, and it must not
37
- replace the current pointer or enter the operating dashboard queue. A
43
+ replace the current pointer or enter the automatic operating dashboard
44
+ queue. It may be displayed from its exact immutable reference only when the
45
+ user explicitly requests a backtest dashboard publication. A
38
46
  `next_plan` is likewise stored as forward lineage and does not replace the
39
47
  legacy current pointer; its explicit dashboard handoff links it to the
40
48
  active cycle after successor validation.
@@ -82,4 +90,12 @@ declaration upstream needs correcting.
82
90
 
83
91
  ## Completion
84
92
 
85
- The planning workflow ends only after `fpa_artifact_commit` returns `status: committed`, an `immutable_fingerprint`, and (for assigned artifacts) a `dashboard_handoff`. Commit freezes the business artifact but does not enqueue or publish dashboard work. The calling main Agent owns that explicit handoff. A Markdown report is optional context, not the frozen source of truth. Never report a forecast as frozen on the strength of a written file alone — the file is a draft until the tool returns a fingerprint. Execution, if requested, is a separate workflow using `$fpa-execute-approved-strategy`. Review waits for the next cycle's Actuals and uses `$fpa-review-cycle`.
93
+ The forecast-freeze workflow ends only after `fpa_artifact_commit` returns
94
+ `status: committed`, an `immutable_fingerprint`, and (for assigned artifacts) a
95
+ `dashboard_handoff`. Commit freezes the business artifact but does not enqueue
96
+ or publish dashboard work. The calling main Agent owns that explicit handoff.
97
+ A Markdown report is optional context, not the frozen source of truth. Never
98
+ report a forecast as frozen on the strength of a written file alone — the file
99
+ is a draft until the tool returns a fingerprint. Execution, if requested, is a
100
+ separate `fpa-strategy-execution` Graph. Review waits for the next cycle's
101
+ Actuals and uses the separate `fpa-cycle-review` Graph.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fpa-plan-cycle
3
- description: Convert a business objective into a bounded FP&A and UA planning brief for one configurable business period. Use when starting a planning cycle, changing targets or constraints, or deciding what the downstream analysis and recommendation must optimize.
3
+ description: Convert a business objective into a bounded FP&A and UA planning brief for one configurable business period. This is a single planning phase; for a multi-stage planning request, use the core Graph routing gate.
4
4
  ---
5
5
 
6
6
  # FP&A Cycle Planning
@@ -26,4 +26,7 @@ Load `$fpa-apply-core-rules` first. Produce the goal contract that every downstr
26
26
 
27
27
  ## Handoff
28
28
 
29
- The next skill is `$fpa-diagnose-actuals`. Pass only the frozen `planning_brief`, not an informal summary.
29
+ When a Graph node assigns this phase, return the frozen `planning_brief` to the
30
+ Graph for `$fpa-diagnose-actuals`; pass the artifact, not an informal summary.
31
+ A main agent handling a multi-stage request must not continue the remaining
32
+ workflow in the parent agent.
@@ -26,14 +26,21 @@ only part of this tuple or fall back to a mutable current pointer.
26
26
  3. If the result is `enqueued` or `already_pending` and the current task expects
27
27
  the dashboard immediately, call one bounded `drain`, then verify with
28
28
  `fpa_dashboard_status`.
29
- 4. If the result is `not_applicable` for a `backtest`, report that the historical
30
- forecast is frozen and the operating dashboard intentionally remains
31
- unchanged. Never relabel the artifact to force it into the active lineage.
29
+ 4. If the result is `not_applicable` for a `backtest`, do not treat that as a
30
+ failed artifact. Automatic post-Graph handoff intentionally leaves the
31
+ operating dashboard unchanged. If the user explicitly asked to display or
32
+ publish that backtest, continue through the exact backtest display path
33
+ below; otherwise report that the frozen historical forecast remains in the
34
+ ledger without changing the dashboard.
32
35
 
33
36
  ### Human-requested rebuild
34
37
 
35
38
  1. Call `fpa_dashboard_status` to inspect the current generation and diagnostics.
36
- 2. Call `fpa_dashboard_refresh` with `preset: forecast-closed-loop-v1` and `mode: preview`.
39
+ 2. Call `fpa_dashboard_refresh` with `preset: forecast-closed-loop-v1` and
40
+ `mode: preview`. For a user-requested backtest display, pass the exact
41
+ `scope_id`, `cycle_id`, and `forecast_ref` from its immutable ledger handoff
42
+ together with `allow_backtest_display: true`. Never set that flag merely
43
+ because a Graph completed.
37
44
  3. Review the returned lineage, coverage, query receipts, warnings, widget list, and `preview_fingerprint`.
38
45
  4. If the user asked only to inspect or preview, stop without publishing.
39
46
  5. Publish only when the user explicitly asked to rebuild or publish the dashboard. Call the same tool with `mode: publish` and the exact preview fingerprint.
@@ -55,6 +62,10 @@ answer, and do not describe it as "the period has no data yet".
55
62
  - Never write `.fpa-dashboard` with generic file or shell tools.
56
63
  - Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
57
64
  - Do not edit the approved forecast during projection.
65
+ - Never relabel a backtest as `original`, `eac`, or `next_plan` to display it.
66
+ - `allow_backtest_display` is explicit user authorization to make that exact
67
+ immutable backtest the displayed dashboard generation. Omit it for automatic
68
+ handoff and for every non-backtest forecast.
58
69
  - If inputs change between preview and publish, preview again rather than bypassing the fingerprint check.
59
70
  - Artifact commit never performs dashboard I/O. The calling main Agent explicitly
60
71
  enqueues its exact handoff. Actuals watermark changes remain monitored by the
@@ -8,5 +8,5 @@ The dashboard is a deterministic projection, not a second planning model.
8
8
  - Coverage: retain the Actuals cutoff and query receipts. Planned slices with no Actuals remain null; paid Actuals outside the approved allocation are reported as warnings.
9
9
  - Comparison: the prior-period window must contain the same number of covered calendar days as the current Actuals window, using the forecast timezone.
10
10
  - Execution truth: `reported` means reported, not verified. Only `verified` external evidence may be labelled verified.
11
- - Publication: preview first, then atomically publish content-addressed datasets and promote `manifest.json` last.
11
+ - Publication: preview first, then atomically publish content-addressed datasets and promote `manifest.json` last. Automatic handoff never promotes a backtest; an exact committed backtest may be displayed only after explicit user authorization through `allow_backtest_display` on both preview and publish.
12
12
  - Ownership: only `fpa_dashboard_refresh` may publish this projection.
@@ -9,7 +9,12 @@ Load `$fpa-apply-core-rules` first. This workflow is event-driven by a new perio
9
9
 
10
10
  ## Entry gate
11
11
 
12
- Require the immutable `approved_cycle_forecast` and a newly diagnosed `actuals_snapshot` covering the same period and comparable scope. If data is incomplete or source grain is incompatible, narrow the review or return `blocked`.
12
+ Require the exact immutable `approved_cycle_forecast` ref, the exact immutable
13
+ `execution_receipt` ref returned by `fpa-strategy-execution`, and a newly
14
+ diagnosed `actuals_snapshot` covering the same period and comparable scope.
15
+ All refs must carry matching scope and cycle lineage. If a ref is missing or
16
+ mismatched, data is incomplete, or source grain is incompatible, return
17
+ `blocked`; never resolve a mutable current pointer as a substitute.
13
18
 
14
19
  ## Procedure
15
20
 
@@ -25,4 +25,7 @@ Load `$fpa-apply-core-rules` first. The reviewer must be independent from the re
25
25
 
26
26
  ## Handoff
27
27
 
28
- If the reviewer supports the proposal, send both artifacts to the human approval gate. After explicit approval, `$fpa-forecast-approved-strategy` may run.
28
+ If the reviewer supports the proposal, return both artifacts to the Graph so it
29
+ can create the version-bound `reviewed_strategy_handoff`. Human approval and
30
+ `$fpa-forecast-approved-strategy` belong to the separate
31
+ `fpa-forecast-freeze` Graph; the reviewing agent must not start them.