@gr8ful/spf 0.4.0 → 0.5.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 (80) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +158 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/paths.d.ts +3 -0
  48. package/dist/core/paths.js +48 -1
  49. package/dist/core/providers.js +4 -0
  50. package/dist/core/refine.js +11 -3
  51. package/dist/core/session.js +39 -2
  52. package/dist/core/tracer.d.ts +31 -2
  53. package/dist/core/tracer.js +69 -11
  54. package/dist/core/watch.d.ts +11 -0
  55. package/dist/core/watch.js +17 -2
  56. package/dist/test/chains.test.js +8 -3
  57. package/dist/test/data_types.test.js +140 -2
  58. package/dist/test/git_helper.test.d.ts +1 -0
  59. package/dist/test/git_helper.test.js +59 -0
  60. package/dist/test/hermetic_git.d.ts +1 -0
  61. package/dist/test/hermetic_git.js +22 -0
  62. package/dist/test/init_command.test.d.ts +14 -1
  63. package/dist/test/init_command.test.js +54 -1
  64. package/dist/test/interview.test.d.ts +15 -1
  65. package/dist/test/interview.test.js +127 -0
  66. package/dist/test/ollama_provider.test.d.ts +1 -0
  67. package/dist/test/ollama_provider.test.js +103 -0
  68. package/dist/test/otel.test.d.ts +26 -0
  69. package/dist/test/otel.test.js +512 -0
  70. package/dist/test/paths.test.d.ts +1 -0
  71. package/dist/test/paths.test.js +68 -0
  72. package/dist/test/refine.test.js +64 -1
  73. package/dist/test/repo_chains.test.d.ts +21 -0
  74. package/dist/test/repo_chains.test.js +416 -0
  75. package/dist/test/signoff.test.d.ts +1 -0
  76. package/dist/test/signoff.test.js +329 -0
  77. package/dist/test/ui_server.test.d.ts +7 -1
  78. package/dist/test/ui_server.test.js +1 -0
  79. package/dist/test/watch.test.js +124 -1
  80. package/package.json +5 -5
@@ -11,10 +11,49 @@
11
11
  * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
12
  * before running spf. Space-separated command chains are supported:
13
13
  * - `SPF_CLAUDE_CMD="claude"` (default)
14
- * - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher)
14
+ * - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher
15
+ * the `--model` is `ollama launch`'s OWN flag, and is mandatory in
16
+ * headless mode; see below)
15
17
  * The command/launcher must support the full Claude Code CLI interface.
16
18
  * When unset, defaults to `claude`.
17
19
  *
20
+ * Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
21
+ * `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
22
+ * ...args]`, so a plain passthrough shim needs nothing special, and a
23
+ * cmdSpec that already contains its own literal `--` is left completely
24
+ * alone — `args` lands after it exactly as written. `ollama launch <cmd>
25
+ * [flags...]` is the one documented shape that does NOT self-supply that
26
+ * separator: it uses cobra flag parsing, which treats anything typed after
27
+ * `launch <cmd>` as ITS OWN flags unless a literal `--` says otherwise —
28
+ * spike-verified live: `SPF_CLAUDE_CMD="ollama launch claude"` alone dies
29
+ * with `unknown shorthand flag: 'p' in -p` before `claude` ever starts
30
+ * (scratchpad/ollama-spike/probe2-claudecode-ollama/run3_ollama_launch_claude.log).
31
+ *
32
+ * The `--` insertion below fixes THAT failure, but is NOT by itself
33
+ * sufficient to reach `claude` — it only trades one error for the next one.
34
+ * `ollama launch` also requires its OWN `--model <tag>` flag, typed BEFORE
35
+ * the `--` separator, whenever it's run headless: with no model flag it
36
+ * falls back to an interactive model picker, and SPF always spawns with
37
+ * piped (non-interactive) stdio, so that picker can never run. Spike-verified
38
+ * live: `--` alone (no `--model` anywhere) dies one step later with `Error:
39
+ * model selection requires an interactive terminal; use --model to run in
40
+ * headless mode` (.../run4_ollama_launch_claude_dashdash.log) — that log IS
41
+ * the "just add `--`" experiment, and it fails. Putting `--model <tag>`
42
+ * AFTER the separator doesn't help either: at that point it's parsed as
43
+ * `claude`'s own `--model`, not `ollama launch`'s, so `ollama launch` still
44
+ * sees no model and still fails the same way. Only supplying `ollama
45
+ * launch`'s `--model` BEFORE the separator succeeds end-to-end
46
+ * (.../run5_ollama_launch_claude_full.log). So the operator's `SPF_CLAUDE_CMD`
47
+ * itself must read `ollama launch claude --model <tag>` (tag from `ollama
48
+ * list`) — this module can insert the `--`, but cannot supply the model tag
49
+ * on the operator's behalf; `doctor.ts` hard-fails a `SPF_CLAUDE_CMD` that
50
+ * omits it, since this is a static, deterministic misconfiguration.
51
+ *
52
+ * This module special-cases exactly the `ollama launch ...` token shape
53
+ * (with no `--` already present) and inserts the separator automatically at
54
+ * that position — never for any other wrapper, and never a second `--` if
55
+ * the operator already wrote one themselves.
56
+ *
18
57
  * Every flag below was verified against a REAL local run of this exact
19
58
  * machine's `claude` CLI (v2.1.237) before being written — not assumed from
20
59
  * the SDK's docs, which describe a related but separately-versioned
@@ -11,10 +11,49 @@
11
11
  * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
12
  * before running spf. Space-separated command chains are supported:
13
13
  * - `SPF_CLAUDE_CMD="claude"` (default)
14
- * - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher)
14
+ * - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher
15
+ * the `--model` is `ollama launch`'s OWN flag, and is mandatory in
16
+ * headless mode; see below)
15
17
  * The command/launcher must support the full Claude Code CLI interface.
16
18
  * When unset, defaults to `claude`.
17
19
  *
20
+ * Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
21
+ * `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
22
+ * ...args]`, so a plain passthrough shim needs nothing special, and a
23
+ * cmdSpec that already contains its own literal `--` is left completely
24
+ * alone — `args` lands after it exactly as written. `ollama launch <cmd>
25
+ * [flags...]` is the one documented shape that does NOT self-supply that
26
+ * separator: it uses cobra flag parsing, which treats anything typed after
27
+ * `launch <cmd>` as ITS OWN flags unless a literal `--` says otherwise —
28
+ * spike-verified live: `SPF_CLAUDE_CMD="ollama launch claude"` alone dies
29
+ * with `unknown shorthand flag: 'p' in -p` before `claude` ever starts
30
+ * (scratchpad/ollama-spike/probe2-claudecode-ollama/run3_ollama_launch_claude.log).
31
+ *
32
+ * The `--` insertion below fixes THAT failure, but is NOT by itself
33
+ * sufficient to reach `claude` — it only trades one error for the next one.
34
+ * `ollama launch` also requires its OWN `--model <tag>` flag, typed BEFORE
35
+ * the `--` separator, whenever it's run headless: with no model flag it
36
+ * falls back to an interactive model picker, and SPF always spawns with
37
+ * piped (non-interactive) stdio, so that picker can never run. Spike-verified
38
+ * live: `--` alone (no `--model` anywhere) dies one step later with `Error:
39
+ * model selection requires an interactive terminal; use --model to run in
40
+ * headless mode` (.../run4_ollama_launch_claude_dashdash.log) — that log IS
41
+ * the "just add `--`" experiment, and it fails. Putting `--model <tag>`
42
+ * AFTER the separator doesn't help either: at that point it's parsed as
43
+ * `claude`'s own `--model`, not `ollama launch`'s, so `ollama launch` still
44
+ * sees no model and still fails the same way. Only supplying `ollama
45
+ * launch`'s `--model` BEFORE the separator succeeds end-to-end
46
+ * (.../run5_ollama_launch_claude_full.log). So the operator's `SPF_CLAUDE_CMD`
47
+ * itself must read `ollama launch claude --model <tag>` (tag from `ollama
48
+ * list`) — this module can insert the `--`, but cannot supply the model tag
49
+ * on the operator's behalf; `doctor.ts` hard-fails a `SPF_CLAUDE_CMD` that
50
+ * omits it, since this is a static, deterministic misconfiguration.
51
+ *
52
+ * This module special-cases exactly the `ollama launch ...` token shape
53
+ * (with no `--` already present) and inserts the separator automatically at
54
+ * that position — never for any other wrapper, and never a second `--` if
55
+ * the operator already wrote one themselves.
56
+ *
18
57
  * Every flag below was verified against a REAL local run of this exact
19
58
  * machine's `claude` CLI (v2.1.237) before being written — not assumed from
20
59
  * the SDK's docs, which describe a related but separately-versioned
@@ -240,9 +279,17 @@ export async function run(request, onEvent, onSpawn, onExit) {
240
279
  "--strict-mcp-config", // see the module doc comment — required, not optional
241
280
  ];
242
281
  const cmdSpec = process.env.SPF_CLAUDE_CMD || "claude";
243
- const [cmd, ...cmdArgs] = cmdSpec.split(/\s+/);
244
- const fullArgs = [...cmdArgs, ...args];
245
- const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: operatorEnv() });
282
+ const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
283
+ const [cmd, ...cmdArgs] = cmdTokens;
284
+ // See the module doc comment for why `ollama launch ...` (and ONLY that
285
+ // shape) gets an auto-inserted `--`: cobra flag parsing otherwise consumes
286
+ // `args`' own flags (e.g. `-p`) as `ollama launch`'s, before `claude` is
287
+ // ever reached. Any cmdSpec that already contains a literal `--` token is
288
+ // left completely alone — `args` is appended after it exactly as written,
289
+ // never a second separator.
290
+ const needsOllamaLaunchSeparator = cmdTokens[0] === "ollama" && cmdTokens[1] === "launch" && !cmdArgs.includes("--");
291
+ const fullArgs = needsOllamaLaunchSeparator ? [...cmdArgs, "--", ...args] : [...cmdArgs, ...args];
292
+ const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: request.env ?? operatorEnv() });
246
293
  // The prompt travels as a positional argv element, not stdin — closing it
247
294
  // immediately avoids a real, observed ~3s "no stdin data received" stall
248
295
  // where `claude` otherwise waits to see whether anything is piped in.
@@ -25,6 +25,7 @@
25
25
  import { AgentRunError, createBashTool, createEditTool, createGlobTool, createGrepTool, createReadTool, createWriteTool, init, observe, useDataWriter, useModel, useSandbox, useTool, } from "@flue/runtime";
26
26
  import { local, sqlite, start } from "@flue/runtime/node";
27
27
  import { UsageBreakdown, makeAgentResult } from "./data_types.js";
28
+ import { registerOllamaModel } from "./ollama_provider.js";
28
29
  import { nowIso, operatorEnv } from "./utils.js";
29
30
  const RESULT_SNIPPET_CHARS = 20_000; // tool output rides along whole; clip only guards pathological cases
30
31
  const ARG_VALUE_CHARS = 20_000; // args too — the UI scrolls, it must not be handed cut-off data
@@ -156,10 +157,12 @@ function resolveBuiltinTools(names) {
156
157
  * `undefined`/`null` toolNames = every builtin (Flue's own default when no
157
158
  * `tools` override is given — matches SPF's "unset = all tools usable").
158
159
  * `local()`'s env does NOT inherit process.env by default (only PATH/HOME/
159
- * USER/LANG/TERM/TMPDIR) — operatorEnv() restores today's actual behavior.
160
+ * USER/LANG/TERM/TMPDIR) — `env` is `request.env ?? operatorEnv()` from the
161
+ * caller, restoring today's actual behavior unless the agent's own
162
+ * `env_allowlist` narrowed it (see agents.ts).
160
163
  */
161
- function sandboxFor(toolNames, cwd) {
162
- const base = local({ cwd, env: operatorEnv() });
164
+ function sandboxFor(toolNames, cwd, env) {
165
+ const base = local({ cwd, env });
163
166
  if (!toolNames)
164
167
  return base;
165
168
  const factories = resolveBuiltinTools(toolNames);
@@ -188,7 +191,7 @@ function sfAgentRender({ id }) {
188
191
  if (!spec)
189
192
  throw new Error(`agent_flue: no render spec registered for conversation ${id} — run() must set it before dispatching`);
190
193
  useModel(spec.model, { thinkingLevel: spec.thinking });
191
- useSandbox(sandboxFor(spec.toolNames, spec.cwd));
194
+ useSandbox(sandboxFor(spec.toolNames, spec.cwd, spec.env));
192
195
  const writeReport = useDataWriter("sf_report");
193
196
  useTool({
194
197
  name: "sf_report",
@@ -265,6 +268,26 @@ function contextTokensOf(usage) {
265
268
  * not attempted here.
266
269
  */
267
270
  export async function run(request, onEvent, onSpawn, onExit) {
271
+ // Ollama has no pi-ai/Flue built-in provider — it must be registered as a
272
+ // custom `createProvider(...)` before anything dispatches against it (see
273
+ // ollama_provider.ts's header comment for why: dummy api key, union
274
+ // re-registration, exact version pin). Doing this BEFORE `ensureRuntime()`
275
+ // means it's in place before that call's `start()` ever gets a chance to
276
+ // run its own default-provider registration — irrelevant today (pi-ai ships
277
+ // no "ollama" built-in to collide with) but keeps the ordering the one a
278
+ // future built-in couldn't quietly undermine. `resolveModel` is a cheap
279
+ // string split regardless of provider, so the check above costs nothing
280
+ // extra for a non-ollama agent — but `registerOllamaModel`'s own dynamic
281
+ // imports are NOT a load-time saving for the ollama case either (this
282
+ // module's own unconditional `@flue/runtime/node` import already pulls in
283
+ // pi-ai's full runtime for every run; see ollama_provider.ts's header
284
+ // comment for the measured numbers). What the lazy boundary actually buys
285
+ // is keeping ollama-only symbols off the module graph of anything that
286
+ // imports this module for `resolveModel()` alone (doctor.ts, interview.ts)
287
+ // without ever dispatching an ollama call.
288
+ const [provider, modelId] = resolveModel(request.model);
289
+ if (provider === "ollama")
290
+ await registerOllamaModel(modelId);
268
291
  await ensureRuntime(request.flue_db_path);
269
292
  REGISTRY.set(request.session_id, {
270
293
  model: request.model,
@@ -274,6 +297,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
274
297
  systemText: request.system_prompt,
275
298
  outputSchema: request.output_schema,
276
299
  outputTypeName: request.output_type_name,
300
+ env: request.env ?? operatorEnv(),
277
301
  });
278
302
  const pid = process.pid ?? -1;
279
303
  onSpawn?.(pid);
@@ -8,6 +8,14 @@
8
8
  * disposes.
9
9
  */
10
10
  import { GateReport, makeEventRecord, type AgentCall, type AgentConfig, type EnvelopeBase, type Phase, type SFConfig } from "./data_types.ts";
11
+ /**
12
+ * `undefined` (no `env_allowlist` configured — the default) means "don't
13
+ * filter at all"; both backends treat that as `request.env ?? operatorEnv()`,
14
+ * i.e. today's unfiltered behavior, byte-identical. Configured, this filters
15
+ * the operator's own environment down to the named keys plus the baseline
16
+ * `local()` would keep anyway.
17
+ */
18
+ export declare function agentEnv(agent: AgentConfig): Record<string, string> | undefined;
11
19
  export declare class GateFailure extends Error {
12
20
  }
13
21
  /**
@@ -17,8 +17,32 @@ import * as paths from "./paths.js";
17
17
  import * as permissions from "./permissions.js";
18
18
  import * as prompts from "./prompts.js";
19
19
  import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
20
- import { newId } from "./utils.js";
20
+ import { newId, operatorEnv } from "./utils.js";
21
21
  const JSON_FIX_ATTEMPTS = 2; // continue-with-correction attempts for malformed JSON
22
+ // Kept alongside agent_flue.ts's own comment on this same list — `local()`'s
23
+ // sandbox keeps these regardless of any `env` override, so an allowlist that
24
+ // omitted them would silently lose them there but not on agent_cc.ts's plain
25
+ // `spawn()`. Folding them in here keeps both backends' filtered env identical.
26
+ const ENV_BASELINE_KEYS = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
27
+ /**
28
+ * `undefined` (no `env_allowlist` configured — the default) means "don't
29
+ * filter at all"; both backends treat that as `request.env ?? operatorEnv()`,
30
+ * i.e. today's unfiltered behavior, byte-identical. Configured, this filters
31
+ * the operator's own environment down to the named keys plus the baseline
32
+ * `local()` would keep anyway.
33
+ */
34
+ export function agentEnv(agent) {
35
+ if (!agent.env_allowlist)
36
+ return undefined;
37
+ const operator = operatorEnv();
38
+ const keep = new Set([...ENV_BASELINE_KEYS, ...agent.env_allowlist]);
39
+ const env = {};
40
+ for (const key of keep) {
41
+ if (operator[key] !== undefined)
42
+ env[key] = operator[key];
43
+ }
44
+ return env;
45
+ }
22
46
  export class GateFailure extends Error {
23
47
  }
24
48
  /**
@@ -57,7 +81,19 @@ function mergeAgentLists(base, override) {
57
81
  }
58
82
  return merged;
59
83
  }
60
- /** `defaults`/`observability`/`quality`/`watch`/`notifications` merge key-by-key; `agents` merges by name. */
84
+ /**
85
+ * `defaults`/`observability`/`quality`/`watch`/`notifications`/`review` merge
86
+ * key-by-key; `agents` merges by name.
87
+ *
88
+ * THE SILENT-DROP TRAP (adversarial history, not a hypothetical): this
89
+ * function returns a FIXED-SHAPE object literal — every top-level `SFConfig`
90
+ * key has to be named on BOTH sides of every merged field here, by hand, or
91
+ * a `.spf/spf.config.yaml` value for it is silently discarded before
92
+ * `v.parse(SFConfigSchema, raw)` ever sees it. Adding a key to
93
+ * `SFConfigSchema` without adding it here is exactly that bug — see
94
+ * `data_types.test.ts`'s merge-survival test for `review`, which is the
95
+ * regression guard this comment is here to justify.
96
+ */
61
97
  function mergeRawConfig(base, override) {
62
98
  return {
63
99
  defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
@@ -67,6 +103,9 @@ function mergeRawConfig(base, override) {
67
103
  // channels is a whole-array replace on override, same as quality.checks —
68
104
  // you don't want an override's channels appended to the built-in's.
69
105
  notifications: { ...(base.notifications || {}), ...(override.notifications || {}) },
106
+ // review.require_human_signoff / review.signoff_timeout_seconds — see
107
+ // data_types.ts's ReviewConfigSchema doc comment for why this key exists.
108
+ review: { ...(base.review || {}), ...(override.review || {}) },
70
109
  agents: mergeAgentLists(base.agents || [], override.agents || []),
71
110
  };
72
111
  }
@@ -90,7 +129,7 @@ export function loadConfig(configPaths) {
90
129
  }
91
130
  const defaults = raw.defaults || {};
92
131
  for (const agent of raw.agents || []) {
93
- for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes"]) {
132
+ for (const key of ["coding_agent", "model", "thinking", "color", "tools", "writes", "env_allowlist"]) {
94
133
  if (key in defaults && !(key in agent))
95
134
  agent[key] = defaults[key];
96
135
  }
@@ -231,6 +270,7 @@ export async function execute(run, phase, call) {
231
270
  output_type_name: call.output_type.name,
232
271
  cwd: run.repo_root,
233
272
  flue_db_path: path.join(run.data_dir, "flue.db"),
273
+ env: agentEnv(agent),
234
274
  };
235
275
  const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
236
276
  const onSpawn = (pid) => run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`);
@@ -376,6 +376,7 @@ export declare const AgentConfigSchema: v.ObjectSchema<{
376
376
  readonly harness_engineering: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
377
377
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
378
378
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
379
+ readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
379
380
  }, undefined>;
380
381
  export type AgentConfig = v.InferOutput<typeof AgentConfigSchema>;
381
382
  export declare const ConfigDefaultsSchema: v.ObjectSchema<{
@@ -389,9 +390,35 @@ export declare const ConfigDefaultsSchema: v.ObjectSchema<{
389
390
  readonly data_dir: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data">;
390
391
  }, undefined>;
391
392
  export type ConfigDefaults = v.InferOutput<typeof ConfigDefaultsSchema>;
393
+ /**
394
+ * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
395
+ * required BY THIS SCHEMA rather than defaulted, because presence of the block
396
+ * IS the activation switch. No ambient environment variable can turn export on
397
+ * (notably NOT `OTEL_EXPORTER_OTLP_ENDPOINT`): an unrelated shell variable must
398
+ * never become a data-egress switch. See `core/otel.ts`'s header for the full
399
+ * set of constraints, including the attribute allowlist that keeps repo source
400
+ * code (tool args, prompts, envelopes, the request text) off the wire.
401
+ *
402
+ * `endpoint` is URL-validated so a typo fails at config load rather than as a
403
+ * silent per-run export failure. Either the full OTLP traces path
404
+ * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
405
+ * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
406
+ * token goes; its VALUES are treated as secrets and never logged.
407
+ */
408
+ export declare const OTelConfigSchema: v.ObjectSchema<{
409
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
410
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
411
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
412
+ }, undefined>;
413
+ export type OTelConfig = v.InferOutput<typeof OTelConfigSchema>;
392
414
  export declare const ObservabilityConfigSchema: v.ObjectSchema<{
393
415
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
394
416
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
417
+ readonly otel: v.OptionalSchema<v.ObjectSchema<{
418
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
419
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
420
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
421
+ }, undefined>, undefined>;
395
422
  }, undefined>;
396
423
  export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema>;
397
424
  /**
@@ -409,8 +436,25 @@ export type ObservabilityConfig = v.InferOutput<typeof ObservabilityConfigSchema
409
436
  * watch` startup, not here — an empty string parses fine (this schema has
410
437
  * no opinion on whether watch is even configured), matching the same
411
438
  * "fails loudly before anything spawns, not eagerly at parse time" pattern
412
- * `quality:` already uses. Its shape depends on `code_host`: "owner/name"
413
- * for github, "workspace/repo_slug" for bitbucket.
439
+ * `quality:` already uses. `repo` always names `code_host`'s own repo —
440
+ * its shape depends on `code_host`: "owner/name" for github,
441
+ * "workspace/repo_slug" for bitbucket. `resolveCodeHostProvider`
442
+ * (`cli/commands/watch.ts`) always reads it.
443
+ *
444
+ * `issue_repo` exists for exactly one combination where that single field
445
+ * stops being enough: `issue_provider: github` with `code_host: bitbucket`
446
+ * — GitHub-issues-against-a-Bitbucket-repo, a real setup this project
447
+ * explicitly supports, where the issue tracker and the code host are
448
+ * genuinely different repos in different systems, not the same repo worn
449
+ * two ways. Every other combination stays a single field: `issue_provider:
450
+ * github` + `code_host: github` is one repo by construction; `issue_provider:
451
+ * jira` never reads `repo` at all (it uses `jira.base_url`/`project_key`),
452
+ * so `repo` unambiguously belongs to whichever code host is configured.
453
+ * `resolveIssueProvider`'s github branch and `core/refine.ts`'s
454
+ * `resolveAuthoringProvider` (issue authoring always targets the issue
455
+ * tracker, never the code host) both read `issue_repo || repo` — leaving
456
+ * `issue_repo` unset is a complete no-op, so no existing config needs to
457
+ * change.
414
458
  */
415
459
  export declare const WatchIssueProviderSchema: v.PicklistSchema<["github", "jira"], undefined>;
416
460
  export type WatchIssueProviderKind = v.InferOutput<typeof WatchIssueProviderSchema>;
@@ -442,6 +486,7 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
442
486
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
443
487
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
444
488
  readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
489
+ readonly issue_repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
445
490
  readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
446
491
  readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
447
492
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
@@ -501,6 +546,34 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
501
546
  }, undefined>, undefined>, () => never[]>;
502
547
  }, undefined>;
503
548
  export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
549
+ /**
550
+ * `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
551
+ * AMENDMENTS on the review-accountability thread. `simple_sdlc.ts`'s
552
+ * `commit_build` predicate is the ONLY place in this codebase where an AI
553
+ * reviewer's `approved` flag gates a commit; `decideSignoff` (same file)
554
+ * turns that PROPOSAL into a human's DISPOSAL wherever a human is at the
555
+ * keyboard, and reads these two knobs when there isn't one.
556
+ *
557
+ * `require_human_signoff` defaults to FALSE for this release, deliberately:
558
+ * flipping it to fail-closed-by-default would break every unattended
559
+ * `simple-sdlc` run (`spf watch`, CI) the day this shipped, before `spf
560
+ * watch` itself is signoff-aware (its own human gate today is the PR merge,
561
+ * now informed by the reviewer digest — see `cli/commands/watch.ts`). An
562
+ * unattended run instead proceeds on the AI verdict alone with a LOUD
563
+ * one-time warning (`decideSignoff`'s `AI_ONLY_SIGNOFF_WARNING`) until that
564
+ * changes. Set `true` and an unattended run fails the phase CLOSED instead —
565
+ * see `decideSignoff`'s fail-closed branch — rather than silently
566
+ * auto-approving because nobody typed at a prompt that was never shown.
567
+ *
568
+ * `signoff_timeout_seconds` bounds the interactive prompt itself: expiry
569
+ * means NOT accepted (the confirm's own default), never an unbounded stdin
570
+ * read inside `run.phase()` — see `cli/ask.ts`'s `confirm(..., {timeoutMs})`.
571
+ */
572
+ export declare const ReviewConfigSchema: v.ObjectSchema<{
573
+ readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
574
+ readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
575
+ }, undefined>;
576
+ export type ReviewConfig = v.InferOutput<typeof ReviewConfigSchema>;
504
577
  export declare const SFConfigSchema: v.ObjectSchema<{
505
578
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
506
579
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -524,9 +597,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
524
597
  readonly observability: v.OptionalSchema<v.ObjectSchema<{
525
598
  readonly db: v.OptionalSchema<v.StringSchema<undefined>, ".spf/data/spf.db">;
526
599
  readonly poll_ms: v.OptionalSchema<v.NumberSchema<undefined>, 500>;
600
+ readonly otel: v.OptionalSchema<v.ObjectSchema<{
601
+ readonly endpoint: v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.UrlAction<string, undefined>]>;
602
+ readonly headers: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, undefined>;
603
+ readonly service_name: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
604
+ }, undefined>, undefined>;
527
605
  }, undefined>, () => {
528
606
  db: string;
529
607
  poll_ms: number;
608
+ otel?: {
609
+ endpoint: string;
610
+ headers?: {
611
+ [x: string]: string;
612
+ } | undefined;
613
+ service_name: string;
614
+ } | undefined;
530
615
  }>;
531
616
  readonly agents: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
532
617
  readonly name: v.StringSchema<undefined>;
@@ -542,6 +627,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
542
627
  readonly harness_engineering: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
543
628
  readonly tools: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
544
629
  readonly writes: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
630
+ readonly env_allowlist: v.OptionalSchema<v.NullableSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, undefined>, undefined>;
545
631
  }, undefined>, undefined>, () => never[]>;
546
632
  readonly quality: v.OptionalSchema<v.ObjectSchema<{
547
633
  readonly checks: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
@@ -568,6 +654,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
568
654
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
569
655
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
570
656
  readonly repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
657
+ readonly issue_repo: v.OptionalSchema<v.StringSchema<undefined>, "">;
571
658
  readonly label_prefix: v.OptionalSchema<v.StringSchema<undefined>, "spf">;
572
659
  readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "plan-build-test">;
573
660
  readonly base_branch: v.OptionalSchema<v.StringSchema<undefined>, "main">;
@@ -593,6 +680,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
593
680
  issue_provider: "github" | "jira";
594
681
  code_host: "bitbucket" | "github";
595
682
  repo: string;
683
+ issue_repo: string;
596
684
  label_prefix: string;
597
685
  chain: string;
598
686
  base_branch: string;
@@ -627,13 +715,24 @@ export declare const SFConfigSchema: v.ObjectSchema<{
627
715
  name: string;
628
716
  }[];
629
717
  }>;
718
+ readonly review: v.OptionalSchema<v.ObjectSchema<{
719
+ readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
720
+ readonly signoff_timeout_seconds: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>]>, 300>;
721
+ }, undefined>, () => {
722
+ require_human_signoff: boolean;
723
+ signoff_timeout_seconds: number;
724
+ }>;
630
725
  }, undefined>;
631
726
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
727
+ /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
728
+ export declare const EVENT_RECORD_TYPES: readonly ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"];
729
+ export declare const EventRecordTypeSchema: v.PicklistSchema<readonly ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"], undefined>;
730
+ export type EventRecordType = v.InferOutput<typeof EventRecordTypeSchema>;
632
731
  /** One traced event, always logged against adw_id + phase. */
633
732
  export interface EventRecord {
634
733
  adw_id: string;
635
734
  phase_id: string;
636
- type: string;
735
+ type: EventRecordType;
637
736
  name: string;
638
737
  payload: Record<string, unknown>;
639
738
  parent_id: string;
@@ -643,7 +742,7 @@ export interface EventRecord {
643
742
  }
644
743
  export declare function makeEventRecord(input: Partial<EventRecord> & {
645
744
  adw_id: string;
646
- type: string;
745
+ type: EventRecordType;
647
746
  }): EventRecord;
648
747
  /**
649
748
  * Everything one coding-agent dispatch+read turn needs, whichever backend
@@ -675,6 +774,7 @@ export interface AgentRequest {
675
774
  output_type_name: string;
676
775
  cwd: string;
677
776
  flue_db_path: string;
777
+ env?: Record<string, string>;
678
778
  }
679
779
  /**
680
780
  * Tokens and the dollars they cost, per component, summed over a call.
@@ -281,6 +281,15 @@ export const AgentConfigSchema = v.object({
281
281
  // [...] -> only these. A trailing "/" means a directory prefix; a "*"
282
282
  // makes it a glob; anything else is an exact path.
283
283
  writes: v.optional(v.nullable(v.array(v.string()))),
284
+ // Opt-in env allowlist for this agent's subprocess/sandbox environment.
285
+ // undefined/null (the default) -> unrestricted: the full operator
286
+ // environment is passed through, byte-identical to before this field
287
+ // existed — same three-state shape as `writes` above, so `null` (the
288
+ // spelling config.md teaches for "unrestricted") parses instead of
289
+ // rejecting. Set -> only these keys, plus the baseline
290
+ // (PATH/HOME/USER/LANG/TERM/TMPDIR) that agent_flue.ts's local() sandbox
291
+ // would keep anyway.
292
+ env_allowlist: v.optional(v.nullable(v.array(v.string()))),
284
293
  });
285
294
  export const ConfigDefaultsSchema = v.object({
286
295
  coding_agent: v.optional(v.picklist(["flue", "claude_code"]), "flue"),
@@ -296,9 +305,36 @@ export const ConfigDefaultsSchema = v.object({
296
305
  protected_files: v.optional(v.array(v.string()), () => [".spf/", "spf.config.yaml"]),
297
306
  data_dir: v.optional(v.string(), ".spf/data"),
298
307
  });
308
+ /**
309
+ * OpenTelemetry span export — OFF unless this block exists, and `endpoint` is
310
+ * required BY THIS SCHEMA rather than defaulted, because presence of the block
311
+ * IS the activation switch. No ambient environment variable can turn export on
312
+ * (notably NOT `OTEL_EXPORTER_OTLP_ENDPOINT`): an unrelated shell variable must
313
+ * never become a data-egress switch. See `core/otel.ts`'s header for the full
314
+ * set of constraints, including the attribute allowlist that keeps repo source
315
+ * code (tool args, prompts, envelopes, the request text) off the wire.
316
+ *
317
+ * `endpoint` is URL-validated so a typo fails at config load rather than as a
318
+ * silent per-run export failure. Either the full OTLP traces path
319
+ * (`https://collector:4318/v1/traces`) or a bare origin (`/v1/traces` is
320
+ * appended — see `resolveTracesUrl`). `headers` is where a collector's auth
321
+ * token goes; its VALUES are treated as secrets and never logged.
322
+ */
323
+ export const OTelConfigSchema = v.object({
324
+ endpoint: v.pipe(v.string(), v.url()),
325
+ headers: v.optional(v.record(v.string(), v.string()), undefined),
326
+ service_name: v.optional(v.string(), "spf"),
327
+ });
299
328
  export const ObservabilityConfigSchema = v.object({
300
329
  db: v.optional(v.string(), ".spf/data/spf.db"),
301
330
  poll_ms: v.optional(v.number(), 500),
331
+ // Absent by default. `agents.ts`'s mergeRawConfig spreads `observability`
332
+ // field-by-field, so this nested object merges as a WHOLE-OBJECT replace on
333
+ // override — an override that sets `otel:` replaces the base's entirely,
334
+ // which is the semantics you want for an endpoint + its headers (a
335
+ // half-merged pair of the two would send tokens to the wrong collector).
336
+ // Pinned by a merge-survival test in src/test/data_types.test.ts.
337
+ otel: v.optional(OTelConfigSchema),
302
338
  });
303
339
  /**
304
340
  * `spf watch`'s configuration. `issue_provider` (the tracker) and
@@ -315,8 +351,25 @@ export const ObservabilityConfigSchema = v.object({
315
351
  * watch` startup, not here — an empty string parses fine (this schema has
316
352
  * no opinion on whether watch is even configured), matching the same
317
353
  * "fails loudly before anything spawns, not eagerly at parse time" pattern
318
- * `quality:` already uses. Its shape depends on `code_host`: "owner/name"
319
- * for github, "workspace/repo_slug" for bitbucket.
354
+ * `quality:` already uses. `repo` always names `code_host`'s own repo —
355
+ * its shape depends on `code_host`: "owner/name" for github,
356
+ * "workspace/repo_slug" for bitbucket. `resolveCodeHostProvider`
357
+ * (`cli/commands/watch.ts`) always reads it.
358
+ *
359
+ * `issue_repo` exists for exactly one combination where that single field
360
+ * stops being enough: `issue_provider: github` with `code_host: bitbucket`
361
+ * — GitHub-issues-against-a-Bitbucket-repo, a real setup this project
362
+ * explicitly supports, where the issue tracker and the code host are
363
+ * genuinely different repos in different systems, not the same repo worn
364
+ * two ways. Every other combination stays a single field: `issue_provider:
365
+ * github` + `code_host: github` is one repo by construction; `issue_provider:
366
+ * jira` never reads `repo` at all (it uses `jira.base_url`/`project_key`),
367
+ * so `repo` unambiguously belongs to whichever code host is configured.
368
+ * `resolveIssueProvider`'s github branch and `core/refine.ts`'s
369
+ * `resolveAuthoringProvider` (issue authoring always targets the issue
370
+ * tracker, never the code host) both read `issue_repo || repo` — leaving
371
+ * `issue_repo` unset is a complete no-op, so no existing config needs to
372
+ * change.
320
373
  */
321
374
  export const WatchIssueProviderSchema = v.picklist(["github", "jira"]);
322
375
  export const WatchCodeHostSchema = v.picklist(["github", "bitbucket"]);
@@ -344,6 +397,7 @@ export const WatchConfigSchema = v.object({
344
397
  issue_provider: v.optional(WatchIssueProviderSchema, "github"),
345
398
  code_host: v.optional(WatchCodeHostSchema, "github"),
346
399
  repo: v.optional(v.string(), ""),
400
+ issue_repo: v.optional(v.string(), ""),
347
401
  label_prefix: v.optional(v.string(), "spf"),
348
402
  chain: v.optional(v.string(), "plan-build-test"),
349
403
  base_branch: v.optional(v.string(), "main"),
@@ -381,6 +435,33 @@ export const NotificationsConfigSchema = v.object({
381
435
  timeout_ms: v.optional(v.number(), 5_000),
382
436
  channels: v.optional(v.array(NotifyChannelSchema), () => []),
383
437
  });
438
+ /**
439
+ * `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
440
+ * AMENDMENTS on the review-accountability thread. `simple_sdlc.ts`'s
441
+ * `commit_build` predicate is the ONLY place in this codebase where an AI
442
+ * reviewer's `approved` flag gates a commit; `decideSignoff` (same file)
443
+ * turns that PROPOSAL into a human's DISPOSAL wherever a human is at the
444
+ * keyboard, and reads these two knobs when there isn't one.
445
+ *
446
+ * `require_human_signoff` defaults to FALSE for this release, deliberately:
447
+ * flipping it to fail-closed-by-default would break every unattended
448
+ * `simple-sdlc` run (`spf watch`, CI) the day this shipped, before `spf
449
+ * watch` itself is signoff-aware (its own human gate today is the PR merge,
450
+ * now informed by the reviewer digest — see `cli/commands/watch.ts`). An
451
+ * unattended run instead proceeds on the AI verdict alone with a LOUD
452
+ * one-time warning (`decideSignoff`'s `AI_ONLY_SIGNOFF_WARNING`) until that
453
+ * changes. Set `true` and an unattended run fails the phase CLOSED instead —
454
+ * see `decideSignoff`'s fail-closed branch — rather than silently
455
+ * auto-approving because nobody typed at a prompt that was never shown.
456
+ *
457
+ * `signoff_timeout_seconds` bounds the interactive prompt itself: expiry
458
+ * means NOT accepted (the confirm's own default), never an unbounded stdin
459
+ * read inside `run.phase()` — see `cli/ask.ts`'s `confirm(..., {timeoutMs})`.
460
+ */
461
+ export const ReviewConfigSchema = v.object({
462
+ require_human_signoff: v.optional(v.boolean(), false),
463
+ signoff_timeout_seconds: v.optional(v.pipe(v.number(), v.minValue(1)), 300),
464
+ });
384
465
  export const SFConfigSchema = v.object({
385
466
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
386
467
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
@@ -388,7 +469,23 @@ export const SFConfigSchema = v.object({
388
469
  quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
389
470
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
390
471
  notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
472
+ review: v.optional(ReviewConfigSchema, () => v.parse(ReviewConfigSchema, {})),
391
473
  });
474
+ // ── Tracing ──────────────────────────────────────────────────────────────────
475
+ /** The full set of event kinds any tracer.event()/makeEventRecord() call site emits — kept in sync with src/ui/shared/types.ts's EventType. */
476
+ export const EVENT_RECORD_TYPES = [
477
+ "phase_start",
478
+ "agent_start",
479
+ "tool_call",
480
+ "handoff",
481
+ "gate_pass",
482
+ "gate_fail",
483
+ "log",
484
+ "agent_end",
485
+ "phase_end",
486
+ "error",
487
+ ];
488
+ export const EventRecordTypeSchema = v.picklist(EVENT_RECORD_TYPES);
392
489
  export function makeEventRecord(input) {
393
490
  return {
394
491
  phase_id: "",