@gr8ful/spf 0.1.5 → 0.1.7

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,59 @@
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
44
+
45
+ # REQUIRED, not just an example: the packaged default roster pins
46
+ # planner/reviewer/documenter to their own explicit Flue-style
47
+ # provider/model-id strings, which an agent's own model always wins over
48
+ # defaults.model above — switching coding_agent globally does NOT reset
49
+ # those three, so they'd run on Claude Code (redirected at Ollama) with a
50
+ # model id Ollama has never heard of, and fail outright. builder/scout
51
+ # have no model of their own in the packaged roster, so they correctly
52
+ # inherit defaults.model above and need no override here.
53
+ agents:
54
+ - name: planner
55
+ model: qwen3-coder:30b
56
+ - name: reviewer
57
+ model: qwen3-coder:30b
58
+ - name: documenter
59
+ model: qwen3-coder:30b
@@ -0,0 +1,53 @@
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
39
+
40
+ # Not required the way the claude_code templates' agents: override is (an
41
+ # agent's own model always wins over defaults.model, but Flue accepts any
42
+ # provider/model-id, so the packaged roster's planner/reviewer/documenter
43
+ # — fireworks/openai models, unrelated to OpenRouter — would still resolve
44
+ # and run fine, just outside OPENROUTER_API_KEY's reach). Overridden here
45
+ # anyway so every agent actually demonstrates the OpenRouter routing this
46
+ # template is about, not just the three without their own packaged model.
47
+ agents:
48
+ - name: planner
49
+ model: openrouter/moonshotai/kimi-k2.7
50
+ - name: reviewer
51
+ model: openrouter/moonshotai/kimi-k2.7
52
+ - name: documenter
53
+ model: openrouter/moonshotai/kimi-k2.7
@@ -0,0 +1,66 @@
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 (and the agents: override below) to stay
31
+ # on Flue with a `provider/model-id` roster instead.
32
+ defaults:
33
+ coding_agent: claude_code
34
+ model: sonnet # claude_code's own alias — NOT provider/model-id
35
+
36
+ # REQUIRED alongside coding_agent: claude_code above, not just an example:
37
+ # the packaged default roster pins planner/reviewer/documenter to their own
38
+ # explicit Flue-style provider/model-id strings, which an agent's own model
39
+ # always wins over defaults.model — switching coding_agent globally does
40
+ # NOT reset those three, so they'd run on Claude Code with a model id it
41
+ # can't resolve at all ("There's an issue with the selected model...").
42
+ # builder/scout have no model of their own in the packaged roster, so they
43
+ # correctly inherit defaults.model above and need no override here.
44
+ agents:
45
+ - name: planner
46
+ model: sonnet
47
+ - name: reviewer
48
+ model: sonnet
49
+ - name: documenter
50
+ model: sonnet
51
+
52
+ # Optional: retune one agent further without touching the rest of the roster.
53
+ # - name: builder
54
+ # thinking: high
55
+ # writes: [src/, tests/] # narrow what this agent may change in the repo
56
+
57
+ # Optional: poll an issue tracker's `spf:ready`-labeled issues and run a
58
+ # chain against each — see the main README's "spf watch" section for every
59
+ # issue_provider x code_host combination's env vars and required scopes
60
+ # (GitHub, or Jira issues against a Bitbucket repo, or any mix).
61
+ # Needs a GITHUB_TOKEN env var (classic PAT, repo scope) for the defaults below.
62
+ # watch:
63
+ # repo: owner/name
64
+ # label_prefix: spf
65
+ # chain: plan-build-test
66
+ # 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
@@ -25,15 +44,28 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
25
44
  # model: anthropic/claude-sonnet-4-6
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
47
+ #
48
+ # Switching coding_agent globally, in defaults: above, instead of per-agent?
49
+ # The packaged default roster pins planner/reviewer/documenter to their own
50
+ # explicit Flue-style provider/model-id strings, and an agent's own model
51
+ # always wins over defaults.model — so those three keep running on whatever
52
+ # backend you just switched to, with a model id it can't resolve, unless
53
+ # you override their model here too (builder/scout have no model of their
54
+ # own in the packaged roster, so they need no override). See
55
+ # assets/templates/ts.spf.config.yaml (or --template ts) for the full
56
+ # working pattern.
28
57
 
29
- # Uncomment to enable \`spf watch\` — polls GitHub issues labeled
58
+ # Uncomment to enable \`spf watch\` — polls an issue tracker labeled
30
59
  # <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.
60
+ # issue_provider and code_host default to "github" and are independent
61
+ # a Jira tracker against a Bitbucket repo is just issue_provider: jira +
62
+ # code_host: bitbucket + a watch.jira: {base_url, project_key} block. See
63
+ # README.md's "spf watch" section for every combination's env vars and
64
+ # required scopes. \`spf doctor\` checks whatever this resolves to.
35
65
  # watch:
36
- # repo: owner/name
66
+ # issue_provider: github # github | jira
67
+ # code_host: github # github | bitbucket
68
+ # repo: owner/name # "owner/name" (github) or "workspace/repo_slug" (bitbucket)
37
69
  # label_prefix: spf
38
70
  # chain: plan-build-test
39
71
  # base_branch: main
@@ -44,7 +76,7 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
44
76
  // (engine/, from `spf eject`), and secrets (.env).
45
77
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
46
78
  export function initCommand(argv) {
47
- const { options, flags } = parseCli(argv, ["cwd"], ["force"]);
79
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force"]);
48
80
  const anchor = paths.resolveAnchor(options["cwd"]);
49
81
  const sfDir = path.join(anchor.repo_root, ".spf");
50
82
  mkdirSync(sfDir, { recursive: true });
@@ -53,10 +85,17 @@ export function initCommand(argv) {
53
85
  console.log(`${configPath} already exists — leaving it alone (--force to overwrite)`);
54
86
  }
55
87
  else {
56
- writeFileSync(configPath, STARTER_CONFIG);
57
- console.log(`wrote ${configPath}`);
88
+ const templateName = options["template"];
89
+ const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
90
+ writeFileSync(configPath, content);
91
+ console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
58
92
  }
59
93
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
94
+ const templates = listTemplates();
95
+ if (templates.length > 0) {
96
+ console.log(`templates available via --template: ${templates.join(", ")}`);
97
+ }
60
98
  console.log(`\nnext: spf doctor (confirm everything resolves), then spf scout "describe this repo"`);
99
+ 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
100
  return 0;
62
101
  }
@@ -5,40 +5,79 @@
5
5
  * actual state machine; this file is just the wiring: config, the GitHub
6
6
  * provider, the chain-dispatch callback, the lockfile, and the CLI loop.
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
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
11
  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);
31
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);
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);
69
+ }
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);
40
78
  }
41
- return new GitHubProvider(cfg.watch.repo, cfg.watch.label_prefix, token);
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;
@@ -119,6 +161,27 @@ export async function watchCommand(argv) {
119
161
  const git = makeGit(anchor.repo_root);
120
162
  const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
121
163
  mkdirSync(worktreesDir, { recursive: true });
164
+ /**
165
+ * Without this, a claimed issue's chain resolves its session/trace data
166
+ * relative to `cwd` (the worktree, not the main repo — see
167
+ * ChainContext's doc comment), so it lands in a fresh `.spf/data` that
168
+ * `cleanupWorktree` deletes along with the rest of the worktree once the
169
+ * issue finishes: invisible in `spf ui` while running, and gone entirely
170
+ * afterward. Symlinking `.spf/data` in the worktree to the main repo's
171
+ * own `dataPaths.data_dir` makes every claimed issue show up in the same
172
+ * `spf ui` you already have open, and survive worktree cleanup. Multiple
173
+ * concurrent worktrees writing through the same symlink to one sqlite
174
+ * file is exactly what tracer.ts's WAL + busy_timeout=5000 already exist
175
+ * for. Idempotent: a no-op if the worktree already has a `.spf/data`
176
+ * (e.g. resuming an orphaned worktree).
177
+ */
178
+ function linkDataDir(worktreePath) {
179
+ const target = path.join(worktreePath, ".spf", "data");
180
+ if (existsSync(target))
181
+ return;
182
+ mkdirSync(path.dirname(target), { recursive: true });
183
+ symlinkSync(dataPaths.data_dir, target, "dir");
184
+ }
122
185
  const runChain = async (opts) => {
123
186
  const chainDef = findChain(cfg.watch.chain); // checked above
124
187
  const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd };
@@ -141,6 +204,7 @@ export async function watchCommand(argv) {
141
204
  };
142
205
  const deps = {
143
206
  provider,
207
+ codeHost,
144
208
  git,
145
209
  worktreeGit: makeGit,
146
210
  labelPrefix: cfg.watch.label_prefix,
@@ -148,6 +212,7 @@ export async function watchCommand(argv) {
148
212
  baseBranch: cfg.watch.base_branch,
149
213
  concurrency: cfg.watch.concurrency,
150
214
  worktreesDir,
215
+ linkDataDir,
151
216
  dryRun: Boolean(flags["dry-run"]),
152
217
  runChain,
153
218
  log: (message) => console.log(message),
@@ -184,7 +249,7 @@ export async function watchCommand(argv) {
184
249
  };
185
250
  process.on("SIGINT", stop);
186
251
  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)" : ""}`);
252
+ 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
253
  try {
189
254
  for (;;) {
190
255
  await tick(deps, state);