@gr8ful/spf 0.3.0 → 0.5.0

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 (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. package/package.json +5 -5
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Ollama registration for the Flue backend (agent_flue.ts).
3
+ *
4
+ * Neither pi-ai nor Flue ship a built-in "ollama" provider — Ollama is
5
+ * reached through the OpenAI-compatible `/v1/chat/completions` surface it
6
+ * serves locally, registered the same way any self-hosted OpenAI-compatible
7
+ * endpoint would be: `createProvider()` + Flue's `setProvider()`. Three
8
+ * constraints below came out of a live spike (raw logs under
9
+ * scratchpad/ollama-spike) and would look like accidental complexity to a
10
+ * future maintainer without this note, so each is called out where it bites.
11
+ *
12
+ * EXACT VERSION PIN — see package.json's `@earendil-works/pi-ai: "0.83.0"`
13
+ * (no caret). `@flue/runtime@2.0.3` itself depends on `^0.83.0`; pinning our
14
+ * own dependency to the exact same version lets npm dedupe both into ONE
15
+ * physical copy of pi-ai in node_modules. A newer 0.83.x/0.84.x would still
16
+ * satisfy flue's range, but npm would then keep two-or-three separate copies
17
+ * side by side — and the `Provider`/`Model` values this file hands to
18
+ * `setProvider()` must be instances `@flue/runtime`'s OWN copy of pi-ai
19
+ * recognizes, or registration silently never reaches the registry flue's
20
+ * `resolveModel()` actually reads from. Verified live: with the exact pin,
21
+ * dedupe holds and registration is visible to flue's registry immediately.
22
+ *
23
+ * LAZY IMPORT — every symbol used here is loaded via dynamic `import()`
24
+ * inside `registerOllamaModel`, never at this module's top level. This is
25
+ * NOT a load-time saving, and the reasoning below is the measured truth, not
26
+ * the "avoid eagerly loading pi-ai's runtime for non-ollama runs" story that
27
+ * comment used to tell: `agent_flue.ts` already imports `@flue/runtime/node`
28
+ * unconditionally, and that import ALONE already pulls in pi-ai's full
29
+ * runtime (auth flows, every provider's model-catalog JSON, OAuth machinery)
30
+ * for every SPF run, ollama or not. Measured live: `await
31
+ * import("@flue/runtime/node")` costs 183ms / 38 pi-ai module-cache entries
32
+ * by itself; the subsequent `await import("@earendil-works/pi-ai")` costs
33
+ * 0ms / 0 additional entries, and `await
34
+ * import("@earendil-works/pi-ai/api/openai-completions.lazy")` costs 1ms / 0
35
+ * additional entries — pi-ai is already resident by the time either dynamic
36
+ * import here runs. The real reason to keep the dynamic form is narrower:
37
+ * it keeps this module's ollama-only symbols — especially the deep
38
+ * `api/openai-completions.lazy` subpath — off the module graph of anything
39
+ * that merely imports `agent_flue.ts` for `resolveModel()` (as `doctor.ts`
40
+ * and `interview.ts` both do) without ever dispatching an ollama call.
41
+ * `type` imports below are erased at compile time (verbatimModuleSyntax) and
42
+ * cost nothing at runtime either way.
43
+ */
44
+ import type { Provider } from "@earendil-works/pi-ai";
45
+ /** 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
+ export declare function ollamaBaseUrl(): string;
47
+ /**
48
+ * Registers `modelId` (the part after `ollama/` in an agent's `model`
49
+ * 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.
59
+ *
60
+ * Must complete before the FIRST Flue dispatch that names this model
61
+ * (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).
65
+ */
66
+ export declare function registerOllamaModel(modelId: string): Promise<void>;
67
+ /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
68
+ export declare function providerForTest(): Provider<"openai-completions"> | undefined;
69
+ /** 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`. */
70
+ export declare function resetOllamaRegistrationForTest(): void;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Ollama registration for the Flue backend (agent_flue.ts).
3
+ *
4
+ * Neither pi-ai nor Flue ship a built-in "ollama" provider — Ollama is
5
+ * reached through the OpenAI-compatible `/v1/chat/completions` surface it
6
+ * serves locally, registered the same way any self-hosted OpenAI-compatible
7
+ * endpoint would be: `createProvider()` + Flue's `setProvider()`. Three
8
+ * constraints below came out of a live spike (raw logs under
9
+ * scratchpad/ollama-spike) and would look like accidental complexity to a
10
+ * future maintainer without this note, so each is called out where it bites.
11
+ *
12
+ * EXACT VERSION PIN — see package.json's `@earendil-works/pi-ai: "0.83.0"`
13
+ * (no caret). `@flue/runtime@2.0.3` itself depends on `^0.83.0`; pinning our
14
+ * own dependency to the exact same version lets npm dedupe both into ONE
15
+ * physical copy of pi-ai in node_modules. A newer 0.83.x/0.84.x would still
16
+ * satisfy flue's range, but npm would then keep two-or-three separate copies
17
+ * side by side — and the `Provider`/`Model` values this file hands to
18
+ * `setProvider()` must be instances `@flue/runtime`'s OWN copy of pi-ai
19
+ * recognizes, or registration silently never reaches the registry flue's
20
+ * `resolveModel()` actually reads from. Verified live: with the exact pin,
21
+ * dedupe holds and registration is visible to flue's registry immediately.
22
+ *
23
+ * LAZY IMPORT — every symbol used here is loaded via dynamic `import()`
24
+ * inside `registerOllamaModel`, never at this module's top level. This is
25
+ * NOT a load-time saving, and the reasoning below is the measured truth, not
26
+ * the "avoid eagerly loading pi-ai's runtime for non-ollama runs" story that
27
+ * comment used to tell: `agent_flue.ts` already imports `@flue/runtime/node`
28
+ * unconditionally, and that import ALONE already pulls in pi-ai's full
29
+ * runtime (auth flows, every provider's model-catalog JSON, OAuth machinery)
30
+ * for every SPF run, ollama or not. Measured live: `await
31
+ * import("@flue/runtime/node")` costs 183ms / 38 pi-ai module-cache entries
32
+ * by itself; the subsequent `await import("@earendil-works/pi-ai")` costs
33
+ * 0ms / 0 additional entries, and `await
34
+ * import("@earendil-works/pi-ai/api/openai-completions.lazy")` costs 1ms / 0
35
+ * additional entries — pi-ai is already resident by the time either dynamic
36
+ * import here runs. The real reason to keep the dynamic form is narrower:
37
+ * it keeps this module's ollama-only symbols — especially the deep
38
+ * `api/openai-completions.lazy` subpath — off the module graph of anything
39
+ * that merely imports `agent_flue.ts` for `resolveModel()` (as `doctor.ts`
40
+ * and `interview.ts` both do) without ever dispatching an ollama call.
41
+ * `type` imports below are erased at compile time (verbatimModuleSyntax) and
42
+ * cost nothing at runtime either way.
43
+ */
44
+ // Ollama has no auth of its own — `pi-ai`'s auth resolution always calls
45
+ // `getClientApiKey()` before a dispatch, and that call throws "No API key
46
+ // for provider: ollama" if the resolved key is falsy (verified live: the
47
+ // upstream-documented `auth: { apiKey: {} }` recipe, with no `resolve()` or a
48
+ // resolver returning no key, throws exactly that at the FIRST dispatch, not
49
+ // at registration). There is no supported way to mark a provider as needing
50
+ // no key at all — the value below is a placeholder pi-ai never actually
51
+ // sends anywhere Ollama would look at it: Ollama's OpenAI-compatible server
52
+ // does not check the Authorization header's contents.
53
+ const DUMMY_API_KEY = "ollama-local-unused";
54
+ // Advisory only: pi-ai's `openai-completions` api reads this per REQUEST via
55
+ // its own `options.maxTokens`, not from `Model.maxTokens` directly — the
56
+ // field here only feeds Flue's compaction-reserve sizing (moot in practice
57
+ // since `contextWindow: 0` below disables threshold compaction, matching
58
+ // agent_flue.ts's own `context_window: 0`). Kept generous since Ollama
59
+ // enforces nothing against it.
60
+ const DEFAULT_MAX_TOKENS = 8192;
61
+ /**
62
+ * Every `ollama/<id>` model id a `registerOllamaModel` call has ever
63
+ * SUCCEEDED in registering, in call order. `setProvider()` REPLACES the
64
+ * named provider's entire model list on every call — it is not additive —
65
+ * so this Set is what lets each call re-register the FULL union instead of
66
+ * just the newest id. Without it: register "a", then "b", and "a" becomes an
67
+ * unknown model id at its next dispatch (verified live: "Unknown model ID …
68
+ * for provider \"ollama\""), because the second `setProvider()` call
69
+ * replaced the first provider object — the one whose `models` list still
70
+ * had "a" — outright.
71
+ *
72
+ * `OLLAMA_BASE_URL` is read fresh (via `ollamaBaseUrl()`) at each
73
+ * registration call, and the whole union is re-registered at whatever URL
74
+ * is current AT THAT MOMENT — so a mid-process env change applies unevenly:
75
+ * ids already registered keep the base URL they were registered under until
76
+ * the NEXT new id triggers a fresh union re-registration, which then
77
+ * re-points every id at once. Deliberate: a single local server for the
78
+ * whole process is the supported case, and this asymmetry only bites a
79
+ * per-agent override, which isn't.
80
+ */
81
+ const registeredIds = new Set();
82
+ // Registrations currently in flight, keyed by model id — lets a second
83
+ // caller for the SAME id that arrives before the first `await` resolves
84
+ // join that in-progress registration instead of returning immediately with
85
+ // nothing registered yet (which would let it dispatch before `setProvider()`
86
+ // has actually run).
87
+ const inflight = new Map();
88
+ // The most recently constructed provider object, kept only so tests can
89
+ // inspect its auth/model shape directly — this Flue version's public
90
+ // surface (`@flue/runtime/internal`) exports `setProvider`/`hasProvider`/
91
+ // `resolveModel` but no `getProvider`, so there is no other way to read a
92
+ // registered provider's `auth` back out of Flue's own registry.
93
+ let lastProvider;
94
+ /** 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. */
95
+ export function ollamaBaseUrl() {
96
+ const raw = (process.env.OLLAMA_BASE_URL ?? "").trim();
97
+ return raw || "http://localhost:11434/v1";
98
+ }
99
+ function modelFor(id, baseUrl) {
100
+ return {
101
+ id,
102
+ name: id,
103
+ api: "openai-completions",
104
+ provider: "ollama",
105
+ baseUrl,
106
+ reasoning: false,
107
+ input: ["text"],
108
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
109
+ // Disables Flue's threshold-based compaction outright — there's no
110
+ // reliable catalog of context windows for arbitrary local Ollama models,
111
+ // and agent_flue.ts already treats 0 as "unknown, don't compact" for its
112
+ // own reported `context_window`. Verified live to be a safe no-op, not
113
+ // silently truncating requests.
114
+ contextWindow: 0,
115
+ maxTokens: DEFAULT_MAX_TOKENS,
116
+ };
117
+ }
118
+ /**
119
+ * Registers `modelId` (the part after `ollama/` in an agent's `model`
120
+ * config) with Flue's provider registry, alongside every other `ollama/*`
121
+ * id ever registered this process. Idempotent: a repeat of an already-seen
122
+ * id is a no-op — no re-registration, no re-import. A concurrent call for
123
+ * the SAME id joins the in-flight registration rather than returning early
124
+ * (see `inflight`'s doc); `registeredIds` itself is only ever updated AFTER
125
+ * `setProvider()` succeeds, so a failed attempt (a bad install, a bundler
126
+ * that can't resolve the deep `.lazy` subpath, a future validation error)
127
+ * leaves the id unregistered and eligible for a real retry — not
128
+ * permanently and misleadingly marked "done" while nothing is actually
129
+ * registered.
130
+ *
131
+ * Must complete before the FIRST Flue dispatch that names this model
132
+ * (agent_flue.ts's `run()` awaits this before `ensureRuntime()`/`start()`),
133
+ * but is equally safe to call again later with a new id mid-process — that
134
+ * later call's union re-registration is exactly how a second model gets
135
+ * added without orphaning the first (see the `registeredIds` doc above).
136
+ */
137
+ export async function registerOllamaModel(modelId) {
138
+ if (registeredIds.has(modelId))
139
+ return;
140
+ const existing = inflight.get(modelId);
141
+ if (existing)
142
+ return existing;
143
+ const promise = (async () => {
144
+ // Deliberately dynamic, not top-level, imports — see the module doc's
145
+ // "LAZY IMPORT" note. `@flue/runtime/internal` is cheap either way
146
+ // (it's already reachable from `@flue/runtime`/`@flue/runtime/node`,
147
+ // which agent_flue.ts imports unconditionally); pi-ai's own package is
148
+ // the one this module keeps off other modules' graphs.
149
+ const [{ createProvider }, { openAICompletionsApi }, { setProvider }] = await Promise.all([
150
+ import("@earendil-works/pi-ai"),
151
+ import("@earendil-works/pi-ai/api/openai-completions.lazy"),
152
+ import("@flue/runtime/internal"),
153
+ ]);
154
+ // Built from a local candidate set, not `registeredIds` itself — the id
155
+ // being registered right now isn't committed to `registeredIds` until
156
+ // AFTER `setProvider()` below succeeds (see this function's doc).
157
+ const ids = new Set(registeredIds);
158
+ ids.add(modelId);
159
+ const baseUrl = ollamaBaseUrl();
160
+ const models = [...ids].map((id) => modelFor(id, baseUrl));
161
+ const options = {
162
+ id: "ollama",
163
+ name: "Ollama (local)",
164
+ baseUrl,
165
+ auth: {
166
+ apiKey: {
167
+ name: "Ollama (keyless)",
168
+ // See DUMMY_API_KEY above for why this can't just report "no key
169
+ // needed" — pi-ai's dispatch path requires a truthy resolved key.
170
+ resolve: async () => ({ auth: { apiKey: DUMMY_API_KEY } }),
171
+ },
172
+ },
173
+ models,
174
+ api: openAICompletionsApi(),
175
+ };
176
+ // NOT `start({ providers: [...] })` — per @flue/runtime's own node/index
177
+ // typings, that option REPLACES the runtime's entire default provider
178
+ // set (every pi-ai built-in), which would silently drop
179
+ // anthropic/openai/etc. for every agent, not just ollama ones.
180
+ // `setProvider()` is the additive (per-id) primitive; it upserts this
181
+ // one id and leaves every other already-registered provider untouched.
182
+ const provider = createProvider(options);
183
+ setProvider(provider);
184
+ lastProvider = provider;
185
+ // Only commit to the Set once `setProvider()` has actually run —
186
+ // see this function's doc for why ordering this after, not before,
187
+ // matters.
188
+ for (const id of ids)
189
+ registeredIds.add(id);
190
+ })();
191
+ inflight.set(modelId, promise);
192
+ try {
193
+ await promise;
194
+ }
195
+ finally {
196
+ inflight.delete(modelId);
197
+ }
198
+ }
199
+ /** Test-only: the most recently constructed provider object (see `lastProvider`'s doc). */
200
+ export function providerForTest() {
201
+ return lastProvider;
202
+ }
203
+ /** 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`. */
204
+ export function resetOllamaRegistrationForTest() {
205
+ registeredIds.clear();
206
+ inflight.clear();
207
+ lastProvider = undefined;
208
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * OpenTelemetry span export (v1): a config-gated, lossy, fire-and-forget
3
+ * PROJECTION of the trace SQLite already holds. Read this header before
4
+ * changing anything here — every paragraph is a constraint that survived an
5
+ * adversarial review, not a preference.
6
+ *
7
+ * WHAT THIS IS NOT. It is not a second source of truth and it is not in any
8
+ * control path. `tracer.ts` (files + SQLite, synchronous) remains THE record;
9
+ * this module is a tail-call fan-out off its write methods. Export can never
10
+ * affect a phase, a gate, or a run outcome — "agent proposes, code disposes"
11
+ * is untouched because export cannot dispose of anything. Nothing in here may
12
+ * ever throw into a caller, block a caller, or be awaited by a caller other
13
+ * than the two shutdown paths named under LIFECYCLE below.
14
+ *
15
+ * SPANS ONLY. No `resourceMetrics`, no `resourceLogs`. The OTLP metrics data
16
+ * model (temporality, monotonicity, cumulative-vs-delta) is exactly where a
17
+ * hand-rolled encoder produces numbers a backend silently misreads, and a
18
+ * wrong cost number is worse than no cost number. Token counts and dollars
19
+ * ride as span ATTRIBUTES instead. Do not "just add metrics" here.
20
+ *
21
+ * EXPLICIT CONFIG ONLY. Activation requires `observability.otel.endpoint` in
22
+ * the config file. This module NEVER reads `OTEL_EXPORTER_OTLP_ENDPOINT` or
23
+ * any other ambient exporter variable: an unrelated shell variable inherited
24
+ * from a CI image or a coworker's dotfiles must not be able to turn a repo's
25
+ * telemetry egress on. (`SPF_CLAUDE_CMD` is not a precedent for the opposite:
26
+ * that variable is SPF-namespaced and only redirects a LOCAL subprocess — it
27
+ * moves no data off the machine.)
28
+ *
29
+ * ATTRIBUTE ALLOWLIST — exfiltration is the top risk here, because
30
+ * `EventRecord.payload` carries the repository's own source code (tool args,
31
+ * result snippets, diffs, prompts, envelope contents, the operator's request
32
+ * text). The allowlist, in full: phase name/kind/owner/status/seq/attempt,
33
+ * chain name, adw_id, agent name/model/coding_agent, gate name + passed +
34
+ * violation COUNT, token counts (UsageBreakdown fields) + costs, durations
35
+ * (implied by span start/end), and event TYPE. Everything else is excluded by
36
+ * construction, not by filtering:
37
+ * - This module reads `EventRecord.payload` for FINITE NUMBERS ONLY (see
38
+ * `numOrNull`) and only under known UsageBreakdown/cost keys. A string can
39
+ * never reach an attribute through the payload path. Do not add a
40
+ * `stringValue` read from `payload` — that single line is the whole
41
+ * exfiltration bug.
42
+ * - Agent model/coding_agent come from the typed `AgentConfig` handed to
43
+ * `recordAgentSession` (config data), NOT from the `agent_start` payload.
44
+ * - Tool spans are named from `record.name`'s prefix up to the first ":"
45
+ * (see `toolSpanName`). The full `record.name` is a HUMAN LABEL built from
46
+ * real tool arguments (`agent_flue.ts`'s `labelFor` -> "bash: cat
47
+ * src/secret.ts") and must never be exported verbatim.
48
+ * - `Phase.error` is NOT exported. It is an agent- and repo-derived string.
49
+ * A failed phase span carries status ERROR with no message.
50
+ * - `tracer.sessionRequest`, `tracer.envelopeRow`, `tracer.processStart/End`
51
+ * have deliberately NO fan-out: the operator's request text, envelope
52
+ * contents, and pids are all outside the allowlist. Do not add one.
53
+ *
54
+ * SPAN MODEL. One run (adw_id) = one trace. Root span = the run. Each phase =
55
+ * a child span of the root, using `runner.ts`'s real `started_at`/`ended_at`.
56
+ * Each agent call (`agent_start`..`agent_end`, with its UsageBreakdown) = a
57
+ * CHILD span of its phase: a phase-only tree cannot answer "which agent call
58
+ * burned the tokens", which is the question this feature exists for.
59
+ * `tool_call` events (they carry real timing) = child spans of the open agent
60
+ * call where attributable, else of the phase. `handoff`/`error` become a span
61
+ * EVENT on the phase span, with allowlisted attributes only. `gate_pass`/
62
+ * `gate_fail` do NOT (see `recordGate` below, which carries the structured
63
+ * verdict instead). `log` is dropped outright — a console line is redundant
64
+ * with the phase span itself and would only crowd out `handoff`/`error` in
65
+ * the per-phase event cap (see BACKPRESSURE and `MAX_EVENTS_PER_SPAN`). Any
66
+ * event type not named above is dropped, fail-closed, by `recordEvent`.
67
+ *
68
+ * IDS ARE A BESPOKE CONVENTION, documented so nobody mistakes it for the OTel
69
+ * SDK's random-id behavior: trace-id = first 32 hex of sha256(adw_id),
70
+ * span-id = first 16 hex of sha256(a stable key — `phase_id` for a phase,
71
+ * `agent:<phase_id>:<agent>:<n>` for an agent call, `tool:<phase_id>:<event_id>`
72
+ * for a tool call). Determinism means a re-export of the same run lands on the
73
+ * same ids instead of duplicating the trace, and a child span can name its
74
+ * parent's id without waiting for the parent to be emitted.
75
+ * `EventRecord.parent_id` is structurally ALWAYS EMPTY today (SPF's phases are
76
+ * flat siblings; nothing writes nesting), so there is no recorded hierarchy to
77
+ * mine — the parenting above is reconstructed from phase_id + agent-call
78
+ * bracketing, and that is the only reason it needs reconstructing at all.
79
+ *
80
+ * PHASE SPANS ARE EMITTED AT PHASE END ONLY. A hung or killed phase is
81
+ * therefore INVISIBLE to the backend (its buffered span events die with it),
82
+ * while SQLite still shows it as `running`. Deliberate v1 trade: streaming a
83
+ * span at phase start would require mutating an already-sent span, which OTLP
84
+ * has no notion of. Recorded here so it is a known gap, not a surprise.
85
+ *
86
+ * INBOUND TRACEPARENT. When a valid W3C `traceparent` is present in the
87
+ * environment, its trace-id becomes this run's trace-id and the run's root
88
+ * span is parented under its span-id, so an SPF run joins the CI trace that
89
+ * launched it instead of hanging as an orphan root. Reading `traceparent` is
90
+ * NOT ambient activation: with no `observability.otel` config, nothing is
91
+ * constructed and nothing is sent, traceparent or not. Garbage is rejected
92
+ * silently (see `parseTraceparent`) — a malformed variable must degrade to
93
+ * "own root", never to an error.
94
+ *
95
+ * LIFECYCLE (copied from `notify/notifier.ts`'s discipline). A module-level
96
+ * LIVE registry holds every exporter this process created; `flushAll()` is
97
+ * awaited in `src/cli/index.ts`'s existing `finally` block next to
98
+ * `notify.flushAll()`, AND `session.ts`'s signal handler runs a bounded,
99
+ * timeout-capped drain before its `process.exit(128+n)` (notify does NOT do
100
+ * that second one today — its in-flight webhooks are dropped on SIGTERM; only
101
+ * the otel path is fixed here, on purpose, to keep this change to one seam).
102
+ * Send failures log ONE line for the life of the exporter, with the endpoint
103
+ * and every header VALUE redacted, and are then swallowed.
104
+ *
105
+ * BACKPRESSURE. `tracer.event()` fires per tool call on a hot path, so raw
106
+ * promise-per-span fire-and-forget is a memory bug, not a style choice.
107
+ * Spans go into a BOUNDED queue (`MAX_QUEUED_SPANS`, drop-OLDEST) and leave in
108
+ * batches (`BATCH_SPANS`, or `FLUSH_INTERVAL_MS`, whichever comes first) via an
109
+ * UNREF'D timer that can never hold the process open. Dropped spans are
110
+ * counted, reported once as a warn line, and exported as a resource attribute
111
+ * on the final flush so the gap is visible in the backend too. The size
112
+ * trigger schedules a timer rather than flushing inline, which also means a
113
+ * synchronous burst of thousands of events exercises the bound (see the queue
114
+ * test) instead of interleaving sends.
115
+ *
116
+ * WIRE FORMAT is hand-rolled OTLP/HTTP with a JSON body — no new npm
117
+ * dependency for an optional, lossy projection. The shape that matters:
118
+ * `{resourceSpans:[{resource:{attributes:[KeyValue]},scopeSpans:[{scope,spans:[Span]}]}]}`,
119
+ * every attribute value wrapped in an AnyValue (`{stringValue}`/`{intValue}`/
120
+ * `{doubleValue}`/`{boolValue}`), trace/span ids as lowercase hex strings, and
121
+ * every uint64 nanosecond timestamp AS A STRING (a JSON number would lose
122
+ * precision past 2^53 and backends reject it). `src/test/otel.test.ts` pins
123
+ * this shape against an in-process receiver.
124
+ */
125
+ import type { AgentConfig, EventRecord, GateReport, OTelConfig, Phase, SFConfig } from "./data_types.ts";
126
+ /** trace-id = first 32 hex of sha256(adw_id). Bespoke convention — see the header. */
127
+ export declare function traceIdFor(adwId: string): string;
128
+ /** span-id = first 16 hex of sha256(key), where key is a phase_id or a synthetic child key. */
129
+ export declare function spanIdFor(key: string): string;
130
+ export interface TraceParent {
131
+ traceId: string;
132
+ spanId: string;
133
+ sampled: boolean;
134
+ }
135
+ /**
136
+ * Strict W3C `traceparent` parse: `00-<32 hex>-<16 hex>-<2 hex>`, lowercase,
137
+ * exact lengths, neither id all-zero. Anything else — a wrong version, an
138
+ * uppercase digest, a truncated id, an empty string, unset — returns null and
139
+ * the run keeps its own root. Never throws, never logs: a malformed CI
140
+ * variable is not this module's problem to report.
141
+ */
142
+ export declare function parseTraceparent(value: string | undefined | null): TraceParent | null;
143
+ /**
144
+ * The two env vars CI systems actually set. Reading them is not activation —
145
+ * see INBOUND TRACEPARENT in the header.
146
+ */
147
+ export declare function inboundTraceparent(env?: NodeJS.ProcessEnv): TraceParent | null;
148
+ /**
149
+ * The configured endpoint is used AS GIVEN when it already names a path — the
150
+ * operator's URL is not ours to rewrite. A bare origin (`http://host:4318`,
151
+ * or a trailing "/") gets the standard OTLP/HTTP traces path appended, because
152
+ * that is the one guess with a single right answer. Returns the input
153
+ * unchanged if it does not parse as a URL; the config schema rejects those
154
+ * first, so this is only belt-and-braces for direct callers.
155
+ */
156
+ export declare function resolveTracesUrl(endpoint: string): string;
157
+ /**
158
+ * A printable form of the endpoint for `spf doctor`: origin + path only.
159
+ * Userinfo (`https://user:token@host/...`) and the query string are dropped —
160
+ * both are places a credential is routinely smuggled into a URL.
161
+ */
162
+ export declare function endpointLabel(endpoint: string): string;
163
+ /**
164
+ * ISO-8601 -> uint64 nanoseconds AS A STRING (see WIRE FORMAT). Unparseable,
165
+ * missing, or pre-epoch input falls back to `fallbackMs`, because a span with
166
+ * a nonsense timestamp is rejected wholesale by most backends while a span
167
+ * with an approximate one is still useful.
168
+ */
169
+ export declare function nanosFromIso(iso: string | null | undefined, fallbackMs?: number): string;
170
+ /**
171
+ * The safe half of a `tool_call` event's name. `record.name` for a tool call is
172
+ * a human label built FROM THE TOOL'S ARGUMENTS ("bash: cat src/secret.ts",
173
+ * "read: /etc/hosts"); only the part before the first ":" is the tool's own
174
+ * identity. Also clipped and character-restricted so a hand-rolled event name
175
+ * can't smuggle a payload through as a span name.
176
+ */
177
+ export declare function toolSpanName(eventName: string | undefined | null): string;
178
+ /**
179
+ * Redact every secret from a log line. The endpoint and each header VALUE are
180
+ * passed in; `fetch` failures routinely embed the URL they attempted, and a
181
+ * proxy error can echo a header. One-line rule: nothing configured under
182
+ * `observability.otel` ever reaches a log.
183
+ */
184
+ export declare function redact(message: string, secrets: Array<string | undefined>): string;
185
+ export interface OtelExporterInit {
186
+ /** The validated `observability.otel` block. Its presence IS the activation switch. */
187
+ cfg: OTelConfig;
188
+ adwId: string;
189
+ /** The CLI chain name, from `session.ts` — allowlisted, config-declared. */
190
+ chainName: string;
191
+ /** Injectable for tests; defaults to stderr, exactly like `Notifier`'s. */
192
+ log?: (message: string) => void;
193
+ /** Injectable for tests; defaults to `process.env`. Only ever read for `traceparent`. */
194
+ env?: NodeJS.ProcessEnv;
195
+ }
196
+ export declare class OtelExporter {
197
+ private readonly cfg;
198
+ private readonly adwId;
199
+ private readonly chainName;
200
+ private readonly serviceName;
201
+ private readonly url;
202
+ private readonly log;
203
+ private readonly traceId;
204
+ /** "" unless an inbound traceparent parented this run — see INBOUND TRACEPARENT. */
205
+ private readonly rootParentSpanId;
206
+ private readonly rootSpanId;
207
+ private queue;
208
+ private dropped;
209
+ private droppedEvents;
210
+ private warnedDrops;
211
+ private loggedFailure;
212
+ private timer;
213
+ private timerDelay;
214
+ private pending;
215
+ /** Span events buffered until their phase span exists. Key "" = the root run span. */
216
+ private bufferedEvents;
217
+ /**
218
+ * Phases whose span has already gone out. Events DO arrive after a phase
219
+ * span is emitted — `run.finish()`'s `not_accepted` error names the last
220
+ * phase — and a span already on the wire cannot grow an event, so those are
221
+ * re-homed onto the root run span instead of accumulating in a buffer that
222
+ * nothing will ever drain.
223
+ */
224
+ private emittedPhases;
225
+ /** `<phase_id><agent>` -> the open agent call, for closing it and parenting tool spans. */
226
+ private openAgents;
227
+ /** How many times an agent has been called in a phase, so a retry gets its own span id. */
228
+ private agentCalls;
229
+ /** agent name -> config metadata, from `recordAgentSession` (typed config, never a payload). */
230
+ private agentMeta;
231
+ private runStartedAtMs;
232
+ private rootEmitted;
233
+ constructor(init: OtelExporterInit);
234
+ /** `tracer.sessionStart` — only the run's clock; the engineer name is not allowlisted. */
235
+ recordSessionStart(startedAtIso?: string | null): void;
236
+ /**
237
+ * `tracer.event` — the one hot seam. Dispatch, in full:
238
+ * phase_start/phase_end -> ignored (the phase span's own boundaries say it)
239
+ * agent_start -> open an agent call
240
+ * agent_end -> emit the agent-call child span, with usage
241
+ * tool_call -> emit a tool child span (real timing, safe name)
242
+ * gate_pass/gate_fail -> ignored HERE; `recordGate` carries the structured
243
+ * GateReport for the same gate, and doubling it
244
+ * would put two span events on every gate
245
+ * handoff/error -> a span event buffered onto the phase span
246
+ * log -> dropped: a console line adds nothing beyond
247
+ * `spf.event.type`/`name`, and buffering it would
248
+ * crowd `handoff`/`error` out of the per-phase cap
249
+ * (see MAX_EVENTS_PER_SPAN)
250
+ * anything else -> dropped. The dispatch is fail-closed on purpose:
251
+ * a future EventRecord.type whose `name` is
252
+ * derived from agent output must not fall through
253
+ * to export by default.
254
+ */
255
+ recordEvent(record: EventRecord, eventId: string, tsIso: string): void;
256
+ /**
257
+ * `tracer.phaseUpsert` — the phase's END is the emit point (see PHASE SPANS
258
+ * ARE EMITTED AT PHASE END ONLY). The start-of-phase upsert has no
259
+ * `ended_at` and is skipped, which also makes this idempotent-ish: a
260
+ * re-upsert of the same finished phase re-emits a span with the SAME id, so
261
+ * a backend overwrites rather than duplicates.
262
+ */
263
+ recordPhase(phase: Phase): void;
264
+ /**
265
+ * `tracer.gateRow` — the verdict and its SIZE, never its content. A
266
+ * violation string quotes the agent's own claim and the repo's files; only
267
+ * the count crosses the wire.
268
+ */
269
+ recordGate(phase: Phase, gate: string, report: GateReport, attempt: number): void;
270
+ /**
271
+ * `tracer.agentSessionRow` — the typed source for an agent's model and
272
+ * backend. Load-bearing, not decoration: it is written BEFORE the
273
+ * `agent_end` event (see `agents.ts`), which is what lets the agent-call
274
+ * span carry model/coding_agent without ever reading the `agent_start`
275
+ * payload. `session_id` is NOT exported (it is a coding-agent handle, not a
276
+ * measure).
277
+ */
278
+ recordAgentSession(agent: AgentConfig): void;
279
+ /**
280
+ * `tracer.sessionFinish` — emits the root run span exactly once. Called
281
+ * twice on some paths (a failing phase finalizes, then `finish()` does), and
282
+ * the guard is why that is harmless.
283
+ */
284
+ recordSessionFinish(ok: boolean): void;
285
+ /** Queued spans and spans/events dropped so far. For tests and diagnostics. */
286
+ stats(): {
287
+ queued: number;
288
+ dropped: number;
289
+ droppedEvents: number;
290
+ };
291
+ /**
292
+ * The exact JSON body the next flush would POST, without sending or
293
+ * draining. This is the seam `src/test/otel.test.ts` uses to prove the
294
+ * allowlist holds — the assertion is on the literal bytes, so any future
295
+ * attribute that leaks a payload fails a test rather than a review.
296
+ */
297
+ pendingJson(): string;
298
+ private enqueue;
299
+ private schedule;
300
+ private track;
301
+ /**
302
+ * Send whatever is queued. Never throws, never rejects: a failed export is a
303
+ * single redacted log line and a swallowed error, because the alternative is
304
+ * an observability feature that can fail a run.
305
+ */
306
+ flush(isFinal?: boolean): Promise<void>;
307
+ /**
308
+ * Drain: flush, then await anything already in flight, all under one hard
309
+ * budget. Never throws. Called from the CLI's `finally` and from
310
+ * `session.ts`'s signal handler (with a tighter budget there).
311
+ */
312
+ drain(budgetMs?: number): Promise<void>;
313
+ private emitRootSpan;
314
+ private agentKey;
315
+ private openAgentCall;
316
+ private closeAgentCall;
317
+ /**
318
+ * Tool spans carry REAL elapsed time (the tracker records started_at/ended_at
319
+ * per call), which is the whole reason they are spans and not span events.
320
+ * Parented under the open agent call when one is attributable — the agent
321
+ * name comes from `payload.agent`, and it is used ONLY as a map lookup key,
322
+ * never written to an attribute, so an unexpected value yields "no parent
323
+ * found" rather than an exported string.
324
+ */
325
+ private emitToolSpan;
326
+ private bufferSpanEvent;
327
+ private takeBufferedEvents;
328
+ private payloadFor;
329
+ private logFailureOnce;
330
+ }
331
+ /**
332
+ * Build an exporter from `cfg.observability.otel`, or `null` when it is
333
+ * absent — the same optional-dependency shape as `resolveNotifier`, so every
334
+ * call site is `otel?.record...()` and never a conditional branch. `null` is
335
+ * the default for every repo that has not configured an endpoint, and no
336
+ * environment variable can change that (see EXPLICIT CONFIG ONLY).
337
+ */
338
+ export declare function resolveOtelExporter(cfg: SFConfig, opts: {
339
+ adwId: string;
340
+ chainName: string;
341
+ log?: (message: string) => void;
342
+ env?: NodeJS.ProcessEnv;
343
+ }): OtelExporter | null;
344
+ /**
345
+ * Drain every exporter this process created, under one budget. A no-op when
346
+ * otel is unconfigured. Called from `src/cli/index.ts`'s `finally` (next to
347
+ * `notify.flushAll()`) and, with a tighter budget, from `session.ts`'s signal
348
+ * handler. Never throws.
349
+ */
350
+ export declare function flushAll(budgetMs?: number): Promise<void>;
351
+ /** Tests only: forget every registered exporter so cases cannot leak into each other. */
352
+ export declare function resetLiveForTest(): void;