@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.
Files changed (50) hide show
  1. package/README.md +8 -0
  2. package/assets/skill/references/config.md +1 -0
  3. package/dist/cli/commands/doctor.js +25 -3
  4. package/dist/cli/commands/estimate.d.ts +22 -6
  5. package/dist/cli/commands/estimate.js +32 -10
  6. package/dist/cli/commands/loop.d.ts +20 -0
  7. package/dist/cli/commands/loop.js +20 -1
  8. package/dist/cli/commands/ui.js +2 -1
  9. package/dist/cli/commands/watch.js +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/interview.js +13 -0
  12. package/dist/cli/ui/run_dashboard.js +13 -7
  13. package/dist/core/agent_cc.d.ts +19 -3
  14. package/dist/core/agent_cc.js +38 -18
  15. package/dist/core/agent_flue.js +51 -14
  16. package/dist/core/agent_opencode.d.ts +62 -25
  17. package/dist/core/agent_opencode.js +71 -30
  18. package/dist/core/agents.d.ts +51 -4
  19. package/dist/core/agents.js +79 -4
  20. package/dist/core/console.d.ts +24 -4
  21. package/dist/core/console.js +20 -7
  22. package/dist/core/data_types.d.ts +300 -19
  23. package/dist/core/data_types.js +134 -5
  24. package/dist/core/issues/jira_provider.d.ts +58 -3
  25. package/dist/core/issues/jira_provider.js +89 -18
  26. package/dist/core/issues/markdown_adf.d.ts +58 -0
  27. package/dist/core/issues/markdown_adf.js +705 -0
  28. package/dist/core/issues/provider.d.ts +23 -0
  29. package/dist/core/loop.d.ts +39 -1
  30. package/dist/core/loop.js +33 -2
  31. package/dist/core/ollama_provider.d.ts +96 -13
  32. package/dist/core/ollama_provider.js +172 -26
  33. package/dist/core/otel.js +10 -1
  34. package/dist/core/otel_propagation.d.ts +168 -24
  35. package/dist/core/otel_propagation.js +219 -43
  36. package/dist/core/permissions.d.ts +16 -1
  37. package/dist/core/permissions.js +91 -3
  38. package/dist/core/providers.js +8 -3
  39. package/dist/core/refine.js +13 -1
  40. package/dist/core/runner.d.ts +33 -2
  41. package/dist/core/runner.js +40 -5
  42. package/dist/core/tiering.js +7 -3
  43. package/dist/core/tracer.d.ts +7 -1
  44. package/dist/core/tracer.js +15 -3
  45. package/dist/ui/server/db.d.ts +8 -1
  46. package/dist/ui/server/db.js +21 -4
  47. package/dist/ui/server/serve.d.ts +7 -0
  48. package/dist/ui/server/serve.js +10 -7
  49. package/dist/ui/shared/types.d.ts +16 -0
  50. package/package.json +3 -3
@@ -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 = [...outcomes.entries()].map(([p, outcome]) => ` - ${p} — ${outcome}`).join("\n");
186
- throw new PermissionBreach(`${agent.name} is ${scope} but modified ${breaches.length} path(s):\n${detail}`);
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
  }
@@ -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 check for or
25
- // prompt for. An empty array here means "known provider, needs no key",
26
- // never "unknown provider" (that's a missing table entry, not `[]`).
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
@@ -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
  }
@@ -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
- addUsage(tokens: number, cost: number): Promise<void>;
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.
@@ -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
- await this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
115
- await this.console.notifyUsage(this.tokens, this.cost);
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
  }
@@ -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());
@@ -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
- sessionAddUsage(adwId: string, tokens: number, cost: number): Promise<void>;
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
  *
@@ -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
- async sessionAddUsage(adwId, tokens, cost) {
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
  /**
@@ -180,10 +180,17 @@ export interface ChainHistorySession {
180
180
  started_at: string | null;
181
181
  total_tokens: number;
182
182
  total_cost: number;
183
- /** In `phases.seq` order; one entry per phase iteration (e.g. `fix_1`, `fix_2`), not yet loop-normalized. */
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
  }
@@ -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 entry = { name: row.name, seq: row.seq, tokens: row.tokens ?? 0 };
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 {
@@ -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 flip a
6
- * database column and reveal agent prompts, and a companion CLI has no
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: "127.0.0.1" }, () => resolvePromise(server));
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://127.0.0.1:${boundPort}`;
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.18.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.4",
46
- "@flue/runtime": "2.0.3",
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",