@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/cli/commands/run.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as paths from "../../core/paths.js";
|
|
3
3
|
import { parseCli, resolvePrompt } from "../../core/utils.js";
|
|
4
4
|
import { runChain } from "../../chains/index.js";
|
|
5
|
-
const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
|
|
5
|
+
const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base", "issue"];
|
|
6
6
|
export function usageFor(chain) {
|
|
7
7
|
return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
|
|
8
8
|
}
|
|
@@ -19,6 +19,10 @@ export async function dispatchChain(chain, argv) {
|
|
|
19
19
|
adw_id: options["adw-id"] ?? null,
|
|
20
20
|
cwd: anchor.cwd,
|
|
21
21
|
chain_name: chain.name,
|
|
22
|
+
// Only `refine` reads this (a "## Parent: #<id>" back-reference on
|
|
23
|
+
// every issue it creates — see ChainContext's doc comment); every
|
|
24
|
+
// other chain ignores it, so it's harmless to always pass through.
|
|
25
|
+
issue_id: options["issue"] ?? null,
|
|
22
26
|
};
|
|
23
27
|
const chainOptions = {};
|
|
24
28
|
if (options["agent"] !== undefined)
|
|
@@ -150,6 +150,21 @@ export async function watchCommand(argv) {
|
|
|
150
150
|
console.error(`watch.chain ${JSON.stringify(cfg.watch.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
151
151
|
return 1;
|
|
152
152
|
}
|
|
153
|
+
if (cfg.watch.refine.enabled) {
|
|
154
|
+
if (cfg.watch.issue_provider !== "github") {
|
|
155
|
+
// Fail loudly at startup, not silently every tick: JiraProvider
|
|
156
|
+
// doesn't implement IssueAuthoringProvider yet (see its module
|
|
157
|
+
// comment) — a refine lane that can never publish would otherwise
|
|
158
|
+
// just claim every spec-ready spec and block it, forever.
|
|
159
|
+
console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
|
|
160
|
+
`the refine lane needs "github" (issue authoring isn't implemented for Jira yet)`);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
|
163
|
+
if (!findChain(cfg.watch.refine.chain)) {
|
|
164
|
+
console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
153
168
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
154
169
|
const lockPath = path.join(dataPaths.data_dir, "watch.lock");
|
|
155
170
|
try {
|
|
@@ -186,25 +201,65 @@ export async function watchCommand(argv) {
|
|
|
186
201
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
187
202
|
symlinkSync(dataPaths.data_dir, target, "dir");
|
|
188
203
|
}
|
|
204
|
+
/** Shared by `runChain`/`runRefine`: best-effort enrichment of a generic "didn't succeed" message with the first phase that actually failed, read back from the worktree's own (symlinked) trace db. */
|
|
205
|
+
function detailFromFailedPhase(cwd, adwId, prefix) {
|
|
206
|
+
let detail = prefix;
|
|
207
|
+
try {
|
|
208
|
+
const wtAnchor = paths.resolveAnchor(cwd);
|
|
209
|
+
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
210
|
+
const db = new SfDb(wtDataPaths.db_path);
|
|
211
|
+
const failed = db.phases(adwId).find((p) => p.status === "fail");
|
|
212
|
+
if (failed)
|
|
213
|
+
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// best-effort — the generic message above still points at where to look
|
|
217
|
+
}
|
|
218
|
+
return detail;
|
|
219
|
+
}
|
|
189
220
|
const runChain = async (opts) => {
|
|
190
221
|
const chainDef = findChain(cfg.watch.chain); // checked above
|
|
191
222
|
const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
|
|
192
223
|
const code = await runChainDef(chainDef, ctx);
|
|
193
224
|
if (code === 0)
|
|
194
225
|
return { accepted: true, adwId: opts.adwId, detail: "" };
|
|
195
|
-
|
|
226
|
+
const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
227
|
+
return { accepted: false, adwId: opts.adwId, detail };
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* Same shape as `runChain`, for the refine lane — with one extra step on
|
|
231
|
+
* success: a chain's return value is just an exit code, so the created-
|
|
232
|
+
* issues list `steps.publishIssues()` actually produced has to come back
|
|
233
|
+
* through the side channel it wrote (`refine_publish.json`, under the
|
|
234
|
+
* SAME symlinked session dir `linkDataDir` already wires up), not through
|
|
235
|
+
* `runChainDef`'s return value.
|
|
236
|
+
*/
|
|
237
|
+
const runRefine = async (opts) => {
|
|
238
|
+
const chainDef = findChain(cfg.watch.refine.chain); // checked above
|
|
239
|
+
const ctx = {
|
|
240
|
+
prompt: opts.prompt,
|
|
241
|
+
config_paths: configPaths,
|
|
242
|
+
adw_id: opts.adwId,
|
|
243
|
+
cwd: opts.cwd,
|
|
244
|
+
chain_name: chainDef.name,
|
|
245
|
+
issue_id: opts.issueId,
|
|
246
|
+
};
|
|
247
|
+
const code = await runChainDef(chainDef, ctx);
|
|
248
|
+
if (code !== 0) {
|
|
249
|
+
const detail = detailFromFailedPhase(opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
250
|
+
return { accepted: false, adwId: opts.adwId, detail, created: [] };
|
|
251
|
+
}
|
|
252
|
+
let created = [];
|
|
196
253
|
try {
|
|
197
254
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
198
255
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
if (failed)
|
|
202
|
-
detail += ` Phase "${failed.name}" failed: ${failed.error ?? "(no detail)"}`;
|
|
256
|
+
const summaryPath = path.join(wtDataPaths.data_dir, "sessions", opts.adwId, "context_handoff", "refine_publish.json");
|
|
257
|
+
created = JSON.parse(readFileSync(summaryPath, "utf-8"));
|
|
203
258
|
}
|
|
204
259
|
catch {
|
|
205
|
-
// best-effort —
|
|
260
|
+
// best-effort — an empty list still lets runSpec finish cleanly, just with no per-issue summary
|
|
206
261
|
}
|
|
207
|
-
return { accepted:
|
|
262
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created };
|
|
208
263
|
};
|
|
209
264
|
const deps = {
|
|
210
265
|
provider,
|
|
@@ -215,6 +270,10 @@ export async function watchCommand(argv) {
|
|
|
215
270
|
chain: cfg.watch.chain,
|
|
216
271
|
baseBranch: cfg.watch.base_branch,
|
|
217
272
|
concurrency: cfg.watch.concurrency,
|
|
273
|
+
refineEnabled: cfg.watch.refine.enabled,
|
|
274
|
+
refineConcurrency: cfg.watch.refine.concurrency,
|
|
275
|
+
refineChain: cfg.watch.refine.chain,
|
|
276
|
+
runRefine,
|
|
218
277
|
worktreesDir,
|
|
219
278
|
linkDataDir,
|
|
220
279
|
dryRun: Boolean(flags["dry-run"]),
|
|
@@ -254,7 +313,9 @@ export async function watchCommand(argv) {
|
|
|
254
313
|
};
|
|
255
314
|
process.on("SIGINT", stop);
|
|
256
315
|
process.on("SIGTERM", stop);
|
|
257
|
-
console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}
|
|
316
|
+
console.log(`[spf] watch ${cfg.watch.issue_provider}+${cfg.watch.code_host} ${cfg.watch.repo} label "${cfg.watch.label_prefix}:*" chain "${cfg.watch.chain}" concurrency ${cfg.watch.concurrency}` +
|
|
317
|
+
(cfg.watch.refine.enabled ? ` refine "${cfg.watch.refine.chain}" concurrency ${cfg.watch.refine.concurrency}` : "") +
|
|
318
|
+
(flags["dry-run"] ? " (dry run)" : ""));
|
|
258
319
|
deps.notify({
|
|
259
320
|
kind: "watch_started",
|
|
260
321
|
level: "info",
|
package/dist/cli/index.js
CHANGED
|
@@ -28,8 +28,8 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
28
28
|
|
|
29
29
|
spf list the chain registry — names, phases, what each needs
|
|
30
30
|
spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
|
|
31
|
-
spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
|
|
32
|
-
spf install-skill [--user] [--force] install the Claude Code skill
|
|
31
|
+
spf init [--force] [--yes] [--template <name>] [--no-skills] interview to seed .spf/spf.config.yaml + .env, and install the Claude Code skill unless --no-skills (--yes/--template skip the interview, not the skill install)
|
|
32
|
+
spf install-skill [--user] [--force] (re)install the Claude Code skill by hand — spf init already does this
|
|
33
33
|
spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
|
|
34
34
|
spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
|
|
35
35
|
spf doctor [--json] check everything that fails silently otherwise
|
|
@@ -42,7 +42,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
42
42
|
spf abort <adw_id> signal a run's process to stop
|
|
43
43
|
spf version print the installed version
|
|
44
44
|
|
|
45
|
-
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>]
|
|
45
|
+
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
|
|
46
46
|
Run \`spf list\` to see every chain and what it needs.`;
|
|
47
47
|
/** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
|
|
48
48
|
function findCwdFlag(argv) {
|
package/dist/cli/interview.js
CHANGED
|
@@ -231,6 +231,18 @@ export async function runInterview(asker, ctx) {
|
|
|
231
231
|
const chainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
|
|
232
232
|
const chain = await asker.select("Chain to run per issue", chainChoices, "plan-build-test");
|
|
233
233
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
234
|
+
// Issue authoring (create + link a hierarchy) is only implemented on
|
|
235
|
+
// GitHubProvider today — see jira_provider.ts's module comment — so
|
|
236
|
+
// this lane isn't offered at all on a Jira tracker rather than asking a
|
|
237
|
+
// question that would just fail at `spf watch` startup.
|
|
238
|
+
if (issueProvider === "github") {
|
|
239
|
+
const enableRefine = await asker.confirm(`Also enable the refine lane (decompose a "${labelPrefix}:spec-ready" product spec into a feature/story-or-bug tree)?`, false);
|
|
240
|
+
if (enableRefine) {
|
|
241
|
+
const refineChainChoices = CHAINS.map((c) => ({ value: c.name, label: c.name, hint: c.describe }));
|
|
242
|
+
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
243
|
+
watch.refine = { enabled: true, chain: refineChain };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
234
246
|
if (issueProvider === "jira") {
|
|
235
247
|
const baseUrl = await asker.text("Jira base URL", {
|
|
236
248
|
default: "https://your-domain.atlassian.net",
|
|
@@ -334,6 +346,11 @@ export async function runInterview(asker, ctx) {
|
|
|
334
346
|
const concurrency = await asker.text("watch.concurrency", { default: "2" });
|
|
335
347
|
if (concurrency !== "2")
|
|
336
348
|
watch.concurrency = Number(concurrency);
|
|
349
|
+
if (watch.refine?.enabled) {
|
|
350
|
+
const refineConcurrency = await asker.text("watch.refine.concurrency", { default: "1" });
|
|
351
|
+
if (refineConcurrency !== "1")
|
|
352
|
+
watch.refine.concurrency = Number(refineConcurrency);
|
|
353
|
+
}
|
|
337
354
|
}
|
|
338
355
|
const engineerName = await asker.text("ENGINEER_NAME (falls back to `git config user.name`)", { default: ctx.gitName ?? "" });
|
|
339
356
|
if (engineerName && engineerName !== ctx.gitName)
|
|
@@ -150,6 +150,45 @@ export declare const DocumentOutput: EnvelopeType<{
|
|
|
150
150
|
commit_message: string;
|
|
151
151
|
}>;
|
|
152
152
|
export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
|
|
153
|
+
/**
|
|
154
|
+
* One node in a decomposed product spec — a feature/epic container, or a
|
|
155
|
+
* story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
|
|
156
|
+
* emits a flat list far more reliably than a recursive tree, and a flat
|
|
157
|
+
* shape is what lets `blocked_by` reference ANY other node, container or
|
|
158
|
+
* leaf, not just siblings under the same parent.
|
|
159
|
+
*
|
|
160
|
+
* `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
|
|
161
|
+
* scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
|
|
162
|
+
* resolves `key`s to real issue numbers as it creates them, in dependency
|
|
163
|
+
* order. See `gates.refinementWellFormed` for the shape rules enforced on
|
|
164
|
+
* this list before publish ever runs (unique keys, resolvable references,
|
|
165
|
+
* no cycles, container/leaf kind agreement, at least one leaf).
|
|
166
|
+
*/
|
|
167
|
+
export declare const RefinedIssueSchema: v.ObjectSchema<{
|
|
168
|
+
readonly key: v.StringSchema<undefined>;
|
|
169
|
+
readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
|
|
170
|
+
readonly title: v.StringSchema<undefined>;
|
|
171
|
+
readonly body: v.StringSchema<undefined>;
|
|
172
|
+
readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
173
|
+
readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
|
|
174
|
+
}, undefined>;
|
|
175
|
+
export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
|
|
176
|
+
/** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
|
|
177
|
+
export declare const RefineOutput: EnvelopeType<{
|
|
178
|
+
status: "fail" | "success";
|
|
179
|
+
summary: string;
|
|
180
|
+
artifacts: string[];
|
|
181
|
+
notes_for_next_agent: string;
|
|
182
|
+
issues: {
|
|
183
|
+
key: string;
|
|
184
|
+
kind: "bug" | "epic" | "feature" | "story" | "task";
|
|
185
|
+
title: string;
|
|
186
|
+
body: string;
|
|
187
|
+
parent: string;
|
|
188
|
+
blocked_by: string[];
|
|
189
|
+
}[];
|
|
190
|
+
}>;
|
|
191
|
+
export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
|
|
153
192
|
export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
|
|
154
193
|
export type QualityArea = v.InferOutput<typeof QualityAreaSchema>;
|
|
155
194
|
export declare const QualityOperationSchema: v.PicklistSchema<["lint", "typecheck", "build"], undefined>;
|
|
@@ -383,6 +422,22 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
|
|
|
383
422
|
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
384
423
|
}, undefined>;
|
|
385
424
|
export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
425
|
+
/**
|
|
426
|
+
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
427
|
+
* spec into a feature/story tree of real issues, instead of running
|
|
428
|
+
* `watch.chain` against it directly (a spec is not individually workable —
|
|
429
|
+
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
430
|
+
* behavior is unchanged by upgrading; turning it on with
|
|
431
|
+
* `issue_provider: jira` fails loudly at `spf watch` startup, since
|
|
432
|
+
* `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
|
|
433
|
+
* yet — see its module comment.
|
|
434
|
+
*/
|
|
435
|
+
export declare const WatchRefineConfigSchema: v.ObjectSchema<{
|
|
436
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
437
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
438
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
439
|
+
}, undefined>;
|
|
440
|
+
export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
|
|
386
441
|
export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
387
442
|
readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
|
|
388
443
|
readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
|
|
@@ -399,6 +454,15 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
|
399
454
|
base_url: string;
|
|
400
455
|
project_key: string;
|
|
401
456
|
}>;
|
|
457
|
+
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
458
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
459
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
460
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
461
|
+
}, undefined>, () => {
|
|
462
|
+
enabled: boolean;
|
|
463
|
+
chain: string;
|
|
464
|
+
concurrency: number;
|
|
465
|
+
}>;
|
|
402
466
|
}, undefined>;
|
|
403
467
|
export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
|
|
404
468
|
/**
|
|
@@ -516,6 +580,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
516
580
|
base_url: string;
|
|
517
581
|
project_key: string;
|
|
518
582
|
}>;
|
|
583
|
+
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
584
|
+
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
585
|
+
readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
|
|
586
|
+
readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
|
|
587
|
+
}, undefined>, () => {
|
|
588
|
+
enabled: boolean;
|
|
589
|
+
chain: string;
|
|
590
|
+
concurrency: number;
|
|
591
|
+
}>;
|
|
519
592
|
}, undefined>, () => {
|
|
520
593
|
issue_provider: "github" | "jira";
|
|
521
594
|
code_host: "bitbucket" | "github";
|
|
@@ -529,6 +602,11 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
529
602
|
base_url: string;
|
|
530
603
|
project_key: string;
|
|
531
604
|
};
|
|
605
|
+
refine: {
|
|
606
|
+
enabled: boolean;
|
|
607
|
+
chain: string;
|
|
608
|
+
concurrency: number;
|
|
609
|
+
};
|
|
532
610
|
}>;
|
|
533
611
|
readonly notifications: v.OptionalSchema<v.ObjectSchema<{
|
|
534
612
|
readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
|
package/dist/core/data_types.js
CHANGED
|
@@ -103,6 +103,32 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
|
|
|
103
103
|
documented_files: v.optional(v.array(v.string()), () => []),
|
|
104
104
|
commit_message: v.optional(v.string(), ""),
|
|
105
105
|
});
|
|
106
|
+
/**
|
|
107
|
+
* One node in a decomposed product spec — a feature/epic container, or a
|
|
108
|
+
* story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
|
|
109
|
+
* emits a flat list far more reliably than a recursive tree, and a flat
|
|
110
|
+
* shape is what lets `blocked_by` reference ANY other node, container or
|
|
111
|
+
* leaf, not just siblings under the same parent.
|
|
112
|
+
*
|
|
113
|
+
* `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
|
|
114
|
+
* scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
|
|
115
|
+
* resolves `key`s to real issue numbers as it creates them, in dependency
|
|
116
|
+
* order. See `gates.refinementWellFormed` for the shape rules enforced on
|
|
117
|
+
* this list before publish ever runs (unique keys, resolvable references,
|
|
118
|
+
* no cycles, container/leaf kind agreement, at least one leaf).
|
|
119
|
+
*/
|
|
120
|
+
export const RefinedIssueSchema = v.object({
|
|
121
|
+
key: v.string(),
|
|
122
|
+
kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
|
|
123
|
+
title: v.string(),
|
|
124
|
+
body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
|
|
125
|
+
parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
|
|
126
|
+
blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
|
|
127
|
+
});
|
|
128
|
+
/** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
|
|
129
|
+
export const RefineOutput = envelopeType("RefineOutput", {
|
|
130
|
+
issues: v.optional(v.array(RefinedIssueSchema), () => []),
|
|
131
|
+
});
|
|
106
132
|
// ── Deterministic quality blocks ─────────────────────────────────────────────
|
|
107
133
|
export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
|
|
108
134
|
export const QualityOperationSchema = v.picklist(["lint", "typecheck", "build"]);
|
|
@@ -299,6 +325,21 @@ export const WatchJiraConfigSchema = v.object({
|
|
|
299
325
|
base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
|
|
300
326
|
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
301
327
|
});
|
|
328
|
+
/**
|
|
329
|
+
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
330
|
+
* spec into a feature/story tree of real issues, instead of running
|
|
331
|
+
* `watch.chain` against it directly (a spec is not individually workable —
|
|
332
|
+
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
333
|
+
* behavior is unchanged by upgrading; turning it on with
|
|
334
|
+
* `issue_provider: jira` fails loudly at `spf watch` startup, since
|
|
335
|
+
* `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
|
|
336
|
+
* yet — see its module comment.
|
|
337
|
+
*/
|
|
338
|
+
export const WatchRefineConfigSchema = v.object({
|
|
339
|
+
enabled: v.optional(v.boolean(), false),
|
|
340
|
+
chain: v.optional(v.string(), "refine"),
|
|
341
|
+
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
|
|
342
|
+
});
|
|
302
343
|
export const WatchConfigSchema = v.object({
|
|
303
344
|
issue_provider: v.optional(WatchIssueProviderSchema, "github"),
|
|
304
345
|
code_host: v.optional(WatchCodeHostSchema, "github"),
|
|
@@ -309,6 +350,7 @@ export const WatchConfigSchema = v.object({
|
|
|
309
350
|
poll_ms: v.optional(v.number(), 60_000),
|
|
310
351
|
concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
|
|
311
352
|
jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
|
|
353
|
+
refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
|
|
312
354
|
});
|
|
313
355
|
/**
|
|
314
356
|
* Optional outbound push for unattended work (`spf watch`, any chain run) —
|
package/dist/core/gates.d.ts
CHANGED
|
@@ -29,5 +29,18 @@ export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContex
|
|
|
29
29
|
* reading a line of the diff.
|
|
30
30
|
*/
|
|
31
31
|
export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
|
|
32
|
+
/**
|
|
33
|
+
* The gate that turns `to-tickets`' flat, untyped ticket list into an
|
|
34
|
+
* actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
|
|
35
|
+
* doc comment in `data_types.ts`. Checks the envelope's `issues` list
|
|
36
|
+
* against itself, never anything already published: `core/refine.ts` never
|
|
37
|
+
* gets a chance to publish a malformed tree in the first place, because a
|
|
38
|
+
* violation here re-prompts the SAME refiner session before `steps.refine()`
|
|
39
|
+
* ever hands off to `steps.publishIssues()`.
|
|
40
|
+
*
|
|
41
|
+
* "Container" and "leaf" are derived from the graph, not asserted by the
|
|
42
|
+
* agent: a node is a container iff some other node names it as `parent`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
|
|
32
45
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
33
46
|
export declare function testsPass(command: string): GateFn;
|
package/dist/core/gates.js
CHANGED
|
@@ -128,6 +128,109 @@ export function verdictConsistent(envelope, _run) {
|
|
|
128
128
|
: "approved=false but no blocking item or unmet requirement was given");
|
|
129
129
|
return report;
|
|
130
130
|
}
|
|
131
|
+
const CONTAINER_KINDS = new Set(["epic", "feature"]);
|
|
132
|
+
const LEAF_KINDS = new Set(["story", "bug", "task"]);
|
|
133
|
+
/**
|
|
134
|
+
* The gate that turns `to-tickets`' flat, untyped ticket list into an
|
|
135
|
+
* actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
|
|
136
|
+
* doc comment in `data_types.ts`. Checks the envelope's `issues` list
|
|
137
|
+
* against itself, never anything already published: `core/refine.ts` never
|
|
138
|
+
* gets a chance to publish a malformed tree in the first place, because a
|
|
139
|
+
* violation here re-prompts the SAME refiner session before `steps.refine()`
|
|
140
|
+
* ever hands off to `steps.publishIssues()`.
|
|
141
|
+
*
|
|
142
|
+
* "Container" and "leaf" are derived from the graph, not asserted by the
|
|
143
|
+
* agent: a node is a container iff some other node names it as `parent`.
|
|
144
|
+
*/
|
|
145
|
+
export function refinementWellFormed(envelope, _run) {
|
|
146
|
+
const report = new GateReport();
|
|
147
|
+
const issues = envelope.issues ?? [];
|
|
148
|
+
if (issues.length === 0) {
|
|
149
|
+
report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
|
|
150
|
+
return report;
|
|
151
|
+
}
|
|
152
|
+
const byKey = new Map();
|
|
153
|
+
for (const issue of issues) {
|
|
154
|
+
if (byKey.has(issue.key)) {
|
|
155
|
+
report.check(`key ${JSON.stringify(issue.key)}`, false, "duplicate key — every node needs a unique key within this refinement");
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
byKey.set(issue.key, issue);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const childKeys = new Set(); // keys named as some OTHER node's parent -> that node is a container
|
|
162
|
+
for (const issue of issues) {
|
|
163
|
+
if (!issue.parent)
|
|
164
|
+
continue;
|
|
165
|
+
if (!byKey.has(issue.parent)) {
|
|
166
|
+
report.check(`${issue.key}.parent`, false, `parent ${JSON.stringify(issue.parent)} does not match any issue's key`);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
childKeys.add(issue.parent);
|
|
170
|
+
}
|
|
171
|
+
for (const issue of issues) {
|
|
172
|
+
for (const blocker of issue.blocked_by) {
|
|
173
|
+
if (!byKey.has(blocker)) {
|
|
174
|
+
report.check(`${issue.key}.blocked_by`, false, `blocked_by ${JSON.stringify(blocker)} does not match any issue's key`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Cycle check over the union of parent + blocked_by edges — both mean
|
|
179
|
+
// "must exist before this node" from core/refine.ts's own topological
|
|
180
|
+
// publish order, so a cycle in either (or across both) would hang it.
|
|
181
|
+
const WHITE = 0;
|
|
182
|
+
const GRAY = 1;
|
|
183
|
+
const BLACK = 2;
|
|
184
|
+
const color = new Map();
|
|
185
|
+
let cyclic = false;
|
|
186
|
+
const edgesFrom = (key) => {
|
|
187
|
+
const issue = byKey.get(key);
|
|
188
|
+
if (!issue)
|
|
189
|
+
return [];
|
|
190
|
+
const out = [];
|
|
191
|
+
if (issue.parent && byKey.has(issue.parent))
|
|
192
|
+
out.push(issue.parent);
|
|
193
|
+
for (const b of issue.blocked_by)
|
|
194
|
+
if (byKey.has(b))
|
|
195
|
+
out.push(b);
|
|
196
|
+
return out;
|
|
197
|
+
};
|
|
198
|
+
const visit = (key) => {
|
|
199
|
+
if (cyclic)
|
|
200
|
+
return;
|
|
201
|
+
color.set(key, GRAY);
|
|
202
|
+
for (const next of edgesFrom(key)) {
|
|
203
|
+
const c = color.get(next) ?? WHITE;
|
|
204
|
+
if (c === GRAY) {
|
|
205
|
+
cyclic = true;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (c === WHITE)
|
|
209
|
+
visit(next);
|
|
210
|
+
}
|
|
211
|
+
color.set(key, BLACK);
|
|
212
|
+
};
|
|
213
|
+
for (const issue of issues) {
|
|
214
|
+
if ((color.get(issue.key) ?? WHITE) === WHITE)
|
|
215
|
+
visit(issue.key);
|
|
216
|
+
}
|
|
217
|
+
report.check("dependency graph", !cyclic, cyclic ? "parent/blocked_by edges form a cycle — nothing to publish first" : "acyclic");
|
|
218
|
+
for (const issue of issues) {
|
|
219
|
+
const isContainer = childKeys.has(issue.key);
|
|
220
|
+
if (isContainer && !CONTAINER_KINDS.has(issue.kind)) {
|
|
221
|
+
report.check(`${issue.key}.kind`, false, `has children but kind is ${JSON.stringify(issue.kind)} — a container must be "epic" or "feature"`);
|
|
222
|
+
}
|
|
223
|
+
else if (!isContainer && !LEAF_KINDS.has(issue.kind)) {
|
|
224
|
+
report.check(`${issue.key}.kind`, false, `has no children but kind is ${JSON.stringify(issue.kind)} — a leaf must be "story", "bug", or "task"`);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
report.check(`${issue.key}.kind`, true, isContainer ? "container" : "leaf");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
|
|
231
|
+
report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
|
|
232
|
+
return report;
|
|
233
|
+
}
|
|
131
234
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
132
235
|
export function testsPass(command) {
|
|
133
236
|
const gate = (_envelope, run) => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GitHub REST implementation of
|
|
3
|
-
* — one class, since GitHub natively is
|
|
4
|
-
*
|
|
2
|
+
* GitHub REST implementation of `IssueProvider`, `CodeHostProvider`, AND
|
|
3
|
+
* `IssueAuthoringProvider` — one class, since GitHub natively is an issue
|
|
4
|
+
* tracker, a code host, and (via sub-issues) an issue-hierarchy API all at
|
|
5
|
+
* once — via Node 22's native `fetch()`: deliberately not `octokit`, whose
|
|
5
6
|
* full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
|
|
6
|
-
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
|
|
7
|
-
*
|
|
7
|
+
* `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which is
|
|
8
|
+
* a couple dozen REST calls, none of them exotic. `spf`'s own package stays
|
|
9
|
+
* dependency-free either way.
|
|
8
10
|
*
|
|
9
11
|
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
10
12
|
* construction — matching the reference implementation's pattern and this
|
|
@@ -13,8 +15,11 @@
|
|
|
13
15
|
* makes (a repo with >100 open `<prefix>:ready` issues at once is not this
|
|
14
16
|
* version's problem to solve).
|
|
15
17
|
*/
|
|
16
|
-
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
17
|
-
|
|
18
|
+
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
19
|
+
/** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
|
|
20
|
+
export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
|
|
21
|
+
export type IssueKind = (typeof ISSUE_KINDS)[number];
|
|
22
|
+
export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
|
|
18
23
|
private readonly repo;
|
|
19
24
|
private readonly labelPrefix;
|
|
20
25
|
private readonly token;
|
|
@@ -22,14 +27,17 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
22
27
|
labelPrefix: string, token: string);
|
|
23
28
|
private gh;
|
|
24
29
|
private label;
|
|
30
|
+
private typeLabel;
|
|
25
31
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
26
32
|
private getLabel;
|
|
27
33
|
/**
|
|
28
|
-
* Idempotent by inspection, not by "create and catch a 422": GET
|
|
34
|
+
* Idempotent by inspection, not by "create and catch a 422": GET the
|
|
29
35
|
* label first, then create/update/leave alone depending on what's
|
|
30
36
|
* actually there. One fewer request in the common "already correct"
|
|
31
37
|
* case, and no brittle matching against GitHub's error-message text.
|
|
38
|
+
* Shared by `ensureLabels()`'s state-label and type-label passes.
|
|
32
39
|
*/
|
|
40
|
+
private ensureOneLabel;
|
|
33
41
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
34
42
|
private toIssue;
|
|
35
43
|
private listByLabel;
|
|
@@ -37,7 +45,10 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
37
45
|
listInState(state: WatchState, opts?: {
|
|
38
46
|
includeAll?: boolean;
|
|
39
47
|
}): Promise<Issue[]>;
|
|
40
|
-
claim(issue: Issue
|
|
48
|
+
claim(issue: Issue, opts?: {
|
|
49
|
+
from?: WatchState;
|
|
50
|
+
to?: WatchState;
|
|
51
|
+
}): Promise<boolean>;
|
|
41
52
|
transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
|
|
42
53
|
comment(issue: Issue, body: string): Promise<void>;
|
|
43
54
|
openPr(opts: {
|
|
@@ -47,6 +58,21 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
|
|
|
47
58
|
base: string;
|
|
48
59
|
}): Promise<PrRef>;
|
|
49
60
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
61
|
+
/** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
|
|
62
|
+
createIssue(input: {
|
|
63
|
+
title: string;
|
|
64
|
+
body: string;
|
|
65
|
+
labels: string[];
|
|
66
|
+
}): Promise<Issue>;
|
|
67
|
+
/**
|
|
68
|
+
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|
|
69
|
+
* native sub-issue link. Confirmed against GitHub's own REST docs: the
|
|
70
|
+
* body param is `sub_issue_id`, the CHILD's database id, not its issue
|
|
71
|
+
* number — hence `linkChild` requiring `child.internal_id` rather than
|
|
72
|
+
* `child.id`. GitHub's documented limits (not enforced client-side here):
|
|
73
|
+
* 100 sub-issues per parent, 8 levels of nesting.
|
|
74
|
+
*/
|
|
75
|
+
linkChild(parent: Issue, child: Issue): Promise<void>;
|
|
50
76
|
private findMarkerComment;
|
|
51
77
|
readMarker(issue: Issue): Promise<WatchMarker | null>;
|
|
52
78
|
writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;
|