@viccydev/pi-fpa 0.9.4 → 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.
|
|
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.
|
|
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
|
|
|
@@ -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 {
|
|
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
|
+
"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"
|