@viccydev/pi-fpa 0.3.2 → 0.4.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 +4 -2
- package/extensions/fpa-artifacts/index.ts +17 -4
- package/extensions/fpa-artifacts/store.ts +45 -2
- package/extensions/fpa-data/index.ts +96 -26
- package/extensions/fpa-data/supabase.ts +21 -2
- package/package.json +3 -3
- package/skills/fpa-execute-approved-strategy/SKILL.md +2 -2
- package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +9 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +32 -2
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +10 -1
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Skill:
|
|
|
39
39
|
| `fpa_calc` | 确定性计算器:命名公式求值(四则、abs/min/max/round),NULL 与除零安全传播 |
|
|
40
40
|
| `fpa_compare` | 双期间对比:差值、百分比变化、逐行贡献度全部由代码计算 |
|
|
41
41
|
|
|
42
|
+
`fpa_data_catalog` 默认只读取数据字典和各数据集日期覆盖,不执行随表规模增长的精确行数扫描。确实需要精确 `row_count`、App 数量和 cohort-size 覆盖时显式传入 `include_stats: true`;需要 App 列表时传入 `include_apps: true`。这些补充信息都有独立的 5 秒预算,单个数据集超时会返回对应的 `*_unavailable` 说明,不会丢失其它数据集的覆盖信息。认证、网络、服务端错误和调用方取消仍然抛出。
|
|
43
|
+
|
|
42
44
|
设计契约:**模型不写 SQL、不做任何算术**。模型只从注册表中选择数据集、指标和维度;SQL 生成、数据库聚合和全部派生计算(比率、差异、LTV/ROAS/留存、临时公式)都在 Extension 代码内完成,缺数据或除零返回 `NULL`,绝不编造数值。
|
|
43
45
|
|
|
44
46
|
Extension 内置的关键防护:
|
|
@@ -108,12 +110,12 @@ pi list
|
|
|
108
110
|
团队分发建议使用固定 Git tag:
|
|
109
111
|
|
|
110
112
|
```bash
|
|
111
|
-
pi install git:github.com/linyqh/pi-fpa@v0.3.
|
|
113
|
+
pi install git:github.com/linyqh/pi-fpa@v0.3.2
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
## 发布到 npm
|
|
115
117
|
|
|
116
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version
|
|
118
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.4.0` 对应 `v0.4.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
117
119
|
|
|
118
120
|
首次发布前需要完成一次仓库配置:
|
|
119
121
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import { commitArtifact } from "./store.ts";
|
|
4
|
+
import { commitArtifact, commitArtifactFromPath } from "./store.ts";
|
|
5
5
|
|
|
6
6
|
function toolResult(value: Record<string, unknown>) {
|
|
7
7
|
return {
|
|
@@ -16,21 +16,34 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
16
16
|
label: "Commit FP&A Artifact",
|
|
17
17
|
description:
|
|
18
18
|
"Validate, reconcile, fingerprint, and atomically commit a canonical FP&A artifact under the current project's artifacts directory. " +
|
|
19
|
-
"Use this instead of claiming a Markdown report is frozen. Supported v1 artifacts are approved_cycle_forecast and execution_receipt."
|
|
19
|
+
"Use this instead of claiming a Markdown report is frozen. Supported v1 artifacts are approved_cycle_forecast and execution_receipt. " +
|
|
20
|
+
"Supply the artifact inline, or write it to a JSON file first and pass artifact_path — prefer the file for anything large.",
|
|
20
21
|
promptSnippet: "Validate and durably freeze a canonical FP&A artifact",
|
|
21
22
|
promptGuidelines: [
|
|
22
23
|
"Use fpa_artifact_commit before calling an approved forecast immutable or using it to publish a dashboard.",
|
|
23
24
|
"Do not supply immutable_fingerprint; the tool calculates it after validation and reconciliation.",
|
|
25
|
+
"Write a large artifact to a JSON file across turns and commit it with artifact_path; a single inline argument can be cut off at the model output limit.",
|
|
24
26
|
],
|
|
25
27
|
parameters: Type.Object(
|
|
26
28
|
{
|
|
27
|
-
artifact: Type.Unknown({ description: "Canonical artifact object matching the referenced FP&A artifact contract" }),
|
|
29
|
+
artifact: Type.Optional(Type.Unknown({ description: "Canonical artifact object matching the referenced FP&A artifact contract" })),
|
|
30
|
+
artifact_path: Type.Optional(Type.String({
|
|
31
|
+
minLength: 1,
|
|
32
|
+
description: "Project-relative path to a JSON file holding the canonical artifact. Mutually exclusive with artifact.",
|
|
33
|
+
})),
|
|
28
34
|
},
|
|
29
35
|
{ additionalProperties: false },
|
|
30
36
|
),
|
|
31
37
|
executionMode: "sequential",
|
|
32
38
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
33
|
-
const
|
|
39
|
+
const hasInline = params.artifact !== undefined;
|
|
40
|
+
const hasPath = params.artifact_path !== undefined;
|
|
41
|
+
if (hasInline === hasPath) {
|
|
42
|
+
throw new Error("Supply exactly one of artifact or artifact_path.");
|
|
43
|
+
}
|
|
44
|
+
const committed = hasPath
|
|
45
|
+
? await commitArtifactFromPath(ctx.cwd, params.artifact_path as string)
|
|
46
|
+
: await commitArtifact(ctx.cwd, params.artifact);
|
|
34
47
|
return toolResult({
|
|
35
48
|
status: "committed",
|
|
36
49
|
artifact_type: committed.artifactType,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, mkdir, open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
validateArtifact,
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
type CanonicalArtifactInput,
|
|
11
11
|
} from "./contracts.ts";
|
|
12
12
|
|
|
13
|
+
export const ARTIFACT_MAX_BYTES = 2 * 1024 * 1024;
|
|
14
|
+
|
|
13
15
|
export interface CommitArtifactResult {
|
|
14
16
|
artifactType: ArtifactType;
|
|
15
17
|
fingerprint: string;
|
|
@@ -58,6 +60,47 @@ async function existingArtifactDirectory(projectRoot: string): Promise<string> {
|
|
|
58
60
|
return artifactsDir;
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a caller-supplied draft path inside the project.
|
|
65
|
+
*
|
|
66
|
+
* Same fail-closed posture as the committed artifacts themselves: no escaping
|
|
67
|
+
* the project root, no symlinks, no unbounded reads.
|
|
68
|
+
*/
|
|
69
|
+
async function resolveDraftPath(projectRoot: string, artifactPath: string): Promise<string> {
|
|
70
|
+
if (!artifactPath.trim()) throw new Error("artifact_path must be a non-empty path.");
|
|
71
|
+
const root = await realpath(projectRoot);
|
|
72
|
+
const resolved = resolve(root, artifactPath);
|
|
73
|
+
const relation = relative(root, resolved);
|
|
74
|
+
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) {
|
|
75
|
+
throw new Error("artifact_path must stay inside the project directory.");
|
|
76
|
+
}
|
|
77
|
+
const stat = await lstat(resolved);
|
|
78
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("artifact_path must point at a regular file, not a symlink.");
|
|
79
|
+
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`artifact_path exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
|
|
80
|
+
return resolved;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Commit a canonical artifact an agent wrote to disk instead of passing inline.
|
|
85
|
+
*
|
|
86
|
+
* A full `approved_cycle_forecast` runs to tens of thousands of tokens, and
|
|
87
|
+
* emitting one as a single tool argument is exactly the shape that gets cut off
|
|
88
|
+
* at the model output limit — leaving the caller convinced it committed
|
|
89
|
+
* something it never did. A draft file can be written and corrected across as
|
|
90
|
+
* many turns as it takes, and this entry point keeps the validation,
|
|
91
|
+
* reconciliation, fingerprinting, and atomic write identical to the inline path.
|
|
92
|
+
*/
|
|
93
|
+
export async function commitArtifactFromPath(projectRoot: string, artifactPath: string): Promise<CommitArtifactResult> {
|
|
94
|
+
const path = await resolveDraftPath(projectRoot, artifactPath);
|
|
95
|
+
let parsed: unknown;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`artifact_path is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
}
|
|
101
|
+
return commitArtifact(projectRoot, parsed);
|
|
102
|
+
}
|
|
103
|
+
|
|
61
104
|
export async function commitArtifact(projectRoot: string, input: unknown): Promise<CommitArtifactResult> {
|
|
62
105
|
const artifact = validateArtifact(input);
|
|
63
106
|
if (artifact.artifact_type === "execution_receipt") {
|
|
@@ -98,7 +141,7 @@ export async function readCommittedArtifact(projectRoot: string, artifactType: A
|
|
|
98
141
|
const path = join(await existingArtifactDirectory(projectRoot), `${artifactType}.json`);
|
|
99
142
|
const stat = await lstat(path);
|
|
100
143
|
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${artifactType} must be a regular file, not a symlink.`);
|
|
101
|
-
if (stat.size >
|
|
144
|
+
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${artifactType} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
|
|
102
145
|
let parsed: unknown;
|
|
103
146
|
try {
|
|
104
147
|
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -30,23 +30,22 @@ import {
|
|
|
30
30
|
type QuerySpec,
|
|
31
31
|
} from "./sql.ts";
|
|
32
32
|
import { runStructuredQuery } from "./runtime.ts";
|
|
33
|
-
import { runQuery, type SqlRow } from "./supabase.ts";
|
|
33
|
+
import { runQuery, SupabaseQueryTimeoutError, type SqlRow } from "./supabase.ts";
|
|
34
34
|
|
|
35
35
|
const MAX_TOOL_TEXT_CHARS = 100_000;
|
|
36
36
|
const MAX_DISPLAY_ROWS = 200;
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
38
|
+
/** Coverage is essential but one slow dataset must not consume the whole tool budget. */
|
|
39
|
+
const CATALOG_COVERAGE_TIMEOUT_MS = 5_000;
|
|
40
40
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
async function bestEffort<T>(work: Promise<T>, signal?: AbortSignal): Promise<T | null> {
|
|
41
|
+
/** Explicitly requested supplementary catalog data has a bounded, non-fatal budget. */
|
|
42
|
+
const CATALOG_OPTIONAL_TIMEOUT_MS = 5_000;
|
|
43
|
+
|
|
44
|
+
async function fallbackOnTimeout<T>(work: Promise<T>, signal?: AbortSignal): Promise<T | null> {
|
|
46
45
|
try {
|
|
47
46
|
return await work;
|
|
48
47
|
} catch (error) {
|
|
49
|
-
if (signal?.aborted) throw error;
|
|
48
|
+
if (signal?.aborted || !(error instanceof SupabaseQueryTimeoutError)) throw error;
|
|
50
49
|
return null;
|
|
51
50
|
}
|
|
52
51
|
}
|
|
@@ -144,6 +143,12 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
144
143
|
],
|
|
145
144
|
parameters: Type.Object(
|
|
146
145
|
{
|
|
146
|
+
include_stats: Type.Optional(
|
|
147
|
+
Type.Boolean({
|
|
148
|
+
description:
|
|
149
|
+
"Also request exact row/app counts and cohort-size availability. These are scan-bound and best-effort.",
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
147
152
|
include_apps: Type.Optional(
|
|
148
153
|
Type.Boolean({ description: "Also list distinct app codes (up to 200)." }),
|
|
149
154
|
),
|
|
@@ -152,25 +157,65 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
152
157
|
),
|
|
153
158
|
executionMode: "parallel",
|
|
154
159
|
async execute(_toolCallId, params, signal) {
|
|
155
|
-
// Date coverage is the part callers must have
|
|
156
|
-
//
|
|
157
|
-
//
|
|
160
|
+
// Date coverage is the part callers must have. Keep each dataset in its own
|
|
161
|
+
// timeout domain so a missing leading-date index cannot sink the whole catalog;
|
|
162
|
+
// exact scan-bound statistics remain opt-in.
|
|
158
163
|
const coverageRows = await Promise.all(
|
|
159
|
-
COVERAGE_TARGETS.map(async (target) =>
|
|
164
|
+
COVERAGE_TARGETS.map(async (target) => {
|
|
165
|
+
const rows = await fallbackOnTimeout(
|
|
166
|
+
runQuery(buildCoverageSql(target), {
|
|
167
|
+
signal,
|
|
168
|
+
timeoutMs: CATALOG_COVERAGE_TIMEOUT_MS,
|
|
169
|
+
}),
|
|
170
|
+
signal,
|
|
171
|
+
);
|
|
172
|
+
return rows?.[0] ?? {
|
|
173
|
+
dataset: target.dataset,
|
|
174
|
+
date_min: null,
|
|
175
|
+
date_max: null,
|
|
176
|
+
...(rows === null
|
|
177
|
+
? {
|
|
178
|
+
coverage_unavailable:
|
|
179
|
+
"Date coverage timed out for this dataset; other datasets are unaffected.",
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
160
184
|
);
|
|
185
|
+
const statsBySource = new Map<string, Promise<SqlRow[] | null>>();
|
|
186
|
+
const statsFor = (target: (typeof COVERAGE_TARGETS)[number]) => {
|
|
187
|
+
const key = `${target.table}\u0000${target.appColumn}`;
|
|
188
|
+
let pending = statsBySource.get(key);
|
|
189
|
+
if (!pending) {
|
|
190
|
+
pending = fallbackOnTimeout(
|
|
191
|
+
runQuery(buildCoverageStatsSql(target), {
|
|
192
|
+
signal,
|
|
193
|
+
timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS,
|
|
194
|
+
}),
|
|
195
|
+
signal,
|
|
196
|
+
);
|
|
197
|
+
statsBySource.set(key, pending);
|
|
198
|
+
}
|
|
199
|
+
return pending;
|
|
200
|
+
};
|
|
161
201
|
const [statsRows, cohortSize, apps] = await Promise.all([
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
params.include_stats
|
|
203
|
+
? Promise.all(
|
|
204
|
+
COVERAGE_TARGETS.map((target) => statsFor(target)),
|
|
205
|
+
)
|
|
206
|
+
: Promise.resolve(COVERAGE_TARGETS.map(() => null)),
|
|
207
|
+
params.include_stats
|
|
208
|
+
? fallbackOnTimeout(runQuery(buildCohortSizeCoverageSql(), { signal, timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS }), signal)
|
|
209
|
+
: Promise.resolve(null),
|
|
168
210
|
params.include_apps
|
|
169
|
-
?
|
|
170
|
-
|
|
171
|
-
"
|
|
172
|
-
|
|
173
|
-
|
|
211
|
+
? fallbackOnTimeout(
|
|
212
|
+
runQuery(
|
|
213
|
+
"select app_code, string_agg(distinct platform, ',' order by platform) as platforms " +
|
|
214
|
+
"from appsflyer_ua_campaign_daily where app_code is not null " +
|
|
215
|
+
"group by 1 order by 1 limit 200",
|
|
216
|
+
{ signal, timeoutMs: CATALOG_OPTIONAL_TIMEOUT_MS },
|
|
217
|
+
),
|
|
218
|
+
signal,
|
|
174
219
|
)
|
|
175
220
|
: Promise.resolve<SqlRow[]>([]),
|
|
176
221
|
]);
|
|
@@ -178,7 +223,15 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
178
223
|
const stats = statsRows[index]?.[0];
|
|
179
224
|
return stats
|
|
180
225
|
? { ...row, ...stats }
|
|
181
|
-
: {
|
|
226
|
+
: {
|
|
227
|
+
...row,
|
|
228
|
+
row_count: null,
|
|
229
|
+
apps: null,
|
|
230
|
+
updated_at: null,
|
|
231
|
+
stats_unavailable: params.include_stats
|
|
232
|
+
? "Row counts timed out; date coverage above is unaffected."
|
|
233
|
+
: "Not requested; set include_stats=true for exact row and app counts.",
|
|
234
|
+
};
|
|
182
235
|
});
|
|
183
236
|
|
|
184
237
|
return toolResult(
|
|
@@ -200,8 +253,25 @@ export default function fpaDataExtension(pi: ExtensionAPI): void {
|
|
|
200
253
|
})),
|
|
201
254
|
coverage,
|
|
202
255
|
cohort_size_available: cohortSize?.[0] ?? null,
|
|
256
|
+
...(cohortSize?.[0]
|
|
257
|
+
? {}
|
|
258
|
+
: {
|
|
259
|
+
cohort_size_unavailable: params.include_stats
|
|
260
|
+
? "Cohort-size coverage timed out; dataset date coverage is unaffected."
|
|
261
|
+
: "Not requested; set include_stats=true for cohort-size availability.",
|
|
262
|
+
}),
|
|
203
263
|
caveats: CATALOG_CAVEATS,
|
|
204
|
-
...(params.include_apps
|
|
264
|
+
...(params.include_apps
|
|
265
|
+
? {
|
|
266
|
+
apps,
|
|
267
|
+
...(apps === null
|
|
268
|
+
? {
|
|
269
|
+
apps_unavailable:
|
|
270
|
+
"App list timed out; dataset definitions and date coverage are unaffected.",
|
|
271
|
+
}
|
|
272
|
+
: {}),
|
|
273
|
+
}
|
|
274
|
+
: {}),
|
|
205
275
|
},
|
|
206
276
|
"coverage",
|
|
207
277
|
);
|
|
@@ -35,6 +35,15 @@ export function resolveConfig(env: Record<string, string | undefined> = process.
|
|
|
35
35
|
|
|
36
36
|
export type SqlRow = Record<string, unknown>;
|
|
37
37
|
|
|
38
|
+
export class SupabaseQueryTimeoutError extends Error {
|
|
39
|
+
readonly code = "SUPABASE_QUERY_TIMEOUT";
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super("Supabase query timed out. Narrow the date range or filters.");
|
|
43
|
+
this.name = "SupabaseQueryTimeoutError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
export async function runQuery(
|
|
39
48
|
sql: string,
|
|
40
49
|
options: { signal?: AbortSignal; timeoutMs?: number; config?: SupabaseConfig } = {},
|
|
@@ -45,6 +54,7 @@ export async function runQuery(
|
|
|
45
54
|
if (options.signal) signals.push(options.signal);
|
|
46
55
|
|
|
47
56
|
let response: Response;
|
|
57
|
+
let body: string;
|
|
48
58
|
try {
|
|
49
59
|
response = await fetch(
|
|
50
60
|
`${API_BASE}/v1/projects/${config.projectRef}/database/query`,
|
|
@@ -58,16 +68,19 @@ export async function runQuery(
|
|
|
58
68
|
signal: AbortSignal.any(signals),
|
|
59
69
|
},
|
|
60
70
|
);
|
|
71
|
+
body = await response.text();
|
|
61
72
|
} catch (error) {
|
|
73
|
+
if (options.signal?.aborted) {
|
|
74
|
+
throw options.signal.reason ?? error;
|
|
75
|
+
}
|
|
62
76
|
if (error instanceof Error && error.name === "TimeoutError") {
|
|
63
|
-
throw new
|
|
77
|
+
throw new SupabaseQueryTimeoutError();
|
|
64
78
|
}
|
|
65
79
|
throw new Error(
|
|
66
80
|
`Could not reach the Supabase Management API: ${error instanceof Error ? error.message : String(error)}`,
|
|
67
81
|
);
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
const body = await response.text();
|
|
71
84
|
if (new TextEncoder().encode(body).byteLength > MAX_RESPONSE_BYTES) {
|
|
72
85
|
throw new Error(
|
|
73
86
|
`Supabase response exceeds ${MAX_RESPONSE_BYTES} bytes. Narrow the query with filters, a shorter date range, or a lower limit.`,
|
|
@@ -86,6 +99,12 @@ export async function runQuery(
|
|
|
86
99
|
payload && typeof payload === "object" && "message" in payload
|
|
87
100
|
? String((payload as { message: unknown }).message)
|
|
88
101
|
: body.slice(0, 500);
|
|
102
|
+
if (
|
|
103
|
+
response.status === 504 ||
|
|
104
|
+
/(?:statement|query|gateway) (?:timed out|timeout)/i.test(detail)
|
|
105
|
+
) {
|
|
106
|
+
throw new SupabaseQueryTimeoutError();
|
|
107
|
+
}
|
|
89
108
|
throw new Error(`Supabase Management API error ${response.status}: ${detail}`);
|
|
90
109
|
}
|
|
91
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viccydev/pi-fpa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"extensions"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
26
|
+
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
27
27
|
"test:structure": "node tests/package-structure.test.mjs",
|
|
28
|
-
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
28
|
+
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
29
29
|
"test:loader": "node tests/pi-loader-smoke.mjs",
|
|
30
30
|
"test:live": "node tests/live-smoke.mjs",
|
|
31
31
|
"pack:check": "npm pack --dry-run"
|
|
@@ -29,7 +29,7 @@ If any requirement is missing, return `blocked` and make no external call. Never
|
|
|
29
29
|
4. Present or record the dry-run result when the adapter supports it.
|
|
30
30
|
5. Execute only the approved mutation set.
|
|
31
31
|
6. Read back the resulting state and reconcile it with the requested state.
|
|
32
|
-
7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), then
|
|
32
|
+
7. Build the canonical `execution_receipt` using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/execution_receipt.input.json`, then commit it with `fpa_artifact_commit` using `artifact_path`.
|
|
33
33
|
|
|
34
34
|
## Stop conditions
|
|
35
35
|
|
|
@@ -37,4 +37,4 @@ Stop before or during execution on version mismatch, target ambiguity, budget mi
|
|
|
37
37
|
|
|
38
38
|
## Boundary
|
|
39
39
|
|
|
40
|
-
Execution ends after reconciliation and a successful canonical receipt commit. A Markdown report is not an execution receipt. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
|
|
40
|
+
Execution ends after reconciliation and a successful canonical receipt commit. A Markdown report is not an execution receipt, and neither is a written draft file — the receipt is frozen only when `fpa_artifact_commit` returns a fingerprint. Do not continuously monitor performance. Performance review begins only when the next cycle's Actuals arrive and uses `$fpa-review-cycle`.
|
|
@@ -40,3 +40,12 @@ blockers: []
|
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
Only adapter responses or independently verified external evidence may populate `applied_mutations`, applied spend, external receipt IDs, and resulting-state evidence. `verification_status: verified` requires a passing reconciliation, resulting-state fingerprint, `executed_at`, and evidence plus applied spend for every receipt slice. A manual report remains `reported` until independently verified, and therefore cannot use `status: complete`. A blocked receipt must have no applied mutations, applied spend, or `executed_at`. Do not include `immutable_fingerprint`; `fpa_artifact_commit` supplies it after strict validation and durable storage.
|
|
43
|
+
|
|
44
|
+
Pass this artifact by path, not inline:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
write artifacts/execution_receipt.input.json
|
|
48
|
+
fpa_artifact_commit { "artifact_path": "artifacts/execution_receipt.input.json" }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`artifact_path` is project-relative, must stay inside the project, and must be a regular file. Emitting a full receipt as one inline argument risks being cut off at the model output limit, which leaves nothing committed while the turn still reads as finished.
|
|
@@ -25,7 +25,37 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
|
|
|
25
25
|
3. Recalculate future-period operating outcomes from that allocation using the declared model.
|
|
26
26
|
4. Provide downside, base, and upside values for each supported KPI.
|
|
27
27
|
5. Reconcile allocation totals, formulas, and cross-metric identities.
|
|
28
|
-
6. Build the canonical `approved_cycle_forecast` input using [artifact-contract.md](references/artifact-contract.md),
|
|
28
|
+
6. Build the canonical `approved_cycle_forecast` input using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/approved_cycle_forecast.input.json`.
|
|
29
|
+
7. Commit it with `fpa_artifact_commit` using `artifact_path`.
|
|
30
|
+
|
|
31
|
+
## Why the draft file
|
|
32
|
+
|
|
33
|
+
A full forecast is far too large to emit as a single inline tool argument: the
|
|
34
|
+
call gets cut off at the model output limit, and what is left behind reads like
|
|
35
|
+
a finished turn. Write the draft with the file tools instead — across as many
|
|
36
|
+
turns and corrections as it takes — then commit the finished file by path. If a
|
|
37
|
+
later step in this graph already commits the draft for you, stop after writing
|
|
38
|
+
it and say so; do not claim a fingerprint you were not handed.
|
|
39
|
+
|
|
40
|
+
## Status and the publish gate
|
|
41
|
+
|
|
42
|
+
`status` describes the quality of what this forecast promised to deliver, not
|
|
43
|
+
how much it promised.
|
|
44
|
+
|
|
45
|
+
- Everything promised was delivered, and every approval condition is met → `complete`.
|
|
46
|
+
- Something promised could not be delivered, or carries a known defect → `complete_with_limits`, with that item and its reason in `unsupported_metrics`.
|
|
47
|
+
- Approval evidence is missing or mismatched → `blocked`.
|
|
48
|
+
|
|
49
|
+
Work the planning stage explicitly placed outside this cycle's scope is neither
|
|
50
|
+
a limit nor a defect: it does not belong in `unsupported_metrics` and does not
|
|
51
|
+
downgrade `status`.
|
|
52
|
+
|
|
53
|
+
This matters downstream: `fpa_dashboard_refresh` with `mode: publish` accepts
|
|
54
|
+
only `complete`, so `complete_with_limits` blocks the dashboard. Do not
|
|
55
|
+
misreport a narrowed scope as a limit and stall the publish, and do not hide a
|
|
56
|
+
real defect to get through the gate. When something that was promised cannot be
|
|
57
|
+
delivered, report `complete_with_limits` honestly and say that the scope
|
|
58
|
+
declaration upstream needs correcting.
|
|
29
59
|
|
|
30
60
|
## Boundaries
|
|
31
61
|
|
|
@@ -36,4 +66,4 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
|
|
|
36
66
|
|
|
37
67
|
## Completion
|
|
38
68
|
|
|
39
|
-
The planning workflow ends only after `fpa_artifact_commit` returns `status: committed` and an `immutable_fingerprint`. A Markdown report is optional context, not the frozen source of truth. Execution, if requested, is a separate workflow using `$fpa-execute-approved-strategy`. Review waits for the next cycle's Actuals and uses `$fpa-review-cycle`.
|
|
69
|
+
The planning workflow ends only after `fpa_artifact_commit` returns `status: committed` and an `immutable_fingerprint`. A Markdown report is optional context, not the frozen source of truth. Never report a forecast as frozen on the strength of a written file alone — the file is a draft until the tool returns a fingerprint. Execution, if requested, is a separate workflow using `$fpa-execute-approved-strategy`. Review waits for the next cycle's Actuals and uses `$fpa-review-cycle`.
|
|
@@ -43,4 +43,13 @@ reconciliation_checks: []
|
|
|
43
43
|
frozen_at: timestamp
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Do not include `immutable_fingerprint` in the tool input. `fpa_artifact_commit` validates exact fields, approval state, allocation totals, every scenario's slice-to-consolidated totals, and supplied ROAS against aggregated revenue/spend. It computes the fingerprint and returns it after durable storage. The agent must not claim the artifact is frozen until that tool succeeds.
|
|
46
|
+
Do not include `immutable_fingerprint` in the tool input; a draft that carries one is rejected. `fpa_artifact_commit` validates exact fields, approval state, allocation totals, every scenario's slice-to-consolidated totals, and supplied ROAS against aggregated revenue/spend. It computes the fingerprint and returns it after durable storage. The agent must not claim the artifact is frozen until that tool succeeds.
|
|
47
|
+
|
|
48
|
+
Pass this artifact by path, not inline:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
write artifacts/approved_cycle_forecast.input.json
|
|
52
|
+
fpa_artifact_commit { "artifact_path": "artifacts/approved_cycle_forecast.input.json" }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`artifact_path` is project-relative, must stay inside the project, and must be a regular file. The inline `artifact` parameter still works and is fine for small artifacts, but a forecast of any real size will be truncated at the model output limit if you try to emit it in one call.
|