@gr8ful/spf 0.1.6 → 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/assets/templates/ts-cc.spf.config.yaml +16 -0
- package/assets/templates/ts-flue-openrouter.spf.config.yaml +15 -0
- package/assets/templates/ts.spf.config.yaml +19 -4
- package/dist/cli/commands/init.js +10 -0
- package/dist/cli/commands/watch.js +23 -1
- package/dist/core/issues/jira_provider.d.ts +9 -0
- package/dist/core/issues/jira_provider.js +20 -2
- package/dist/core/watch.d.ts +12 -0
- package/dist/core/watch.js +11 -0
- package/dist/test/watch.test.js +41 -1
- package/package.json +1 -1
|
@@ -41,3 +41,19 @@ quality:
|
|
|
41
41
|
defaults:
|
|
42
42
|
coding_agent: claude_code
|
|
43
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
|
|
@@ -36,3 +36,18 @@ defaults:
|
|
|
36
36
|
# (kimi-k3, gemini-3.6, gpt-5.6, claude-sonnet-5, ...), not a live catalog
|
|
37
37
|
# entry verified against a real API.
|
|
38
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
|
|
@@ -27,14 +27,29 @@ quality:
|
|
|
27
27
|
# Optional: run agents on Claude Code instead of the default Flue backend.
|
|
28
28
|
# Needs the `claude` CLI installed and authenticated (`spf doctor` checks) —
|
|
29
29
|
# no separate provider API key required if you're logged in via `claude
|
|
30
|
-
# login`. Remove this whole block
|
|
31
|
-
# roster instead.
|
|
30
|
+
# login`. Remove this whole block (and the agents: override below) to stay
|
|
31
|
+
# on Flue with a `provider/model-id` roster instead.
|
|
32
32
|
defaults:
|
|
33
33
|
coding_agent: claude_code
|
|
34
34
|
model: sonnet # claude_code's own alias — NOT provider/model-id
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
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.
|
|
38
53
|
# - name: builder
|
|
39
54
|
# thinking: high
|
|
40
55
|
# writes: [src/, tests/] # narrow what this agent may change in the repo
|
|
@@ -44,6 +44,16 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
|
|
|
44
44
|
# model: anthropic/claude-sonnet-4-6
|
|
45
45
|
# coding_agent: claude_code # run this agent on Claude Code instead of Flue
|
|
46
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.
|
|
47
57
|
|
|
48
58
|
# Uncomment to enable \`spf watch\` — polls an issue tracker labeled
|
|
49
59
|
# <label_prefix>:ready and runs \`chain\` against each in its own worktree.
|
|
@@ -5,7 +5,7 @@
|
|
|
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";
|
|
@@ -161,6 +161,27 @@ export async function watchCommand(argv) {
|
|
|
161
161
|
const git = makeGit(anchor.repo_root);
|
|
162
162
|
const worktreesDir = path.join(homedir(), ".spf", "watch", path.basename(anchor.repo_root), "worktrees");
|
|
163
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
|
+
}
|
|
164
185
|
const runChain = async (opts) => {
|
|
165
186
|
const chainDef = findChain(cfg.watch.chain); // checked above
|
|
166
187
|
const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd };
|
|
@@ -191,6 +212,7 @@ export async function watchCommand(argv) {
|
|
|
191
212
|
baseBranch: cfg.watch.base_branch,
|
|
192
213
|
concurrency: cfg.watch.concurrency,
|
|
193
214
|
worktreesDir,
|
|
215
|
+
linkDataDir,
|
|
194
216
|
dryRun: Boolean(flags["dry-run"]),
|
|
195
217
|
runChain,
|
|
196
218
|
log: (message) => console.log(message),
|
|
@@ -45,6 +45,15 @@ export declare class JiraProvider implements IssueProvider {
|
|
|
45
45
|
/** Jira labels are freeform strings, not a seedable registry — report what's used, create nothing. */
|
|
46
46
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
47
47
|
private toIssue;
|
|
48
|
+
/**
|
|
49
|
+
* POST with the JQL in the JSON body, NOT a GET with `jql` as a query
|
|
50
|
+
* param: this endpoint's own real-world behavior (confirmed by multiple
|
|
51
|
+
* independent bug reports against it, not just this project's own
|
|
52
|
+
* testing) is to silently ignore query-string parameters and return an
|
|
53
|
+
* empty `issues` array with a 200 OK — no error, nothing to catch. A
|
|
54
|
+
* `spf watch` that builds this as a GET query string would run cleanly
|
|
55
|
+
* forever without ever claiming a single issue.
|
|
56
|
+
*/
|
|
48
57
|
private searchByLabel;
|
|
49
58
|
listEligible(): Promise<Issue[]>;
|
|
50
59
|
/**
|
|
@@ -36,6 +36,9 @@ export class JiraProvider {
|
|
|
36
36
|
return `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`;
|
|
37
37
|
}
|
|
38
38
|
async jira(path, init) {
|
|
39
|
+
const debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
|
|
40
|
+
if (debug)
|
|
41
|
+
console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
|
|
39
42
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
40
43
|
...init,
|
|
41
44
|
headers: {
|
|
@@ -51,7 +54,10 @@ export class JiraProvider {
|
|
|
51
54
|
}
|
|
52
55
|
if (response.status === 204)
|
|
53
56
|
return undefined;
|
|
54
|
-
|
|
57
|
+
const text = await response.text();
|
|
58
|
+
if (debug)
|
|
59
|
+
console.error(`[jira debug] -> ${response.status} ${text.slice(0, 2000)}`);
|
|
60
|
+
return text ? JSON.parse(text) : undefined;
|
|
55
61
|
}
|
|
56
62
|
label(state) {
|
|
57
63
|
return `${this.labelPrefix}:${state}`;
|
|
@@ -68,9 +74,21 @@ export class JiraProvider {
|
|
|
68
74
|
labels: raw.fields.labels,
|
|
69
75
|
};
|
|
70
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* POST with the JQL in the JSON body, NOT a GET with `jql` as a query
|
|
79
|
+
* param: this endpoint's own real-world behavior (confirmed by multiple
|
|
80
|
+
* independent bug reports against it, not just this project's own
|
|
81
|
+
* testing) is to silently ignore query-string parameters and return an
|
|
82
|
+
* empty `issues` array with a 200 OK — no error, nothing to catch. A
|
|
83
|
+
* `spf watch` that builds this as a GET query string would run cleanly
|
|
84
|
+
* forever without ever claiming a single issue.
|
|
85
|
+
*/
|
|
71
86
|
async searchByLabel(label) {
|
|
72
87
|
const jql = `project = ${JSON.stringify(this.projectKey)} AND labels = ${JSON.stringify(label)}`;
|
|
73
|
-
const result = await this.jira(
|
|
88
|
+
const result = await this.jira("/rest/api/3/search/jql", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify({ jql, maxResults: 100, fields: ["summary", "description", "labels"] }),
|
|
91
|
+
});
|
|
74
92
|
return result.issues.map((i) => this.toIssue(i));
|
|
75
93
|
}
|
|
76
94
|
async listEligible() {
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -17,6 +17,18 @@ export interface WatchDeps {
|
|
|
17
17
|
baseBranch: string;
|
|
18
18
|
concurrency: number;
|
|
19
19
|
worktreesDir: string;
|
|
20
|
+
/**
|
|
21
|
+
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
22
|
+
* repo's own persistent data_dir, called once per worktree right after
|
|
23
|
+
* it's created. Without this, a chain run's session/trace data resolves
|
|
24
|
+
* relative to `cwd` (the worktree — see ChainContext's doc comment) and
|
|
25
|
+
* lands in a fresh, throwaway `.spf/data` that `cleanupWorktree` deletes
|
|
26
|
+
* along with the rest of the worktree once the issue finishes: no trace
|
|
27
|
+
* in `spf ui`, and no trace anywhere at all after cleanup. Injected (not
|
|
28
|
+
* called directly) so watch.ts's own tests never touch the real
|
|
29
|
+
* filesystem for it.
|
|
30
|
+
*/
|
|
31
|
+
linkDataDir: (worktreePath: string) => void;
|
|
20
32
|
dryRun: boolean;
|
|
21
33
|
runChain: (opts: {
|
|
22
34
|
prompt: string;
|
package/dist/core/watch.js
CHANGED
|
@@ -129,8 +129,19 @@ async function runIssue(deps, issue) {
|
|
|
129
129
|
const worktreePath = worktreePathFor(deps, issue);
|
|
130
130
|
const adwId = `issue-${issue.id}`;
|
|
131
131
|
try {
|
|
132
|
+
// Both worktreePath and branch are fully deterministic from issue.id —
|
|
133
|
+
// the only way either could already exist is a previous spf watch
|
|
134
|
+
// attempt for THIS issue that never reached its own cleanup (killed
|
|
135
|
+
// mid-run, crashed, machine restart). `git worktree add -b` refuses
|
|
136
|
+
// outright if the branch already exists ("fatal: a branch named '...'
|
|
137
|
+
// already exists"), which without this would permanently block the
|
|
138
|
+
// issue from ever being claimed again — it'd fail this same way on
|
|
139
|
+
// every single retry. Safe to clear unconditionally: worktreeRemove/
|
|
140
|
+
// deleteLocalBranch are both no-ops if there's nothing to remove.
|
|
141
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
132
142
|
deps.git.fetch("origin", deps.baseBranch);
|
|
133
143
|
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
144
|
+
deps.linkDataDir(worktreePath);
|
|
134
145
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
135
146
|
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
136
147
|
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
package/dist/test/watch.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { branchNameFor, claimNewWork, createWatchState, finishReviews, reconcileOrphans } from "../core/watch.js";
|
|
4
5
|
/** In-memory fake — exactly the seam `provider.ts` exists for. */
|
|
5
6
|
class FakeProvider {
|
|
@@ -91,6 +92,7 @@ function makeDeps(provider, codeHost, overrides = {}) {
|
|
|
91
92
|
baseBranch: "main",
|
|
92
93
|
concurrency: 2,
|
|
93
94
|
worktreesDir: "/tmp/spf-watch-test-worktrees",
|
|
95
|
+
linkDataDir: () => { },
|
|
94
96
|
dryRun: false,
|
|
95
97
|
runChain: async () => ({ accepted: true, adwId: "issue-1", detail: "" }),
|
|
96
98
|
log: () => { },
|
|
@@ -110,12 +112,45 @@ test("branchNameFor: sanitizes a title into a safe branch name", () => {
|
|
|
110
112
|
assert.equal(branchNameFor({ id: "7", title: "", body: "", labels: [] }), "spf-watch/7-issue");
|
|
111
113
|
assert.equal(branchNameFor({ id: "PROJ-123", title: "Fix the thing", body: "", labels: [] }), "spf-watch/PROJ-123-fix-the-thing");
|
|
112
114
|
});
|
|
115
|
+
test("claimNewWork: clears a stale worktree/branch from a killed prior attempt before creating a fresh one", async () => {
|
|
116
|
+
const provider = new FakeProvider();
|
|
117
|
+
provider.addIssue("2", "Retry me");
|
|
118
|
+
const codeHost = new FakeCodeHost();
|
|
119
|
+
const state = createWatchState();
|
|
120
|
+
const calls = [];
|
|
121
|
+
// Simulates a `spf watch` process killed mid-run for this exact issue: a
|
|
122
|
+
// real `git.worktreeAdd` would refuse outright with "fatal: a branch
|
|
123
|
+
// named '...' already exists" until the leftover branch is cleared.
|
|
124
|
+
let branchExists = true;
|
|
125
|
+
const deps = makeDeps(provider, codeHost, {
|
|
126
|
+
git: fakeGit({
|
|
127
|
+
worktreeRemove: (p) => calls.push(`remove:${p}`),
|
|
128
|
+
deleteLocalBranch: (n) => {
|
|
129
|
+
calls.push(`delete-branch:${n}`);
|
|
130
|
+
branchExists = false;
|
|
131
|
+
},
|
|
132
|
+
worktreeAdd: (p, b) => {
|
|
133
|
+
if (branchExists)
|
|
134
|
+
throw new Error(`fatal: a branch named '${b}' already exists`);
|
|
135
|
+
calls.push(`add:${p}`);
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
await claimNewWork(deps, state);
|
|
140
|
+
await waitUntil(() => state.inflight.size === 0);
|
|
141
|
+
const branchIdx = calls.findIndex((c) => c.startsWith("delete-branch:"));
|
|
142
|
+
const addIdx = calls.findIndex((c) => c.startsWith("add:"));
|
|
143
|
+
assert.ok(branchIdx !== -1 && addIdx !== -1 && branchIdx < addIdx, `expected the stale branch cleared before worktreeAdd, got: ${calls.join(", ")}`);
|
|
144
|
+
// The retry then succeeds normally, same as any other claim.
|
|
145
|
+
assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
|
|
146
|
+
});
|
|
113
147
|
test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves to review", async () => {
|
|
114
148
|
const provider = new FakeProvider();
|
|
115
149
|
provider.addIssue("1", "Add a /health endpoint");
|
|
116
150
|
const codeHost = new FakeCodeHost();
|
|
117
151
|
const state = createWatchState();
|
|
118
|
-
const
|
|
152
|
+
const linkedWorktrees = [];
|
|
153
|
+
const deps = makeDeps(provider, codeHost, { linkDataDir: (worktreePath) => linkedWorktrees.push(worktreePath) });
|
|
119
154
|
await claimNewWork(deps, state);
|
|
120
155
|
await waitUntil(() => state.inflight.size === 0);
|
|
121
156
|
assert.deepEqual(provider.claimCalls, ["1"]);
|
|
@@ -123,6 +158,11 @@ test("claimNewWork: claims a ready issue, runs the chain, opens a PR, and moves
|
|
|
123
158
|
assert.match(codeHost.openedPrs[0].title, /^Add a \/health endpoint \(1\)/);
|
|
124
159
|
assert.deepEqual(provider.transitions.map((t) => t.to), ["review"]);
|
|
125
160
|
assert.equal(provider.entries.get("1").marker?.pr, 1000);
|
|
161
|
+
// linkDataDir must run before the chain does — otherwise the run's own
|
|
162
|
+
// session/trace data resolves into the worktree's throwaway .spf/data
|
|
163
|
+
// instead of the main repo's persistent one (invisible in spf ui, and
|
|
164
|
+
// deleted along with the worktree on cleanup).
|
|
165
|
+
assert.deepEqual(linkedWorktrees, [path.join(deps.worktreesDir, "issue-1")]);
|
|
126
166
|
});
|
|
127
167
|
test("claimNewWork: a rejected chain run blocks the issue with the failure detail", async () => {
|
|
128
168
|
const provider = new FakeProvider();
|