@viccydev/pi-fpa 0.7.2 → 0.8.0

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.
@@ -0,0 +1,192 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { stableJson } from "../fpa-artifacts/store.ts";
6
+ import { resolveDashboardDir } from "./publisher.ts";
7
+
8
+ const SHA256_RE = /^[a-f0-9]{64}$/;
9
+
10
+ export interface StrategyDecisionRequestInput {
11
+ sessionId: string;
12
+ strategyVersion: string;
13
+ handoffFingerprint: string;
14
+ createdAt?: string;
15
+ }
16
+
17
+ export interface StrategyDecisionRequest {
18
+ kind: "fpa.strategy.decision.request";
19
+ schema_version: 1;
20
+ action_id: string;
21
+ session_id: string;
22
+ strategy_version: string;
23
+ handoff_fingerprint: string;
24
+ created_at: string;
25
+ }
26
+
27
+ export interface CommitStrategyDecisionInput {
28
+ actionId: string;
29
+ sessionId: string;
30
+ decision: "confirm" | "request_changes";
31
+ feedback?: string;
32
+ decidedAt?: string;
33
+ }
34
+
35
+ export interface CommitStrategyDecisionResult {
36
+ decisionFingerprint: string;
37
+ path: string;
38
+ decision: "confirm" | "request_changes";
39
+ strategyVersion: string;
40
+ handoffFingerprint: string;
41
+ }
42
+
43
+ export interface CommittedStrategyDecision {
44
+ kind: "fpa.strategy.decision";
45
+ schema_version: 1;
46
+ action_id: string;
47
+ strategy_version: string;
48
+ handoff_fingerprint: string;
49
+ decision: "confirm" | "request_changes";
50
+ feedback?: string;
51
+ decision_fingerprint: string;
52
+ decided_at: string;
53
+ }
54
+
55
+ function sha256(value: string): string {
56
+ return createHash("sha256").update(value).digest("hex");
57
+ }
58
+
59
+ function requiredString(value: string, label: string): string {
60
+ if (typeof value !== "string" || value.trim() === "" || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
61
+ throw new Error(`${label} must be a non-empty bounded string without control characters.`);
62
+ }
63
+ return value;
64
+ }
65
+
66
+ async function ensureDirectory(parent: string, name: string): Promise<string> {
67
+ const path = join(parent, name);
68
+ try {
69
+ const stat = await lstat(path);
70
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
71
+ } catch (error) {
72
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
73
+ await mkdir(path, { mode: 0o700 });
74
+ }
75
+ return path;
76
+ }
77
+
78
+ async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<"created" | "exists"> {
79
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
80
+ const handle = await open(temporary, "wx", 0o600);
81
+ try {
82
+ await handle.writeFile(contents, "utf8");
83
+ await handle.sync();
84
+ await handle.close();
85
+ try {
86
+ await link(temporary, destination);
87
+ return "created";
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
90
+ throw error;
91
+ }
92
+ } finally {
93
+ await handle.close().catch(() => undefined);
94
+ await unlink(temporary).catch(() => undefined);
95
+ }
96
+ }
97
+
98
+ export async function createStrategyDecisionRequest(cwd: string, input: StrategyDecisionRequestInput): Promise<{ actionId: string; path: string }> {
99
+ const sessionId = requiredString(input.sessionId, "sessionId");
100
+ const strategyVersion = requiredString(input.strategyVersion, "strategyVersion");
101
+ if (!SHA256_RE.test(input.handoffFingerprint)) throw new Error("handoffFingerprint must be a SHA-256 digest.");
102
+ const dashboardDir = await resolveDashboardDir(cwd);
103
+ try {
104
+ const stat = await lstat(dashboardDir);
105
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Dashboard directory must be a regular directory, not a symlink.");
106
+ } catch (error) {
107
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
108
+ await mkdir(dashboardDir, { mode: 0o700 });
109
+ }
110
+ const actionsDir = await ensureDirectory(dashboardDir, "actions");
111
+ const createdAt = input.createdAt ?? new Date().toISOString();
112
+ const actionId = sha256(stableJson({ sessionId, strategyVersion, handoffFingerprint: input.handoffFingerprint, createdAt, nonce: randomUUID() }));
113
+ const request: StrategyDecisionRequest = {
114
+ kind: "fpa.strategy.decision.request",
115
+ schema_version: 1,
116
+ action_id: actionId,
117
+ session_id: sessionId,
118
+ strategy_version: strategyVersion,
119
+ handoff_fingerprint: input.handoffFingerprint,
120
+ created_at: createdAt,
121
+ };
122
+ const path = join(actionsDir, `${actionId}.json`);
123
+ await appendOnlyWrite(actionsDir, path, `${JSON.stringify(request, null, 2)}\n`);
124
+ return { actionId, path };
125
+ }
126
+
127
+ export async function readStrategyDecisionRequest(cwd: string, actionId: string): Promise<StrategyDecisionRequest> {
128
+ if (!SHA256_RE.test(actionId)) throw new Error("actionId must be a SHA-256 digest.");
129
+ const dashboardDir = await resolveDashboardDir(cwd);
130
+ const path = join(dashboardDir, "actions", `${actionId}.json`);
131
+ const stat = await lstat(path);
132
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Strategy decision request must be a regular file.");
133
+ const request = JSON.parse(await readFile(path, "utf8")) as StrategyDecisionRequest;
134
+ if (request.kind !== "fpa.strategy.decision.request" || request.schema_version !== 1 || request.action_id !== actionId) throw new Error("Strategy decision request is invalid.");
135
+ return request;
136
+ }
137
+
138
+ export async function commitStrategyDecision(cwd: string, input: CommitStrategyDecisionInput): Promise<CommitStrategyDecisionResult> {
139
+ const sessionId = requiredString(input.sessionId, "sessionId");
140
+ const request = await readStrategyDecisionRequest(cwd, input.actionId);
141
+ if (request.session_id !== sessionId) throw new Error("Strategy decision must be committed from the originating session.");
142
+ const feedback = input.feedback?.trim();
143
+ if (input.decision === "request_changes" && !feedback) throw new Error("request_changes requires non-empty feedback.");
144
+ if (input.decision === "confirm" && feedback) throw new Error("confirm must not include feedback.");
145
+
146
+ const projectRoot = await realpath(cwd);
147
+ const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
148
+ const decisionsDir = await ensureDirectory(artifactsDir, "strategy-decisions");
149
+ const identity = {
150
+ action_id: request.action_id,
151
+ strategy_version: request.strategy_version,
152
+ handoff_fingerprint: request.handoff_fingerprint,
153
+ decision: input.decision,
154
+ ...(feedback ? { feedback } : {}),
155
+ };
156
+ const decisionFingerprint = sha256(stableJson(identity));
157
+ const decision = {
158
+ kind: "fpa.strategy.decision",
159
+ schema_version: 1,
160
+ ...identity,
161
+ decision_fingerprint: decisionFingerprint,
162
+ decided_at: input.decidedAt ?? new Date().toISOString(),
163
+ };
164
+ const path = join(decisionsDir, `${request.action_id}.json`);
165
+ const contents = `${JSON.stringify(decision, null, 2)}\n`;
166
+ const write = await appendOnlyWrite(decisionsDir, path, contents);
167
+ if (write === "exists") {
168
+ const existing = JSON.parse(await readFile(path, "utf8")) as typeof decision;
169
+ if (existing.decision_fingerprint !== decisionFingerprint) throw new Error("A different strategy decision is already committed for this action.");
170
+ return {
171
+ decisionFingerprint: existing.decision_fingerprint,
172
+ path,
173
+ decision: existing.decision,
174
+ strategyVersion: request.strategy_version,
175
+ handoffFingerprint: request.handoff_fingerprint,
176
+ };
177
+ }
178
+ return { decisionFingerprint, path, decision: input.decision, strategyVersion: request.strategy_version, handoffFingerprint: request.handoff_fingerprint };
179
+ }
180
+
181
+ export async function readCommittedStrategyDecision(cwd: string, actionId: string, decisionFingerprint: string): Promise<CommittedStrategyDecision> {
182
+ if (!SHA256_RE.test(actionId) || !SHA256_RE.test(decisionFingerprint)) throw new Error("Strategy decision identity must use SHA-256 digests.");
183
+ const projectRoot = await realpath(cwd);
184
+ const path = join(projectRoot, "artifacts", "strategy-decisions", `${actionId}.json`);
185
+ const stat = await lstat(path);
186
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Committed strategy decision must be a regular file.");
187
+ const decision = JSON.parse(await readFile(path, "utf8")) as CommittedStrategyDecision;
188
+ if (decision.kind !== "fpa.strategy.decision" || decision.schema_version !== 1 || decision.action_id !== actionId || decision.decision_fingerprint !== decisionFingerprint) {
189
+ throw new Error("Committed strategy decision identity does not match the requested decision.");
190
+ }
191
+ return decision;
192
+ }
@@ -7,6 +7,8 @@ import type {
7
7
 
8
8
  const GRAPH_TOOLS = ["graph_list", "graph_run"] as const;
9
9
  const FP_AND_A_GRAPHS = [
10
+ "fpa-period-analysis",
11
+ "fpa-strategy-recommendation",
10
12
  "fpa-strategy-planning",
11
13
  "fpa-forecast-freeze",
12
14
  "fpa-strategy-execution",
@@ -19,6 +21,7 @@ interface RoutingState {
19
21
  catalogLoaded: boolean;
20
22
  availableGraphs: Set<string>;
21
23
  graphRunStarted: boolean;
24
+ graphRunCompleted: boolean;
22
25
  }
23
26
 
24
27
  function emptyState(): RoutingState {
@@ -26,6 +29,7 @@ function emptyState(): RoutingState {
26
29
  catalogLoaded: false,
27
30
  availableGraphs: new Set(),
28
31
  graphRunStarted: false,
32
+ graphRunCompleted: false,
29
33
  };
30
34
  }
31
35
 
@@ -115,7 +119,7 @@ export function classifyFpaGraph(prompt: string): FpaGraph | undefined {
115
119
  return "fpa-strategy-execution";
116
120
  }
117
121
 
118
- return hasFpaIntent(normalized) ? "fpa-strategy-planning" : undefined;
122
+ return hasFpaIntent(normalized) ? "fpa-period-analysis" : undefined;
119
123
  }
120
124
 
121
125
  function contentText(event: ToolResultEvent): string {
@@ -149,7 +153,7 @@ function routingInstruction(graph: FpaGraph): string {
149
153
  "FP&A runtime routing guard is active for this request.",
150
154
  `The first eligible workflow is ${graph}.`,
151
155
  "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.",
156
+ "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.",
153
157
  ].join(" ");
154
158
  }
155
159
 
@@ -180,6 +184,15 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
180
184
  return { block: true, reason: `Call graph_list before graph_run for ${graph}.` };
181
185
  }
182
186
  const requested = "graph" in event.input ? event.input.graph : undefined;
187
+ if (graph === "fpa-period-analysis" && state.graphRunCompleted && requested === "fpa-strategy-recommendation") {
188
+ if (!state.availableGraphs.has("fpa-strategy-recommendation")) {
189
+ return { block: true, reason: "fpa-strategy-recommendation is unavailable; do not emulate strategy work in the parent Agent." };
190
+ }
191
+ state.requiredGraph = "fpa-strategy-recommendation";
192
+ state.graphRunStarted = true;
193
+ state.graphRunCompleted = false;
194
+ return;
195
+ }
183
196
  if (requested !== graph) {
184
197
  return { block: true, reason: `Run the first eligible ${graph} Graph; received ${String(requested)}.` };
185
198
  }
@@ -190,9 +203,21 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
190
203
  };
191
204
  }
192
205
  state.graphRunStarted = true;
206
+ state.graphRunCompleted = false;
193
207
  return;
194
208
  }
195
209
 
210
+ if (event.toolName === "fpa_dashboard_module_status") return;
211
+ if ((graph === "fpa-forecast-freeze" || graph === "fpa-strategy-recommendation") && !state.graphRunStarted && event.toolName === "fpa_strategy_decision_commit") return;
212
+ const allowedPublication = graph === "fpa-period-analysis"
213
+ ? "fpa_dashboard_publish_review"
214
+ : graph === "fpa-strategy-recommendation"
215
+ ? "fpa_dashboard_publish_strategy"
216
+ : graph === "fpa-forecast-freeze"
217
+ ? "fpa_dashboard_publish_forecast"
218
+ : "fpa_dashboard_refresh_queue";
219
+ if (event.toolName === allowedPublication && state.graphRunCompleted) return;
220
+
196
221
  if (!event.toolName.startsWith("fpa_") && !artifactWrite(event)) return;
197
222
  if (!state.catalogLoaded) {
198
223
  return { block: true, reason: `Call graph_list before any FP&A phase tool for ${graph}.` };
@@ -213,8 +238,17 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
213
238
  });
214
239
 
215
240
  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));
241
+ if (!state.requiredGraph || event.isError) return;
242
+ if (event.toolName === "graph_list") {
243
+ state.catalogLoaded = true;
244
+ state.availableGraphs = new Set(catalogNames(event));
245
+ return;
246
+ }
247
+ if (event.toolName === "graph_run" && state.graphRunStarted) {
248
+ const status = event.details && typeof event.details === "object" && "status" in event.details
249
+ ? (event.details as { status?: unknown }).status
250
+ : undefined;
251
+ state.graphRunCompleted = status !== "failed" && status !== "blocked" && status !== "cancelled";
252
+ }
219
253
  });
220
254
  }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "fpa-forecast-freeze",
3
+ "version": "2.0.0",
4
+ "start": "load_confirmed_strategy",
5
+ "nodes": [
6
+ { "id": "load_confirmed_strategy", "type": "subagent", "agentName": "load_confirmed_strategy", "next": "draft_forecast", "tools": ["read"], "prompt": "核验主 Agent 已提交的策略 decision 与 reviewed handoff 精确一致;不得审批或发布仪表盘。" },
7
+ { "id": "draft_forecast", "type": "subagent", "agentName": "draft_forecast", "next": "compose_forecast", "skills": ["fpa-forecast-approved-strategy"], "tools": ["read", "write", "fpa_calc"], "prompt": "只按已确认策略写预测计划;不得重新优化策略或发布仪表盘。" },
8
+ { "id": "compose_forecast", "type": "tool", "tool": "fpa_forecast_compose", "next": "commit_forecast", "args": { "plan_path": "artifacts/forecast_plan.json" } },
9
+ { "id": "commit_forecast", "type": "tool", "tool": "fpa_artifact_commit", "mutates": true, "args": { "artifact": "{{data.compose_forecast.details.artifact}}", "context": { "scope_id": "{{context.scope_id}}", "cycle_id": "{{context.cycle_id}}", "forecast_role": "{{context.forecast_role}}" } } }
10
+ ]
11
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "fpa-period-analysis",
3
+ "version": "1.0.0",
4
+ "start": "plan_cycle",
5
+ "nodes": [
6
+ { "id": "plan_cycle", "type": "subagent", "agentName": "plan_cycle", "next": "diagnose_actuals", "skills": ["fpa-plan-cycle"], "tools": ["read", "write", "fpa_data_catalog"], "prompt": "规划精确周期、范围、指标与边界;不得推荐策略或调用仪表盘工具。" },
7
+ { "id": "diagnose_actuals", "type": "subagent", "agentName": "diagnose_actuals", "next": "analyze_drivers", "skills": ["fpa-diagnose-actuals"], "tools": ["read", "write", "fpa_data_catalog", "fpa_query"], "prompt": "诊断 Actuals 完整性并写入工件;不得调用仪表盘工具。" },
8
+ { "id": "analyze_drivers", "type": "subagent", "agentName": "analyze_drivers", "skills": ["fpa-analyze-drivers"], "tools": ["read", "write", "fpa_query", "fpa_calc"], "prompt": "分析上周期驱动因素并写 artifacts/driver_analysis.json;不得推荐策略或调用仪表盘工具。" }
9
+ ]
10
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "fpa-strategy-recommendation",
3
+ "version": "1.0.0",
4
+ "start": "simulate_strategies",
5
+ "nodes": [
6
+ { "id": "simulate_strategies", "type": "subagent", "agentName": "simulate_strategies", "next": "recommend_strategy", "skills": ["fpa-simulate-strategies"], "tools": ["read", "write", "fpa_query", "fpa_calc"], "prompt": "根据分析工件试算候选策略;不得批准、预测或发布仪表盘。" },
7
+ { "id": "recommend_strategy", "type": "subagent", "agentName": "recommend_strategy", "next": "review_strategy", "skills": ["fpa-recommend-strategy"], "tools": ["read", "write"], "prompt": "形成完整策略提案;不得自我批准或发布仪表盘。" },
8
+ { "id": "review_strategy", "type": "subagent", "agentName": "review_strategy", "next": "prepare_handoff", "skills": ["fpa-review-strategy"], "tools": ["read", "write", "fpa_calc"], "prompt": "独立复核策略并写审核工件;不得批准或发布仪表盘。" },
9
+ { "id": "prepare_handoff", "type": "subagent", "agentName": "prepare_handoff", "tools": ["read", "write"], "prompt": "仅在审核通过时形成 reviewed_strategy_handoff;停止,不批准、不预测、不发布仪表盘。" }
10
+ ]
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
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",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "README.md",
24
24
  "bin",
25
+ "graphs",
25
26
  "prompts",
26
27
  "skills",
27
28
  "extensions"
@@ -30,9 +31,9 @@
30
31
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
31
32
  },
32
33
  "scripts": {
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
+ "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
34
35
  "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/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
+ "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
37
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
37
38
  "test:live": "node tests/live-smoke.mjs",
38
39
  "pack:check": "npm pack --dry-run"
@@ -17,10 +17,20 @@ argument-hint: "<project-root> <cycle-id> [instructions]"
17
17
  ## 强制 Graph 路由
18
18
 
19
19
  先读取 `fpa-apply-core-rules`。如果当前 Agent 提供 `graph_list` 和
20
- `graph_run`,必须先调用 `graph_list`,确认 `fpa-strategy-planning` 后再以
21
- 完整用户目标、已解析的项目根、周期边界、业务范围和数据上下文调用
22
- `graph_run`。在这两步之前不得直接调用任何 `fpa_*` 工具或写 Artifact,
23
- Agent 也不得自行串行模拟 Graph 内的阶段。
20
+ `graph_run`,必须先调用 `graph_list`,确认 `fpa-period-analysis`
21
+ `fpa-strategy-recommendation` 后按以下边界执行:
22
+
23
+ 1. 以完整用户目标、项目根、周期边界、业务范围和数据上下文运行
24
+ `fpa-period-analysis`。Graph 返回 `artifacts/driver_analysis.json` 后,父
25
+ Agent 调用 `fpa_dashboard_publish_review` 先 preview,再用精确
26
+ fingerprint/revision 发布 `period-review`。
27
+ 2. 再运行 `fpa-strategy-recommendation`。Graph 返回
28
+ `strategy_proposal.json` 与 `reviewed_strategy_handoff.json` 后,父 Agent
29
+ 调用 `fpa_dashboard_publish_strategy` preview/publish,发布
30
+ `next-strategy` 并等待仪表盘决策。
31
+
32
+ 两个 Graph 都不得拥有或调用任何 `fpa_dashboard_*` 工具。发布是父 Agent
33
+ 在 Graph 返回后的职责;父 Agent不得自行模拟 Graph 内的业务阶段。
24
34
 
25
35
  如果匹配 Graph 不在 catalog 中或无法加载,列出缺口并保持 `blocked`;不得把
26
36
  本多阶段入口自动降级为父 Agent 本地执行。用户之后可另行显式请求一个隔离阶段。
@@ -29,9 +39,12 @@ Graph 返回业务阻塞同样不构成降级理由。所需上下文缺失时
29
39
  ## 不可跳过的停点
30
40
 
31
41
  - 策略推荐者不得批准或独立复核自己的提案。若宿主不能证明复核上下文与提案作者独立,复核阶段必须报告 `blocked`。
32
- - 独立复核完成后,`fpa-strategy-planning` 必须生成精确绑定提案与复核版本的 `reviewed_strategy_handoff`,然后停止。
42
+ - 独立复核完成后,`fpa-strategy-recommendation` 必须生成精确绑定提案与复核版本的 `reviewed_strategy_handoff`,然后停止。
33
43
  - 聊天中的认可、模型自我确认或 Forecast 授权都不等于策略执行授权。
34
- - 人工批准与正式预测属于后续独立的 `fpa-forecast-freeze` Graph;父 Agent 不得在本入口中继续执行。
44
+ - 仪表盘响应回到原主会话后,先由主 Agent 调用
45
+ `fpa_strategy_decision_commit`。只有 `confirm` 成功才可在后续运行
46
+ `fpa-forecast-freeze`;Graph 返回 immutable forecast ref 后仍由主 Agent
47
+ 调用 `fpa_dashboard_publish_forecast`。本入口发布策略后停止等待用户。
35
48
  - 本入口不得加载 Forecast 或策略执行 Skill,不得声称已经冻结 Forecast 或修改外部投放状态。
36
49
 
37
50
  ## 全局计算与证据底线
@@ -20,15 +20,18 @@ writing artifacts. It applies only when the current agent exposes both
20
20
 
21
21
  | Eligible request and evidence | Required Graph |
22
22
  | --- | --- |
23
- | New or changed objective requiring planning, Actuals diagnosis, 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` |
23
+ | New or changed objective requiring planning, Actuals diagnosis, and driver analysis | `fpa-period-analysis` |
24
+ | Completed `driver_analysis` requiring scenarios, recommendation, and independent review | `fpa-strategy-recommendation` |
25
+ | Ready `reviewed_strategy_handoff` plus an exact confirmed `strategy_decision` and a request for an official forecast | `fpa-forecast-freeze` |
25
26
  | Exact committed approved Forecast ref plus an authorized execution request | `fpa-strategy-execution` |
26
27
  | Exact Forecast and Execution refs plus newly arrived comparable Actuals | `fpa-cycle-review` |
27
28
 
28
29
  3. Call `graph_run` with the user's complete goal and the exact immutable
29
30
  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.
31
+ nodes or pre-run their `fpa_*` calls. After a successful Graph handoff, the
32
+ main Agent must publish only that stage's module with the matching
33
+ `fpa_dashboard_publish_*` tool. Graph nodes must never receive dashboard
34
+ publication tools.
32
35
  4. If required context is missing or ambiguous, return `blocked` and request
33
36
  the exact identity or ref. Do not fall back to direct phase execution.
34
37
 
@@ -2,20 +2,32 @@
2
2
 
3
3
  ## 1. Workflow and artifacts
4
4
 
5
- Strategy-planning workflow (`fpa-strategy-planning`):
5
+ Period-analysis workflow (`fpa-period-analysis`):
6
6
 
7
- `planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
7
+ `planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis`
8
8
 
9
- This Graph stops after an independently reviewed strategy handoff. It does not
9
+ The calling main Agent publishes `period-review`, then starts the separate
10
+ strategy-recommendation workflow (`fpa-strategy-recommendation`):
11
+
12
+ `driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
13
+
14
+ Each Graph stops at its named handoff. Neither Graph may publish dashboard
15
+ content. The strategy Graph does not
10
16
  approve the strategy, create an official forecast, or execute the allocation.
11
17
 
18
+ The calling main Agent publishes the exact ready handoff through
19
+ `fpa_dashboard_publish_strategy`, waits for the dashboard response in the
20
+ originating main session, and commits it with `fpa_strategy_decision_commit`.
21
+ Dashboard publishing and decision commit tools are forbidden from every Graph
22
+ tool allowlist.
23
+
12
24
  Forecast-freeze workflow (`fpa-forecast-freeze`), entered only with that exact
13
- ready handoff:
25
+ ready handoff plus the exact confirmed strategy-decision fingerprint:
14
26
 
15
- `reviewed_strategy_handoff -> human strategy_approval -> forecast_plan -> approved_cycle_forecast`
27
+ `reviewed_strategy_handoff + strategy_decision -> forecast_plan -> approved_cycle_forecast`
16
28
 
17
- The forecast Graph owns approval recording, deterministic composition, and
18
- artifact freezing. A rejected approval returns to a new
29
+ The main Agent owns approval recording; the forecast Graph owns deterministic
30
+ composition and artifact freezing. A rejected approval returns to a new
19
31
  `fpa-strategy-planning` version rather than modifying the reviewed proposal in
20
32
  place.
21
33
 
@@ -28,7 +40,8 @@ after new Actuals arrive:
28
40
 
29
41
  `exact approved_cycle_forecast ref + exact execution_receipt ref + next actuals_snapshot -> cycle_review -> optional new planning cycle`
30
42
 
31
- The main agent routes to only the currently eligible Graph. It must not emulate
43
+ The main agent routes to only the currently eligible Graph and is solely
44
+ responsible for publishing the returned stage module. It must not emulate
32
45
  multiple phases itself or automatically cross a human approval or immutable
33
46
  artifact handoff.
34
47
 
@@ -17,7 +17,9 @@ Require all of the following:
17
17
  `fpa-strategy-planning`, including its bound proposal and review identities;
18
18
  - the exact `strategy_proposal` version;
19
19
  - a completed independent `strategy_review` supporting that version;
20
- - explicit human `strategy_approval` identifying the same version and any conditions;
20
+ - the exact immutable `strategy_decision` fingerprint returned by
21
+ `fpa_strategy_decision_commit` in the originating main session, confirming
22
+ the same version and handoff fingerprint;
21
23
  - the eligible data snapshot, assumptions, and model version used for forecasting.
22
24
 
23
25
  If approval evidence is absent, ambiguous, expired, conditional but unmet, or refers to another strategy version, return `blocked` without forecasting.
@@ -26,7 +28,9 @@ from it, return `blocked`; never reconstruct or substitute the handoff.
26
28
 
27
29
  ## Procedure
28
30
 
29
- 1. Verify approval and artifact lineage.
31
+ 1. Verify the committed strategy decision and artifact lineage. The Graph must
32
+ consume that decision as input; it must not display, request, or record a
33
+ second approval itself.
30
34
  2. Lock the approved allocation, accountable owner for each slice when known, and all approval conditions.
31
35
  3. Recalculate future-period operating outcomes from that allocation using the declared model.
32
36
  4. Provide downside, base, and upside values for each supported KPI.
@@ -74,12 +78,11 @@ Work the planning stage explicitly placed outside this cycle's scope is neither
74
78
  a limit nor a defect: it does not belong in `unsupported_metrics` and does not
75
79
  downgrade `status`.
76
80
 
77
- This matters downstream: `fpa_dashboard_refresh` with `mode: publish` accepts
78
- only `complete`, so `complete_with_limits` blocks the dashboard. Do not
79
- misreport a narrowed scope as a limit and stall the publish, and do not hide a
80
- real defect to get through the gate. When something that was promised cannot be
81
- delivered, report `complete_with_limits` honestly and say that the scope
82
- declaration upstream needs correcting.
81
+ The calling main Agent publishes the committed result with
82
+ `fpa_dashboard_publish_forecast`; the Graph itself never calls a dashboard
83
+ tool. `complete_with_limits` remains publishable when its limitations are
84
+ explicit, while `blocked` is not. Do not hide a real defect to get through the
85
+ gate.
83
86
 
84
87
  ## Boundaries
85
88
 
@@ -1,13 +1,45 @@
1
1
  ---
2
2
  name: fpa-refresh-dashboard
3
- description: Preview, validate, and atomically publish the forecast closed-loop FP&A dashboard from a committed approved forecast, optional execution receipt, and current read-only Actuals. Use when the user asks to inspect, rebuild, or publish the tenant dashboard.
3
+ description: Preview, validate, and publish independent FP&A dashboard modules for period review, reviewed strategy approval, confirmed forecast, and execution evidence. Use after each Graph stage returns or when the user asks to inspect, rebuild, or publish the tenant dashboard.
4
4
  ---
5
5
 
6
- # FP&A Dashboard Refresh
6
+ # FP&A Dashboard Module Publication
7
7
 
8
8
  Load `$fpa-apply-core-rules` first. Follow [dashboard-policy.md](references/dashboard-policy.md).
9
9
 
10
- ## Entry gate
10
+ ## Ownership boundary
11
+
12
+ Dashboard publication belongs to the calling main Agent. A Graph may analyze,
13
+ review, forecast, and return exact artifact paths or refs, but no Graph node or
14
+ Graph tool allowlist may contain any `fpa_dashboard_*` tool. The main Agent must
15
+ wait for the Graph result, then preview and publish the corresponding module.
16
+
17
+ Do not publish the whole dashboard after every stage. The root manifest is only
18
+ an atomic catalog of independently versioned modules; publishing one module must
19
+ preserve every other module revision.
20
+
21
+ ## Module sequence
22
+
23
+ 1. After the analysis Graph returns `driver_analysis`, preview then publish
24
+ `period-review` with `fpa_dashboard_publish_review`.
25
+ 2. After the strategy Graph returns the exact `strategy_proposal` and
26
+ `reviewed_strategy_handoff`, preview then publish `next-strategy` with
27
+ `fpa_dashboard_publish_strategy`. Publication binds its decision action to
28
+ the current main session; never copy or expose a session id in dashboard data.
29
+ 3. Stop and wait for the dashboard action. The Web host sends a canonical
30
+ decision message back to the bound original session. In that session call
31
+ `fpa_strategy_decision_commit` with the exact `action_id`.
32
+ 4. On `confirm`, run `fpa-forecast-freeze`; after it returns an exact committed
33
+ forecast ref, preview then publish `next-forecast` with
34
+ `fpa_dashboard_publish_forecast`.
35
+ 5. On `request_changes`, pass the verbatim feedback back into strategy planning,
36
+ publish the replacement `next-strategy` module, and wait for a new decision.
37
+
38
+ Every preview returns both `preview_fingerprint` and `dashboard_revision`.
39
+ Publish with both exact values. A `null` dashboard revision is an explicit CAS
40
+ value for a dashboard that did not exist at preview time.
41
+
42
+ ## Legacy closed-loop entry gate
11
43
 
12
44
  Require a committed `approved_cycle_forecast` for the target project. Treat a committed `execution_receipt` as optional execution evidence; never describe manual reported execution as independently verified.
13
45
 
@@ -17,7 +49,10 @@ only part of this tuple or fall back to a mutable current pointer.
17
49
 
18
50
  ## Procedure
19
51
 
20
- ### Post-Graph handoff
52
+ ### Legacy post-Graph handoff
53
+
54
+ This path remains only for legacy execution-evidence refreshes. It must not be
55
+ used to replace review, strategy, or forecast modules.
21
56
 
22
57
  1. Take the exact `artifact_ref` from `fpa_artifact_commit.dashboard_handoff`.
23
58
  2. Call `fpa_dashboard_refresh_queue` with `action: enqueue_artifact` and that
@@ -60,6 +95,11 @@ answer, and do not describe it as "the period has no data yet".
60
95
  ## Boundaries
61
96
 
62
97
  - Never write `.fpa-dashboard` with generic file or shell tools.
98
+ - Never let pi-graph or a Graph node call dashboard publication tools.
99
+ - Never accept caller-authored widgets; module tools project widgets from exact
100
+ business artifacts and immutable forecast refs.
101
+ - Never start forecast generation before the strategy decision is durably
102
+ committed in the bound original main session.
63
103
  - Never fabricate Actuals, replace missing values with zero, or average row-level ratios.
64
104
  - Do not edit the approved forecast during projection.
65
105
  - Never relabel a backtest as `original`, `eac`, or `next_plan` to display it.