@gr8ful/spf 0.6.0 → 0.7.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 +122 -27
- package/assets/prompts/refiner/system.md +11 -1
- package/assets/prompts/refiner/user.md +9 -3
- package/assets/skill/references/config.md +51 -13
- package/assets/templates/ts.spf.config.yaml +6 -2
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +20 -1
- package/dist/cli/commands/doctor.js +4 -2
- package/dist/cli/commands/init.js +4 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +55 -7
- package/dist/cli/index.js +1 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +87 -3
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +84 -1
- package/dist/core/issues/provider.d.ts +73 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@ import { isRepoAt, makeGit } from "../../core/git_helper.js";
|
|
|
16
16
|
import { GitHubProvider } from "../../core/issues/github_provider.js";
|
|
17
17
|
import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
18
18
|
import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
|
|
19
|
+
import { isAuthoringProvider } from "../../core/issues/provider.js";
|
|
19
20
|
import { createWatchState, tick } from "../../core/watch.js";
|
|
20
21
|
import { findChain, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
21
22
|
import { ReviewOutput } from "../../core/data_types.js";
|
|
@@ -107,7 +108,7 @@ function resolveIssueProvider(cfg) {
|
|
|
107
108
|
console.error('JIRA_EMAIL and JIRA_API_TOKEN must both be set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens). See README.md\'s "spf watch" section.');
|
|
108
109
|
return null;
|
|
109
110
|
}
|
|
110
|
-
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token);
|
|
111
|
+
return new JiraProvider(cfg.watch.jira.base_url, cfg.watch.jira.project_key, cfg.watch.label_prefix, email, token, cfg.watch.jira.issue_types);
|
|
111
112
|
}
|
|
112
113
|
console.error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} is not supported`);
|
|
113
114
|
return null;
|
|
@@ -177,6 +178,14 @@ function releaseLock(lockPath) {
|
|
|
177
178
|
* machine needs, with sensible colors/descriptions. Doesn't touch git or
|
|
178
179
|
* run anything, so it skips watchCommand's repo/chain checks entirely —
|
|
179
180
|
* you can seed labels before ever wiring up a worktree-capable checkout.
|
|
181
|
+
*
|
|
182
|
+
* On Jira, with `watch.refine.enabled`, this also runs a READ-ONLY check of
|
|
183
|
+
* `watch.jira.issue_types` against the real project — the same check
|
|
184
|
+
* `watchCommand`'s own startup gate runs, exposed here too so a bad mapping
|
|
185
|
+
* can be caught (and fixed) before ever starting the daemon, not just at
|
|
186
|
+
* startup time. Labels themselves stay a pure report on Jira either way
|
|
187
|
+
* (Jira labels are freeform strings with no color/description registry to
|
|
188
|
+
* seed — see `jira_provider.ts`'s module comment).
|
|
180
189
|
*/
|
|
181
190
|
export async function watchInitCommand(argv) {
|
|
182
191
|
const { options } = parseCli(argv, ["cwd", "config"], []);
|
|
@@ -193,6 +202,15 @@ export async function watchInitCommand(argv) {
|
|
|
193
202
|
console.log(` ~ ${name} (updated color/description)`);
|
|
194
203
|
for (const name of result.unchanged)
|
|
195
204
|
console.log(` = ${name} (already correct)`);
|
|
205
|
+
if (cfg.watch.refine.enabled && cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
|
|
206
|
+
const checks = await provider.validateIssueTypes();
|
|
207
|
+
console.log(`\nvalidating watch.jira.issue_types against ${cfg.watch.jira.project_key}:`);
|
|
208
|
+
for (const c of checks) {
|
|
209
|
+
console.log(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
|
|
210
|
+
}
|
|
211
|
+
if (checks.some((c) => !c.exists))
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
196
214
|
return 0;
|
|
197
215
|
}
|
|
198
216
|
export async function watchCommand(argv) {
|
|
@@ -215,19 +233,37 @@ export async function watchCommand(argv) {
|
|
|
215
233
|
return 1;
|
|
216
234
|
}
|
|
217
235
|
if (cfg.watch.refine.enabled) {
|
|
218
|
-
if (cfg.watch.issue_provider !== "github") {
|
|
219
|
-
// Fail loudly at startup, not silently every tick:
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
236
|
+
if (cfg.watch.issue_provider !== "github" && cfg.watch.issue_provider !== "jira") {
|
|
237
|
+
// Fail loudly at startup, not silently every tick: neither provider
|
|
238
|
+
// implements IssueAuthoringProvider besides these two — a refine lane
|
|
239
|
+
// that can never publish would otherwise just claim every spec-ready
|
|
240
|
+
// spec and block it, forever.
|
|
223
241
|
console.error(`watch.refine.enabled is true but watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — ` +
|
|
224
|
-
`the refine lane needs "github"
|
|
242
|
+
`the refine lane needs "github" or "jira"`);
|
|
225
243
|
return 1;
|
|
226
244
|
}
|
|
227
245
|
if (!findChain(cfg.watch.refine.chain)) {
|
|
228
246
|
console.error(`watch.refine.chain ${JSON.stringify(cfg.watch.refine.chain)} is not a registered chain — run \`spf list\` to see every chain`);
|
|
229
247
|
return 1;
|
|
230
248
|
}
|
|
249
|
+
// Jira's issue-type mapping is user-configured (watch.jira.issue_types)
|
|
250
|
+
// and project-specific — validated here, at startup, for the same
|
|
251
|
+
// reason findChain() is: a bad mapping should stop the daemon before
|
|
252
|
+
// it starts, not fail silently every tick once the first spec-ready
|
|
253
|
+
// spec tries to publish. Silent on success, matching this function's
|
|
254
|
+
// other startup checks; loud (and the full per-kind report) on failure.
|
|
255
|
+
if (cfg.watch.issue_provider === "jira" && provider instanceof JiraProvider) {
|
|
256
|
+
const checks = await provider.validateIssueTypes();
|
|
257
|
+
const mismatches = checks.filter((c) => !c.exists);
|
|
258
|
+
if (mismatches.length > 0) {
|
|
259
|
+
console.error(`watch.jira.issue_types has ${mismatches.length} mismatch(es) against ${cfg.watch.jira.project_key}:`);
|
|
260
|
+
for (const c of checks) {
|
|
261
|
+
console.error(` ${c.exists ? "✓" : "✗"} ${c.kind} → ${c.jiraType}${c.exists ? "" : ` — no issue type named "${c.jiraType}" in ${cfg.watch.jira.project_key}`}`);
|
|
262
|
+
}
|
|
263
|
+
console.error(`Fix watch.jira.issue_types, or the project's issue types, before running spf watch unattended. Run \`spf watch init\` any time to re-check.`);
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
231
267
|
}
|
|
232
268
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
233
269
|
const lockPath = path.join(dataPaths.data_dir, "watch.lock");
|
|
@@ -422,6 +458,17 @@ export async function watchCommand(argv) {
|
|
|
422
458
|
}
|
|
423
459
|
return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
|
|
424
460
|
};
|
|
461
|
+
// `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
|
|
462
|
+
// structural check (see `provider.ts`), so both GitHub and Jira are
|
|
463
|
+
// recognized here without an `instanceof` chain that would need editing
|
|
464
|
+
// for every future authoring-capable provider. `null` on any tracker that
|
|
465
|
+
// ISN'T authoring-capable makes `rollUp` a logged no-op there rather than
|
|
466
|
+
// a startup failure the way `watch.refine.enabled` without authoring
|
|
467
|
+
// support is above: the build lane functions fine without container
|
|
468
|
+
// roll-up, unlike refine, which cannot function without authoring at all.
|
|
469
|
+
// Not a second `resolveAuthoringProvider()` call: that helper's own config
|
|
470
|
+
// validation already ran to produce `provider` itself.
|
|
471
|
+
const authoringProvider = isAuthoringProvider(provider) ? provider : null;
|
|
425
472
|
const deps = {
|
|
426
473
|
provider,
|
|
427
474
|
codeHost,
|
|
@@ -440,6 +487,7 @@ export async function watchCommand(argv) {
|
|
|
440
487
|
linkDataDir,
|
|
441
488
|
dryRun: Boolean(flags["dry-run"]),
|
|
442
489
|
runChain,
|
|
490
|
+
listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
|
|
443
491
|
log: (message) => console.log(message),
|
|
444
492
|
notify: (event) => notifier?.send(event),
|
|
445
493
|
};
|
package/dist/cli/index.js
CHANGED
|
@@ -49,7 +49,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
|
|
|
49
49
|
spf abort <adw_id> signal a run's process to stop
|
|
50
50
|
spf version print the installed version
|
|
51
51
|
|
|
52
|
-
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>]
|
|
52
|
+
Chain options: [--config <path>] [--adw-id <id>] [--cwd <dir>] [--agent <name>] [--base <ref>] [--issue <id>] [--priority p0|p1|p2|p3]
|
|
53
53
|
Run budget: set defaults.max_run_cost (USD) and/or defaults.max_run_tokens in spf.config.yaml to stop the NEXT agent call once a run has already spent this much — checked before each call, never after, so a single call is never capped and a one-agent-dispatch chain (scout/prompt/build) can never trip it; absent (the default) = unbounded.
|
|
54
54
|
Run \`spf list\` to see every chain and what it needs.`;
|
|
55
55
|
/** A raw scan for `--cwd`, ahead of any command-specific argv parsing — every command that takes it means the same thing by it. */
|
package/dist/cli/interview.js
CHANGED
|
@@ -286,16 +286,20 @@ export async function runInterview(asker, ctx) {
|
|
|
286
286
|
watch = { issue_provider: issueProvider, code_host: codeHost, repo, label_prefix: labelPrefix, chain, base_branch: baseBranch };
|
|
287
287
|
if (issueRepo)
|
|
288
288
|
watch.issue_repo = issueRepo;
|
|
289
|
-
// Issue authoring (create + link a hierarchy) is
|
|
290
|
-
// GitHubProvider
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
if (issueProvider === "github") {
|
|
289
|
+
// Issue authoring (create + link a hierarchy) is implemented on both
|
|
290
|
+
// GitHubProvider and JiraProvider — see jira_provider.ts's module
|
|
291
|
+
// comment for how Jira's version works (native issue types + the
|
|
292
|
+
// `parent` field).
|
|
293
|
+
if (issueProvider === "github" || issueProvider === "jira") {
|
|
294
294
|
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);
|
|
295
295
|
if (enableRefine) {
|
|
296
296
|
const refineChainChoices = allChains().map((c) => ({ value: c.name, label: c.name, hint: c.source ? `${c.describe} (repo)` : c.describe }));
|
|
297
297
|
const refineChain = await asker.select("Chain to run per spec", refineChainChoices, "refine");
|
|
298
298
|
watch.refine = { enabled: true, chain: refineChain };
|
|
299
|
+
if (issueProvider === "jira") {
|
|
300
|
+
asker.note('Jira issue types default to epic/feature -> "Epic", story -> "Story", bug -> "Bug", task -> "Task" — ' +
|
|
301
|
+
"customize per-kind in watch.jira.issue_types if your project renames any of them, then run `spf watch init` to validate.");
|
|
302
|
+
}
|
|
299
303
|
}
|
|
300
304
|
}
|
|
301
305
|
if (issueProvider === "jira") {
|
|
@@ -164,6 +164,20 @@ export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
|
|
|
164
164
|
* this list before publish ever runs (unique keys, resolvable references,
|
|
165
165
|
* no cycles, container/leaf kind agreement, at least one leaf).
|
|
166
166
|
*/
|
|
167
|
+
/**
|
|
168
|
+
* `p0` (drop everything) .. `p3` (someday) — see `assets/prompts/refiner/system.md`'s
|
|
169
|
+
* `## Priority` section for what each rung means. Two rules the schema alone
|
|
170
|
+
* can't enforce, checked instead by `gates.refinementWellFormed`
|
|
171
|
+
* (monotonicity: no node outranks its `parent`) and `core/refine.ts`'s
|
|
172
|
+
* `publish()` (the spec's own priority, when known, is a ceiling clamped onto
|
|
173
|
+
* every node): see both files' doc comments.
|
|
174
|
+
*/
|
|
175
|
+
export declare const RefinedPrioritySchema: v.PicklistSchema<["p0", "p1", "p2", "p3"], undefined>;
|
|
176
|
+
export type RefinedPriority = v.InferOutput<typeof RefinedPrioritySchema>;
|
|
177
|
+
/** Lower rank = more urgent. The one place both `gates.refinementWellFormed` (monotonicity) and `core/refine.ts`'s `publish()` (the spec-priority ceiling) get their ordering from — see `RefinedPrioritySchema`'s doc comment. */
|
|
178
|
+
export declare const PRIORITY_RANK: Record<RefinedPriority, number>;
|
|
179
|
+
/** `priority`, pulled down to `ceiling` if it outranks it — never raised. `ceiling` nullish (a bare `spf refine` with nothing to inherit from) is a no-op. */
|
|
180
|
+
export declare function clampPriority(priority: RefinedPriority, ceiling: RefinedPriority | null | undefined): RefinedPriority;
|
|
167
181
|
export declare const RefinedIssueSchema: v.ObjectSchema<{
|
|
168
182
|
readonly key: v.StringSchema<undefined>;
|
|
169
183
|
readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
|
|
@@ -171,6 +185,7 @@ export declare const RefinedIssueSchema: v.ObjectSchema<{
|
|
|
171
185
|
readonly body: v.StringSchema<undefined>;
|
|
172
186
|
readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
173
187
|
readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
|
|
188
|
+
readonly priority: v.OptionalSchema<v.PicklistSchema<["p0", "p1", "p2", "p3"], undefined>, "p2">;
|
|
174
189
|
}, undefined>;
|
|
175
190
|
export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
|
|
176
191
|
/**
|
|
@@ -208,6 +223,7 @@ export declare const RefineOutput: EnvelopeType<{
|
|
|
208
223
|
body: string;
|
|
209
224
|
parent: string;
|
|
210
225
|
blocked_by: string[];
|
|
226
|
+
priority: "p0" | "p1" | "p2" | "p3";
|
|
211
227
|
}[];
|
|
212
228
|
questions: {
|
|
213
229
|
id: string;
|
|
@@ -534,10 +550,50 @@ export declare const WatchIssueProviderSchema: v.PicklistSchema<["github", "jira
|
|
|
534
550
|
export type WatchIssueProviderKind = v.InferOutput<typeof WatchIssueProviderSchema>;
|
|
535
551
|
export declare const WatchCodeHostSchema: v.PicklistSchema<["github", "bitbucket"], undefined>;
|
|
536
552
|
export type WatchCodeHostKind = v.InferOutput<typeof WatchCodeHostSchema>;
|
|
537
|
-
/**
|
|
553
|
+
/**
|
|
554
|
+
* What each `RefinedIssue.kind` creates as, on Jira — Jira's create endpoint
|
|
555
|
+
* requires a real `issuetype`, and project setups vary (renamed types,
|
|
556
|
+
* non-English instances, custom schemes), so this is a name -> name map,
|
|
557
|
+
* not a hardcoded assumption. Every field defaults independently: a config
|
|
558
|
+
* that only sets `bug: Defect` still gets Epic/Epic/Story/Task for the
|
|
559
|
+
* other four. `jira_provider.ts`'s `createIssue`/`validateIssueTypes` are
|
|
560
|
+
* the readers; `spf watch init` and `spf watch`'s own startup check
|
|
561
|
+
* (`cli/commands/watch.ts`) both validate this against the real project
|
|
562
|
+
* before anything unattended runs on it.
|
|
563
|
+
*/
|
|
564
|
+
export declare const JiraIssueTypeMapSchema: v.ObjectSchema<{
|
|
565
|
+
readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
566
|
+
readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
567
|
+
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
568
|
+
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
569
|
+
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
570
|
+
}, undefined>;
|
|
571
|
+
export type JiraIssueTypeMap = v.InferOutput<typeof JiraIssueTypeMapSchema>;
|
|
572
|
+
/**
|
|
573
|
+
* Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
|
|
574
|
+
* `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
|
|
575
|
+
* object replace on config-file-layer merge, like `refine`/
|
|
576
|
+
* `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
|
|
577
|
+
* file that touches `watch.jira` at all must repeat `issue_types` too if it
|
|
578
|
+
* wants to keep a customized mapping, same caveat that already applies to
|
|
579
|
+
* `base_url`/`project_key` today.
|
|
580
|
+
*/
|
|
538
581
|
export declare const WatchJiraConfigSchema: v.ObjectSchema<{
|
|
539
582
|
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
540
583
|
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
584
|
+
readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
|
|
585
|
+
readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
586
|
+
readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
587
|
+
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
588
|
+
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
589
|
+
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
590
|
+
}, undefined>, () => {
|
|
591
|
+
epic: string;
|
|
592
|
+
feature: string;
|
|
593
|
+
story: string;
|
|
594
|
+
bug: string;
|
|
595
|
+
task: string;
|
|
596
|
+
}>;
|
|
541
597
|
}, undefined>;
|
|
542
598
|
export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
543
599
|
/**
|
|
@@ -545,10 +601,10 @@ export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
|
|
|
545
601
|
* spec into a feature/story tree of real issues, instead of running
|
|
546
602
|
* `watch.chain` against it directly (a spec is not individually workable —
|
|
547
603
|
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
548
|
-
* behavior is unchanged by upgrading
|
|
549
|
-
* `
|
|
550
|
-
*
|
|
551
|
-
*
|
|
604
|
+
* behavior is unchanged by upgrading. Needs `issue_provider: github` or
|
|
605
|
+
* `"jira"` — both implement `IssueAuthoringProvider` (create/link/list) —
|
|
606
|
+
* any other value fails loudly at `spf watch` startup rather than running a
|
|
607
|
+
* refine lane that can never publish anything.
|
|
552
608
|
*/
|
|
553
609
|
export declare const WatchRefineConfigSchema: v.ObjectSchema<{
|
|
554
610
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
@@ -585,9 +641,29 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
|
|
|
585
641
|
readonly jira: v.OptionalSchema<v.ObjectSchema<{
|
|
586
642
|
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
587
643
|
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
644
|
+
readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
|
|
645
|
+
readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
646
|
+
readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
647
|
+
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
648
|
+
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
649
|
+
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
650
|
+
}, undefined>, () => {
|
|
651
|
+
epic: string;
|
|
652
|
+
feature: string;
|
|
653
|
+
story: string;
|
|
654
|
+
bug: string;
|
|
655
|
+
task: string;
|
|
656
|
+
}>;
|
|
588
657
|
}, undefined>, () => {
|
|
589
658
|
base_url: string;
|
|
590
659
|
project_key: string;
|
|
660
|
+
issue_types: {
|
|
661
|
+
epic: string;
|
|
662
|
+
feature: string;
|
|
663
|
+
story: string;
|
|
664
|
+
bug: string;
|
|
665
|
+
task: string;
|
|
666
|
+
};
|
|
591
667
|
}>;
|
|
592
668
|
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
593
669
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
@@ -873,9 +949,29 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
873
949
|
readonly jira: v.OptionalSchema<v.ObjectSchema<{
|
|
874
950
|
readonly base_url: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
875
951
|
readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
|
|
952
|
+
readonly issue_types: v.OptionalSchema<v.ObjectSchema<{
|
|
953
|
+
readonly epic: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
954
|
+
readonly feature: v.OptionalSchema<v.StringSchema<undefined>, "Epic">;
|
|
955
|
+
readonly story: v.OptionalSchema<v.StringSchema<undefined>, "Story">;
|
|
956
|
+
readonly bug: v.OptionalSchema<v.StringSchema<undefined>, "Bug">;
|
|
957
|
+
readonly task: v.OptionalSchema<v.StringSchema<undefined>, "Task">;
|
|
958
|
+
}, undefined>, () => {
|
|
959
|
+
epic: string;
|
|
960
|
+
feature: string;
|
|
961
|
+
story: string;
|
|
962
|
+
bug: string;
|
|
963
|
+
task: string;
|
|
964
|
+
}>;
|
|
876
965
|
}, undefined>, () => {
|
|
877
966
|
base_url: string;
|
|
878
967
|
project_key: string;
|
|
968
|
+
issue_types: {
|
|
969
|
+
epic: string;
|
|
970
|
+
feature: string;
|
|
971
|
+
story: string;
|
|
972
|
+
bug: string;
|
|
973
|
+
task: string;
|
|
974
|
+
};
|
|
879
975
|
}>;
|
|
880
976
|
readonly refine: v.OptionalSchema<v.ObjectSchema<{
|
|
881
977
|
readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
|
|
@@ -902,6 +998,13 @@ export declare const SFConfigSchema: v.ObjectSchema<{
|
|
|
902
998
|
jira: {
|
|
903
999
|
base_url: string;
|
|
904
1000
|
project_key: string;
|
|
1001
|
+
issue_types: {
|
|
1002
|
+
epic: string;
|
|
1003
|
+
feature: string;
|
|
1004
|
+
story: string;
|
|
1005
|
+
bug: string;
|
|
1006
|
+
task: string;
|
|
1007
|
+
};
|
|
905
1008
|
};
|
|
906
1009
|
refine: {
|
|
907
1010
|
enabled: boolean;
|
package/dist/core/data_types.js
CHANGED
|
@@ -117,6 +117,23 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
|
|
|
117
117
|
* this list before publish ever runs (unique keys, resolvable references,
|
|
118
118
|
* no cycles, container/leaf kind agreement, at least one leaf).
|
|
119
119
|
*/
|
|
120
|
+
/**
|
|
121
|
+
* `p0` (drop everything) .. `p3` (someday) — see `assets/prompts/refiner/system.md`'s
|
|
122
|
+
* `## Priority` section for what each rung means. Two rules the schema alone
|
|
123
|
+
* can't enforce, checked instead by `gates.refinementWellFormed`
|
|
124
|
+
* (monotonicity: no node outranks its `parent`) and `core/refine.ts`'s
|
|
125
|
+
* `publish()` (the spec's own priority, when known, is a ceiling clamped onto
|
|
126
|
+
* every node): see both files' doc comments.
|
|
127
|
+
*/
|
|
128
|
+
export const RefinedPrioritySchema = v.picklist(["p0", "p1", "p2", "p3"]);
|
|
129
|
+
/** Lower rank = more urgent. The one place both `gates.refinementWellFormed` (monotonicity) and `core/refine.ts`'s `publish()` (the spec-priority ceiling) get their ordering from — see `RefinedPrioritySchema`'s doc comment. */
|
|
130
|
+
export const PRIORITY_RANK = { p0: 0, p1: 1, p2: 2, p3: 3 };
|
|
131
|
+
/** `priority`, pulled down to `ceiling` if it outranks it — never raised. `ceiling` nullish (a bare `spf refine` with nothing to inherit from) is a no-op. */
|
|
132
|
+
export function clampPriority(priority, ceiling) {
|
|
133
|
+
if (!ceiling)
|
|
134
|
+
return priority;
|
|
135
|
+
return PRIORITY_RANK[priority] < PRIORITY_RANK[ceiling] ? ceiling : priority;
|
|
136
|
+
}
|
|
120
137
|
export const RefinedIssueSchema = v.object({
|
|
121
138
|
key: v.string(),
|
|
122
139
|
kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
|
|
@@ -124,6 +141,7 @@ export const RefinedIssueSchema = v.object({
|
|
|
124
141
|
body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
|
|
125
142
|
parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
|
|
126
143
|
blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
|
|
144
|
+
priority: v.optional(RefinedPrioritySchema, "p2"), // what spf watch's build lane schedules by — see RefinedPrioritySchema
|
|
127
145
|
});
|
|
128
146
|
/**
|
|
129
147
|
* One open question the refiner could not answer itself — material ambiguity
|
|
@@ -439,20 +457,47 @@ export const ObservabilityConfigSchema = v.object({
|
|
|
439
457
|
*/
|
|
440
458
|
export const WatchIssueProviderSchema = v.picklist(["github", "jira"]);
|
|
441
459
|
export const WatchCodeHostSchema = v.picklist(["github", "bitbucket"]);
|
|
442
|
-
/**
|
|
460
|
+
/**
|
|
461
|
+
* What each `RefinedIssue.kind` creates as, on Jira — Jira's create endpoint
|
|
462
|
+
* requires a real `issuetype`, and project setups vary (renamed types,
|
|
463
|
+
* non-English instances, custom schemes), so this is a name -> name map,
|
|
464
|
+
* not a hardcoded assumption. Every field defaults independently: a config
|
|
465
|
+
* that only sets `bug: Defect` still gets Epic/Epic/Story/Task for the
|
|
466
|
+
* other four. `jira_provider.ts`'s `createIssue`/`validateIssueTypes` are
|
|
467
|
+
* the readers; `spf watch init` and `spf watch`'s own startup check
|
|
468
|
+
* (`cli/commands/watch.ts`) both validate this against the real project
|
|
469
|
+
* before anything unattended runs on it.
|
|
470
|
+
*/
|
|
471
|
+
export const JiraIssueTypeMapSchema = v.object({
|
|
472
|
+
epic: v.optional(v.string(), "Epic"),
|
|
473
|
+
feature: v.optional(v.string(), "Epic"),
|
|
474
|
+
story: v.optional(v.string(), "Story"),
|
|
475
|
+
bug: v.optional(v.string(), "Bug"),
|
|
476
|
+
task: v.optional(v.string(), "Task"),
|
|
477
|
+
});
|
|
478
|
+
/**
|
|
479
|
+
* Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` +
|
|
480
|
+
* `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. Whole-
|
|
481
|
+
* object replace on config-file-layer merge, like `refine`/
|
|
482
|
+
* `observability.otel` (see `agents.ts`'s `mergeRawConfig`) — an override
|
|
483
|
+
* file that touches `watch.jira` at all must repeat `issue_types` too if it
|
|
484
|
+
* wants to keep a customized mapping, same caveat that already applies to
|
|
485
|
+
* `base_url`/`project_key` today.
|
|
486
|
+
*/
|
|
443
487
|
export const WatchJiraConfigSchema = v.object({
|
|
444
488
|
base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
|
|
445
489
|
project_key: v.optional(v.string(), ""), // e.g. "PROJ"
|
|
490
|
+
issue_types: v.optional(JiraIssueTypeMapSchema, () => v.parse(JiraIssueTypeMapSchema, {})),
|
|
446
491
|
});
|
|
447
492
|
/**
|
|
448
493
|
* The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
|
|
449
494
|
* spec into a feature/story tree of real issues, instead of running
|
|
450
495
|
* `watch.chain` against it directly (a spec is not individually workable —
|
|
451
496
|
* see `core/refine.ts`). Off by default so an existing `watch:` config's
|
|
452
|
-
* behavior is unchanged by upgrading
|
|
453
|
-
* `
|
|
454
|
-
*
|
|
455
|
-
*
|
|
497
|
+
* behavior is unchanged by upgrading. Needs `issue_provider: github` or
|
|
498
|
+
* `"jira"` — both implement `IssueAuthoringProvider` (create/link/list) —
|
|
499
|
+
* any other value fails loudly at `spf watch` startup rather than running a
|
|
500
|
+
* refine lane that can never publish anything.
|
|
456
501
|
*/
|
|
457
502
|
export const WatchRefineConfigSchema = v.object({
|
|
458
503
|
enabled: v.optional(v.boolean(), false),
|
package/dist/core/gates.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { GateReport } from "./data_types.js";
|
|
20
|
+
import { GateReport, PRIORITY_RANK } from "./data_types.js";
|
|
21
21
|
import { operatorEnv } from "./utils.js";
|
|
22
22
|
const TAIL_CHARS = 1000; // command output kept as evidence on a failure
|
|
23
23
|
/**
|
|
@@ -252,6 +252,29 @@ export function refinementWellFormed(envelope, _run) {
|
|
|
252
252
|
}
|
|
253
253
|
const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
|
|
254
254
|
report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
|
|
255
|
+
// Monotonicity: no node may be MORE urgent than its own parent — p0 < p1 <
|
|
256
|
+
// p2 < p3, so "more urgent" is a lower rank. Only checked against `parent`
|
|
257
|
+
// (the containment edge), never `blocked_by` (a real dependency can easily
|
|
258
|
+
// be less urgent than the thing it blocks — a p3 prefactor gating a p0
|
|
259
|
+
// feature is normal, not a mistake). A violation here is a decomposition
|
|
260
|
+
// mistake worth a correction round-trip (this gate's `retries: 1` —
|
|
261
|
+
// steps.ts's `refine()`), not a hard block: a p0 story wearing a p3
|
|
262
|
+
// feature's parent almost always means the feature was mis-scored, not the
|
|
263
|
+
// story. The spec's OWN priority ceiling is enforced separately, in
|
|
264
|
+
// `core/refine.ts`'s `publish()` — this gate only knows about the tree,
|
|
265
|
+
// never the spec issue it came from.
|
|
266
|
+
for (const issue of issues) {
|
|
267
|
+
if (!issue.parent)
|
|
268
|
+
continue;
|
|
269
|
+
const parent = byKey.get(issue.parent);
|
|
270
|
+
if (!parent)
|
|
271
|
+
continue; // already reported above as an unresolved parent
|
|
272
|
+
const childRank = PRIORITY_RANK[issue.priority] ?? 2;
|
|
273
|
+
const parentRank = PRIORITY_RANK[parent.priority] ?? 2;
|
|
274
|
+
report.check(`${issue.key}.priority`, childRank >= parentRank, childRank >= parentRank
|
|
275
|
+
? `${issue.priority} — no more urgent than parent ${JSON.stringify(issue.parent)} (${parent.priority})`
|
|
276
|
+
: `${issue.priority} is more urgent than parent ${JSON.stringify(issue.parent)}'s ${parent.priority} — a container's priority is a ceiling for everything under it`);
|
|
277
|
+
}
|
|
255
278
|
return report;
|
|
256
279
|
}
|
|
257
280
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
|
|
12
12
|
* construction — matching the reference implementation's pattern and this
|
|
13
|
-
* project's existing env-var-for-credentials philosophy.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* project's existing env-var-for-credentials philosophy. `listByLabel`
|
|
14
|
+
* paginates up to `MAX_LIST_PAGES` (500 issues per label query) — no longer
|
|
15
|
+
* "not this version's problem to solve," now that priority ordering makes a
|
|
16
|
+
* truncated first page a correctness bug (an old, high-priority issue past
|
|
17
|
+
* page 1 would silently lose to a new low-priority one), not just a missed
|
|
18
|
+
* issue. A repo past even that cap gets a loud warning, never a silent
|
|
19
|
+
* truncation — see `listByLabel`'s own doc comment.
|
|
17
20
|
*/
|
|
18
21
|
import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueComment, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
|
|
22
|
+
import type { RefinedIssue } from "../data_types.ts";
|
|
19
23
|
/** 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
24
|
export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
|
|
21
25
|
export type IssueKind = (typeof ISSUE_KINDS)[number];
|
|
@@ -28,6 +32,8 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
28
32
|
private gh;
|
|
29
33
|
private label;
|
|
30
34
|
private typeLabel;
|
|
35
|
+
/** Mirrors `core/refine.ts`'s own module-level `priorityLabel()` — that one stays provider-agnostic (a plain string, no `this`); this one is `ensureLabels()`'s seeding half. */
|
|
36
|
+
private priorityLabel;
|
|
31
37
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
32
38
|
private getLabel;
|
|
33
39
|
/**
|
|
@@ -40,11 +46,32 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
40
46
|
private ensureOneLabel;
|
|
41
47
|
ensureLabels(): Promise<EnsureLabelsResult>;
|
|
42
48
|
private toIssue;
|
|
49
|
+
/**
|
|
50
|
+
* `sort=created&direction=asc` is stated, not inherited: without it,
|
|
51
|
+
* GitHub's own default (`created`, `desc` — newest first) is what
|
|
52
|
+
* `claimNewWork` used to walk, silently, which is why a >100-issue ready
|
|
53
|
+
* backlog used to be a real risk before pagination existed at all.
|
|
54
|
+
* Oldest-first is also `orderEligible`'s own final tiebreaker (`watch.ts`),
|
|
55
|
+
* so this method's order and that function's are the same order absent a
|
|
56
|
+
* priority/affinity difference — no redundant client-side re-sort needed
|
|
57
|
+
* for the plain case.
|
|
58
|
+
*
|
|
59
|
+
* Paginates up to `MAX_LIST_PAGES` (500 issues) — no longer "this
|
|
60
|
+
* version's problem to solve": a client-side priority sort over a
|
|
61
|
+
* truncated first page would silently misorder or hide real work, which is
|
|
62
|
+
* worse than the old unordered-100-issues behavior it replaces. A repo
|
|
63
|
+
* that still exceeds the cap gets a loud, named warning rather than a
|
|
64
|
+
* silent truncation.
|
|
65
|
+
*/
|
|
43
66
|
private listByLabel;
|
|
44
67
|
listEligible(): Promise<Issue[]>;
|
|
45
68
|
listInState(state: WatchState, opts?: {
|
|
46
69
|
includeAll?: boolean;
|
|
47
70
|
}): Promise<Issue[]>;
|
|
71
|
+
/** `null` on a real 404 — deleted, or (state defaults to open in a plain fetch) an issue GitHub itself considers gone. Any other non-2xx still throws, same as `gh()`. */
|
|
72
|
+
getIssue(id: string): Promise<Issue | null>;
|
|
73
|
+
/** `GET .../sub_issues` — the read-back half of `linkChild`; what makes container roll-up possible (`rollUp` in `watch.ts`). Closed children ARE returned (no `state` filter) — roll-up needs to see a `blocked` child too, to correctly NOT finish the container. */
|
|
74
|
+
listChildren(parent: Issue): Promise<Issue[]>;
|
|
48
75
|
claim(issue: Issue, opts?: {
|
|
49
76
|
from?: WatchState;
|
|
50
77
|
to?: WatchState;
|
|
@@ -58,11 +85,18 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider,
|
|
|
58
85
|
base: string;
|
|
59
86
|
}): Promise<PrRef>;
|
|
60
87
|
prStatus(pr: PrRef): Promise<PrStatus>;
|
|
61
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s
|
|
90
|
+
* module doc). `input.kind` is unused here: GitHub has no native
|
|
91
|
+
* issue-type field the way Jira does, and `input.labels` already carries
|
|
92
|
+
* `<prefix>:type:<kind>` for GitHub's own bookkeeping — the parameter
|
|
93
|
+
* exists on the shared interface for `JiraProvider`'s sake.
|
|
94
|
+
*/
|
|
62
95
|
createIssue(input: {
|
|
63
96
|
title: string;
|
|
64
97
|
body: string;
|
|
65
98
|
labels: string[];
|
|
99
|
+
kind: RefinedIssue["kind"];
|
|
66
100
|
}): Promise<Issue>;
|
|
67
101
|
/**
|
|
68
102
|
* `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
|