@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.
@@ -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 to stay on Flue with a `provider/model-id`
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
- # Optional: retune one agent without touching the rest of the roster.
37
- # agents:
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
- return (await response.json());
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(`/rest/api/3/search/jql?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary,description,labels`);
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() {
@@ -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;
@@ -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 });
@@ -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 deps = makeDeps(provider, codeHost);
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",