@viccydev/pi-fpa 0.8.0 → 0.9.0
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/compose.ts +156 -42
- package/extensions/fpa-artifacts/contracts.ts +202 -2
- package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
- package/extensions/fpa-artifacts/index.ts +24 -1
- package/extensions/fpa-dashboard/coordinator.ts +2 -2
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +5 -4
- package/extensions/fpa-dashboard/forward-outlook.ts +5 -2
- package/extensions/fpa-dashboard/index.ts +12 -3
- package/extensions/fpa-dashboard/projector.ts +58 -1
- package/extensions/fpa-dashboard/service.ts +13 -2
- package/extensions/fpa-dashboard/stage-projector.ts +131 -10
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +114 -28
- package/graphs/fpa-forecast-freeze.json +103 -7
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +2 -2
- package/prompts/fpa-plan-cycle.md +12 -12
- package/skills/fpa-apply-core-rules/SKILL.md +8 -8
- package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
- 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 +7 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
- package/graphs/fpa-period-analysis.json +0 -10
- package/graphs/fpa-strategy-recommendation.json +0 -11
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const TEMPLATE_NAMES = ["fpa-strategy-planning", "fpa-forecast-freeze"] as const;
|
|
7
|
+
const RETIRED_NAMES = ["fpa-period-analysis", "fpa-strategy-recommendation"] as const;
|
|
8
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
9
|
+
|
|
10
|
+
interface GraphInstallResult {
|
|
11
|
+
installed: string[];
|
|
12
|
+
upgraded: string[];
|
|
13
|
+
retired: string[];
|
|
14
|
+
backups: string[];
|
|
15
|
+
warnings: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface GraphIdentity {
|
|
19
|
+
name?: string;
|
|
20
|
+
version?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function pathStat(path: string) {
|
|
24
|
+
try {
|
|
25
|
+
return await lstat(path);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function secureGraphsDirectory(cwd: string): Promise<{ agentGraph: string; graphs: string }> {
|
|
33
|
+
const cwdReal = await realpath(cwd);
|
|
34
|
+
const agentGraph = join(cwdReal, ".agent-graph");
|
|
35
|
+
const graphs = join(agentGraph, "graphs");
|
|
36
|
+
for (const directory of [agentGraph, graphs]) {
|
|
37
|
+
let existing = await pathStat(directory);
|
|
38
|
+
if (!existing) {
|
|
39
|
+
try {
|
|
40
|
+
await mkdir(directory);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
43
|
+
}
|
|
44
|
+
existing = await pathStat(directory);
|
|
45
|
+
}
|
|
46
|
+
if (!existing?.isDirectory() || existing.isSymbolicLink()) {
|
|
47
|
+
throw new Error(`FP&A Graph directory must be a real directory: ${directory}`);
|
|
48
|
+
}
|
|
49
|
+
if (await realpath(directory) !== directory) throw new Error(`FP&A Graph directory escapes the trusted cwd: ${directory}`);
|
|
50
|
+
}
|
|
51
|
+
return { agentGraph, graphs };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function secureChildDirectory(parent: string, directory: string): Promise<void> {
|
|
55
|
+
if (dirname(directory) !== parent) throw new Error(`FP&A Graph backup directory escapes its parent: ${directory}`);
|
|
56
|
+
let existing = await pathStat(directory);
|
|
57
|
+
if (!existing) {
|
|
58
|
+
try {
|
|
59
|
+
await mkdir(directory);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
62
|
+
}
|
|
63
|
+
existing = await pathStat(directory);
|
|
64
|
+
}
|
|
65
|
+
if (!existing?.isDirectory() || existing.isSymbolicLink() || await realpath(directory) !== directory) {
|
|
66
|
+
throw new Error(`FP&A Graph backup directory must be a real directory inside the trusted cwd: ${directory}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function graphIdentity(content: string): GraphIdentity {
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
73
|
+
return {
|
|
74
|
+
name: typeof parsed.name === "string" ? parsed.name : undefined,
|
|
75
|
+
version: typeof parsed.version === "string" ? parsed.version : undefined,
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compareVersions(left: string | undefined, right: string | undefined): number | undefined {
|
|
83
|
+
const parse = (value: string | undefined) => value?.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
|
|
84
|
+
const a = parse(left);
|
|
85
|
+
const b = parse(right);
|
|
86
|
+
if (!a || !b) return undefined;
|
|
87
|
+
for (let index = 0; index < 3; index += 1) {
|
|
88
|
+
if (a[index] !== b[index]) return a[index] - b[index];
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeVersion(value: string | undefined): string {
|
|
94
|
+
return value?.match(/^\d+\.\d+\.\d+$/)?.[0] ?? "unknown";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function atomicCreate(path: string, content: string): Promise<void> {
|
|
98
|
+
await writeFile(path, content, { flag: "wx" });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function backupFile(agentGraph: string, path: string, content: string, version: string | undefined, removeSource: boolean): Promise<string> {
|
|
102
|
+
const backupDirectory = join(agentGraph, `graphs_backup_v${safeVersion(version)}`);
|
|
103
|
+
await secureChildDirectory(agentGraph, backupDirectory);
|
|
104
|
+
let destination = join(backupDirectory, basename(path));
|
|
105
|
+
let existing = await pathStat(destination);
|
|
106
|
+
if (existing) {
|
|
107
|
+
if (!existing.isFile() || existing.isSymbolicLink()) throw new Error(`Unsafe FP&A Graph backup target: ${destination}`);
|
|
108
|
+
const existingContent = await readFile(destination, "utf8");
|
|
109
|
+
if (existingContent === content) {
|
|
110
|
+
if (removeSource) await unlink(path);
|
|
111
|
+
return destination;
|
|
112
|
+
}
|
|
113
|
+
const fingerprint = createHash("sha256").update(content).digest("hex").slice(0, 12);
|
|
114
|
+
destination = join(backupDirectory, `${basename(path, ".json")}.${fingerprint}.json`);
|
|
115
|
+
existing = await pathStat(destination);
|
|
116
|
+
if (existing) {
|
|
117
|
+
if (!existing.isFile() || existing.isSymbolicLink() || await readFile(destination, "utf8") !== content) {
|
|
118
|
+
throw new Error(`Conflicting FP&A Graph backup target: ${destination}`);
|
|
119
|
+
}
|
|
120
|
+
if (removeSource) await unlink(path);
|
|
121
|
+
return destination;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await atomicCreate(destination, content);
|
|
125
|
+
if (removeSource) await unlink(path);
|
|
126
|
+
return destination;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function atomicWrite(path: string, content: string): Promise<void> {
|
|
130
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
|
|
131
|
+
await writeFile(temporary, content, { flag: "wx" });
|
|
132
|
+
try {
|
|
133
|
+
await rename(temporary, path);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
await unlink(temporary).catch(() => undefined);
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function withInstallerLock<T>(agentGraph: string, timeoutMs: number, action: () => Promise<T>): Promise<T> {
|
|
141
|
+
const lockPath = join(agentGraph, ".fpa-graph-installer.lock");
|
|
142
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
143
|
+
let ownership: { dev: number | bigint; ino: number | bigint } | undefined;
|
|
144
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
145
|
+
while (!handle) {
|
|
146
|
+
try {
|
|
147
|
+
const candidate = await open(lockPath, "wx", 0o600);
|
|
148
|
+
try {
|
|
149
|
+
await candidate.writeFile(`${process.pid} ${Date.now()}\n`);
|
|
150
|
+
const stat = await candidate.stat();
|
|
151
|
+
ownership = { dev: stat.dev, ino: stat.ino };
|
|
152
|
+
handle = candidate;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
await candidate.close().catch(() => undefined);
|
|
155
|
+
await unlink(lockPath).catch(() => undefined);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
160
|
+
const stat = await pathStat(lockPath);
|
|
161
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error(`Unsafe FP&A Graph installer lock: ${lockPath}`);
|
|
162
|
+
// Never steal a pathname from an unknown owner: an old holder could
|
|
163
|
+
// otherwise unlink the replacement lock from its own finally block.
|
|
164
|
+
if (Date.now() >= deadline) break;
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!handle) throw new Error(`Timed out waiting for FP&A Graph installer lock: ${lockPath}`);
|
|
169
|
+
try {
|
|
170
|
+
return await action();
|
|
171
|
+
} finally {
|
|
172
|
+
await handle.close();
|
|
173
|
+
const current = await pathStat(lockPath);
|
|
174
|
+
if (
|
|
175
|
+
ownership
|
|
176
|
+
&& current?.isFile()
|
|
177
|
+
&& !current.isSymbolicLink()
|
|
178
|
+
&& current.dev === ownership.dev
|
|
179
|
+
&& current.ino === ownership.ino
|
|
180
|
+
) await unlink(lockPath).catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function ensureFpaProjectGraphs(options: { cwd: string; trusted: boolean; lockTimeoutMs?: number }): Promise<GraphInstallResult> {
|
|
185
|
+
const result: GraphInstallResult = { installed: [], upgraded: [], retired: [], backups: [], warnings: [] };
|
|
186
|
+
if (!options.trusted) return result;
|
|
187
|
+
const { agentGraph, graphs } = await secureGraphsDirectory(options.cwd);
|
|
188
|
+
return withInstallerLock(agentGraph, options.lockTimeoutMs ?? 5_000, async () => {
|
|
189
|
+
for (const name of TEMPLATE_NAMES) {
|
|
190
|
+
const sourcePath = join(PACKAGE_ROOT, "graphs", `${name}.json`);
|
|
191
|
+
const sourceContent = await readFile(sourcePath, "utf8");
|
|
192
|
+
const sourceIdentity = graphIdentity(sourceContent);
|
|
193
|
+
if (sourceIdentity.name !== name || !sourceIdentity.version) throw new Error(`Invalid packaged FP&A Graph template: ${sourcePath}`);
|
|
194
|
+
const targetPath = join(graphs, `${name}.json`);
|
|
195
|
+
const targetStat = await pathStat(targetPath);
|
|
196
|
+
if (!targetStat) {
|
|
197
|
+
await atomicWrite(targetPath, sourceContent);
|
|
198
|
+
result.installed.push(name);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (!targetStat.isFile() || targetStat.isSymbolicLink()) {
|
|
202
|
+
result.warnings.push(`Preserved unsafe or non-file Graph target: ${targetPath}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const targetContent = await readFile(targetPath, "utf8");
|
|
206
|
+
if (targetContent === sourceContent) continue;
|
|
207
|
+
const targetIdentity = graphIdentity(targetContent);
|
|
208
|
+
const comparison = compareVersions(targetIdentity.version, sourceIdentity.version);
|
|
209
|
+
if (targetIdentity.name === name && comparison !== undefined && comparison > 0) {
|
|
210
|
+
result.warnings.push(`Preserved newer project Graph ${name}@${targetIdentity.version}; package provides ${sourceIdentity.version}.`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const backup = await backupFile(agentGraph, targetPath, targetContent, targetIdentity.version, false);
|
|
214
|
+
result.backups.push(backup);
|
|
215
|
+
await atomicWrite(targetPath, sourceContent);
|
|
216
|
+
result.upgraded.push(name);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
for (const name of RETIRED_NAMES) {
|
|
220
|
+
const path = join(graphs, `${name}.json`);
|
|
221
|
+
const stat = await pathStat(path);
|
|
222
|
+
if (!stat) continue;
|
|
223
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
224
|
+
result.warnings.push(`Preserved unsafe or non-file retired Graph target: ${path}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const content = await readFile(path, "utf8");
|
|
228
|
+
const backup = await backupFile(agentGraph, path, content, graphIdentity(content).version, true);
|
|
229
|
+
result.backups.push(backup);
|
|
230
|
+
result.retired.push(name);
|
|
231
|
+
}
|
|
232
|
+
return result;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -5,10 +5,11 @@ import type {
|
|
|
5
5
|
ToolResultEvent,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
|
|
8
|
+
import { ensureFpaProjectGraphs } from "./graph-installer.ts";
|
|
9
|
+
|
|
8
10
|
const GRAPH_TOOLS = ["graph_list", "graph_run"] as const;
|
|
11
|
+
const ROUTING_STATE_CUSTOM_TYPE = "fpa-routing-guard-state";
|
|
9
12
|
const FP_AND_A_GRAPHS = [
|
|
10
|
-
"fpa-period-analysis",
|
|
11
|
-
"fpa-strategy-recommendation",
|
|
12
13
|
"fpa-strategy-planning",
|
|
13
14
|
"fpa-forecast-freeze",
|
|
14
15
|
"fpa-strategy-execution",
|
|
@@ -22,6 +23,14 @@ interface RoutingState {
|
|
|
22
23
|
availableGraphs: Set<string>;
|
|
23
24
|
graphRunStarted: boolean;
|
|
24
25
|
graphRunCompleted: boolean;
|
|
26
|
+
reviewPublished: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PersistedRoutingState {
|
|
30
|
+
version: 1;
|
|
31
|
+
requiredGraph: FpaGraph;
|
|
32
|
+
graphRunCompleted: boolean;
|
|
33
|
+
reviewPublished: boolean;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function emptyState(): RoutingState {
|
|
@@ -30,6 +39,7 @@ function emptyState(): RoutingState {
|
|
|
30
39
|
availableGraphs: new Set(),
|
|
31
40
|
graphRunStarted: false,
|
|
32
41
|
graphRunCompleted: false,
|
|
42
|
+
reviewPublished: false,
|
|
33
43
|
};
|
|
34
44
|
}
|
|
35
45
|
|
|
@@ -60,6 +70,10 @@ function isExplicitIsolatedPhase(prompt: string): boolean {
|
|
|
60
70
|
const skill = prompt.match(/^<skill name="(fpa-[^"]+)"/);
|
|
61
71
|
if (skill && skill[1] !== "fpa-apply-core-rules") return true;
|
|
62
72
|
if (!/(?:^|[。;;]\s*)(?:请)?(?:本次)?(?:只|仅)|\bonly\b/i.test(prompt)) return false;
|
|
73
|
+
if (
|
|
74
|
+
FP_AND_A_GRAPHS.some((graph) => prompt.toLowerCase().includes(graph))
|
|
75
|
+
&& /(?:运行|执行|启动|调用|使用|通过|\brun\b|\bexecute\b|\bstart\b)/i.test(prompt)
|
|
76
|
+
) return false;
|
|
63
77
|
return !/(?:然后|接着|随后|再进入|后再|直至|全流程|完整流程|end[- ]to[- ]end)/i.test(prompt);
|
|
64
78
|
}
|
|
65
79
|
|
|
@@ -119,7 +133,7 @@ export function classifyFpaGraph(prompt: string): FpaGraph | undefined {
|
|
|
119
133
|
return "fpa-strategy-execution";
|
|
120
134
|
}
|
|
121
135
|
|
|
122
|
-
return hasFpaIntent(normalized) ? "fpa-
|
|
136
|
+
return hasFpaIntent(normalized) ? "fpa-strategy-planning" : undefined;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
function contentText(event: ToolResultEvent): string {
|
|
@@ -148,30 +162,93 @@ function artifactWrite(event: ToolCallEvent): boolean {
|
|
|
148
162
|
return /artifacts?[\\/]/i.test(command);
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
function
|
|
152
|
-
|
|
165
|
+
function graphContextError(event: ToolCallEvent, graph: FpaGraph): string | undefined {
|
|
166
|
+
const context = "context" in event.input ? event.input.context : undefined;
|
|
167
|
+
if (!context || typeof context !== "object" || Array.isArray(context)) {
|
|
168
|
+
return "graph_run requires a context object whose values are strings.";
|
|
169
|
+
}
|
|
170
|
+
const values = context as Record<string, unknown>;
|
|
171
|
+
const required = graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze"
|
|
172
|
+
? ["scope_id", "cycle_id", "forecast_role"]
|
|
173
|
+
: [];
|
|
174
|
+
const missing = required.filter((key) => !(key in values) || (typeof values[key] === "string" && values[key].trim().length === 0));
|
|
175
|
+
if (missing.length > 0) return `graph_run requires non-empty string context keys: ${missing.join(", ")}.`;
|
|
176
|
+
const nonStrings = Object.entries(values)
|
|
177
|
+
.filter(([, value]) => typeof value !== "string")
|
|
178
|
+
.map(([key]) => key);
|
|
179
|
+
return nonStrings.length > 0
|
|
180
|
+
? `All graph_run context values must be strings; invalid keys: ${nonStrings.join(", ")}. Put structured data in goal.`
|
|
181
|
+
: undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function routingInstruction(graph: FpaGraph, resumeCompletedGraph = false): string {
|
|
185
|
+
const instructions = [
|
|
153
186
|
"FP&A runtime routing guard is active for this request.",
|
|
154
187
|
`The first eligible workflow is ${graph}.`,
|
|
155
|
-
"Call graph_list first, then graph_run with that exact graph
|
|
188
|
+
"Call graph_list first, then graph_run with that exact graph. All graph_run context values must be strings; never pass arrays, objects, numbers, booleans, or null.",
|
|
156
189
|
"Graph nodes own business phase execution. After a successful Graph handoff, only the main Agent may call the matching fpa_dashboard_publish_* tool; Graphs must never publish dashboard data.",
|
|
157
|
-
]
|
|
190
|
+
];
|
|
191
|
+
if (graph === "fpa-strategy-planning" || graph === "fpa-forecast-freeze") {
|
|
192
|
+
instructions.push("Provide the immutable non-empty string context keys scope_id, cycle_id, and forecast_role. Put structured planning details in goal, not context.");
|
|
193
|
+
}
|
|
194
|
+
if (graph === "fpa-strategy-planning") {
|
|
195
|
+
instructions.push(resumeCompletedGraph
|
|
196
|
+
? "A completed combined Graph handoff was restored from this session. Continue modular publication from its persisted artifacts; do not rerun the Graph unless those artifacts are unavailable or the user changed the planning requirements. Publish period-review before next-strategy."
|
|
197
|
+
: "After this combined Graph completes, the main Agent must publish period-review first from artifacts/driver_analysis.json, then publish next-strategy from artifacts/strategy_proposal.json, artifacts/strategy_review.json, and artifacts/reviewed_strategy_handoff.json with the exact scope_id, cycle_id, forecast_role, and a fresh dashboard revision. Stop for the dashboard decision; do not run forecast yet.");
|
|
198
|
+
}
|
|
199
|
+
return instructions.join(" ");
|
|
158
200
|
}
|
|
159
201
|
|
|
160
202
|
export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
|
|
161
203
|
let state = emptyState();
|
|
204
|
+
let installWarning: string | undefined;
|
|
205
|
+
|
|
206
|
+
function persistState(): void {
|
|
207
|
+
if (!state.requiredGraph) return;
|
|
208
|
+
pi.appendEntry(ROUTING_STATE_CUSTOM_TYPE, {
|
|
209
|
+
version: 1,
|
|
210
|
+
requiredGraph: state.requiredGraph,
|
|
211
|
+
graphRunCompleted: state.graphRunCompleted,
|
|
212
|
+
reviewPublished: state.reviewPublished,
|
|
213
|
+
} satisfies PersistedRoutingState);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
217
|
+
state = emptyState();
|
|
218
|
+
installWarning = undefined;
|
|
219
|
+
try {
|
|
220
|
+
const installed = await ensureFpaProjectGraphs({ cwd: ctx.cwd, trusted: ctx.isProjectTrusted() });
|
|
221
|
+
if (installed.warnings.length > 0) installWarning = installed.warnings.join(" ");
|
|
222
|
+
} catch (error) {
|
|
223
|
+
installWarning = error instanceof Error ? error.message : String(error);
|
|
224
|
+
}
|
|
225
|
+
const restored = [...ctx.sessionManager.getBranch()].reverse().find((entry) => entry.type === "custom" && entry.customType === ROUTING_STATE_CUSTOM_TYPE);
|
|
226
|
+
if (!restored || restored.type !== "custom" || !restored.data || typeof restored.data !== "object" || Array.isArray(restored.data)) return;
|
|
227
|
+
const data = restored.data as Partial<PersistedRoutingState>;
|
|
228
|
+
if (data.version !== 1 || !FP_AND_A_GRAPHS.includes(data.requiredGraph as FpaGraph)) return;
|
|
229
|
+
state.requiredGraph = data.requiredGraph;
|
|
230
|
+
state.graphRunStarted = data.graphRunCompleted === true;
|
|
231
|
+
state.graphRunCompleted = data.graphRunCompleted === true;
|
|
232
|
+
state.reviewPublished = data.graphRunCompleted === true && data.reviewPublished === true;
|
|
233
|
+
});
|
|
162
234
|
|
|
163
235
|
pi.on("before_agent_start", (event: BeforeAgentStartEvent) => {
|
|
164
|
-
const
|
|
236
|
+
const previousState = state;
|
|
165
237
|
state = emptyState();
|
|
166
238
|
const active = new Set(pi.getActiveTools());
|
|
167
239
|
if (!GRAPH_TOOLS.every((tool) => active.has(tool))) return;
|
|
168
240
|
|
|
169
241
|
const classified = classifyFpaGraph(event.prompt);
|
|
170
242
|
const isContinuation = /^(?:继续|接着|下一步|continue|go on)[。.!!\s]*$/i.test(event.prompt.trim());
|
|
171
|
-
const requiredGraph = classified ?? (isContinuation ?
|
|
243
|
+
const requiredGraph = classified ?? (isContinuation ? previousState.requiredGraph : undefined);
|
|
172
244
|
if (!requiredGraph) return;
|
|
173
245
|
state.requiredGraph = requiredGraph;
|
|
174
|
-
|
|
246
|
+
if (isContinuation && previousState.requiredGraph === requiredGraph) {
|
|
247
|
+
state.graphRunStarted = previousState.graphRunCompleted;
|
|
248
|
+
state.graphRunCompleted = previousState.graphRunCompleted;
|
|
249
|
+
state.reviewPublished = previousState.reviewPublished;
|
|
250
|
+
}
|
|
251
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${routingInstruction(requiredGraph, state.graphRunCompleted)}${installWarning ? `\n\nFP&A Graph provisioning warning: ${installWarning}` : ""}` };
|
|
175
252
|
});
|
|
176
253
|
|
|
177
254
|
pi.on("tool_call", (event: ToolCallEvent) => {
|
|
@@ -184,15 +261,6 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
|
|
|
184
261
|
return { block: true, reason: `Call graph_list before graph_run for ${graph}.` };
|
|
185
262
|
}
|
|
186
263
|
const requested = "graph" in event.input ? event.input.graph : undefined;
|
|
187
|
-
if (graph === "fpa-period-analysis" && state.graphRunCompleted && requested === "fpa-strategy-recommendation") {
|
|
188
|
-
if (!state.availableGraphs.has("fpa-strategy-recommendation")) {
|
|
189
|
-
return { block: true, reason: "fpa-strategy-recommendation is unavailable; do not emulate strategy work in the parent Agent." };
|
|
190
|
-
}
|
|
191
|
-
state.requiredGraph = "fpa-strategy-recommendation";
|
|
192
|
-
state.graphRunStarted = true;
|
|
193
|
-
state.graphRunCompleted = false;
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
264
|
if (requested !== graph) {
|
|
197
265
|
return { block: true, reason: `Run the first eligible ${graph} Graph; received ${String(requested)}.` };
|
|
198
266
|
}
|
|
@@ -202,21 +270,28 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
|
|
|
202
270
|
reason: `${graph} is not available. Return blocked; local work requires a new, explicit isolated-phase request.`,
|
|
203
271
|
};
|
|
204
272
|
}
|
|
273
|
+
const contextError = graphContextError(event, graph);
|
|
274
|
+
if (contextError) return { block: true, reason: contextError };
|
|
205
275
|
state.graphRunStarted = true;
|
|
206
276
|
state.graphRunCompleted = false;
|
|
277
|
+
state.reviewPublished = false;
|
|
278
|
+
persistState();
|
|
207
279
|
return;
|
|
208
280
|
}
|
|
209
281
|
|
|
210
282
|
if (event.toolName === "fpa_dashboard_module_status") return;
|
|
211
|
-
if ((graph === "fpa-forecast-freeze" || graph === "fpa-strategy-
|
|
212
|
-
const
|
|
213
|
-
? "fpa_dashboard_publish_review"
|
|
214
|
-
: graph === "fpa-
|
|
215
|
-
? "
|
|
216
|
-
:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
283
|
+
if ((graph === "fpa-forecast-freeze" || graph === "fpa-strategy-planning") && !state.graphRunStarted && event.toolName === "fpa_strategy_decision_commit") return;
|
|
284
|
+
const allowedPublications = graph === "fpa-strategy-planning"
|
|
285
|
+
? new Set(["fpa_dashboard_publish_review", "fpa_dashboard_publish_strategy"])
|
|
286
|
+
: graph === "fpa-forecast-freeze"
|
|
287
|
+
? new Set(["fpa_dashboard_publish_forecast"])
|
|
288
|
+
: new Set(["fpa_dashboard_refresh_queue"]);
|
|
289
|
+
if (allowedPublications.has(event.toolName) && state.graphRunCompleted) {
|
|
290
|
+
if (graph === "fpa-strategy-planning" && event.toolName === "fpa_dashboard_publish_strategy" && !state.reviewPublished) {
|
|
291
|
+
return { block: true, reason: "Publish the period-review module successfully before previewing or publishing next-strategy." };
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
220
295
|
|
|
221
296
|
if (!event.toolName.startsWith("fpa_") && !artifactWrite(event)) return;
|
|
222
297
|
if (!state.catalogLoaded) {
|
|
@@ -249,6 +324,17 @@ export default function fpaRoutingGuardExtension(pi: ExtensionAPI): void {
|
|
|
249
324
|
? (event.details as { status?: unknown }).status
|
|
250
325
|
: undefined;
|
|
251
326
|
state.graphRunCompleted = status !== "failed" && status !== "blocked" && status !== "cancelled";
|
|
327
|
+
persistState();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (event.toolName === "fpa_dashboard_publish_review" && state.graphRunCompleted) {
|
|
331
|
+
const status = event.details && typeof event.details === "object" && "status" in event.details
|
|
332
|
+
? (event.details as { status?: unknown }).status
|
|
333
|
+
: undefined;
|
|
334
|
+
if (status === "published") {
|
|
335
|
+
state.reviewPublished = true;
|
|
336
|
+
persistState();
|
|
337
|
+
}
|
|
252
338
|
}
|
|
253
339
|
});
|
|
254
340
|
}
|
|
@@ -1,11 +1,107 @@
|
|
|
1
1
|
{
|
|
2
|
+
"description": "FP&A 正式预测冻结:消费已审核策略交接与主 Agent 已提交的精确 strategy_decision,生成、合成并冻结 approved_cycle_forecast;Graph 不请求审批、不发布仪表盘。",
|
|
3
|
+
"maxSteps": 10,
|
|
4
|
+
"mutationPolicy": "mutating",
|
|
2
5
|
"name": "fpa-forecast-freeze",
|
|
3
|
-
"version": "2.0.0",
|
|
4
|
-
"start": "load_confirmed_strategy",
|
|
5
6
|
"nodes": [
|
|
6
|
-
{
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
{
|
|
8
|
+
"agentName": "load_confirmed_strategy",
|
|
9
|
+
"id": "load_confirmed_strategy",
|
|
10
|
+
"label": "核验已确认策略",
|
|
11
|
+
"next": "route_confirmed_strategy",
|
|
12
|
+
"outputKey": "confirmed_strategy",
|
|
13
|
+
"parseJson": true,
|
|
14
|
+
"prompt": "操作备注:{{goal}}\n\n读取 artifacts/reviewed_strategy_handoff.json、其中绑定的 strategy_proposal 与 strategy_review,以及操作备注明确给出的 strategy_decision path。必须核验 handoff status=ready;scope_id/cycle_id/forecast_role 与 Graph context 一致;proposal、review、handoff 的 strategy_version 一致;decision.kind=fpa.strategy.decision、decision=confirm;decision 的 strategy_version、handoff_fingerprint、decision_fingerprint 与操作备注和当前 handoff 精确一致。任一不一致即 blocked。不得请求或记录第二次审批,不得调用任何 fpa_dashboard_* 工具。\n\n只输出 JSON:{\"status\":\"ready|blocked\",\"strategy_version\":\"...\",\"strategy_decision_path\":\"...\",\"strategy_decision_fingerprint\":\"...\",\"review_conditions\":[],\"blockers\":[]}",
|
|
15
|
+
"skills": ["fpa-apply-core-rules"],
|
|
16
|
+
"systemPrompt": "你是 FP&A 已确认策略核验 Agent。只读核验主 Agent 已提交的不可变决策,不批准、不预测、不发布仪表盘。权威上下文为 scope_id={{context.scope_id}}、cycle_id={{context.cycle_id}}、forecast_role={{context.forecast_role}}。",
|
|
17
|
+
"tools": ["read"],
|
|
18
|
+
"type": "subagent"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"cases": [
|
|
22
|
+
{ "equals": "ready", "label": "确认有效", "to": "draft_forecast" },
|
|
23
|
+
{ "equals": "blocked", "label": "确认无效", "to": "report_blocked" }
|
|
24
|
+
],
|
|
25
|
+
"default": { "label": "其他结果", "to": "report_blocked" },
|
|
26
|
+
"id": "route_confirmed_strategy",
|
|
27
|
+
"label": "判断是否可预测",
|
|
28
|
+
"path": "data.confirmed_strategy.status",
|
|
29
|
+
"type": "router"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "report_blocked",
|
|
33
|
+
"label": "报告预测阻断",
|
|
34
|
+
"outputKey": "forecast_blocked",
|
|
35
|
+
"parseJson": true,
|
|
36
|
+
"prompt": "策略核验结果:{{data.confirmed_strategy}}\n预测计划修复结果:{{data.repair_forecast_plan}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
|
|
37
|
+
"systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
|
|
38
|
+
"type": "prompt"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"agentName": "draft_forecast",
|
|
42
|
+
"id": "draft_forecast",
|
|
43
|
+
"label": "正式预测决策计划",
|
|
44
|
+
"next": "compose_forecast",
|
|
45
|
+
"outputKey": "draft_forecast",
|
|
46
|
+
"prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。优先使用 ua_spend 的 app_code/platform/media_source 真实组合;若已确认分配中的部分切片与当前上游映射不一致,不得擅自删除、置零、重分配或伪造映射,保留原切片交给 compose 自动产出 complete_with_limits、覆盖率和数据修复项。不得调用任何 fpa_dashboard_* 工具。",
|
|
47
|
+
"skills": ["fpa-apply-core-rules", "fpa-forecast-approved-strategy"],
|
|
48
|
+
"systemPrompt": "你是 FP&A 正式预测 Agent。只按已确认策略写预测计划,不请求审批、不发布仪表盘、不手算派生值。",
|
|
49
|
+
"tools": ["read", "write", "fpa_calc"],
|
|
50
|
+
"type": "subagent"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"args": { "plan_path": "artifacts/forecast_plan.json" },
|
|
54
|
+
"failure": { "maxAttempts": 2, "onError": "repair_forecast_plan" },
|
|
55
|
+
"id": "compose_forecast",
|
|
56
|
+
"label": "合成正式预测",
|
|
57
|
+
"next": "commit_forecast",
|
|
58
|
+
"outputKey": "compose_forecast",
|
|
59
|
+
"tool": "fpa_forecast_compose",
|
|
60
|
+
"type": "tool"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"agentName": "repair_forecast_plan",
|
|
64
|
+
"id": "repair_forecast_plan",
|
|
65
|
+
"label": "定点修正预测计划",
|
|
66
|
+
"next": "route_repair_forecast_plan",
|
|
67
|
+
"outputKey": "repair_forecast_plan",
|
|
68
|
+
"parseJson": true,
|
|
69
|
+
"prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式或 forecast plan 契约问题。不得通过删除、置零、重分配或伪造映射来修正数据质量问题;这类问题应由 compose 降级为 complete_with_limits。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,返回 blocked。不得调用任何 fpa_dashboard_* 工具。只输出 JSON:{\"status\":\"repaired|blocked\",\"changes\":[],\"blockers\":[]}",
|
|
70
|
+
"skills": ["fpa-forecast-approved-strategy"],
|
|
71
|
+
"systemPrompt": "你是预测计划修复 Agent,只按确定性合成报错做最小修正。",
|
|
72
|
+
"tools": ["read", "write", "edit"],
|
|
73
|
+
"type": "subagent"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"cases": [
|
|
77
|
+
{ "equals": "repaired", "label": "已修正契约", "to": "compose_forecast" },
|
|
78
|
+
{ "equals": "blocked", "label": "需要治理处理", "to": "report_blocked" }
|
|
79
|
+
],
|
|
80
|
+
"default": { "label": "其他结果", "to": "report_blocked" },
|
|
81
|
+
"id": "route_repair_forecast_plan",
|
|
82
|
+
"label": "判断能否重新合成",
|
|
83
|
+
"path": "data.repair_forecast_plan.status",
|
|
84
|
+
"type": "router"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"args": {
|
|
88
|
+
"artifact": "{{data.compose_forecast.details.artifact}}",
|
|
89
|
+
"context": {
|
|
90
|
+
"cycle_id": "{{context.cycle_id}}",
|
|
91
|
+
"forecast_role": "{{context.forecast_role}}",
|
|
92
|
+
"scope_id": "{{context.scope_id}}"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"id": "commit_forecast",
|
|
96
|
+
"label": "冻结正式预测",
|
|
97
|
+
"mutates": true,
|
|
98
|
+
"outputKey": "commit_forecast",
|
|
99
|
+
"tool": "fpa_artifact_commit",
|
|
100
|
+
"type": "tool"
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
"schemaVersion": 1,
|
|
104
|
+
"start": "load_confirmed_strategy",
|
|
105
|
+
"transitionLabels": { "default": "其他情况", "error": "失败", "next": "继续" },
|
|
106
|
+
"version": "2.2.0"
|
|
11
107
|
}
|