@viccydev/pi-fpa 0.7.2 → 0.8.1
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 +15 -4
- package/extensions/fpa-artifacts/index.ts +22 -0
- package/extensions/fpa-dashboard/compat-publisher.ts +45 -0
- package/extensions/fpa-dashboard/coordinator.ts +3 -2
- package/extensions/fpa-dashboard/index.ts +237 -3
- package/extensions/fpa-dashboard/module-publisher.ts +390 -0
- package/extensions/fpa-dashboard/stage-projector.ts +304 -0
- package/extensions/fpa-dashboard/strategy-decision.ts +192 -0
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +131 -11
- package/graphs/fpa-forecast-freeze.json +95 -0
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +4 -3
- package/prompts/fpa-plan-cycle.md +18 -5
- package/skills/fpa-apply-core-rules/SKILL.md +9 -6
- package/skills/fpa-apply-core-rules/references/core-rules.md +21 -10
- package/skills/fpa-forecast-approved-strategy/SKILL.md +11 -8
- package/skills/fpa-recommend-strategy/SKILL.md +1 -1
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
- package/skills/fpa-refresh-dashboard/SKILL.md +47 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
4
|
+
import type { DashboardModuleBuild } from "./module-publisher.ts";
|
|
5
|
+
import type { TableCell } from "./projector.ts";
|
|
6
|
+
import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
|
|
7
|
+
import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
8
|
+
|
|
9
|
+
const STRATEGY_ACTION_LABELS = {
|
|
10
|
+
grow: "增长",
|
|
11
|
+
hold: "保持",
|
|
12
|
+
cut: "削减",
|
|
13
|
+
stop: "止损",
|
|
14
|
+
} as const;
|
|
15
|
+
type StrategyAction = keyof typeof STRATEGY_ACTION_LABELS;
|
|
16
|
+
|
|
17
|
+
export interface StrategyModuleContext {
|
|
18
|
+
scope_id: string;
|
|
19
|
+
cycle_id: string;
|
|
20
|
+
forecast_role: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
25
|
+
return value as Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function requiredString(value: unknown, label: string): string {
|
|
29
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string.`);
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function array(value: unknown, label: string): unknown[] {
|
|
34
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fingerprint(value: unknown): string {
|
|
39
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cell(value: unknown): TableCell {
|
|
43
|
+
if (value === null || value === undefined) return null;
|
|
44
|
+
if (typeof value === "string") return value;
|
|
45
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lines(value: unknown): string {
|
|
50
|
+
if (!Array.isArray(value) || value.length === 0) return "—";
|
|
51
|
+
return value.map((item) => typeof item === "string" ? item : JSON.stringify(item)).join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function strategyAction(value: unknown, label: string): StrategyAction {
|
|
55
|
+
if (typeof value === "string" && value in STRATEGY_ACTION_LABELS) return value as StrategyAction;
|
|
56
|
+
throw new Error(`${label} must be one of grow, hold, cut, stop.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function projectReviewModule(input: unknown): DashboardModuleBuild {
|
|
60
|
+
const analysis = record(input, "driver_analysis");
|
|
61
|
+
if (analysis.artifact_type !== "driver_analysis") throw new Error("Review publication requires artifact_type=driver_analysis.");
|
|
62
|
+
const headline = record(analysis.headline_results ?? {}, "driver_analysis.headline_results");
|
|
63
|
+
const drivers = array(analysis.drivers ?? [], "driver_analysis.drivers");
|
|
64
|
+
return {
|
|
65
|
+
id: "period-review",
|
|
66
|
+
title: "上周期复盘",
|
|
67
|
+
status: "published",
|
|
68
|
+
source: {
|
|
69
|
+
artifact_type: "driver_analysis",
|
|
70
|
+
artifact_fingerprint: fingerprint(analysis),
|
|
71
|
+
...(typeof analysis.analysis_version === "string" ? { analysis_version: analysis.analysis_version } : {}),
|
|
72
|
+
...(typeof analysis.status === "string" ? { artifact_status: analysis.status } : {}),
|
|
73
|
+
},
|
|
74
|
+
widgets: [
|
|
75
|
+
{
|
|
76
|
+
id: "review-headline",
|
|
77
|
+
type: "table",
|
|
78
|
+
span: "half",
|
|
79
|
+
dataset: "review-headline.json",
|
|
80
|
+
data: {
|
|
81
|
+
label: "核心结果",
|
|
82
|
+
columns: [{ key: "metric", label: "指标" }, { key: "value", label: "结果", align: "right" }],
|
|
83
|
+
rows: Object.entries(headline).map(([metric, value]) => ({ metric, value: cell(value) })),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "review-drivers",
|
|
88
|
+
type: "table",
|
|
89
|
+
span: "half",
|
|
90
|
+
dataset: "review-drivers.json",
|
|
91
|
+
data: {
|
|
92
|
+
label: "关键驱动",
|
|
93
|
+
columns: [{ key: "driver", label: "驱动" }, { key: "impact", label: "影响" }, { key: "evidence", label: "证据" }],
|
|
94
|
+
rows: drivers.map((item, index) => {
|
|
95
|
+
const driver = record(item, `driver_analysis.drivers[${index}]`);
|
|
96
|
+
return { driver: cell(driver.driver ?? driver.name ?? `Driver ${index + 1}`), impact: cell(driver.impact ?? driver.effect), evidence: cell(driver.evidence ?? driver.evidence_ids) };
|
|
97
|
+
}),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "review-limitations",
|
|
102
|
+
type: "table",
|
|
103
|
+
span: "full",
|
|
104
|
+
dataset: "review-limitations.json",
|
|
105
|
+
data: {
|
|
106
|
+
label: "限制与风险",
|
|
107
|
+
columns: [{ key: "item", label: "说明" }],
|
|
108
|
+
rows: array(analysis.limitations ?? analysis.risks ?? [], "driver_analysis.limitations").map((item) => ({ item: cell(item) })),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function projectStrategyModule(
|
|
116
|
+
proposalInput: unknown,
|
|
117
|
+
handoffInput: unknown,
|
|
118
|
+
reviewInput: unknown,
|
|
119
|
+
expectedContext: StrategyModuleContext,
|
|
120
|
+
): DashboardModuleBuild {
|
|
121
|
+
const proposal = record(proposalInput, "strategy_proposal");
|
|
122
|
+
const handoff = record(handoffInput, "reviewed_strategy_handoff");
|
|
123
|
+
const review = record(reviewInput, "strategy_review");
|
|
124
|
+
if (proposal.artifact_type !== "strategy_proposal") throw new Error("Strategy publication requires artifact_type=strategy_proposal.");
|
|
125
|
+
if (handoff.kind !== "fpa.reviewed-strategy-handoff") throw new Error("Strategy publication requires kind=fpa.reviewed-strategy-handoff.");
|
|
126
|
+
if (review.artifact_type !== "strategy_review") throw new Error("Strategy publication requires artifact_type=strategy_review.");
|
|
127
|
+
const strategyVersion = requiredString(proposal.strategy_version, "strategy_proposal.strategy_version");
|
|
128
|
+
if (handoff.strategy_version !== strategyVersion || handoff.reviewed_strategy_version !== strategyVersion) {
|
|
129
|
+
throw new Error("Reviewed handoff version does not match the strategy proposal version.");
|
|
130
|
+
}
|
|
131
|
+
if (handoff.status !== "ready") throw new Error("Reviewed strategy handoff is not ready for a human decision.");
|
|
132
|
+
if (review.reviewed_strategy_version !== strategyVersion) throw new Error("Strategy review version does not match the strategy proposal version.");
|
|
133
|
+
if (review.status !== "complete" && review.status !== "complete_with_limits") throw new Error("Strategy review is not complete.");
|
|
134
|
+
if (review.independence_confirmed !== true || handoff.independence_confirmed !== true) throw new Error("Strategy publication requires an independently reviewed proposal.");
|
|
135
|
+
const reviewerIdentity = requiredString(review.reviewer_identity, "strategy_review.reviewer_identity");
|
|
136
|
+
if (handoff.reviewer_identity !== reviewerIdentity) throw new Error("Reviewed handoff reviewer identity does not match the strategy review.");
|
|
137
|
+
if (review.opinion !== "support" && review.opinion !== "support_with_conditions") throw new Error("Strategy review opinion does not support human approval.");
|
|
138
|
+
if (handoff.review_opinion !== review.opinion) throw new Error("Reviewed handoff opinion does not match the strategy review.");
|
|
139
|
+
if (handoff.next_graph !== "fpa-forecast-freeze") throw new Error("Reviewed handoff next_graph must be fpa-forecast-freeze.");
|
|
140
|
+
for (const key of ["scope_id", "cycle_id", "forecast_role"] as const) {
|
|
141
|
+
const expected = requiredString(expectedContext[key], `strategy publication ${key}`);
|
|
142
|
+
if (handoff[key] !== expected) throw new Error(`Reviewed handoff ${key} does not match the publication context.`);
|
|
143
|
+
}
|
|
144
|
+
if (handoff.proposal_fingerprint !== fingerprint(proposal)) throw new Error("Reviewed handoff proposal_fingerprint does not match the strategy proposal.");
|
|
145
|
+
if (handoff.review_fingerprint !== fingerprint(review)) throw new Error("Reviewed handoff review_fingerprint does not match the strategy review.");
|
|
146
|
+
const reviewConditions = array(review.conditions_for_human_approval ?? [], "strategy_review.conditions_for_human_approval");
|
|
147
|
+
const residualRisks = array(review.residual_risks ?? [], "strategy_review.residual_risks");
|
|
148
|
+
if (stableJson(handoff.review_conditions ?? []) !== stableJson(reviewConditions)) throw new Error("Reviewed handoff conditions do not match the strategy review.");
|
|
149
|
+
if (stableJson(handoff.review_residual_risks ?? []) !== stableJson(residualRisks)) throw new Error("Reviewed handoff residual risks do not match the strategy review.");
|
|
150
|
+
const allocations = array(proposal.allocation, "strategy_proposal.allocation");
|
|
151
|
+
const outcomes = record(proposal.expected_outcomes, "strategy_proposal.expected_outcomes");
|
|
152
|
+
const base = record(outcomes.base ?? {}, "strategy_proposal.expected_outcomes.base");
|
|
153
|
+
const parsedAllocations = allocations.map((item, index) => {
|
|
154
|
+
const allocation = record(item, `strategy_proposal.allocation[${index}]`);
|
|
155
|
+
if (typeof allocation.spend !== "number" || !Number.isFinite(allocation.spend) || allocation.spend < 0) throw new Error(`strategy_proposal.allocation[${index}].spend must be a finite non-negative number.`);
|
|
156
|
+
if (typeof allocation.change_from_baseline !== "number" || !Number.isFinite(allocation.change_from_baseline)) throw new Error(`strategy_proposal.allocation[${index}].change_from_baseline must be a finite number.`);
|
|
157
|
+
const action = strategyAction(allocation.action, `strategy_proposal.allocation[${index}].action`);
|
|
158
|
+
const consistent = action === "grow"
|
|
159
|
+
? allocation.change_from_baseline > 0
|
|
160
|
+
: action === "hold"
|
|
161
|
+
? allocation.change_from_baseline === 0
|
|
162
|
+
: action === "cut"
|
|
163
|
+
? allocation.change_from_baseline < 0 && allocation.spend > 0
|
|
164
|
+
: allocation.change_from_baseline <= 0 && allocation.spend === 0;
|
|
165
|
+
if (!consistent) throw new Error(`strategy_proposal.allocation[${index}].action ${action} conflicts with spend and change_from_baseline.`);
|
|
166
|
+
return { allocation, action };
|
|
167
|
+
});
|
|
168
|
+
const totalSpend = parsedAllocations.reduce((sum, { allocation }) => sum + (allocation.spend as number), 0);
|
|
169
|
+
const actionCounts = Object.fromEntries(Object.keys(STRATEGY_ACTION_LABELS).map((action) => [action, 0])) as Record<StrategyAction, number>;
|
|
170
|
+
for (const { action } of parsedAllocations) actionCounts[action] += 1;
|
|
171
|
+
const actionHeadline = (Object.entries(STRATEGY_ACTION_LABELS) as [StrategyAction, string][])
|
|
172
|
+
.map(([action, label]) => `${label} ${actionCounts[action]} 项`)
|
|
173
|
+
.join(";");
|
|
174
|
+
return {
|
|
175
|
+
id: "next-strategy",
|
|
176
|
+
title: "下周期执行策略",
|
|
177
|
+
status: "awaiting_decision",
|
|
178
|
+
source: {
|
|
179
|
+
artifact_type: "reviewed_strategy",
|
|
180
|
+
artifact_fingerprint: fingerprint({ proposal, review, handoff }),
|
|
181
|
+
proposal_fingerprint: fingerprint(proposal),
|
|
182
|
+
review_fingerprint: fingerprint(review),
|
|
183
|
+
handoff_fingerprint: fingerprint(handoff),
|
|
184
|
+
strategy_version: strategyVersion,
|
|
185
|
+
review_opinion: handoff.review_opinion,
|
|
186
|
+
},
|
|
187
|
+
widgets: [
|
|
188
|
+
{
|
|
189
|
+
id: "strategy-summary",
|
|
190
|
+
type: "table",
|
|
191
|
+
span: "full",
|
|
192
|
+
dataset: "strategy-summary.json",
|
|
193
|
+
data: {
|
|
194
|
+
label: "策略摘要",
|
|
195
|
+
columns: [{ key: "metric", label: "项目" }, { key: "value", label: "值" }],
|
|
196
|
+
rows: [
|
|
197
|
+
{ metric: "策略版本", value: strategyVersion },
|
|
198
|
+
{ metric: "总预算", value: totalSpend.toFixed(2) },
|
|
199
|
+
{ metric: "Base 收入", value: cell(base.revenue) },
|
|
200
|
+
{ metric: "Base ROAS", value: cell(base.roas) },
|
|
201
|
+
{ metric: "执行结论", value: actionHeadline },
|
|
202
|
+
{ metric: "决策理由", value: lines(proposal.decision_rationale) },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
id: "strategy-allocation",
|
|
208
|
+
type: "table",
|
|
209
|
+
span: "full",
|
|
210
|
+
dataset: "strategy-allocation.json",
|
|
211
|
+
data: {
|
|
212
|
+
label: "预算分配",
|
|
213
|
+
columns: [
|
|
214
|
+
{ key: "app", label: "App" }, { key: "store", label: "商店" }, { key: "channel", label: "渠道" },
|
|
215
|
+
{ key: "action", label: "动作" },
|
|
216
|
+
{ key: "spend", label: "预算", align: "right" }, { key: "change", label: "较基线变化", align: "right" },
|
|
217
|
+
],
|
|
218
|
+
rows: parsedAllocations.map(({ allocation, action }) => {
|
|
219
|
+
return {
|
|
220
|
+
app: cell(allocation.app_id), store: cell(allocation.store), channel: cell(allocation.channel_group),
|
|
221
|
+
action: STRATEGY_ACTION_LABELS[action],
|
|
222
|
+
spend: (allocation.spend as number).toFixed(2), change: cell(allocation.change_from_baseline),
|
|
223
|
+
};
|
|
224
|
+
}),
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
id: "strategy-governance",
|
|
229
|
+
type: "table",
|
|
230
|
+
span: "full",
|
|
231
|
+
dataset: "strategy-governance.json",
|
|
232
|
+
data: {
|
|
233
|
+
label: "审批条件与风险",
|
|
234
|
+
columns: [{ key: "category", label: "类别" }, { key: "details", label: "内容" }],
|
|
235
|
+
rows: [
|
|
236
|
+
{ category: "审批条件", details: lines(handoff.review_conditions) },
|
|
237
|
+
{ category: "剩余风险", details: lines(handoff.review_residual_risks ?? proposal.risks) },
|
|
238
|
+
{ category: "待确认事项", details: lines(proposal.required_human_decisions) },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
|
|
247
|
+
const allocationByKey = new Map(forecast.approved_allocation.map((item) => [`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`, item]));
|
|
248
|
+
return {
|
|
249
|
+
id: "next-forecast",
|
|
250
|
+
title: "下周期数据预测",
|
|
251
|
+
status: "published",
|
|
252
|
+
source: {
|
|
253
|
+
artifact_type: "approved_cycle_forecast",
|
|
254
|
+
artifact_ref: artifactRef,
|
|
255
|
+
artifact_fingerprint: forecast.immutable_fingerprint,
|
|
256
|
+
forecast_version: forecast.forecast_version,
|
|
257
|
+
strategy_version: forecast.strategy_version,
|
|
258
|
+
target_period: forecast.target_period,
|
|
259
|
+
data_as_of: forecast.data_as_of,
|
|
260
|
+
},
|
|
261
|
+
widgets: [
|
|
262
|
+
{
|
|
263
|
+
id: "forecast-summary",
|
|
264
|
+
type: "table",
|
|
265
|
+
span: "full",
|
|
266
|
+
dataset: "forecast-summary.json",
|
|
267
|
+
data: {
|
|
268
|
+
label: "预测摘要",
|
|
269
|
+
columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
|
|
270
|
+
rows: Object.entries(forecast.consolidated_forecast).map(([metric, scenario]) => ({
|
|
271
|
+
metric,
|
|
272
|
+
downside: cell(scenario.downside),
|
|
273
|
+
base: cell(scenario.base),
|
|
274
|
+
upside: cell(scenario.upside),
|
|
275
|
+
})),
|
|
276
|
+
description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}`,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: "forecast-by-slice",
|
|
281
|
+
type: "table",
|
|
282
|
+
span: "full",
|
|
283
|
+
dataset: "forecast-by-slice.json",
|
|
284
|
+
data: {
|
|
285
|
+
label: "分片预测",
|
|
286
|
+
columns: [
|
|
287
|
+
{ key: "slice", label: "App / 商店 / 渠道" }, { key: "spend", label: "预算", align: "right" },
|
|
288
|
+
{ key: "revenue", label: "Base 收入", align: "right" }, { key: "roas", label: "Base ROAS", align: "right" },
|
|
289
|
+
],
|
|
290
|
+
rows: forecast.forecast_by_slice.map((slice) => {
|
|
291
|
+
const key = `${slice.app_id}\u0000${slice.store}\u0000${slice.channel_group}`;
|
|
292
|
+
const allocation = allocationByKey.get(key);
|
|
293
|
+
return {
|
|
294
|
+
slice: `${slice.app_id} · ${slice.store} · ${slice.channel_group}`,
|
|
295
|
+
spend: cell(allocation?.approved_spend),
|
|
296
|
+
revenue: cell(slice.metrics.revenue?.base),
|
|
297
|
+
roas: cell(slice.metrics.roas?.base),
|
|
298
|
+
};
|
|
299
|
+
}),
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, realpath, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
6
|
+
import { resolveDashboardDir } from "./publisher.ts";
|
|
7
|
+
|
|
8
|
+
const SHA256_RE = /^[a-f0-9]{64}$/;
|
|
9
|
+
|
|
10
|
+
export interface StrategyDecisionRequestInput {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
strategyVersion: string;
|
|
13
|
+
handoffFingerprint: string;
|
|
14
|
+
createdAt?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StrategyDecisionRequest {
|
|
18
|
+
kind: "fpa.strategy.decision.request";
|
|
19
|
+
schema_version: 1;
|
|
20
|
+
action_id: string;
|
|
21
|
+
session_id: string;
|
|
22
|
+
strategy_version: string;
|
|
23
|
+
handoff_fingerprint: string;
|
|
24
|
+
created_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CommitStrategyDecisionInput {
|
|
28
|
+
actionId: string;
|
|
29
|
+
sessionId: string;
|
|
30
|
+
decision: "confirm" | "request_changes";
|
|
31
|
+
feedback?: string;
|
|
32
|
+
decidedAt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CommitStrategyDecisionResult {
|
|
36
|
+
decisionFingerprint: string;
|
|
37
|
+
path: string;
|
|
38
|
+
decision: "confirm" | "request_changes";
|
|
39
|
+
strategyVersion: string;
|
|
40
|
+
handoffFingerprint: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CommittedStrategyDecision {
|
|
44
|
+
kind: "fpa.strategy.decision";
|
|
45
|
+
schema_version: 1;
|
|
46
|
+
action_id: string;
|
|
47
|
+
strategy_version: string;
|
|
48
|
+
handoff_fingerprint: string;
|
|
49
|
+
decision: "confirm" | "request_changes";
|
|
50
|
+
feedback?: string;
|
|
51
|
+
decision_fingerprint: string;
|
|
52
|
+
decided_at: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sha256(value: string): string {
|
|
56
|
+
return createHash("sha256").update(value).digest("hex");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requiredString(value: string, label: string): string {
|
|
60
|
+
if (typeof value !== "string" || value.trim() === "" || value.length > 512 || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
61
|
+
throw new Error(`${label} must be a non-empty bounded string without control characters.`);
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ensureDirectory(parent: string, name: string): Promise<string> {
|
|
67
|
+
const path = join(parent, name);
|
|
68
|
+
try {
|
|
69
|
+
const stat = await lstat(path);
|
|
70
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${name} must be a regular directory, not a symlink.`);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
73
|
+
await mkdir(path, { mode: 0o700 });
|
|
74
|
+
}
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function appendOnlyWrite(directory: string, destination: string, contents: string): Promise<"created" | "exists"> {
|
|
79
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`);
|
|
80
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
81
|
+
try {
|
|
82
|
+
await handle.writeFile(contents, "utf8");
|
|
83
|
+
await handle.sync();
|
|
84
|
+
await handle.close();
|
|
85
|
+
try {
|
|
86
|
+
await link(temporary, destination);
|
|
87
|
+
return "created";
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return "exists";
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
await handle.close().catch(() => undefined);
|
|
94
|
+
await unlink(temporary).catch(() => undefined);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function createStrategyDecisionRequest(cwd: string, input: StrategyDecisionRequestInput): Promise<{ actionId: string; path: string }> {
|
|
99
|
+
const sessionId = requiredString(input.sessionId, "sessionId");
|
|
100
|
+
const strategyVersion = requiredString(input.strategyVersion, "strategyVersion");
|
|
101
|
+
if (!SHA256_RE.test(input.handoffFingerprint)) throw new Error("handoffFingerprint must be a SHA-256 digest.");
|
|
102
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
103
|
+
try {
|
|
104
|
+
const stat = await lstat(dashboardDir);
|
|
105
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Dashboard directory must be a regular directory, not a symlink.");
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
108
|
+
await mkdir(dashboardDir, { mode: 0o700 });
|
|
109
|
+
}
|
|
110
|
+
const actionsDir = await ensureDirectory(dashboardDir, "actions");
|
|
111
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
112
|
+
const actionId = sha256(stableJson({ sessionId, strategyVersion, handoffFingerprint: input.handoffFingerprint, createdAt, nonce: randomUUID() }));
|
|
113
|
+
const request: StrategyDecisionRequest = {
|
|
114
|
+
kind: "fpa.strategy.decision.request",
|
|
115
|
+
schema_version: 1,
|
|
116
|
+
action_id: actionId,
|
|
117
|
+
session_id: sessionId,
|
|
118
|
+
strategy_version: strategyVersion,
|
|
119
|
+
handoff_fingerprint: input.handoffFingerprint,
|
|
120
|
+
created_at: createdAt,
|
|
121
|
+
};
|
|
122
|
+
const path = join(actionsDir, `${actionId}.json`);
|
|
123
|
+
await appendOnlyWrite(actionsDir, path, `${JSON.stringify(request, null, 2)}\n`);
|
|
124
|
+
return { actionId, path };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function readStrategyDecisionRequest(cwd: string, actionId: string): Promise<StrategyDecisionRequest> {
|
|
128
|
+
if (!SHA256_RE.test(actionId)) throw new Error("actionId must be a SHA-256 digest.");
|
|
129
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
130
|
+
const path = join(dashboardDir, "actions", `${actionId}.json`);
|
|
131
|
+
const stat = await lstat(path);
|
|
132
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Strategy decision request must be a regular file.");
|
|
133
|
+
const request = JSON.parse(await readFile(path, "utf8")) as StrategyDecisionRequest;
|
|
134
|
+
if (request.kind !== "fpa.strategy.decision.request" || request.schema_version !== 1 || request.action_id !== actionId) throw new Error("Strategy decision request is invalid.");
|
|
135
|
+
return request;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function commitStrategyDecision(cwd: string, input: CommitStrategyDecisionInput): Promise<CommitStrategyDecisionResult> {
|
|
139
|
+
const sessionId = requiredString(input.sessionId, "sessionId");
|
|
140
|
+
const request = await readStrategyDecisionRequest(cwd, input.actionId);
|
|
141
|
+
if (request.session_id !== sessionId) throw new Error("Strategy decision must be committed from the originating session.");
|
|
142
|
+
const feedback = input.feedback?.trim();
|
|
143
|
+
if (input.decision === "request_changes" && !feedback) throw new Error("request_changes requires non-empty feedback.");
|
|
144
|
+
if (input.decision === "confirm" && feedback) throw new Error("confirm must not include feedback.");
|
|
145
|
+
|
|
146
|
+
const projectRoot = await realpath(cwd);
|
|
147
|
+
const artifactsDir = await ensureDirectory(projectRoot, "artifacts");
|
|
148
|
+
const decisionsDir = await ensureDirectory(artifactsDir, "strategy-decisions");
|
|
149
|
+
const identity = {
|
|
150
|
+
action_id: request.action_id,
|
|
151
|
+
strategy_version: request.strategy_version,
|
|
152
|
+
handoff_fingerprint: request.handoff_fingerprint,
|
|
153
|
+
decision: input.decision,
|
|
154
|
+
...(feedback ? { feedback } : {}),
|
|
155
|
+
};
|
|
156
|
+
const decisionFingerprint = sha256(stableJson(identity));
|
|
157
|
+
const decision = {
|
|
158
|
+
kind: "fpa.strategy.decision",
|
|
159
|
+
schema_version: 1,
|
|
160
|
+
...identity,
|
|
161
|
+
decision_fingerprint: decisionFingerprint,
|
|
162
|
+
decided_at: input.decidedAt ?? new Date().toISOString(),
|
|
163
|
+
};
|
|
164
|
+
const path = join(decisionsDir, `${request.action_id}.json`);
|
|
165
|
+
const contents = `${JSON.stringify(decision, null, 2)}\n`;
|
|
166
|
+
const write = await appendOnlyWrite(decisionsDir, path, contents);
|
|
167
|
+
if (write === "exists") {
|
|
168
|
+
const existing = JSON.parse(await readFile(path, "utf8")) as typeof decision;
|
|
169
|
+
if (existing.decision_fingerprint !== decisionFingerprint) throw new Error("A different strategy decision is already committed for this action.");
|
|
170
|
+
return {
|
|
171
|
+
decisionFingerprint: existing.decision_fingerprint,
|
|
172
|
+
path,
|
|
173
|
+
decision: existing.decision,
|
|
174
|
+
strategyVersion: request.strategy_version,
|
|
175
|
+
handoffFingerprint: request.handoff_fingerprint,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { decisionFingerprint, path, decision: input.decision, strategyVersion: request.strategy_version, handoffFingerprint: request.handoff_fingerprint };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function readCommittedStrategyDecision(cwd: string, actionId: string, decisionFingerprint: string): Promise<CommittedStrategyDecision> {
|
|
182
|
+
if (!SHA256_RE.test(actionId) || !SHA256_RE.test(decisionFingerprint)) throw new Error("Strategy decision identity must use SHA-256 digests.");
|
|
183
|
+
const projectRoot = await realpath(cwd);
|
|
184
|
+
const path = join(projectRoot, "artifacts", "strategy-decisions", `${actionId}.json`);
|
|
185
|
+
const stat = await lstat(path);
|
|
186
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Committed strategy decision must be a regular file.");
|
|
187
|
+
const decision = JSON.parse(await readFile(path, "utf8")) as CommittedStrategyDecision;
|
|
188
|
+
if (decision.kind !== "fpa.strategy.decision" || decision.schema_version !== 1 || decision.action_id !== actionId || decision.decision_fingerprint !== decisionFingerprint) {
|
|
189
|
+
throw new Error("Committed strategy decision identity does not match the requested decision.");
|
|
190
|
+
}
|
|
191
|
+
return decision;
|
|
192
|
+
}
|