@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 +30 -9
- package/extensions/fpa-artifacts/index.ts +10 -30
- package/extensions/fpa-artifacts/store.ts +36 -12
- package/extensions/fpa-dashboard/coordinator.ts +125 -8
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +3 -2
- package/extensions/fpa-dashboard/index.ts +23 -4
- package/extensions/fpa-dashboard/projector.ts +2 -1
- package/extensions/fpa-dashboard/publisher.ts +3 -0
- package/extensions/fpa-dashboard/status.ts +4 -4
- package/extensions/fpa-routing-guard/index.ts +220 -0
- package/package.json +4 -3
- package/prompts/fpa-plan-cycle.md +15 -15
- package/prompts/fpa-review-cycle.md +10 -6
- package/skills/fpa-apply-core-rules/SKILL.md +35 -1
- package/skills/fpa-apply-core-rules/references/core-rules.md +23 -7
- package/skills/fpa-execute-approved-strategy/SKILL.md +5 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +24 -4
- package/skills/fpa-plan-cycle/SKILL.md +5 -2
- package/skills/fpa-refresh-dashboard/SKILL.md +21 -5
- package/skills/fpa-review-cycle/SKILL.md +6 -1
- package/skills/fpa-review-strategy/SKILL.md +4 -1
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
|
|
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
|
|
12
|
-
- `/fpa-review-cycle
|
|
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` |
|
|
84
|
+
| `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/Execution,Actuals 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.
|
|
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.
|
|
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 "
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
115
|
-
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
|
231
|
-
throw new Error("context.forecast_role must be original, eac, or
|
|
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
|
|
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
|
|
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
|
|
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
|
|
422
|
-
|
|
423
|
-
|
|
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 {
|
|
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?:
|
|
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?:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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?:
|
|
20
|
+
current_forecast_role?: ForecastRole;
|
|
20
21
|
actuals: unknown;
|
|
21
22
|
execution?: unknown;
|
|
22
23
|
next_forecast?: unknown;
|
|
23
|
-
next_forecast_role?:
|
|
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
|
|
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
|
|
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?:
|
|
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:
|
|
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?:
|
|
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
|
|
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.
|
|
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
|
-
|
|
33
|
+
"test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
34
34
|
"test:structure": "node tests/package-structure.test.mjs",
|
|
35
|
-
|
|
35
|
+
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs",
|
|
36
36
|
"test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
-
|
|
34
|
-
-
|
|
35
|
-
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 ->
|
|
7
|
+
`planning_brief -> actuals_snapshot + data_issue_report -> driver_analysis -> strategy_scenarios -> strategy_proposal -> strategy_review -> reviewed_strategy_handoff`
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
12
|
+
Forecast-freeze workflow (`fpa-forecast-freeze`), entered only with that exact
|
|
13
|
+
ready handoff:
|
|
12
14
|
|
|
13
|
-
`strategy_approval
|
|
15
|
+
`reviewed_strategy_handoff -> human strategy_approval -> forecast_plan -> approved_cycle_forecast`
|
|
14
16
|
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
45
|
-
|
|
46
|
-
package coordinator running in the package-owned
|
|
47
|
-
process (never in the read-only Web host); inspect or
|
|
48
|
-
`fpa_dashboard_refresh_queue` rather than recreating its
|
|
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`
|
|
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,
|
|
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.
|