@gr8ful/spf 0.6.0 → 0.8.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/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +21 -2
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +47 -9
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +30 -2
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +93 -13
- package/dist/cli/index.js +4 -4
- package/dist/cli/interview.js +9 -5
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +103 -4
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +97 -2
- 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/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +6 -1
|
@@ -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/fanout.d.ts
CHANGED
|
@@ -214,6 +214,15 @@ export interface FanoutDeps {
|
|
|
214
214
|
* ABORT GRANULARITY note.
|
|
215
215
|
*/
|
|
216
216
|
firstSuccess?: boolean;
|
|
217
|
+
/**
|
|
218
|
+
* Fires once per attempt the moment it settles (success, fail, error, or
|
|
219
|
+
* skipped) — the same record that lands in the final `attempts` array, just
|
|
220
|
+
* as it happens instead of only once every worker has finished. Purely
|
|
221
|
+
* observational: nothing here changes selection or cleanup. `cli/commands/
|
|
222
|
+
* fanout.ts` uses it to update a live results table row by row on a TTY;
|
|
223
|
+
* omitted (the default) everywhere else, including every test.
|
|
224
|
+
*/
|
|
225
|
+
onAttempt?: (attempt: FanoutAttempt) => void;
|
|
217
226
|
}
|
|
218
227
|
/**
|
|
219
228
|
* Run `n` attempts of one prompt, select deterministically, clean up the
|
package/dist/core/fanout.js
CHANGED
|
@@ -284,10 +284,14 @@ export async function runBestOf(deps) {
|
|
|
284
284
|
return;
|
|
285
285
|
if (decided) {
|
|
286
286
|
deps.log(`fanout: attempt ${index} skipped — an earlier attempt already succeeded`);
|
|
287
|
-
|
|
287
|
+
const attempt = skipped(index);
|
|
288
|
+
attempts.push(attempt);
|
|
289
|
+
deps.onAttempt?.(attempt);
|
|
288
290
|
continue;
|
|
289
291
|
}
|
|
290
|
-
|
|
292
|
+
const attempt = await runOne(index);
|
|
293
|
+
attempts.push(attempt);
|
|
294
|
+
deps.onAttempt?.(attempt);
|
|
291
295
|
}
|
|
292
296
|
};
|
|
293
297
|
const workers = Array.from({ length: Math.max(1, Math.min(deps.concurrency, deps.n)) }, () => worker());
|
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
|
|
@@ -10,8 +10,26 @@ const STATES = [
|
|
|
10
10
|
"refined",
|
|
11
11
|
"needs-feedback",
|
|
12
12
|
"continue-refinement",
|
|
13
|
+
"spec-in-progress",
|
|
13
14
|
];
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* GREEDY capture, not lazy: `WatchMarker.feedback` nests its own object
|
|
17
|
+
* (`{rounds, asked_at}`), so a lazy `\{.*?\}` stops at the FIRST `}` it
|
|
18
|
+
* finds — which is `feedback`'s own closing brace, not the marker's outer
|
|
19
|
+
* one — capturing an unbalanced, unparseable fragment the instant a spec
|
|
20
|
+
* escalates even once. `JSON.parse` then throws, is silently caught, and
|
|
21
|
+
* `readMarker` returns `null` forever after that point: the round counter
|
|
22
|
+
* can never advance past 1, a fresh marker comment gets posted every tick
|
|
23
|
+
* instead of the existing one being edited in place, and — worst of all —
|
|
24
|
+
* `buildSpecPrompt` never receives a valid `asked_at`, so a human's answer
|
|
25
|
+
* is never recognized as one, just dumped into undifferentiated "earlier
|
|
26
|
+
* discussion." A greedy match backtracks from the END of the string to find
|
|
27
|
+
* the LAST `}` — the marker's own outer brace — which is exactly right
|
|
28
|
+
* here since nothing meaningful follows it but the closing `-->`.
|
|
29
|
+
*/
|
|
30
|
+
const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*\})\s*-->/s;
|
|
31
|
+
/** `listByLabel`'s pagination bound — see its own doc comment. */
|
|
32
|
+
const MAX_LIST_PAGES = 5;
|
|
15
33
|
/** 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. */
|
|
16
34
|
export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
|
|
17
35
|
// GitHub label colors are 6 hex digits, no leading '#'.
|
|
@@ -26,6 +44,7 @@ const LABEL_META = {
|
|
|
26
44
|
refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
|
|
27
45
|
"needs-feedback": { color: "d93f0b", description: "spf's refiner needs a human answer before it can finish decomposing this spec" },
|
|
28
46
|
"continue-refinement": { color: "0e8a16", description: "add this once you've answered — spf will resume refining from where it left off" },
|
|
47
|
+
"spec-in-progress": { color: "1d76db", description: "decomposed and published — waiting on every generated issue to reach spf:done" },
|
|
29
48
|
};
|
|
30
49
|
const TYPE_LABEL_META = {
|
|
31
50
|
epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
|
|
@@ -34,6 +53,24 @@ const TYPE_LABEL_META = {
|
|
|
34
53
|
bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
35
54
|
task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
|
|
36
55
|
};
|
|
56
|
+
/**
|
|
57
|
+
* What `claimNewWork` (`watch.ts`) schedules by — see `RefinedPrioritySchema`
|
|
58
|
+
* in `data_types.ts`. Deliberately a LABEL, not this repo's own GitHub
|
|
59
|
+
* Projects v2 "Priority" field (a single-select with its own Urgent/High/
|
|
60
|
+
* Medium/Low options): a Projects v2 value is GraphQL-only, needs a
|
|
61
|
+
* `project` token scope and a project id in config, and has no Jira
|
|
62
|
+
* equivalent — the exact abstraction `jira_provider.ts` exists to protect.
|
|
63
|
+
* If a repo's board also carries a Priority field, the two are independent
|
|
64
|
+
* and nothing reconciles them; `spf watch` obeys only this label. See
|
|
65
|
+
* README.md's `spf watch` section for the reconciliation-by-hand caveat.
|
|
66
|
+
*/
|
|
67
|
+
const PRIORITIES = ["p0", "p1", "p2", "p3"];
|
|
68
|
+
const PRIORITY_LABEL_META = {
|
|
69
|
+
p0: { color: "b60205", description: "drop everything — a broken promise to users, or blocking everything else" },
|
|
70
|
+
p1: { color: "d93f0b", description: "the spec's core value — the slices without which it isn't shipped" },
|
|
71
|
+
p2: { color: "fbca04", description: "the default — real scope, can wait a cycle" },
|
|
72
|
+
p3: { color: "c5def5", description: "worth writing down, not worth scheduling yet" },
|
|
73
|
+
};
|
|
37
74
|
export class GitHubProvider {
|
|
38
75
|
repo;
|
|
39
76
|
labelPrefix;
|
|
@@ -69,6 +106,10 @@ export class GitHubProvider {
|
|
|
69
106
|
typeLabel(kind) {
|
|
70
107
|
return `${this.labelPrefix}:type:${kind}`;
|
|
71
108
|
}
|
|
109
|
+
/** 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. */
|
|
110
|
+
priorityLabel(priority) {
|
|
111
|
+
return `${this.labelPrefix}:priority:${priority}`;
|
|
112
|
+
}
|
|
72
113
|
/** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
|
|
73
114
|
async getLabel(name) {
|
|
74
115
|
const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
|
|
@@ -116,6 +157,13 @@ export class GitHubProvider {
|
|
|
116
157
|
const { color, description } = TYPE_LABEL_META[kind];
|
|
117
158
|
await this.ensureOneLabel(this.typeLabel(kind), color, description, result);
|
|
118
159
|
}
|
|
160
|
+
// `<prefix>:priority:p0..p3` — what claimNewWork schedules by (see
|
|
161
|
+
// PRIORITY_LABEL_META's doc comment above on why this is a label, not
|
|
162
|
+
// this repo's own Projects v2 Priority field).
|
|
163
|
+
for (const priority of PRIORITIES) {
|
|
164
|
+
const { color, description } = PRIORITY_LABEL_META[priority];
|
|
165
|
+
await this.ensureOneLabel(this.priorityLabel(priority), color, description, result);
|
|
166
|
+
}
|
|
119
167
|
return result;
|
|
120
168
|
}
|
|
121
169
|
toIssue(raw) {
|
|
@@ -127,9 +175,36 @@ export class GitHubProvider {
|
|
|
127
175
|
labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
|
|
128
176
|
};
|
|
129
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* `sort=created&direction=asc` is stated, not inherited: without it,
|
|
180
|
+
* GitHub's own default (`created`, `desc` — newest first) is what
|
|
181
|
+
* `claimNewWork` used to walk, silently, which is why a >100-issue ready
|
|
182
|
+
* backlog used to be a real risk before pagination existed at all.
|
|
183
|
+
* Oldest-first is also `orderEligible`'s own final tiebreaker (`watch.ts`),
|
|
184
|
+
* so this method's order and that function's are the same order absent a
|
|
185
|
+
* priority/affinity difference — no redundant client-side re-sort needed
|
|
186
|
+
* for the plain case.
|
|
187
|
+
*
|
|
188
|
+
* Paginates up to `MAX_LIST_PAGES` (500 issues) — no longer "this
|
|
189
|
+
* version's problem to solve": a client-side priority sort over a
|
|
190
|
+
* truncated first page would silently misorder or hide real work, which is
|
|
191
|
+
* worse than the old unordered-100-issues behavior it replaces. A repo
|
|
192
|
+
* that still exceeds the cap gets a loud, named warning rather than a
|
|
193
|
+
* silent truncation.
|
|
194
|
+
*/
|
|
130
195
|
async listByLabel(label, state) {
|
|
131
|
-
const
|
|
132
|
-
|
|
196
|
+
const results = [];
|
|
197
|
+
for (let page = 1; page <= MAX_LIST_PAGES; page++) {
|
|
198
|
+
const raw = await this.gh(`/repos/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=${state}&sort=created&direction=asc&per_page=100&page=${page}`);
|
|
199
|
+
results.push(...raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i)));
|
|
200
|
+
if (raw.length < 100)
|
|
201
|
+
return results; // short page — this was the last one
|
|
202
|
+
if (page === MAX_LIST_PAGES) {
|
|
203
|
+
console.error(`spf watch: listByLabel(${JSON.stringify(label)}) hit the ${MAX_LIST_PAGES}-page (${MAX_LIST_PAGES * 100}-issue) cap — ` +
|
|
204
|
+
`older ${JSON.stringify(label)} issues past this cap are invisible this tick`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
133
208
|
}
|
|
134
209
|
async listEligible() {
|
|
135
210
|
return this.listByLabel(this.label("ready"), "open");
|
|
@@ -137,6 +212,24 @@ export class GitHubProvider {
|
|
|
137
212
|
async listInState(state, opts) {
|
|
138
213
|
return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
|
|
139
214
|
}
|
|
215
|
+
/** `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()`. */
|
|
216
|
+
async getIssue(id) {
|
|
217
|
+
const response = await fetch(`${API}/repos/${this.repo}/issues/${id}`, {
|
|
218
|
+
headers: { Authorization: `Bearer ${this.token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
219
|
+
});
|
|
220
|
+
if (response.status === 404)
|
|
221
|
+
return null;
|
|
222
|
+
if (!response.ok) {
|
|
223
|
+
const detail = await response.text().catch(() => "");
|
|
224
|
+
throw new Error(`GitHub GET /repos/${this.repo}/issues/${id} -> ${response.status}: ${detail.slice(0, 500)}`);
|
|
225
|
+
}
|
|
226
|
+
return this.toIssue((await response.json()));
|
|
227
|
+
}
|
|
228
|
+
/** `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. */
|
|
229
|
+
async listChildren(parent) {
|
|
230
|
+
const raw = await this.gh(`/repos/${this.repo}/issues/${parent.id}/sub_issues`);
|
|
231
|
+
return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
|
|
232
|
+
}
|
|
140
233
|
async claim(issue, opts) {
|
|
141
234
|
const from = this.label(opts?.from ?? "ready");
|
|
142
235
|
const to = this.label(opts?.to ?? "working");
|
|
@@ -209,7 +302,13 @@ export class GitHubProvider {
|
|
|
209
302
|
}
|
|
210
303
|
return { merged: detail.merged, state: detail.state, ciStatus };
|
|
211
304
|
}
|
|
212
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s
|
|
307
|
+
* module doc). `input.kind` is unused here: GitHub has no native
|
|
308
|
+
* issue-type field the way Jira does, and `input.labels` already carries
|
|
309
|
+
* `<prefix>:type:<kind>` for GitHub's own bookkeeping — the parameter
|
|
310
|
+
* exists on the shared interface for `JiraProvider`'s sake.
|
|
311
|
+
*/
|
|
213
312
|
async createIssue(input) {
|
|
214
313
|
const raw = await this.gh(`/repos/${this.repo}/issues`, {
|
|
215
314
|
method: "POST",
|