@viccydev/pi-fpa 0.9.0 → 0.9.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
@@ -80,7 +80,7 @@ Extension 内置的关键防护:
80
80
 
81
81
  ## Artifact 与看板 Extension
82
82
 
83
- `fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
83
+ `fpa-artifacts` 提供 `fpa_forecast_finalize`,把紧凑 Forecast plan 一次性合成、校验、按目标周期推导生命周期角色并冻结;模型无需在 Graph handoff 与 ledger 枚举之间转译 `forecast_role`。通用的 `fpa_artifact_commit` 仍负责 `approved_cycle_forecast` 和 `execution_receipt` 的严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
84
84
 
85
85
  `fpa-dashboard` 提供分模块发布工具和兼容的闭环刷新工具:
86
86
 
@@ -159,12 +159,12 @@ pi list
159
159
  团队分发建议使用固定 Git tag:
160
160
 
161
161
  ```bash
162
- pi install git:github.com/linyqh/pi-fpa@v0.9.0
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.1
163
163
  ```
164
164
 
165
165
  ## 发布到 npm
166
166
 
167
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.0` 对应 `v0.9.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.1` 对应 `v0.9.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
168
168
 
169
169
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
170
170
 
@@ -34,11 +34,10 @@ import {
34
34
  // derived. So the plan carries just those, and this module computes the rest —
35
35
  // which makes the identities true by construction rather than true if checked.
36
36
  //
37
- // Deliberately returns the artifact instead of writing it. A node that writes
38
- // is a mutating node, and the engine bars mutating nodes from automatic retry
39
- // and from failure routes that lead back to themselves. Keeping composition
40
- // read-only is what lets a bad plan be repaired and recomposed automatically,
41
- // while the irreversible freeze stays in its own node.
37
+ // Deliberately returns the artifact instead of writing it, so isolated callers
38
+ // can inspect diagnostics without mutation. The forecast-freeze Graph uses the
39
+ // finalizer as its one mutation seam; malformed plans fail once with the full
40
+ // actionable limitation report instead of entering a repair loop.
42
41
  // ============================================================================
43
42
 
44
43
  /** Distinct dimension values a plan's slice keys must be drawn from. */
@@ -328,19 +327,45 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
328
327
  );
329
328
  }
330
329
  if (status === "complete_with_limits") {
330
+ const limitationErrors: string[] = [];
331
+ const capture = (check: () => void): void => {
332
+ try {
333
+ check();
334
+ } catch (error) {
335
+ limitationErrors.push(error instanceof Error ? error.message : String(error));
336
+ }
337
+ };
331
338
  for (const [index, value] of unsupported.entries()) {
332
- const item = record(value, `plan.unsupported_metrics[${index}]`);
333
- text(item.metric, `plan.unsupported_metrics[${index}].metric`);
334
- text(item.reason, `plan.unsupported_metrics[${index}].reason`);
339
+ const path = `plan.unsupported_metrics[${index}]`;
340
+ let item: Record<string, unknown>;
341
+ try {
342
+ item = record(value, path);
343
+ } catch (error) {
344
+ limitationErrors.push(error instanceof Error ? error.message : String(error));
345
+ continue;
346
+ }
347
+ capture(() => { text(item.metric, `${path}.metric`); });
348
+ capture(() => { text(item.reason, `${path}.reason`); });
335
349
  if (!Array.isArray(item.affected_scope) || item.affected_scope.length === 0) {
336
- throw new Error(`plan.unsupported_metrics[${index}].affected_scope must be a non-empty array.`);
350
+ limitationErrors.push(`${path}.affected_scope must be a non-empty array.`);
351
+ } else {
352
+ for (const [scopeIndex, scope] of item.affected_scope.entries()) {
353
+ capture(() => { text(scope, `${path}.affected_scope[${scopeIndex}]`); });
354
+ }
337
355
  }
338
- for (const [scopeIndex, scope] of item.affected_scope.entries()) text(scope, `plan.unsupported_metrics[${index}].affected_scope[${scopeIndex}]`);
339
- const evidence = Array.isArray(item.evidence) ? item.evidence : [item.evidence];
340
- if (evidence.length === 0) throw new Error(`plan.unsupported_metrics[${index}].evidence must not be empty.`);
341
- for (const [evidenceIndex, entry] of evidence.entries()) text(entry, `plan.unsupported_metrics[${index}].evidence[${evidenceIndex}]`);
342
- text(item.remediation, `plan.unsupported_metrics[${index}].remediation`);
343
- text(item.owner_role, `plan.unsupported_metrics[${index}].owner_role`);
356
+ const evidence = item.evidence === undefined ? [] : Array.isArray(item.evidence) ? item.evidence : [item.evidence];
357
+ if (evidence.length === 0) {
358
+ limitationErrors.push(`${path}.evidence must not be empty.`);
359
+ } else {
360
+ for (const [evidenceIndex, entry] of evidence.entries()) {
361
+ capture(() => { text(entry, `${path}.evidence[${evidenceIndex}]`); });
362
+ }
363
+ }
364
+ capture(() => { text(item.remediation, `${path}.remediation`); });
365
+ capture(() => { text(item.owner_role, `${path}.owner_role`); });
366
+ }
367
+ if (limitationErrors.length > 0) {
368
+ throw new Error(`Forecast plan limitations are invalid:\n- ${limitationErrors.join("\n- ")}`);
344
369
  }
345
370
  }
346
371
 
@@ -0,0 +1,99 @@
1
+ import { composeApprovedForecast, type ComposeOptions, type ComposeResult } from "./compose.ts";
2
+ import {
3
+ commitArtifact,
4
+ type ArtifactCommitContext,
5
+ type CommitArtifactResult,
6
+ type ForecastRole,
7
+ } from "./store.ts";
8
+
9
+ export interface ForecastFinalizeContext {
10
+ scope_id: string;
11
+ cycle_id: string;
12
+ }
13
+
14
+ export interface ForecastFinalizeResult extends ComposeResult {
15
+ forecastRole: Extract<ForecastRole, "eac" | "next_plan" | "backtest">;
16
+ committed: CommitArtifactResult;
17
+ }
18
+
19
+ function withoutCallerFrozenAt(plan: unknown): unknown {
20
+ if (plan === null || typeof plan !== "object" || Array.isArray(plan)) return plan;
21
+ const { frozen_at: _ignored, ...decisionPlan } = plan as Record<string, unknown>;
22
+ return decisionPlan;
23
+ }
24
+
25
+ /**
26
+ * The forecast-freeze workflow owns next-cycle planning, in-period reforecast,
27
+ * and historical reruns. Its ledger role follows from when the immutable body
28
+ * is frozen relative to its own target period; callers do not translate a
29
+ * business label into storage vocabulary.
30
+ */
31
+ export function deriveFreezeForecastRole(
32
+ targetPeriod: { start_inclusive: string; end_exclusive: string },
33
+ frozenAt: string,
34
+ ): ForecastFinalizeResult["forecastRole"] {
35
+ const start = Date.parse(targetPeriod.start_inclusive);
36
+ const end = Date.parse(targetPeriod.end_exclusive);
37
+ const frozen = Date.parse(frozenAt);
38
+ if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(frozen) || end <= start) {
39
+ throw new Error("Cannot derive forecast role from an invalid target period or frozen_at timestamp.");
40
+ }
41
+ if (frozen >= end) return "backtest";
42
+ if (frozen >= start) return "eac";
43
+ return "next_plan";
44
+ }
45
+
46
+ export async function finalizeApprovedForecast(
47
+ projectRoot: string,
48
+ plan: unknown,
49
+ context: ForecastFinalizeContext,
50
+ options: ComposeOptions = {},
51
+ ): Promise<ForecastFinalizeResult> {
52
+ const composed = await composeApprovedForecast(withoutCallerFrozenAt(plan), options);
53
+ if (composed.artifact.status === "blocked") {
54
+ throw new Error("A blocked forecast plan cannot be finalized or committed.");
55
+ }
56
+ const forecastRole = deriveFreezeForecastRole(
57
+ composed.artifact.target_period,
58
+ composed.artifact.frozen_at,
59
+ );
60
+ const commitContext: ArtifactCommitContext = {
61
+ scope_id: context.scope_id,
62
+ cycle_id: context.cycle_id,
63
+ forecast_role: forecastRole,
64
+ };
65
+ const committed = await commitArtifact(projectRoot, composed.artifact, { context: commitContext });
66
+ return { ...composed, forecastRole, committed };
67
+ }
68
+
69
+ /** Keep the Graph result actionable without returning the full canonical artifact. */
70
+ export function forecastFinalizeDetails(result: ForecastFinalizeResult): Record<string, unknown> {
71
+ const artifactRef = result.committed.artifactRef;
72
+ const dashboardHandoff = result.forecastRole === "backtest"
73
+ ? {
74
+ status: "not_applicable",
75
+ reason: "backtest forecasts are immutable evidence and are not enqueued for an operating dashboard",
76
+ }
77
+ : artifactRef
78
+ ? {
79
+ status: "deferred",
80
+ tool: "fpa_dashboard_refresh_queue",
81
+ action: "enqueue_artifact",
82
+ artifact_ref: artifactRef,
83
+ }
84
+ : undefined;
85
+ return {
86
+ status: "committed",
87
+ artifact_type: result.committed.artifactType,
88
+ forecast_role: result.forecastRole,
89
+ immutable_fingerprint: result.committed.fingerprint,
90
+ path: result.committed.path,
91
+ ledger_status: result.committed.ledgerStatus,
92
+ diagnostics: result.diagnostics as unknown as Record<string, unknown>,
93
+ data_quality: result.artifact.data_quality,
94
+ unsupported_metrics: result.artifact.unsupported_metrics,
95
+ conclusion: result.artifact.conclusion,
96
+ ...(artifactRef ? { artifact_ref: artifactRef } : {}),
97
+ ...(dashboardHandoff ? { dashboard_handoff: dashboardHandoff } : {}),
98
+ };
99
+ }
@@ -4,6 +4,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
5
 
6
6
  import { composeApprovedForecast } from "./compose.ts";
7
+ import { finalizeApprovedForecast, forecastFinalizeDetails } from "./finalize.ts";
7
8
  import {
8
9
  commitArtifact,
9
10
  commitArtifactFromPath,
@@ -170,4 +171,37 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
170
171
  });
171
172
  },
172
173
  });
174
+
175
+ pi.registerTool({
176
+ name: "fpa_forecast_finalize",
177
+ label: "Finalize Approved Forecast",
178
+ description:
179
+ "Compose, validate, assign the lifecycle role, and atomically commit an approved forecast from its compact plan. " +
180
+ "The role is derived from frozen_at relative to the target period: future is next_plan, in-period is eac, and ended periods are backtest. " +
181
+ "Use this as the single mutation seam for fpa-forecast-freeze; callers do not pass or translate forecast_role.",
182
+ promptSnippet: "Finalize and freeze a compact approved forecast plan",
183
+ promptGuidelines: [
184
+ "Pass only the compact plan path and immutable scope/cycle identity; forecast_role is derived deterministically.",
185
+ "A historical target is committed as backtest evidence and never promoted as an operating forecast.",
186
+ ],
187
+ parameters: Type.Object({
188
+ plan_path: Type.String({
189
+ minLength: 1,
190
+ description: "Project-relative path to the compact forecast plan JSON file.",
191
+ }),
192
+ scope_id: Type.String({ minLength: 1, maxLength: 256 }),
193
+ cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
194
+ }, { additionalProperties: false }),
195
+ executionMode: "sequential",
196
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
197
+ const plan = await readProjectJsonFile(ctx.cwd, params.plan_path, "plan_path");
198
+ const finalized = await finalizeApprovedForecast(
199
+ ctx.cwd,
200
+ plan,
201
+ { scope_id: params.scope_id, cycle_id: params.cycle_id },
202
+ { signal },
203
+ );
204
+ return toolResult(forecastFinalizeDetails(finalized));
205
+ },
206
+ });
173
207
  }
@@ -168,9 +168,11 @@ function graphContextError(event: ToolCallEvent, graph: FpaGraph): string | unde
168
168
  return "graph_run requires a context object whose values are strings.";
169
169
  }
170
170
  const values = context as Record<string, unknown>;
171
- const required = graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze"
171
+ const required = graph === "fpa-strategy-planning"
172
172
  ? ["scope_id", "cycle_id", "forecast_role"]
173
- : [];
173
+ : graph === "fpa-forecast-freeze"
174
+ ? ["scope_id", "cycle_id"]
175
+ : [];
174
176
  const missing = required.filter((key) => !(key in values) || (typeof values[key] === "string" && values[key].trim().length === 0));
175
177
  if (missing.length > 0) return `graph_run requires non-empty string context keys: ${missing.join(", ")}.`;
176
178
  const nonStrings = Object.entries(values)
@@ -188,9 +190,12 @@ function routingInstruction(graph: FpaGraph, resumeCompletedGraph = false): stri
188
190
  "Call graph_list first, then graph_run with that exact graph. All graph_run context values must be strings; never pass arrays, objects, numbers, booleans, or null.",
189
191
  "Graph nodes own business phase execution. After a successful Graph handoff, only the main Agent may call the matching fpa_dashboard_publish_* tool; Graphs must never publish dashboard data.",
190
192
  ];
191
- if (graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze") {
193
+ if (graph === "fpa-strategy-planning") {
192
194
  instructions.push("Provide the immutable non-empty string context keys scope_id, cycle_id, and forecast_role. Put structured planning details in goal, not context.");
193
195
  }
196
+ if (graph === "fpa-forecast-freeze") {
197
+ instructions.push("Provide only the immutable non-empty string context keys scope_id and cycle_id. The ledger forecast_role is derived from the frozen forecast target period; do not translate or pass a business role label.");
198
+ }
194
199
  if (graph === "fpa-strategy-planning") {
195
200
  instructions.push(resumeCompletedGraph
196
201
  ? "A completed combined Graph handoff was restored from this session. Continue modular publication from its persisted artifacts; do not rerun the Graph unless those artifacts are unavailable or the user changed the planning requirements. Publish period-review before next-strategy."
@@ -1,6 +1,6 @@
1
1
  {
2
- "description": "FP&A 正式预测冻结:消费已审核策略交接与主 Agent 已提交的精确 strategy_decision,生成、合成并冻结 approved_cycle_forecast;Graph 不请求审批、不发布仪表盘。",
3
- "maxSteps": 10,
2
+ "description": "FP&A 正式预测冻结:消费已审核策略交接与主 Agent 已提交的精确 strategy_decision,生成预测决策计划并通过确定性 finalize module 冻结 approved_cycle_forecast;Graph 不请求审批、不发布仪表盘。",
3
+ "maxSteps": 6,
4
4
  "mutationPolicy": "mutating",
5
5
  "name": "fpa-forecast-freeze",
6
6
  "nodes": [
@@ -11,29 +11,29 @@
11
11
  "next": "route_confirmed_strategy",
12
12
  "outputKey": "confirmed_strategy",
13
13
  "parseJson": true,
14
- "prompt": "操作备注:{{goal}}\n\n读取 artifacts/reviewed_strategy_handoff.json、其中绑定的 strategy_proposal 与 strategy_review,以及操作备注明确给出的 strategy_decision path。必须核验 handoff status=ready;scope_id/cycle_id/forecast_role 与 Graph context 一致;proposal、review、handoff 的 strategy_version 一致;decision.kind=fpa.strategy.decision、decision=confirm;decision 的 strategy_version、handoff_fingerprint、decision_fingerprint 与操作备注和当前 handoff 精确一致。任一不一致即 blocked。不得请求或记录第二次审批,不得调用任何 fpa_dashboard_* 工具。\n\n只输出 JSON:{\"status\":\"ready|blocked\",\"strategy_version\":\"...\",\"strategy_decision_path\":\"...\",\"strategy_decision_fingerprint\":\"...\",\"review_conditions\":[],\"blockers\":[]}",
14
+ "prompt": "操作备注:{{goal}}\n\n读取 artifacts/reviewed_strategy_handoff.json、其中绑定的 strategy_proposal 与 strategy_review,以及操作备注明确给出的 strategy_decision path。必须核验 handoff status=ready;scope_id/cycle_id 与 Graph context 一致;proposal、review、handoff 的 strategy_version 一致;decision.kind=fpa.strategy.decision、decision=confirm;decision 的 strategy_version、handoff_fingerprint、decision_fingerprint 与操作备注和当前 handoff 精确一致。handoff 中的旧业务 forecast_role 仅作为兼容元数据,不参与 ledger role 判断;最终角色由冻结 module 按目标周期确定。任一身份或审批不一致即 blocked。不得请求或记录第二次审批,不得调用任何 fpa_dashboard_* 工具。\n\n只输出 JSON:{\"status\":\"ready|blocked\",\"strategy_version\":\"...\",\"strategy_decision_path\":\"...\",\"strategy_decision_fingerprint\":\"...\",\"review_conditions\":[],\"blockers\":[]}",
15
15
  "skills": ["fpa-apply-core-rules"],
16
- "systemPrompt": "你是 FP&A 已确认策略核验 Agent。只读核验主 Agent 已提交的不可变决策,不批准、不预测、不发布仪表盘。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}、forecast_role={{context.forecast_role}}。",
16
+ "systemPrompt": "你是 FP&A 已确认策略核验 Agent。只读核验主 Agent 已提交的不可变决策,不批准、不预测、不发布仪表盘。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}。",
17
17
  "tools": ["read"],
18
18
  "type": "subagent"
19
19
  },
20
20
  {
21
21
  "cases": [
22
22
  { "equals": "ready", "label": "确认有效", "to": "draft_forecast" },
23
- { "equals": "blocked", "label": "确认无效", "to": "report_blocked" }
23
+ { "equals": "blocked", "label": "确认无效", "to": "report_strategy_blocked" }
24
24
  ],
25
- "default": { "label": "其他结果", "to": "report_blocked" },
25
+ "default": { "label": "其他结果", "to": "report_strategy_blocked" },
26
26
  "id": "route_confirmed_strategy",
27
27
  "label": "判断是否可预测",
28
28
  "path": "data.confirmed_strategy.status",
29
29
  "type": "router"
30
30
  },
31
31
  {
32
- "id": "report_blocked",
33
- "label": "报告预测阻断",
32
+ "id": "report_strategy_blocked",
33
+ "label": "报告策略核验阻断",
34
34
  "outputKey": "forecast_blocked",
35
35
  "parseJson": true,
36
- "prompt": "策略核验结果:{{data.confirmed_strategy}}\n预测计划修复结果:{{data.repair_forecast_plan}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
36
+ "prompt": "策略核验结果:{{data.confirmed_strategy}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
37
37
  "systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
38
38
  "type": "prompt"
39
39
  },
@@ -41,67 +41,51 @@
41
41
  "agentName": "draft_forecast",
42
42
  "id": "draft_forecast",
43
43
  "label": "正式预测决策计划",
44
- "next": "compose_forecast",
44
+ "next": "route_draft_forecast",
45
45
  "outputKey": "draft_forecast",
46
- "prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。优先使用 ua_spend 的 app_code/platform/media_source 真实组合;若已确认分配中的部分切片与当前上游映射不一致,不得擅自删除、置零、重分配或伪造映射,保留原切片交给 compose 自动产出 complete_with_limits、覆盖率和数据修复项。不得调用任何 fpa_dashboard_* 工具。",
46
+ "parseJson": true,
47
+ "prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时不得写入计划,必须返回 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位、frozen_at、forecast_role、数据覆盖状态和修复提醒全部由下游 fpa_forecast_finalize 确定。优先使用 ua_spend 的 app_code/platform/media_source 真实组合;若已确认分配中的部分切片与当前上游映射不一致,不得擅自删除、置零、重分配或伪造映射,保留原切片交给 finalize 自动产出 complete_with_limits、覆盖率和数据修复项。不得调用任何 fpa_dashboard_* 工具。成功写入后只输出 JSON:{\"status\":\"ready\",\"plan_path\":\"artifacts/forecast_plan.json\",\"blockers\":[]};需要治理处理时只输出 JSON:{\"status\":\"blocked\",\"plan_path\":null,\"blockers\":[]}。",
47
48
  "skills": ["fpa-apply-core-rules", "fpa-forecast-approved-strategy"],
48
49
  "systemPrompt": "你是 FP&A 正式预测 Agent。只按已确认策略写预测计划,不请求审批、不发布仪表盘、不手算派生值。",
49
50
  "tools": ["read", "write", "fpa_calc"],
50
51
  "type": "subagent"
51
52
  },
52
- {
53
- "args": { "plan_path": "artifacts/forecast_plan.json" },
54
- "failure": { "maxAttempts": 2, "onError": "repair_forecast_plan" },
55
- "id": "compose_forecast",
56
- "label": "合成正式预测",
57
- "next": "commit_forecast",
58
- "outputKey": "compose_forecast",
59
- "tool": "fpa_forecast_compose",
60
- "type": "tool"
61
- },
62
- {
63
- "agentName": "repair_forecast_plan",
64
- "id": "repair_forecast_plan",
65
- "label": "定点修正预测计划",
66
- "next": "route_repair_forecast_plan",
67
- "outputKey": "repair_forecast_plan",
68
- "parseJson": true,
69
- "prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式或 forecast plan 契约问题。不得通过删除、置零、重分配或伪造映射来修正数据质量问题;这类问题应由 compose 降级为 complete_with_limits。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,返回 blocked。不得调用任何 fpa_dashboard_* 工具。只输出 JSON:{\"status\":\"repaired|blocked\",\"changes\":[],\"blockers\":[]}",
70
- "skills": ["fpa-forecast-approved-strategy"],
71
- "systemPrompt": "你是预测计划修复 Agent,只按确定性合成报错做最小修正。",
72
- "tools": ["read", "write", "edit"],
73
- "type": "subagent"
74
- },
75
53
  {
76
54
  "cases": [
77
- { "equals": "repaired", "label": "已修正契约", "to": "compose_forecast" },
78
- { "equals": "blocked", "label": "需要治理处理", "to": "report_blocked" }
55
+ { "equals": "ready", "label": "计划已写入", "to": "finalize_forecast" },
56
+ { "equals": "blocked", "label": "需要治理处理", "to": "report_plan_blocked" }
79
57
  ],
80
- "default": { "label": "其他结果", "to": "report_blocked" },
81
- "id": "route_repair_forecast_plan",
82
- "label": "判断能否重新合成",
83
- "path": "data.repair_forecast_plan.status",
58
+ "default": { "label": "其他结果", "to": "report_plan_blocked" },
59
+ "id": "route_draft_forecast",
60
+ "label": "判断预测计划是否可冻结",
61
+ "path": "data.draft_forecast.status",
84
62
  "type": "router"
85
63
  },
64
+ {
65
+ "id": "report_plan_blocked",
66
+ "label": "报告预测计划阻断",
67
+ "outputKey": "forecast_blocked",
68
+ "parseJson": true,
69
+ "prompt": "预测计划结果:{{data.draft_forecast}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
70
+ "systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
71
+ "type": "prompt"
72
+ },
86
73
  {
87
74
  "args": {
88
- "artifact": "{{data.compose_forecast.details.artifact}}",
89
- "context": {
90
- "cycle_id": "{{context.cycle_id}}",
91
- "forecast_role": "{{context.forecast_role}}",
92
- "scope_id": "{{context.scope_id}}"
93
- }
75
+ "plan_path": "artifacts/forecast_plan.json",
76
+ "scope_id": "{{context.scope_id}}",
77
+ "cycle_id": "{{context.cycle_id}}"
94
78
  },
95
- "id": "commit_forecast",
79
+ "id": "finalize_forecast",
96
80
  "label": "冻结正式预测",
97
81
  "mutates": true,
98
- "outputKey": "commit_forecast",
99
- "tool": "fpa_artifact_commit",
82
+ "outputKey": "finalize_forecast",
83
+ "tool": "fpa_forecast_finalize",
100
84
  "type": "tool"
101
85
  }
102
86
  ],
103
87
  "schemaVersion": 1,
104
88
  "start": "load_confirmed_strategy",
105
89
  "transitionLabels": { "default": "其他情况", "error": "失败", "next": "继续" },
106
- "version": "2.2.0"
90
+ "version": "3.0.0"
107
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",
@@ -31,9 +31,9 @@
31
31
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
32
32
  },
33
33
  "scripts": {
34
- "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
34
+ "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
35
35
  "test:structure": "node tests/package-structure.test.mjs",
36
- "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
36
+ "test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
37
37
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
38
38
  "test:live": "node tests/live-smoke.mjs",
39
39
  "pack:check": "npm pack --dry-run"
@@ -26,8 +26,10 @@ writing artifacts. It applies only when the current agent exposes both
26
26
  | Exact Forecast and Execution refs plus newly arrived comparable Actuals | `fpa-cycle-review` |
27
27
 
28
28
  3. Call `graph_run` with the user's complete goal. Every `context` value must
29
- be a string; provide exact `scope_id`, `cycle_id`, and `forecast_role`, and
30
- keep structured planning details in `goal`. The parent agent must not
29
+ be a string. Provide exact `scope_id` and `cycle_id`; planning additionally
30
+ requires its business `forecast_role`, while forecast freeze derives the
31
+ ledger role from the target period and freeze time. Keep structured planning
32
+ details in `goal`. The parent agent must not
31
33
  reproduce Graph nodes or pre-run their `fpa_*` calls. After the combined
32
34
  planning Graph succeeds, the main Agent publishes `period-review` and then
33
35
  `next-strategy` with their matching `fpa_dashboard_publish_*` tools. Graph
@@ -24,8 +24,11 @@ ready handoff plus the exact confirmed strategy-decision fingerprint:
24
24
 
25
25
  `reviewed_strategy_handoff + strategy_decision -> forecast_plan -> approved_cycle_forecast`
26
26
 
27
- The main Agent owns approval recording; the forecast Graph owns deterministic
28
- composition and artifact freezing. A rejected approval reruns
27
+ The main Agent owns approval recording; the forecast Graph writes the approved
28
+ plan and delegates deterministic composition, lifecycle-role derivation, and
29
+ artifact freezing to `fpa_forecast_finalize`. A business role recorded in an
30
+ older handoff is lineage metadata, not a storage role that the model must
31
+ translate. A rejected approval reruns
29
32
  `fpa-strategy-planning` with the exact feedback and produces a new version
30
33
  rather than modifying the reviewed proposal in place.
31
34
 
@@ -36,34 +36,28 @@ from it, return `blocked`; never reconstruct or substitute the handoff.
36
36
  4. Provide downside, base, and upside values for each supported KPI.
37
37
  5. Reconcile allocation totals, formulas, and cross-metric identities.
38
38
  6. Write the forecast plan to `artifacts/forecast_plan.json` using [artifact-contract.md](references/artifact-contract.md).
39
- 7. Derive the artifact with `fpa_forecast_compose`, then freeze it with
40
- `fpa_artifact_commit` and an assigned context containing the explicit
41
- `scope_id`, `cycle_id`, and `forecast_role`. Use `original` only when the
42
- forecast is frozen no later than period start; use `eac` for an in-period
43
- reforecast and `next_plan` only when it is frozen no later than its target
44
- period start and is the approved direct successor of the active cycle. Use
45
- `backtest` for a historical rerun frozen after its target
46
- period; it is immutable evidence, not an operating plan, and it must not
39
+ 7. Call `fpa_forecast_finalize` with the plan path and the exact `scope_id` and
40
+ `cycle_id`. Do not choose or pass `forecast_role`: the tool derives
41
+ `next_plan` before period start, `eac` during the period, and `backtest`
42
+ after period end, then composes and commits the artifact atomically. A
43
+ historical rerun is immutable evidence, not an operating plan, and must not
47
44
  replace the current pointer or enter the automatic operating dashboard
48
45
  queue. It may be displayed from its exact immutable reference only when the
49
- user explicitly requests a backtest dashboard publication. A
50
- `next_plan` is likewise stored as forward lineage and does not replace the
51
- legacy current pointer; its explicit dashboard handoff links it to the
52
- active cycle after successor validation.
46
+ user explicitly requests a backtest dashboard publication.
53
47
 
54
48
  ## Write the decision, not the arithmetic
55
49
 
56
50
  The only judgement in a forecast is the allocation and each slice's ROAS
57
51
  assumption. Revenue, the consolidated roll-up, units, windows, and `frozen_at`
58
- all follow from those, and `fpa_forecast_compose` derives them — which is what
52
+ all follow from those, and `fpa_forecast_finalize` derives them — which is what
59
53
  makes the identities the commit checks true by construction rather than
60
54
  dependent on transcribing several hundred numbers without a slip.
61
55
 
62
56
  So do not hand-compute revenue, totals, or ratios, and do not emit the full
63
57
  artifact as one tool argument: that is the shape that gets cut off at the model
64
- output limit and leaves a turn that reads finished but committed nothing. If a
65
- later node in this graph composes and commits for you, stop after writing the
66
- plan and say so; never claim a fingerprint you were not handed.
58
+ output limit and leaves a turn that reads finished but committed nothing. Stop
59
+ after writing the plan; the finalizer owns composition and commit. Never claim
60
+ a fingerprint you were not handed.
67
61
 
68
62
  ## Status and the publish gate
69
63
 
@@ -86,7 +80,7 @@ gate.
86
80
 
87
81
  Upstream identity or coverage defects are non-blocking data limits. Preserve
88
82
  the full approved allocation, leave affected revenue and ROAS values `NULL`,
89
- and let `fpa_forecast_compose` emit `data_quality`, a coverage-labelled partial
83
+ and let `fpa_forecast_finalize` emit `data_quality`, a coverage-labelled partial
90
84
  `conclusion`, and actionable repair items. Never delete a bad slice, turn it
91
85
  into zero spend, or renormalize the remaining allocation. The affected slice
92
86
  remains ineligible for Actuals comparison and execution until its mapping is
@@ -105,7 +99,7 @@ governance failures.
105
99
 
106
100
  ## Completion
107
101
 
108
- The forecast-freeze workflow ends only after `fpa_artifact_commit` returns
102
+ The forecast-freeze workflow ends only after `fpa_forecast_finalize` returns
109
103
  `status: committed`, an `immutable_fingerprint`, and (for assigned artifacts) a
110
104
  `dashboard_handoff`. Commit freezes the business artifact but does not enqueue
111
105
  or publish dashboard work. The calling main Agent owns that explicit handoff.
@@ -73,9 +73,10 @@ Do not include `immutable_fingerprint` in the tool input; a draft that carries o
73
73
  Almost none of the artifact above is a decision. The decision is the allocation
74
74
  and the ROAS assumption behind each slice; revenue, the ROAS write-back, the
75
75
  consolidated roll-up, every unit and window string, and `frozen_at` all follow
76
- from those by arithmetic. Write the plan and let `fpa_forecast_compose` derive
77
- the rest it makes the identities the commit checks true by construction
78
- instead of true if you typed them correctly.
76
+ from those by arithmetic. Write the plan and let `fpa_forecast_finalize` derive
77
+ the rest, choose the lifecycle role from time, and commit it it makes the
78
+ identities the commit checks true by construction instead of true if you typed
79
+ them correctly.
79
80
 
80
81
  ```yaml
81
82
  # artifacts/forecast_plan.json
@@ -111,10 +112,12 @@ Then:
111
112
 
112
113
  ```
113
114
  write artifacts/forecast_plan.json
114
- fpa_forecast_compose { "plan_path": "artifacts/forecast_plan.json" }
115
- fpa_artifact_commit { "artifact": <the artifact compose returned> }
115
+ fpa_forecast_finalize { "plan_path": "artifacts/forecast_plan.json", "scope_id": "<exact scope>", "cycle_id": "<exact cycle>" }
116
116
  ```
117
117
 
118
+ The finalizer derives `next_plan` before the target period, `eac` during it,
119
+ and `backtest` after it. The model does not pass or translate `forecast_role`.
120
+
118
121
  Rules the plan has to respect, because compose enforces them:
119
122
 
120
123
  - **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
@@ -32,9 +32,12 @@ preserve every other module revision.
32
32
  3. Stop and wait for the dashboard action. The Web host sends a canonical
33
33
  decision message back to the bound original session. In that session call
34
34
  `fpa_strategy_decision_commit` with the exact `action_id`.
35
- 4. On `confirm`, run `fpa-forecast-freeze`; after it returns an exact committed
36
- forecast ref, preview then publish `next-forecast` with
37
- `fpa_dashboard_publish_forecast`.
35
+ 4. On `confirm`, run `fpa-forecast-freeze`. For an operating Forecast, preview
36
+ then publish `next-forecast` with `fpa_dashboard_publish_forecast`. If the
37
+ finalizer returns `forecast_role: backtest` and a `not_applicable` dashboard
38
+ handoff, stop after reporting its immutable ref; do not attempt operating
39
+ publication unless the user explicitly requested the separate backtest
40
+ display path.
38
41
  5. On `request_changes`, pass the verbatim feedback back into strategy planning,
39
42
  publish the replacement `next-strategy` module, and wait for a new decision.
40
43
 
@@ -57,19 +60,21 @@ only part of this tuple or fall back to a mutable current pointer.
57
60
  This path remains only for legacy execution-evidence refreshes. It must not be
58
61
  used to replace review, strategy, or forecast modules.
59
62
 
60
- 1. Take the exact `artifact_ref` from `fpa_artifact_commit.dashboard_handoff`.
63
+ 1. For an operating forecast, take the exact `artifact_ref` from a deferred
64
+ `fpa_forecast_finalize.dashboard_handoff` (or the compatible
65
+ `fpa_artifact_commit.dashboard_handoff` path). A backtest handoff is
66
+ `not_applicable` and must not be queued automatically.
61
67
  2. Call `fpa_dashboard_refresh_queue` with `action: enqueue_artifact` and that
62
68
  ref. Do not restate scope, cycle, role, or upstream refs; the package resolves
63
69
  them from the immutable ledger.
64
70
  3. If the result is `enqueued` or `already_pending` and the current task expects
65
71
  the dashboard immediately, call one bounded `drain`, then verify with
66
72
  `fpa_dashboard_status`.
67
- 4. If the result is `not_applicable` for a `backtest`, do not treat that as a
68
- failed artifact. Automatic post-Graph handoff intentionally leaves the
69
- operating dashboard unchanged. If the user explicitly asked to display or
70
- publish that backtest, continue through the exact backtest display path
71
- below; otherwise report that the frozen historical forecast remains in the
72
- ledger without changing the dashboard.
73
+ 4. A compatible legacy `fpa_artifact_commit` handoff may still return
74
+ `not_applicable` for a `backtest`; do not treat that as a failed artifact or
75
+ retry it. If the user explicitly asked to display that backtest, continue
76
+ through the exact backtest display path below. Otherwise report that it
77
+ remains in the ledger without changing the operating dashboard.
73
78
 
74
79
  ### Human-requested rebuild
75
80