@gr8ful/spf 0.19.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 (48) 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 +51 -1
  25. package/dist/core/issues/jira_provider.js +69 -1
  26. package/dist/core/issues/provider.d.ts +23 -0
  27. package/dist/core/loop.d.ts +39 -1
  28. package/dist/core/loop.js +33 -2
  29. package/dist/core/ollama_provider.d.ts +96 -13
  30. package/dist/core/ollama_provider.js +172 -26
  31. package/dist/core/otel.js +10 -1
  32. package/dist/core/otel_propagation.d.ts +168 -24
  33. package/dist/core/otel_propagation.js +219 -43
  34. package/dist/core/permissions.d.ts +16 -1
  35. package/dist/core/permissions.js +91 -3
  36. package/dist/core/providers.js +8 -3
  37. package/dist/core/refine.js +13 -1
  38. package/dist/core/runner.d.ts +33 -2
  39. package/dist/core/runner.js +40 -5
  40. package/dist/core/tiering.js +7 -3
  41. package/dist/core/tracer.d.ts +7 -1
  42. package/dist/core/tracer.js +15 -3
  43. package/dist/ui/server/db.d.ts +8 -1
  44. package/dist/ui/server/db.js +21 -4
  45. package/dist/ui/server/serve.d.ts +7 -0
  46. package/dist/ui/server/serve.js +10 -7
  47. package/dist/ui/shared/types.d.ts +16 -0
  48. package/package.json +1 -1
@@ -528,6 +528,46 @@ export const ConfigDefaultsSchema = v.object({
528
528
  // the machinery that decides whether its work passed.
529
529
  // .spf/ is the whole per-repo footprint now — no adws/ tree to protect.
530
530
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
531
+ /**
532
+ * Paths a READ-ONLY (or write-restricted) agent may touch WITHOUT failing
533
+ * the phase — `core/permissions.ts`'s `enforce()` still rolls every one of
534
+ * them back (an agent's claimed report must never rest on a change that
535
+ * didn't survive), it just does not count that rollback as a breach.
536
+ *
537
+ * WHY THIS EXISTS: a lockfile is dependency-manager BOOKKEEPING, not the
538
+ * repo's intent — an agent that ran `npm install` (to read a package's
539
+ * real shape, say) rewrites `package-lock.json` as a side effect of a
540
+ * read, not an edit. Observed live: a read-only scout phase failed with
541
+ * "scout is read-only but modified 1 path(s): factory/content/
542
+ * package-lock.json — rolled back" over exactly this, for work that
543
+ * changed nothing an operator would call "the code."
544
+ *
545
+ * The four defaults are the lockfiles of every package manager this repo
546
+ * already builds against (npm, pnpm, yarn, bun) — additive, not
547
+ * exhaustive; a repo using another one adds its own pattern here. Same
548
+ * glob syntax as `protected_files`/`agents[].writes` (`permissions.ts`'s
549
+ * `globToRegex`), where a leading "**" followed by a path separator
550
+ * matches at any depth INCLUDING the repo root — so the packaged
551
+ * defaults below match a lockfile whether it sits at the top of the repo
552
+ * or nested under a subdirectory.
553
+ *
554
+ * Emptying this list (`read_only_ignore: []`) restores today's strict
555
+ * behavior exactly — every touched path outside an agent's own allowlist
556
+ * fails the phase, lockfiles included.
557
+ *
558
+ * PRECEDENCE: `protected_files` always wins. A path matching
559
+ * `protected_files` is never ignorable via `read_only_ignore`, no matter
560
+ * how narrowly write-restricted the agent is — a pattern here that
561
+ * happens to also match a protected path is not read as "exempt this
562
+ * from protected_files too"; it stays a real breach. See
563
+ * `permissions.ts`'s `isSafeToIgnore` for the enforcement.
564
+ */
565
+ read_only_ignore: v.optional(v.array(v.string()), () => [
566
+ "**/package-lock.json",
567
+ "**/pnpm-lock.yaml",
568
+ "**/yarn.lock",
569
+ "**/bun.lockb",
570
+ ]),
531
571
  data_dir: v.optional(v.string(), ".spf/data"),
532
572
  /**
533
573
  * RUN BUDGET CEILINGS — the two knobs that bound what one adw_id may spend.
@@ -550,11 +590,20 @@ export const ConfigDefaultsSchema = v.object({
550
590
  * `agents.ts`'s `BudgetExceeded`.
551
591
  *
552
592
  * `max_run_cost` is USD (the same unit the provider's own usage.cost
553
- * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is TOTAL
554
- * tokens, i.e. the spend number — every turn re-sends the whole
555
- * conversation, so this counts cached re-reads too, exactly like the
556
- * `total_tokens` column in `sessions` (see `ui/server/db.ts`'s `usage()`
557
- * for why that number is much larger than "material moved").
593
+ * arrives in, summed by `UsageBreakdown`); `max_run_tokens` is BILLABLE
594
+ * tokens — `UsageBreakdown.billable_tokens` (input + cache-write + output),
595
+ * checked against `Run.billable_tokens`, NOT the `total_tokens` column
596
+ * `sessions` also carries for display. A prompt-caching backend (Ollama
597
+ * Cloud's kimi models, Anthropic's own caching) re-sends the whole
598
+ * conversation every turn as CACHE READS, which `total_tokens` counts and
599
+ * this ceiling does not: cache reads are billed (when billed at all) at a
600
+ * small fraction of input price, sometimes free, so a ceiling measured
601
+ * against the bigger number trips on bulk that cost nothing — observed
602
+ * live, one scout phase alone reported 1,311,740 total_tokens against a
603
+ * gateway that billed 189,321 uncached input + 17,908 output for it.
604
+ * `total_tokens` is kept exactly as before for anything display-only
605
+ * (the sessions-panel "tokens" line, the UI) — only the budget check
606
+ * changed which number it reads.
558
607
  *
559
608
  * Both are `> 0`, not `>= 0`: a zero ceiling would mean "no agent may ever
560
609
  * run", which is a config mistake, not a budget — it would fail the first
@@ -573,6 +622,43 @@ export const ConfigDefaultsSchema = v.object({
573
622
  */
574
623
  max_run_cost: v.optional(v.pipe(v.number(), v.gtValue(0))),
575
624
  max_run_tokens: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
625
+ /**
626
+ * REQUEST TIMEOUT — how long a single agent dispatch may run before it is
627
+ * aborted and settled as failed, rather than hanging on a connection that
628
+ * silently died mid-call with nothing to notice.
629
+ *
630
+ * FLUE-SPECIFIC, unlike every other key in this schema: it maps straight
631
+ * onto `AgentStatics.durability.timeoutMs` (see `@flue/runtime`'s own
632
+ * docs), a `flue`-backend-only mechanism. `claude_code`/`opencode` are
633
+ * subprocess backends with no such knob today — this field is silently
634
+ * ignored for them, the same way `flue_db_path` on `AgentRequest` already
635
+ * is. Not a bug to fix here: a subprocess backend needs its own separate
636
+ * process-level timeout story, which is out of scope for this key.
637
+ *
638
+ * ABSENT BY DEFAULT, and absence is a total no-op: Flue's own default
639
+ * applies unchanged (1 hour, 10 attempts) — the same "surprise mid-run
640
+ * failure on a ceiling nobody chose is worse than the spend" reasoning as
641
+ * `max_run_cost`/`max_run_tokens` above. Set this when a hung connection
642
+ * should surface as an attributable failure (and feed the normal
643
+ * gate-correction / `spf watch` retry loop) in minutes, not however long
644
+ * Flue's own default takes — e.g. `300_000` for a five-minute ceiling.
645
+ * NOT a precise deadline, though: manual verification against a socket
646
+ * that accepts a connection and then sends nothing (see
647
+ * `request_timeout.test.ts`'s header comment) saw Flue's own timeout check
648
+ * fire on a coarser periodic sweep — a 3s ceiling settled at ~15s, not 3s.
649
+ * Bounded-but-imprecise is still a firm improvement over unbounded.
650
+ *
651
+ * SCOPE IS ONE SUBMISSION (one agent dispatch — the first prompt, one
652
+ * JSON-repair retry, one gate correction), NOT the accumulated run, unlike
653
+ * `max_run_cost`/`max_run_tokens` above. It is still process-scoped, not
654
+ * per-agent: Flue's `durability` is a static on the single shared agent
655
+ * function `agent_flue.ts` dispatches everything through, set once before
656
+ * the first dispatch of the process — see that file's `ensureRuntime()`.
657
+ * Deliberately NOT in `loadConfig`'s per-agent back-fill list for the same
658
+ * reason `max_run_cost`/`max_run_tokens` aren't: a per-agent copy would
659
+ * read as "this agent gets its own timeout", which nothing enforces.
660
+ */
661
+ request_timeout_ms: v.optional(v.pipe(v.number(), v.integer(), v.gtValue(0))),
576
662
  });
577
663
  /**
578
664
  * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
@@ -869,6 +955,30 @@ export const WatchJiraConfigSchema = v.object({
869
955
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
870
956
  issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
871
957
  status_map: v.optional(JiraStatusMapSchema, () => v.parse(JiraStatusMapSchema, {})),
958
+ /**
959
+ * The Jira issue-link `type` name `refine.ts`'s `publish()` uses to
960
+ * connect a freshly-published tree's ROOT issue(s) back to the spec they
961
+ * were refined from (`JiraProvider.linkToSpec`) — a plain, symmetric
962
+ * "issue link" (Jira's generic relate-two-issues mechanism), never the
963
+ * hierarchical `parent` field `linkChild` sets: the spec's own issue type
964
+ * defaults to Story (`issue_types.spec`), and a root node is often an
965
+ * Epic/Task — Jira's issue-type hierarchy frequently refuses a Story as
966
+ * one of those types' PARENT, so the hierarchy field is not a safe choice
967
+ * here regardless of which type actually published. "Relates" is a
968
+ * built-in link type on every Jira Cloud project; override this only if a
969
+ * project's admin has renamed or restricted it.
970
+ *
971
+ * MUST NAME A SYMMETRIC LINK TYPE. `JiraProvider.linkToSpec` fixes which
972
+ * side is `inwardIssue`/`outwardIssue` (the published root is always
973
+ * inward, the spec always outward) and does not expose direction as a
974
+ * separate knob — harmless for a symmetric type like "Relates" (Jira's UI
975
+ * does not even surface a direction for one), but pointing this at a
976
+ * DIRECTIONAL type (e.g. "blocks"/"is blocked by") would silently record
977
+ * the opposite relationship from the one intended. Only rename this to
978
+ * another symmetric type; a directional one needs code changes, not just
979
+ * config.
980
+ */
981
+ link_type: v.optional(v.string(), "Relates"),
872
982
  });
873
983
  /**
874
984
  * Optional, per-repo `WatchState` -> GitHub Projects v2 "Status" option-name
@@ -1230,6 +1340,24 @@ export class UsageBreakdown {
1230
1340
  // at the output rate. Report it nested under output, never added to it.
1231
1341
  reasoning_tokens = 0;
1232
1342
  total_tokens = 0;
1343
+ /**
1344
+ * The SPEND number, as distinct from `total_tokens` (the SIZE number).
1345
+ * `input_tokens + output_tokens + cache_write_tokens` — `cache_read_tokens`
1346
+ * excluded on purpose: a cache read is Anthropic's own prompt-caching
1347
+ * discount (billed at a small fraction of the input rate, sometimes free
1348
+ * on some gateways) for context the conversation already sent, not new
1349
+ * material moved. `total_tokens` re-sends (and re-counts) the whole
1350
+ * conversation every turn, so a long-running scout/build session's cache
1351
+ * reads dwarf everything else in it (observed live: 1.31M total_tokens in
1352
+ * one phase, of which 1.15M were cache reads the gateway did not bill as
1353
+ * input) — a run-budget ceiling measured against `total_tokens` trips on
1354
+ * cache-driven bulk that cost nothing, not on real spend. `assertRunBudget`
1355
+ * (`agents.ts`) checks THIS field against `defaults.max_run_tokens`;
1356
+ * `total_tokens` is kept, unchanged, for display (the sessions-panel
1357
+ * "tokens" line, the UI) because an operator sizing context occupancy
1358
+ * still needs the real re-send count, not the billable one.
1359
+ */
1360
+ billable_tokens = 0;
1233
1361
  input_cost = 0.0;
1234
1362
  output_cost = 0.0;
1235
1363
  cache_read_cost = 0.0;
@@ -1249,6 +1377,7 @@ export class UsageBreakdown {
1249
1377
  this.cache_write_tokens += usage.cacheWrite || 0;
1250
1378
  this.reasoning_tokens += usage.reasoning || 0;
1251
1379
  this.total_tokens += totalTokens;
1380
+ this.billable_tokens += (usage.input || 0) + (usage.output || 0) + (usage.cacheWrite || 0);
1252
1381
  this.input_cost += cost.input || 0.0;
1253
1382
  this.output_cost += cost.output || 0.0;
1254
1383
  this.cache_read_cost += cost.cacheRead || 0.0;
@@ -71,6 +71,15 @@
71
71
  * `feature` (both mapping to Jira's Epic type by default) surfaces a real
72
72
  * Jira API error at publish time — a genuine platform difference, not
73
73
  * something this file tries to paper over.
74
+ *
75
+ * `linkToSpec` is the OTHER half of `IssueAuthoringProvider`'s hierarchy —
76
+ * a published tree's ROOT connected back to the spec it was refined FROM,
77
+ * which `linkChild` cannot express (the spec's own issue type, a Story by
78
+ * default, frequently cannot legally PARENT a root node's type). Uses
79
+ * Jira's plain issue-link API instead (`type` configurable via
80
+ * `watch.jira.link_type`, "Relates" by default), with a plain comment as
81
+ * its own fallback if that API is unavailable — see the method's own doc
82
+ * comment.
74
83
  */
75
84
  import type { JiraIssueTypeMap, JiraStatusMap } from "../data_types.ts";
76
85
  import type { EnsureLabelsResult, Issue, IssueAuthoringKind, IssueAuthoringProvider, IssueComment, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
@@ -82,8 +91,9 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
82
91
  private readonly apiToken;
83
92
  private readonly issueTypes;
84
93
  private readonly statusMap;
94
+ private readonly linkType;
85
95
  constructor(baseUrl: string, // e.g. "https://your-domain.atlassian.net", no trailing slash
86
- projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap);
96
+ projectKey: string, labelPrefix: string, email: string, apiToken: string, issueTypes: JiraIssueTypeMap, statusMap?: JiraStatusMap, linkType?: string);
87
97
  private authHeader;
88
98
  private jira;
89
99
  private label;
@@ -134,6 +144,46 @@ export declare class JiraProvider implements IssueProvider, IssueAuthoringProvid
134
144
  * Epic-under-Epic limitation this implies.
135
145
  */
136
146
  linkChild(parent: Issue, child: Issue): Promise<void>;
147
+ /**
148
+ * `IssueAuthoringProvider.linkToSpec` — see its own doc comment
149
+ * (`issues/provider.ts`) for why this is a plain Jira "issue link"
150
+ * (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
151
+ * field: the spec's issue type (Story by default) frequently cannot
152
+ * legally PARENT a root node's type under Jira's issue-type hierarchy,
153
+ * while a generic issue link has no such restriction.
154
+ *
155
+ * `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
156
+ * type like "Relates" — Jira does not surface it differently in the UI —
157
+ * so `issue` (the freshly published root) is the inward side and the
158
+ * spec is the outward side, consistently.
159
+ *
160
+ * BEST-EFFORT: this project's Jira instance may not have `linkType`
161
+ * enabled/named exactly this way (a renamed or removed link type, a
162
+ * permission scheme that disallows issue links for this project, ...) —
163
+ * a failure here must never fail the whole publish over what is, at
164
+ * bottom, a cosmetic cross-reference. Falls back to a plain comment
165
+ * naming the spec, so the relationship is visible SOMEWHERE even when
166
+ * the link API itself is unavailable.
167
+ *
168
+ * The fallback comment gets the SAME "never fail publish() over this"
169
+ * treatment as the link call itself: a rate limit or network blip on the
170
+ * comment call is just as cosmetic a failure as one on the link call, so
171
+ * it is caught and logged here rather than left to propagate out of
172
+ * `publish()` (`refine.ts`), which awaits this unguarded.
173
+ *
174
+ * `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
175
+ * project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
176
+ * fixed (the published root is always inward, the spec always outward)
177
+ * and not independently configurable, which is fine for a symmetric type
178
+ * (Jira does not surface the direction differently in the UI) but WRONG
179
+ * for a directional one (e.g. "blocks"/"is blocked by"): configuring
180
+ * `watch.jira.link_type` to a directional type would silently assert the
181
+ * opposite relationship from the one intended. See that field's own doc
182
+ * comment (`data_types.ts`) — direction is not exposed as a separate knob
183
+ * on purpose, to avoid a second config field only meaningful alongside a
184
+ * link type most projects never change from the "Relates" default.
185
+ */
186
+ linkToSpec(specId: string, issue: Issue): Promise<void>;
137
187
  /** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
138
188
  listChildren(parent: Issue): Promise<Issue[]>;
139
189
  /**
@@ -56,8 +56,12 @@ export class JiraProvider {
56
56
  apiToken;
57
57
  issueTypes;
58
58
  statusMap;
59
+ linkType;
59
60
  constructor(baseUrl, // e.g. "https://your-domain.atlassian.net", no trailing slash
60
- projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {}) {
61
+ projectKey, labelPrefix, email, apiToken, issueTypes, statusMap = {},
62
+ // See `WatchJiraConfigSchema.link_type`'s own doc comment (`data_types.ts`)
63
+ // — the issue-link `type` name `linkToSpec` below creates.
64
+ linkType = "Relates") {
61
65
  this.baseUrl = baseUrl;
62
66
  this.projectKey = projectKey;
63
67
  this.labelPrefix = labelPrefix;
@@ -65,6 +69,7 @@ export class JiraProvider {
65
69
  this.apiToken = apiToken;
66
70
  this.issueTypes = issueTypes;
67
71
  this.statusMap = statusMap;
72
+ this.linkType = linkType;
68
73
  }
69
74
  authHeader() {
70
75
  return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
@@ -185,6 +190,69 @@ export class JiraProvider {
185
190
  async linkChild(parent, child) {
186
191
  await this.jira(`/rest/api/3/issue/${child.id}`, { method: "PUT", body: JSON.stringify({ fields: { parent: { key: parent.id } } }) });
187
192
  }
193
+ /**
194
+ * `IssueAuthoringProvider.linkToSpec` — see its own doc comment
195
+ * (`issues/provider.ts`) for why this is a plain Jira "issue link"
196
+ * (`/rest/api/3/issueLink`) rather than `linkChild`'s hierarchy `parent`
197
+ * field: the spec's issue type (Story by default) frequently cannot
198
+ * legally PARENT a root node's type under Jira's issue-type hierarchy,
199
+ * while a generic issue link has no such restriction.
200
+ *
201
+ * `inwardIssue`/`outwardIssue` direction is arbitrary for a symmetric
202
+ * type like "Relates" — Jira does not surface it differently in the UI —
203
+ * so `issue` (the freshly published root) is the inward side and the
204
+ * spec is the outward side, consistently.
205
+ *
206
+ * BEST-EFFORT: this project's Jira instance may not have `linkType`
207
+ * enabled/named exactly this way (a renamed or removed link type, a
208
+ * permission scheme that disallows issue links for this project, ...) —
209
+ * a failure here must never fail the whole publish over what is, at
210
+ * bottom, a cosmetic cross-reference. Falls back to a plain comment
211
+ * naming the spec, so the relationship is visible SOMEWHERE even when
212
+ * the link API itself is unavailable.
213
+ *
214
+ * The fallback comment gets the SAME "never fail publish() over this"
215
+ * treatment as the link call itself: a rate limit or network blip on the
216
+ * comment call is just as cosmetic a failure as one on the link call, so
217
+ * it is caught and logged here rather than left to propagate out of
218
+ * `publish()` (`refine.ts`), which awaits this unguarded.
219
+ *
220
+ * `this.linkType` MUST name a SYMMETRIC link type ("Relates" and its
221
+ * project-renamed equivalents) — `inwardIssue`/`outwardIssue` above are
222
+ * fixed (the published root is always inward, the spec always outward)
223
+ * and not independently configurable, which is fine for a symmetric type
224
+ * (Jira does not surface the direction differently in the UI) but WRONG
225
+ * for a directional one (e.g. "blocks"/"is blocked by"): configuring
226
+ * `watch.jira.link_type` to a directional type would silently assert the
227
+ * opposite relationship from the one intended. See that field's own doc
228
+ * comment (`data_types.ts`) — direction is not exposed as a separate knob
229
+ * on purpose, to avoid a second config field only meaningful alongside a
230
+ * link type most projects never change from the "Relates" default.
231
+ */
232
+ async linkToSpec(specId, issue) {
233
+ try {
234
+ await this.jira("/rest/api/3/issueLink", {
235
+ method: "POST",
236
+ body: JSON.stringify({
237
+ type: { name: this.linkType },
238
+ inwardIssue: { key: issue.id },
239
+ outwardIssue: { key: specId },
240
+ }),
241
+ });
242
+ }
243
+ catch (linkError) {
244
+ try {
245
+ await this.comment(issue, `Refined from ${specId}.`);
246
+ }
247
+ catch (commentError) {
248
+ // Both the issue-link API and the comment fallback failed — the
249
+ // spec/root relationship is not recorded ANYWHERE on Jira this run,
250
+ // but that is still a cosmetic loss, not a reason to fail the whole
251
+ // publish (see this method's own doc comment).
252
+ console.error(`spf watch: ${issue.id} — could not link to spec ${specId} (${linkError instanceof Error ? linkError.message : String(linkError)}) and the fallback comment also failed — ${commentError instanceof Error ? commentError.message : String(commentError)}`);
253
+ }
254
+ }
255
+ }
188
256
  /** The read-back half of `linkChild` — same JQL-in-body pattern as `searchByLabel`, since a GET with query params silently returns nothing on this endpoint (see the module comment). What makes container roll-up (`rollUp` in `watch.ts`) work on Jira too. */
189
257
  async listChildren(parent) {
190
258
  const jql = `parent = ${JSON.stringify(parent.id)}`;
@@ -311,6 +311,29 @@ export interface IssueAuthoringProvider {
311
311
  * roll-up, refine cannot function without authoring at all.
312
312
  */
313
313
  listChildren(parent: Issue): Promise<Issue[]>;
314
+ /**
315
+ * Best-effort: relate a freshly published tree's ROOT issue back to the
316
+ * spec issue (`specId`) it was refined FROM — `core/refine.ts`'s
317
+ * `publish()` calls this once per root node (a node with no `parent` of
318
+ * its own within the refined tree) when it was given a `specIssueId`.
319
+ *
320
+ * Deliberately NOT `linkChild`: that method sets the tracker's
321
+ * HIERARCHY field (Jira's `parent`, GitHub's sub-issues API), and a
322
+ * spec's own issue type (Story by default — `JiraIssueTypeMapSchema.spec`)
323
+ * frequently cannot legally PARENT a root node's type in Jira's
324
+ * issue-type hierarchy — see `JiraProvider`'s own doc comment on
325
+ * `publishSpecs()` for the same constraint. `linkToSpec` uses a plain,
326
+ * symmetric issue-to-issue reference instead (Jira's generic "issue
327
+ * link"), which has no such hierarchy restriction.
328
+ *
329
+ * OPTIONAL, not every tracker needs one: GitHub already gets a native,
330
+ * visible cross-reference for free the moment `renderBody`'s "## Parent"
331
+ * section renders a plain "#N" in the body — GitHub auto-links same-repo
332
+ * issue mentions into a real timeline "referenced this issue" event, no
333
+ * API call required. Jira does not do this for plain text, which is why
334
+ * `JiraProvider` implements this and `GitHubProvider` does not.
335
+ */
336
+ linkToSpec?(specId: string, issue: Issue): Promise<void>;
314
337
  }
315
338
  /**
316
339
  * Structural, not nominal: checks for the three methods rather than
@@ -106,7 +106,35 @@ export interface LedgerAttempt {
106
106
  error: string | null;
107
107
  /** HEAD's short sha after a successful (exit 0) iteration — unchanged from the previous attempt's if this iteration committed nothing new, which is exactly what `isStuck` below looks for. `null` only for an errored or non-accepted (nonzero exit) attempt, where nothing was checked out to read. */
108
108
  commit_sha: string | null;
109
+ /**
110
+ * Kept for backward compatibility with ledger rows written before
111
+ * `billable_tokens` (below) existed — on a row this old, `tokens` is the
112
+ * DISPLAY total (`sessions.total_tokens`, cache reads included), the only
113
+ * figure that era ever recorded. On every row written since, this field
114
+ * and `billable_tokens` are set to the SAME (billable) value; they only
115
+ * ever diverge across that one-time boundary, never within a single
116
+ * write. `cumulativeSpend` is what actually reads either — see its own
117
+ * doc comment for the precedence.
118
+ */
109
119
  tokens: number;
120
+ /**
121
+ * The BILLABLE figure (input + cache-write + output) — what
122
+ * `--max-tokens`/`overCumulativeBudget` is SUPPOSED to compare against,
123
+ * same metric `defaults.max_run_tokens` uses within one chain run (see
124
+ * `UsageBreakdown.billable_tokens`'s doc comment, `data_types.ts`). NOT
125
+ * the display total (`sessions.total_tokens`, cache reads included) —
126
+ * `cli/commands/loop.ts`'s readback reads `session.billable_tokens`,
127
+ * falling back to `total_tokens` only for a SESSION row that predates
128
+ * that sqlite column being populated.
129
+ *
130
+ * `undefined` on any LEDGER row written before this field existed — a
131
+ * genuinely old-format row, not a free ($0) iteration. `cumulativeSpend`
132
+ * falls back to `tokens` for exactly that row (the only figure it has,
133
+ * a display total it cannot retroactively convert), and
134
+ * `hasLegacyTokenRows` is what a caller uses to warn once that a ceiling
135
+ * check is now mixing metrics across the ledger's own history.
136
+ */
137
+ billable_tokens?: number;
110
138
  cost: number;
111
139
  failures: string[];
112
140
  started_at: string;
@@ -151,13 +179,22 @@ export declare function newLedger(input: {
151
179
  export declare function resolveGoalId(explicit: string | undefined): string;
152
180
  export interface CumulativeBudget {
153
181
  maxCost?: number;
182
+ /** `--max-tokens` — checked against BILLABLE tokens (see `LedgerAttempt.billable_tokens`'s own doc comment), the same metric `defaults.max_run_tokens` checks per-call inside one chain run. */
154
183
  maxTokens?: number;
155
184
  }
156
185
  export declare function cumulativeSpend(attempts: LedgerAttempt[]): {
157
186
  cost: number;
158
187
  tokens: number;
159
188
  };
160
- /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
189
+ /**
190
+ * True when the ledger holds at least one attempt written before
191
+ * `billable_tokens` existed — `cumulativeSpend` is then silently mixing a
192
+ * display-total figure (that row's `tokens`) into a sum whose newer rows
193
+ * are billable. Used to print a one-time operator warning rather than let
194
+ * that mismatch pass unremarked; see `runLoop`'s call site.
195
+ */
196
+ export declare function hasLegacyTokenRows(attempts: LedgerAttempt[]): boolean;
197
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. `spend.tokens` must already be BILLABLE tokens (see `LedgerAttempt.tokens`) — this function just compares, it does not know which metric it was handed. */
161
198
  export declare function overCumulativeBudget(budget: CumulativeBudget, spend: {
162
199
  cost: number;
163
200
  tokens: number;
@@ -185,6 +222,7 @@ export interface IterationResult {
185
222
  error: string | null;
186
223
  /** `null` when nothing was committed this iteration (a no-op, or a throw before any commit). */
187
224
  commit_sha: string | null;
225
+ /** BILLABLE tokens — see `LedgerAttempt.tokens`'s own doc comment; `runIteration` (`cli/commands/loop.ts`) reads this straight from `LedgerAttempt`, so the two must stay the same metric. */
188
226
  tokens: number;
189
227
  cost: number;
190
228
  /** `null` when the iteration errored or exited non-zero — the stop check only ever runs against a chain that accepted its own work, same as `fixLoop` only re-verifies after a phase that didn't already throw. */
package/dist/core/loop.js CHANGED
@@ -132,10 +132,30 @@ export function newLedger(input) {
132
132
  export function resolveGoalId(explicit) {
133
133
  return explicit ?? newId(8);
134
134
  }
135
+ /**
136
+ * Per-attempt token figure for budget purposes: `billable_tokens` where the
137
+ * row has it, else `tokens` (an old-format row, predating that field — see
138
+ * `LedgerAttempt.billable_tokens`'s own doc comment). Never the other way
139
+ * around: a new row's `tokens` happens to equal its `billable_tokens` today,
140
+ * but `billable_tokens` is the one this function trusts on purpose.
141
+ */
142
+ function tokensForBudget(a) {
143
+ return a.billable_tokens ?? a.tokens;
144
+ }
135
145
  export function cumulativeSpend(attempts) {
136
- return attempts.reduce((acc, a) => ({ cost: acc.cost + a.cost, tokens: acc.tokens + a.tokens }), { cost: 0, tokens: 0 });
146
+ return attempts.reduce((acc, a) => ({ cost: acc.cost + a.cost, tokens: acc.tokens + tokensForBudget(a) }), { cost: 0, tokens: 0 });
137
147
  }
138
- /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. */
148
+ /**
149
+ * True when the ledger holds at least one attempt written before
150
+ * `billable_tokens` existed — `cumulativeSpend` is then silently mixing a
151
+ * display-total figure (that row's `tokens`) into a sum whose newer rows
152
+ * are billable. Used to print a one-time operator warning rather than let
153
+ * that mismatch pass unremarked; see `runLoop`'s call site.
154
+ */
155
+ export function hasLegacyTokenRows(attempts) {
156
+ return attempts.some((a) => a.billable_tokens === undefined);
157
+ }
158
+ /** Whether the goal-scoped ceiling is already exhausted going into the NEXT iteration — a stronger, ledger-wide check than any single iteration's own budget. `spend.tokens` must already be BILLABLE tokens (see `LedgerAttempt.tokens`) — this function just compares, it does not know which metric it was handed. */
139
159
  export function overCumulativeBudget(budget, spend) {
140
160
  if (budget.maxCost !== undefined && spend.cost >= budget.maxCost)
141
161
  return true;
@@ -207,6 +227,13 @@ export async function runLoop(deps) {
207
227
  if (existing) {
208
228
  deps.log(`loop: resuming goal ${deps.goalId} — ${existing.attempts.length} attempt(s) already recorded`);
209
229
  }
230
+ // One-time info line, not a per-iteration one: legacy rows already IN the
231
+ // ledger (from a resumed goal) do not change count as this run proceeds,
232
+ // so there is nothing to re-warn about after the first check.
233
+ if (deps.budget.maxTokens !== undefined && hasLegacyTokenRows(ledger.attempts)) {
234
+ deps.log(`loop: goal ${deps.goalId} — ledger has attempt(s) recorded before billable-token tracking; ` +
235
+ "--max-tokens falls back to their display-total token count for those rows (see LedgerAttempt.billable_tokens)");
236
+ }
210
237
  let lastVerdict = ledger.attempts.length > 0 ? { passed: false, failures: ledger.attempts[ledger.attempts.length - 1].failures, artifacts: [] } : null;
211
238
  for (let i = ledger.attempts.length + 1; i <= deps.max; i++) {
212
239
  if (overCumulativeBudget(deps.budget, cumulativeSpend(ledger.attempts))) {
@@ -244,7 +271,11 @@ export async function runLoop(deps) {
244
271
  exit_code: result.exit_code,
245
272
  error: result.error,
246
273
  commit_sha: result.commit_sha,
274
+ // Every row written from here on sets both to the same (billable)
275
+ // value — see `LedgerAttempt.tokens`/`billable_tokens`'s doc comments
276
+ // for why they only ever diverge on an old-format row.
247
277
  tokens: result.tokens,
278
+ billable_tokens: result.tokens,
248
279
  cost: result.cost,
249
280
  failures,
250
281
  started_at: startedAt,
@@ -42,28 +42,111 @@
42
42
  * cost nothing at runtime either way.
43
43
  */
44
44
  import type { Provider } from "@earendil-works/pi-ai";
45
+ /**
46
+ * `OLLAMA_API_KEY`, trimmed, when the operator has set one — e.g. the
47
+ * Briefs gateway (Envoy AI Gateway) enforces a per-client bearer and 401s
48
+ * the dummy key `DUMMY_API_KEY` was designed for a bare local server that
49
+ * checks nothing. Falls back to the dummy exactly as before when unset, so
50
+ * THIS function's own return value — the resolved `Authorization` bearer —
51
+ * is byte-identical to pre-gateway behavior for a bare local Ollama server
52
+ * (the common case this module was built for). MINOR-H: that is narrower
53
+ * than "the whole request is unchanged" — it is not, even with
54
+ * `OLLAMA_API_KEY` unset: a `traceparent` (and, once `agent_flue.ts` has an
55
+ * adw_id/agent_name to give it, `x-correlation-id`/`x-spf-agent`) is ALWAYS
56
+ * sent now, gateway or no gateway (see the "Gateway headers" section below).
57
+ * A bare local Ollama server ignores headers it doesn't recognize, so this
58
+ * is harmless — just not byte-identical. Read fresh inside `resolve()` (see
59
+ * its call site below) — never cached — so a key exported mid-process (or
60
+ * changed) takes effect on the very next dispatch with no re-registration.
61
+ *
62
+ * MINOR 3: exported so `doctor.ts`'s `OLLAMA_BASE_URL reachability` probe
63
+ * calls this SAME function rather than reading `process.env.OLLAMA_API_KEY`
64
+ * raw — a prior version of that probe sent NO `Authorization` header at all
65
+ * when the env var was unset, which diverges from what a real dispatch
66
+ * sends (the dummy bearer below, always). Against a gateway that rejects a
67
+ * request with no `Authorization` header at all differently than one with a
68
+ * wrong/dummy bearer, that divergence could make doctor report reachable
69
+ * when a real dispatch would 401, or vice versa. Calling `ollamaApiKey()` in
70
+ * both places means doctor's probe and a real dispatch send byte-identical
71
+ * bearers for the same env state.
72
+ */
73
+ export declare function ollamaApiKey(): string;
74
+ /** SPF-side identity for one LLM call, as far as `registerOllamaModel`'s caller can supply it. Both fields optional — an absent one simply omits its header. */
75
+ export interface GatewayCallContext {
76
+ /** The run's adw_id — sent as `x-correlation-id` so the gateway groups this run's calls. */
77
+ adwId?: string;
78
+ /** The SPF agent name — sent as `x-spf-agent`. */
79
+ agentName?: string;
80
+ }
81
+ /** Must NEVER be sent — Envoy/Switchyard own it end-to-end; a client-supplied value breaks their sampling. Exported only so tests can assert its absence by name, not a literal string. The sole surviving export of this name in the codebase — see MINOR-G in this change's review; `otel_propagation.ts` no longer has one now that `XRequestIdPropagator` is gone (BLOCKER B). */
82
+ export declare const X_REQUEST_ID_HEADER = "x-request-id";
83
+ /**
84
+ * A fresh W3C `traceparent` for one outbound call — used only on the NOT
85
+ * INSTALLED path (see the section above); when propagation IS installed,
86
+ * `resolve()` does not call this at all, relying entirely on the
87
+ * instrumentation's own per-request injection instead (this is what fixed
88
+ * BLOCKER A/MAJOR-C: this function used to be called unconditionally, and
89
+ * on the installed path it silently reused the one still-open span's
90
+ * traceparent for every call inside that span, which is both a duplicate
91
+ * header AND not actually fresh per call).
92
+ *
93
+ * Reuses the active OTel span context when one is installed and current —
94
+ * the same trace this call's other telemetry already belongs to — falling
95
+ * back to a brand-new random trace/span id pair when there is none (no span
96
+ * active at this exact point, e.g. a stray call before any span opened), so
97
+ * the gateway still gets a well-formed, per-call-unique traceparent either
98
+ * way. Never throws; `isSpanContextValid` is the same guard
99
+ * `otel_propagation.ts`'s own propagators use.
100
+ */
101
+ export declare function freshTraceparent(): string;
45
102
  /** Exported so `doctor.ts`'s reachability probe agrees with what a real dispatch resolves to — see its call site for why a `??`/`||` mismatch here matters. */
46
103
  export declare function ollamaBaseUrl(): string;
47
104
  /**
48
105
  * Registers `modelId` (the part after `ollama/` in an agent's `model`
49
106
  * config) with Flue's provider registry, alongside every other `ollama/*`
50
- * id ever registered this process. Idempotent: a repeat of an already-seen
51
- * id is a no-op no re-registration, no re-import. A concurrent call for
52
- * the SAME id joins the in-flight registration rather than returning early
53
- * (see `inflight`'s doc); `registeredIds` itself is only ever updated AFTER
54
- * `setProvider()` succeeds, so a failed attempt (a bad install, a bundler
55
- * that can't resolve the deep `.lazy` subpath, a future validation error)
56
- * leaves the id unregistered and eligible for a real retry — not
57
- * permanently and misleadingly marked "done" while nothing is actually
58
- * registered.
107
+ * id ever registered this process. NOT idempotent w.r.t. `ctx` (see MAJOR-D
108
+ * below) every call re-runs the union re-registration (dynamic imports
109
+ * are cheap after the first, and `setProvider()` is a cheap in-memory
110
+ * upsert), so this id's `Model.headers` always reflect the MOST RECENT
111
+ * `ctx` this function was called with, not just the first. A concurrent
112
+ * call for the SAME id joins the in-flight registration rather than running
113
+ * a second one in parallel (see `inflight`'s doc); `registeredIds` itself
114
+ * is only ever updated AFTER `setProvider()` succeeds, so a failed attempt
115
+ * (a bad install, a bundler that can't resolve the deep `.lazy` subpath, a
116
+ * future validation error) leaves the id unregistered and eligible for a
117
+ * real retry — not permanently and misleadingly marked "done" while
118
+ * nothing is actually registered.
59
119
  *
60
120
  * Must complete before the FIRST Flue dispatch that names this model
61
121
  * (agent_flue.ts's `run()` awaits this before `ensureRuntime()`/`start()`),
62
- * but is equally safe to call again later with a new id mid-process that
63
- * later call's union re-registration is exactly how a second model gets
64
- * added without orphaning the first (see the `registeredIds` doc above).
122
+ * but is equally safe to call again later with a new id, or the SAME id
123
+ * again, mid-process — a new id's union re-registration is how a second
124
+ * model gets added without orphaning the first (see the `registeredIds` doc
125
+ * above); a repeat of the SAME id is how MAJOR-D below is fixed.
126
+ *
127
+ * MAJOR-D (fixed): `ctx`, when given AND `isFluePropagationInstalled()` is
128
+ * false (see the "Gateway headers" section above — when it's true, these
129
+ * two headers come from the per-request `GatewayHeadersPropagator`
130
+ * instead), is stamped onto this id's `Model.headers` as
131
+ * `x-correlation-id`/`x-spf-agent`. A PRIOR version of this function
132
+ * returned immediately for an already-registered id (a false comment
133
+ * claimed "one spf process runs one adw_id for its whole lifetime" to
134
+ * justify this) — which meant every later agent/adw_id sharing a model id
135
+ * within one process (spf `loop`/`fanout`/`watch`, which run many adw_ids
136
+ * in ONE process, `fanout` concurrently) silently kept the FIRST
137
+ * registration's headers forever. Re-running the full registration on every
138
+ * call, unconditionally, fixes that for every case except one, which
139
+ * remains and is not silently swallowed: two flue agents dispatching
140
+ * CONCURRENTLY (not sequentially) to the SAME `ollama/<id>` model id race on
141
+ * `registrationContext`/`setProvider()` — whichever registration's
142
+ * `setProvider()` call lands last wins the headers BOTH calls' subsequent
143
+ * dispatches see, until the next registration for that id. This is a
144
+ * `fanout` concurrency > 1 scenario specifically (two DIFFERENT agents,
145
+ * same process, same model id, truly overlapping registrations) — a
146
+ * sequential loop/watch never hits it, since each call's `await` completes
147
+ * before the next one starts.
65
148
  */
66
- export declare function registerOllamaModel(modelId: string): Promise<void>;
149
+ export declare function registerOllamaModel(modelId: string, ctx?: GatewayCallContext): Promise<void>;
67
150
  /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
68
151
  export declare function providerForTest(): Provider<"openai-completions"> | undefined;
69
152
  /** Test-only: forgets accumulated ids so test files don't leak into each other. Does not touch Flue's own registry — pair with `resetModelsForTests()` from `@flue/runtime/internal`. */