@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
@@ -1,14 +1,51 @@
1
- import { CHAINS } from "../../chains/index.js";
1
+ import path from "node:path";
2
+ import { allChains, repoChainProblems } from "../../chains/index.js";
3
+ /**
4
+ * A repo-defined chain's `source` is an absolute path into `.spf/chains/` by
5
+ * construction (that's the one place `loadRepoChains` ever looks) — trim it
6
+ * back to the repo-relative fragment a human actually wants to see, rather
7
+ * than a long absolute path that's identical on every machine's checkout
8
+ * except for the leading segment.
9
+ */
10
+ function repoChainLabel(source) {
11
+ const marker = path.join(".spf", "chains");
12
+ const idx = source.lastIndexOf(marker);
13
+ return idx === -1 ? source : source.slice(idx);
14
+ }
2
15
  export function listCommand() {
3
- const width = Math.max(...CHAINS.map((c) => c.name.length));
4
- for (const chain of CHAINS) {
16
+ // Built-ins first, then repo chains — same order `allChains()` guarantees,
17
+ // so this listing and `findChain`'s resolution order never disagree about
18
+ // which chain wins on a name collision.
19
+ const chains = allChains();
20
+ const width = Math.max(...chains.map((c) => c.name.length));
21
+ for (const chain of chains) {
5
22
  const agents = typeof chain.requiredAgents === "function" ? "(--agent picks who)" : chain.requiredAgents.join(", ") || "(none)";
23
+ const suites = typeof chain.requiredSuites === "function" ? chain.requiredSuites({}) : chain.requiredSuites;
24
+ const suiteNote = typeof chain.requiredSuites === "function" ? " (--suite overrides)" : "";
6
25
  console.log(`${chain.name.padEnd(width)} ${chain.phases}`);
7
26
  console.log(`${"".padEnd(width)} ${chain.describe}`);
8
- console.log(`${"".padEnd(width)} agents: ${agents}${chain.requiredSuites.length ? ` · quality suites: ${chain.requiredSuites.join(", ")}` : ""}`);
27
+ console.log(`${"".padEnd(width)} agents: ${agents}${suites.length ? ` · quality suites: ${suites.join(", ")}${suiteNote}` : ""}`);
28
+ // `source` is undefined for every built-in — only a chain loaded from a
29
+ // `.spf/chains/*.yaml` file carries one (see chains/repo_chains.ts).
30
+ if (chain.source) {
31
+ console.log(`${"".padEnd(width)} (repo: ${repoChainLabel(chain.source)})`);
32
+ }
9
33
  console.log();
10
34
  }
11
- console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] (spf run <name> ... works identically)`);
35
+ console.log(`spf <name> "<prompt>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>] (spf run <name> ... works identically)`);
12
36
  console.log(`spf watch polls a tracker and runs one of these chains per issue — spf doctor shows the current config.`);
37
+ // Every malformed `.spf/chains/*.yaml` file (bad YAML, a schema/params
38
+ // mismatch, a name naming a step factory that doesn't exist) becomes a
39
+ // problem here instead of a chain in the list above — loadRepoChains()
40
+ // never throws, so this is the only place an operator finds out their
41
+ // chain file didn't register at all.
42
+ const problems = repoChainProblems();
43
+ if (problems.length > 0) {
44
+ console.log();
45
+ console.log(`${problems.length} repo chain file(s) failed to load — run \`spf doctor\` for detail:`);
46
+ for (const problem of problems) {
47
+ console.log(` ! ${problem.file}: ${problem.message}`);
48
+ }
49
+ }
13
50
  return 0;
14
51
  }
@@ -2,9 +2,10 @@
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
4
  import { runChain } from "../../chains/index.js";
5
- const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue"];
5
+ import { isInteractive } from "../ask.js";
6
+ const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue", "suite"];
6
7
  export function usageFor(chain) {
7
- return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
8
+ return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>] [--suite <name>]`;
8
9
  }
9
10
  export async function dispatchChain(chain, argv) {
10
11
  const { positionals, options } = parseCli(argv, KNOWN_OPTIONS);
@@ -12,6 +13,15 @@ export async function dispatchChain(chain, argv) {
12
13
  console.error(usageFor(chain));
13
14
  return 1;
14
15
  }
16
+ // Only a step-derived chain's requiredSuites can actually read --suite
17
+ // (see deriveRequiredSuites/qualityCheck/fixLoop) — an imperative chain
18
+ // like simple-sdlc has a compiled-in static array and never consults
19
+ // options["suite"] at all, so accepting the flag there would silently do
20
+ // nothing. Reject loudly instead of letting it lie.
21
+ if (options["suite"] !== undefined && typeof chain.requiredSuites !== "function") {
22
+ console.error(`--suite has no effect on chain "${chain.name}" — its quality suite is fixed at ${JSON.stringify(chain.requiredSuites)}`);
23
+ return 2;
24
+ }
15
25
  const anchor = paths.resolveAnchor(options["cwd"]);
16
26
  const ctx = {
17
27
  prompt: resolvePrompt(positionals[0]),
@@ -23,11 +33,24 @@ export async function dispatchChain(chain, argv) {
23
33
  // every issue it creates — see ChainContext's doc comment); every
24
34
  // other chain ignores it, so it's harmless to always pass through.
25
35
  issue_id: options["issue"] ?? null,
36
+ // A real TTY (isInteractive(), from cli/ask.ts) is the one case an
37
+ // agent step could plausibly block on a human — everything else (a CI
38
+ // run, a piped `spf <chain>`, spf watch's own dispatch) is unattended.
39
+ // Read by `simple_sdlc.ts`'s sign-off phase (`isInteractive() &&
40
+ // !ctx.unattended`, folded into `canPrompt`) — see `decideSignoff`.
41
+ unattended: !isInteractive(),
42
+ // `undefined` for every built-in chain (ChainDefinition.source is only
43
+ // ever set for a chain loaded from `.spf/chains/*.yaml` — see
44
+ // chains/repo_chains.ts) — passed through unconditionally since a
45
+ // `string | undefined` field is exactly what ChainContext declares.
46
+ chain_source: chain.source,
26
47
  };
27
48
  const chainOptions = {};
28
49
  if (options["agent"] !== undefined)
29
50
  chainOptions["agent"] = options["agent"];
30
51
  if (options["base"] !== undefined)
31
52
  chainOptions["base"] = options["base"];
53
+ if (options["suite"] !== undefined)
54
+ chainOptions["suite"] = options["suite"];
32
55
  return runChain(chain, ctx, chainOptions);
33
56
  }
@@ -1,3 +1,21 @@
1
+ import { type ReviewOutputT } from "../../core/data_types.ts";
2
+ /**
3
+ * `&`/`<`/`>` are active markup in every destination a digest lands in —
4
+ * `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
5
+ * pipeline, and (cosmetically only — Adaptive Cards don't decode entities)
6
+ * Teams' TextBlock and the generic webhook payload — and a reviewer's
7
+ * findings/blocking text is LLM-authored, so it is untrusted content, not a
8
+ * template. Escaped before it ever reaches any renderer, never trusted as
9
+ * pre-formatted. Note this runs BEFORE truncateDigest, so a cut can in
10
+ * principle land mid-entity (e.g. `&am…`) — cosmetic only, since the `<`
11
+ * escape (the one that actually prevents Slack link forgery) is always
12
+ * complete by the time truncation could touch it.
13
+ */
14
+ export declare function escapeForMarkup(text: string): string;
15
+ /** Codepoint-safe truncation — a plain `.slice(0, n)` can land mid surrogate pair on a multi-byte finding. */
16
+ export declare function truncateDigest(text: string): string;
17
+ /** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
18
+ export declare function formatReviewDigest(review: ReviewOutputT): string;
1
19
  /**
2
20
  * `spf watch init` — idempotently seed the `<prefix>:*` labels the state
3
21
  * machine needs, with sensible colors/descriptions. Doesn't touch git or
@@ -8,6 +8,7 @@
8
8
  import { existsSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import path from "node:path";
11
+ import * as v from "valibot";
11
12
  import * as agents from "../../core/agents.js";
12
13
  import * as paths from "../../core/paths.js";
13
14
  import { resolveNotifier } from "../../core/notify/notifier.js";
@@ -16,9 +17,58 @@ import { GitHubProvider } from "../../core/issues/github_provider.js";
16
17
  import { JiraProvider } from "../../core/issues/jira_provider.js";
17
18
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
18
19
  import { createWatchState, tick } from "../../core/watch.js";
19
- import { findChain, runChain as runChainDef } from "../../chains/index.js";
20
+ import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
21
+ import { ReviewOutput } from "../../core/data_types.js";
20
22
  import { SfDb } from "../../ui/server/db.js";
21
23
  import { parseCli } from "../../core/utils.js";
24
+ /** Kept well under Slack's own 2900-char slice on `detail` (see `slack_channel.ts`) — a reviewer can emit a lot of findings, but the PR body/notification only needs enough to tell a human whether to look closer. */
25
+ const MAX_REVIEW_DIGEST_CHARS = 1200;
26
+ /**
27
+ * `&`/`<`/`>` are active markup in every destination a digest lands in —
28
+ * `<url|text>`/`*bold*` in Slack mrkdwn, raw HTML in GitHub's markdown
29
+ * pipeline, and (cosmetically only — Adaptive Cards don't decode entities)
30
+ * Teams' TextBlock and the generic webhook payload — and a reviewer's
31
+ * findings/blocking text is LLM-authored, so it is untrusted content, not a
32
+ * template. Escaped before it ever reaches any renderer, never trusted as
33
+ * pre-formatted. Note this runs BEFORE truncateDigest, so a cut can in
34
+ * principle land mid-entity (e.g. `&am…`) — cosmetic only, since the `<`
35
+ * escape (the one that actually prevents Slack link forgery) is always
36
+ * complete by the time truncation could touch it.
37
+ */
38
+ export function escapeForMarkup(text) {
39
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
40
+ }
41
+ /** Codepoint-safe truncation — a plain `.slice(0, n)` can land mid surrogate pair on a multi-byte finding. */
42
+ export function truncateDigest(text) {
43
+ const codepoints = Array.from(text);
44
+ if (codepoints.length <= MAX_REVIEW_DIGEST_CHARS)
45
+ return text;
46
+ return `${codepoints.slice(0, MAX_REVIEW_DIGEST_CHARS).join("")}…`;
47
+ }
48
+ /** A `ReviewOutput` envelope, reduced to the short digest threaded into the PR body and the `pr_opened` notification — see `core/watch.ts`'s `ChainRunResult.reviewSummary`. */
49
+ export function formatReviewDigest(review) {
50
+ const lines = [`Reviewer verdict: ${review.approved ? "approved" : "changes requested"}.`];
51
+ if (review.blocking.length > 0) {
52
+ lines.push("Blocking:", ...review.blocking.map((b) => `- ${escapeForMarkup(b)}`));
53
+ }
54
+ const unmet = review.findings.filter((f) => !f.met);
55
+ if (unmet.length > 0) {
56
+ lines.push("Unmet requirements:", ...unmet.map((f) => `- ${escapeForMarkup(f.requirement)}${f.evidence ? ` — ${escapeForMarkup(f.evidence)}` : ""}`));
57
+ }
58
+ // The common case on the success path (see the module comment on
59
+ // `reviewSummaryFor`): every reachable ReviewOutput has approved:true,
60
+ // blocking:[], unmet:[] — gates.verdictConsistent throws otherwise, and a
61
+ // gate failure exits the run non-zero before a digest is ever built. So
62
+ // without this, an approved run's digest would be the vacuous constant
63
+ // "Reviewer verdict: approved." with zero information in it. The met
64
+ // findings ARE the reviewer's signal on an approved run — what it actually
65
+ // verified — so surface them.
66
+ const met = review.findings.filter((f) => f.met);
67
+ if (met.length > 0) {
68
+ lines.push(`Verified ${met.length} requirement(s):`, ...met.map((f) => `- ${escapeForMarkup(f.requirement)}${f.evidence ? ` — ${escapeForMarkup(f.evidence)}` : ""}`));
69
+ }
70
+ return truncateDigest(lines.join("\n"));
71
+ }
22
72
  /**
23
73
  * Shared by `watch` and `watch init`: resolve config into an `IssueProvider`
24
74
  * — checking only what BOTH need. `watch`'s own extra checks (a real git
@@ -27,8 +77,16 @@ import { parseCli } from "../../core/utils.js";
27
77
  */
28
78
  function resolveIssueProvider(cfg) {
29
79
  if (cfg.watch.issue_provider === "github") {
30
- if (!cfg.watch.repo.trim()) {
31
- console.error(`watch.repo is not configured add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
80
+ // `issue_repo` (falling back to `repo`) — NOT `repo` alone — because
81
+ // `repo` always names `code_host`'s own repo (see WatchConfigSchema's
82
+ // doc comment). Those coincide for issue_provider: github + code_host:
83
+ // github (the common case, and why the fallback exists at all), but
84
+ // for issue_provider: github + code_host: bitbucket they are two
85
+ // different repos in two different systems — reading `repo` here would
86
+ // silently poll the BITBUCKET repo's identifier for GitHub issues.
87
+ const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
88
+ if (!repo) {
89
+ console.error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
32
90
  return null;
33
91
  }
34
92
  const token = process.env["GITHUB_TOKEN"];
@@ -36,7 +94,7 @@ function resolveIssueProvider(cfg) {
36
94
  console.error('GITHUB_TOKEN is not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo). See README.md\'s "GITHUB_TOKEN scope" section.');
37
95
  return null;
38
96
  }
39
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
97
+ return new GitHubProvider(repo, cfg.watch.label_prefix, token);
40
98
  }
41
99
  if (cfg.watch.issue_provider === "jira") {
42
100
  if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
@@ -54,7 +112,13 @@ function resolveIssueProvider(cfg) {
54
112
  console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
55
113
  return null;
56
114
  }
57
- /** Same shape as `resolveIssueProvider`, for `watch.code_host`. */
115
+ /**
116
+ * Same shape as `resolveIssueProvider`, for `watch.code_host` — always
117
+ * reads plain `watch.repo`, never `watch.issue_repo`. `repo` is defined as
118
+ * the CODE HOST's own repo (see `WatchConfigSchema`'s doc comment); `issue_repo`
119
+ * exists only to give the issue-tracker side an override when it names a
120
+ * different repo, which is `resolveIssueProvider`'s concern, not this one's.
121
+ */
58
122
  function resolveCodeHostProvider(cfg) {
59
123
  if (!cfg.watch.repo.trim()) {
60
124
  console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section`);
@@ -196,6 +260,17 @@ export async function watchCommand(argv) {
196
260
  */
197
261
  function linkDataDir(worktreePath) {
198
262
  const target = path.join(worktreePath, ".spf", "data");
263
+ // Hard guard, independent of however `worktreePath` got resolved: a real
264
+ // self-referential .spf/data symlink was observed live once already
265
+ // (points at itself — every later `mkdirSync` through it throws ELOOP;
266
+ // see `paths.healBrokenDataDir` for the recovery side of this same bug).
267
+ // The exact trigger was never pinned down, but `worktreePath` resolving
268
+ // to the main repo's own `data_dir` can never be correct regardless of
269
+ // how it got there, so refuse outright rather than create it again.
270
+ if (path.resolve(target) === path.resolve(dataPaths.data_dir)) {
271
+ console.error(`watch: refusing to link ${target} to itself — worktree path resolved to the main repo's own data_dir`);
272
+ return;
273
+ }
199
274
  if (existsSync(target))
200
275
  return;
201
276
  mkdirSync(path.dirname(target), { recursive: true });
@@ -204,10 +279,11 @@ export async function watchCommand(argv) {
204
279
  /** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
205
280
  function detailFromFailedPhase(cwd, adwId, prefix) {
206
281
  let detail = prefix;
282
+ let db;
207
283
  try {
208
284
  const wtAnchor = paths.resolveAnchor(cwd);
209
285
  const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
210
- const db = new SfDb(wtDataPaths.db_path);
286
+ db = new SfDb(wtDataPaths.db_path);
211
287
  const failed = db.phases(adwId).find((p) => p.status === "fail");
212
288
  if (failed)
213
289
  detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
@@ -215,16 +291,83 @@ export async function watchCommand(argv) {
215
291
  catch {
216
292
  // best-effort — the generic message above still points at where to look
217
293
  }
294
+ finally {
295
+ db?.close();
296
+ }
218
297
  return detail;
219
298
  }
299
+ /**
300
+ * Best-effort: the reviewer's latest verdict for this run, read back from
301
+ * the worktree's own (symlinked) trace db and reduced to a digest — same
302
+ * DB, same try/catch shape as `detailFromFailedPhase` above, but on the
303
+ * SUCCESS path. `undefined` on any DB/parse hiccup, or when the chain
304
+ * never produced a `ReviewOutput` envelope at all — never thrown: a digest
305
+ * is a nice-to-have, not something that gets to block the PR-open flow
306
+ * it's decorating.
307
+ */
308
+ function reviewSummaryFor(cwd, adwId) {
309
+ let db;
310
+ try {
311
+ const wtAnchor = paths.resolveAnchor(cwd);
312
+ const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
313
+ db = new SfDb(wtDataPaths.db_path);
314
+ const envelope = db
315
+ .envelopes(adwId)
316
+ .filter((e) => e.output_type === ReviewOutput.name)
317
+ .at(-1); // the LATEST verdict — a revise loop can produce several
318
+ if (!envelope?.payload_json)
319
+ return undefined;
320
+ const review = v.parse(ReviewOutput.schema, JSON.parse(envelope.payload_json));
321
+ return formatReviewDigest(review);
322
+ }
323
+ catch {
324
+ return undefined; // best-effort — see the doc comment above
325
+ }
326
+ finally {
327
+ db?.close();
328
+ }
329
+ }
220
330
  const runChain = async (opts) => {
331
+ // WATCH DIVERGENCE: `findChain` here resolves against the registry
332
+ // `cli/index.ts`'s `main()` built ONCE, at daemon start, from the MAIN
333
+ // repo anchor (the `registerRepoChains(...)` call before the command
334
+ // switch). `opts.cwd` below is a per-issue WORKTREE, which may carry a
335
+ // different (or edited) `.spf/chains/`, but that file is never
336
+ // re-read here — this is deliberate, not a gap: the disposer stays the
337
+ // OPERATOR's, never the branch's, so an agent can't rewrite its own
338
+ // quality gate mid-run by editing a chain file as part of the change
339
+ // it's making. (Also documented in the `spf init` scaffold, since that's
340
+ // the one place an author is invited to edit a chain file at all.)
221
341
  const chainDef = findChain(cfg.watch.chain); // checked above
222
- const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
342
+ const ctx = {
343
+ prompt: opts.prompt,
344
+ config_paths: configPaths,
345
+ adw_id: opts.adwId,
346
+ cwd: opts.cwd,
347
+ chain_name: chainDef.name,
348
+ // Every `spf watch` dispatch is unattended by definition — there is
349
+ // no human at a TTY to prompt, ever, for an issue claimed off a
350
+ // tracker poll. `chain_source` is undefined for a built-in chain,
351
+ // the resolved chain's `.spf/chains/*.yaml` path for a repo one.
352
+ unattended: true,
353
+ chain_source: chainDef.source,
354
+ };
355
+ // KNOWN LIMITATION (not fixed here): runChainDef is called below with no
356
+ // third `options` argument, so nothing --suite-shaped ever reaches this
357
+ // dispatch — `resolveRequiredSuites`/`resolveRequiredAgents` below both
358
+ // fall back to each chain's compiled-in/YAML-declared default. An
359
+ // unattended watch run therefore can't override a chain's suite the way
360
+ // an interactive `spf <chain> --suite <name>` can.
223
361
  const code = await runChainDef(chainDef, ctx);
224
- if (code === 0)
225
- return { accepted: true, adwId: opts.adwId, detail: "" };
362
+ // Static for every chain but "prompt" (whose --agent flag `spf watch`
363
+ // never passes) resolved with no options, exactly like `runChainDef`
364
+ // above ran it.
365
+ const reviewRequired = resolveRequiredAgents(chainDef, {}).includes("reviewer");
366
+ if (code === 0) {
367
+ return { accepted: true, adwId: opts.adwId, detail: "", reviewRequired, reviewSummary: reviewSummaryFor(opts.cwd, opts.adwId) };
368
+ }
226
369
  const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
227
- return { accepted: false, adwId: opts.adwId, detail };
370
+ return { accepted: false, adwId: opts.adwId, detail, reviewRequired };
228
371
  };
229
372
  /**
230
373
  * Same shape as `runChain`, for the refine lane — with one extra step on
@@ -243,6 +386,11 @@ export async function watchCommand(argv) {
243
386
  cwd: opts.cwd,
244
387
  chain_name: chainDef.name,
245
388
  issue_id: opts.issueId,
389
+ // Same reasoning as runChain's ctx above — an unattended dispatch,
390
+ // with no --suite-shaped options reaching it either (same KNOWN
391
+ // LIMITATION).
392
+ unattended: true,
393
+ chain_source: chainDef.source,
246
394
  };
247
395
  const code = await runChainDef(chainDef, ctx);
248
396
  if (code !== 0) {
package/dist/cli/index.js CHANGED
@@ -8,8 +8,10 @@ import path from "node:path";
8
8
  import * as agentCc from "../core/agent_cc.js";
9
9
  import * as agentFlue from "../core/agent_flue.js";
10
10
  import * as notify from "../core/notify/notifier.js";
11
+ import * as otel from "../core/otel.js";
11
12
  import * as paths from "../core/paths.js";
12
- import { findChain } from "../chains/index.js";
13
+ import { findChain, registerRepoChains, repoChainProblems } from "../chains/index.js";
14
+ import { loadRepoChains } from "../chains/repo_chains.js";
13
15
  import { dispatchChain, usageFor } from "./commands/run.js";
14
16
  import { listCommand } from "./commands/list.js";
15
17
  import { initCommand } from "./commands/init.js";
@@ -49,6 +51,26 @@ function findCwdFlag(argv) {
49
51
  const idx = argv.indexOf("--cwd");
50
52
  return idx !== -1 ? argv[idx + 1] : undefined;
51
53
  }
54
+ /**
55
+ * The moment someone types a chain name that doesn't resolve is the highest-
56
+ * traffic place a broken `.spf/chains/*.yaml` file is ever discovered — and,
57
+ * before this, the one place its load problem was withheld (`spf list` and
58
+ * `spf doctor` report it; this "unknown chain"/"unknown command or chain"
59
+ * path printed only the full HELP text). A file named for exactly the chain
60
+ * someone typed is surfaced first, ahead of any other broken file, since
61
+ * that's the one they're actually looking for right now.
62
+ */
63
+ function reportUnresolvedChain(typedName) {
64
+ const problems = repoChainProblems();
65
+ if (problems.length === 0)
66
+ return;
67
+ const matching = typedName ? problems.filter((p) => path.basename(p.file).startsWith(typedName)) : [];
68
+ const rest = problems.filter((p) => !matching.includes(p));
69
+ const ordered = [...matching, ...rest];
70
+ console.error(`${problems.length} repo chain file(s) failed to load — one of these may be what you meant:`);
71
+ for (const p of ordered)
72
+ console.error(` ! ${p.file}: ${p.message}`);
73
+ }
52
74
  export async function main() {
53
75
  const [cmd, ...rest] = process.argv.slice(2);
54
76
  // `process.loadEnvFile()` with no argument reads from `process.cwd()` —
@@ -58,13 +80,41 @@ export async function main() {
58
80
  // invoking shell's directory instead of the target repo's is exactly the
59
81
  // anchor-mismatch bug that principle exists to close. Resolve the same
60
82
  // way every command does, and load .env from the repo root it finds.
83
+ //
84
+ // Hoisted out of the try/catch below: resolveAnchor() cannot throw —
85
+ // findRepoRoot() (core/git_helper.ts) guards its own `git rev-parse
86
+ // --git-dir` probe with isRepoAt() and falls back to `cwd` itself when
87
+ // it's not a repo, AND guards the follow-up `--show-toplevel` call the
88
+ // same way (that one can fail even when isRepoAt() said yes — a bare repo
89
+ // or a `.git/` directory itself has no work tree), so it never throws
90
+ // either. findSfDir()'s walk is a plain existsSync/statSync loop with no
91
+ // failure path. The only thing in this block that CAN throw is
92
+ // loadEnvFile() (ENOENT when there's no .env), which is exactly what the
93
+ // catch below still guards.
94
+ const anchor = paths.resolveAnchor(findCwdFlag(rest));
61
95
  try {
62
- const anchor = paths.resolveAnchor(findCwdFlag(rest));
63
96
  process.loadEnvFile(path.join(anchor.repo_root, ".env"));
64
97
  }
65
98
  catch {
66
99
  // no .env there — fine, nothing to load
67
100
  }
101
+ // Repo-local chains as DATA: a `.spf/chains/*.yaml` file names existing
102
+ // step factories, it never imports repo code — SPF's own dispatcher stays
103
+ // the one thing that ever executes. Registered here, before the command
104
+ // switch, so every dispatch path (`spf <chain>`, `spf run <chain>`, `spf
105
+ // list`, `spf doctor`, `spf watch`) sees the same merged registry without
106
+ // each of them re-scanning `.spf/chains/` on its own. This is also the
107
+ // ONCE in "`spf watch` registers chains from the main anchor once, at
108
+ // daemon start" — see the WATCH DIVERGENCE comment on `runChain` in
109
+ // `cli/commands/watch.ts` for what that means for a chain file edited on
110
+ // an issue branch mid-run. Zero filesystem cost when there's no `.spf/` at
111
+ // all (anchor.spf_dir is null for a repo that
112
+ // never ran `spf init`) — loadRepoChains() is only ever called once we
113
+ // know there's something to look at.
114
+ if (anchor.spf_dir) {
115
+ const { chains, problems } = loadRepoChains(anchor);
116
+ registerRepoChains(chains, problems);
117
+ }
68
118
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
69
119
  console.log(HELP);
70
120
  process.exitCode = 0;
@@ -81,6 +131,7 @@ export async function main() {
81
131
  const chain = chainName ? findChain(chainName) : undefined;
82
132
  if (!chain) {
83
133
  console.error(`unknown chain: ${chainName ?? "(none given)"} — run \`spf list\` to see every chain`);
134
+ reportUnresolvedChain(chainName);
84
135
  process.exitCode = 1;
85
136
  return;
86
137
  }
@@ -103,7 +154,7 @@ export async function main() {
103
154
  process.exitCode = ejectCommand(rest);
104
155
  return;
105
156
  case "doctor":
106
- process.exitCode = doctorCommand(rest);
157
+ process.exitCode = await doctorCommand(rest);
107
158
  return;
108
159
  case "ui":
109
160
  process.exitCode = await uiCommand(rest);
@@ -129,6 +180,7 @@ export async function main() {
129
180
  const chain = findChain(cmd);
130
181
  if (!chain) {
131
182
  console.error(`unknown command or chain: ${cmd}\n`);
183
+ reportUnresolvedChain(cmd);
132
184
  console.error(HELP);
133
185
  process.exitCode = 1;
134
186
  return;
@@ -156,5 +208,10 @@ export async function main() {
156
208
  // A no-op if notifications are off/unconfigured — awaits any in-flight
157
209
  // webhook POST so a fast-exiting command doesn't drop it mid-flight.
158
210
  await notify.flushAll();
211
+ // A no-op if `observability.otel` is not configured — drains the bounded
212
+ // span queue and awaits any in-flight OTLP POST, under its own hard
213
+ // deadline, so a fast-exiting command neither drops spans mid-flight nor
214
+ // waits on an unreachable collector. Never throws (core/otel.ts).
215
+ await otel.flushAll();
159
216
  }
160
217
  }
@@ -21,7 +21,7 @@ import { ThinkingLevelSchema } from "../core/data_types.js";
21
21
  import { loadConfig } from "../core/agents.js";
22
22
  import { BUILTIN_CONFIG_PATH } from "../core/paths.js";
23
23
  import { DEFAULT_NOTIFY_ENV_KEY } from "../core/notify/notifier.js";
24
- import { CHAINS } from "../chains/index.js";
24
+ import { allChains } from "../chains/index.js";
25
25
  function gitConfigValue(repoRoot, key) {
26
26
  const result = spawnSync("git", ["config", key], { cwd: repoRoot, encoding: "utf-8" });
27
27
  const value = result.status === 0 ? result.stdout.trim() : "";
@@ -120,7 +120,7 @@ export async function runInterview(asker, ctx) {
120
120
  const token = await asker.secret("ANTHROPIC_AUTH_TOKEN", { current: ctx.existingEnv.get("ANTHROPIC_AUTH_TOKEN") });
121
121
  env["ANTHROPIC_AUTH_TOKEN"] = token || "ollama";
122
122
  envExampleKeys.push("ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN");
123
- asker.note("spf doctor does not check these twoconfirm the endpoint is reachable yourself.");
123
+ asker.note("spf doctor now probes ANTHROPIC_BASE_URL with a minimal POST /v1/messages (informational only, never a hard failure) and flags a base URL that already ends in \"/v1\" as a likely double-path mistake — the default above (without a trailing /v1) is exactly what that check is guarding against. It still cannot validate ANTHROPIC_AUTH_TOKEN itself.");
124
124
  }
125
125
  // The packaged roster pins planner/reviewer/documenter to their own
126
126
  // Flue-style provider/model-id strings, which always win over
@@ -149,11 +149,44 @@ export async function runInterview(asker, ctx) {
149
149
  asker.note(`warning: ${error.message}`);
150
150
  }
151
151
  defaults.model = model;
152
- const envKey = PROVIDER_ENV_KEYS[provider][0];
153
- const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
154
- if (key)
155
- env[envKey] = key;
156
- envExampleKeys.push(envKey);
152
+ const envKeys = PROVIDER_ENV_KEYS[provider];
153
+ if (envKeys.length > 0) {
154
+ const envKey = envKeys[0];
155
+ const key = await asker.secret(envKey, { current: ctx.existingEnv.get(envKey) });
156
+ if (key)
157
+ env[envKey] = key;
158
+ envExampleKeys.push(envKey);
159
+ }
160
+ else {
161
+ asker.note(`${provider} is keyless — no API key to collect.`);
162
+ if (provider === "ollama") {
163
+ // Same shape as the claude_code branch's ANTHROPIC_BASE_URL prompt
164
+ // above: a local/cloud Ollama server has no key, just an address.
165
+ // The `/v1` suffix matches agent_flue.ts's own registration default
166
+ // (spike-verified: Ollama serves its OpenAI-compatible surface —
167
+ // the one Flue's pi-ai backend actually speaks — under `/v1`, not
168
+ // at the bare root `/api/...` Ollama also exposes).
169
+ const baseUrl = await asker.text("OLLAMA_BASE_URL", {
170
+ default: "http://localhost:11434/v1",
171
+ validate: (v) => (v.trim() ? null : "required"),
172
+ });
173
+ env["OLLAMA_BASE_URL"] = baseUrl;
174
+ envExampleKeys.push("OLLAMA_BASE_URL");
175
+ asker.note("spf doctor checks this one — a probe against OLLAMA_BASE_URL/models runs on every `spf doctor`.");
176
+ // Same problem the claude_code branch already solves above: the
177
+ // packaged roster pins planner/reviewer/documenter to their own
178
+ // fireworks/gemini/openai model strings, which always win over
179
+ // defaults.model. Left alone, this flow would produce a config
180
+ // where three of six agents are still routed at hosted providers
181
+ // whose keys this ollama flow never collects — `spf doctor` would
182
+ // immediately report missing FIREWORKS_API_KEY/GEMINI_API_KEY/
183
+ // OPENAI_API_KEY right after an interview that asked for neither.
184
+ for (const name of ["planner", "reviewer", "documenter"]) {
185
+ agentOverrides.push({ name, model: defaults.model });
186
+ }
187
+ notes.push("planner/reviewer/documenter pin their own model in the packaged roster and always win over defaults.model — overriding all three to the ollama model chosen above, or spf doctor would report missing FIREWORKS_API_KEY/GEMINI_API_KEY/OPENAI_API_KEY for providers this flow never asked about.");
188
+ }
189
+ }
157
190
  }
158
191
  // Declined (the default): today's behavior exactly — on claude_code, the
159
192
  // three-agent auto-pin above stands unchanged; on flue, no agents: block
@@ -222,15 +255,37 @@ export async function runInterview(asker, ctx) {
222
255
  { value: "bitbucket", label: "Bitbucket" },
223
256
  ], "github");
224
257
  const repoHint = codeHost === "bitbucket" ? "workspace/repo_slug" : "owner/name";
225
- const repo = await asker.text(`Repo (${repoHint})`, {
258
+ // github issues + bitbucket code is the one combination where a single
259
+ // repo field is ambiguous — they're two different repos in two
260
+ // different systems, not one repo worn two ways (github+github is one
261
+ // repo by construction; jira never reads `repo` at all). Ask a second,
262
+ // explicitly-labeled question only in that one case.
263
+ const splitRepo = issueProvider === "github" && codeHost === "bitbucket";
264
+ const repo = await asker.text(splitRepo ? `Code repo, where PRs open (${repoHint})` : `Repo (${repoHint})`, {
226
265
  default: codeHost === "github" ? ctx.repoSlug ?? "" : "",
227
266
  validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "${repoHint}"`),
228
267
  });
268
+ let issueRepo = "";
269
+ if (splitRepo) {
270
+ asker.note("GitHub issues and the Bitbucket code repo are different repos here — spf watch needs both.");
271
+ issueRepo = await asker.text("Issue repo, where spf:ready issues live (owner/name)", {
272
+ default: ctx.repoSlug ?? "",
273
+ validate: (val) => (val.includes("/") && val.split("/").filter(Boolean).length === 2 ? null : `must be "owner/name"`),
274
+ });
275
+ }
229
276
  const labelPrefix = await asker.text("Label prefix", { default: "spf" });
230
277
  const baseBranch = await asker.text("Base branch", { default: ctx.currentBranch });
231
- const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
278
+ // allChains(), not the built-in-only CHAINS: `registerRepoChains` already
279
+ // ran in main() before initCommand, so a pre-existing `.spf/chains/`
280
+ // repo chain is a legal watch.chain everywhere else (watch.ts resolves
281
+ // it via findChain, doctor.ts validates and labels it) — this picker is
282
+ // the one place a user chooses watch.chain, so it must offer the same
283
+ // set. The "(repo)" hint marker distinguishes it in the list.
284
+ const chainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
232
285
  const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
233
286
  watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
287
+ if (issueRepo)
288
+ watch.issue_repo = issueRepo;
234
289
  // Issue authoring (create + link a hierarchy) is only implemented on
235
290
  // GitHubProvider today — see jira_provider.ts's module comment — so
236
291
  // this lane isn't offered at all on a Jira tracker rather than asking a
@@ -238,7 +293,7 @@ export async function runInterview(asker, ctx) {
238
293
  if (issueProvider === "github") {
239
294
  const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
240
295
  if (enableRefine) {
241
- const refineChainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
296
+ const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
242
297
  const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
243
298
  watch.refine = { enabled: true, chain: refineChain };
244
299
  }