@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
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,14 +400,63 @@ 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
+ }
146
427
  if (cfg.watch.refine.enabled) {
147
- check(report, "watch.refine.chain", Boolean(findChain(cfg.watch.refine.chain)), findChain(cfg.watch.refine.chain) ? cfg.watch.refine.chain : `"${cfg.watch.refine.chain}" is not a registered chain`);
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`);
148
430
  check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github", cfg.watch.issue_provider === "github"
149
431
  ? "github supports issue authoring (createIssue/sub-issues)"
150
432
  : `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" (Jira issue authoring isn't implemented yet)`);
151
433
  }
152
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
+ }
459
+ }
153
460
  if (cfg.notifications.events !== "off") {
154
461
  check(report, "notifications.events", true, cfg.notifications.events);
155
462
  if (cfg.notifications.channels.length === 0) {
@@ -169,7 +476,8 @@ function finish(report, json) {
169
476
  }
170
477
  else {
171
478
  for (const c of report.checks) {
172
- 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}`);
173
481
  }
174
482
  console.log(report.ok ? "\nspf doctor: clean" : "\nspf doctor: problems found above");
175
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>;
@@ -91,7 +91,9 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
91
91
  # watch:
92
92
  # issue_provider: github # github | jira
93
93
  # code_host: github # github | bitbucket
94
- # repo: owner/name # "owner/name" (github) or "workspace/repo_slug" (bitbucket)
94
+ # repo: owner/name # the CODE HOST's repo — "owner/name" (github) or "workspace/repo_slug" (bitbucket)
95
+ # # issue_repo: owner/name # ONLY for issue_provider: github + code_host: bitbucket, when
96
+ # # # the tracker and code host are genuinely different repos
95
97
  # label_prefix: spf
96
98
  # chain: plan-build-test
97
99
  # base_branch: main
@@ -116,12 +118,76 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
116
118
  # channels:
117
119
  # - kind: slack # slack | teams | webhook
118
120
  # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
121
+
122
+ # Uncomment to change the human sign-off gate in front of simple-sdlc's
123
+ # commit_build phase — the one place an AI reviewer's approved flag alone
124
+ # can gate a commit. See assets/skill/references/config.md's "review"
125
+ # section for the full behavior.
126
+ # review:
127
+ # require_human_signoff: false # true = an unattended simple-sdlc run fails closed instead of committing on the reviewer's verdict alone
128
+ # signoff_timeout_seconds: 300
119
129
  `;
120
130
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
121
131
  // shared project config, same as package.json. Only runtime/generated
122
132
  // content is ignored: session traces (data/), a hand-editable engine copy
123
133
  // (engine/, from `spf eject`), and secrets (.env).
134
+ //
135
+ // `.spf/chains/` is deliberately NOT in this list. A chain file is DATA
136
+ // that decides what the disposer runs (see repo_chains.ts's module comment)
137
+ // — the same trust boundary `defaults.protected_files` exists to guard.
138
+ // Gitignoring it would let anyone (or any agent) rewrite what "spf plan-
139
+ // build-test" or a watch.chain even means, invisibly to `git diff`/PR
140
+ // review, in a repo that otherwise protects every other file that judges
141
+ // agent output. Chain files stay tracked so `protected_files` covers the
142
+ // chain that judges the agents, not just the code it judges.
124
143
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
144
+ /**
145
+ * Scaffolded once by `spf init` — fully commented out, so every line is a
146
+ * `#` comment and `parseYaml` reads the whole file as an empty document;
147
+ * `loadOne` (repo_chains.ts) treats that as "declares no chain" and skips it
148
+ * silently — no chain registered, but also no problem reported against
149
+ * spf's own scaffold. It exists to show the shape (naming existing
150
+ * `chains/steps.ts` factories as data, never importing repo code — params
151
+ * are flat siblings of `step:`, never nested under a `params:` key) and to
152
+ * carry the one divergence an author needs to know before writing a real
153
+ * one.
154
+ */
155
+ export const EXAMPLE_CHAIN_YAML = `# .spf/chains/example.yaml — a repo-local chain, loaded as DATA.
156
+ #
157
+ # This file NAMES existing step factories from spf's own chains/steps.ts
158
+ # (request, plan, build, fixLoop, commit, ...) — it never imports or runs
159
+ # code from this repo. "Agent proposes, code disposes," and the code that
160
+ # disposes is always SPF's own, packaged code; a chain file only ever picks
161
+ # which of ITS steps run, in which order, with which params. See
162
+ # .claude/skills/spf/cookbooks/authoring_chains.md (installed by \`spf init\`
163
+ # into THIS repo) for the full step vocabulary — its "Repo-local chains"
164
+ # section is written for exactly this file.
165
+ #
166
+ # WATCH DIVERGENCE: \`spf watch\` registers chains from the MAIN repo anchor
167
+ # once, at daemon start — not per-issue, not per-worktree. A chain file
168
+ # edited on an issue branch (inside the worktree \`spf watch\` checks that
169
+ # branch out into) is NOT what runs for that issue; the daemon keeps using
170
+ # whatever \`.spf/chains/\` looked like in the main repo when it started. The
171
+ # disposer stays the OPERATOR's, never the branch's — exactly the property
172
+ # that keeps an agent from being able to rewrite its own quality gate mid-run
173
+ # by editing a chain file as part of the change it's making.
174
+ #
175
+ # Uncomment and edit to register this chain (spf list / spf doctor will
176
+ # then show it). Every field below is required unless noted.
177
+ #
178
+ # name: example # spf example "<prompt>" / spf run example "<prompt>"
179
+ # describe: small build+fix example chain — build, then a bounded test-fix loop
180
+ # steps:
181
+ # - step: request # every chain opens with this
182
+ # - step: build
183
+ # fromPlan: false # no preceding plan() step in this chain
184
+ # - step: fixLoop
185
+ # suite: test # must name a key under quality.suites in spf.config.yaml
186
+ # owner: builder # must name an agent in the roster (cfg.agents)
187
+ # max: 3 # optional — defaults to 3 if omitted
188
+ # - step: commit
189
+ # onlyIfAccepted: true
190
+ `;
125
191
  const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s interview, merged ON TOP of
126
192
  # spf's packaged built-in defaults. Only what you changed is here; run
127
193
  # \`spf doctor\` any time to see what's actually in effect for this repo, and
@@ -133,6 +199,17 @@ export async function initCommand(argv) {
133
199
  const anchor = paths.resolveAnchor(options["cwd"]);
134
200
  const sfDir = path.join(anchor.repo_root, ".spf");
135
201
  mkdirSync(sfDir, { recursive: true });
202
+ // Scaffold `.spf/chains/` unconditionally, on every path below (interview
203
+ // or not) — same idempotent shape as installSkill(): never overwrites a
204
+ // file that's already there (an author may have started editing the
205
+ // example, or written their own chains alongside it), so this is always
206
+ // safe to run again on a repo that already has one.
207
+ const chainsDir = path.join(sfDir, "chains");
208
+ mkdirSync(chainsDir, { recursive: true });
209
+ const exampleChainPath = path.join(chainsDir, "example.yaml");
210
+ if (!existsSync(exampleChainPath)) {
211
+ writeFileSync(exampleChainPath, EXAMPLE_CHAIN_YAML);
212
+ }
136
213
  // Idempotent (a no-op once the skill is already up to date, a `.new`
137
214
  // sibling rather than an overwrite for a locally-edited file) — safe to
138
215
  // call on every `spf init`, not just the first.