@gr8ful/spf 0.1.4 → 0.1.6

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 CHANGED
@@ -28,11 +28,12 @@ spf scout "describe this repo" # a real, read-only run, no setup required
28
28
  ### Customizing a repo
29
29
 
30
30
  ```bash
31
- spf init # seed .spf/spf.config.yaml — override only what you want to change
32
- spf list # every chain this install knows, its phases, what it needs
31
+ spf init # seed .spf/spf.config.yaml — override only what you want to change
32
+ spf init --template ts-cc # or start from a packaged, ready-to-run template instead
33
+ spf list # every chain this install knows, its phases, what it needs
33
34
  ```
34
35
 
35
- `spf init` writes a small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field — override one model, one prompt, one quality check, and everything else stays inherited. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
36
+ `spf init` writes a small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field — override one model, one prompt, one quality check, and everything else stays inherited. `--template <name>` writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after `spf init` runs, and the same files live in [`assets/templates/`](assets/templates/) to browse directly. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
36
37
 
37
38
  ### Local development
38
39
 
@@ -116,6 +117,24 @@ Config defines who an agent **is**. The chain call site defines how it is **used
116
117
 
117
118
  Set `coding_agent: claude_code` on any agent (or in `defaults`) to run it on your own installed [Claude Code](https://claude.com/product/claude-code) CLI instead of Flue — `spf doctor` checks it's on `PATH`. Model names follow Claude Code's own vocabulary (a bare alias like `sonnet`, not `provider/model-id`); everything else — `tools`, `writes`, `thinking` — stays the same shape. Pointing a `claude_code` agent at a local or cloud [Ollama](https://ollama.com) server needs no config at all — just `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` set before you run `spf`, since Claude Code's CLI reads those itself.
118
119
 
120
+ #### Proxy or wrapper launchers
121
+
122
+ To route the `claude` command through a wrapper, proxy server, or launcher (e.g., [Ollama](https://ollama.com)), set the `SPF_CLAUDE_CMD` environment variable before running `spf`. Space-separated command chains are supported:
123
+
124
+ ```bash
125
+ # Route through Ollama's launcher
126
+ export SPF_CLAUDE_CMD="ollama launch claude"
127
+ spf build "your prompt"
128
+ ```
129
+
130
+ ```bash
131
+ # Or use a custom wrapper script
132
+ export SPF_CLAUDE_CMD=/path/to/my-wrapper
133
+ spf build "your prompt"
134
+ ```
135
+
136
+ The command/launcher must support the full Claude Code CLI interface: `-p` for prompt, `--json-schema`, `--model`, `--session-id`/`--resume`, `--output-format stream-json`, and all other flags `agent_cc` uses. When unset, `SPF_CLAUDE_CMD` defaults to `claude` (resolved from `PATH` normally).
137
+
119
138
  ---
120
139
 
121
140
  ## Phases: three lanes, one primitive
@@ -219,11 +238,15 @@ spf build-test "implement the plan" --adw-id a1b2c3d4
219
238
 
220
239
  ## `spf watch`
221
240
 
222
- Polls a GitHub repo for issues labeled `<prefix>:ready`, runs a configured chain against each in its own git worktree, opens a PR, and tracks it through to merged or blocked — driving the same chains above rather than reimplementing an SDLC. Labels are the whole state machine: `ready → working → review → done`/`blocked`.
241
+ Polls an issue tracker for issues labeled `<prefix>:ready`, runs a configured chain against each in its own git worktree, opens a PR against a code host, and tracks it through to merged or blocked — driving the same chains above rather than reimplementing an SDLC. Labels are the whole state machine: `ready → working → review → done`/`blocked`.
242
+
243
+ The tracker (`issue_provider`) and the code host (`code_host`) are independent config choices, not one bundled "provider" — a tracker and a host are independent choices in practice (Jira issues against a Bitbucket repo is a real setup). Supported today: `issue_provider: github | jira`, `code_host: github | bitbucket` — any combination works, including Jira+GitHub or GitHub-issues+Bitbucket.
223
244
 
224
245
  ```yaml
225
- # .spf/spf.config.yaml
246
+ # .spf/spf.config.yaml — GitHub issues + GitHub PRs (the default)
226
247
  watch:
248
+ issue_provider: github # default
249
+ code_host: github # default
227
250
  repo: owner/name
228
251
  label_prefix: spf # polls issues labeled spf:ready
229
252
  chain: plan-build-test # any registered chain
@@ -232,31 +255,68 @@ watch:
232
255
  concurrency: 2
233
256
  ```
234
257
 
258
+ ```yaml
259
+ # .spf/spf.config.yaml — Jira issues + Bitbucket PRs
260
+ watch:
261
+ issue_provider: jira
262
+ code_host: bitbucket
263
+ repo: workspace/repo_slug # Bitbucket's own two-part identifier
264
+ label_prefix: spf # polls Jira issues labeled spf:ready
265
+ chain: plan-build-test
266
+ base_branch: main
267
+ jira:
268
+ base_url: https://your-domain.atlassian.net
269
+ project_key: PROJ
270
+ ```
271
+
235
272
  ```bash
236
- export GITHUB_TOKEN=... # classic PAT — see "GITHUB_TOKEN scope" below; spf doctor checks it's set
237
- spf watch init # idempotently create/update the 5 labels below — run this first
273
+ spf watch init # idempotently seed tracker state (no-op for Jira — see below); run this first
238
274
  spf watch # foreground daemon; Ctrl-C drains in-flight claims first
239
275
  spf watch --once # one poll tick, then exit — good for cron
240
276
  spf watch --dry-run # log intended claims/transitions, mutate nothing
241
277
  ```
242
278
 
243
- `spf watch init` seeds `<prefix>:ready`/`working`/`review`/`done`/`blocked` with a color and description each safe to re-run any time (creates what's missing, corrects any that drifted, leaves the rest alone).
279
+ No GitHub App, no webhook, no Jira/Bitbucket app install — it's a plain REST poll against whichever combination is configured, same philosophy as the trace db's own polling contract. See [`assets/templates/`](assets/templates/) for full worked configs (also usable directly via `spf init --template <name>`), and `spf install-skill`'s installed skill (`roster.md`, `references/config.md`) for the field-by-field reference.
244
280
 
245
- No GitHub App, no webhook — it's a plain REST poll, same philosophy as the trace db's own polling contract. See [`docs/examples/`](docs/examples/) for full worked configs, and `spf install-skill`'s installed skill (`roster.md`, `references/config.md`) for the field-by-field reference.
281
+ ### GitHub (`issue_provider: github` and/or `code_host: github`)
246
282
 
247
- ### `GITHUB_TOKEN` scope
283
+ ```bash
284
+ export GITHUB_TOKEN=... # classic PAT; spf doctor checks it's set
285
+ ```
248
286
 
249
- A **classic** PAT (fine-grained tokens use different permission names not covered here), scoped to the minimum that covers every call `spf watch`/`spf watch init` makes: creating/editing labels, reading and labeling issues, posting comments, opening PRs, and reading PR/check-run status.
287
+ `spf watch init` seeds `<prefix>:ready`/`working`/`review`/`done`/`blocked` labels with a color and description each safe to re-run any time (creates what's missing, corrects any that drifted, leaves the rest alone).
288
+
289
+ A **classic** PAT (fine-grained tokens use different permission names — not covered here), scoped to the minimum that covers every call `spf watch`/`spf watch init` makes on GitHub: creating/editing labels, reading and labeling issues, posting comments, opening PRs, and reading PR/check-run status.
250
290
 
251
291
  | Target repo | Scope | Covers |
252
292
  |---|---|---|
253
293
  | Private | `repo` | Everything above, full read/write |
254
294
  | Public only | `public_repo` | The same, restricted to public repos |
255
295
 
256
- **Do not grant the `project` scope.** It's a separate, unrelated permission for GitHub Projects (classic/org/user boards) — `spf watch` doesn't touch Projects at all (deliberately out of scope for v1; see the label-based state machine above), so granting it would just be more access than this tool ever uses.
296
+ **Do not grant the `project` scope.** It's a separate, unrelated permission for GitHub Projects (classic/org/user boards) — `spf watch` doesn't touch Projects at all, so granting it would just be more access than this tool ever uses.
257
297
 
258
298
  There's no dedicated "issues" or "pull requests" scope on classic PATs — GitHub bundles both into `repo`/`public_repo`, which is why that's the whole table.
259
299
 
300
+ ### Jira (`issue_provider: jira`)
301
+
302
+ ```bash
303
+ export JIRA_EMAIL=you@example.com
304
+ export JIRA_API_TOKEN=... # id.atlassian.com -> Security -> API tokens
305
+ ```
306
+
307
+ State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly, rather than native workflow status transitions — the latter would need per-project transition-id mapping, since workflows vary by project/scheme; labels work identically everywhere with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
308
+
309
+ `spf watch init` is a no-op here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) — it just reports the labels this run will use.
310
+
311
+ ### Bitbucket (`code_host: bitbucket`)
312
+
313
+ ```bash
314
+ export BITBUCKET_EMAIL=you@example.com
315
+ export BITBUCKET_API_TOKEN=... # same Atlassian API token mechanism as Jira above
316
+ ```
317
+
318
+ **Bitbucket Cloud app passwords are being fully removed** (brownout window closing July 28, 2026) — this project only supports the replacement, API tokens, which need the account's email alongside the token (username alone no longer works).
319
+
260
320
  ## What's in this repo
261
321
 
262
322
  ```
@@ -100,8 +100,9 @@ quality:
100
100
  ```
101
101
 
102
102
  A full worked example, including the `defaults.coding_agent`/`watch:`
103
- sections: `docs/examples/node-typescript.spf.config.yaml` in the spf
104
- repo (or wherever this skill's own package is installed from).
103
+ sections: `assets/templates/ts.spf.config.yaml` in the spf package (or
104
+ `spf init --template ts` to write it straight into `.spf/spf.config.yaml`).
105
+ `spf init` with no `--template` prints every packaged template's name.
105
106
 
106
107
  ### `agents[]`
107
108
 
@@ -0,0 +1,43 @@
1
+ # .spf/spf.config.yaml — Claude Code backend, pointed at Ollama (local or
2
+ # cloud) instead of Anthropic's API. `spf init --template ts-cc` writes
3
+ # this file as-is.
4
+ #
5
+ # No spf-side config makes the Ollama redirect happen: `coding_agent:
6
+ # claude_code` shells out to your own installed `claude` CLI, and Claude
7
+ # Code's CLI itself reads ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN to
8
+ # decide where to send requests. agent_cc.ts never special-cases this — it
9
+ # just passes your shell's environment through unmodified to the `claude`
10
+ # subprocess, the same way every other env var reaches an agent.
11
+ #
12
+ # Local Ollama, before running spf:
13
+ # ollama serve # if not already running
14
+ # ollama pull qwen3-coder:30b # or whatever model you want
15
+ # export ANTHROPIC_BASE_URL=http://localhost:11434
16
+ # export ANTHROPIC_AUTH_TOKEN=ollama # any non-empty value — local Ollama doesn't check it
17
+ #
18
+ # Ollama Cloud instead of a local server:
19
+ # export ANTHROPIC_BASE_URL=<your Ollama Cloud endpoint — see docs.ollama.com>
20
+ # export ANTHROPIC_AUTH_TOKEN=<a real Ollama Cloud API key>
21
+ #
22
+ # `spf doctor` checks that `claude` is on PATH and treats a missing
23
+ # ANTHROPIC_API_KEY as informational (claude login is a valid alternative)
24
+ # — it does NOT know about this redirect, so it won't tell you whether
25
+ # ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN are actually set. Confirm those
26
+ # yourself before a real run.
27
+ #
28
+ # `model:` becomes whatever model name your Ollama server actually serves
29
+ # once redirected — not one of Claude Code's own aliases (sonnet/opus/...)
30
+ # and not provider/model-id.
31
+ quality:
32
+ checks:
33
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
34
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
35
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
36
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
37
+ suites:
38
+ test: [test]
39
+ all: [typecheck, lint, build, test]
40
+
41
+ defaults:
42
+ coding_agent: claude_code
43
+ model: qwen3-coder:30b # swap for whatever `ollama list` shows on your machine
@@ -0,0 +1,38 @@
1
+ # .spf/spf.config.yaml — Flue backend (the default), routed through
2
+ # OpenRouter to a Kimi model. `spf init --template ts-flue-openrouter`
3
+ # writes this file as-is.
4
+ #
5
+ # model: is ALWAYS provider/model-id for Flue — the string's first segment
6
+ # names the provider, and that segment alone decides which env var gets
7
+ # read for credentials. This resolution belongs to Flue's own underlying
8
+ # `pi-ai` library, not to spf: agents.validate() only checks the STRING
9
+ # SHAPE (does it look like provider/id, via agent_flue.ts's resolveModel())
10
+ # — never that the provider is real or that its key is actually set. A
11
+ # wrong provider name or a missing key only surfaces at the first real
12
+ # dispatch, not at validate() time.
13
+ #
14
+ # `spf doctor` has its own small, hand-maintained table of common
15
+ # providers' env-var conventions (src/cli/commands/doctor.ts) for an
16
+ # informational check — pi-ai's real internal provider table isn't
17
+ # exported for spf to read, so this is best-effort, not authoritative.
18
+ # openrouter's own entry in that table: OPENROUTER_API_KEY.
19
+ #
20
+ # Before running spf:
21
+ # export OPENROUTER_API_KEY=sk-or-...
22
+ quality:
23
+ checks:
24
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
25
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
26
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
27
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
28
+ suites:
29
+ test: [test]
30
+ all: [typecheck, lint, build, test]
31
+
32
+ defaults:
33
+ coding_agent: flue
34
+ # Check openrouter.ai/models for the exact current id — "kimi-k2.7" here
35
+ # matches this project's existing fictional-model-version convention
36
+ # (kimi-k3, gemini-3.6, gpt-5.6, claude-sonnet-5, ...), not a live catalog
37
+ # entry verified against a real API.
38
+ model: openrouter/moonshotai/kimi-k2.7
@@ -0,0 +1,51 @@
1
+ # .spf/spf.config.yaml for a Node/TypeScript project — `spf init --template
2
+ # ts` writes this file as-is; `spf init` with no --template seeds a starter
3
+ # with the same shape, commented out. This fills it in with real commands
4
+ # so `plan-build-test`/`plan-build-test-quality`/`quality`/`simple-sdlc` —
5
+ # every chain with a REQUIRED_SUITES entry — actually have something to run
6
+ # instead of failing loudly at validate() time.
7
+ #
8
+ # Adjust the argv to your own project: swap `npm` for `pnpm`/`yarn`, point
9
+ # `build`/`typecheck` at whatever scripts you already have in package.json.
10
+ quality:
11
+ checks:
12
+ # `operation` classifies the check for the trace/UI (lint | typecheck |
13
+ # build) — it doesn't have to literally match the argv. Running a test
14
+ # suite doesn't have its own operation value, so `build` is the closest
15
+ # fit; it doesn't change what actually runs.
16
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
17
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
18
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
19
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
20
+ suites:
21
+ # `test:` is what build-test/plan-build-test/simple-sdlc's bounded fix
22
+ # loop runs after every build. Keep it fast — it runs on every attempt.
23
+ test: [test]
24
+ # `all:` is what plan-build-test-quality/quality run — the full bar.
25
+ all: [typecheck, lint, build, test]
26
+
27
+ # Optional: run agents on Claude Code instead of the default Flue backend.
28
+ # Needs the `claude` CLI installed and authenticated (`spf doctor` checks) —
29
+ # no separate provider API key required if you're logged in via `claude
30
+ # login`. Remove this whole block to stay on Flue with a `provider/model-id`
31
+ # roster instead.
32
+ defaults:
33
+ coding_agent: claude_code
34
+ model: sonnet # claude_code's own alias — NOT provider/model-id
35
+
36
+ # Optional: retune one agent without touching the rest of the roster.
37
+ # agents:
38
+ # - name: builder
39
+ # thinking: high
40
+ # writes: [src/, tests/] # narrow what this agent may change in the repo
41
+
42
+ # Optional: poll an issue tracker's `spf:ready`-labeled issues and run a
43
+ # chain against each — see the main README's "spf watch" section for every
44
+ # issue_provider x code_host combination's env vars and required scopes
45
+ # (GitHub, or Jira issues against a Bitbucket repo, or any mix).
46
+ # Needs a GITHUB_TOKEN env var (classic PAT, repo scope) for the defaults below.
47
+ # watch:
48
+ # repo: owner/name
49
+ # label_prefix: spf
50
+ # chain: plan-build-test
51
+ # base_branch: main
@@ -143,10 +143,26 @@ export function doctorCommand(argv) {
143
143
  check(report, `${envFile}`, true, existsSync(p) ? `present (${statSync(p).size} bytes)` : "absent — fine if no provider needs a key from it");
144
144
  }
145
145
  if (cfg.watch.repo.trim()) {
146
- check(report, "watch.provider", cfg.watch.provider === "github", cfg.watch.provider);
147
- check(report, "GITHUB_TOKEN", Boolean(process.env["GITHUB_TOKEN"]), process.env["GITHUB_TOKEN"]
148
- ? "set"
149
- : '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');
146
+ check(report, "watch.issue_provider", true, cfg.watch.issue_provider);
147
+ check(report, "watch.code_host", true, cfg.watch.code_host);
148
+ if (cfg.watch.issue_provider === "github" || cfg.watch.code_host === "github") {
149
+ check(report, "GITHUB_TOKEN", Boolean(process.env["GITHUB_TOKEN"]), process.env["GITHUB_TOKEN"]
150
+ ? "set"
151
+ : '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');
152
+ }
153
+ if (cfg.watch.issue_provider === "jira") {
154
+ check(report, "watch.jira", Boolean(cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()), cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()
155
+ ? `${cfg.watch.jira.base_url} (${cfg.watch.jira.project_key})`
156
+ : "watch.jira.base_url and watch.jira.project_key must both be set");
157
+ check(report, "JIRA_EMAIL / JIRA_API_TOKEN", Boolean(process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]), process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]
158
+ ? "set"
159
+ : 'not set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens); see README.md\'s "spf watch" section');
160
+ }
161
+ if (cfg.watch.code_host === "bitbucket") {
162
+ check(report, "BITBUCKET_EMAIL / BITBUCKET_API_TOKEN", Boolean(process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]), process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]
163
+ ? "set"
164
+ : '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');
165
+ }
150
166
  check(report, "watch.chain", Boolean(findChain(cfg.watch.chain)), findChain(cfg.watch.chain) ? cfg.watch.chain : `"${cfg.watch.chain}" is not a registered chain`);
151
167
  }
152
168
  return finish(report, flags["json"]);
@@ -1,9 +1,28 @@
1
1
  /** `spf init` — seed a `.spf/` override directory. Everything else is inherited from the packaged defaults. */
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import * as paths from "../../core/paths.js";
5
5
  import { ensureGitignore } from "../gitignore.js";
6
6
  import { parseCli } from "../../core/utils.js";
7
+ import { paint } from "../../core/console.js";
8
+ const TEMPLATE_SUFFIX = ".spf.config.yaml";
9
+ /** Every template's short name (e.g. "ts-cc"), derived from disk rather than hand-maintained — never drifts from what's actually packaged. */
10
+ function listTemplates() {
11
+ if (!existsSync(paths.TEMPLATES_DIR))
12
+ return [];
13
+ return readdirSync(paths.TEMPLATES_DIR)
14
+ .filter((f) => f.endsWith(TEMPLATE_SUFFIX))
15
+ .map((f) => f.slice(0, -TEMPLATE_SUFFIX.length))
16
+ .sort();
17
+ }
18
+ function loadTemplate(name) {
19
+ const templatePath = path.join(paths.TEMPLATES_DIR, `${name}${TEMPLATE_SUFFIX}`);
20
+ if (!existsSync(templatePath)) {
21
+ const available = listTemplates();
22
+ throw new Error(`unknown template ${JSON.stringify(name)} — available: ${available.length > 0 ? available.join(", ") : "(none packaged)"}`);
23
+ }
24
+ return readFileSync(templatePath, "utf-8");
25
+ }
7
26
  const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packaged built-in defaults.
8
27
  # List only what you want to CHANGE; everything else (the roster, prompts,
9
28
  # models) is inherited. Run \`spf doctor\` any time to see what's actually in
@@ -26,14 +45,17 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
26
45
  # coding_agent: claude_code # run this agent on Claude Code instead of Flue
27
46
  # model: sonnet # claude_code's own alias, NOT provider/model-id
28
47
 
29
- # Uncomment to enable \`spf watch\` — polls GitHub issues labeled
48
+ # Uncomment to enable \`spf watch\` — polls an issue tracker labeled
30
49
  # <label_prefix>:ready and runs \`chain\` against each in its own worktree.
31
- # Needs a GITHUB_TOKEN env var: a classic PAT with repo scope (or public_repo
32
- # for a public-only repo) never the project scope, which is unrelated and
33
- # unused here. See README.md's "GITHUB_TOKEN scope" section. \`spf doctor\`
34
- # checks it's set.
50
+ # issue_provider and code_host default to "github" and are independent
51
+ # a Jira tracker against a Bitbucket repo is just issue_provider: jira +
52
+ # code_host: bitbucket + a watch.jira: {base_url, project_key} block. See
53
+ # README.md's "spf watch" section for every combination's env vars and
54
+ # required scopes. \`spf doctor\` checks whatever this resolves to.
35
55
  # watch:
36
- # repo: owner/name
56
+ # issue_provider: github # github | jira
57
+ # code_host: github # github | bitbucket
58
+ # repo: owner/name # "owner/name" (github) or "workspace/repo_slug" (bitbucket)
37
59
  # label_prefix: spf
38
60
  # chain: plan-build-test
39
61
  # base_branch: main
@@ -44,7 +66,7 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
44
66
  // (engine/, from `spf eject`), and secrets (.env).
45
67
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
46
68
  export function initCommand(argv) {
47
- const { options, flags } = parseCli(argv, ["cwd"], ["force"]);
69
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force"]);
48
70
  const anchor = paths.resolveAnchor(options["cwd"]);
49
71
  const sfDir = path.join(anchor.repo_root, ".spf");
50
72
  mkdirSync(sfDir, { recursive: true });
@@ -53,10 +75,17 @@ export function initCommand(argv) {
53
75
  console.log(`${configPath} already exists — leaving it alone (--force to overwrite)`);
54
76
  }
55
77
  else {
56
- writeFileSync(configPath, STARTER_CONFIG);
57
- console.log(`wrote ${configPath}`);
78
+ const templateName = options["template"];
79
+ const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
80
+ writeFileSync(configPath, content);
81
+ console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
58
82
  }
59
83
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
84
+ const templates = listTemplates();
85
+ if (templates.length > 0) {
86
+ console.log(`templates available via --template: ${templates.join(", ")}`);
87
+ }
60
88
  console.log(`\nnext: spf doctor (confirm everything resolves), then spf scout "describe this repo"`);
89
+ console.log(paint("yellow", `warning: spf ui reads .spf/data/spf.db, which doesn't exist until a run creates it — run spf scout (or any chain) at least once before spf ui.`));
61
90
  return 0;
62
91
  }
@@ -12,33 +12,72 @@ import * as agents from "../../core/agents.js";
12
12
  import * as paths from "../../core/paths.js";
13
13
  import { isRepoAt, makeGit } from "../../core/git_helper.js";
14
14
  import { GitHubProvider } from "../../core/issues/github_provider.js";
15
+ import { JiraProvider } from "../../core/issues/jira_provider.js";
16
+ import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
15
17
  import { createWatchState, tick } from "../../core/watch.js";
16
18
  import { findChain } from "../../chains/index.js";
17
19
  import { SfDb } from "../../ui/server/db.js";
18
20
  import { parseCli } from "../../core/utils.js";
19
21
  /**
20
- * Shared by `watch` and `watch init`: resolve config and build the
21
- * provider, checking only what BOTH need a supported provider, a
22
- * non-empty `watch.repo`, and `GITHUB_TOKEN`. `watch`'s own extra checks
23
- * (a real git repo, a registered chain) don't apply to seeding labels.
24
- * Prints its own error and returns `null` on failure — the caller just
25
- * needs to `return 1`.
22
+ * Shared by `watch` and `watch init`: resolve config into an `IssueProvider`
23
+ * checking only what BOTH need. `watch`'s own extra checks (a real git
24
+ * repo, a registered chain) don't apply to seeding labels. Prints its own
25
+ * error and returns `null` on failure the caller just needs to `return 1`.
26
26
  */
27
- function resolveWatchProvider(cfg) {
28
- if (cfg.watch.provider !== "github") {
29
- console.error(`watch.provider ${JSON.stringify(cfg.watch.provider)} is not supported`);
30
- return null;
27
+ function resolveIssueProvider(cfg) {
28
+ if (cfg.watch.issue_provider === "github") {
29
+ if (!cfg.watch.repo.trim()) {
30
+ console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
31
+ return null;
32
+ }
33
+ const token = process.env["GITHUB_TOKEN"];
34
+ if (!token) {
35
+ 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.');
36
+ return null;
37
+ }
38
+ return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
39
+ }
40
+ if (cfg.watch.issue_provider === "jira") {
41
+ if (!cfg.watch.jira.base_url.trim() || !cfg.watch.jira.project_key.trim()) {
42
+ console.error(`watch.jira.base_url and watch.jira.project_key must both be set when watch.issue_provider is "jira"`);
43
+ return null;
44
+ }
45
+ const email = process.env["JIRA_EMAIL"];
46
+ const token = process.env["JIRA_API_TOKEN"];
47
+ if (!email || !token) {
48
+ console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens). See README.md\'s "spf watch" section.');
49
+ return null;
50
+ }
51
+ return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token);
31
52
  }
53
+ console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
54
+ return null;
55
+ }
56
+ /** Same shape as `resolveIssueProvider`, for `watch.code_host`. */
57
+ function resolveCodeHostProvider(cfg) {
32
58
  if (!cfg.watch.repo.trim()) {
33
- console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
59
+ console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section`);
34
60
  return null;
35
61
  }
36
- const token = process.env["GITHUB_TOKEN"];
37
- if (!token) {
38
- 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.');
39
- return null;
62
+ if (cfg.watch.code_host === "github") {
63
+ const token = process.env["GITHUB_TOKEN"];
64
+ if (!token) {
65
+ 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.');
66
+ return null;
67
+ }
68
+ return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
40
69
  }
41
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
70
+ if (cfg.watch.code_host === "bitbucket") {
71
+ const email = process.env["BITBUCKET_EMAIL"];
72
+ const token = process.env["BITBUCKET_API_TOKEN"];
73
+ if (!email || !token) {
74
+ console.error('BITBUCKET_EMAIL and BITBUCKET_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (Bitbucket app passwords are being removed; API tokens are the replacement). See README.md\'s "spf watch" section.');
75
+ return null;
76
+ }
77
+ return new BitbucketProvider(cfg.watch.repo, email, token);
78
+ }
79
+ console.error(`watch.code_host ${JSON.stringify(cfg.watch.code_host)} is not supported`);
80
+ return null;
42
81
  }
43
82
  function isPidAlive(pid) {
44
83
  try {
@@ -78,11 +117,11 @@ export async function watchInitCommand(argv) {
78
117
  const { options } = parseCli(argv, ["cwd", "config"], []);
79
118
  const anchor = paths.resolveAnchor(options["cwd"]);
80
119
  const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
81
- const provider = resolveWatchProvider(cfg);
120
+ const provider = resolveIssueProvider(cfg);
82
121
  if (!provider)
83
122
  return 1;
84
123
  const result = await provider.ensureLabels();
85
- console.log(`spf watch init: ${cfg.watch.repo} (label prefix "${cfg.watch.label_prefix}")`);
124
+ console.log(`spf watch init: ${cfg.watch.issue_provider} (label prefix "${cfg.watch.label_prefix}")`);
86
125
  for (const name of result.created)
87
126
  console.log(` + ${name} (created)`);
88
127
  for (const name of result.updated)
@@ -96,9 +135,12 @@ export async function watchCommand(argv) {
96
135
  const anchor = paths.resolveAnchor(options["cwd"]);
97
136
  const configPaths = paths.resolveConfigPaths(anchor, options["config"]).paths;
98
137
  const cfg = agents.loadConfig(configPaths);
99
- const provider = resolveWatchProvider(cfg);
138
+ const provider = resolveIssueProvider(cfg);
100
139
  if (!provider)
101
140
  return 1;
141
+ const codeHost = resolveCodeHostProvider(cfg);
142
+ if (!codeHost)
143
+ return 1;
102
144
  if (!isRepoAt(anchor.repo_root)) {
103
145
  console.error(`${anchor.repo_root} is not a git repository`);
104
146
  return 1;
@@ -141,6 +183,7 @@ export async function watchCommand(argv) {
141
183
  };
142
184
  const deps = {
143
185
  provider,
186
+ codeHost,
144
187
  git,
145
188
  worktreeGit: makeGit,
146
189
  labelPrefix: cfg.watch.label_prefix,
@@ -184,7 +227,7 @@ export async function watchCommand(argv) {
184
227
  };
185
228
  process.on("SIGINT", stop);
186
229
  process.on("SIGTERM", stop);
187
- console.log(`[spf] watch ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
230
+ console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}${flags["dry-run"] ? " (dry run)" : ""}`);
188
231
  try {
189
232
  for (;;) {
190
233
  await tick(deps, state);
package/dist/cli/index.js CHANGED
@@ -27,7 +27,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
27
27
 
28
28
  spf list the chain registry — names, phases, what each needs
29
29
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
30
- spf init [--force] seed .spf/spf.config.yaml in the current repo
30
+ spf init [--force] [--template <name>] seed .spf/spf.config.yaml (from a packaged template, if named)
31
31
  spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
32
32
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
33
33
  spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
@@ -7,6 +7,14 @@
7
7
  * backend needs installed anyway) — shelling out costs SPF zero new
8
8
  * dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
9
9
  *
10
+ * The `claude` command is resolved from `PATH` by default. To route it through
11
+ * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
+ * before running spf. Space-separated command chains are supported:
13
+ * - `SPF_CLAUDE_CMD="claude"` (default)
14
+ * - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher)
15
+ * The command/launcher must support the full Claude Code CLI interface.
16
+ * When unset, defaults to `claude`.
17
+ *
10
18
  * Every flag below was verified against a REAL local run of this exact
11
19
  * machine's `claude` CLI (v2.1.237) before being written — not assumed from
12
20
  * the SDK's docs, which describe a related but separately-versioned
@@ -7,6 +7,14 @@
7
7
  * backend needs installed anyway) — shelling out costs SPF zero new
8
8
  * dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
9
9
  *
10
+ * The `claude` command is resolved from `PATH` by default. To route it through
11
+ * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
+ * before running spf. Space-separated command chains are supported:
13
+ * - `SPF_CLAUDE_CMD="claude"` (default)
14
+ * - `SPF_CLAUDE_CMD="ollama launch claude"` (Ollama launcher)
15
+ * The command/launcher must support the full Claude Code CLI interface.
16
+ * When unset, defaults to `claude`.
17
+ *
10
18
  * Every flag below was verified against a REAL local run of this exact
11
19
  * machine's `claude` CLI (v2.1.237) before being written — not assumed from
12
20
  * the SDK's docs, which describe a related but separately-versioned
@@ -231,7 +239,10 @@ export async function run(request, onEvent, onSpawn, onExit) {
231
239
  toolsFlagValue(request.tools),
232
240
  "--strict-mcp-config", // see the module doc comment — required, not optional
233
241
  ];
234
- const child = spawn("claude", args, { cwd: request.cwd, env: operatorEnv() });
242
+ const cmdSpec = process.env.SPF_CLAUDE_CMD || "claude";
243
+ const [cmd, ...cmdArgs] = cmdSpec.split(/\s+/);
244
+ const fullArgs = [...cmdArgs, ...args];
245
+ const child = spawn(cmd, fullArgs, { cwd: request.cwd, env: operatorEnv() });
235
246
  // The prompt travels as a positional argv element, not stdin — closing it
236
247
  // immediately avoids a real, observed ~3s "no stdin data received" stall
237
248
  // where `claude` otherwise waits to see whether anything is piped in.
@@ -7,6 +7,7 @@
7
7
  * so a CI log reads exactly like a terminal.
8
8
  */
9
9
  import type { EnvelopeBase, EventRecord, GateReport, Phase } from "./data_types.ts";
10
+ export declare function paint(style: string, text: string): string;
10
11
  interface Tracer {
11
12
  event(record: EventRecord): string;
12
13
  }
@@ -21,7 +21,7 @@ const CODES = {
21
21
  cyan: "36",
22
22
  white: "37",
23
23
  };
24
- function paint(style, text) {
24
+ export function paint(style, text) {
25
25
  const codes = style.split(" ").map((s) => CODES[s]).filter(Boolean);
26
26
  if (codes.length === 0)
27
27
  return text;