@viccydev/pi-fpa 0.9.1 → 0.9.3

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
@@ -159,12 +159,12 @@ pi list
159
159
  团队分发建议使用固定 Git tag:
160
160
 
161
161
  ```bash
162
- pi install git:github.com/linyqh/pi-fpa@v0.9.1
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.3
163
163
  ```
164
164
 
165
165
  ## 发布到 npm
166
166
 
167
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.1` 对应 `v0.9.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.3` 对应 `v0.9.3`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
168
168
 
169
169
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
170
170
 
@@ -11,6 +11,7 @@ import {
11
11
  } from "../fpa-artifacts/store.ts";
12
12
  import { dashboardBuildFingerprint, resolveDashboardDir } from "./publisher.ts";
13
13
  import { publishDashboardProjection } from "./compat-publisher.ts";
14
+ import { readDashboardModuleManifest } from "./module-publisher.ts";
14
15
  import { buildDashboardProjection } from "./service.ts";
15
16
 
16
17
  const EVENT_MAX_BYTES = 64 * 1024;
@@ -178,6 +179,13 @@ export interface DashboardCoordinatorStatus {
178
179
  scope_id: string | null;
179
180
  cycle_id: string | null;
180
181
  generation_id: string | null;
182
+ /**
183
+ * The modular manifest's current `dashboardRevision`, read from disk rather
184
+ * than from the subscription. `generation_id` only ever identifies a legacy
185
+ * whole-dashboard generation, so a consumer on a v2 manifest has nothing in
186
+ * the same hash space to compare against without this.
187
+ */
188
+ dashboard_revision: string | null;
181
189
  pending: number;
182
190
  processing: number;
183
191
  dead: number;
@@ -384,6 +392,13 @@ async function writeCoordinatorStatus(
384
392
  await ensureDirectory(dashboardDir, "Dashboard directory");
385
393
  const previous = await readCoordinatorStatus(cwd);
386
394
  const queue = options.queue ?? await inspectDashboardRefreshQueue(cwd);
395
+ // Read the revision off the manifest instead of tracking it in the
396
+ // subscription: every publish path (legacy refresh, module publish,
397
+ // strategy transition) rewrites the manifest, so this is the one value that
398
+ // is correct no matter who wrote last.
399
+ const dashboardRevision = await readDashboardModuleManifest(cwd)
400
+ .then((manifest) => manifest?.dashboardRevision ?? null)
401
+ .catch(() => previous?.dashboard_revision ?? null);
387
402
  const status: DashboardCoordinatorStatus = {
388
403
  kind: "fpa.dashboard.coordinator-status",
389
404
  schema_version: 1,
@@ -397,6 +412,7 @@ async function writeCoordinatorStatus(
397
412
  scope_id: options.subscription?.scope_id ?? null,
398
413
  cycle_id: options.subscription?.cycle_id ?? null,
399
414
  generation_id: options.subscription?.last_generation_id ?? null,
415
+ dashboard_revision: dashboardRevision,
400
416
  pending: queue.pending,
401
417
  processing: queue.processing,
402
418
  dead: queue.dead,
@@ -6,7 +6,14 @@ import { stableJson } from "../fpa-artifacts/store.ts";
6
6
  import type { DashboardWidget } from "./projector.ts";
7
7
  import { validateDatasetForWidget } from "./schema.ts";
8
8
 
9
- export type DashboardModuleId = "period-review" | "next-strategy" | "next-forecast" | "execution-evidence";
9
+ export type DashboardModuleId =
10
+ | "portfolio-overview"
11
+ | "period-review"
12
+ | "next-strategy"
13
+ | "next-forecast"
14
+ | "forecast-accuracy"
15
+ | "decision-ledger"
16
+ | "execution-evidence";
10
17
  export type DashboardModuleStatus = "published" | "awaiting_decision" | "approved" | "changes_requested" | "generating" | "superseded";
11
18
 
12
19
  export interface DashboardModuleInteraction {
@@ -20,6 +27,14 @@ export interface DashboardModuleBuild {
20
27
  status: DashboardModuleStatus;
21
28
  source: Record<string, unknown>;
22
29
  widgets: DashboardWidget[];
30
+ /**
31
+ * Decision limits the projector found while building this module — an
32
+ * unverified execution receipt, approved slices with no Actuals, an
33
+ * incomplete close. They belong to the module they were computed for, so
34
+ * the consumer can show them next to the numbers they qualify instead of
35
+ * pooling every module's caveats into one banner nobody reads.
36
+ */
37
+ warnings?: string[];
23
38
  interaction?: DashboardModuleInteraction;
24
39
  }
25
40
 
@@ -64,7 +79,15 @@ export interface DashboardManifestV2 {
64
79
  modules: Partial<Record<DashboardModuleId, DashboardModuleManifestEntry>>;
65
80
  }
66
81
 
67
- const MODULE_IDS: DashboardModuleId[] = ["period-review", "next-strategy", "next-forecast", "execution-evidence"];
82
+ const MODULE_IDS: DashboardModuleId[] = [
83
+ "portfolio-overview",
84
+ "period-review",
85
+ "next-strategy",
86
+ "next-forecast",
87
+ "forecast-accuracy",
88
+ "decision-ledger",
89
+ "execution-evidence",
90
+ ];
68
91
  const MODULE_ID_SET = new Set<string>(MODULE_IDS);
69
92
  const DATASET_FILE_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,191}\.json$/;
70
93
 
@@ -136,6 +159,12 @@ function assertModule(module: DashboardModuleBuild): void {
136
159
  if (!MODULE_ID_SET.has(module.id)) throw new Error(`Unsupported dashboard module id "${module.id}".`);
137
160
  if (!module.title?.trim()) throw new Error("Dashboard module title is required.");
138
161
  if (!Array.isArray(module.widgets) || module.widgets.length === 0 || module.widgets.length > 64) throw new Error("Dashboard module must contain between 1 and 64 widgets.");
162
+ if (module.warnings !== undefined) {
163
+ if (!Array.isArray(module.warnings) || module.warnings.length > 128) throw new Error("Dashboard module warnings must be an array of at most 128 entries.");
164
+ for (const warning of module.warnings) {
165
+ if (typeof warning !== "string" || warning.length > 4_096) throw new Error("Each dashboard module warning must be a string of at most 4096 characters.");
166
+ }
167
+ }
139
168
  if (module.interaction && (module.id !== "next-strategy" || module.status !== "awaiting_decision")) {
140
169
  throw new Error("Strategy decisions are available only on an awaiting next-strategy module.");
141
170
  }
@@ -209,6 +238,9 @@ async function normalizeLegacyModuleReceipt(
209
238
  if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Legacy dashboard build receipt must be a regular file.");
210
239
  const old = JSON.parse(await readFile(sourcePath, "utf8")) as { source?: unknown; warnings?: unknown; datasets?: unknown };
211
240
  if (!Array.isArray(old.datasets)) throw new Error("Legacy dashboard build receipt datasets are invalid.");
241
+ // The legacy generation receipt carried warnings at the top level too, so
242
+ // they migrate straight across rather than being buried inside `source`.
243
+ const legacyWarnings = Array.isArray(old.warnings) ? old.warnings.filter((entry): entry is string => typeof entry === "string") : [];
212
244
  const receipt = {
213
245
  kind: "fpa.dashboard.module.build",
214
246
  schema_version: 1,
@@ -216,7 +248,8 @@ async function normalizeLegacyModuleReceipt(
216
248
  module_revision: legacy.revision,
217
249
  published_at: legacy.updatedAt,
218
250
  status: legacy.status,
219
- source: { ...(old.source && typeof old.source === "object" ? old.source : {}), warnings: old.warnings ?? [], legacy_generation_id: legacy.revision },
251
+ source: { ...(old.source && typeof old.source === "object" ? old.source : {}), legacy_generation_id: legacy.revision },
252
+ warnings: legacyWarnings,
220
253
  datasets: old.datasets,
221
254
  };
222
255
  const contents = `${JSON.stringify(receipt, null, 2)}\n`;
@@ -290,6 +323,7 @@ export async function publishDashboardModule(options: PublishDashboardModuleOpti
290
323
  published_at: publishedAt,
291
324
  status: options.module.status,
292
325
  source: options.module.source,
326
+ warnings: options.module.warnings ?? [],
293
327
  datasets: datasets.map(({ widget, filename, digest }) => ({ logical: widget.dataset, filename, sha256: digest })),
294
328
  };
295
329
  const receiptContents = `${JSON.stringify(receipt, null, 2)}\n`;
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
 
3
3
  import { stableJson } from "../fpa-artifacts/store.ts";
4
4
  import type { DashboardModuleBuild } from "./module-publisher.ts";
5
- import type { TableCell } from "./projector.ts";
5
+ import type { TableCell, TableDataset } from "./projector.ts";
6
6
  import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
7
7
  import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
8
8
 
@@ -56,10 +56,80 @@ function strategyAction(value: unknown, label: string): StrategyAction {
56
56
  throw new Error(`${label} must be one of grow, hold, cut, stop.`);
57
57
  }
58
58
 
59
+ function signedPercent(value: unknown): TableCell {
60
+ if (typeof value !== "number" || !Number.isFinite(value)) return cell(value);
61
+ return `${value > 0 ? "+" : ""}${(value * 100).toFixed(1)}%`;
62
+ }
63
+
64
+ /** `metric` repeats across scopes, so the scope is what makes a headline row identifiable. */
65
+ function headlineLabel(entry: Record<string, unknown>, index: number): string {
66
+ const metric = requiredString(entry.metric ?? `Metric ${index + 1}`, `driver_analysis.headline_results[${index}].metric`);
67
+ return typeof entry.scope === "string" && entry.scope.trim() !== "" ? `${metric} · ${entry.scope}` : metric;
68
+ }
69
+
70
+ /**
71
+ * The `driver_analysis` contract publishes `headline_results` as a list of metric rows
72
+ * carrying actual/baseline/variance. Earlier artifacts used a flat `{ metric: value }`
73
+ * map, so both shapes are projected here rather than rejecting the contract shape.
74
+ */
75
+ function headlineTable(value: unknown): TableDataset {
76
+ if (Array.isArray(value)) {
77
+ return {
78
+ label: "核心结果",
79
+ columns: [
80
+ { key: "metric", label: "指标" },
81
+ { key: "actual", label: "实际", align: "right" },
82
+ { key: "baseline", label: "基准", align: "right" },
83
+ { key: "variance", label: "变化", align: "right" },
84
+ { key: "variance_pct", label: "变化率", align: "right" },
85
+ ],
86
+ rows: value.map((item, index) => {
87
+ const entry = record(item, `driver_analysis.headline_results[${index}]`);
88
+ return {
89
+ metric: headlineLabel(entry, index),
90
+ actual: cell(entry.actual),
91
+ baseline: cell(entry.baseline),
92
+ variance: cell(entry.variance),
93
+ variance_pct: signedPercent(entry.variance_pct),
94
+ };
95
+ }),
96
+ };
97
+ }
98
+ const headline = record(value ?? {}, "driver_analysis.headline_results");
99
+ return {
100
+ label: "核心结果",
101
+ columns: [{ key: "metric", label: "指标" }, { key: "value", label: "结果", align: "right" }],
102
+ rows: Object.entries(headline).map(([metric, entry]) => ({ metric, value: cell(entry) })),
103
+ };
104
+ }
105
+
106
+ /** Evidence is a list in the contract; a bare JSON dump of it is unreadable in a table cell. */
107
+ function listCell(value: unknown): TableCell {
108
+ return Array.isArray(value) ? lines(value) : cell(value);
109
+ }
110
+
111
+ /**
112
+ * `data_limits` is the contract field and `limitations` is what older artifacts wrote.
113
+ * They are merged instead of ranked so a published module never silently drops one set.
114
+ */
115
+ function limitationRows(analysis: Record<string, unknown>): Array<Record<string, TableCell>> {
116
+ const seen = new Set<string>();
117
+ const rows: Array<Record<string, TableCell>> = [];
118
+ for (const key of ["data_limits", "limitations", "risks"] as const) {
119
+ if (analysis[key] === undefined) continue;
120
+ for (const item of array(analysis[key], `driver_analysis.${key}`)) {
121
+ const dedupe = stableJson(item);
122
+ if (seen.has(dedupe)) continue;
123
+ seen.add(dedupe);
124
+ rows.push({ item: cell(item) });
125
+ }
126
+ }
127
+ return rows;
128
+ }
129
+
59
130
  export function projectReviewModule(input: unknown): DashboardModuleBuild {
60
131
  const analysis = record(input, "driver_analysis");
61
132
  if (analysis.artifact_type !== "driver_analysis") throw new Error("Review publication requires artifact_type=driver_analysis.");
62
- const headline = record(analysis.headline_results ?? {}, "driver_analysis.headline_results");
63
133
  const drivers = array(analysis.drivers ?? [], "driver_analysis.drivers");
64
134
  return {
65
135
  id: "period-review",
@@ -75,25 +145,25 @@ export function projectReviewModule(input: unknown): DashboardModuleBuild {
75
145
  {
76
146
  id: "review-headline",
77
147
  type: "table",
78
- span: "half",
148
+ span: "full",
79
149
  dataset: "review-headline.json",
80
- data: {
81
- label: "核心结果",
82
- columns: [{ key: "metric", label: "指标" }, { key: "value", label: "结果", align: "right" }],
83
- rows: Object.entries(headline).map(([metric, value]) => ({ metric, value: cell(value) })),
84
- },
150
+ data: headlineTable(analysis.headline_results),
85
151
  },
86
152
  {
87
153
  id: "review-drivers",
88
154
  type: "table",
89
- span: "half",
155
+ span: "full",
90
156
  dataset: "review-drivers.json",
91
157
  data: {
92
158
  label: "关键驱动",
93
- columns: [{ key: "driver", label: "驱动" }, { key: "impact", label: "影响" }, { key: "evidence", label: "证据" }],
159
+ columns: [{ key: "driver", label: "驱动" }, { key: "impact", label: "影响", align: "right" }, { key: "evidence", label: "证据" }],
94
160
  rows: drivers.map((item, index) => {
95
161
  const driver = record(item, `driver_analysis.drivers[${index}]`);
96
- return { driver: cell(driver.driver ?? driver.name ?? `Driver ${index + 1}`), impact: cell(driver.impact ?? driver.effect), evidence: cell(driver.evidence ?? driver.evidence_ids) };
162
+ return {
163
+ driver: cell(driver.driver ?? driver.name ?? `Driver ${index + 1}`),
164
+ impact: cell(driver.impact ?? driver.effect ?? driver.quantified_contribution),
165
+ evidence: listCell(driver.evidence ?? driver.evidence_ids),
166
+ };
97
167
  }),
98
168
  },
99
169
  },
@@ -105,7 +175,7 @@ export function projectReviewModule(input: unknown): DashboardModuleBuild {
105
175
  data: {
106
176
  label: "限制与风险",
107
177
  columns: [{ key: "item", label: "说明" }],
108
- rows: array(analysis.limitations ?? analysis.risks ?? [], "driver_analysis.limitations").map((item) => ({ item: cell(item) })),
178
+ rows: limitationRows(analysis),
109
179
  },
110
180
  },
111
181
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
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",
@@ -19,17 +19,20 @@
19
19
  "engines": {
20
20
  "node": ">=22.19"
21
21
  },
22
+ "fpaDashboard": {
23
+ "contractVersion": 1
24
+ },
22
25
  "files": [
23
26
  "README.md",
24
- "bin",
25
- "graphs",
27
+ "bin",
28
+ "graphs",
26
29
  "prompts",
27
30
  "skills",
28
31
  "extensions"
29
32
  ],
30
- "bin": {
31
- "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
32
- },
33
+ "bin": {
34
+ "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
35
+ },
33
36
  "scripts": {
34
37
  "test": "node tests/package-structure.test.mjs && node tests/workflow-routing.test.mjs && node --test tests/workflow-routing-guard.test.mjs tests/graph-contract.test.mjs tests/graph-installer.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/artifact-ledger.test.mjs tests/artifact-handoff.test.mjs tests/forecast-compose.test.mjs tests/forecast-finalize.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-module-publisher.test.mjs tests/dashboard-stage-projector.test.mjs tests/strategy-decision.test.mjs tests/cycle-operating-projection.test.mjs tests/forward-outlook.test.mjs tests/forecast-accuracy.test.mjs tests/dashboard-publisher.test.mjs tests/dashboard-provenance.test.mjs tests/dashboard-coordinator.test.mjs && node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
35
38
  "test:structure": "node tests/package-structure.test.mjs",
@@ -59,7 +62,7 @@
59
62
  "./skills"
60
63
  ],
61
64
  "extensions": [
62
- "./extensions/fpa-routing-guard/index.ts",
65
+ "./extensions/fpa-routing-guard/index.ts",
63
66
  "./extensions/fpa-data/index.ts",
64
67
  "./extensions/fpa-artifacts/index.ts",
65
68
  "./extensions/fpa-dashboard/index.ts"
@@ -8,6 +8,7 @@ actuals_snapshot_id: string
8
8
  comparison_basis: string
9
9
  headline_results:
10
10
  - metric: string
11
+ scope: string | absent # required when the same metric is reported for more than one scope
11
12
  actual: number | null
12
13
  baseline: number | null
13
14
  variance: number | null