@viccydev/pi-fpa 0.3.3 → 0.4.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 CHANGED
@@ -110,20 +110,22 @@ pi list
110
110
  团队分发建议使用固定 Git tag:
111
111
 
112
112
  ```bash
113
- pi install git:github.com/linyqh/pi-fpa@v0.3.2
113
+ pi install git:github.com/linyqh/pi-fpa@v0.4.1
114
114
  ```
115
115
 
116
116
  ## 发布到 npm
117
117
 
118
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.3.3` 对应 `v0.3.3`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
118
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.4.1` 对应 `v0.4.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
119
119
 
120
- 首次发布前需要完成一次仓库配置:
120
+ 发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
121
121
 
122
- 1. 确认 npm 账号或组织拥有 `@viccydev` scope
123
- 2. GitHub 仓库的 Actions secrets 中创建 `NPM_TOKEN`。Token 需要允许发布该 scope,并允许 CI 绕过发布时的 2FA。
124
- 3. 将仓库推送到 GitHub,然后创建与 `package.json` 版本匹配的 GitHub Release。
122
+ 1. Provider:GitHub Actions
123
+ 2. Organization or user:`linyqh`。
124
+ 3. Repository:`pi-fpa`。
125
+ 4. Workflow filename:`publish.yml`。
126
+ 5. Allowed actions:`npm publish`。
125
127
 
126
- 首发成功后,建议到 npm 包设置中将 Trusted Publisher 配置为 GitHub Actions;GitHub 用户填写 `linyqh`,仓库填写 `pi-fpa`,工作流文件填写 `publish.yml`,Allowed actions 选择 `npm publish`。验证 OIDC 发布成功后即可删除长期 `NPM_TOKEN`。当前许可证仍是 `UNLICENSED`;若准备让第三方使用或修改本包,应在首发前明确许可证。
128
+ 工作流必须保留 `permissions.id-token: write`,并使用满足 npm Trusted Publishing 最低版本要求的 Node/npm;不要重新添加 `NPM_TOKEN` `NODE_AUTH_TOKEN`。当前 GitHub 仓库为 private,OIDC 发布可用,但 npm 不会生成 provenance。当前许可证仍是 `UNLICENSED`;若准备让第三方使用或修改本包,应先明确许可证。
127
129
 
128
130
  ## 使用
129
131
 
@@ -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 committed = await commitArtifact(ctx.cwd, params.artifact);
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 > 2 * 1024 * 1024) throw new Error(`${artifactType} exceeds the 2MB artifact limit.`);
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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
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",
@@ -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 call `fpa_artifact_commit`.
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), then call `fpa_artifact_commit`.
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.