@viccydev/pi-fpa 0.9.3 → 0.9.5

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.3
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.5
163
163
  ```
164
164
 
165
165
  ## 发布到 npm
166
166
 
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`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.5` 对应 `v0.9.5`。工作流会检出该标签,执行 `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
 
@@ -76,12 +76,34 @@ if (args.includes("--once")) {
76
76
  if (!abortController.signal.aborted) throw error;
77
77
  }
78
78
  } else {
79
+ // A failure that persists — a missing ledger, an artifact a subscription
80
+ // still references — repeats on every tick. Reprinting its stack every
81
+ // interval buries everything else in the log without adding information, so
82
+ // an unchanged failure is reported once in full and then only counted.
83
+ let lastFailure = null;
84
+ let repeatCount = 0;
79
85
  while (!stopping) {
80
86
  try {
81
87
  await runOnce();
88
+ if (lastFailure) {
89
+ process.stderr.write(`${new Date().toISOString()} recovered after ${repeatCount + 1} consecutive failure(s)\n`);
90
+ lastFailure = null;
91
+ repeatCount = 0;
92
+ }
82
93
  } catch (error) {
83
94
  if (abortController.signal.aborted) break;
84
- process.stderr.write(`${new Date().toISOString()} ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ if (message === lastFailure) {
97
+ repeatCount += 1;
98
+ // Back off the log, not the work: still retried every interval.
99
+ if (repeatCount === 1 || repeatCount % 20 === 0) {
100
+ process.stderr.write(`${new Date().toISOString()} still failing (${repeatCount + 1}x): ${message}\n`);
101
+ }
102
+ } else {
103
+ lastFailure = message;
104
+ repeatCount = 0;
105
+ process.stderr.write(`${new Date().toISOString()} ${error instanceof Error ? error.stack ?? message : message}\n`);
106
+ }
85
107
  }
86
108
  if (!stopping) await new Promise((resolveWait) => {
87
109
  const timer = setTimeout(() => {
@@ -144,7 +144,21 @@ async function existingLedgerDirectories(projectRoot: string): Promise<{ root: s
144
144
  const objects = join(root, "objects");
145
145
  const entries = join(root, "entries");
146
146
  for (const [path, label] of [[root, ".ledger"], [objects, ".ledger/objects"], [entries, ".ledger/entries"]] as const) {
147
- const stat = await lstat(path);
147
+ let stat;
148
+ try {
149
+ stat = await lstat(path);
150
+ } catch (error) {
151
+ // A project with no ledger is a state a caller can act on — the
152
+ // artifacts were never committed through it, or the directory was
153
+ // removed out from under a subscription that still holds refs.
154
+ // Letting a raw ENOENT escape turns that into an unreadable stack
155
+ // trace in the coordinator's last_error, which the operator then
156
+ // has to reverse-engineer.
157
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
158
+ throw new Error(`This project has no artifact ledger (${label} is missing), so artifacts cannot be resolved by ref. Commit an artifact through the ledger before referencing one.`);
159
+ }
160
+ throw error;
161
+ }
148
162
  if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} must be a regular directory, not a symlink.`);
149
163
  }
150
164
  return { root, objects, entries };
@@ -0,0 +1,323 @@
1
+ import type { DashboardModuleBuild } from "./module-publisher.ts";
2
+ import type { DashboardWidget, TableCell, TableDataset } from "./projector.ts";
3
+ import type { DecisionEvent, DecisionPackage } from "./decision-package.ts";
4
+
5
+ // ============================================================================
6
+ // The decision ledger module.
7
+ //
8
+ // Answers "where did the approved decisions get to" — the question the
9
+ // dashboard could not answer at all before, because confirming a strategy left
10
+ // nothing behind to track.
11
+ //
12
+ // Spend is not computed here. It arrives measured, from the execution receipt
13
+ // or the actuals source, and stays null when nothing has measured it yet. A
14
+ // decision showing $0 spent looks like a decision nobody acted on, which is a
15
+ // very different thing from one nobody has reported on.
16
+ // ============================================================================
17
+
18
+ const EVENT_TYPE_LABELS = {
19
+ approved: "批准",
20
+ parameters_locked: "参数锁定",
21
+ task_accepted: "任务接受",
22
+ task_completed: "任务完成",
23
+ parameter_adjusted: "参数调整",
24
+ threshold_breached: "阈值触发",
25
+ closed: "关闭",
26
+ } as const;
27
+
28
+ const TASK_STATUS_LABELS = {
29
+ not_started: "未开始",
30
+ in_progress: "进行中",
31
+ completed: "已完成",
32
+ adjusted: "已调整",
33
+ synced: "已同步",
34
+ configured: "已配置",
35
+ not_completed: "未完成",
36
+ } as const;
37
+
38
+ export interface DecisionSpend {
39
+ decision_id: string;
40
+ /** Measured spend to date, or null when nothing has measured it. */
41
+ actual_spend_usd: number | null;
42
+ /** 0..1 execution progress, or null when unknown. */
43
+ progress?: number | null;
44
+ /** Where the measurement came from, shown as evidence. */
45
+ source?: string;
46
+ }
47
+
48
+ export interface DecisionLedgerInput {
49
+ packages: DecisionPackage[];
50
+ events: Record<string, DecisionEvent[]>;
51
+ spend?: DecisionSpend[];
52
+ locale?: string;
53
+ }
54
+
55
+ export interface DecisionLedgerProjection {
56
+ module: DashboardModuleBuild;
57
+ /** Decisions whose measured spend has passed their approved threshold. */
58
+ breaches: Array<{ decision_id: string; title: string; over_pct: number }>;
59
+ }
60
+
61
+ function currency(value: number | null, code: string, locale: string): string | null {
62
+ if (value === null) return null;
63
+ return new Intl.NumberFormat(locale, { style: "currency", currency: code, maximumFractionDigits: 0 }).format(value);
64
+ }
65
+
66
+ function percent(value: number | null, locale: string): string | null {
67
+ if (value === null) return null;
68
+ return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(value);
69
+ }
70
+
71
+ function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
72
+ return tone ? { value, tone } : value;
73
+ }
74
+
75
+ /**
76
+ * Over-budget fraction, or null when it cannot be computed.
77
+ *
78
+ * Returns null rather than 0 when the budget is missing or zero: "we cannot
79
+ * tell whether this is over budget" must not render as "this is exactly on
80
+ * budget", which is what a 0 here would claim.
81
+ */
82
+ function overBudgetFraction(pkg: DecisionPackage, spend: number | null): number | null {
83
+ if (spend === null || pkg.approved_budget_usd === null || pkg.approved_budget_usd <= 0) return null;
84
+ return spend / pkg.approved_budget_usd - 1;
85
+ }
86
+
87
+ function summaryTable(
88
+ packages: DecisionPackage[],
89
+ spendById: Map<string, DecisionSpend>,
90
+ locale: string,
91
+ ): TableDataset {
92
+ return {
93
+ label: "决策归档包",
94
+ description: "已批准决策的批准预算、实际支出与执行进度",
95
+ columns: [
96
+ { key: "decision", label: "决策包" },
97
+ { key: "version", label: "版本" },
98
+ { key: "approver", label: "批准人" },
99
+ { key: "owner", label: "负责人" },
100
+ { key: "budget", label: "批准预算", align: "right" },
101
+ { key: "actual", label: "实际支出", align: "right" },
102
+ { key: "variance", label: "偏差", align: "right" },
103
+ { key: "progress", label: "执行进度", align: "right" },
104
+ { key: "status", label: "状态" },
105
+ ],
106
+ rows: packages.map((pkg) => {
107
+ const spend = spendById.get(pkg.decision_id);
108
+ const actual = spend?.actual_spend_usd ?? null;
109
+ const over = overBudgetFraction(pkg, actual);
110
+ const breached = over !== null && over > pkg.budget_threshold_pct;
111
+ return {
112
+ decision: `${pkg.decision_id} · ${pkg.title}`,
113
+ version: pkg.version,
114
+ approver: pkg.approver_role,
115
+ owner: pkg.owner,
116
+ budget: currency(pkg.approved_budget_usd, pkg.reporting_currency, locale),
117
+ actual: currency(actual, pkg.reporting_currency, locale),
118
+ variance: toned(
119
+ over === null ? null : `${over > 0 ? "+" : ""}${percent(over, locale)}`,
120
+ over === null ? undefined : breached ? "negative" : over > 0 ? "warning" : "positive",
121
+ ),
122
+ progress: spend?.progress === null || spend?.progress === undefined ? null : percent(spend.progress, locale),
123
+ status: toned(
124
+ breached ? "超阈值" : spend?.progress === 1 ? "已完成" : "执行中",
125
+ breached ? "negative" : spend?.progress === 1 ? "positive" : "neutral",
126
+ ),
127
+ };
128
+ }),
129
+ };
130
+ }
131
+
132
+ function approvalsTable(packages: DecisionPackage[]): TableDataset {
133
+ return {
134
+ label: "审批留痕",
135
+ description: "每个决策包的审批链与时间戳",
136
+ columns: [
137
+ { key: "decision", label: "决策包" },
138
+ { key: "step", label: "步骤", align: "right" },
139
+ { key: "role", label: "角色" },
140
+ { key: "approver", label: "审批人" },
141
+ { key: "action", label: "动作" },
142
+ { key: "at", label: "时间" },
143
+ { key: "comment", label: "备注" },
144
+ ],
145
+ rows: packages.flatMap((pkg) =>
146
+ pkg.approvals.map((approval) => ({
147
+ decision: pkg.decision_id,
148
+ step: String(approval.step_order),
149
+ role: approval.role,
150
+ approver: approval.approver,
151
+ action: approval.action === "approved" ? "批准" : approval.action === "acknowledged" ? "确认" : "驳回",
152
+ at: approval.decided_at,
153
+ comment: approval.comment ?? null,
154
+ })),
155
+ ),
156
+ };
157
+ }
158
+
159
+ function tasksTable(packages: DecisionPackage[]): TableDataset {
160
+ return {
161
+ label: "执行任务清单",
162
+ columns: [
163
+ { key: "decision", label: "决策包" },
164
+ { key: "task", label: "任务" },
165
+ { key: "owner", label: "负责人" },
166
+ { key: "due", label: "截止" },
167
+ { key: "status", label: "状态" },
168
+ ],
169
+ rows: packages.flatMap((pkg) =>
170
+ pkg.tasks.map((task) => ({
171
+ decision: pkg.decision_id,
172
+ task: task.task_name,
173
+ owner: task.owner,
174
+ due: task.due_date,
175
+ status: toned(
176
+ TASK_STATUS_LABELS[task.status],
177
+ task.status === "completed" ? "positive" : task.status === "not_completed" ? "negative" : "neutral",
178
+ ),
179
+ })),
180
+ ),
181
+ };
182
+ }
183
+
184
+ function auditTable(packages: DecisionPackage[], events: Record<string, DecisionEvent[]>): TableDataset {
185
+ const rows = packages.flatMap((pkg) =>
186
+ (events[pkg.decision_id] ?? []).map((event) => ({
187
+ at: event.event_at,
188
+ decision: pkg.decision_id,
189
+ actor: `${event.actor}${event.actor_type === "system" ? " (系统)" : ""}`,
190
+ type: toned(
191
+ EVENT_TYPE_LABELS[event.event_type],
192
+ event.event_type === "threshold_breached" ? "negative" : event.event_type === "approved" ? "positive" : "neutral",
193
+ ),
194
+ summary: event.summary,
195
+ reason: event.reason ?? null,
196
+ detail: event.change_detail ?? null,
197
+ })),
198
+ );
199
+ return {
200
+ label: "审计日志",
201
+ description: "谁在什么时候、因为什么、改了什么",
202
+ columns: [
203
+ { key: "at", label: "时间" },
204
+ { key: "decision", label: "决策包" },
205
+ { key: "actor", label: "操作者" },
206
+ { key: "type", label: "事件" },
207
+ { key: "summary", label: "说明" },
208
+ { key: "reason", label: "原因" },
209
+ { key: "detail", label: "变更" },
210
+ ],
211
+ // Newest first: the audit log is read to answer "what just happened".
212
+ rows: rows.sort((left, right) => String(right.at).localeCompare(String(left.at))),
213
+ };
214
+ }
215
+
216
+ export function projectDecisionLedger(input: DecisionLedgerInput): DecisionLedgerProjection {
217
+ const locale = input.locale ?? "zh-CN";
218
+ const packages = input.packages;
219
+ const spendById = new Map((input.spend ?? []).map((entry) => [entry.decision_id, entry]));
220
+ const warnings: string[] = [];
221
+
222
+ const breaches: DecisionLedgerProjection["breaches"] = [];
223
+ let unmeasured = 0;
224
+ for (const pkg of packages) {
225
+ const actual = spendById.get(pkg.decision_id)?.actual_spend_usd ?? null;
226
+ if (actual === null) {
227
+ unmeasured += 1;
228
+ continue;
229
+ }
230
+ const over = overBudgetFraction(pkg, actual);
231
+ if (over !== null && over > pkg.budget_threshold_pct) {
232
+ breaches.push({ decision_id: pkg.decision_id, title: pkg.title, over_pct: over });
233
+ }
234
+ }
235
+
236
+ // The limits belong to this module, next to the numbers they qualify.
237
+ if (unmeasured > 0) {
238
+ warnings.push(`${unmeasured} 个决策包尚无实际支出计量,其偏差与状态显示为缺失而非零。`);
239
+ }
240
+ for (const breach of breaches) {
241
+ warnings.push(`${breach.decision_id}「${breach.title}」实际支出超批准预算 ${(breach.over_pct * 100).toFixed(1)}%,已超过其阈值。`);
242
+ }
243
+ const missingBudget = packages.filter((pkg) => pkg.approved_budget_usd === null);
244
+ if (missingBudget.length > 0) {
245
+ warnings.push(`${missingBudget.length} 个决策包没有可追踪的批准预算,无法判断是否超支。`);
246
+ }
247
+
248
+ const measured = packages.length - unmeasured;
249
+ const widgets: DashboardWidget[] = [
250
+ {
251
+ id: "decision-active-count",
252
+ type: "stat",
253
+ span: "quarter",
254
+ dataset: "decision-active-count.json",
255
+ data: {
256
+ label: "进行中决策",
257
+ value: String(packages.filter((pkg) => (spendById.get(pkg.decision_id)?.progress ?? 0) < 1).length),
258
+ description: `共 ${packages.length} 个已批准决策包`,
259
+ },
260
+ },
261
+ {
262
+ id: "decision-breach-count",
263
+ type: "stat",
264
+ span: "quarter",
265
+ dataset: "decision-breach-count.json",
266
+ data: {
267
+ label: "需关注",
268
+ value: String(breaches.length),
269
+ ...(breaches.length > 0
270
+ ? { description: breaches.map((breach) => breach.decision_id).join("、") }
271
+ : {}),
272
+ ...(measured === 0
273
+ ? { footnote: "尚无实际支出计量,此计数仅覆盖已计量的决策包。" }
274
+ : {}),
275
+ delta: breaches.length > 0
276
+ ? { direction: "up", label: `${breaches.length} 个超阈值`, sentiment: "negative" }
277
+ : { direction: "flat", label: "无超阈值", sentiment: "positive" },
278
+ },
279
+ },
280
+ {
281
+ id: "decision-measured-coverage",
282
+ type: "stat",
283
+ span: "quarter",
284
+ dataset: "decision-measured-coverage.json",
285
+ data: {
286
+ label: "支出计量覆盖",
287
+ value: packages.length === 0 ? null : `${measured}/${packages.length}`,
288
+ ...(packages.length === 0 ? { missingReason: "还没有已批准的决策包。" } : {}),
289
+ ...(packages.length > 0
290
+ ? {
291
+ progress: {
292
+ fraction: measured / packages.length,
293
+ label: percent(measured / packages.length, locale) ?? "—",
294
+ tone: measured === packages.length ? "positive" : "warning",
295
+ },
296
+ }
297
+ : {}),
298
+ },
299
+ },
300
+ { id: "decision-summary", type: "table", span: "full", dataset: "decision-summary.json", data: summaryTable(packages, spendById, locale) },
301
+ { id: "decision-tasks", type: "table", span: "full", dataset: "decision-tasks.json", data: tasksTable(packages) },
302
+ { id: "decision-approvals", type: "table", span: "full", dataset: "decision-approvals.json", data: approvalsTable(packages) },
303
+ { id: "decision-audit", type: "table", span: "full", dataset: "decision-audit.json", data: auditTable(packages, input.events) },
304
+ ];
305
+
306
+ return {
307
+ module: {
308
+ id: "decision-ledger",
309
+ title: "决策履约与审计",
310
+ status: "published",
311
+ source: {
312
+ artifact_type: "decision_package",
313
+ package_count: packages.length,
314
+ decision_ids: packages.map((pkg) => pkg.decision_id),
315
+ package_fingerprints: packages.map((pkg) => pkg.package_fingerprint),
316
+ measured_spend_count: measured,
317
+ },
318
+ warnings,
319
+ widgets,
320
+ },
321
+ breaches,
322
+ };
323
+ }
@@ -0,0 +1,437 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { link, lstat, mkdir, open, readFile, readdir, realpath, unlink } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ import { stableJson } from "../fpa-artifacts/store.ts";
6
+
7
+ // ============================================================================
8
+ // Decision packages.
9
+ //
10
+ // A confirmed strategy used to leave no trace a person could follow: the
11
+ // dashboard posted an action, the agent re-ran a Graph, and the decision
12
+ // itself stopped existing. A decision package is that decision made into an
13
+ // object with a lifecycle — what was approved, by whom, against which budget,
14
+ // and what happened afterwards.
15
+ //
16
+ // Two stores, because the data has two different natures:
17
+ //
18
+ // * The package is written once and never edited. It carries the parameter
19
+ // snapshot as it stood at approval. If this were mutable, "what was
20
+ // approved" would drift into "what we ended up doing", and the ledger
21
+ // would lose the only thing it exists to prove.
22
+ //
23
+ // * Events append. Execution progress, threshold breaches, and task
24
+ // transitions arrive over time and must never overwrite each other.
25
+ //
26
+ // Deliberately NOT an entry in the artifact ledger: that ledger holds the
27
+ // immutable forecast/execution chain, addressed by content fingerprint. A
28
+ // package accumulates history, so it would have to be re-committed on every
29
+ // event, and each re-commit would mint a new identity for the same decision.
30
+ // ============================================================================
31
+
32
+ const SHA256_RE = /^[a-f0-9]{64}$/;
33
+ const DECISION_ID_RE = /^DEC-\d{4}-\d{4}-[a-f0-9]{8}$/;
34
+ const PACKAGES_DIR = "decision-packages";
35
+
36
+ export const DECISION_STATUSES = ["in_progress", "completed", "variance", "cancelled"] as const;
37
+ export type DecisionStatus = (typeof DECISION_STATUSES)[number];
38
+
39
+ export const DECISION_EVENT_TYPES = [
40
+ "approved",
41
+ "parameters_locked",
42
+ "task_accepted",
43
+ "task_completed",
44
+ "parameter_adjusted",
45
+ "threshold_breached",
46
+ "closed",
47
+ ] as const;
48
+ export type DecisionEventType = (typeof DECISION_EVENT_TYPES)[number];
49
+
50
+ export const DECISION_TASK_STATUSES = ["not_started", "in_progress", "completed", "adjusted", "synced", "configured", "not_completed"] as const;
51
+ export type DecisionTaskStatus = (typeof DECISION_TASK_STATUSES)[number];
52
+
53
+ export interface DecisionApproval {
54
+ step_order: number;
55
+ role: string;
56
+ approver: string;
57
+ action: "approved" | "acknowledged" | "rejected";
58
+ decided_at: string;
59
+ comment?: string;
60
+ }
61
+
62
+ export interface DecisionTask {
63
+ task_id: string;
64
+ task_name: string;
65
+ owner: string;
66
+ due_date: string;
67
+ status: DecisionTaskStatus;
68
+ completed_at?: string;
69
+ }
70
+
71
+ export interface DecisionPackage {
72
+ kind: "fpa.decision.package";
73
+ schema_version: 1;
74
+ decision_id: string;
75
+ version: string;
76
+ title: string;
77
+ scope_id: string;
78
+ cycle_id: string;
79
+ strategy_version: string;
80
+ handoff_fingerprint: string;
81
+ action_id: string;
82
+ owner: string | null;
83
+ approver_role: string;
84
+ /** Null when the approved strategy carries no budget the ledger can track. */
85
+ approved_budget_usd: number | null;
86
+ /** Fraction over budget that trips an alert, e.g. 0.10 for +10%. */
87
+ budget_threshold_pct: number;
88
+ reporting_currency: string;
89
+ approved_at: string;
90
+ locked_at: string;
91
+ /** The parameters as approved. Never edited — see the note above. */
92
+ parameters: Record<string, unknown>;
93
+ approvals: DecisionApproval[];
94
+ tasks: DecisionTask[];
95
+ memo_ref: string | null;
96
+ /** Integrity over everything above. */
97
+ package_fingerprint: string;
98
+ }
99
+
100
+ export interface DecisionEvent {
101
+ kind: "fpa.decision.event";
102
+ schema_version: 1;
103
+ decision_id: string;
104
+ event_at: string;
105
+ actor: string;
106
+ actor_type: "human" | "system";
107
+ event_type: DecisionEventType;
108
+ summary: string;
109
+ reason?: string;
110
+ change_detail?: string;
111
+ estimated_impact_usd?: number;
112
+ }
113
+
114
+ /** Identifiers and titles: no control characters at all, DEL included. */
115
+ const CONTROL_CHARS = new RegExp("[\\u0000-\\u001f\\u007f]");
116
+
117
+ /**
118
+ * Free text a person wrote — an approval comment, a reason for a change.
119
+ * Newlines and tabs are content there, so only the remaining control
120
+ * characters are rejected.
121
+ */
122
+ const CONTROL_CHARS_ALLOWING_NEWLINES = new RegExp("[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f]");
123
+
124
+ function sha256(value: string): string {
125
+ return createHash("sha256").update(value).digest("hex");
126
+ }
127
+
128
+ function boundedString(value: unknown, label: string, max = 512): string {
129
+ if (typeof value !== "string" || value.trim() === "" || value.length > max || CONTROL_CHARS.test(value)) {
130
+ throw new Error(`${label} must be a non-empty bounded string without control characters.`);
131
+ }
132
+ return value;
133
+ }
134
+
135
+ function optionalBoundedString(value: unknown, label: string, max = 4_096): string | undefined {
136
+ if (value === undefined || value === null) return undefined;
137
+ if (typeof value !== "string" || value.length > max || CONTROL_CHARS_ALLOWING_NEWLINES.test(value)) {
138
+ throw new Error(`${label} must be a bounded string without control characters.`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function isoTimestamp(value: unknown, label: string): string {
144
+ const text = boundedString(value, label, 64);
145
+ if (Number.isNaN(Date.parse(text))) throw new Error(`${label} must be an ISO timestamp.`);
146
+ return text;
147
+ }
148
+
149
+ function finiteNumber(value: unknown, label: string): number {
150
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${label} must be a finite number.`);
151
+ return value;
152
+ }
153
+
154
+ function enumValue<T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
155
+ if (typeof value !== "string" || !allowed.includes(value)) {
156
+ throw new Error(`${label} must be one of ${allowed.join(", ")}.`);
157
+ }
158
+ return value as T[number];
159
+ }
160
+
161
+ async function ensureDirectory(parent: string, name: string): Promise<string> {
162
+ const path = join(parent, name);
163
+ try {
164
+ const stat = await lstat(path);
165
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
166
+ } catch (error) {
167
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
168
+ await mkdir(path, { recursive: true, mode: 0o700 });
169
+ }
170
+ return path;
171
+ }
172
+
173
+ async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<"created" | "exists"> {
174
+ const temporary = join(directory, `.${randomUUID()}.tmp`);
175
+ const handle = await open(temporary, "wx", 0o600);
176
+ try {
177
+ await handle.writeFile(contents, "utf8");
178
+ await handle.sync();
179
+ await handle.close();
180
+ try {
181
+ await link(temporary, destination);
182
+ return "created";
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
185
+ throw error;
186
+ }
187
+ } finally {
188
+ await handle.close().catch(() => undefined);
189
+ await unlink(temporary).catch(() => undefined);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * A human-readable id with a content-addressed tail.
195
+ *
196
+ * The ledger is read by people discussing a specific decision, so `DEC-2026-
197
+ * 0718-3f9a1c02` beats a bare digest in a meeting. The tail still binds the id
198
+ * to the approval it came from, so two decisions cannot collide on the same day.
199
+ */
200
+ export function decisionIdFor(input: { actionId: string; strategyVersion: string; approvedAt: string }): string {
201
+ const date = new Date(input.approvedAt);
202
+ if (Number.isNaN(date.getTime())) throw new Error("approvedAt must be an ISO timestamp.");
203
+ const year = date.getUTCFullYear();
204
+ const monthDay = `${String(date.getUTCMonth() + 1).padStart(2, "0")}${String(date.getUTCDate()).padStart(2, "0")}`;
205
+ const tail = sha256(stableJson({ action_id: input.actionId, strategy_version: input.strategyVersion })).slice(0, 8);
206
+ return `DEC-${year}-${monthDay}-${tail}`;
207
+ }
208
+
209
+ export interface CreateDecisionPackageInput {
210
+ actionId: string;
211
+ strategyVersion: string;
212
+ handoffFingerprint: string;
213
+ title: string;
214
+ scopeId: string;
215
+ cycleId: string;
216
+ approverRole: string;
217
+ owner?: string | null;
218
+ approvedBudgetUsd?: number | null;
219
+ budgetThresholdPct?: number;
220
+ reportingCurrency?: string;
221
+ parameters?: Record<string, unknown>;
222
+ approvals?: DecisionApproval[];
223
+ tasks?: DecisionTask[];
224
+ memoRef?: string | null;
225
+ version?: string;
226
+ approvedAt?: string;
227
+ }
228
+
229
+ function validateApprovals(value: unknown): DecisionApproval[] {
230
+ if (value === undefined) return [];
231
+ if (!Array.isArray(value) || value.length > 16) throw new Error("approvals must be an array of at most 16 entries.");
232
+ return value.map((raw, index) => {
233
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`approvals[${index}] must be an object.`);
234
+ const source = raw as Record<string, unknown>;
235
+ return {
236
+ step_order: finiteNumber(source.step_order, `approvals[${index}].step_order`),
237
+ role: boundedString(source.role, `approvals[${index}].role`, 128),
238
+ approver: boundedString(source.approver, `approvals[${index}].approver`, 128),
239
+ action: enumValue(source.action, ["approved", "acknowledged", "rejected"] as const, `approvals[${index}].action`),
240
+ decided_at: isoTimestamp(source.decided_at, `approvals[${index}].decided_at`),
241
+ ...(optionalBoundedString(source.comment, `approvals[${index}].comment`) !== undefined
242
+ ? { comment: optionalBoundedString(source.comment, `approvals[${index}].comment`) as string }
243
+ : {}),
244
+ };
245
+ });
246
+ }
247
+
248
+ function validateTasks(value: unknown): DecisionTask[] {
249
+ if (value === undefined) return [];
250
+ if (!Array.isArray(value) || value.length > 64) throw new Error("tasks must be an array of at most 64 entries.");
251
+ return value.map((raw, index) => {
252
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`tasks[${index}] must be an object.`);
253
+ const source = raw as Record<string, unknown>;
254
+ return {
255
+ task_id: boundedString(source.task_id, `tasks[${index}].task_id`, 64),
256
+ task_name: boundedString(source.task_name, `tasks[${index}].task_name`, 256),
257
+ owner: boundedString(source.owner, `tasks[${index}].owner`, 128),
258
+ due_date: boundedString(source.due_date, `tasks[${index}].due_date`, 32),
259
+ status: enumValue(source.status, DECISION_TASK_STATUSES, `tasks[${index}].status`),
260
+ ...(source.completed_at ? { completed_at: isoTimestamp(source.completed_at, `tasks[${index}].completed_at`) } : {}),
261
+ };
262
+ });
263
+ }
264
+
265
+ /**
266
+ * Write the package for a confirmed strategy. Idempotent: committing the same
267
+ * decision twice returns the existing package rather than minting a second one.
268
+ */
269
+ export async function createDecisionPackage(cwd: string, input: CreateDecisionPackageInput): Promise<DecisionPackage> {
270
+ if (!SHA256_RE.test(input.actionId)) throw new Error("actionId must be a SHA-256 digest.");
271
+ if (!SHA256_RE.test(input.handoffFingerprint)) throw new Error("handoffFingerprint must be a SHA-256 digest.");
272
+ const approvedAt = input.approvedAt ?? new Date().toISOString();
273
+ const budgetThreshold = input.budgetThresholdPct ?? 0.1;
274
+ if (budgetThreshold < 0 || budgetThreshold > 1) throw new Error("budgetThresholdPct must be between 0 and 1.");
275
+ const approvedBudget = input.approvedBudgetUsd ?? null;
276
+ if (approvedBudget !== null) finiteNumber(approvedBudget, "approvedBudgetUsd");
277
+
278
+ const decisionId = decisionIdFor({ actionId: input.actionId, strategyVersion: input.strategyVersion, approvedAt });
279
+ const core = {
280
+ kind: "fpa.decision.package" as const,
281
+ schema_version: 1 as const,
282
+ decision_id: decisionId,
283
+ version: input.version ?? "v1",
284
+ title: boundedString(input.title, "title", 256),
285
+ scope_id: boundedString(input.scopeId, "scopeId", 256),
286
+ cycle_id: boundedString(input.cycleId, "cycleId", 256),
287
+ strategy_version: boundedString(input.strategyVersion, "strategyVersion"),
288
+ handoff_fingerprint: input.handoffFingerprint,
289
+ action_id: input.actionId,
290
+ owner: input.owner ? boundedString(input.owner, "owner", 128) : null,
291
+ approver_role: boundedString(input.approverRole, "approverRole", 128),
292
+ approved_budget_usd: approvedBudget,
293
+ budget_threshold_pct: budgetThreshold,
294
+ reporting_currency: input.reportingCurrency ?? "USD",
295
+ approved_at: approvedAt,
296
+ // The snapshot is sealed at the same instant it is approved: a gap
297
+ // between the two is a window in which the parameters could change.
298
+ locked_at: approvedAt,
299
+ parameters: input.parameters ?? {},
300
+ approvals: validateApprovals(input.approvals),
301
+ tasks: validateTasks(input.tasks),
302
+ memo_ref: input.memoRef ?? null,
303
+ };
304
+ const pkg: DecisionPackage = { ...core, package_fingerprint: sha256(stableJson(core)) };
305
+
306
+ const projectRoot = await realpath(cwd);
307
+ const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
308
+ const packagesDir = await ensureDirectory(artifactsDir, PACKAGES_DIR);
309
+ const path = join(packagesDir, `${decisionId}.json`);
310
+ const write = await appendOnlyWrite(packagesDir, path, `${JSON.stringify(pkg, null, 2)}\n`);
311
+ if (write === "exists") {
312
+ const existing = await readDecisionPackage(cwd, decisionId);
313
+ if (existing.package_fingerprint !== pkg.package_fingerprint) {
314
+ throw new Error(`Decision package ${decisionId} already exists with different content.`);
315
+ }
316
+ return existing;
317
+ }
318
+ await appendDecisionEvent(cwd, {
319
+ decision_id: decisionId,
320
+ event_at: approvedAt,
321
+ actor: input.approverRole,
322
+ actor_type: "human",
323
+ event_type: "approved",
324
+ summary: `${input.approverRole} approved ${core.title}`,
325
+ });
326
+ await appendDecisionEvent(cwd, {
327
+ decision_id: decisionId,
328
+ event_at: approvedAt,
329
+ actor: "system",
330
+ actor_type: "system",
331
+ event_type: "parameters_locked",
332
+ summary: "Parameter snapshot locked; the approved parameters are now immutable.",
333
+ });
334
+ return pkg;
335
+ }
336
+
337
+ export function validateDecisionPackage(value: unknown, label = "decision package"): DecisionPackage {
338
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
339
+ const source = value as Record<string, unknown>;
340
+ if (source.kind !== "fpa.decision.package" || source.schema_version !== 1) throw new Error(`${label} kind or schema version is unsupported.`);
341
+ const decisionId = boundedString(source.decision_id, `${label}.decision_id`, 64);
342
+ if (!DECISION_ID_RE.test(decisionId)) throw new Error(`${label}.decision_id is malformed.`);
343
+ if (typeof source.package_fingerprint !== "string" || !SHA256_RE.test(source.package_fingerprint)) {
344
+ throw new Error(`${label}.package_fingerprint must be a SHA-256 digest.`);
345
+ }
346
+ const { package_fingerprint: fingerprint, ...core } = source;
347
+ if (sha256(stableJson(core)) !== fingerprint) throw new Error(`${label} fingerprint does not match its content.`);
348
+ return source as unknown as DecisionPackage;
349
+ }
350
+
351
+ export async function readDecisionPackage(cwd: string, decisionId: string): Promise<DecisionPackage> {
352
+ if (!DECISION_ID_RE.test(decisionId)) throw new Error("decisionId is malformed.");
353
+ const projectRoot = await realpath(cwd);
354
+ const path = join(projectRoot, "artifacts", PACKAGES_DIR, `${decisionId}.json`);
355
+ const stat = await lstat(path);
356
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Decision package must be a regular file, not a symlink.");
357
+ if (stat.size > 512 * 1024) throw new Error("Decision package exceeds its 512KB limit.");
358
+ return validateDecisionPackage(JSON.parse(await readFile(path, "utf8")), `decision package ${decisionId}`);
359
+ }
360
+
361
+ export async function listDecisionPackages(cwd: string): Promise<DecisionPackage[]> {
362
+ const projectRoot = await realpath(cwd);
363
+ const packagesDir = join(projectRoot, "artifacts", PACKAGES_DIR);
364
+ let names: string[];
365
+ try {
366
+ names = await readdir(packagesDir);
367
+ } catch (error) {
368
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
369
+ throw error;
370
+ }
371
+ const packages: DecisionPackage[] = [];
372
+ for (const name of names.filter((entry) => entry.endsWith(".json")).slice(0, 500)) {
373
+ const decisionId = name.slice(0, -".json".length);
374
+ if (!DECISION_ID_RE.test(decisionId)) continue;
375
+ packages.push(await readDecisionPackage(cwd, decisionId));
376
+ }
377
+ // Newest first: the decision someone is asking about is usually the last one.
378
+ return packages.sort((left, right) => right.approved_at.localeCompare(left.approved_at));
379
+ }
380
+
381
+ export async function appendDecisionEvent(cwd: string, event: Omit<DecisionEvent, "kind" | "schema_version">): Promise<DecisionEvent> {
382
+ if (!DECISION_ID_RE.test(event.decision_id)) throw new Error("decision_id is malformed.");
383
+ const record: DecisionEvent = {
384
+ kind: "fpa.decision.event",
385
+ schema_version: 1,
386
+ decision_id: event.decision_id,
387
+ event_at: isoTimestamp(event.event_at, "event_at"),
388
+ actor: boundedString(event.actor, "actor", 128),
389
+ actor_type: enumValue(event.actor_type, ["human", "system"] as const, "actor_type"),
390
+ event_type: enumValue(event.event_type, DECISION_EVENT_TYPES, "event_type"),
391
+ summary: boundedString(event.summary, "summary", 1_024),
392
+ ...(optionalBoundedString(event.reason, "reason") !== undefined ? { reason: event.reason as string } : {}),
393
+ ...(optionalBoundedString(event.change_detail, "change_detail") !== undefined ? { change_detail: event.change_detail as string } : {}),
394
+ ...(event.estimated_impact_usd !== undefined ? { estimated_impact_usd: finiteNumber(event.estimated_impact_usd, "estimated_impact_usd") } : {}),
395
+ };
396
+
397
+ const projectRoot = await realpath(cwd);
398
+ const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
399
+ const packagesDir = await ensureDirectory(artifactsDir, PACKAGES_DIR);
400
+ const path = join(packagesDir, `${event.decision_id}.events.jsonl`);
401
+ // Append with O_APPEND so concurrent writers interleave whole lines rather
402
+ // than overwriting each other's offsets.
403
+ const handle = await open(path, "a", 0o600);
404
+ try {
405
+ await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
406
+ await handle.sync();
407
+ } finally {
408
+ await handle.close();
409
+ }
410
+ return record;
411
+ }
412
+
413
+ export async function readDecisionEvents(cwd: string, decisionId: string): Promise<DecisionEvent[]> {
414
+ if (!DECISION_ID_RE.test(decisionId)) throw new Error("decisionId is malformed.");
415
+ const projectRoot = await realpath(cwd);
416
+ const path = join(projectRoot, "artifacts", PACKAGES_DIR, `${decisionId}.events.jsonl`);
417
+ let raw: string;
418
+ try {
419
+ const stat = await lstat(path);
420
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Decision event log must be a regular file, not a symlink.");
421
+ if (stat.size > 4 * 1024 * 1024) throw new Error("Decision event log exceeds its 4MB limit.");
422
+ raw = await readFile(path, "utf8");
423
+ } catch (error) {
424
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
425
+ throw error;
426
+ }
427
+ const events: DecisionEvent[] = [];
428
+ for (const [index, line] of raw.split("\n").entries()) {
429
+ if (line.trim() === "") continue;
430
+ const parsed = JSON.parse(line) as DecisionEvent;
431
+ if (parsed.kind !== "fpa.decision.event" || parsed.schema_version !== 1 || parsed.decision_id !== decisionId) {
432
+ throw new Error(`Decision event log line ${index + 1} does not belong to ${decisionId}.`);
433
+ }
434
+ events.push(parsed);
435
+ }
436
+ return events.sort((left, right) => left.event_at.localeCompare(right.event_at));
437
+ }
@@ -13,6 +13,8 @@ import {
13
13
  transitionStrategyModule,
14
14
  } from "./module-publisher.ts";
15
15
  import { projectForecastModule, projectReviewModule, projectStrategyModule } from "./stage-projector.ts";
16
+ import { projectDecisionLedger } from "./decision-ledger-projector.ts";
17
+ import { listDecisionPackages, readDecisionEvents } from "./decision-package.ts";
16
18
  import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
17
19
  import { commitStrategyDecision, createStrategyDecisionRequest, readCommittedStrategyDecision } from "./strategy-decision.ts";
18
20
  import {
@@ -262,6 +264,71 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
262
264
  },
263
265
  });
264
266
 
267
+ pi.registerTool({
268
+ name: "fpa_dashboard_publish_decisions",
269
+ label: "Publish FP&A Decision Ledger Module",
270
+ description: "Preview or publish only the decision-ledger module from committed decision packages and their append-only event logs. Other dashboard modules are preserved.",
271
+ promptSnippet: "Publish the approved decision packages and their execution tracking",
272
+ promptGuidelines: [
273
+ "This tool is called by the main Agent; never add it to a Graph tool allowlist.",
274
+ "Preview first, then publish with the exact preview fingerprint and dashboard revision.",
275
+ "Supply measured spend only from an execution receipt or the actuals source. Never estimate it — an unmeasured decision must stay null, because a zero reads as 'nobody acted on this'.",
276
+ ],
277
+ parameters: Type.Object({
278
+ mode: StringEnum(["preview", "publish"] as const),
279
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
280
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
281
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
282
+ /** Measured spend per decision. Omitted decisions render as unmeasured. */
283
+ spend: Type.Optional(Type.Array(Type.Object({
284
+ decision_id: Type.String({ pattern: "^DEC-\\d{4}-\\d{4}-[a-f0-9]{8}$" }),
285
+ actual_spend_usd: Type.Union([Type.Number(), Type.Null()]),
286
+ progress: Type.Optional(Type.Union([Type.Number({ minimum: 0, maximum: 1 }), Type.Null()])),
287
+ source: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
288
+ }, { additionalProperties: false }), { maxItems: 500 })),
289
+ }, { additionalProperties: false }),
290
+ executionMode: "sequential",
291
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
292
+ const packages = await listDecisionPackages(ctx.cwd);
293
+ if (packages.length === 0) throw new Error("No decision packages exist yet; confirm a strategy before publishing the decision ledger.");
294
+ const events: Record<string, Awaited<ReturnType<typeof readDecisionEvents>>> = {};
295
+ for (const pkg of packages) events[pkg.decision_id] = await readDecisionEvents(ctx.cwd, pkg.decision_id);
296
+ const known = new Set(packages.map((pkg) => pkg.decision_id));
297
+ for (const entry of params.spend ?? []) {
298
+ if (!known.has(entry.decision_id)) throw new Error(`spend references unknown decision package ${entry.decision_id}.`);
299
+ }
300
+ const { module, breaches } = projectDecisionLedger({ packages, events, spend: params.spend ?? [] });
301
+ const previewFingerprint = dashboardModuleBuildFingerprint(module);
302
+ const current = await readDashboardModuleManifest(ctx.cwd);
303
+ if (params.mode === "preview") return toolResult({
304
+ status: "ready",
305
+ module_id: module.id,
306
+ preview_fingerprint: previewFingerprint,
307
+ dashboard_revision: current?.dashboardRevision ?? null,
308
+ widget_count: module.widgets.length,
309
+ package_count: packages.length,
310
+ threshold_breaches: breaches,
311
+ warnings: module.warnings ?? [],
312
+ });
313
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Decision ledger inputs changed after preview; preview again before publishing.");
314
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
315
+ const published = await publishDashboardModule({
316
+ cwd: ctx.cwd,
317
+ module,
318
+ dashboardTitle: params.dashboard_title,
319
+ expectedDashboardRevision: params.expected_dashboard_revision,
320
+ });
321
+ return toolResult({
322
+ status: "published",
323
+ module_id: module.id,
324
+ preview_fingerprint: previewFingerprint,
325
+ dashboard_revision: published.dashboardRevision,
326
+ module_revision: published.moduleRevision,
327
+ threshold_breaches: breaches,
328
+ });
329
+ },
330
+ });
331
+
265
332
  pi.registerTool({
266
333
  name: "fpa_dashboard_status",
267
334
  label: "FP&A Dashboard Status",
@@ -3,6 +3,7 @@ import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/pr
3
3
  import { join } from "node:path";
4
4
 
5
5
  import { stableJson } from "../fpa-artifacts/store.ts";
6
+ import { createDecisionPackage } from "./decision-package.ts";
6
7
  import { resolveDashboardDir } from "./publisher.ts";
7
8
 
8
9
  const SHA256_RE = /^[a-f0-9]{64}$/;
@@ -30,6 +31,21 @@ export interface CommitStrategyDecisionInput {
30
31
  decision: "confirm" | "request_changes";
31
32
  feedback?: string;
32
33
  decidedAt?: string;
34
+ /**
35
+ * Context for the decision package materialised on `confirm`. Optional so a
36
+ * caller with no budget context still records the decision — the package
37
+ * then carries a null budget and says so rather than inventing one.
38
+ */
39
+ packageContext?: {
40
+ title?: string;
41
+ scopeId?: string;
42
+ cycleId?: string;
43
+ approverRole?: string;
44
+ owner?: string | null;
45
+ approvedBudgetUsd?: number | null;
46
+ budgetThresholdPct?: number;
47
+ parameters?: Record<string, unknown>;
48
+ };
33
49
  }
34
50
 
35
51
  export interface CommitStrategyDecisionResult {
@@ -38,6 +54,8 @@ export interface CommitStrategyDecisionResult {
38
54
  decision: "confirm" | "request_changes";
39
55
  strategyVersion: string;
40
56
  handoffFingerprint: string;
57
+ /** Set only on `confirm` — a rejected strategy has nothing to track. */
58
+ decisionId?: string;
41
59
  }
42
60
 
43
61
  export interface CommittedStrategyDecision {
@@ -173,9 +191,62 @@ export async function commitStrategyDecision(cwd: string, input: CommitStrategyD
173
191
  decision: existing.decision,
174
192
  strategyVersion: request.strategy_version,
175
193
  handoffFingerprint: request.handoff_fingerprint,
194
+ // The already-committed decision owns the timestamp. Re-deriving it
195
+ // here would change the package's content — and its id, across a UTC
196
+ // midnight — so a second submission would look like a different
197
+ // decision instead of the same one.
198
+ ...(existing.decision === "confirm"
199
+ ? { decisionId: await materialiseDecisionPackage(cwd, request, existing.decided_at, input.packageContext) }
200
+ : {}),
176
201
  };
177
202
  }
178
- return { decisionFingerprint, path, decision: input.decision, strategyVersion: request.strategy_version, handoffFingerprint: request.handoff_fingerprint };
203
+ return {
204
+ decisionFingerprint,
205
+ path,
206
+ decision: input.decision,
207
+ strategyVersion: request.strategy_version,
208
+ handoffFingerprint: request.handoff_fingerprint,
209
+ ...(input.decision === "confirm"
210
+ ? { decisionId: await materialiseDecisionPackage(cwd, request, decision.decided_at, input.packageContext) }
211
+ : {}),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Turn a confirmed strategy into a trackable object.
217
+ *
218
+ * Runs after the decision record is durable, so a package can never exist for
219
+ * a decision that was not committed. `createDecisionPackage` is idempotent on
220
+ * the same inputs, which is what makes the re-commit path above safe to call.
221
+ */
222
+ async function materialiseDecisionPackage(
223
+ cwd: string,
224
+ request: StrategyDecisionRequest,
225
+ decidedAt: string,
226
+ context: CommitStrategyDecisionInput["packageContext"],
227
+ ): Promise<string> {
228
+ const pkg = await createDecisionPackage(cwd, {
229
+ actionId: request.action_id,
230
+ strategyVersion: request.strategy_version,
231
+ handoffFingerprint: request.handoff_fingerprint,
232
+ title: context?.title ?? `执行策略 ${request.strategy_version}`,
233
+ scopeId: context?.scopeId ?? "unscoped",
234
+ cycleId: context?.cycleId ?? "uncycled",
235
+ approverRole: context?.approverRole ?? "CEO",
236
+ owner: context?.owner ?? null,
237
+ approvedBudgetUsd: context?.approvedBudgetUsd ?? null,
238
+ ...(context?.budgetThresholdPct !== undefined ? { budgetThresholdPct: context.budgetThresholdPct } : {}),
239
+ parameters: context?.parameters ?? {},
240
+ approvals: [{
241
+ step_order: 1,
242
+ role: context?.approverRole ?? "CEO",
243
+ approver: context?.approverRole ?? "CEO",
244
+ action: "approved",
245
+ decided_at: decidedAt,
246
+ }],
247
+ approvedAt: decidedAt,
248
+ });
249
+ return pkg.decision_id;
179
250
  }
180
251
 
181
252
  export async function readCommittedStrategyDecision(cwd: string, actionId: string, decisionFingerprint: string): Promise<CommittedStrategyDecision> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
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",
@@ -34,9 +34,9 @@
34
34
  "fpa-dashboard-worker": "./bin/fpa-dashboard-worker.mjs"
35
35
  },
36
36
  "scripts": {
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",
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/decision-ledger.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",
38
38
  "test:structure": "node tests/package-structure.test.mjs",
39
- "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/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",
39
+ "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/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/decision-ledger.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",
40
40
  "test:loader": "node tests/pi-loader-smoke.mjs && node tests/worker-loader-smoke.mjs",
41
41
  "test:live": "node tests/live-smoke.mjs",
42
42
  "pack:check": "npm pack --dry-run"