@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 +72 -12
- package/assets/skill/references/config.md +3 -2
- package/assets/templates/ts-cc.spf.config.yaml +59 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +53 -0
- package/assets/templates/ts.spf.config.yaml +66 -0
- package/dist/cli/commands/doctor.js +20 -4
- package/dist/cli/commands/init.js +49 -10
- package/dist/cli/commands/watch.js +86 -21
- package/dist/cli/index.js +1 -1
- package/dist/core/agent_cc.d.ts +8 -0
- package/dist/core/agent_cc.js +12 -1
- package/dist/core/console.d.ts +1 -0
- package/dist/core/console.js +1 -1
- package/dist/core/data_types.d.ts +45 -10
- package/dist/core/data_types.js +22 -8
- package/dist/core/issues/bitbucket_provider.d.ts +35 -0
- package/dist/core/issues/bitbucket_provider.js +70 -0
- package/dist/core/issues/github_provider.d.ts +7 -6
- package/dist/core/issues/github_provider.js +15 -20
- package/dist/core/issues/jira_provider.d.ts +72 -0
- package/dist/core/issues/jira_provider.js +163 -0
- package/dist/core/issues/provider.d.ts +37 -14
- package/dist/core/issues/provider.js +12 -4
- package/dist/core/paths.d.ts +2 -0
- package/dist/core/paths.js +2 -0
- package/dist/core/watch.d.ts +15 -2
- package/dist/core/watch.js +43 -24
- package/dist/test/watch.test.js +122 -66
- package/package.json +1 -1
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
|
|
32
|
-
spf
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
+
### GitHub (`issue_provider: github` and/or `code_host: github`)
|
|
246
282
|
|
|
247
|
-
|
|
283
|
+
```bash
|
|
284
|
+
export GITHUB_TOKEN=... # classic PAT; spf doctor checks it's set
|
|
285
|
+
```
|
|
248
286
|
|
|
249
|
-
|
|
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
|
|
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: `
|
|
104
|
-
|
|
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.
|
|
147
|
-
check(report, "
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
28
|
-
if (cfg.watch.
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
59
|
+
console.error(`watch.repo is not configured — add it to spf.config.yaml's watch: section`);
|
|
34
60
|
return null;
|
|
35
61
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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);
|