@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
@@ -14,8 +14,11 @@
14
14
  * `-undici` create a real client span (with a real, non-noop SpanContext)
15
15
  * around every outbound `http`/`https`/`fetch`(undici) call made from this
16
16
  * process, and the OTel API's global propagator is what those
17
- * instrumentations use to inject `traceparent` (and, here, `x-request-id`)
18
- * into that call's headers REGARDLESS of which provider SDK issued it.
17
+ * instrumentations use to inject `traceparent` (and, here, the gateway's own
18
+ * `x-correlation-id`/`x-spf-agent`see GatewayHeadersPropagator below;
19
+ * NEVER `x-request-id`, which Envoy/Switchyard own end-to-end and which a
20
+ * client-sent value would corrupt) into that call's headers — REGARDLESS of
21
+ * which provider SDK issued it.
19
22
  * This reaches every provider whose Node SDK issues requests through
20
23
  * Node's own `http`/`https` modules or `undici` (verified: `fetch()`,
21
24
  * `https.request()`). It does NOT reach a provider transport that bypasses
@@ -58,7 +61,64 @@
58
61
  * agent processing", which is the id SPF mints and hands to
59
62
  * `init(SfAgent, { id })`. Extraction goes through the globally
60
63
  * registered propagator against ROOT_CONTEXT, so no leaked loop context
61
- * can stick. Consequences, all intended:
64
+ * can stick.
65
+ *
66
+ * BLOCKER 1 (fixed) — `resolveRootContext`'s return value NEVER becomes the
67
+ * active context. Verified against `@flue/opentelemetry`'s own dist
68
+ * (index.mjs:361 for the `chat <model>` span, :329 for the interceptor that
69
+ * activates it): the resolved `Context` is passed to `tracer.startSpan(...)`
70
+ * ONLY as `parentContext` — it supplies the new span's trace id/parent span
71
+ * id and is then discarded. What actually gets activated around the real
72
+ * dispatch is `context.with(trace.setSpan(context.active(), span), next)` —
73
+ * a context built from `context.active()` (whatever was active before,
74
+ * almost always ROOT) plus the freshly minted `span`, NOT the resolved
75
+ * context itself. A prior version of this module stashed the session's
76
+ * `adw_id`/`agent_name` as a plain context VALUE on the context
77
+ * `resolveFlueRootContext` returned (keyed under a private context key) and
78
+ * had `GatewayHeadersPropagator` read that value back at inject time — which
79
+ * can never work, because that exact context object is never the one made
80
+ * active; only its SpanContext (traceId/spanId/flags) survives, carried by
81
+ * the new span. Confirmed live: `scratchpad/probe_installed_real.mjs`,
82
+ * reproducing flue's exact two-line sequence against a real pi-ai dispatch,
83
+ * printed `x-correlation-id: ABSENT` / `x-spf-agent: ABSENT` before this fix.
84
+ *
85
+ * THE FIX: key the registration by TRACE ID instead of by context identity.
86
+ * `registerFlueSessionTrace` now ALSO records, in `traceIdRegistrations`,
87
+ * the trace id parsed out of the very `traceparent` it's given — the same
88
+ * deterministic trace id `resolveFlueRootContext` extracts and hands to
89
+ * `tracer.startSpan` as that span's parent, so the span it creates carries
90
+ * that exact trace id forward into whatever context DOES get activated.
91
+ * `GatewayHeadersPropagator.inject()` below reads `trace.getSpanContext(ctx)
92
+ * ?.traceId` — the thing that provably survives into the active context at
93
+ * request time — and looks IT UP in `traceIdRegistrations`, rather than
94
+ * trying to read a value off a context object that was never activated.
95
+ * One deterministic trace id per adw_id (`otel.ts:556`'s `traceIdFor(adwId)`,
96
+ * reused for EVERY agent call in that run — `otel.ts:737`'s
97
+ * `agentCallTraceContext` varies only the span id, never the trace id), and
98
+ * `traceIdRegistrations` is keyed on that trace id alone, not on session id
99
+ * or span id. So "latest registration for this trace id wins" is exact
100
+ * within a run when its agents run one at a time — which is how a `fanout`
101
+ * attempt's own agents run: `fanout.ts:448` derives each attempt its OWN
102
+ * adw_id, so distinct fanout attempts get distinct trace ids and cannot
103
+ * collide here, regardless of `fanout`'s concurrency. The one real collision
104
+ * this map can still see is narrower and does not happen in SPF today: TWO
105
+ * AGENTS OVERLAPPING INSIDE ONE adw_id — a single run dispatching a second
106
+ * flue agent call before the first one's `unregisterFlueSessionTrace` has
107
+ * run — since both share that run's one trace id, the second
108
+ * `registerFlueSessionTrace` call overwrites the first agent's entry in
109
+ * `traceIdRegistrations` while its dispatch may still be in flight, and that
110
+ * agent's outbound request would then carry the OTHER agent's
111
+ * `x-correlation-id`/`x-spf-agent` (never a wrong `adw_id`, since both
112
+ * belong to the same run — only the wrong `agentName`). No chain SPF ships
113
+ * dispatches two agents concurrently within one adw_id; this is flagged as
114
+ * the mechanism's honest limit, not a bug being carried forward. The SAME
115
+ * registration carries this session's `adw_id`/agent name (see
116
+ * `FlueSessionRegistration`) so a
117
+ * session's `x-correlation-id`/`x-spf-agent` ride the exact same "resolved
118
+ * once per submission, read per real HTTP call" path as `traceparent` does,
119
+ * via `GatewayHeadersPropagator` below, instead of the static-per-model-id
120
+ * fallback `ollama_provider.ts` needs when this pipeline isn't installed at
121
+ * all (otel unconfigured). Consequences, all intended:
62
122
  * - Flue's spans inherit SPF's sha256 trace id, parented under the
63
123
  * right agent-call span PER SESSION — correct under multiple agents
64
124
  * per process, concurrent agents, and claim-loop restarts alike. Span
@@ -66,17 +126,42 @@
66
126
  * - The http/undici client spans' injected `traceparent` carries the
67
127
  * deterministic id too, so Switchyard/vLLM hops land as descendants of
68
128
  * SPF's trace — parity with `claude_code`'s `ANTHROPIC_CUSTOM_HEADERS`
69
- * path. `x-request-id` stays the this-span id for request-keyed
70
- * correlation, unchanged.
129
+ * path. The same per-request injection point also carries THIS
130
+ * session's `x-correlation-id`/`x-spf-agent`, correctly attributed even
131
+ * when multiple sessions are concurrent in one process, AS LONG AS their
132
+ * deterministic trace ids differ (their `ctx.id`s, and therefore their
133
+ * registrations, are distinct) — see BLOCKER 1 above for the precise
134
+ * lookup key.
71
135
  * - Unmapped sessions (never registered, restarted process with a
72
136
  * durable backlog, post-`unregister` straggler bookkeeping spans)
73
137
  * resolve to an unparented root — flue's spans root a separate SDK
74
- * trace exactly as v1 did, correlatable by `x-request-id`/`spf.adw_id`/
75
- * time window. Degraded join, never an error and never MIS-attributed.
138
+ * trace exactly as v1 did, correlatable by `spf.adw_id`/time window.
139
+ * Degraded join, never an error and never MIS-attributed; the gateway
140
+ * headers for exactly this case are simply ABSENT (no fallback identity
141
+ * — see `GatewayHeadersPropagator.inject()` below) rather than guessing
142
+ * at whichever session happened to register most recently.
76
143
  * - flue's internal `executionContext.traceCarrier` (typed but not on
77
144
  * the public `AgentDispatchRequest` surface) stays unused — noted here
78
145
  * as flue's own escape hatch, not something SPF reaches into.
79
146
  *
147
+ * STATIC HEADERS STAY SUPPRESSED WHEN INSTALLED (BLOCKER 1's other half,
148
+ * decided AGAINST enabling): `ollama_provider.ts`'s `modelFor()` keeps
149
+ * omitting `Model.headers`' `x-correlation-id`/`x-spf-agent` whenever
150
+ * `isFluePropagationInstalled()` is true — it is NOT also stamped as a
151
+ * baseline alongside this propagator. Measured live
152
+ * (`scratchpad/probe_duplicate_header.mjs`, against the REAL
153
+ * `@opentelemetry/instrumentation-undici` used here): `request.addHeader(k,
154
+ * v)` — what that instrumentation calls for every header
155
+ * `propagation.inject()` returns — APPENDS a second raw header line rather
156
+ * than replacing one already present (undici's own `Request.addHeader` has
157
+ * no dedupe-by-name step); the receiving `http.IncomingMessage.headers`
158
+ * then comma-joins the two into one corrupted value
159
+ * (`"FROM_STATIC, FROM_PROPAGATOR"`), and `req.rawHeaders` shows the literal
160
+ * duplicate line. So when propagation is installed, THIS propagator is the
161
+ * sole source of `x-correlation-id`/`x-spf-agent` — never doubled up with a
162
+ * static value from `Model.headers` — proven on the wire by
163
+ * `src/test/ollama_gateway_e2e.test.ts`'s "no duplicate headers" test.
164
+ *
80
165
  * REGISTRATION TIMING. `installFluePropagation()` is called from
81
166
  * `agent_flue.ts`'s `run()`, before `ensureRuntime()`/dispatch — i.e. before
82
167
  * the actual outbound call, which is the only ordering that matters for
@@ -108,56 +193,124 @@ import { BasicTracerProvider, BatchSpanProcessor } from "@opentelemetry/sdk-trac
108
193
  import { createOpenTelemetryInstrumentation } from "@flue/opentelemetry";
109
194
  import { instrument } from "@flue/runtime";
110
195
  import { resolveTracesUrl } from "./otel.js";
111
- /** The one custom propagation field this repo adds beyond the standard W3C `traceparent`: the current span's own id, for a collector/log pipeline that correlates by request rather than by trace. */
112
- export const X_REQUEST_ID_HEADER = "x-request-id";
113
196
  /**
114
- * Injects `x-request-id` from whatever span is active at the point of the
115
- * outbound call the same id that would appear as that span's `spanId` on
116
- * the (separate — see the module header) Flue-side trace. One-directional:
117
- * `extract()` is a pass-through, since nothing on the INBOUND side of an
118
- * outbound provider call needs to read this back.
197
+ * The Briefs gateway's own correlation headers see `ollama_provider.ts`'s
198
+ * "Gateway headers" section for the full three-header contract (the third,
199
+ * `traceparent`, is the W3C standard one `W3CTraceContextPropagator` already
200
+ * injects). Defined here (not in `ollama_provider.ts`) because
201
+ * `GatewayHeadersPropagator` below is the thing that actually injects them
202
+ * onto the wire when this pipeline is installed; `ollama_provider.ts`
203
+ * imports these two constants rather than redeclaring them, so there is
204
+ * exactly one spelling of each header name in the codebase.
205
+ */
206
+ export const X_CORRELATION_ID_HEADER = "x-correlation-id";
207
+ export const X_SPF_AGENT_HEADER = "x-spf-agent";
208
+ /**
209
+ * Instance-id -> registration backing `resolveFlueRootContext` — see the
210
+ * module header for the full mechanism and WHY this is a map consulted per
211
+ * span rather than a dispatch-time context wrap (flue's single claim loop
212
+ * makes the latter mis-attribute every agent after the first). One entry per
213
+ * in-flight `agent_flue.run()` call.
214
+ */
215
+ const flueSessionTraces = new Map();
216
+ /**
217
+ * Trace id -> gateway identity, keyed on the deterministic trace id parsed
218
+ * out of a registration's OWN `traceparent` (see `traceIdFromTraceparent`) —
219
+ * see the module header's BLOCKER 1 section for why this, and not a context
220
+ * VALUE, is the lookup `GatewayHeadersPropagator` uses. Populated by
221
+ * `registerFlueSessionTrace`, pruned by `unregisterFlueSessionTrace`. A
222
+ * trace id collision here (two DIFFERENT registrations sharing one trace id)
223
+ * is not expected in practice — trace ids are SPF's deterministic per-adw_id
224
+ * ids — but if it ever happened, the later `registerFlueSessionTrace` call
225
+ * would simply win, matching this map's own "current call wins" rule.
119
226
  */
120
- export class XRequestIdPropagator {
227
+ const traceIdRegistrations = new Map();
228
+ /**
229
+ * Parses the W3C `traceId` segment out of a `traceparent` string via a real
230
+ * `propagation.extract`/`getSpanContext` round trip — the SAME extraction
231
+ * `resolveFlueRootContext` performs, so "the trace id we index
232
+ * `traceIdRegistrations` under" and "the trace id a span parented via this
233
+ * `traceparent` actually carries" are provably the same value, not two
234
+ * independent parses that could drift. Returns `undefined` for a malformed
235
+ * `traceparent` (mirrors `resolveFlueRootContext`'s own malformed-input
236
+ * handling) rather than throwing.
237
+ */
238
+ function traceIdFromTraceparent(traceparent) {
239
+ const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
240
+ const spanContext = traceApi.getSpanContext(extracted);
241
+ return spanContext && isSpanContextValid(spanContext) ? spanContext.traceId : undefined;
242
+ }
243
+ /**
244
+ * Registers `registration` (SPF's deterministic agent-call traceparent, plus
245
+ * this session's adw_id/agent name — see `FlueSessionRegistration`) as the
246
+ * trace root for flue spans belonging to `sessionId` — flue's instance id,
247
+ * minted by SPF and handed to `init(SfAgent, { id })`. Overwrites a prior
248
+ * registration for the same id (a same-phase retry is the same logical call;
249
+ * the current call wins). Also indexes the SAME identity under this
250
+ * registration's parsed trace id (`traceIdRegistrations`) — see the module
251
+ * header's BLOCKER 1 section and that map's own doc for why.
252
+ */
253
+ export function registerFlueSessionTrace(sessionId, registration) {
254
+ flueSessionTraces.set(sessionId, registration);
255
+ const identity = { adwId: registration.adwId, agentName: registration.agentName };
256
+ const traceId = traceIdFromTraceparent(registration.traceparent);
257
+ if (traceId)
258
+ traceIdRegistrations.set(traceId, identity);
259
+ }
260
+ /**
261
+ * Injects `x-correlation-id`/`x-spf-agent` by looking up the ACTIVE
262
+ * `Context`'s SpanContext trace id (`trace.getSpanContext(ctx)?.traceId`) in
263
+ * `traceIdRegistrations` — the same per-real-HTTP-call injection point
264
+ * `W3CTraceContextPropagator` uses for `traceparent`, and, critically, keyed
265
+ * on the one piece of `resolveFlueRootContext`'s return value that provably
266
+ * survives into that active context (see the module header's BLOCKER 1
267
+ * section for why a context VALUE does not). Injects NOTHING — no fallback,
268
+ * no last-known identity — when this exact trace id is not in the map: an
269
+ * unregistered/unmapped span (flue's own bookkeeping spans, or ANY span
270
+ * after every session has been unregistered) must not stamp a stale or
271
+ * unrelated session's `x-correlation-id`/`x-spf-agent` onto unrelated
272
+ * outbound traffic (a third-party call, a post-session straggler). A true
273
+ * no-op (nothing set) in that case — never throws, never invents a value.
274
+ * Replaces the old `XRequestIdPropagator`:
275
+ * `x-request-id` must NEVER reach the gateway (Envoy/Switchyard own it
276
+ * end-to-end; a client-sent value corrupts their own sampling) and this repo
277
+ * no longer has anything that wants it emitted.
278
+ */
279
+ export class GatewayHeadersPropagator {
121
280
  inject(ctx, carrier, setter) {
122
281
  const spanContext = traceApi.getSpanContext(ctx);
123
- if (!spanContext || !isSpanContextValid(spanContext))
282
+ const registration = spanContext && traceIdRegistrations.get(spanContext.traceId);
283
+ if (!registration)
124
284
  return;
125
- setter.set(carrier, X_REQUEST_ID_HEADER, spanContext.spanId);
285
+ if (registration.adwId)
286
+ setter.set(carrier, X_CORRELATION_ID_HEADER, registration.adwId);
287
+ if (registration.agentName)
288
+ setter.set(carrier, X_SPF_AGENT_HEADER, registration.agentName);
126
289
  }
127
290
  extract(ctx) {
128
291
  return ctx;
129
292
  }
130
293
  fields() {
131
- return [X_REQUEST_ID_HEADER];
294
+ return [X_CORRELATION_ID_HEADER, X_SPF_AGENT_HEADER];
132
295
  }
133
296
  }
134
- /**
135
- * Instance-id -> agent-call traceparent registrations backing
136
- * `resolveFlueRootContext` — see the module header for the full mechanism
137
- * and WHY this is a map consulted per span rather than a dispatch-time
138
- * context wrap (flue's single claim loop makes the latter mis-attribute
139
- * every agent after the first). One entry per in-flight `agent_flue.run()`
140
- * call.
141
- */
142
- const flueSessionTraces = new Map();
143
- /**
144
- * Registers `traceparent` (SPF's deterministic agent-call span, as a W3C
145
- * carrier string) as the trace root for flue spans belonging to `sessionId`
146
- * — flue's instance id, minted by SPF and handed to `init(SfAgent, { id })`.
147
- * Overwrites a prior registration for the same id (a same-phase retry is
148
- * the same logical call; the current call wins).
149
- */
150
- export function registerFlueSessionTrace(sessionId, traceparent) {
151
- flueSessionTraces.set(sessionId, traceparent);
152
- }
153
297
  /**
154
298
  * Idempotent — called from `run()`'s `finally`. Post-settlement bookkeeping
155
299
  * spans flue mints after this point simply resolve to an unparented root
156
300
  * (separate trace), which is preferable to leaking a registration whose id
157
- * a REUSED session id could collide with on a later phase.
301
+ * a REUSED session id could collide with on a later phase. Also prunes this
302
+ * session's entry out of `traceIdRegistrations` (parsed fresh from the
303
+ * departing registration's own `traceparent`, so it removes exactly the
304
+ * entry this session added.
158
305
  */
159
306
  export function unregisterFlueSessionTrace(sessionId) {
307
+ const registration = flueSessionTraces.get(sessionId);
160
308
  flueSessionTraces.delete(sessionId);
309
+ if (!registration)
310
+ return;
311
+ const traceId = traceIdFromTraceparent(registration.traceparent);
312
+ if (traceId)
313
+ traceIdRegistrations.delete(traceId);
161
314
  }
162
315
  /**
163
316
  * The `resolveRootContext` implementation handed to
@@ -169,14 +322,23 @@ export function unregisterFlueSessionTrace(sessionId) {
169
322
  * unparented root span of its own) for an unmapped session id, a malformed
170
323
  * traceparent, an absent ctx — and, with no global propagator installed,
171
324
  * for everything. Exported for tests.
325
+ *
326
+ * Used by `@flue/opentelemetry` ONLY as `tracer.startSpan`'s parent
327
+ * argument (see the module header's BLOCKER 1 section) — the value handed
328
+ * back here is never itself activated, so it carries no context VALUES for
329
+ * `GatewayHeadersPropagator` to read; `x-correlation-id`/`x-spf-agent` are
330
+ * instead looked up by trace id, via `traceIdRegistrations`, which
331
+ * `registerFlueSessionTrace` populates independently of this function.
172
332
  */
173
333
  export function resolveFlueRootContext(_event, ctx) {
174
- const traceparent = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
175
- if (!traceparent)
334
+ const registration = ctx?.id ? flueSessionTraces.get(ctx.id) : undefined;
335
+ if (!registration)
176
336
  return undefined;
177
- const extracted = propagation.extract(ROOT_CONTEXT, { traceparent }, defaultTextMapGetter);
337
+ const extracted = propagation.extract(ROOT_CONTEXT, { traceparent: registration.traceparent }, defaultTextMapGetter);
178
338
  const spanContext = traceApi.getSpanContext(extracted);
179
- return spanContext && isSpanContextValid(spanContext) ? extracted : undefined;
339
+ if (!spanContext || !isSpanContextValid(spanContext))
340
+ return undefined;
341
+ return extracted;
180
342
  }
181
343
  let installed = false;
182
344
  let flueInstrumented = false;
@@ -200,7 +362,7 @@ export function installFluePropagation(cfg, log = (m) => console.error(m)) {
200
362
  });
201
363
  traceApi.setGlobalTracerProvider(provider);
202
364
  contextApi.setGlobalContextManager(new AsyncHooksContextManager().enable());
203
- propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new XRequestIdPropagator()] }));
365
+ propagation.setGlobalPropagator(new CompositePropagator({ propagators: [new W3CTraceContextPropagator(), new GatewayHeadersPropagator()] }));
204
366
  registerInstrumentations({ instrumentations: [new HttpInstrumentation(), new UndiciInstrumentation()] });
205
367
  // Set AFTER the fallible registrations above: on a construction-time
206
368
  // throw, the next call must be free to retry — latching `installed`
@@ -223,3 +385,17 @@ export function resetFluePropagationForTest() {
223
385
  installed = false;
224
386
  flueInstrumented = false;
225
387
  }
388
+ /**
389
+ * Whether `installFluePropagation` has actually installed the global
390
+ * TracerProvider/propagator/instrumentations in THIS process. Consulted by
391
+ * `ollama_provider.ts` (see its "Gateway headers" section) so `resolve()`/
392
+ * `modelFor()` know whether the instrumentation above already covers
393
+ * `traceparent`/`x-correlation-id`/`x-spf-agent` per real outbound call —
394
+ * supplying any of the three a second way when this is `true` would double
395
+ * it up on the wire (`UndiciInstrumentation` appends via `addHeader`, it
396
+ * does not replace). `false` — the common case, otel unconfigured — means
397
+ * `ollama_provider.ts` must supply all three itself.
398
+ */
399
+ export function isFluePropagationInstalled() {
400
+ return installed;
401
+ }
@@ -73,6 +73,21 @@ export declare function permitted(p: string, agent: AgentConfig, cfg: SFConfig):
73
73
  * Detection alone would leave the repo holding the unauthorized change while
74
74
  * reporting a failure, so anything the agent introduced outside its allowlist
75
75
  * is rolled back before the phase dies. What it cannot undo, it names.
76
+ *
77
+ * `defaults.read_only_ignore` carves out one exception to "names, fails" —
78
+ * but only where `isSafeToIgnore` says restoring is actually possible (see
79
+ * its own doc comment): a TRUE read-only agent (`writes: []`) that churned
80
+ * a lockfile that was CLEAN before this phase. That one case is STILL
81
+ * rolled back unconditionally like any other breach — an ignored path is
82
+ * never left standing — it just does not, on its own, fail the phase.
83
+ * Everything else `isIgnorableChurn` alone would have matched (a dirty-before
84
+ * path, an agent that reverted uncommitted work, a write-restricted rather
85
+ * than read-only agent) falls through to the real-breach path below instead.
86
+ * `onIgnored`, when given, is called once with every safely-ignored path
87
+ * before returning, so a caller with a logger (`agents.ts`'s `execute()`)
88
+ * can print the one required info line — this module has no logger of its
89
+ * own to call (`RunLike` is only `repo_root`/`cfg`), so the caller does the
90
+ * printing.
76
91
  */
77
- export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>): string[];
92
+ export declare function enforce(run: RunLike, _phase: unknown, agent: AgentConfig, before: Record<string, string>, onIgnored?: (paths: string[]) => void): string[];
78
93
  export {};
@@ -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.