@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.
- package/README.md +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +237 -18
- package/dist/chains/steps.js +477 -58
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +297 -6
- 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
|
-
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|