@viccydev/pi-fpa 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.6.0
130
+ pi install git:github.com/linyqh/pi-fpa@v0.6.2
131
131
  ```
132
132
 
133
133
  ## 发布到 npm
134
134
 
135
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.6.0` 对应 `v0.6.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
135
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.6.2` 对应 `v0.6.2`。工作流会检出该标签,执行 `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
 
@@ -507,10 +507,18 @@ export async function enqueueDashboardRefresh(cwd: string, value: DashboardRefre
507
507
  const projectName = basename(projectRoot);
508
508
  const directories = await queueDirectories(projectRoot);
509
509
  const now = new Date().toISOString();
510
+ const active = request.forecast_role === "next_plan" ? await activeSubscription(directories) : undefined;
511
+ if (request.forecast_role === "next_plan" && (
512
+ !active
513
+ || active.project_name !== projectName
514
+ || active.scope_id !== request.scope_id
515
+ || active.subscription_id === subscriptionIdentity(projectName, request.scope_id, request.cycle_id)
516
+ )) {
517
+ throw new Error("Next-plan dashboard refresh requires an active current-cycle subscription in the same project and scope.");
518
+ }
510
519
  const subscription = await persistSubscription(directories, projectName, request, now);
511
520
  if (request.forecast_role === "next_plan") {
512
- const active = await activeSubscription(directories);
513
- if (active && active.project_name === projectName && active.scope_id === request.scope_id && active.subscription_id !== subscription.subscription_id) {
521
+ if (active) {
514
522
  const unsortedForwardRefs = [...(active.forward_forecast_refs ?? (active.next_forecast_ref ? [active.next_forecast_ref] : [])), request.forecast_ref]
515
523
  .filter((ref, index, refs) => refs.findIndex((candidate) => candidate.entry_id === ref.entry_id) === index)
516
524
  .slice(0, 6);
@@ -153,6 +153,10 @@ function addDay(date: string): string {
153
153
  return value.toISOString().slice(0, 10);
154
154
  }
155
155
 
156
+ function canonicalTimezone(timezone: string): string {
157
+ return new Intl.DateTimeFormat("en-US", { timeZone: timezone }).resolvedOptions().timeZone;
158
+ }
159
+
156
160
  function approximatelyEqual(left: number, right: number): boolean {
157
161
  return Math.abs(left - right) <= Math.max(0.000001, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
158
162
  }
@@ -209,7 +213,7 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
209
213
  const receipt = input.execution === undefined ? undefined : execution(input.execution, current);
210
214
  const next = input.next_forecast === undefined ? undefined : forecast(input.next_forecast, "next_forecast");
211
215
  if (next && (Date.parse(next.target_period.start_inclusive) !== Date.parse(current.target_period.end_exclusive)
212
- || next.target_period.timezone !== current.target_period.timezone)) {
216
+ || canonicalTimezone(next.target_period.timezone) !== canonicalTimezone(current.target_period.timezone))) {
213
217
  throw new Error("next_forecast target period must be the exact successor of the current cycle in the same timezone.");
214
218
  }
215
219
  if (next && next.reporting_currency !== current.reporting_currency) throw new Error("next_forecast reporting_currency must match current_forecast.");
@@ -32,10 +32,23 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
32
32
  label: "FP&A Dashboard Status",
33
33
  description: "Inspect the current tenant dashboard generation, datasets, build receipt, and diagnostics without changing files.",
34
34
  promptSnippet: "Inspect the current FP&A dashboard generation and diagnostics",
35
- parameters: Type.Object({}, { additionalProperties: false }),
35
+ parameters: Type.Object({
36
+ expected_lineage: Type.Optional(Type.Object({
37
+ forecast_role: Type.Union([Type.Literal("original"), Type.Literal("eac"), Type.Literal("next_plan")]),
38
+ forecast_ref: Type.String({ pattern: "^[a-f0-9]{64}$" }),
39
+ }, { additionalProperties: false })),
40
+ require_lineage_match: Type.Optional(Type.Boolean()),
41
+ }, { additionalProperties: false }),
36
42
  executionMode: "parallel",
37
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
38
- return toolResult(await inspectDashboard(ctx.cwd) as unknown as Record<string, unknown>);
43
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
44
+ if (params.require_lineage_match && !params.expected_lineage) {
45
+ throw new Error("require_lineage_match requires expected_lineage.");
46
+ }
47
+ const status = await inspectDashboard(ctx.cwd, params.expected_lineage);
48
+ if (params.require_lineage_match && !status.lineage_verification?.matches) {
49
+ throw new Error(`Dashboard lineage verification failed: ${status.lineage_verification?.reason ?? "no verifiable lineage"}`);
50
+ }
51
+ return toolResult(status as unknown as Record<string, unknown>);
39
52
  },
40
53
  });
41
54
 
@@ -24,10 +24,131 @@ export interface DashboardStatus {
24
24
  projector?: DashboardProjectorProvenance;
25
25
  runtime?: DashboardProjectorRuntime;
26
26
  provenance_status?: "verified" | "legacy_missing" | "invalid";
27
+ receipt_summary?: DashboardReceiptSummary;
28
+ lineage_verification?: DashboardLineageVerification;
27
29
  widget_count: number;
28
30
  diagnostics: Array<{ target: string; message: string }>;
29
31
  }
30
32
 
33
+ export interface DashboardLineageExpectation {
34
+ forecast_role: "original" | "eac" | "next_plan";
35
+ forecast_ref: string;
36
+ }
37
+
38
+ export interface DashboardLineageVerification {
39
+ matches: boolean;
40
+ reason: string | null;
41
+ }
42
+
43
+ export interface DashboardReceiptSummary {
44
+ source: {
45
+ project_name?: string;
46
+ scope_id?: string;
47
+ cycle_id?: string;
48
+ forecast_version: string;
49
+ forecast_ref?: string;
50
+ forecast_role?: "original" | "eac" | "next_plan";
51
+ next_forecast_ref?: string;
52
+ forward_forecast_refs: string[];
53
+ execution_ref?: string;
54
+ data_as_of: string;
55
+ };
56
+ actuals: {
57
+ period_complete: boolean | null;
58
+ reconciled: boolean | null;
59
+ };
60
+ operating: {
61
+ current_forecast_version: string | null;
62
+ execution_status: string | null;
63
+ next_cycle_status: string | null;
64
+ next_forecast_version: string | null;
65
+ };
66
+ warnings: string[];
67
+ }
68
+
69
+ function record(value: unknown): Record<string, unknown> | null {
70
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
71
+ }
72
+
73
+ function optionalBoundedString(value: unknown, maxLength = 4096): string | undefined {
74
+ return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : undefined;
75
+ }
76
+
77
+ function buildReceiptSummary(receipt: Record<string, unknown>): DashboardReceiptSummary | undefined {
78
+ const source = record(receipt.source);
79
+ const forecastVersion = optionalBoundedString(source?.forecast_version, 256);
80
+ const dataAsOf = optionalBoundedString(source?.data_as_of, 256);
81
+ if (!source || !forecastVersion || !dataAsOf) return undefined;
82
+ const role = source.forecast_role;
83
+ const forecastRole = role === "original" || role === "eac" || role === "next_plan" ? role : undefined;
84
+ const forwardForecastRefs = Array.isArray(source.forward_forecast_refs)
85
+ ? source.forward_forecast_refs.filter((value): value is string => typeof value === "string" && /^[a-f0-9]{64}$/.test(value)).slice(0, 6)
86
+ : [];
87
+ const evidence = record(source.actuals_snapshot_evidence);
88
+ const projection = record(receipt.operating_projection);
89
+ const currentCycle = record(projection?.current_cycle);
90
+ const execution = record(currentCycle?.execution);
91
+ const nextCycle = record(projection?.next_cycle);
92
+ const warnings = Array.isArray(receipt.warnings)
93
+ ? receipt.warnings.filter((value): value is string => typeof value === "string" && value.length <= 4096).slice(0, 128)
94
+ : [];
95
+ return {
96
+ source: {
97
+ ...(optionalBoundedString(source.project_name, 256) ? { project_name: source.project_name as string } : {}),
98
+ ...(optionalBoundedString(source.scope_id, 256) ? { scope_id: source.scope_id as string } : {}),
99
+ ...(optionalBoundedString(source.cycle_id, 256) ? { cycle_id: source.cycle_id as string } : {}),
100
+ forecast_version: forecastVersion,
101
+ ...(optionalBoundedString(source.forecast_ref, 64) && /^[a-f0-9]{64}$/.test(source.forecast_ref as string) ? { forecast_ref: source.forecast_ref as string } : {}),
102
+ ...(forecastRole ? { forecast_role: forecastRole } : {}),
103
+ ...(optionalBoundedString(source.next_forecast_ref, 64) && /^[a-f0-9]{64}$/.test(source.next_forecast_ref as string) ? { next_forecast_ref: source.next_forecast_ref as string } : {}),
104
+ forward_forecast_refs: forwardForecastRefs,
105
+ ...(optionalBoundedString(source.execution_ref, 64) && /^[a-f0-9]{64}$/.test(source.execution_ref as string) ? { execution_ref: source.execution_ref as string } : {}),
106
+ data_as_of: dataAsOf,
107
+ },
108
+ actuals: {
109
+ period_complete: typeof evidence?.period_complete === "boolean" ? evidence.period_complete : null,
110
+ reconciled: typeof evidence?.reconciled === "boolean" ? evidence.reconciled : null,
111
+ },
112
+ operating: {
113
+ current_forecast_version: optionalBoundedString(currentCycle?.forecast_version, 256) ?? null,
114
+ execution_status: optionalBoundedString(execution?.status, 256) ?? null,
115
+ next_cycle_status: optionalBoundedString(nextCycle?.status, 256) ?? null,
116
+ next_forecast_version: optionalBoundedString(nextCycle?.forecast_version, 256) ?? null,
117
+ },
118
+ warnings,
119
+ };
120
+ }
121
+
122
+ function verifyLineage(
123
+ summary: DashboardReceiptSummary | undefined,
124
+ expectation: DashboardLineageExpectation,
125
+ ): DashboardLineageVerification {
126
+ if (!/^[a-f0-9]{64}$/.test(expectation.forecast_ref)) throw new Error("expected forecast_ref must be a SHA-256 entry id.");
127
+ if (!summary) return { matches: false, reason: "Dashboard receipt has no verifiable business lineage." };
128
+ if (expectation.forecast_role === "next_plan") {
129
+ if (summary.source.forecast_ref === expectation.forecast_ref) {
130
+ return { matches: false, reason: "Expected next-plan forecast is published as the current forecast." };
131
+ }
132
+ if (summary.source.next_forecast_ref !== expectation.forecast_ref) {
133
+ return { matches: false, reason: "Expected next-plan forecast is not linked as next_forecast_ref." };
134
+ }
135
+ if (!summary.source.forward_forecast_refs.includes(expectation.forecast_ref)) {
136
+ return { matches: false, reason: "Expected next-plan forecast is not present in forward_forecast_refs." };
137
+ }
138
+ if (summary.operating.next_cycle_status !== "ready" && summary.operating.next_cycle_status !== "ready_with_limits") {
139
+ return { matches: false, reason: "Expected next-plan forecast is linked but the next cycle is not ready." };
140
+ }
141
+ return { matches: true, reason: null };
142
+ }
143
+ if (summary.source.forecast_ref !== expectation.forecast_ref) {
144
+ return { matches: false, reason: "Expected forecast is not published as the current forecast." };
145
+ }
146
+ if (summary.source.forecast_role !== expectation.forecast_role) {
147
+ return { matches: false, reason: "Published current forecast role does not match the expected role." };
148
+ }
149
+ return { matches: true, reason: null };
150
+ }
151
+
31
152
  async function readJson(path: string, maxBytes: number): Promise<unknown> {
32
153
  const stat = await lstat(path);
33
154
  if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("must be a regular file, not a symlink");
@@ -35,7 +156,7 @@ async function readJson(path: string, maxBytes: number): Promise<unknown> {
35
156
  return JSON.parse(await readFile(path, "utf8"));
36
157
  }
37
158
 
38
- export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
159
+ export async function inspectDashboard(cwd: string, expectation?: DashboardLineageExpectation): Promise<DashboardStatus> {
39
160
  const dashboardDir = await resolveDashboardDir(cwd);
40
161
  const diagnostics: DashboardStatus["diagnostics"] = [];
41
162
  let manifest: unknown;
@@ -63,6 +184,7 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
63
184
  let projector: DashboardProjectorProvenance | undefined;
64
185
  let runtime: DashboardProjectorRuntime | undefined;
65
186
  let provenanceStatus: DashboardStatus["provenance_status"];
187
+ let receiptSummary: DashboardReceiptSummary | undefined;
66
188
  const receiptFilename = typeof source.buildReceipt === "string" && DATASET_FILE_RE.test(source.buildReceipt)
67
189
  ? source.buildReceipt
68
190
  : "build-receipt.json";
@@ -75,6 +197,7 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
75
197
  if (receiptSource.kind !== "fpa.dashboard.build" || receiptSource.schema_version !== 1) {
76
198
  throw new Error("has an unsupported build receipt kind or schema");
77
199
  }
200
+ receiptSummary = buildReceiptSummary(receiptSource);
78
201
  if (receiptSource.projector === undefined && receiptSource.runtime === undefined) {
79
202
  provenanceStatus = "legacy_missing";
80
203
  } else {
@@ -199,6 +322,8 @@ export async function inspectDashboard(cwd: string): Promise<DashboardStatus> {
199
322
  ...(projector ? { projector } : {}),
200
323
  ...(runtime ? { runtime } : {}),
201
324
  ...(provenanceStatus ? { provenance_status: provenanceStatus } : {}),
325
+ ...(receiptSummary ? { receipt_summary: receiptSummary } : {}),
326
+ ...(expectation ? { lineage_verification: verifyLineage(receiptSummary, expectation) } : {}),
202
327
  widget_count: source.widgets.length,
203
328
  diagnostics,
204
329
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",