@gr8ful/spf 0.3.0 → 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 +55 -5
- 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 +38 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts.spf.config.yaml +10 -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 +6 -0
- package/dist/cli/commands/init.js +30 -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 +69 -8
- package/dist/cli/index.js +3 -3
- package/dist/cli/interview.js +17 -0
- package/dist/core/data_types.d.ts +78 -0
- package/dist/core/data_types.js +42 -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 +1 -1
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +144 -0
- package/dist/core/watch.d.ts +56 -1
- package/dist/core/watch.js +200 -11
- package/dist/test/chains.test.js +1 -0
- package/dist/test/init_command.test.js +17 -0
- package/dist/test/interview.test.js +4 -3
- package/dist/test/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +126 -0
- package/dist/test/watch.test.js +173 -5
- package/package.json +1 -1
package/dist/core/watch.d.ts
CHANGED
|
@@ -7,6 +7,21 @@ export interface ChainRunResult {
|
|
|
7
7
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
8
8
|
detail: string;
|
|
9
9
|
}
|
|
10
|
+
/** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
|
|
11
|
+
export interface RefinedIssueRef {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
kind: string;
|
|
15
|
+
isLeaf: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface RefineRunResult {
|
|
18
|
+
accepted: boolean;
|
|
19
|
+
adwId: string;
|
|
20
|
+
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
21
|
+
detail: string;
|
|
22
|
+
/** What `steps.publishIssues()` created, read back from its side-channel file — see `cli/commands/watch.ts`'s `runRefine`. Empty when `!accepted`. */
|
|
23
|
+
created: RefinedIssueRef[];
|
|
24
|
+
}
|
|
10
25
|
export interface WatchDeps {
|
|
11
26
|
provider: IssueProvider;
|
|
12
27
|
codeHost: CodeHostProvider;
|
|
@@ -17,6 +32,23 @@ export interface WatchDeps {
|
|
|
17
32
|
chain: string;
|
|
18
33
|
baseBranch: string;
|
|
19
34
|
concurrency: number;
|
|
35
|
+
/**
|
|
36
|
+
* The second lane — decomposing a `<prefix>:spec-ready` product spec
|
|
37
|
+
* instead of building a `<prefix>:ready` issue. `false` (the default) is
|
|
38
|
+
* a complete no-op: `claimSpecs`/`reconcileRefining` return immediately,
|
|
39
|
+
* so an existing `spf watch` config sees no new poll traffic at all until
|
|
40
|
+
* this is turned on. See `WatchConfigSchema`'s `refine` field.
|
|
41
|
+
*/
|
|
42
|
+
refineEnabled: boolean;
|
|
43
|
+
refineConcurrency: number;
|
|
44
|
+
refineChain: string;
|
|
45
|
+
/** Same shape as `runChain`, for the refine lane — see its own doc comment for why the two aren't unified into one callback. */
|
|
46
|
+
runRefine: (opts: {
|
|
47
|
+
prompt: string;
|
|
48
|
+
cwd: string;
|
|
49
|
+
adwId: string;
|
|
50
|
+
issueId: string;
|
|
51
|
+
}) => Promise<RefineRunResult>;
|
|
20
52
|
worktreesDir: string;
|
|
21
53
|
/**
|
|
22
54
|
* Symlink (or otherwise wire up) `<worktreePath>/.spf/data` to the MAIN
|
|
@@ -49,9 +81,22 @@ export interface WatchDeps {
|
|
|
49
81
|
}
|
|
50
82
|
export interface WatchRunState {
|
|
51
83
|
inflight: Set<string>;
|
|
84
|
+
/**
|
|
85
|
+
* Separate from `inflight` — not a defensive copy of the same set, a
|
|
86
|
+
* genuinely different budget. The refine lane's `claimSpecs` caps against
|
|
87
|
+
* `refineConcurrency`, independent of the build lane's `concurrency`; a
|
|
88
|
+
* shared set would conflate "how many specs are being refined" with "how
|
|
89
|
+
* many issues are being built" and make either budget impossible to
|
|
90
|
+
* enforce on its own. The two never collide on an id in practice (a
|
|
91
|
+
* `spec-ready` issue and a `ready` issue are never the same issue), but
|
|
92
|
+
* that isn't why this is separate — the budgets are what require it.
|
|
93
|
+
*/
|
|
94
|
+
refining: Set<string>;
|
|
52
95
|
}
|
|
53
96
|
export declare function createWatchState(): WatchRunState;
|
|
54
97
|
export declare function branchNameFor(issue: Issue): string;
|
|
98
|
+
/** 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. */
|
|
99
|
+
export declare function refineBranchNameFor(issue: Issue): string;
|
|
55
100
|
/**
|
|
56
101
|
* Any issue labeled `working` that THIS process isn't tracking is an
|
|
57
102
|
* orphan — a daemon restart, or another instance's claim this process
|
|
@@ -59,9 +104,19 @@ export declare function branchNameFor(issue: Issue): string;
|
|
|
59
104
|
* merged) PR; otherwise retry up to `MAX_ORPHAN_ATTEMPTS`, then give up.
|
|
60
105
|
*/
|
|
61
106
|
export declare function reconcileOrphans(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* The refine lane's own `reconcileOrphans` — a `refining`-labeled spec this
|
|
109
|
+
* process isn't tracking is either a completed publish that crashed before
|
|
110
|
+
* its own `transition(issue, "done")` ran (resume: finish it, no re-run),
|
|
111
|
+
* or a genuine orphan (retry up to `MAX_ORPHAN_ATTEMPTS`, then give up).
|
|
112
|
+
* A no-op entirely when `watch.refine` is off — see `WatchDeps.refineEnabled`.
|
|
113
|
+
*/
|
|
114
|
+
export declare function reconcileRefining(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
62
115
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
63
116
|
export declare function finishReviews(deps: WatchDeps): Promise<void>;
|
|
64
117
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
65
118
|
export declare function claimNewWork(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
66
|
-
/**
|
|
119
|
+
/** 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. */
|
|
120
|
+
export declare function claimSpecs(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
|
121
|
+
/** 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. */
|
|
67
122
|
export declare function tick(deps: WatchDeps, state: WatchRunState): Promise<void>;
|
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;
|
|
@@ -106,6 +123,78 @@ export async function reconcileOrphans(deps, state) {
|
|
|
106
123
|
}
|
|
107
124
|
}
|
|
108
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
|
+
}
|
|
109
198
|
/** Poll every `review`-labeled issue's PR for merged (-> done) or closed-without-merging (-> blocked). */
|
|
110
199
|
export async function finishReviews(deps) {
|
|
111
200
|
const reviewing = await deps.provider.listInState("review", { includeAll: true });
|
|
@@ -231,6 +320,72 @@ async function runIssue(deps, issue) {
|
|
|
231
320
|
cleanupWorktree(deps, { worktree: worktreePath, branch });
|
|
232
321
|
}
|
|
233
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
|
+
}
|
|
234
389
|
/** Claim as many `ready` issues as the concurrency budget allows, and kick off `runIssue` for each in the background. */
|
|
235
390
|
export async function claimNewWork(deps, state) {
|
|
236
391
|
if (state.inflight.size >= deps.concurrency)
|
|
@@ -261,6 +416,38 @@ export async function claimNewWork(deps, state) {
|
|
|
261
416
|
runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
|
|
262
417
|
}
|
|
263
418
|
}
|
|
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
|
+
}
|
|
264
451
|
function tickErrorHandler(deps, stage) {
|
|
265
452
|
return (error) => {
|
|
266
453
|
const message = error.message;
|
|
@@ -268,9 +455,11 @@ function tickErrorHandler(deps, stage) {
|
|
|
268
455
|
deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
|
|
269
456
|
};
|
|
270
457
|
}
|
|
271
|
-
/** One poll tick: reconcile, finish, claim — each independently caught, so one
|
|
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. */
|
|
272
459
|
export async function tick(deps, state) {
|
|
273
460
|
await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
|
|
461
|
+
await reconcileRefining(deps, state).catch(tickErrorHandler(deps, "reconcileRefining"));
|
|
274
462
|
await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
|
|
463
|
+
await claimSpecs(deps, state).catch(tickErrorHandler(deps, "claimSpecs"));
|
|
275
464
|
await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
|
|
276
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) " +
|
|
@@ -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
|
+
});
|
|
@@ -233,15 +233,16 @@ test("customize models per agent: declining keeps today's behavior — only the
|
|
|
233
233
|
for (const a of config.agents)
|
|
234
234
|
assert.equal(a.model, "opus");
|
|
235
235
|
});
|
|
236
|
-
test("customize models per agent: accepting patches the pinned three and appends builder/scout", async () => {
|
|
236
|
+
test("customize models per agent: accepting patches the pinned three and appends builder/scout/refiner", async () => {
|
|
237
237
|
const ctx = gatherContext(dir, new Map());
|
|
238
|
-
assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "reviewer", "scout"]);
|
|
238
|
+
assert.deepEqual(ctx.rosterNames.slice().sort(), ["builder", "documenter", "planner", "refiner", "reviewer", "scout"]);
|
|
239
239
|
const asker = createFakeAsker({
|
|
240
240
|
select: { "backend runs": "claude_code", "Model (Claude": "sonnet", Authentication: "login" },
|
|
241
241
|
text: {
|
|
242
242
|
" planner": "opus",
|
|
243
243
|
" builder": "sonnet",
|
|
244
244
|
" scout": "haiku",
|
|
245
|
+
" refiner": "opus",
|
|
245
246
|
" reviewer": "opus",
|
|
246
247
|
" documenter": "sonnet",
|
|
247
248
|
},
|
|
@@ -261,7 +262,7 @@ test("customize models per agent: accepting patches the pinned three and appends
|
|
|
261
262
|
assert.ok(result);
|
|
262
263
|
const config = result.config;
|
|
263
264
|
const byName = Object.fromEntries(config.agents.map((a) => [a.name, a.model]));
|
|
264
|
-
assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku" });
|
|
265
|
+
assert.deepEqual(byName, { planner: "opus", reviewer: "opus", documenter: "sonnet", builder: "sonnet", scout: "haiku", refiner: "opus" });
|
|
265
266
|
const configPath = mergedConfigPath();
|
|
266
267
|
const { stringify } = await import("yaml");
|
|
267
268
|
writeFileSync(configPath, stringify(config));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { publish } from "../core/refine.js";
|
|
4
|
+
import { refinementWellFormed } from "../core/gates.js";
|
|
5
|
+
/** In-memory fake — exactly the seam `IssueAuthoringProvider` exists for. */
|
|
6
|
+
class FakeTracker {
|
|
7
|
+
created = [];
|
|
8
|
+
links = [];
|
|
9
|
+
nextId = 1;
|
|
10
|
+
async createIssue(input) {
|
|
11
|
+
this.created.push(input);
|
|
12
|
+
const id = String(this.nextId++);
|
|
13
|
+
return { id, internal_id: `db-${id}`, title: input.title, body: input.body, labels: input.labels };
|
|
14
|
+
}
|
|
15
|
+
async linkChild(parent, child) {
|
|
16
|
+
this.links.push({ parent: parent.id, child: child.id });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function node(overrides) {
|
|
20
|
+
return { body: "", parent: "", blocked_by: [], ...overrides };
|
|
21
|
+
}
|
|
22
|
+
// ── publish() ────────────────────────────────────────────────────────────
|
|
23
|
+
test("publish: creates a container before its children, and links them via linkChild", async () => {
|
|
24
|
+
const tracker = new FakeTracker();
|
|
25
|
+
const issues = [
|
|
26
|
+
node({ key: "S1", kind: "story", title: "Owner invites by email", parent: "F1" }),
|
|
27
|
+
node({ key: "F1", kind: "feature", title: "Team invitations" }),
|
|
28
|
+
];
|
|
29
|
+
const created = await publish(tracker, issues, { labelPrefix: "spf" });
|
|
30
|
+
assert.deepEqual(tracker.created.map((c) => c.title), ["Team invitations", "Owner invites by email"], "the parent must be created before its child");
|
|
31
|
+
assert.deepEqual(tracker.links, [{ parent: "1", child: "2" }]);
|
|
32
|
+
assert.deepEqual(created.map((c) => ({ key: c.key, isLeaf: c.isLeaf })), [
|
|
33
|
+
{ key: "F1", isLeaf: false },
|
|
34
|
+
{ key: "S1", isLeaf: true },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
test("publish: labels a leaf with its type AND spf:refined; a container gets only its type label", async () => {
|
|
38
|
+
const tracker = new FakeTracker();
|
|
39
|
+
const issues = [node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })];
|
|
40
|
+
await publish(tracker, issues, { labelPrefix: "spf" });
|
|
41
|
+
const feature = tracker.created.find((c) => c.title === "A feature");
|
|
42
|
+
const leaf = tracker.created.find((c) => c.title === "A leaf");
|
|
43
|
+
assert.deepEqual(feature.labels, ["spf:type:feature"]);
|
|
44
|
+
assert.deepEqual(leaf.labels, ["spf:type:story", "spf:refined"]);
|
|
45
|
+
});
|
|
46
|
+
test("publish: creates a blocker before what it blocks, and renders a real #n reference in the body", async () => {
|
|
47
|
+
const tracker = new FakeTracker();
|
|
48
|
+
const issues = [
|
|
49
|
+
node({ key: "S2", kind: "bug", title: "Fix the expiry check", blocked_by: ["S1"] }),
|
|
50
|
+
node({ key: "S1", kind: "story", title: "Owner invites by email" }),
|
|
51
|
+
];
|
|
52
|
+
const created = await publish(tracker, issues, { labelPrefix: "spf" });
|
|
53
|
+
const s1Id = created.find((c) => c.key === "S1").issue.id;
|
|
54
|
+
const s2 = tracker.created.find((c) => c.title === "Fix the expiry check");
|
|
55
|
+
assert.equal(created[0].key, "S1", "the blocker publishes first");
|
|
56
|
+
assert.match(s2.body, new RegExp(`## Blocked by\\n\\n- #${s1Id}`));
|
|
57
|
+
});
|
|
58
|
+
test("publish: a leaf with no blockers gets the 'None (can start immediately)' text", async () => {
|
|
59
|
+
const tracker = new FakeTracker();
|
|
60
|
+
const created = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
|
|
61
|
+
assert.match(created[0].issue.body, /## Blocked by\n\nNone \(can start immediately\)\./);
|
|
62
|
+
});
|
|
63
|
+
test("publish: renders '## Parent: #<id>' when a spec issue id is given, and omits it otherwise", async () => {
|
|
64
|
+
const tracker = new FakeTracker();
|
|
65
|
+
const withSpec = await publish(tracker, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf", specIssueId: "42" });
|
|
66
|
+
assert.match(withSpec[0].issue.body, /## Parent\n\nDecomposed from #42\./);
|
|
67
|
+
const tracker2 = new FakeTracker();
|
|
68
|
+
const withoutSpec = await publish(tracker2, [node({ key: "S1", kind: "story", title: "A leaf" })], { labelPrefix: "spf" });
|
|
69
|
+
assert.doesNotMatch(withoutSpec[0].issue.body, /## Parent/);
|
|
70
|
+
});
|
|
71
|
+
// ── gates.refinementWellFormed ───────────────────────────────────────────
|
|
72
|
+
function envelope(issues) {
|
|
73
|
+
return { status: "success", summary: "", artifacts: [], notes_for_next_agent: "", issues };
|
|
74
|
+
}
|
|
75
|
+
test("refinementWellFormed: an empty issues list fails", () => {
|
|
76
|
+
const report = refinementWellFormed(envelope([]), { repo_root: "/repo" });
|
|
77
|
+
assert.equal(report.passed, false);
|
|
78
|
+
});
|
|
79
|
+
test("refinementWellFormed: a well-formed feature/story tree with no blockers passes clean", () => {
|
|
80
|
+
const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
|
|
81
|
+
assert.equal(report.passed, true);
|
|
82
|
+
});
|
|
83
|
+
test("refinementWellFormed: a container mislabeled as a leaf kind fails", () => {
|
|
84
|
+
const report = refinementWellFormed(envelope([node({ key: "F1", kind: "story", title: "Should be a feature" }), node({ key: "S1", kind: "story", title: "A leaf", parent: "F1" })]), { repo_root: "/repo" });
|
|
85
|
+
assert.equal(report.passed, false);
|
|
86
|
+
assert.ok(report.violations.some((v) => v.includes("F1.kind")));
|
|
87
|
+
});
|
|
88
|
+
test("refinementWellFormed: a two-node parent cycle has no leaves AND fails the dependency-graph check", () => {
|
|
89
|
+
// A finite, ACYCLIC parent forest always has at least one leaf by
|
|
90
|
+
// construction (some node's children set is empty) — the only way to
|
|
91
|
+
// drive leafCount to zero is a cycle, which the dependency-graph check
|
|
92
|
+
// already independently rejects. This test exercises that overlap
|
|
93
|
+
// directly rather than asserting an "all containers, no leaves" shape
|
|
94
|
+
// that isn't otherwise reachable.
|
|
95
|
+
const report = refinementWellFormed(envelope([node({ key: "F1", kind: "feature", title: "A", parent: "F2" }), node({ key: "F2", kind: "feature", title: "B", parent: "F1" })]), { repo_root: "/repo" });
|
|
96
|
+
assert.equal(report.passed, false);
|
|
97
|
+
assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
|
|
98
|
+
assert.ok(report.violations.some((v) => v.toLowerCase().includes("leaves")));
|
|
99
|
+
});
|
|
100
|
+
test("refinementWellFormed: an unresolved parent key fails", () => {
|
|
101
|
+
const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "Orphaned", parent: "nonexistent" })]), {
|
|
102
|
+
repo_root: "/repo",
|
|
103
|
+
});
|
|
104
|
+
assert.equal(report.passed, false);
|
|
105
|
+
assert.ok(report.violations.some((v) => v.includes("parent")));
|
|
106
|
+
});
|
|
107
|
+
test("refinementWellFormed: an unresolved blocked_by key fails", () => {
|
|
108
|
+
const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A leaf", blocked_by: ["nonexistent"] })]), {
|
|
109
|
+
repo_root: "/repo",
|
|
110
|
+
});
|
|
111
|
+
assert.equal(report.passed, false);
|
|
112
|
+
assert.ok(report.violations.some((v) => v.includes("blocked_by")));
|
|
113
|
+
});
|
|
114
|
+
test("refinementWellFormed: a blocked_by cycle fails", () => {
|
|
115
|
+
const report = refinementWellFormed(envelope([
|
|
116
|
+
node({ key: "S1", kind: "story", title: "A", blocked_by: ["S2"] }),
|
|
117
|
+
node({ key: "S2", kind: "story", title: "B", blocked_by: ["S1"] }),
|
|
118
|
+
]), { repo_root: "/repo" });
|
|
119
|
+
assert.equal(report.passed, false);
|
|
120
|
+
assert.ok(report.violations.some((v) => v.toLowerCase().includes("cycle")));
|
|
121
|
+
});
|
|
122
|
+
test("refinementWellFormed: duplicate keys fail", () => {
|
|
123
|
+
const report = refinementWellFormed(envelope([node({ key: "S1", kind: "story", title: "A" }), node({ key: "S1", kind: "bug", title: "B" })]), { repo_root: "/repo" });
|
|
124
|
+
assert.equal(report.passed, false);
|
|
125
|
+
assert.ok(report.violations.some((v) => v.includes("duplicate")));
|
|
126
|
+
});
|