@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
package/dist/cli/ask.d.ts CHANGED
@@ -9,7 +9,20 @@ export interface Asker {
9
9
  validate?: (value: string) => string | null;
10
10
  }): Promise<string>;
11
11
  select<T extends string>(label: string, choices: SelectChoice<T>[], dflt: T): Promise<T>;
12
- confirm(label: string, dflt: boolean): Promise<boolean>;
12
+ /**
13
+ * `opts.timeoutMs`, when given (including `0`, for "don't wait at all" —
14
+ * checked with `!== undefined`, never truthiness), bounds the wait: expiry
15
+ * resolves with `dflt` (never with `true` — a caller that wants "expiry
16
+ * means not approved" passes `dflt: false`, same as any other unanswered
17
+ * prompt) and prints a `timed out — using default (yes|no)` line, phrased
18
+ * from `dflt` itself, so the transcript never contradicts what was
19
+ * returned. Absent -> unbounded, unchanged from before this option existed
20
+ * (the only caller today that needs it is `simple_sdlc.ts`'s sign-off
21
+ * prompt — see `review.signoff_timeout_seconds`).
22
+ */
23
+ confirm(label: string, dflt: boolean, opts?: {
24
+ timeoutMs?: number;
25
+ }): Promise<boolean>;
13
26
  /** Echo-suppressed. `current` (if any) is shown masked; an empty answer keeps it and resolves to `""`. */
14
27
  secret(label: string, opts?: {
15
28
  current?: string;
package/dist/cli/ask.js CHANGED
@@ -30,6 +30,30 @@ class MutableWritable extends Writable {
30
30
  callback();
31
31
  }
32
32
  }
33
+ /** Sentinel distinguishing "the timer fired" from any real answer, including "". */
34
+ const TIMED_OUT = Symbol("timed-out");
35
+ /**
36
+ * Race `promise` against a `ms` timer. On expiry resolves `TIMED_OUT` and
37
+ * lets `promise` keep running unobserved — `readline`'s `question()` has no
38
+ * cancel, so the only alternatives are leaving it pending (harmless: nothing
39
+ * is still awaiting its result once this returns) or never bounding the
40
+ * wait at all, which is exactly what `review.signoff_timeout_seconds` exists
41
+ * to rule out. `.unref()` so a pending prompt with a live timer never keeps
42
+ * the process alive on its own.
43
+ */
44
+ function withTimeout(promise, ms) {
45
+ return new Promise((resolve, reject) => {
46
+ const timer = setTimeout(() => resolve(TIMED_OUT), ms);
47
+ timer.unref?.();
48
+ promise.then((value) => {
49
+ clearTimeout(timer);
50
+ resolve(value);
51
+ }, (error) => {
52
+ clearTimeout(timer);
53
+ reject(error);
54
+ });
55
+ });
56
+ }
33
57
  export function createAsker() {
34
58
  const muteableOut = new MutableWritable();
35
59
  const rl = createInterface({ input: process.stdin, output: muteableOut, terminal: true });
@@ -81,9 +105,15 @@ export function createAsker() {
81
105
  console.log(paint("red", ` not one of: ${[...valid].join(", ")}`));
82
106
  }
83
107
  },
84
- async confirm(label, dflt) {
108
+ async confirm(label, dflt, opts) {
85
109
  const hint = dflt ? "Y/n" : "y/N";
86
- const answer = (await raw(`${label} ${paint("dim", `[${hint}]`)}: `)).toLowerCase();
110
+ const pending = raw(`${label} ${paint("dim", `[${hint}]`)}: `);
111
+ const result = opts?.timeoutMs !== undefined ? await withTimeout(pending, opts.timeoutMs) : await pending;
112
+ if (result === TIMED_OUT) {
113
+ console.log(paint("yellow", ` timed out — using default (${dflt ? "yes" : "no"})`));
114
+ return dflt;
115
+ }
116
+ const answer = result.toLowerCase();
87
117
  if (!answer)
88
118
  return dflt;
89
119
  return answer === "y" || answer === "yes";
@@ -1 +1 @@
1
- export declare function doctorCommand(argv: string[]): number;
1
+ export declare function doctorCommand(argv: string[]): Promise<number>;
@@ -14,18 +14,136 @@ import * as paths from "../../core/paths.js";
14
14
  import * as permissions from "../../core/permissions.js";
15
15
  import * as agentCc from "../../core/agent_cc.js";
16
16
  import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
17
+ import { endpointLabel, redact, resolveTracesUrl } from "../../core/otel.js";
17
18
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
19
+ import { ollamaBaseUrl } from "../../core/ollama_provider.js";
18
20
  import { binaryOnPath, parseCli } from "../../core/utils.js";
19
21
  import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
20
22
  import { isRepoAt } from "../../core/git_helper.js";
21
- import { findChain } from "../../chains/index.js";
22
- function check(report, name, ok, detail) {
23
- report.checks.push({ name, ok, detail });
23
+ import { allChains, findChain, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
24
+ /**
25
+ * `severity` is a display-only axis, orthogonal to `ok`/`report.ok`: an
26
+ * "info"/"warn" check still reports `ok: true` (it never fails `spf doctor`
27
+ * on its own — see each call site for why), but prints as `ℹ`/`⚠` instead of
28
+ * a plain `✓` so a real negative finding (e.g. "unreachable", a `/v1`
29
+ * double-path warning) can't be mistaken for a pass at a glance.
30
+ */
31
+ function check(report, name, ok, detail, severity) {
32
+ report.checks.push({ name, ok, detail, severity });
24
33
  if (!ok)
25
34
  report.ok = false;
26
35
  }
27
- export function doctorCommand(argv) {
28
- const { options, flags } = parseCli(argv, ["cwd", "config"], ["json"]);
36
+ /** Same helper as `list.ts`'s — trims a repo chain's absolute `source` path down to the `.spf/chains/...` fragment worth printing. Small enough, and file-local enough, that sharing it isn't worth a new module. */
37
+ function repoChainLabel(source) {
38
+ const marker = path.join(".spf", "chains");
39
+ const idx = source.lastIndexOf(marker);
40
+ return idx === -1 ? source : source.slice(idx);
41
+ }
42
+ const PROBE_TIMEOUT_MS = 3_000;
43
+ /** A GET that never throws — a down/unreachable server is a finding to report, never a crash of `spf doctor` itself. */
44
+ async function probeGet(url) {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
47
+ try {
48
+ const res = await fetch(url, { signal: controller.signal });
49
+ return { ok: true, status: res.status };
50
+ }
51
+ catch (error) {
52
+ return { ok: false, error: error.message };
53
+ }
54
+ finally {
55
+ clearTimeout(timer);
56
+ }
57
+ }
58
+ /**
59
+ * An EMPTY OTLP trace batch, POSTed exactly the way `core/otel.ts` posts a
60
+ * real one (same URL, same configured headers) — `{"resourceSpans":[]}` is
61
+ * valid OTLP that records nothing, so this probe cannot create a phantom trace
62
+ * in the operator's backend. Never throws: an unreachable collector is a
63
+ * finding to print, not a doctor crash. A 4xx is still a useful answer (the
64
+ * host is up; the path or the auth header is wrong), so the status is reported
65
+ * rather than collapsed into ok/not-ok.
66
+ */
67
+ async function probeOtel(url, headers) {
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
70
+ try {
71
+ const res = await fetch(url, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json", ...(headers ?? {}) },
74
+ body: JSON.stringify({ resourceSpans: [] }),
75
+ signal: controller.signal,
76
+ });
77
+ return { ok: true, status: res.status };
78
+ }
79
+ catch (error) {
80
+ // undici collapses every connection-level failure to "fetch failed" and
81
+ // puts the actual reason (DNS, ECONNREFUSED, a TLS error — each with a
82
+ // different fix) on `error.cause`. Fold it in before redacting so `spf
83
+ // doctor`, whose whole job is telling the operator what's wrong, can.
84
+ const err = error;
85
+ const cause = err.cause;
86
+ const detail = cause ? ` (${cause.code ?? cause.message ?? String(cause)})` : "";
87
+ // redact(): a fetch failure routinely embeds the URL it attempted, and the
88
+ // endpoint may carry userinfo — the same rule the exporter's own log obeys.
89
+ return { ok: false, error: redact(`${err.message}${detail}`, [url, ...Object.values(headers ?? {})]) };
90
+ }
91
+ finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+ /**
96
+ * A minimal `POST {base}/v1/messages` — the same request shape `agent_cc.ts`
97
+ * ultimately drives, just with `max_tokens: 1` and no tools/schema, so a
98
+ * genuinely-reachable-but-model-not-found endpoint still answers fast. Never
99
+ * throws: a network failure or timeout is a finding (`unreachable`), not a
100
+ * doctor crash. Anthropic's Messages API forced adoption is what Ollama
101
+ * 0.32.14 natively speaks at this path (spike-verified) — HTTP 200 with a
102
+ * `type: "message"` body is the positive signal; any other status or body
103
+ * shape still gets reported, just labeled accordingly, since this check is
104
+ * informational either way (see its call site).
105
+ */
106
+ async function probeAnthropicMessages(base, model) {
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
109
+ try {
110
+ // Send whatever auth the operator already has configured for this
111
+ // endpoint, if any: with no header at all, a real hosted endpoint (a
112
+ // proxy, api.anthropic.com) answers 401, which reads as "broken" even
113
+ // though the probe itself never authenticated. Harmless for Ollama,
114
+ // which ignores the header entirely (see the module doc above).
115
+ const apiKey = process.env["ANTHROPIC_AUTH_TOKEN"] || process.env["ANTHROPIC_API_KEY"];
116
+ const res = await fetch(`${base}/v1/messages`, {
117
+ method: "POST",
118
+ headers: {
119
+ "content-type": "application/json",
120
+ "anthropic-version": "2023-06-01",
121
+ ...(apiKey ? { "x-api-key": apiKey } : {}),
122
+ },
123
+ body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: "user", content: "ping" }] }),
124
+ signal: controller.signal,
125
+ });
126
+ let note;
127
+ if (res.status === 200) {
128
+ try {
129
+ const body = (await res.json());
130
+ note = body?.type === "message" ? "type: message" : `unexpected body shape (type: ${JSON.stringify(body?.type)})`;
131
+ }
132
+ catch {
133
+ note = "non-JSON body";
134
+ }
135
+ }
136
+ return { ok: true, status: res.status, note };
137
+ }
138
+ catch (error) {
139
+ return { ok: false, error: error.message };
140
+ }
141
+ finally {
142
+ clearTimeout(timer);
143
+ }
144
+ }
145
+ export async function doctorCommand(argv) {
146
+ const { options, flags } = parseCli(argv, ["cwd", "config"], ["json", "no-probe"]);
29
147
  const report = { ok: true, checks: [] };
30
148
  check(report, "node version", true, process.version);
31
149
  const anchor = paths.resolveAnchor(options["cwd"]);
@@ -36,6 +154,16 @@ export function doctorCommand(argv) {
36
154
  check(report, ".spf directory", true, anchor.spf_dir ?? "(none — running off packaged built-ins; run `spf init` to override)");
37
155
  const isRepo = isRepoAt(anchor.repo_root);
38
156
  check(report, "git repository", isRepo, isRepo ? "yes" : "no — commit phases and change capture will fail");
157
+ // Informational only: gitignoring .spf/ itself (as opposed to the usual
158
+ // .spf/data/, which SHOULD be ignored) silently defeats protected_files
159
+ // for everything under it, including chain files not written yet — git
160
+ // never sees them, so permissions.ts's tracked/dirty scan can't either.
161
+ if (isRepo && anchor.spf_dir) {
162
+ const ignored = spawnSync("git", ["check-ignore", "-q", ".spf"], { cwd: anchor.repo_root }).status === 0;
163
+ check(report, ".spf/ not gitignored", true, ignored
164
+ ? "WARNING: .spf/ itself is gitignored — this silently defeats protected_files for everything under it; ignore .spf/data/ instead, not .spf/"
165
+ : "good");
166
+ }
39
167
  const resolution = paths.resolveConfigPaths(anchor, options["config"]);
40
168
  check(report, "config resolution", true, `${resolution.source}: ${resolution.paths.join(" -> ")}`);
41
169
  let cfg;
@@ -62,13 +190,100 @@ export function doctorCommand(argv) {
62
190
  }
63
191
  const usesClaudeCode = cfg.agents.some((a) => a.coding_agent === "claude_code");
64
192
  if (usesClaudeCode) {
65
- const claudeOnPath = binaryOnPath("claude");
193
+ // A custom SPF_CLAUDE_CMD points the real work at something other than
194
+ // the literal `claude` binary (a wrapper, a launcher) — checking `claude`
195
+ // itself in that case both misses the wrapper being missing and can
196
+ // falsely pass on a machine where `claude` happens to be on PATH but
197
+ // unused. Check whatever agent_cc.ts will actually `spawn()`: cmdSpec's
198
+ // first token.
199
+ const cmdSpec = process.env["SPF_CLAUDE_CMD"] || "claude";
200
+ const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
201
+ const cmdBin = cmdTokens[0] || "claude";
202
+ const claudeOnPath = binaryOnPath(cmdBin);
66
203
  let version = "";
67
- if (claudeOnPath) {
204
+ if (claudeOnPath && cmdBin === "claude") {
68
205
  const result = spawnSync("claude", ["--version"], { encoding: "utf-8" });
69
206
  version = result.status === 0 ? result.stdout.trim() : "";
70
207
  }
71
- check(report, "claude CLI", claudeOnPath, claudeOnPath ? version || "on PATH, but --version failed" : "not found on PATH — required by any coding_agent: claude_code agent");
208
+ check(report, "claude CLI", claudeOnPath, claudeOnPath
209
+ ? version || (cmdBin === "claude" ? "on PATH, but --version failed" : `"${cmdBin}" on PATH (via SPF_CLAUDE_CMD) — --version not checked for a wrapper/launcher`)
210
+ : `"${cmdBin}" not found on PATH — required by any coding_agent: claude_code agent${cmdBin !== "claude" ? " (checked SPF_CLAUDE_CMD's first token, not the literal \"claude\")" : ""}`);
211
+ // `ollama launch <cmd>` needs an explicit `--` before claude's own flags
212
+ // (cobra flag parsing otherwise consumes them as ITS OWN — see
213
+ // agent_cc.ts's module comment for the spike-verified failure and fix),
214
+ // and agent_cc.ts now inserts that separator automatically for exactly
215
+ // this token shape. But `--` alone is NOT sufficient: `ollama launch`
216
+ // also requires its OWN `--model <tag>` flag, typed BEFORE the `--`,
217
+ // whenever it's run headless — SPF always spawns with piped stdio, so
218
+ // without `--model` there, `ollama launch` falls back to an interactive
219
+ // model picker that can never run and fails with "model selection
220
+ // requires an interactive terminal" (spike-verified; see agent_cc.ts's
221
+ // module comment). Unlike the reachability probes below, this is a
222
+ // static, deterministic misconfiguration knowable from the string alone
223
+ // — a real hard failure, not an informational note.
224
+ if (cmdTokens[0] === "ollama" && cmdTokens[1] === "launch") {
225
+ const dashIdx = cmdTokens.indexOf("--");
226
+ const beforeSeparator = dashIdx === -1 ? cmdTokens : cmdTokens.slice(0, dashIdx);
227
+ const hasModelFlag = beforeSeparator.includes("--model");
228
+ check(report, "SPF_CLAUDE_CMD ollama launch wrapper", hasModelFlag, hasModelFlag
229
+ ? cmdTokens.includes("--")
230
+ ? 'explicit "--" and "--model" (before it) already present in SPF_CLAUDE_CMD — left as-is'
231
+ : 'auto-handled: agent_cc.ts inserts "--" before claude\'s flags for this wrapper shape (see its module comment); "--model" is present before that point, as `ollama launch` requires in headless mode'
232
+ : 'missing "--model <tag>" before any "--" — `ollama launch` runs headless (SPF pipes stdio) and will fail with "model selection requires an interactive terminal" even though agent_cc.ts inserts the "--" separator for you. Set SPF_CLAUDE_CMD="ollama launch claude --model <tag>" (tag from `ollama list`).');
233
+ }
234
+ const anthropicBaseUrl = process.env["ANTHROPIC_BASE_URL"];
235
+ if (anthropicBaseUrl && !flags["no-probe"]) {
236
+ const trimmed = anthropicBaseUrl.replace(/\/+$/, "");
237
+ // The claude CLI appends "/v1/messages" itself — a base URL that
238
+ // already ends in "/v1" produces a double "/v1/v1/messages" 404, a
239
+ // real mistake seen in practice, not a hypothetical.
240
+ const doubledPath = /\/v1$/.test(trimmed);
241
+ if (doubledPath) {
242
+ check(report, "ANTHROPIC_BASE_URL shape", true, `${anthropicBaseUrl} ends in "/v1" — the claude CLI appends "/v1/messages" itself; this looks like a double-path mistake (try dropping the trailing "/v1")`, "warn");
243
+ }
244
+ // Informational/warning only (ok always true): reachability here says
245
+ // nothing about auth or model correctness, and a down server between
246
+ // doctor runs shouldn't fail the whole command — it's a fact to
247
+ // surface, the same pattern as the other checks in this file that are
248
+ // never allowed to fail `spf doctor` on their own. When the shape
249
+ // check above just flagged a double "/v1", probe the CORRECTED base
250
+ // instead of the known-bad one — probing the bad path would just
251
+ // confirm the mistake we already diagnosed, spending the probe on
252
+ // nothing new.
253
+ const probeBase = doubledPath ? trimmed.replace(/\/v1$/, "") : trimmed;
254
+ const claudeCodeAgent = cfg.agents.find((a) => a.coding_agent === "claude_code");
255
+ const probeModel = claudeCodeAgent?.model || "claude-sonnet-5";
256
+ const result = await probeAnthropicMessages(probeBase, probeModel);
257
+ check(report, "ANTHROPIC_BASE_URL reachability", true, (result.ok
258
+ ? `${doubledPath ? `dropping the trailing "/v1" makes it ` : ""}reachable: POST ${probeBase}/v1/messages -> HTTP ${result.status}${result.note ? ` (${result.note})` : ""}`
259
+ : `unreachable: POST ${probeBase}/v1/messages -> ${result.error}`) +
260
+ " (this is a real, billable inference request — pass --no-probe to skip both network probes)", result.ok ? "info" : "warn");
261
+ }
262
+ }
263
+ // Flue agents pointed at a local Ollama server (`model: ollama/...`) have
264
+ // no API key to check (providers.ts's PROVIDER_ENV_KEYS.ollama is `[]`,
265
+ // handled in the per-agent loop below) but DO have a server that might
266
+ // simply not be running — worth a reachability probe the same way
267
+ // ANTHROPIC_BASE_URL gets one above.
268
+ const usesOllamaFlue = cfg.agents.some((a) => a.coding_agent !== "claude_code" && a.model.startsWith("ollama/"));
269
+ if (usesOllamaFlue && !flags["no-probe"]) {
270
+ // `ollamaBaseUrl()` (ollama_provider.ts) is the SAME default-substitution
271
+ // logic `registerOllamaModel` uses for a real dispatch, including
272
+ // treating a set-but-EMPTY OLLAMA_BASE_URL as unset — re-deriving the
273
+ // default here with `||` would agree on the common cases but diverge on
274
+ // that one, which is exactly the failure mode doctor exists to catch,
275
+ // not mask.
276
+ const ollamaBase = ollamaBaseUrl().replace(/\/+$/, "");
277
+ // `/models` (the OpenAI-compat list, matching the base URL's own `/v1`
278
+ // shape) over `/api/tags` (Ollama's native catalog endpoint): measured
279
+ // live against a real local server, `/models` returned 886 bytes vs.
280
+ // `/api/tags`'s 4461 bytes for the identical model catalog, same ~20ms
281
+ // latency either way — strictly cheaper for a pure reachability check,
282
+ // and there's no live-server dependency in this choice: doctor's probe
283
+ // itself tolerates either endpoint being down (see `probeGet`).
284
+ const result = await probeGet(`${ollamaBase}/models`);
285
+ check(report, "OLLAMA_BASE_URL reachability", true, // informational/warning only — see the ANTHROPIC_BASE_URL check above for why
286
+ result.ok ? `reachable: GET ${ollamaBase}/models -> HTTP ${result.status}` : `unreachable: GET ${ollamaBase}/models -> ${result.error}`, result.ok ? "info" : "warn");
72
287
  }
73
288
  for (const agent of cfg.agents) {
74
289
  const label = `agent "${agent.name}"`;
@@ -85,6 +300,9 @@ export function doctorCommand(argv) {
85
300
  if (!envKeys) {
86
301
  check(report, `${label} provider key`, true, `provider "${provider}" not in doctor's known list — skipped, not a failure`);
87
302
  }
303
+ else if (envKeys.length === 0) {
304
+ check(report, `${label} provider key`, true, `provider "${provider}" is keyless — no key required`);
305
+ }
88
306
  else {
89
307
  const set = envKeys.find((k) => process.env[k]);
90
308
  check(report, `${label} provider key`, Boolean(set), set ? `${set} is set` : `none of ${envKeys.join(", ")} is set`);
@@ -107,6 +325,34 @@ export function doctorCommand(argv) {
107
325
  const missing = names.filter((n) => !cfg.quality.checks.some((c) => c.name === n));
108
326
  check(report, `quality suite "${suiteName}"`, missing.length === 0, missing.length === 0 ? names.join(", ") : `names unknown check(s): ${missing.join(", ")}`);
109
327
  }
328
+ // Repo-local chains (`.spf/chains/*.yaml`, registered as DATA — see
329
+ // `chains/repo_chains.ts` — never as imported repo code): already merged
330
+ // into the registry by `cli/index.ts`'s `main()` before any command runs,
331
+ // so `allChains()`/`repoChainProblems()` here reflect exactly what `spf
332
+ // list` and `spf run`/`spf <chain>` would resolve, not a re-parse of our
333
+ // own. A malformed chain file is a real ✗, not informational — an operator
334
+ // wrote a chain they cannot run, and that is exactly the "fails silently
335
+ // otherwise" class of problem this command exists to surface.
336
+ for (const problem of repoChainProblems()) {
337
+ check(report, `repo chain ${problem.file}`, false, problem.message);
338
+ }
339
+ for (const chain of allChains().filter((c) => c.source !== undefined)) {
340
+ const owners = resolveRequiredAgents(chain, {});
341
+ const missingOwners = owners.filter((o) => !cfg.agents.some((a) => a.name === o));
342
+ check(report, `repo chain "${chain.name}" owners`, missingOwners.length === 0, missingOwners.length === 0 ? owners.join(", ") || "(none)" : `unknown agent(s): ${missingOwners.join(", ")} — not in cfg.agents`);
343
+ const suiteNames = resolveRequiredSuites(chain, {});
344
+ const missingSuites = suiteNames.filter((s) => !(s in cfg.quality.suites));
345
+ const missingChecks = missingSuites.length === 0 ? suiteNames.flatMap((s) => cfg.quality.suites[s].filter((n) => !cfg.quality.checks.some((c) => c.name === n))) : [];
346
+ check(report, `repo chain "${chain.name}" suites`, missingSuites.length === 0 && missingChecks.length === 0, missingSuites.length > 0
347
+ ? `unknown suite(s): ${missingSuites.join(", ")} — not in cfg.quality.suites`
348
+ : missingChecks.length > 0
349
+ ? `suite(s) name unknown check(s): ${missingChecks.join(", ")}`
350
+ : suiteNames.join(", ") || "(none)");
351
+ // Informational: the derived sequence itself, same string `spf list`
352
+ // shows — printed here so the chain's author can eyeball what they
353
+ // actually wrote without a second command.
354
+ check(report, `repo chain "${chain.name}" phases`, true, chain.phases, "info");
355
+ }
110
356
  // A protected_files pattern matching nothing anywhere in the trace is
111
357
  // usually a stale convention (e.g. an old adws/... pattern) that silently
112
358
  // stopped protecting anything.
@@ -124,6 +370,18 @@ export function doctorCommand(argv) {
124
370
  if (cfg.watch.repo.trim()) {
125
371
  check(report, "watch.issue_provider", true, cfg.watch.issue_provider);
126
372
  check(report, "watch.code_host", true, cfg.watch.code_host);
373
+ check(report, "watch.repo", true, `${cfg.watch.repo} (code_host's repo)`);
374
+ // The one combination where a single `repo` field is ambiguous: GitHub
375
+ // issues against a Bitbucket repo are two different repos in two
376
+ // different systems, not one repo worn two ways. Every other
377
+ // combination is unambiguous even with issue_repo unset — flag this
378
+ // one specifically rather than silently polling GitHub with the
379
+ // Bitbucket workspace/repo_slug string as if it were "owner/name".
380
+ if (cfg.watch.issue_provider === "github" && cfg.watch.code_host === "bitbucket") {
381
+ check(report, "watch.issue_repo", Boolean(cfg.watch.issue_repo.trim()), cfg.watch.issue_repo.trim()
382
+ ? cfg.watch.issue_repo
383
+ : `not set — issue_provider is github and code_host is bitbucket, two different repos in two different systems; without watch.issue_repo, GitHub issues will be polled using watch.repo (${JSON.stringify(cfg.watch.repo)}), which is the BITBUCKET repo`);
384
+ }
127
385
  if (cfg.watch.issue_provider === "github" || cfg.watch.code_host === "github") {
128
386
  check(report, "GITHUB_TOKEN", Boolean(process.env["GITHUB_TOKEN"]), process.env["GITHUB_TOKEN"]
129
387
  ? "set"
@@ -142,7 +400,62 @@ export function doctorCommand(argv) {
142
400
  ? "set"
143
401
  : 'not set — Bitbucket app passwords are being removed; spf watch needs an Atlassian account email plus an API token instead; see README.md\'s "spf watch" section');
144
402
  }
145
- check(report, "watch.chain", Boolean(findChain(cfg.watch.chain)), findChain(cfg.watch.chain) ? cfg.watch.chain : `"${cfg.watch.chain}" is not a registered chain`);
403
+ // findChain() consults built-ins first, then the repo-chain registry
404
+ // (registered by cli/index.ts's main() before doctorCommand ever runs)
405
+ // — a watch.chain naming a `.spf/chains/*.yaml` chain resolves here the
406
+ // same way it would for `spf watch` itself, no special-casing needed.
407
+ const watchChain = findChain(cfg.watch.chain);
408
+ check(report, "watch.chain", Boolean(watchChain), watchChain ? `${cfg.watch.chain}${watchChain.source ? ` (repo: ${repoChainLabel(watchChain.source)})` : ""}` : `"${cfg.watch.chain}" is not a registered chain`);
409
+ // Informational only, never a failure: a chain that skips review and/or
410
+ // never commits is a legitimate choice (e.g. `scout`, `plan`) — this is
411
+ // here so an unattended `spf watch` posture is a visible fact, not a
412
+ // silent assumption.
413
+ if (watchChain) {
414
+ // Structural, not name-coupled: `reviseLoop`'s derived label is always
415
+ // `${reviewer} [-> ${builder}(revise) -> ${reviewer} ...] bounded` (see
416
+ // steps.ts), so this holds for a built-in chain and for a repo chain
417
+ // that overrides `reviewer`/`builder` to something other than the
418
+ // literal agent named "reviewer" — the same owner-override feature
419
+ // this changeset added everywhere else. Matching on the literal name
420
+ // "reviewer" would report "does not include a reviewer" for a chain
421
+ // that is entirely built around a review loop, the moment its reviewer
422
+ // is renamed.
423
+ const hasReviewer = watchChain.phases.includes("(revise)");
424
+ const hasCommitPhase = watchChain.phases.includes("commit");
425
+ check(report, "watch.chain review posture", true, `${hasReviewer ? "includes" : "does not include"} a reviewer; ${hasCommitPhase ? "includes" : "does not include"} a commit phase`);
426
+ }
427
+ if (cfg.watch.refine.enabled) {
428
+ const refineChain = findChain(cfg.watch.refine.chain);
429
+ check(report, "watch.refine.chain", Boolean(refineChain), refineChain ? `${cfg.watch.refine.chain}${refineChain.source ? ` (repo: ${repoChainLabel(refineChain.source)})` : ""}` : `"${cfg.watch.refine.chain}" is not a registered chain`);
430
+ check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github", cfg.watch.issue_provider === "github"
431
+ ? "github supports issue authoring (createIssue/sub-issues)"
432
+ : `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" (Jira issue authoring isn't implemented yet)`);
433
+ }
434
+ }
435
+ // OTel span export: informational in every direction. It is off unless
436
+ // `observability.otel` exists (no env var can turn it on — see
437
+ // core/otel.ts), and when it IS on, an unreachable collector must never fail
438
+ // `spf doctor` any more than it fails a run: export is a lossy projection of
439
+ // a trace SQLite already holds. What doctor adds is visibility — "configured
440
+ // but nothing is listening" is otherwise a single redacted log line inside a
441
+ // run nobody re-reads.
442
+ if (cfg.observability.otel) {
443
+ const url = resolveTracesUrl(cfg.observability.otel.endpoint);
444
+ const insecure = url.startsWith("http://") && !/^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/)/.test(url);
445
+ check(report, "observability.otel", true,
446
+ // endpointLabel() drops any userinfo and query string — both are places
447
+ // a token gets smuggled into a URL, and doctor's output gets pasted into
448
+ // issues. Header VALUES are never printed at all, only their key names.
449
+ `span export -> ${endpointLabel(url)} (service_name: ${cfg.observability.otel.service_name}` +
450
+ `${cfg.observability.otel.headers ? `, headers: ${Object.keys(cfg.observability.otel.headers).join(", ")}` : ""})` +
451
+ (insecure ? " — WARNING: plain http to a non-loopback host sends this telemetry in cleartext" : ""), insecure ? "warn" : "info");
452
+ if (!flags["no-probe"]) {
453
+ const result = await probeOtel(url, cfg.observability.otel.headers);
454
+ check(report, "observability.otel reachability", true, // informational/warning only — same rule as the base-URL probes above
455
+ result.ok
456
+ ? `reachable: POST ${endpointLabel(url)} (empty batch) -> HTTP ${result.status}${result.status >= 400 ? " — reachable but rejecting; check the path (/v1/traces) and headers" : ""}`
457
+ : `unreachable: POST ${endpointLabel(url)} -> ${result.error}`, result.ok && result.status < 400 ? "info" : "warn");
458
+ }
146
459
  }
147
460
  if (cfg.notifications.events !== "off") {
148
461
  check(report, "notifications.events", true, cfg.notifications.events);
@@ -163,7 +476,8 @@ function finish(report, json) {
163
476
  }
164
477
  else {
165
478
  for (const c of report.checks) {
166
- console.log(`${c.ok ? "" : ""} ${c.name}: ${c.detail}`);
479
+ const icon = c.severity === "warn" ? "" : c.severity === "info" ? "ℹ" : c.ok ? "✓" : "✗";
480
+ console.log(`${icon} ${c.name}: ${c.detail}`);
167
481
  }
168
482
  console.log(report.ok ? "\nspf doctor: clean" : "\nspf doctor: problems found above");
169
483
  }
@@ -1 +1,13 @@
1
+ /**
2
+ * Scaffolded once by `spf init` — fully commented out, so every line is a
3
+ * `#` comment and `parseYaml` reads the whole file as an empty document;
4
+ * `loadOne` (repo_chains.ts) treats that as "declares no chain" and skips it
5
+ * silently — no chain registered, but also no problem reported against
6
+ * spf's own scaffold. It exists to show the shape (naming existing
7
+ * `chains/steps.ts` factories as data, never importing repo code — params
8
+ * are flat siblings of `step:`, never nested under a `params:` key) and to
9
+ * carry the one divergence an author needs to know before writing a real
10
+ * one.
11
+ */
12
+ export declare const EXAMPLE_CHAIN_YAML = "# .spf/chains/example.yaml \u2014 a repo-local chain, loaded as DATA.\n#\n# This file NAMES existing step factories from spf's own chains/steps.ts\n# (request, plan, build, fixLoop, commit, ...) \u2014 it never imports or runs\n# code from this repo. \"Agent proposes, code disposes,\" and the code that\n# disposes is always SPF's own, packaged code; a chain file only ever picks\n# which of ITS steps run, in which order, with which params. See\n# .claude/skills/spf/cookbooks/authoring_chains.md (installed by `spf init`\n# into THIS repo) for the full step vocabulary \u2014 its \"Repo-local chains\"\n# section is written for exactly this file.\n#\n# WATCH DIVERGENCE: `spf watch` registers chains from the MAIN repo anchor\n# once, at daemon start \u2014 not per-issue, not per-worktree. A chain file\n# edited on an issue branch (inside the worktree `spf watch` checks that\n# branch out into) is NOT what runs for that issue; the daemon keeps using\n# whatever `.spf/chains/` looked like in the main repo when it started. The\n# disposer stays the OPERATOR's, never the branch's \u2014 exactly the property\n# that keeps an agent from being able to rewrite its own quality gate mid-run\n# by editing a chain file as part of the change it's making.\n#\n# Uncomment and edit to register this chain (spf list / spf doctor will\n# then show it). Every field below is required unless noted.\n#\n# name: example # spf example \"<prompt>\" / spf run example \"<prompt>\"\n# describe: small build+fix example chain \u2014 build, then a bounded test-fix loop\n# steps:\n# - step: request # every chain opens with this\n# - step: build\n# fromPlan: false # no preceding plan() step in this chain\n# - step: fixLoop\n# suite: test # must name a key under quality.suites in spf.config.yaml\n# owner: builder # must name an agent in the roster (cfg.agents)\n# max: 3 # optional \u2014 defaults to 3 if omitted\n# - step: commit\n# onlyIfAccepted: true\n";
1
13
  export declare function initCommand(argv: string[]): Promise<number>;