@gr8ful/spf 0.2.1 → 0.4.0
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 +106 -6
- package/assets/defaults/spf.config.yaml +16 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -0
- package/assets/skill/references/config.md +83 -3
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts.spf.config.yaml +22 -2
- package/dist/chains/context.d.ts +9 -0
- package/dist/chains/index.js +5 -0
- package/dist/chains/steps.d.ts +24 -0
- package/dist/chains/steps.js +55 -4
- package/dist/cli/commands/doctor.js +18 -0
- package/dist/cli/commands/init.js +44 -3
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +1 -0
- package/dist/cli/commands/run.js +5 -1
- package/dist/cli/commands/watch.js +86 -8
- package/dist/cli/index.js +7 -3
- package/dist/cli/interview.d.ts +2 -0
- package/dist/cli/interview.js +107 -3
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +133 -0
- package/dist/core/data_types.js +72 -0
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +144 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.js +3 -0
- package/dist/core/watch.d.ts +66 -1
- package/dist/core/watch.js +267 -15
- package/dist/test/chains.test.js +1 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/init_command.test.js +17 -0
- package/dist/test/interview.test.js +119 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +126 -0
- package/dist/test/watch.test.js +286 -5
- package/package.json +1 -1
package/dist/core/watch.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `spf watch` state machine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `spf watch` state machine — two lanes over the same poll loop.
|
|
3
|
+
*
|
|
4
|
+
* The build lane: poll -> claim -> run a chain -> PR -> done/blocked.
|
|
5
|
+
* The refine lane (`watch.refine.enabled`, off by default): poll a
|
|
6
|
+
* `spec-ready` product spec -> claim -> decompose it into a feature/story
|
|
7
|
+
* tree -> publish those as real issues -> done/blocked. A spec is not
|
|
8
|
+
* individually workable, so this lane never opens a PR — it hands the build
|
|
9
|
+
* lane its next batch of `ready`-able work instead (see `core/refine.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Provider-agnostic (drives whatever `IssueProvider` it's given) and
|
|
12
|
+
* chain-agnostic (drives whatever `runChain`/`runRefine` callback it's
|
|
5
13
|
* given) — deliberately kept out of `src/chains/`'s dependency direction,
|
|
6
|
-
* so this stays testable against a fake provider and
|
|
7
|
-
*
|
|
14
|
+
* so this stays testable against a fake provider and fake callbacks with no
|
|
15
|
+
* chain registry involved.
|
|
8
16
|
*
|
|
9
17
|
* Design lifted from the user's own GitHub-poller reference implementation
|
|
10
18
|
* (a label-as-state-machine daemon), leaned down for a v1: no per-issue
|
|
@@ -30,24 +38,33 @@
|
|
|
30
38
|
import path from "node:path";
|
|
31
39
|
const MAX_ORPHAN_ATTEMPTS = 2;
|
|
32
40
|
export function createWatchState() {
|
|
33
|
-
return { inflight: new Set() };
|
|
41
|
+
return { inflight: new Set(), refining: new Set() };
|
|
34
42
|
}
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
function slugifyTitle(title) {
|
|
44
|
+
return (title
|
|
37
45
|
.toLowerCase()
|
|
38
46
|
.split(/\s+/)
|
|
39
47
|
.filter(Boolean)
|
|
40
48
|
.slice(0, 5)
|
|
41
49
|
.join("-")
|
|
42
|
-
.replace(/[^a-z0-9-]/g, "");
|
|
50
|
+
.replace(/[^a-z0-9-]/g, "") || "issue");
|
|
51
|
+
}
|
|
52
|
+
export function branchNameFor(issue) {
|
|
43
53
|
// The issue's own id in the branch name isn't just labeling: Jira's
|
|
44
54
|
// Bitbucket integration auto-links a PR to the issue when its key
|
|
45
55
|
// appears anywhere in the branch name, no explicit API call needed.
|
|
46
|
-
return `spf-watch/${issue.id}-${
|
|
56
|
+
return `spf-watch/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
/** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
|
|
59
|
+
export function refineBranchNameFor(issue) {
|
|
60
|
+
return `spf-refine/${issue.id}-${slugifyTitle(issue.title)}`.slice(0, 200);
|
|
47
61
|
}
|
|
48
62
|
function worktreePathFor(deps, issue) {
|
|
49
63
|
return path.join(deps.worktreesDir, `issue-${issue.id}`);
|
|
50
64
|
}
|
|
65
|
+
function specWorktreePathFor(deps, issue) {
|
|
66
|
+
return path.join(deps.worktreesDir, `spec-${issue.id}`);
|
|
67
|
+
}
|
|
51
68
|
function cleanupWorktree(deps, marker) {
|
|
52
69
|
if (!marker)
|
|
53
70
|
return;
|
|
@@ -92,6 +109,13 @@ export async function reconcileOrphans(deps, state) {
|
|
|
92
109
|
}
|
|
93
110
|
else {
|
|
94
111
|
deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
112
|
+
deps.notify({
|
|
113
|
+
kind: "issue_blocked",
|
|
114
|
+
level: "error",
|
|
115
|
+
title: `issue ${issue.id} blocked`,
|
|
116
|
+
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
|
|
117
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
118
|
+
});
|
|
95
119
|
if (!deps.dryRun) {
|
|
96
120
|
await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
|
|
97
121
|
cleanupWorktree(deps, marker);
|
|
@@ -99,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
|
|
|
99
123
|
}
|
|
100
124
|
}
|
|
101
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Post the summary comment on a decomposed spec and transition it to `done`
|
|
128
|
+
* — the refine lane's one shared finishing move, reached from both the
|
|
129
|
+
* normal path (`runSpec`, right after a successful publish) and the
|
|
130
|
+
* orphan-resume path (`reconcileRefining`, when a completed publish's
|
|
131
|
+
* marker survived a crash the `transition` itself didn't). `created` only
|
|
132
|
+
* has titles/kinds in the normal path — an orphan resume has nothing but
|
|
133
|
+
* the ids `WatchMarker.refined` recorded, and the comment degrades to a
|
|
134
|
+
* bare list of `#id`s rather than blocking on a re-fetch.
|
|
135
|
+
*/
|
|
136
|
+
async function finishSpec(deps, issue, created) {
|
|
137
|
+
const body = created.length > 0
|
|
138
|
+
? `spf watch refined this spec into ${created.length} issue(s):\n\n` +
|
|
139
|
+
created.map((c) => (c.title ? `- #${c.id} (${c.kind}): ${c.title}` : `- #${c.id}`)).join("\n") +
|
|
140
|
+
`\n\nPromote any of them to \`${deps.labelPrefix}:ready\` when it's worth building.`
|
|
141
|
+
: `spf watch refined this spec but the refiner produced no issues.`;
|
|
142
|
+
deps.notify({
|
|
143
|
+
kind: "spec_refined",
|
|
144
|
+
level: "info",
|
|
145
|
+
title: `spec ${issue.id} refined`,
|
|
146
|
+
detail: `${created.length} issue(s) created.`,
|
|
147
|
+
fields: [["issue", issue.id], ["title", issue.title], ["created", String(created.length)]],
|
|
148
|
+
});
|
|
149
|
+
if (!deps.dryRun) {
|
|
150
|
+
await deps.provider.comment(issue, body);
|
|
151
|
+
await deps.provider.transition(issue, "done");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
156
|
+
* process isn't tracking is either a completed publish that crashed before
|
|
157
|
+
* its own `transition(issue, "done")` ran (resume: finish it, no re-run),
|
|
158
|
+
* or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
|
|
159
|
+
* A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
|
|
160
|
+
*/
|
|
161
|
+
export async function reconcileRefining(deps, state) {
|
|
162
|
+
if (!deps.refineEnabled)
|
|
163
|
+
return;
|
|
164
|
+
const refining = await deps.provider.listInState("refining");
|
|
165
|
+
for (const issue of refining) {
|
|
166
|
+
if (state.refining.has(issue.id))
|
|
167
|
+
continue;
|
|
168
|
+
const marker = await deps.provider.readMarker(issue);
|
|
169
|
+
if (marker?.refined && marker.refined.length > 0) {
|
|
170
|
+
deps.log(`watch: spec ${issue.id} orphaned after publish already completed — finishing`);
|
|
171
|
+
await finishSpec(deps, issue, marker.refined.map((id) => ({ id })));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const attempt = (marker?.attempt ?? 0) + 1;
|
|
175
|
+
if (attempt <= MAX_ORPHAN_ATTEMPTS) {
|
|
176
|
+
deps.log(`watch: spec ${issue.id} orphaned mid-refine, retry ${attempt}/${MAX_ORPHAN_ATTEMPTS} — back to spec-ready`);
|
|
177
|
+
if (!deps.dryRun) {
|
|
178
|
+
await deps.provider.writeMarker(issue, { ...marker, attempt });
|
|
179
|
+
await deps.provider.transition(issue, "spec-ready");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
|
|
184
|
+
deps.notify({
|
|
185
|
+
kind: "issue_blocked",
|
|
186
|
+
level: "error",
|
|
187
|
+
title: `spec ${issue.id} blocked`,
|
|
188
|
+
detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
|
|
189
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
190
|
+
});
|
|
191
|
+
if (!deps.dryRun) {
|
|
192
|
+
await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`);
|
|
193
|
+
cleanupWorktree(deps, marker);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
102
198
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
103
199
|
export async function finishReviews(deps) {
|
|
104
200
|
const reviewing = await deps.provider.listInState("review", { includeAll: true });
|
|
@@ -109,6 +205,13 @@ export async function finishReviews(deps) {
|
|
|
109
205
|
const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
|
|
110
206
|
if (status.merged) {
|
|
111
207
|
deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
|
|
208
|
+
deps.notify({
|
|
209
|
+
kind: "issue_done",
|
|
210
|
+
level: "info",
|
|
211
|
+
title: `issue ${issue.id} done`,
|
|
212
|
+
detail: `PR #${marker.pr} merged.`,
|
|
213
|
+
fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
|
|
214
|
+
});
|
|
112
215
|
if (!deps.dryRun) {
|
|
113
216
|
await deps.provider.transition(issue, "done");
|
|
114
217
|
cleanupWorktree(deps, marker);
|
|
@@ -116,6 +219,13 @@ export async function finishReviews(deps) {
|
|
|
116
219
|
}
|
|
117
220
|
else if (status.state === "closed") {
|
|
118
221
|
deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
|
|
222
|
+
deps.notify({
|
|
223
|
+
kind: "issue_blocked",
|
|
224
|
+
level: "error",
|
|
225
|
+
title: `issue ${issue.id} blocked`,
|
|
226
|
+
detail: `PR #${marker.pr} was closed without merging.`,
|
|
227
|
+
fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
|
|
228
|
+
});
|
|
119
229
|
if (!deps.dryRun) {
|
|
120
230
|
await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
|
|
121
231
|
cleanupWorktree(deps, marker);
|
|
@@ -147,13 +257,28 @@ async function runIssue(deps, issue) {
|
|
|
147
257
|
const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
|
|
148
258
|
if (!result.accepted) {
|
|
149
259
|
deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
|
|
150
|
-
|
|
260
|
+
const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
261
|
+
deps.notify({
|
|
262
|
+
kind: "issue_blocked",
|
|
263
|
+
level: "error",
|
|
264
|
+
title: `issue ${issue.id} blocked`,
|
|
265
|
+
detail,
|
|
266
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
267
|
+
});
|
|
268
|
+
await deps.provider.transition(issue, "blocked", detail);
|
|
151
269
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
152
270
|
return;
|
|
153
271
|
}
|
|
154
272
|
const wtGit = deps.worktreeGit(worktreePath);
|
|
155
273
|
if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
|
|
156
274
|
deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
|
|
275
|
+
deps.notify({
|
|
276
|
+
kind: "issue_blocked",
|
|
277
|
+
level: "error",
|
|
278
|
+
title: `issue ${issue.id} blocked`,
|
|
279
|
+
detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
|
|
280
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
|
|
281
|
+
});
|
|
157
282
|
await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
|
|
158
283
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
159
284
|
return;
|
|
@@ -173,14 +298,94 @@ async function runIssue(deps, issue) {
|
|
|
173
298
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
174
299
|
await deps.provider.transition(issue, "review");
|
|
175
300
|
deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
|
|
301
|
+
deps.notify({
|
|
302
|
+
kind: "pr_opened",
|
|
303
|
+
level: "info",
|
|
304
|
+
title: `PR #${pr.number} opened`,
|
|
305
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
|
|
306
|
+
url: pr.url || undefined,
|
|
307
|
+
});
|
|
176
308
|
}
|
|
177
309
|
catch (error) {
|
|
178
310
|
const message = error.message;
|
|
179
311
|
deps.log(`watch: ${issue.id}: error: ${message}`);
|
|
312
|
+
deps.notify({
|
|
313
|
+
kind: "watch_error",
|
|
314
|
+
level: "error",
|
|
315
|
+
title: `issue ${issue.id} errored`,
|
|
316
|
+
detail: message,
|
|
317
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
318
|
+
});
|
|
180
319
|
await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
|
|
181
320
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
182
321
|
}
|
|
183
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* One spec's full claim -> decompose -> publish path, run in the background
|
|
325
|
+
* — `claimSpecs` doesn't await this. The build lane's `runIssue`, minus the
|
|
326
|
+
* PR half: no `diffFiles` check (the refiner has `writes: []`, so an empty
|
|
327
|
+
* diff is the CORRECT outcome, not a failure), no push, no `openPr`. Its
|
|
328
|
+
* mirror image is "publish, then finish" instead of "commit, then review".
|
|
329
|
+
*/
|
|
330
|
+
async function runSpec(deps, issue) {
|
|
331
|
+
const branch = refineBranchNameFor(issue);
|
|
332
|
+
const worktreePath = specWorktreePathFor(deps, issue);
|
|
333
|
+
const adwId = `spec-${issue.id}`;
|
|
334
|
+
try {
|
|
335
|
+
// A spec re-claimed after a completed publish (the transition/comment
|
|
336
|
+
// that should have followed never ran — a crash, a kill) already has
|
|
337
|
+
// its answer on disk: skip straight to finishing rather than asking the
|
|
338
|
+
// refiner to redo work that already exists on the tracker. `to-tickets`
|
|
339
|
+
// (the skill this lane's prompt is ported from) has no such guard and
|
|
340
|
+
// would duplicate every issue on a re-run.
|
|
341
|
+
const existingMarker = await deps.provider.readMarker(issue);
|
|
342
|
+
if (existingMarker?.refined && existingMarker.refined.length > 0) {
|
|
343
|
+
deps.log(`watch: spec ${issue.id}: a previous attempt already published ${existingMarker.refined.length} issue(s) — finishing without re-running the refiner`);
|
|
344
|
+
await finishSpec(deps, issue, existingMarker.refined.map((id) => ({ id })));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// See runIssue's identical comment: worktreePath/branch are deterministic
|
|
348
|
+
// from issue.id, so a leftover from a killed prior attempt is the only
|
|
349
|
+
// way either could already exist — clear it unconditionally.
|
|
350
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
351
|
+
deps.git.fetch("origin", deps.baseBranch);
|
|
352
|
+
deps.git.worktreeAdd(worktreePath, branch, `origin/${deps.baseBranch}`);
|
|
353
|
+
deps.linkDataDir(worktreePath);
|
|
354
|
+
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0 });
|
|
355
|
+
const prompt = `${issue.title}\n\n${issue.body}`.trim();
|
|
356
|
+
const result = await deps.runRefine({ prompt, cwd: worktreePath, adwId, issueId: issue.id });
|
|
357
|
+
if (!result.accepted) {
|
|
358
|
+
deps.log(`watch: spec ${issue.id}: refine chain "${deps.refineChain}" did not succeed — blocked`);
|
|
359
|
+
const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
|
|
360
|
+
deps.notify({
|
|
361
|
+
kind: "issue_blocked",
|
|
362
|
+
level: "error",
|
|
363
|
+
title: `spec ${issue.id} blocked`,
|
|
364
|
+
detail,
|
|
365
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
|
|
366
|
+
});
|
|
367
|
+
await deps.provider.transition(issue, "blocked", detail);
|
|
368
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, attempt: 0, refined: result.created.map((c) => c.id) });
|
|
372
|
+
await finishSpec(deps, issue, result.created);
|
|
373
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
const message = error.message;
|
|
377
|
+
deps.log(`watch: spec ${issue.id}: refine error: ${message}`);
|
|
378
|
+
deps.notify({
|
|
379
|
+
kind: "watch_error",
|
|
380
|
+
level: "error",
|
|
381
|
+
title: `spec ${issue.id} errored`,
|
|
382
|
+
detail: message,
|
|
383
|
+
fields: [["issue", issue.id], ["title", issue.title]],
|
|
384
|
+
});
|
|
385
|
+
await deps.provider.transition(issue, "blocked", `spf watch refine error: ${message}`).catch(() => undefined);
|
|
386
|
+
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
184
389
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
185
390
|
export async function claimNewWork(deps, state) {
|
|
186
391
|
if (state.inflight.size >= deps.concurrency)
|
|
@@ -201,13 +406,60 @@ export async function claimNewWork(deps, state) {
|
|
|
201
406
|
continue;
|
|
202
407
|
}
|
|
203
408
|
deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
|
|
409
|
+
deps.notify({
|
|
410
|
+
kind: "issue_claimed",
|
|
411
|
+
level: "info",
|
|
412
|
+
title: `issue ${issue.id} claimed`,
|
|
413
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
|
|
414
|
+
});
|
|
204
415
|
state.inflight.add(issue.id);
|
|
205
416
|
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
206
417
|
}
|
|
207
418
|
}
|
|
208
|
-
/**
|
|
419
|
+
/** Claim as many `spec-ready` specs as `refineConcurrency` allows, and kick off `runSpec` for each in the background. A no-op when `watch.refine` is off. */
|
|
420
|
+
export async function claimSpecs(deps, state) {
|
|
421
|
+
if (!deps.refineEnabled)
|
|
422
|
+
return;
|
|
423
|
+
if (state.refining.size >= deps.refineConcurrency)
|
|
424
|
+
return;
|
|
425
|
+
const eligible = await deps.provider.listInState("spec-ready");
|
|
426
|
+
for (const issue of eligible) {
|
|
427
|
+
if (state.refining.size >= deps.refineConcurrency)
|
|
428
|
+
break;
|
|
429
|
+
if (state.refining.has(issue.id))
|
|
430
|
+
continue;
|
|
431
|
+
if (deps.dryRun) {
|
|
432
|
+
deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const claimed = await deps.provider.claim(issue, { from: "spec-ready", to: "refining" });
|
|
436
|
+
if (!claimed) {
|
|
437
|
+
deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
deps.log(`watch: claimed spec ${issue.id}: ${issue.title}`);
|
|
441
|
+
deps.notify({
|
|
442
|
+
kind: "issue_claimed",
|
|
443
|
+
level: "info",
|
|
444
|
+
title: `spec ${issue.id} claimed`,
|
|
445
|
+
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain]],
|
|
446
|
+
});
|
|
447
|
+
state.refining.add(issue.id);
|
|
448
|
+
runSpec(deps, issue).finally(() => state.refining.delete(issue.id));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function tickErrorHandler(deps, stage) {
|
|
452
|
+
return (error) => {
|
|
453
|
+
const message = error.message;
|
|
454
|
+
deps.log(`watch: ${stage} error: ${message}`);
|
|
455
|
+
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** One poll tick: reconcile both lanes, finish reviews, then claim both lanes — each stage independently caught, so one stage's error never blocks the rest. */
|
|
209
459
|
export async function tick(deps, state) {
|
|
210
|
-
await reconcileOrphans(deps, state).catch((
|
|
211
|
-
await
|
|
212
|
-
await
|
|
460
|
+
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
461
|
+
await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
|
|
462
|
+
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
463
|
+
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
464
|
+
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
213
465
|
}
|
package/dist/test/chains.test.js
CHANGED
|
@@ -41,6 +41,7 @@ const EXPECTED = {
|
|
|
41
41
|
},
|
|
42
42
|
quality: { phases: "engineer(request) -> code(quality)", agents: [], suites: ["all"] },
|
|
43
43
|
document: { phases: "engineer(request) -> code(changes) -> documenter", agents: ["documenter"], suites: [] },
|
|
44
|
+
refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
|
|
44
45
|
"simple-sdlc": {
|
|
45
46
|
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
46
47
|
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
|
|
@@ -9,9 +9,13 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { test } from "node:test";
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
12
15
|
import * as v from "valibot";
|
|
13
16
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
14
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
17
|
+
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
+
import { loadConfig } from "../core/agents.js";
|
|
15
19
|
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
16
20
|
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
17
21
|
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
@@ -47,3 +51,32 @@ test("every envelope type still converts to JSON Schema (the sf_report tool wiri
|
|
|
47
51
|
test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
|
|
48
52
|
assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
|
|
49
53
|
});
|
|
54
|
+
test("NotificationsConfigSchema defaults to off, no channels", () => {
|
|
55
|
+
const parsed = v.parse(NotificationsConfigSchema, {});
|
|
56
|
+
assert.equal(parsed.events, "off");
|
|
57
|
+
assert.equal(parsed.timeout_ms, 5_000);
|
|
58
|
+
assert.deepEqual(parsed.channels, []);
|
|
59
|
+
});
|
|
60
|
+
test("a channel's own `events` overrides the top-level scope; unset inherits it", () => {
|
|
61
|
+
const parsed = v.parse(NotificationsConfigSchema, {
|
|
62
|
+
events: "errors",
|
|
63
|
+
channels: [{ kind: "slack" }, { kind: "teams", events: "all" }],
|
|
64
|
+
});
|
|
65
|
+
assert.equal(parsed.channels[0].events, undefined, "unset per-channel scope stays undefined, not defaulted to the top-level value — the caller inherits at read time");
|
|
66
|
+
assert.equal(parsed.channels[1].events, "all");
|
|
67
|
+
});
|
|
68
|
+
test("notifications survives loadConfig's merge — key-by-key like observability/quality, channels replaced wholesale on override", () => {
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-notify-merge-test-"));
|
|
70
|
+
try {
|
|
71
|
+
const base = join(dir, "base.yaml");
|
|
72
|
+
const override = join(dir, "override.yaml");
|
|
73
|
+
writeFileSync(base, "notifications:\n events: off\n channels:\n - {kind: webhook, webhook_url_env: BASE_HOOK}\n");
|
|
74
|
+
writeFileSync(override, "notifications:\n events: all\n channels:\n - {kind: slack, webhook_url_env: SLACK_WEBHOOK_URL}\n");
|
|
75
|
+
const cfg = loadConfig([base, override]);
|
|
76
|
+
assert.equal(cfg.notifications.events, "all", "events: key-by-key, override wins");
|
|
77
|
+
assert.deepEqual(cfg.notifications.channels.map((c) => c.kind), ["slack"], "channels: a whole-array replace, not an append — same semantics as quality.checks");
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
@@ -64,3 +64,20 @@ test("never writes .env or .env.example on the non-interactive paths", async ()
|
|
|
64
64
|
assert.equal(existsSync(join(dir, ".env")), false);
|
|
65
65
|
assert.equal(existsSync(join(dir, ".env.example")), false);
|
|
66
66
|
});
|
|
67
|
+
test("also installs the repo-local Claude Code skill by default, on every non-interactive path", async () => {
|
|
68
|
+
const code = await initCommand(["--cwd", dir, "--yes"]);
|
|
69
|
+
assert.equal(code, 0);
|
|
70
|
+
assert.ok(existsSync(join(dir, ".claude", "skills", "spf", "SKILL.md")), "spf init should install the skill unless --no-skills is passed");
|
|
71
|
+
});
|
|
72
|
+
test("--no-skills skips the skill install", async () => {
|
|
73
|
+
const code = await initCommand(["--cwd", dir, "--yes", "--no-skills"]);
|
|
74
|
+
assert.equal(code, 0);
|
|
75
|
+
assert.equal(existsSync(join(dir, ".claude", "skills", "spf")), false);
|
|
76
|
+
});
|
|
77
|
+
test("re-running spf init doesn't re-copy an unchanged skill install (install-skill's own idempotency)", async () => {
|
|
78
|
+
await initCommand(["--cwd", dir, "--yes"]);
|
|
79
|
+
const manifestPath = join(dir, ".claude", "skills", "spf", ".spf-skill-version");
|
|
80
|
+
const before = readFileSync(manifestPath, "utf-8");
|
|
81
|
+
await initCommand(["--cwd", dir, "--template", "ts-cc"]); // no --force: config write is a no-op, skill install still runs
|
|
82
|
+
assert.equal(readFileSync(manifestPath, "utf-8"), before);
|
|
83
|
+
});
|
|
@@ -151,6 +151,125 @@ test("declining the final confirm returns null — nothing to write", async () =
|
|
|
151
151
|
const result = await runInterview(asker, ctx);
|
|
152
152
|
assert.equal(result, null);
|
|
153
153
|
});
|
|
154
|
+
test("notifications: declining the gate leaves config.notifications undefined", async () => {
|
|
155
|
+
const ctx = gatherContext(dir, new Map());
|
|
156
|
+
const asker = createFakeAsker({
|
|
157
|
+
select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
|
|
158
|
+
confirm: {
|
|
159
|
+
'Add a "typecheck"': false,
|
|
160
|
+
'Add a "lint"': false,
|
|
161
|
+
'Add a "build"': false,
|
|
162
|
+
'Add a "test"': false,
|
|
163
|
+
"Enable spf watch": false,
|
|
164
|
+
"Send notifications": false,
|
|
165
|
+
"Configure advanced": false,
|
|
166
|
+
"Write .spf": true,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const result = await runInterview(asker, ctx);
|
|
170
|
+
assert.ok(result);
|
|
171
|
+
const config = result.config;
|
|
172
|
+
assert.equal(config.notifications, undefined);
|
|
173
|
+
});
|
|
174
|
+
test("notifications: accepting collects the scope, a channel, and its webhook env key", async () => {
|
|
175
|
+
const ctx = gatherContext(dir, new Map());
|
|
176
|
+
const asker = createFakeAsker({
|
|
177
|
+
select: {
|
|
178
|
+
"backend runs": "claude_code",
|
|
179
|
+
"Model (Claude": "sonnet",
|
|
180
|
+
Authentication: "login",
|
|
181
|
+
"Notify on": "all",
|
|
182
|
+
Channel: "slack",
|
|
183
|
+
},
|
|
184
|
+
confirm: {
|
|
185
|
+
'Add a "typecheck"': false,
|
|
186
|
+
'Add a "lint"': false,
|
|
187
|
+
'Add a "build"': false,
|
|
188
|
+
'Add a "test"': false,
|
|
189
|
+
"Enable spf watch": false,
|
|
190
|
+
"Send notifications": true,
|
|
191
|
+
"Add another channel": false,
|
|
192
|
+
"Configure advanced": false,
|
|
193
|
+
"Write .spf": true,
|
|
194
|
+
},
|
|
195
|
+
secret: { SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXXX" },
|
|
196
|
+
});
|
|
197
|
+
const result = await runInterview(asker, ctx);
|
|
198
|
+
assert.ok(result);
|
|
199
|
+
const config = result.config;
|
|
200
|
+
assert.equal(config.notifications.events, "all");
|
|
201
|
+
assert.deepEqual(config.notifications.channels, [{ kind: "slack", webhook_url_env: "SLACK_WEBHOOK_URL" }]);
|
|
202
|
+
assert.equal(result.env.SLACK_WEBHOOK_URL, "https://hooks.slack.com/services/T000/B000/XXXX");
|
|
203
|
+
assert.ok(result.envExampleKeys.includes("SLACK_WEBHOOK_URL"));
|
|
204
|
+
// The written document must still merge and validate cleanly, same
|
|
205
|
+
// pipeline `spf doctor` runs.
|
|
206
|
+
const configPath = mergedConfigPath();
|
|
207
|
+
const { stringify } = await import("yaml");
|
|
208
|
+
writeFileSync(configPath, stringify(config));
|
|
209
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
210
|
+
assert.equal(cfg.notifications.events, "all");
|
|
211
|
+
assert.equal(cfg.notifications.channels[0].kind, "slack");
|
|
212
|
+
});
|
|
213
|
+
test("customize models per agent: declining keeps today's behavior — only the three pinned agents, all matching defaults.model", async () => {
|
|
214
|
+
const ctx = gatherContext(dir, new Map());
|
|
215
|
+
const asker = createFakeAsker({
|
|
216
|
+
select: { "backend runs": "claude_code", "Model (Claude": "opus", Authentication: "login" },
|
|
217
|
+
confirm: {
|
|
218
|
+
'Add a "typecheck"': false,
|
|
219
|
+
'Add a "lint"': false,
|
|
220
|
+
'Add a "build"': false,
|
|
221
|
+
'Add a "test"': false,
|
|
222
|
+
"Enable spf watch": false,
|
|
223
|
+
"Customize models per agent": false,
|
|
224
|
+
"Send notifications": false,
|
|
225
|
+
"Configure advanced": false,
|
|
226
|
+
"Write .spf": true,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const result = await runInterview(asker, ctx);
|
|
230
|
+
assert.ok(result);
|
|
231
|
+
const config = result.config;
|
|
232
|
+
assert.deepEqual(config.agents.map((a) => a.name).sort(), ["documenter", "planner", "reviewer"]);
|
|
233
|
+
for (const a of config.agents)
|
|
234
|
+
assert.equal(a.model, "opus");
|
|
235
|
+
});
|
|
236
|
+
test("customize models per agent: accepting patches the pinned three and appends builder/scout/refiner", async () => {
|
|
237
|
+
const ctx = gatherContext(dir, new Map());
|
|
238
|
+
assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "refiner", "reviewer", "scout"]);
|
|
239
|
+
const asker = createFakeAsker({
|
|
240
|
+
select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
|
|
241
|
+
text: {
|
|
242
|
+
" planner": "opus",
|
|
243
|
+
" builder": "sonnet",
|
|
244
|
+
" scout": "haiku",
|
|
245
|
+
" refiner": "opus",
|
|
246
|
+
" reviewer": "opus",
|
|
247
|
+
" documenter": "sonnet",
|
|
248
|
+
},
|
|
249
|
+
confirm: {
|
|
250
|
+
'Add a "typecheck"': false,
|
|
251
|
+
'Add a "lint"': false,
|
|
252
|
+
'Add a "build"': false,
|
|
253
|
+
'Add a "test"': false,
|
|
254
|
+
"Enable spf watch": false,
|
|
255
|
+
"Customize models per agent": true,
|
|
256
|
+
"Send notifications": false,
|
|
257
|
+
"Configure advanced": false,
|
|
258
|
+
"Write .spf": true,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const result = await runInterview(asker, ctx);
|
|
262
|
+
assert.ok(result);
|
|
263
|
+
const config = result.config;
|
|
264
|
+
const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
|
|
265
|
+
assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku", refiner: "opus" });
|
|
266
|
+
const configPath = mergedConfigPath();
|
|
267
|
+
const { stringify } = await import("yaml");
|
|
268
|
+
writeFileSync(configPath, stringify(config));
|
|
269
|
+
const cfg = loadConfig([BUILTIN_CONFIG_PATH, configPath]);
|
|
270
|
+
validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), dir); // throws on any problem
|
|
271
|
+
assert.equal(cfg.agents.find((a) => a.name === "scout").model, "haiku");
|
|
272
|
+
});
|
|
154
273
|
test("an existing .env value is offered back as the default when a secret is left blank", async () => {
|
|
155
274
|
const ctx = gatherContext(dir, new Map([["GITHUB_TOKEN", "ghp_existingvalue"]]));
|
|
156
275
|
const asker = createFakeAsker({
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|