@viccydev/pi-fpa 0.6.3 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 数据集市:
@@ -60,7 +81,7 @@ Extension 内置的关键防护:
60
81
  | --- | --- |
61
82
  | `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
62
83
  | `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
63
- | `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;Forecast/Execution commit 自动入队,Actuals watermark 按 SLA 轮询并幂等发布 |
84
+ | `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/ExecutionActuals watermark 按 SLA 轮询并幂等发布 |
64
85
 
65
86
  持续刷新由 package 自带的独立 worker 驱动,Web 保持严格只读:
66
87
 
@@ -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.6.3
151
+ pi install git:github.com/linyqh/pi-fpa@v0.7.1
131
152
  ```
132
153
 
133
154
  ## 发布到 npm
134
155
 
135
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.6.3` 对应 `v0.6.3`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
156
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.7.1` 对应 `v0.7.1`。工作流会检出该标签,执行 `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.6.3
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
  ## 资源迁移注意
@@ -2,7 +2,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
4
  import { composeApprovedForecast } from "./compose.ts";
5
- import { enqueueDashboardRefresh } from "../fpa-dashboard/coordinator.ts";
6
5
  import {
7
6
  commitArtifact,
8
7
  commitArtifactFromPath,
@@ -53,7 +52,7 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
53
52
  context: Type.Optional(Type.Object({
54
53
  scope_id: Type.String({ minLength: 1, maxLength: 256 }),
55
54
  cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
56
- forecast_role: Type.Optional(Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan")])),
55
+ forecast_role: Type.Optional(Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan"), Type.Literal("backtest")])),
57
56
  upstream_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 16 })),
58
57
  promote_legacy_pointer: Type.Optional(Type.Boolean()),
59
58
  }, { additionalProperties: false })),
@@ -79,40 +78,21 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
79
78
  const committed = hasPath
80
79
  ? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string, commitOptions)
81
80
  : await commitArtifact(ctx.cwd, params.artifact, commitOptions);
82
- let dashboardRefresh;
83
- let dashboardRefreshError: string | undefined;
84
- if (committed.artifactRef && params.context) {
85
- try {
86
- const forecastRef = committed.artifactType === "approved_cycle_forecast"
87
- ? committed.artifactRef
88
- : params.context.upstream_refs?.find((ref) => ref.artifact_type === "approved_cycle_forecast") as ArtifactRefV2 | undefined;
89
- if (!forecastRef) throw new Error("Assigned execution commit has no forecast ref for dashboard refresh.");
90
- dashboardRefresh = await enqueueDashboardRefresh(ctx.cwd, {
91
- preset: "forecast-closed-loop-v1",
92
- locale: "zh-CN",
93
- scope_id: params.context.scope_id,
94
- cycle_id: params.context.cycle_id,
95
- forecast_ref: forecastRef,
96
- ...(committed.artifactType === "approved_cycle_forecast" && params.context.forecast_role ? { forecast_role: params.context.forecast_role } : {}),
97
- ...(committed.artifactType === "execution_receipt" ? { execution_ref: committed.artifactRef } : {}),
98
- reason: committed.artifactType === "approved_cycle_forecast" ? "forecast_committed" : "execution_committed",
99
- });
100
- } catch (error) {
101
- // The immutable ledger commit is already durable. Report the
102
- // secondary delivery failure explicitly instead of pretending the
103
- // commit itself failed and encouraging an unsafe duplicate retry.
104
- dashboardRefreshError = error instanceof Error ? error.message : String(error);
105
- }
106
- }
107
81
  return toolResult({
108
- status: dashboardRefreshError ? "committed_refresh_enqueue_failed" : "committed",
82
+ status: "committed",
109
83
  artifact_type: committed.artifactType,
110
84
  immutable_fingerprint: committed.fingerprint,
111
85
  path: committed.path,
112
86
  ledger_status: committed.ledgerStatus,
113
87
  ...(committed.artifactRef ? { artifact_ref: committed.artifactRef } : {}),
114
- ...(dashboardRefresh ? { dashboard_refresh: dashboardRefresh } : {}),
115
- ...(dashboardRefreshError ? { dashboard_refresh_error: dashboardRefreshError } : {}),
88
+ ...(committed.artifactRef ? {
89
+ dashboard_handoff: {
90
+ status: "deferred",
91
+ tool: "fpa_dashboard_refresh_queue",
92
+ action: "enqueue_artifact",
93
+ artifact_ref: committed.artifactRef,
94
+ },
95
+ } : {}),
116
96
  });
117
97
  },
118
98
  });
@@ -35,18 +35,25 @@ export interface ArtifactRefV2 {
35
35
  body_fingerprint: string;
36
36
  }
37
37
 
38
+ export const FORECAST_ROLES = ["original", "eac", "next_plan", "backtest"] as const;
39
+ export type ForecastRole = typeof FORECAST_ROLES[number];
40
+
41
+ export function isForecastRole(value: unknown): value is ForecastRole {
42
+ return typeof value === "string" && (FORECAST_ROLES as readonly string[]).includes(value);
43
+ }
44
+
38
45
  export interface ArtifactCommitContext {
39
46
  scope_id: string;
40
47
  cycle_id: string;
41
48
  upstream_refs?: ArtifactRefV2[];
42
- forecast_role?: "original" | "eac" | "next_plan";
49
+ forecast_role?: ForecastRole;
43
50
  }
44
51
 
45
52
  export interface ArtifactLedgerContext {
46
53
  scope_id: string;
47
54
  cycle_id: string;
48
55
  upstream_refs: ArtifactRefV2[];
49
- forecast_role?: "original" | "eac" | "next_plan";
56
+ forecast_role?: ForecastRole;
50
57
  }
51
58
 
52
59
  export interface CommitArtifactOptions {
@@ -61,7 +68,7 @@ interface ArtifactLedgerEntry extends ArtifactRefV2 {
61
68
  created_at: string;
62
69
  available_at: string;
63
70
  upstream_refs: ArtifactRefV2[];
64
- forecast_role?: "original" | "eac" | "next_plan";
71
+ forecast_role?: ForecastRole;
65
72
  assignment_status: "assigned";
66
73
  }
67
74
 
@@ -227,14 +234,14 @@ function validateCommitContext(value: ArtifactCommitContext): ArtifactLedgerCont
227
234
  if (!Array.isArray(upstream) || upstream.length > 16) throw new Error("context.upstream_refs must be an array of at most 16 artifact refs.");
228
235
  const upstreamRefs = upstream.map((ref, index) => validateArtifactRef(ref, `context.upstream_refs[${index}]`));
229
236
  if (new Set(upstreamRefs.map((ref) => ref.entry_id)).size !== upstreamRefs.length) throw new Error("context.upstream_refs must not contain duplicates.");
230
- if (source.forecast_role !== undefined && source.forecast_role !== "original" && source.forecast_role !== "eac" && source.forecast_role !== "next_plan") {
231
- throw new Error("context.forecast_role must be original, eac, or next_plan.");
237
+ if (source.forecast_role !== undefined && !isForecastRole(source.forecast_role)) {
238
+ throw new Error("context.forecast_role must be original, eac, next_plan, or backtest.");
232
239
  }
233
240
  return {
234
241
  scope_id: contextId(source.scope_id, "context.scope_id"),
235
242
  cycle_id: contextId(source.cycle_id, "context.cycle_id"),
236
243
  upstream_refs: upstreamRefs,
237
- ...(source.forecast_role ? { forecast_role: source.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
244
+ ...(isForecastRole(source.forecast_role) ? { forecast_role: source.forecast_role } : {}),
238
245
  };
239
246
  }
240
247
 
@@ -326,7 +333,7 @@ function validateLedgerEntry(value: Record<string, unknown>, path: string): { re
326
333
  }
327
334
  if (!Array.isArray(value.upstream_refs) || value.upstream_refs.length > 16) throw new Error(`${path}.upstream_refs must be an array of at most 16 artifact refs.`);
328
335
  const upstreamRefs = value.upstream_refs.map((item, index) => validateArtifactRef(item, `${path}.upstream_refs[${index}]`));
329
- if (value.forecast_role !== undefined && value.forecast_role !== "original" && value.forecast_role !== "eac" && value.forecast_role !== "next_plan") {
336
+ if (value.forecast_role !== undefined && !isForecastRole(value.forecast_role)) {
330
337
  throw new Error(`${path}.forecast_role is unsupported.`);
331
338
  }
332
339
  if (ref.artifact_type === "execution_receipt" && value.forecast_role !== undefined) throw new Error(`${path}.forecast_role is allowed only for forecasts.`);
@@ -334,7 +341,7 @@ function validateLedgerEntry(value: Record<string, unknown>, path: string): { re
334
341
  scope_id: ref.scope_id,
335
342
  cycle_id: ref.cycle_id,
336
343
  upstream_refs: upstreamRefs,
337
- ...(value.forecast_role ? { forecast_role: value.forecast_role as ArtifactLedgerContext["forecast_role"] } : {}),
344
+ ...(isForecastRole(value.forecast_role) ? { forecast_role: value.forecast_role } : {}),
338
345
  };
339
346
  const expectedEntryId = entryIdentity(context, ref.artifact_type, ref.body_fingerprint);
340
347
  if (expectedEntryId !== ref.entry_id) throw new Error(`${path} identity does not match its upstream refs.`);
@@ -418,9 +425,22 @@ async function commitLedgerEntry(
418
425
  export async function commitArtifact(projectRoot: string, input: unknown, options: CommitArtifactOptions = {}): Promise<CommitArtifactResult> {
419
426
  const artifact = validateArtifact(input);
420
427
  const context = options.context ? validateCommitContext(options.context) : null;
421
- if (artifact.artifact_type === "approved_cycle_forecast" && context?.forecast_role === "original"
422
- && Date.parse(artifact.frozen_at) > Date.parse(artifact.target_period.start_inclusive)) {
423
- throw new Error("An original forecast must be frozen no later than the target period start; use forecast_role=eac for an in-period reforecast.");
428
+ if (artifact.artifact_type === "approved_cycle_forecast" && context?.forecast_role) {
429
+ const frozenAt = Date.parse(artifact.frozen_at);
430
+ const periodStart = Date.parse(artifact.target_period.start_inclusive);
431
+ const periodEnd = Date.parse(artifact.target_period.end_exclusive);
432
+ if (context.forecast_role === "original" && frozenAt > periodStart) {
433
+ throw new Error("An original forecast must be frozen no later than the target period start; use forecast_role=eac for an in-period reforecast.");
434
+ }
435
+ if (context.forecast_role === "eac" && (frozenAt < periodStart || frozenAt >= periodEnd)) {
436
+ throw new Error("An EAC forecast must be frozen during its target period.");
437
+ }
438
+ if (context.forecast_role === "next_plan" && frozenAt > periodStart) {
439
+ throw new Error("A next-plan forecast must be frozen no later than its target period start.");
440
+ }
441
+ if (context.forecast_role === "backtest" && frozenAt < periodEnd) {
442
+ throw new Error("A backtest forecast must be frozen after its target period has ended.");
443
+ }
424
444
  }
425
445
  if (artifact.artifact_type === "execution_receipt") {
426
446
  if (context?.forecast_role !== undefined) throw new Error("context.forecast_role is allowed only for approved forecasts.");
@@ -442,9 +462,13 @@ export async function commitArtifact(projectRoot: string, input: unknown, option
442
462
  }
443
463
  const fingerprint = artifactFingerprint(artifact);
444
464
  const committed = { ...artifact, immutable_fingerprint: fingerprint } as CanonicalArtifact;
465
+ const forwardOnlyRole = context?.forecast_role === "next_plan" || context?.forecast_role === "backtest";
466
+ const promoteLegacyPointer = options.promoteLegacyPointer ?? !forwardOnlyRole;
467
+ if (forwardOnlyRole && promoteLegacyPointer) {
468
+ throw new Error(`A ${context.forecast_role} forecast cannot replace the legacy operating pointer.`);
469
+ }
445
470
  const ledgerCommit = context ? await commitLedgerEntry(projectRoot, committed, context) : undefined;
446
471
  const artifactRef = ledgerCommit?.ref;
447
- const promoteLegacyPointer = options.promoteLegacyPointer ?? true;
448
472
  const destination = promoteLegacyPointer
449
473
  ? await writeLegacyPointer(projectRoot, committed)
450
474
  : ledgerCommit?.bodyPath ?? join(await ensureArtifactDirectory(projectRoot), `${artifact.artifact_type}.json`);
@@ -2,7 +2,13 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
 
5
- import { readArtifactByRef, stableJson, validateArtifactRef, type ArtifactRefV2 } from "../fpa-artifacts/store.ts";
5
+ import {
6
+ readArtifactByRef,
7
+ stableJson,
8
+ validateArtifactRef,
9
+ type ArtifactRefV2,
10
+ type ForecastRole,
11
+ } from "../fpa-artifacts/store.ts";
6
12
  import { dashboardBuildFingerprint, publishDashboard, resolveDashboardDir } from "./publisher.ts";
7
13
  import { buildDashboardProjection } from "./service.ts";
8
14
 
@@ -19,7 +25,7 @@ export interface DashboardRefreshRequest {
19
25
  scope_id: string;
20
26
  cycle_id: string;
21
27
  forecast_ref: ArtifactRefV2;
22
- forecast_role?: "original" | "eac" | "next_plan";
28
+ forecast_role?: OperatingForecastRole;
23
29
  execution_ref?: ArtifactRefV2;
24
30
  next_forecast_ref?: ArtifactRefV2;
25
31
  forward_forecast_refs?: ArtifactRefV2[];
@@ -52,6 +58,80 @@ export interface EnqueueDashboardRefreshResult {
52
58
  queue_dir: string;
53
59
  }
54
60
 
61
+ export interface DashboardRefreshNotApplicableResult {
62
+ status: "not_applicable";
63
+ reason: "backtest_forecasts_do_not_update_the_operating_dashboard";
64
+ artifact_ref: ArtifactRefV2;
65
+ }
66
+
67
+ type OperatingForecastRole = Exclude<ForecastRole, "backtest">;
68
+
69
+ function isOperatingForecastRole(value: unknown): value is OperatingForecastRole {
70
+ return value === "original" || value === "eac" || value === "next_plan";
71
+ }
72
+
73
+ function backtestNotApplicable(artifactRef: ArtifactRefV2): DashboardRefreshNotApplicableResult {
74
+ return {
75
+ status: "not_applicable",
76
+ reason: "backtest_forecasts_do_not_update_the_operating_dashboard",
77
+ artifact_ref: artifactRef,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Resolve one exact committed artifact into durable dashboard work.
83
+ *
84
+ * This is the package-owned handoff seam used by the main Agent after a Graph
85
+ * finishes. Callers provide only the immutable artifact ref; role and upstream
86
+ * lineage are read from the ledger rather than guessed or copied from prompts.
87
+ */
88
+ export async function enqueueDashboardRefreshForArtifact(
89
+ cwd: string,
90
+ value: unknown,
91
+ locale: "zh-CN" | "en-US" = "zh-CN",
92
+ ): Promise<EnqueueDashboardRefreshResult | DashboardRefreshNotApplicableResult> {
93
+ const artifactRef = validateArtifactRef(value);
94
+ const committed = await readArtifactByRef(cwd, artifactRef);
95
+ const context = committed.ledgerContext;
96
+ if (!context) throw new Error("Dashboard handoff requires an assigned ledger artifact.");
97
+
98
+ if (committed.artifact.artifact_type === "approved_cycle_forecast") {
99
+ if (!context.forecast_role) throw new Error("Dashboard handoff requires the committed forecast_role from ledger context.");
100
+ if (context.forecast_role === "backtest") {
101
+ return backtestNotApplicable(artifactRef);
102
+ }
103
+ return enqueueDashboardRefresh(cwd, {
104
+ preset: "forecast-closed-loop-v1",
105
+ locale,
106
+ scope_id: artifactRef.scope_id,
107
+ cycle_id: artifactRef.cycle_id,
108
+ forecast_ref: artifactRef,
109
+ forecast_role: context.forecast_role,
110
+ reason: "forecast_committed",
111
+ });
112
+ }
113
+
114
+ if (context.upstream_refs.length !== 1 || context.upstream_refs[0].artifact_type !== "approved_cycle_forecast") {
115
+ throw new Error("Execution dashboard handoff requires exactly one approved forecast upstream ref.");
116
+ }
117
+ const forecastRef = context.upstream_refs[0];
118
+ const forecast = await readArtifactByRef(cwd, forecastRef);
119
+ if (!forecast.ledgerContext?.forecast_role) throw new Error("Execution dashboard handoff requires the upstream forecast_role from ledger context.");
120
+ if (forecast.ledgerContext.forecast_role === "backtest") {
121
+ return backtestNotApplicable(artifactRef);
122
+ }
123
+ return enqueueDashboardRefresh(cwd, {
124
+ preset: "forecast-closed-loop-v1",
125
+ locale,
126
+ scope_id: artifactRef.scope_id,
127
+ cycle_id: artifactRef.cycle_id,
128
+ forecast_ref: forecastRef,
129
+ forecast_role: forecast.ledgerContext.forecast_role,
130
+ execution_ref: artifactRef,
131
+ reason: "execution_committed",
132
+ });
133
+ }
134
+
55
135
  export interface DashboardRefreshQueueStatus {
56
136
  queue_dir: string;
57
137
  pending: number;
@@ -122,7 +202,7 @@ interface DashboardRefreshSubscription {
122
202
  scope_id: string;
123
203
  cycle_id: string;
124
204
  forecast_ref: ArtifactRefV2;
125
- forecast_role?: "original" | "eac" | "next_plan";
205
+ forecast_role?: OperatingForecastRole;
126
206
  execution_ref?: ArtifactRefV2;
127
207
  next_forecast_ref?: ArtifactRefV2;
128
208
  forward_forecast_refs?: ArtifactRefV2[];
@@ -160,7 +240,7 @@ function validateRequest(value: DashboardRefreshRequest): DashboardRefreshReques
160
240
  const scopeId = boundedId(source.scope_id, "refresh request.scope_id");
161
241
  const cycleId = boundedId(source.cycle_id, "refresh request.cycle_id");
162
242
  const forecastRef = validateArtifactRef(source.forecast_ref, "refresh request.forecast_ref");
163
- if (source.forecast_role !== undefined && source.forecast_role !== "original" && source.forecast_role !== "eac" && source.forecast_role !== "next_plan") {
243
+ if (source.forecast_role !== undefined && !isOperatingForecastRole(source.forecast_role)) {
164
244
  throw new Error("refresh request.forecast_role is unsupported.");
165
245
  }
166
246
  if (forecastRef.artifact_type !== "approved_cycle_forecast" || forecastRef.scope_id !== scopeId || forecastRef.cycle_id !== cycleId) {
@@ -193,7 +273,7 @@ function validateRequest(value: DashboardRefreshRequest): DashboardRefreshReques
193
273
  scope_id: scopeId,
194
274
  cycle_id: cycleId,
195
275
  forecast_ref: forecastRef,
196
- ...(source.forecast_role ? { forecast_role: source.forecast_role as DashboardRefreshRequest["forecast_role"] } : {}),
276
+ ...(isOperatingForecastRole(source.forecast_role) ? { forecast_role: source.forecast_role } : {}),
197
277
  ...(executionRef ? { execution_ref: executionRef } : {}),
198
278
  ...(nextForecastRef ? { next_forecast_ref: nextForecastRef } : {}),
199
279
  ...(forwardForecastRefs.length > 0 ? { forward_forecast_refs: forwardForecastRefs } : {}),
@@ -494,13 +574,45 @@ async function enqueueEventOnly(
494
574
 
495
575
  async function sortForwardForecastRefs(projectRoot: string, refs: ArtifactRefV2[]): Promise<ArtifactRefV2[]> {
496
576
  const dated = await Promise.all(refs.map(async (ref) => {
497
- const read = await readArtifactByRef(projectRoot, ref);
577
+ let read;
578
+ try {
579
+ read = await readArtifactByRef(projectRoot, ref);
580
+ } catch (error) {
581
+ throw new Error(
582
+ `Forward forecast lineage is incomplete at ${ref.entry_id}: ${error instanceof Error ? error.message : String(error)}`,
583
+ { cause: error },
584
+ );
585
+ }
498
586
  if (read.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("A forward forecast ref resolved to the wrong artifact type.");
499
587
  return { ref, start: Date.parse(read.artifact.target_period.start_inclusive) };
500
588
  }));
501
589
  return dated.sort((left, right) => left.start - right.start).map((item) => item.ref).slice(0, 6);
502
590
  }
503
591
 
592
+ function canonicalTimezone(timezone: string): string {
593
+ return new Intl.DateTimeFormat("en-US", { timeZone: timezone }).resolvedOptions().timeZone;
594
+ }
595
+
596
+ async function assertExactNextPlanSuccessor(
597
+ projectRoot: string,
598
+ active: DashboardRefreshSubscription,
599
+ nextRef: ArtifactRefV2,
600
+ ): Promise<void> {
601
+ const [currentRead, nextRead] = await Promise.all([
602
+ readArtifactByRef(projectRoot, active.forecast_ref),
603
+ readArtifactByRef(projectRoot, nextRef),
604
+ ]);
605
+ if (currentRead.artifact.artifact_type !== "approved_cycle_forecast" || nextRead.artifact.artifact_type !== "approved_cycle_forecast") {
606
+ throw new Error("Next-plan dashboard handoff must resolve two approved forecasts.");
607
+ }
608
+ const current = currentRead.artifact;
609
+ const next = nextRead.artifact;
610
+ if (Date.parse(next.target_period.start_inclusive) !== Date.parse(current.target_period.end_exclusive)
611
+ || canonicalTimezone(next.target_period.timezone) !== canonicalTimezone(current.target_period.timezone)) {
612
+ throw new Error("Next-plan forecast must be the exact successor of the active cycle in the same timezone.");
613
+ }
614
+ }
615
+
504
616
  export async function enqueueDashboardRefresh(cwd: string, value: DashboardRefreshRequest): Promise<EnqueueDashboardRefreshResult> {
505
617
  const request = validateRequest(value);
506
618
  const projectRoot = await realpath(cwd);
@@ -516,13 +628,16 @@ export async function enqueueDashboardRefresh(cwd: string, value: DashboardRefre
516
628
  )) {
517
629
  throw new Error("Next-plan dashboard refresh requires an active current-cycle subscription in the same project and scope.");
518
630
  }
519
- const subscription = await persistSubscription(directories, projectName, request, now);
631
+ if (request.forecast_role === "next_plan" && active) {
632
+ await assertExactNextPlanSuccessor(projectRoot, active, request.forecast_ref);
633
+ }
520
634
  if (request.forecast_role === "next_plan") {
521
635
  if (active) {
522
636
  const unsortedForwardRefs = [...(active.forward_forecast_refs ?? (active.next_forecast_ref ? [active.next_forecast_ref] : [])), request.forecast_ref]
523
637
  .filter((ref, index, refs) => refs.findIndex((candidate) => candidate.entry_id === ref.entry_id) === index)
524
638
  .slice(0, 6);
525
639
  const forwardRefs = await sortForwardForecastRefs(projectRoot, unsortedForwardRefs);
640
+ await persistSubscription(directories, projectName, request, now);
526
641
  const linked = { ...active, next_forecast_ref: forwardRefs[0], forward_forecast_refs: forwardRefs, updated_at: now };
527
642
  await atomicReplace(join(directories.subscriptions, `${active.subscription_id}.json`), `${JSON.stringify(linked, null, 2)}\n`);
528
643
  return enqueueEventOnly(directories, projectName, validateRequest({
@@ -538,7 +653,9 @@ export async function enqueueDashboardRefresh(cwd: string, value: DashboardRefre
538
653
  reason: "forecast_committed",
539
654
  }), now);
540
655
  }
541
- } else if (request.reason === "forecast_committed" || request.reason === "execution_committed") {
656
+ }
657
+ const subscription = await persistSubscription(directories, projectName, request, now);
658
+ if (request.reason === "forecast_committed" || request.reason === "execution_committed") {
542
659
  await atomicReplace(join(directories.root, "active-subscription.json"), `${JSON.stringify({
543
660
  kind: "fpa.dashboard.active-subscription",
544
661
  schema_version: 1,
@@ -5,6 +5,7 @@ import {
5
5
  type ExecutionReceiptInput,
6
6
  type ScenarioMetric,
7
7
  } from "../fpa-artifacts/contracts.ts";
8
+ import type { ForecastRole } from "../fpa-artifacts/store.ts";
8
9
  import {
9
10
  ACTUALS_DATA_AS_OF_UNAVAILABLE,
10
11
  validateActualsSnapshot,
@@ -16,11 +17,11 @@ export interface CycleOperatingProjectionInput {
16
17
  scope_id: string;
17
18
  cycle_id: string;
18
19
  current_forecast: unknown;
19
- current_forecast_role?: "original" | "eac" | "next_plan";
20
+ current_forecast_role?: ForecastRole;
20
21
  actuals: unknown;
21
22
  execution?: unknown;
22
23
  next_forecast?: unknown;
23
- next_forecast_role?: "original" | "eac" | "next_plan";
24
+ next_forecast_role?: ForecastRole;
24
25
  }
25
26
 
26
27
  interface ScenarioValues {
@@ -2,10 +2,11 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
 
5
- import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
5
+ import { readArtifactByRef, type ArtifactRefV2 } from "../fpa-artifacts/store.ts";
6
6
  import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
7
7
  import {
8
8
  enqueueDashboardRefresh,
9
+ enqueueDashboardRefreshForArtifact,
9
10
  inspectDashboardRefreshQueue,
10
11
  processDashboardRefreshQueue,
11
12
  } from "./coordinator.ts";
@@ -34,7 +35,7 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
34
35
  promptSnippet: "Inspect the current FP&A dashboard generation and diagnostics",
35
36
  parameters: Type.Object({
36
37
  expected_lineage: Type.Optional(Type.Object({
37
- forecast_role: Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan")]),
38
+ forecast_role: Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan"), Type.Literal("backtest")]),
38
39
  forecast_ref: Type.String({ pattern: "^[a-f0-9]{64}$" }),
39
40
  }, { additionalProperties: false })),
40
41
  require_lineage_match: Type.Optional(Type.Boolean()),
@@ -93,7 +94,16 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
93
94
  ...(params.next_forecast_ref ? { nextForecastRef: params.next_forecast_ref as ArtifactRefV2 } : {}),
94
95
  ...(params.forward_forecast_refs ? { forwardForecastRefs: params.forward_forecast_refs as ArtifactRefV2[] } : {}),
95
96
  } : undefined;
97
+ if (exact) {
98
+ 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.");
101
+ }
102
+ }
96
103
  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.");
106
+ }
97
107
  const previewFingerprint = dashboardBuildFingerprint(build, projector);
98
108
  const summary = {
99
109
  preset: params.preset,
@@ -144,11 +154,12 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
144
154
  pi.registerTool({
145
155
  name: "fpa_dashboard_refresh_queue",
146
156
  label: "FP&A Dashboard Refresh Queue",
147
- description: "Inspect or drain the durable dashboard refresh queue, or enqueue an Actuals-watermark refresh against exact immutable artifact refs.",
157
+ description: "Inspect or drain the durable dashboard refresh queue, enqueue an exact committed artifact after Graph handoff, or enqueue an Actuals-watermark refresh.",
148
158
  promptSnippet: "Inspect or process durable FP&A dashboard refresh work",
149
159
  parameters: Type.Object({
150
- action: StringEnum(["status", "drain", "enqueue_actuals"] as const),
160
+ action: StringEnum(["status", "drain", "enqueue_artifact", "enqueue_actuals"] as const),
151
161
  locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
162
+ artifact_ref: Type.Optional(artifactRefSchema),
152
163
  scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
153
164
  cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
154
165
  forecast_ref: Type.Optional(artifactRefSchema),
@@ -162,6 +173,14 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
162
173
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
163
174
  if (params.action === "status") return toolResult(await inspectDashboardRefreshQueue(ctx.cwd) as unknown as Record<string, unknown>);
164
175
  if (params.action === "drain") return toolResult(await processDashboardRefreshQueue(ctx.cwd, { limit: params.limit, signal }) as unknown as Record<string, unknown>);
176
+ if (params.action === "enqueue_artifact") {
177
+ if (!params.artifact_ref) throw new Error("enqueue_artifact requires artifact_ref from fpa_artifact_commit.");
178
+ return toolResult(await enqueueDashboardRefreshForArtifact(
179
+ ctx.cwd,
180
+ params.artifact_ref,
181
+ params.locale ?? "zh-CN",
182
+ ) as unknown as Record<string, unknown>);
183
+ }
165
184
  if (!params.scope_id || !params.cycle_id || !params.forecast_ref || !params.actuals_watermark) {
166
185
  throw new Error("enqueue_actuals requires scope_id, cycle_id, forecast_ref, and actuals_watermark.");
167
186
  }
@@ -7,6 +7,7 @@ import {
7
7
  type ForecastAllocation,
8
8
  type ForecastSlice,
9
9
  } from "../fpa-artifacts/contracts.ts";
10
+ import type { ForecastRole } from "../fpa-artifacts/store.ts";
10
11
  import { validateActualsSnapshot, ACTUALS_DATA_AS_OF_UNAVAILABLE, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
11
12
  import type { CycleOperatingProjection } from "./cycle-operating-projection.ts";
12
13
  import type { ForwardOutlookProjection } from "./forward-outlook.ts";
@@ -67,7 +68,7 @@ export interface DashboardBuild {
67
68
  forecast_ref?: string;
68
69
  next_forecast_ref?: string;
69
70
  execution_ref?: string;
70
- forecast_role?: "original" | "eac" | "next_plan";
71
+ forecast_role?: ForecastRole;
71
72
  forecast_version: string;
72
73
  forecast_fingerprint?: string;
73
74
  next_forecast_fingerprint?: string;
@@ -177,6 +177,9 @@ export function dashboardBuildFingerprint(build: DashboardBuild, projector: Dash
177
177
 
178
178
  export async function publishDashboard(options: PublishDashboardOptions): Promise<PublishDashboardResult> {
179
179
  assertBuild(options.build);
180
+ if (options.build.source.forecast_role === "backtest") {
181
+ throw new Error("A backtest forecast cannot replace the operating dashboard.");
182
+ }
180
183
  const projector = validateProjectorProvenance(options.projector);
181
184
  const runtime = validateProjectorRuntime(options.runtime);
182
185
  const dashboardDir = await resolveDashboardDir(options.cwd);
@@ -9,7 +9,7 @@ import {
9
9
  type DashboardProjectorProvenance,
10
10
  type DashboardProjectorRuntime,
11
11
  } from "./provenance.ts";
12
- import { stableJson } from "../fpa-artifacts/store.ts";
12
+ import { isForecastRole, stableJson, type ForecastRole } from "../fpa-artifacts/store.ts";
13
13
  import { DASHBOARD_WIDGET_TYPES, validateDatasetForWidget, type DashboardWidgetType } from "./schema.ts";
14
14
  import { validateActualsSnapshot } from "./source.ts";
15
15
 
@@ -31,7 +31,7 @@ export interface DashboardStatus {
31
31
  }
32
32
 
33
33
  export interface DashboardLineageExpectation {
34
- forecast_role: "original" | "eac" | "next_plan";
34
+ forecast_role: ForecastRole;
35
35
  forecast_ref: string;
36
36
  }
37
37
 
@@ -47,7 +47,7 @@ export interface DashboardReceiptSummary {
47
47
  cycle_id?: string;
48
48
  forecast_version: string;
49
49
  forecast_ref?: string;
50
- forecast_role?: "original" | "eac" | "next_plan";
50
+ forecast_role?: ForecastRole;
51
51
  next_forecast_ref?: string;
52
52
  forward_forecast_refs: string[];
53
53
  execution_ref?: string;
@@ -80,7 +80,7 @@ function buildReceiptSummary(receipt: Record<string, unknown>): DashboardReceipt
80
80
  const dataAsOf = optionalBoundedString(source?.data_as_of, 256);
81
81
  if (!source || !forecastVersion || !dataAsOf) return undefined;
82
82
  const role = source.forecast_role;
83
- const forecastRole = role === "original" || role === "eac" || role === "next_plan" ? role : undefined;
83
+ const forecastRole = isForecastRole(role) ? role : undefined;
84
84
  const forwardForecastRefs = Array.isArray(source.forward_forecast_refs)
85
85
  ? source.forward_forecast_refs.filter((value): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)).slice(0, 6)
86
86
  : [];
@@ -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.6.3",
3
+ "version": "0.7.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",
@@ -30,9 +30,9 @@
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/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
- "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/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",
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",
37
37
  "test:live": "node tests/live-smoke.mjs",
38
38
  "pack:check": "npm pack --dry-run"
@@ -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
 
@@ -30,8 +36,14 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
30
36
  `fpa_artifact_commit` and an assigned context containing the explicit
31
37
  `scope_id`, `cycle_id`, and `forecast_role`. Use `original` only when the
32
38
  forecast is frozen no later than period start; use `eac` for an in-period
33
- reforecast and `next_plan` when it is the approved direct successor of the
34
- active cycle.
39
+ reforecast and `next_plan` only when it is frozen no later than its target
40
+ period start and is the approved direct successor of the active cycle. Use
41
+ `backtest` for a historical rerun frozen after its target
42
+ period; it is immutable evidence, not an operating plan, and it must not
43
+ replace the current pointer or enter the operating dashboard queue. A
44
+ `next_plan` is likewise stored as forward lineage and does not replace the
45
+ legacy current pointer; its explicit dashboard handoff links it to the
46
+ active cycle after successor validation.
35
47
 
36
48
  ## Write the decision, not the arithmetic
37
49
 
@@ -76,4 +88,12 @@ declaration upstream needs correcting.
76
88
 
77
89
  ## Completion
78
90
 
79
- The planning workflow ends only after `fpa_artifact_commit` returns `status: committed` and an `immutable_fingerprint`. 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`.
91
+ The forecast-freeze workflow ends only after `fpa_artifact_commit` returns
92
+ `status: committed`, an `immutable_fingerprint`, and (for assigned artifacts) a
93
+ `dashboard_handoff`. Commit freezes the business artifact but does not enqueue
94
+ or publish dashboard work. The calling main Agent owns that explicit handoff.
95
+ A Markdown report is optional context, not the frozen source of truth. Never
96
+ report a forecast as frozen on the strength of a written file alone — the file
97
+ is a draft until the tool returns a fingerprint. Execution, if requested, is a
98
+ separate `fpa-strategy-execution` Graph. Review waits for the next cycle's
99
+ 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.
@@ -17,6 +17,21 @@ only part of this tuple or fall back to a mutable current pointer.
17
17
 
18
18
  ## Procedure
19
19
 
20
+ ### Post-Graph handoff
21
+
22
+ 1. Take the exact `artifact_ref` from `fpa_artifact_commit.dashboard_handoff`.
23
+ 2. Call `fpa_dashboard_refresh_queue` with `action: enqueue_artifact` and that
24
+ ref. Do not restate scope, cycle, role, or upstream refs; the package resolves
25
+ them from the immutable ledger.
26
+ 3. If the result is `enqueued` or `already_pending` and the current task expects
27
+ the dashboard immediately, call one bounded `drain`, then verify with
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.
32
+
33
+ ### Human-requested rebuild
34
+
20
35
  1. Call `fpa_dashboard_status` to inspect the current generation and diagnostics.
21
36
  2. Call `fpa_dashboard_refresh` with `preset: forecast-closed-loop-v1` and `mode: preview`.
22
37
  3. Review the returned lineage, coverage, query receipts, warnings, widget list, and `preview_fingerprint`.
@@ -41,8 +56,9 @@ answer, and do not describe it as "the period has no data yet".
41
56
  - Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
42
57
  - Do not edit the approved forecast during projection.
43
58
  - If inputs change between preview and publish, preview again rather than bypassing the fingerprint check.
44
- - This skill performs one bounded human-requested refresh. Assigned artifact
45
- commits and Actuals watermark changes are monitored separately by the durable
46
- package coordinator running in the package-owned `fpa-dashboard-worker`
47
- process (never in the read-only Web host); inspect or drain that queue with
48
- `fpa_dashboard_refresh_queue` rather than recreating its writes manually.
59
+ - Artifact commit never performs dashboard I/O. The calling main Agent explicitly
60
+ enqueues its exact handoff. Actuals watermark changes remain monitored by the
61
+ durable package coordinator running in the package-owned
62
+ `fpa-dashboard-worker` process (never in the read-only Web host); inspect or
63
+ drain that queue with `fpa_dashboard_refresh_queue` rather than recreating its
64
+ writes manually.
@@ -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.