@gr8ful/spf 0.18.0 → 0.19.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 +8 -0
- package/assets/skill/references/config.md +1 -0
- package/dist/cli/commands/doctor.js +25 -3
- package/dist/cli/commands/estimate.d.ts +22 -6
- package/dist/cli/commands/estimate.js +32 -10
- package/dist/cli/commands/loop.d.ts +20 -0
- package/dist/cli/commands/loop.js +20 -1
- package/dist/cli/commands/ui.js +2 -1
- package/dist/cli/commands/watch.js +1 -1
- package/dist/cli/index.js +2 -2
- package/dist/cli/interview.js +13 -0
- package/dist/cli/ui/run_dashboard.js +13 -7
- package/dist/core/agent_cc.d.ts +19 -3
- package/dist/core/agent_cc.js +38 -18
- package/dist/core/agent_flue.js +51 -14
- package/dist/core/agent_opencode.d.ts +62 -25
- package/dist/core/agent_opencode.js +71 -30
- package/dist/core/agents.d.ts +51 -4
- package/dist/core/agents.js +79 -4
- package/dist/core/console.d.ts +24 -4
- package/dist/core/console.js +20 -7
- package/dist/core/data_types.d.ts +300 -19
- package/dist/core/data_types.js +134 -5
- package/dist/core/issues/jira_provider.d.ts +58 -3
- package/dist/core/issues/jira_provider.js +89 -18
- package/dist/core/issues/markdown_adf.d.ts +58 -0
- package/dist/core/issues/markdown_adf.js +705 -0
- package/dist/core/issues/provider.d.ts +23 -0
- package/dist/core/loop.d.ts +39 -1
- package/dist/core/loop.js +33 -2
- package/dist/core/ollama_provider.d.ts +96 -13
- package/dist/core/ollama_provider.js +172 -26
- package/dist/core/otel.js +10 -1
- package/dist/core/otel_propagation.d.ts +168 -24
- package/dist/core/otel_propagation.js +219 -43
- package/dist/core/permissions.d.ts +16 -1
- package/dist/core/permissions.js +91 -3
- package/dist/core/providers.js +8 -3
- package/dist/core/refine.js +13 -1
- package/dist/core/runner.d.ts +33 -2
- package/dist/core/runner.js +40 -5
- package/dist/core/tiering.js +7 -3
- package/dist/core/tracer.d.ts +7 -1
- package/dist/core/tracer.js +15 -3
- package/dist/ui/server/db.d.ts +8 -1
- package/dist/ui/server/db.js +21 -4
- package/dist/ui/server/serve.d.ts +7 -0
- package/dist/ui/server/serve.js +10 -7
- package/dist/ui/shared/types.d.ts +16 -0
- package/package.json +3 -3
package/dist/core/permissions.js
CHANGED
|
@@ -76,6 +76,19 @@ export function changedPaths(before, after) {
|
|
|
76
76
|
function globToRegex(pattern) {
|
|
77
77
|
let out = "";
|
|
78
78
|
let i = 0;
|
|
79
|
+
// A LEADING "**/" is the common "any depth, including the repo root"
|
|
80
|
+
// idiom (gitignore, npm, ...) — "**/package-lock.json" must match both a
|
|
81
|
+
// root-level "package-lock.json" and a nested "a/b/package-lock.json".
|
|
82
|
+
// The plain "**" -> ".*" rule below (still applied to a "**" anywhere else
|
|
83
|
+
// in a pattern) cannot express that alone: ".*" still requires the
|
|
84
|
+
// literal "/" that follows it in the pattern text, so a root-level file
|
|
85
|
+
// with no directory prefix would never match. Only this one leading shape
|
|
86
|
+
// gets the optional-prefix translation; `defaults.read_only_ignore`'s own
|
|
87
|
+
// default patterns are exactly this shape.
|
|
88
|
+
if (pattern.startsWith("**/")) {
|
|
89
|
+
out += "(?:.*/)?";
|
|
90
|
+
i = 3;
|
|
91
|
+
}
|
|
79
92
|
while (i < pattern.length) {
|
|
80
93
|
const char = pattern[i];
|
|
81
94
|
if (pattern.startsWith("**", i)) {
|
|
@@ -160,6 +173,58 @@ function rollBack(run, p, before, after) {
|
|
|
160
173
|
const result = spawnSync("git", ["checkout", "--", p], { cwd: run.repo_root, encoding: "utf-8" });
|
|
161
174
|
return result.status === 0 ? "rolled back" : "could not roll back";
|
|
162
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* True when `p` matches one of `defaults.read_only_ignore`'s patterns —
|
|
178
|
+
* dependency-manager bookkeeping (a lockfile), not the repo's intent. See
|
|
179
|
+
* that field's own doc comment (`data_types.ts`) for why it exists and what
|
|
180
|
+
* rolling one back "silently" means. Necessary but not sufficient — see
|
|
181
|
+
* `isSafeToIgnore`, which is what `enforce()` actually gates on, and which
|
|
182
|
+
* ALSO requires the path not match `defaults.protected_files`:
|
|
183
|
+
* `read_only_ignore` is a narrow "this churn is incidental, not intent"
|
|
184
|
+
* carve-out, and must never be read as a backdoor around a path an operator
|
|
185
|
+
* explicitly locked down. `protected_files` always wins — a path listed
|
|
186
|
+
* there is never ignorable via `read_only_ignore`, regardless of role.
|
|
187
|
+
*/
|
|
188
|
+
function isIgnorableChurn(p, cfg) {
|
|
189
|
+
return (cfg.defaults.read_only_ignore ?? []).some((pattern) => matches(p, pattern));
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether an ignorable-churn path is actually safe to ignore for THIS phase.
|
|
193
|
+
* Both conditions are required, and either one failing means: real breach,
|
|
194
|
+
* `rollBack`'s own honest outcome string, no silent "ignored".
|
|
195
|
+
*
|
|
196
|
+
* - The agent is a TRUE read-only role (`writes: []`), not merely
|
|
197
|
+
* write-restricted (`writes: [...]`). A write-restricted agent that
|
|
198
|
+
* changes `package.json` and its lockfile together left an
|
|
199
|
+
* INCONSISTENT tree, not incidental dependency-manager bookkeeping —
|
|
200
|
+
* `read_only_ignore` exists for the read-only case this module's header
|
|
201
|
+
* describes (a read that happens to rewrite a lockfile), never as a
|
|
202
|
+
* blanket exemption for one specific file pattern regardless of role.
|
|
203
|
+
* - The path was CLEAN before this phase started (`!(p in before)`). Only
|
|
204
|
+
* then does `rollBack` below actually take the "not in before" branch
|
|
205
|
+
* and restore it (delete the untracked file, or `git checkout --` the
|
|
206
|
+
* tracked one back to HEAD). A path that was ALREADY dirty when the
|
|
207
|
+
* agent started hits `rollBack`'s OTHER branch instead — "left as-is"
|
|
208
|
+
* or, if the agent discarded that uncommitted work, "REVERTED-BY-AGENT
|
|
209
|
+
* (uncommitted work lost, cannot restore)" — and neither of those is a
|
|
210
|
+
* restore. Calling that "ignored" would report a repair that never
|
|
211
|
+
* happened; it must fail the phase like any other breach instead.
|
|
212
|
+
* - The path does NOT match `defaults.protected_files`. `protected_files`
|
|
213
|
+
* is what made this a breach in the first place (`permitted()` above);
|
|
214
|
+
* `read_only_ignore` matching the SAME path too is not a stronger claim
|
|
215
|
+
* that the write was safe, it just means an operator's lockfile-churn
|
|
216
|
+
* pattern happens to overlap a path they explicitly protected. Without
|
|
217
|
+
* this check a `read_only_ignore` entry could silently exempt a
|
|
218
|
+
* read-only agent from `protected_files` — the very thing `protected_files`
|
|
219
|
+
* exists to prevent regardless of an agent's `writes` role. So a
|
|
220
|
+
* protected path is never ignorable: it always falls through to the
|
|
221
|
+
* real-breach path below, still rolled back, but failing the phase.
|
|
222
|
+
*/
|
|
223
|
+
function isSafeToIgnore(p, agent, cfg, before) {
|
|
224
|
+
const isTrueReadOnlyAgent = Array.isArray(agent.writes) && agent.writes.length === 0;
|
|
225
|
+
const isProtected = cfg.defaults.protected_files.some((pattern) => matches(p, pattern));
|
|
226
|
+
return isTrueReadOnlyAgent && isIgnorableChurn(p, cfg) && !(p in before) && !isProtected;
|
|
227
|
+
}
|
|
163
228
|
/**
|
|
164
229
|
* Compare the tree against `before`; undo and raise if the agent overstepped.
|
|
165
230
|
*
|
|
@@ -169,19 +234,42 @@ function rollBack(run, p, before, after) {
|
|
|
169
234
|
* Detection alone would leave the repo holding the unauthorized change while
|
|
170
235
|
* reporting a failure, so anything the agent introduced outside its allowlist
|
|
171
236
|
* is rolled back before the phase dies. What it cannot undo, it names.
|
|
237
|
+
*
|
|
238
|
+
* `defaults.read_only_ignore` carves out one exception to "names, fails" —
|
|
239
|
+
* but only where `isSafeToIgnore` says restoring is actually possible (see
|
|
240
|
+
* its own doc comment): a TRUE read-only agent (`writes: []`) that churned
|
|
241
|
+
* a lockfile that was CLEAN before this phase. That one case is STILL
|
|
242
|
+
* rolled back unconditionally like any other breach — an ignored path is
|
|
243
|
+
* never left standing — it just does not, on its own, fail the phase.
|
|
244
|
+
* Everything else `isIgnorableChurn` alone would have matched (a dirty-before
|
|
245
|
+
* path, an agent that reverted uncommitted work, a write-restricted rather
|
|
246
|
+
* than read-only agent) falls through to the real-breach path below instead.
|
|
247
|
+
* `onIgnored`, when given, is called once with every safely-ignored path
|
|
248
|
+
* before returning, so a caller with a logger (`agents.ts`'s `execute()`)
|
|
249
|
+
* can print the one required info line — this module has no logger of its
|
|
250
|
+
* own to call (`RunLike` is only `repo_root`/`cfg`), so the caller does the
|
|
251
|
+
* printing.
|
|
172
252
|
*/
|
|
173
|
-
export function enforce(run, _phase, agent, before) {
|
|
253
|
+
export function enforce(run, _phase, agent, before, onIgnored) {
|
|
174
254
|
const after = snapshot(run);
|
|
175
255
|
const touched = changedPaths(before, after);
|
|
176
256
|
const breaches = touched.filter((p) => !permitted(p, agent, run.cfg));
|
|
177
257
|
if (breaches.length === 0)
|
|
178
258
|
return touched;
|
|
259
|
+
const ignored = breaches.filter((p) => isSafeToIgnore(p, agent, run.cfg, before));
|
|
260
|
+
const realBreaches = breaches.filter((p) => !ignored.includes(p));
|
|
261
|
+
// Roll back EVERY breach, ignored ones included — restoring the tree is
|
|
262
|
+
// unconditional; only whether it fails the PHASE differs below.
|
|
179
263
|
const outcomes = new Map(breaches.map((p) => [p, rollBack(run, p, before, after)]));
|
|
264
|
+
if (ignored.length > 0)
|
|
265
|
+
onIgnored?.(ignored);
|
|
266
|
+
if (realBreaches.length === 0)
|
|
267
|
+
return touched; // nothing left but ignored churn — rolled back, phase still passes
|
|
180
268
|
const scope = agent.writes && agent.writes.length === 0
|
|
181
269
|
? "read-only"
|
|
182
270
|
: agent.writes
|
|
183
271
|
? `limited to ${JSON.stringify(agent.writes)}`
|
|
184
272
|
: `barred from ${JSON.stringify(run.cfg.defaults.protected_files)}`;
|
|
185
|
-
const detail =
|
|
186
|
-
throw new PermissionBreach(`${agent.name} is ${scope} but modified ${
|
|
273
|
+
const detail = realBreaches.map((p) => ` - ${p} — ${outcomes.get(p)}`).join("\n");
|
|
274
|
+
throw new PermissionBreach(`${agent.name} is ${scope} but modified ${realBreaches.length} path(s):\n${detail}`);
|
|
187
275
|
}
|
package/dist/core/providers.js
CHANGED
|
@@ -21,9 +21,14 @@ export const PROVIDER_ENV_KEYS = {
|
|
|
21
21
|
deepseek: ["DEEPSEEK_API_KEY"],
|
|
22
22
|
together: ["TOGETHER_API_KEY"],
|
|
23
23
|
cerebras: ["CEREBRAS_API_KEY"],
|
|
24
|
-
// Keyless: a local server, not a hosted API — nothing to
|
|
25
|
-
// prompt for. An empty array here means "known provider,
|
|
26
|
-
// never "unknown provider" (that's a missing table entry,
|
|
24
|
+
// Keyless by default: a local server, not a hosted API — nothing to
|
|
25
|
+
// require or prompt for. An empty array here means "known provider,
|
|
26
|
+
// needs no key", never "unknown provider" (that's a missing table entry,
|
|
27
|
+
// not `[]`). NOT the same as "no key is ever honored": ollama_provider.ts's
|
|
28
|
+
// `ollamaApiKey()` reads `OLLAMA_API_KEY` and sends it as the bearer when
|
|
29
|
+
// set (e.g. required by a gateway in front of Ollama, like Envoy AI
|
|
30
|
+
// Gateway), falling back to its dummy placeholder otherwise — optional,
|
|
31
|
+
// not required, which is why it stays out of this required-keys table.
|
|
27
32
|
ollama: [],
|
|
28
33
|
// Cloudflare Workers AI — a real Bearer token (NOT keyless like ollama;
|
|
29
34
|
// Cloudflare's API 401s on an empty Authorization header). The base URL
|
package/dist/core/refine.js
CHANGED
|
@@ -41,7 +41,7 @@ export function resolveAuthoringProvider(cfg) {
|
|
|
41
41
|
if (!email || !token) {
|
|
42
42
|
throw new Error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — the refine lane needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens)');
|
|
43
43
|
}
|
|
44
|
-
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map);
|
|
44
|
+
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types, cfg.watch.jira.status_map, cfg.watch.jira.link_type);
|
|
45
45
|
}
|
|
46
46
|
if (cfg.watch.issue_provider !== "github") {
|
|
47
47
|
throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — the refine lane needs "github" or "jira"`);
|
|
@@ -232,6 +232,18 @@ export async function publish(tracker, issues, opts) {
|
|
|
232
232
|
if (parent)
|
|
233
233
|
await tracker.linkChild(parent.issue, issue);
|
|
234
234
|
}
|
|
235
|
+
else if (opts.specIssueId) {
|
|
236
|
+
// THE GAP THIS CLOSES: `node.parent` only ever names another node
|
|
237
|
+
// IN THIS TREE — the tree's own root(s) have none, so the branch
|
|
238
|
+
// above never runs for them, and nothing else in this loop connects
|
|
239
|
+
// a root back to the spec it was refined FROM. Before this, the only
|
|
240
|
+
// trace of that relationship was `renderBody`'s "## Parent" TEXT
|
|
241
|
+
// (still rendered, unchanged) — real on GitHub (auto-linked "#N"),
|
|
242
|
+
// invisible on Jira (plain text, no cross-reference). `linkToSpec` is
|
|
243
|
+
// optional and best-effort on purpose — see its own doc comment
|
|
244
|
+
// (`issues/provider.ts`) for why this is never `linkChild`.
|
|
245
|
+
await tracker.linkToSpec?.(opts.specIssueId, issue);
|
|
246
|
+
}
|
|
235
247
|
}
|
|
236
248
|
return created;
|
|
237
249
|
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { type GitHandle } from "./git_helper.ts";
|
|
12
12
|
import { Console, type RunObserver } from "./console.ts";
|
|
13
13
|
import { Tracer } from "./tracer.ts";
|
|
14
|
-
import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
|
|
14
|
+
import { type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
|
|
15
15
|
import type { TierResolution } from "./tiering.ts";
|
|
16
16
|
import type { Notifier } from "./notify/notifier.ts";
|
|
17
17
|
interface AgentMapEntry {
|
|
@@ -60,6 +60,30 @@ export declare class Run {
|
|
|
60
60
|
phases: Phase[];
|
|
61
61
|
tokens: number;
|
|
62
62
|
cost: number;
|
|
63
|
+
/** The BILLABLE half of `tokens` — see `UsageBreakdown.billable_tokens`'s doc comment. What `assertRunBudget` actually checks `defaults.max_run_tokens` against; `tokens` stays the display total. */
|
|
64
|
+
billable_tokens: number;
|
|
65
|
+
/**
|
|
66
|
+
* True once this run has DISPATCHED at least one `claude_code` agent
|
|
67
|
+
* while `ANTHROPIC_BASE_URL` pointed somewhere other than Anthropic's own
|
|
68
|
+
* API — i.e. a gateway/proxy stood in for Anthropic on a call that
|
|
69
|
+
* actually happened. Read by `Console.sessionFinished` to label the run's
|
|
70
|
+
* printed cost as an estimate rather than a fact: `claude`'s own
|
|
71
|
+
* `total_cost_usd` is ANTHROPIC's price table applied to whatever the CLI
|
|
72
|
+
* thinks it called, which is honest only when Anthropic itself served the
|
|
73
|
+
* request.
|
|
74
|
+
*
|
|
75
|
+
* Starts `false` and is flipped by `recordDispatch()` below, called once
|
|
76
|
+
* per agent dispatch (`agents.ts`'s `execute()`, right before the real
|
|
77
|
+
* coding-agent call) — computed from what this run actually DISPATCHED,
|
|
78
|
+
* never from the roster's static shape (see `agents.ts`'s
|
|
79
|
+
* `isGatewayEstimatedDispatch` vs. `isGatewayEstimatedCost` doc comments
|
|
80
|
+
* for why that distinction matters: a chain can configure a `claude_code`
|
|
81
|
+
* agent it never actually calls this run, and labeling a real cost as
|
|
82
|
+
* "estimated" because the roster merely CONTAINS such an agent would be
|
|
83
|
+
* its own kind of dishonesty). Sticky: once a qualifying dispatch has
|
|
84
|
+
* happened, a later non-qualifying one must never flip it back to false.
|
|
85
|
+
*/
|
|
86
|
+
cost_is_estimate: boolean;
|
|
63
87
|
repo_root: string;
|
|
64
88
|
/** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
|
|
65
89
|
git: GitHandle;
|
|
@@ -81,7 +105,14 @@ export declare class Run {
|
|
|
81
105
|
private agentMapPath;
|
|
82
106
|
constructor(init: RunInit);
|
|
83
107
|
saveAgentMap(agent: string, entry: AgentMapEntry): void;
|
|
84
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Called once per agent dispatch, right before the real coding-agent call
|
|
110
|
+
* (`agents.ts`'s `execute()`) — see `cost_is_estimate`'s own doc comment
|
|
111
|
+
* for why this, not the roster, is what decides the label. Sticky: only
|
|
112
|
+
* ever flips `cost_is_estimate` from false to true, never back.
|
|
113
|
+
*/
|
|
114
|
+
recordDispatch(agent: AgentConfig): void;
|
|
115
|
+
addUsage(tokens: number, cost: number, billableTokens: number): Promise<void>;
|
|
85
116
|
phase<T>(params: PhaseParams, fn: (ph: PhaseHandle) => Promise<T>): Promise<T>;
|
|
86
117
|
/**
|
|
87
118
|
* Finalize the run and return its exit code. Call this exactly once.
|
package/dist/core/runner.js
CHANGED
|
@@ -66,6 +66,30 @@ export class Run {
|
|
|
66
66
|
phases = [];
|
|
67
67
|
tokens = 0;
|
|
68
68
|
cost = 0;
|
|
69
|
+
/** The BILLABLE half of `tokens` — see `UsageBreakdown.billable_tokens`'s doc comment. What `assertRunBudget` actually checks `defaults.max_run_tokens` against; `tokens` stays the display total. */
|
|
70
|
+
billable_tokens = 0;
|
|
71
|
+
/**
|
|
72
|
+
* True once this run has DISPATCHED at least one `claude_code` agent
|
|
73
|
+
* while `ANTHROPIC_BASE_URL` pointed somewhere other than Anthropic's own
|
|
74
|
+
* API — i.e. a gateway/proxy stood in for Anthropic on a call that
|
|
75
|
+
* actually happened. Read by `Console.sessionFinished` to label the run's
|
|
76
|
+
* printed cost as an estimate rather than a fact: `claude`'s own
|
|
77
|
+
* `total_cost_usd` is ANTHROPIC's price table applied to whatever the CLI
|
|
78
|
+
* thinks it called, which is honest only when Anthropic itself served the
|
|
79
|
+
* request.
|
|
80
|
+
*
|
|
81
|
+
* Starts `false` and is flipped by `recordDispatch()` below, called once
|
|
82
|
+
* per agent dispatch (`agents.ts`'s `execute()`, right before the real
|
|
83
|
+
* coding-agent call) — computed from what this run actually DISPATCHED,
|
|
84
|
+
* never from the roster's static shape (see `agents.ts`'s
|
|
85
|
+
* `isGatewayEstimatedDispatch` vs. `isGatewayEstimatedCost` doc comments
|
|
86
|
+
* for why that distinction matters: a chain can configure a `claude_code`
|
|
87
|
+
* agent it never actually calls this run, and labeling a real cost as
|
|
88
|
+
* "estimated" because the roster merely CONTAINS such an agent would be
|
|
89
|
+
* its own kind of dishonesty). Sticky: once a qualifying dispatch has
|
|
90
|
+
* happened, a later non-qualifying one must never flip it back to false.
|
|
91
|
+
*/
|
|
92
|
+
cost_is_estimate = false;
|
|
69
93
|
repo_root; // where every agent is spawned to work — always absolute
|
|
70
94
|
/** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
|
|
71
95
|
git;
|
|
@@ -107,12 +131,23 @@ export class Run {
|
|
|
107
131
|
this.agent_map[agent] = entry;
|
|
108
132
|
writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
|
|
109
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Called once per agent dispatch, right before the real coding-agent call
|
|
136
|
+
* (`agents.ts`'s `execute()`) — see `cost_is_estimate`'s own doc comment
|
|
137
|
+
* for why this, not the roster, is what decides the label. Sticky: only
|
|
138
|
+
* ever flips `cost_is_estimate` from false to true, never back.
|
|
139
|
+
*/
|
|
140
|
+
recordDispatch(agent) {
|
|
141
|
+
if (agents.isGatewayEstimatedDispatch(agent))
|
|
142
|
+
this.cost_is_estimate = true;
|
|
143
|
+
}
|
|
110
144
|
// ── usage (run totals mirror what the tracer accumulates in the trace db) ─
|
|
111
|
-
async addUsage(tokens, cost) {
|
|
145
|
+
async addUsage(tokens, cost, billableTokens) {
|
|
112
146
|
this.tokens += tokens;
|
|
113
147
|
this.cost += cost;
|
|
114
|
-
|
|
115
|
-
await this.
|
|
148
|
+
this.billable_tokens += billableTokens;
|
|
149
|
+
await this.tracer.sessionAddUsage(this.adw_id, tokens, cost, billableTokens);
|
|
150
|
+
await this.console.notifyUsage(this.tokens, this.cost, this.billable_tokens);
|
|
116
151
|
}
|
|
117
152
|
// ── the phase primitive ─────────────────────────────────────────────────
|
|
118
153
|
async phase(params, fn) {
|
|
@@ -157,7 +192,7 @@ export class Run {
|
|
|
157
192
|
await this.tracer.phaseUpsert(phase);
|
|
158
193
|
await this.tracer.sessionFinish(this.adw_id, false);
|
|
159
194
|
await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
160
|
-
await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
|
|
195
|
+
await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db), this.cost_is_estimate);
|
|
161
196
|
throw error;
|
|
162
197
|
}
|
|
163
198
|
}
|
|
@@ -185,7 +220,7 @@ export class Run {
|
|
|
185
220
|
await this.console.note(`not accepted: ${note}`);
|
|
186
221
|
}
|
|
187
222
|
await this.tracer.sessionFinish(this.adw_id, ok);
|
|
188
|
-
await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
|
|
223
|
+
await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db), this.cost_is_estimate);
|
|
189
224
|
return ok ? 0 : 1;
|
|
190
225
|
}
|
|
191
226
|
}
|
package/dist/core/tiering.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* stands. `effectiveAgent` is the one place that turns a resolution into an
|
|
26
26
|
* actual `AgentConfig` — overriding `model` and NOTHING else.
|
|
27
27
|
*/
|
|
28
|
-
import { ollamaBaseUrl } from "./ollama_provider.js";
|
|
28
|
+
import { ollamaApiKey, ollamaBaseUrl } from "./ollama_provider.js";
|
|
29
29
|
// ── the classifier (design doc §2) ──────────────────────────────────────────
|
|
30
30
|
/**
|
|
31
31
|
* One explicit `name -> weight` table entry per BUILT-IN chain
|
|
@@ -184,9 +184,13 @@ async function fetchServedOllamaTags() {
|
|
|
184
184
|
try {
|
|
185
185
|
// ollamaBaseUrl() is the SAME default-substitution a real dispatch
|
|
186
186
|
// uses, including treating a set-but-EMPTY OLLAMA_BASE_URL as unset —
|
|
187
|
-
// never re-derive that default here.
|
|
187
|
+
// never re-derive that default here. Same for ollamaApiKey(): against a
|
|
188
|
+
// gateway (not bare Ollama) this endpoint 401s without a bearer — send
|
|
189
|
+
// the SAME dummy-or-real bearer a real dispatch sends (byte-identical
|
|
190
|
+
// to doctor.ts's own OLLAMA_BASE_URL reachability probe), or this probe
|
|
191
|
+
// fails closed (401 -> null) even when a real dispatch would succeed.
|
|
188
192
|
const url = ollamaBaseUrl().replace(/\/+$/, "") + "/models";
|
|
189
|
-
const res = await fetch(url, { signal: controller.signal });
|
|
193
|
+
const res = await fetch(url, { signal: controller.signal, headers: { authorization: `Bearer ${ollamaApiKey()}` } });
|
|
190
194
|
if (res.status !== 200)
|
|
191
195
|
return null;
|
|
192
196
|
const body = (await res.json());
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -87,7 +87,13 @@ export declare class Tracer {
|
|
|
87
87
|
sessionStart(adwId: string, engineer: string, adwName?: string | null): Promise<void>;
|
|
88
88
|
sessionRequest(adwId: string, request: string): Promise<void>;
|
|
89
89
|
sessionFinish(adwId: string, ok: boolean): Promise<void>;
|
|
90
|
-
|
|
90
|
+
/**
|
|
91
|
+
* `tokens`/`cost` are the DISPLAY totals (`total_tokens`, unchanged from
|
|
92
|
+
* before `billable_tokens` existed); `billableTokens` is the new column —
|
|
93
|
+
* see `UsageBreakdown.billable_tokens`'s doc comment for what it excludes
|
|
94
|
+
* and why. Both accumulate in the same row, same as before.
|
|
95
|
+
*/
|
|
96
|
+
sessionAddUsage(adwId: string, tokens: number, cost: number, billableTokens: number): Promise<void>;
|
|
91
97
|
/**
|
|
92
98
|
* Record a live process for this run.
|
|
93
99
|
*
|
package/dist/core/tracer.js
CHANGED
|
@@ -123,6 +123,12 @@ const MIGRATIONS = [
|
|
|
123
123
|
["agent_sessions", "context_tokens", "INTEGER"],
|
|
124
124
|
["agent_sessions", "context_window", "INTEGER"],
|
|
125
125
|
["sessions", "archived", "INTEGER DEFAULT 0"],
|
|
126
|
+
// The BILLABLE half of `total_tokens` — see `UsageBreakdown.billable_tokens`'s
|
|
127
|
+
// own doc comment (`data_types.ts`) for why the two diverge. `total_tokens`
|
|
128
|
+
// is untouched (still every re-sent token, cache reads included); this is
|
|
129
|
+
// the new column `sessionAddUsage` also increments, so a trace db from
|
|
130
|
+
// before this existed just gets a column full of 0 until its next run.
|
|
131
|
+
["sessions", "billable_tokens", "INTEGER DEFAULT 0"],
|
|
126
132
|
];
|
|
127
133
|
export class Tracer {
|
|
128
134
|
db;
|
|
@@ -285,10 +291,16 @@ export class Tracer {
|
|
|
285
291
|
if (processesError)
|
|
286
292
|
throw processesError;
|
|
287
293
|
}
|
|
288
|
-
|
|
294
|
+
/**
|
|
295
|
+
* `tokens`/`cost` are the DISPLAY totals (`total_tokens`, unchanged from
|
|
296
|
+
* before `billable_tokens` existed); `billableTokens` is the new column —
|
|
297
|
+
* see `UsageBreakdown.billable_tokens`'s doc comment for what it excludes
|
|
298
|
+
* and why. Both accumulate in the same row, same as before.
|
|
299
|
+
*/
|
|
300
|
+
async sessionAddUsage(adwId, tokens, cost, billableTokens) {
|
|
289
301
|
await this.db
|
|
290
|
-
.query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+? WHERE adw_id=?")
|
|
291
|
-
.run(tokens, cost, adwId);
|
|
302
|
+
.query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+?, billable_tokens=billable_tokens+? WHERE adw_id=?")
|
|
303
|
+
.run(tokens, cost, billableTokens, adwId);
|
|
292
304
|
}
|
|
293
305
|
// ── processes (adw_id → pid, so a hung run can be found and killed) ─────
|
|
294
306
|
/**
|
package/dist/ui/server/db.d.ts
CHANGED
|
@@ -180,10 +180,17 @@ export interface ChainHistorySession {
|
|
|
180
180
|
started_at: string | null;
|
|
181
181
|
total_tokens: number;
|
|
182
182
|
total_cost: number;
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`,
|
|
185
|
+
* `fix_2`), not yet loop-normalized. `tokens` is the display total;
|
|
186
|
+
* `billable_tokens` is what `spf estimate`'s ceiling projection
|
|
187
|
+
* (`findCutoffPhase`) actually walks against `defaults.max_run_tokens` —
|
|
188
|
+
* see that field's own doc comment above for the old-row fallback.
|
|
189
|
+
*/
|
|
184
190
|
phases: {
|
|
185
191
|
name: string;
|
|
186
192
|
seq: number;
|
|
187
193
|
tokens: number;
|
|
194
|
+
billable_tokens: number;
|
|
188
195
|
}[];
|
|
189
196
|
}
|
package/dist/ui/server/db.js
CHANGED
|
@@ -237,7 +237,7 @@ export class SfDb {
|
|
|
237
237
|
const rows = await this.db
|
|
238
238
|
.query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
|
|
239
239
|
status, engineer, started_at, ended_at,
|
|
240
|
-
total_tokens, total_cost,
|
|
240
|
+
total_tokens, total_cost, ${await this.optionalColumn("sessions", "billable_tokens")},
|
|
241
241
|
${await this.optionalColumn("sessions", "archived")}
|
|
242
242
|
FROM sessions
|
|
243
243
|
WHERE COALESCE(${(await this.hasColumn("sessions", "archived")) ? "archived" : "0"}, 0) = 0
|
|
@@ -282,7 +282,7 @@ export class SfDb {
|
|
|
282
282
|
return ((await this.db
|
|
283
283
|
.query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
|
|
284
284
|
status, engineer, started_at, ended_at,
|
|
285
|
-
total_tokens, total_cost
|
|
285
|
+
total_tokens, total_cost, ${await this.optionalColumn("sessions", "billable_tokens")}
|
|
286
286
|
FROM sessions WHERE adw_id = ?`)
|
|
287
287
|
.get(adwId)) ?? null);
|
|
288
288
|
}
|
|
@@ -497,7 +497,7 @@ export class SfDb {
|
|
|
497
497
|
const phaseRows = await this.chunked(ids, (chunk) => {
|
|
498
498
|
const placeholders = chunk.map(() => "?").join(", ");
|
|
499
499
|
return this.db
|
|
500
|
-
.query(`SELECT p.adw_id, p.name, p.seq, e.tokens
|
|
500
|
+
.query(`SELECT p.adw_id, p.name, p.seq, e.tokens, e.payload_json
|
|
501
501
|
FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
|
|
502
502
|
WHERE p.adw_id IN (${placeholders})
|
|
503
503
|
ORDER BY p.seq ASC`)
|
|
@@ -506,7 +506,24 @@ export class SfDb {
|
|
|
506
506
|
const phasesByAdw = new Map();
|
|
507
507
|
for (const row of phaseRows) {
|
|
508
508
|
const list = phasesByAdw.get(row.adw_id);
|
|
509
|
-
const
|
|
509
|
+
const totalTokens = row.tokens ?? 0;
|
|
510
|
+
// Derived from the payload, same "parse it in JS" approach `usage()`
|
|
511
|
+
// above already uses (never a stored column) — so a phase from before
|
|
512
|
+
// `billable_tokens` existed just falls back to the total, exactly
|
|
513
|
+
// like `Session.billable_tokens`'s own doc comment (`ui/shared/types.ts`)
|
|
514
|
+
// describes for the session-level number.
|
|
515
|
+
let billableTokens = totalTokens;
|
|
516
|
+
if (row.payload_json) {
|
|
517
|
+
try {
|
|
518
|
+
const usage = JSON.parse(row.payload_json).usage;
|
|
519
|
+
if (usage && typeof usage.billable_tokens === "number")
|
|
520
|
+
billableTokens = usage.billable_tokens;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
/* a payload written by an older tracer simply falls back to the total */
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const entry = { name: row.name, seq: row.seq, tokens: totalTokens, billable_tokens: billableTokens };
|
|
510
527
|
if (list)
|
|
511
528
|
list.push(entry);
|
|
512
529
|
else
|
|
@@ -7,6 +7,13 @@ export interface UiOptions {
|
|
|
7
7
|
webDir: string;
|
|
8
8
|
/** Explicit port. Omit to try the default and probe upward on collision. */
|
|
9
9
|
port?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Bind address. Omit for the safe default (`127.0.0.1`, loopback-only).
|
|
12
|
+
* Set explicitly (e.g. `"0.0.0.0"`, or a specific interface IP) to make
|
|
13
|
+
* this reachable off-box — a deliberate widening, not something a caller
|
|
14
|
+
* should ever get by accident. See the module doc comment above.
|
|
15
|
+
*/
|
|
16
|
+
host?: string;
|
|
10
17
|
open?: boolean;
|
|
11
18
|
}
|
|
12
19
|
export interface UiHandle {
|
package/dist/ui/server/serve.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* `spf ui` bootstrap: bind the app to a real port, print the URL, optionally
|
|
3
3
|
* open a browser, and shut down cleanly on SIGINT/SIGTERM.
|
|
4
4
|
*
|
|
5
|
-
* Binds loopback-only (127.0.0.1) deliberately — this server can
|
|
6
|
-
* database column and reveal agent prompts, and a companion CLI has
|
|
7
|
-
* reason to listen on every interface
|
|
5
|
+
* Binds loopback-only (127.0.0.1) by DEFAULT, deliberately — this server can
|
|
6
|
+
* flip a database column and reveal agent prompts, and a companion CLI has
|
|
7
|
+
* no reason to listen on every interface unless someone explicitly asks for
|
|
8
|
+
* it via `options.host` (`--host` on the CLI). Widening the bind is an
|
|
9
|
+
* opt-in the caller states on purpose, not a default — see issue #90.
|
|
8
10
|
*/
|
|
9
11
|
import { spawn } from "node:child_process";
|
|
10
12
|
import { serve } from "@hono/node-server";
|
|
@@ -15,9 +17,9 @@ const PORT_PROBE_ATTEMPTS = 10;
|
|
|
15
17
|
function isAddrInUse(error) {
|
|
16
18
|
return typeof error === "object" && error !== null && error.code === "EADDRINUSE";
|
|
17
19
|
}
|
|
18
|
-
function listen(app, port) {
|
|
20
|
+
function listen(app, port, hostname) {
|
|
19
21
|
return new Promise((resolvePromise, reject) => {
|
|
20
|
-
const server = serve({ fetch: app.fetch, port, hostname
|
|
22
|
+
const server = serve({ fetch: app.fetch, port, hostname }, () => resolvePromise(server));
|
|
21
23
|
server.on("error", reject);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
@@ -37,6 +39,7 @@ function openUrl(url) {
|
|
|
37
39
|
export async function runUi(options) {
|
|
38
40
|
const db = await SfDb.open(options.db, options.sessionsDir);
|
|
39
41
|
const app = createApp(db, options.webDir);
|
|
42
|
+
const host = options.host ?? "127.0.0.1";
|
|
40
43
|
const explicit = options.port !== undefined;
|
|
41
44
|
const startPort = options.port ?? DEFAULT_PORT;
|
|
42
45
|
let server;
|
|
@@ -45,7 +48,7 @@ export async function runUi(options) {
|
|
|
45
48
|
for (let i = 0; i < attempts; i++) {
|
|
46
49
|
port = startPort + i;
|
|
47
50
|
try {
|
|
48
|
-
server = await listen(app, port);
|
|
51
|
+
server = await listen(app, port, host);
|
|
49
52
|
break;
|
|
50
53
|
}
|
|
51
54
|
catch (error) {
|
|
@@ -61,7 +64,7 @@ export async function runUi(options) {
|
|
|
61
64
|
}
|
|
62
65
|
const info = server.address();
|
|
63
66
|
const boundPort = info?.port ?? port;
|
|
64
|
-
const url = `http
|
|
67
|
+
const url = `http://${host}:${boundPort}`;
|
|
65
68
|
const shouldOpen = options.open !== false && process.stdout.isTTY && !process.env.CI && !process.env.SSH_CONNECTION;
|
|
66
69
|
if (shouldOpen)
|
|
67
70
|
openUrl(url);
|
|
@@ -24,6 +24,22 @@ export interface Session {
|
|
|
24
24
|
ended_at: string | null;
|
|
25
25
|
total_tokens: number | null;
|
|
26
26
|
total_cost: number | null;
|
|
27
|
+
/**
|
|
28
|
+
* The BILLABLE half of `total_tokens` — see `UsageBreakdown.billable_tokens`'s
|
|
29
|
+
* doc comment (`core/data_types.ts`) for why the two diverge.
|
|
30
|
+
*
|
|
31
|
+
* Two ways a row can predate real tracking, both meaning "fall back to
|
|
32
|
+
* `total_tokens`" (see `cli/commands/loop.ts`'s readback):
|
|
33
|
+
* - `null`: this db has never run the migration that adds the column at
|
|
34
|
+
* all (`optionalColumn` in `db.ts` substitutes `NULL AS billable_tokens`
|
|
35
|
+
* against it) — only possible for a db a writer Tracer has never opened
|
|
36
|
+
* since this column shipped.
|
|
37
|
+
* - `0` while `total_tokens > 0`: the column exists (a Tracer's
|
|
38
|
+
* `ALTER TABLE ... DEFAULT 0` ran) but this SPECIFIC row was written
|
|
39
|
+
* before that, so it was backfilled to 0 rather than to the true
|
|
40
|
+
* (unknowable, after the fact) billable figure.
|
|
41
|
+
*/
|
|
42
|
+
billable_tokens: number | null;
|
|
27
43
|
/** 1 once archived out of the review list. Review state, not run state. */
|
|
28
44
|
archived: number | null;
|
|
29
45
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gr8ful/spf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "0.83.0",
|
|
45
|
-
"@flue/opentelemetry": "2.0.
|
|
46
|
-
"@flue/runtime": "2.0.
|
|
45
|
+
"@flue/opentelemetry": "2.0.5",
|
|
46
|
+
"@flue/runtime": "2.0.5",
|
|
47
47
|
"@hono/node-server": "^2.1.1",
|
|
48
48
|
"@inkjs/ui": "^2.0.0",
|
|
49
49
|
"@opentelemetry/api": "1.9.1",
|