@viccydev/pi-fpa 0.6.2 → 0.7.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.
- package/README.md +3 -3
- 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/forward-outlook.ts +5 -1
- 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/package.json +3 -3
- package/skills/fpa-forecast-approved-strategy/SKILL.md +9 -3
- package/skills/fpa-refresh-dashboard/SKILL.md +21 -5
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ Extension 内置的关键防护:
|
|
|
60
60
|
| --- | --- |
|
|
61
61
|
| `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
|
|
62
62
|
| `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
|
|
63
|
-
| `fpa_dashboard_refresh_queue` |
|
|
63
|
+
| `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/Execution,Actuals watermark 按 SLA 轮询并幂等发布 |
|
|
64
64
|
|
|
65
65
|
持续刷新由 package 自带的独立 worker 驱动,Web 保持严格只读:
|
|
66
66
|
|
|
@@ -127,12 +127,12 @@ pi list
|
|
|
127
127
|
团队分发建议使用固定 Git tag:
|
|
128
128
|
|
|
129
129
|
```bash
|
|
130
|
-
pi install git:github.com/linyqh/pi-fpa@v0.
|
|
130
|
+
pi install git:github.com/linyqh/pi-fpa@v0.7.0
|
|
131
131
|
```
|
|
132
132
|
|
|
133
133
|
## 发布到 npm
|
|
134
134
|
|
|
135
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.
|
|
135
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.7.0` 对应 `v0.7.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
136
136
|
|
|
137
137
|
发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
|
|
138
138
|
|
|
@@ -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 {
|
|
@@ -35,6 +35,10 @@ function values(metric: ScenarioMetric | undefined, path: string) {
|
|
|
35
35
|
return { downside: metric.downside, base: metric.base, upside: metric.upside };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function canonicalTimezone(timezone: string): string {
|
|
39
|
+
return new Intl.DateTimeFormat("en-US", { timeZone: timezone }).resolvedOptions().timeZone;
|
|
40
|
+
}
|
|
41
|
+
|
|
38
42
|
export function projectForwardOutlook(input: {
|
|
39
43
|
current_forecast: unknown;
|
|
40
44
|
forward_forecasts: ForwardForecastInput[];
|
|
@@ -47,7 +51,7 @@ export function projectForwardOutlook(input: {
|
|
|
47
51
|
const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
|
|
48
52
|
if (item.ref.artifact_type !== "approved_cycle_forecast" || item.ref.body_fingerprint === "" || item.ref.cycle_id === "") throw new Error(`forward_forecasts[${index}].ref is invalid.`);
|
|
49
53
|
if (forecast.reporting_currency !== current.reporting_currency) throw new Error(`forward_forecasts[${index}] reporting currency does not match the current forecast.`);
|
|
50
|
-
if (forecast.target_period.timezone !== current.target_period.timezone || Date.parse(forecast.target_period.start_inclusive) !== Date.parse(expectedStart)) {
|
|
54
|
+
if (canonicalTimezone(forecast.target_period.timezone) !== canonicalTimezone(current.target_period.timezone) || Date.parse(forecast.target_period.start_inclusive) !== Date.parse(expectedStart)) {
|
|
51
55
|
throw new Error(`forward_forecasts[${index}] must be the exact consecutive successor in the same timezone.`);
|
|
52
56
|
}
|
|
53
57
|
expectedStart = forecast.target_period.end_exclusive;
|
|
@@ -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
|
: [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viccydev/pi-fpa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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",
|
|
@@ -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/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"
|
|
@@ -30,8 +30,14 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
|
|
|
30
30
|
`fpa_artifact_commit` and an assigned context containing the explicit
|
|
31
31
|
`scope_id`, `cycle_id`, and `forecast_role`. Use `original` only when the
|
|
32
32
|
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.
|
|
33
|
+
reforecast and `next_plan` only when it is frozen no later than its target
|
|
34
|
+
period start and is the approved direct successor of the active cycle. Use
|
|
35
|
+
`backtest` for a historical rerun frozen after its target
|
|
36
|
+
period; it is immutable evidence, not an operating plan, and it must not
|
|
37
|
+
replace the current pointer or enter the operating dashboard queue. A
|
|
38
|
+
`next_plan` is likewise stored as forward lineage and does not replace the
|
|
39
|
+
legacy current pointer; its explicit dashboard handoff links it to the
|
|
40
|
+
active cycle after successor validation.
|
|
35
41
|
|
|
36
42
|
## Write the decision, not the arithmetic
|
|
37
43
|
|
|
@@ -76,4 +82,4 @@ declaration upstream needs correcting.
|
|
|
76
82
|
|
|
77
83
|
## Completion
|
|
78
84
|
|
|
79
|
-
The planning workflow ends only after `fpa_artifact_commit` returns `status: committed` and
|
|
85
|
+
The planning workflow ends only after `fpa_artifact_commit` returns `status: committed`, an `immutable_fingerprint`, and (for assigned artifacts) a `dashboard_handoff`. Commit freezes the business artifact but does not enqueue or publish dashboard work. The calling main Agent owns that explicit handoff. A Markdown report is optional context, not the frozen source of truth. Never report a forecast as frozen on the strength of a written file alone — the file is a draft until the tool returns a fingerprint. Execution, if requested, is a separate workflow using `$fpa-execute-approved-strategy`. Review waits for the next cycle's Actuals and uses `$fpa-review-cycle`.
|
|
@@ -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.
|