@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.
Files changed (58) hide show
  1. package/README.md +122 -27
  2. package/assets/prompts/refiner/system.md +11 -1
  3. package/assets/prompts/refiner/user.md +9 -3
  4. package/assets/skill/references/config.md +51 -13
  5. package/assets/templates/ts.spf.config.yaml +6 -2
  6. package/dist/chains/context.d.ts +26 -0
  7. package/dist/chains/simple_sdlc.js +9 -0
  8. package/dist/chains/steps.d.ts +0 -27
  9. package/dist/chains/steps.js +21 -2
  10. package/dist/cli/ask.d.ts +13 -0
  11. package/dist/cli/ask.js +15 -1
  12. package/dist/cli/commands/doctor.js +47 -9
  13. package/dist/cli/commands/fanout.js +49 -5
  14. package/dist/cli/commands/init.js +11 -3
  15. package/dist/cli/commands/list.d.ts +1 -1
  16. package/dist/cli/commands/list.js +31 -12
  17. package/dist/cli/commands/phases.d.ts +1 -1
  18. package/dist/cli/commands/phases.js +18 -4
  19. package/dist/cli/commands/run.js +30 -2
  20. package/dist/cli/commands/sessions.d.ts +1 -1
  21. package/dist/cli/commands/sessions.js +11 -3
  22. package/dist/cli/commands/watch.d.ts +8 -0
  23. package/dist/cli/commands/watch.js +93 -13
  24. package/dist/cli/index.js +4 -4
  25. package/dist/cli/interview.js +9 -5
  26. package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
  27. package/dist/cli/ui/fanout_dashboard.js +102 -0
  28. package/dist/cli/ui/ink_asker.d.ts +13 -0
  29. package/dist/cli/ui/ink_asker.js +247 -0
  30. package/dist/cli/ui/reports.d.ts +30 -0
  31. package/dist/cli/ui/reports.js +61 -0
  32. package/dist/cli/ui/run_dashboard.d.ts +15 -0
  33. package/dist/cli/ui/run_dashboard.js +131 -0
  34. package/dist/cli/ui/watch_dashboard.d.ts +22 -0
  35. package/dist/cli/ui/watch_dashboard.js +78 -0
  36. package/dist/core/console.d.ts +40 -1
  37. package/dist/core/console.js +25 -3
  38. package/dist/core/data_types.d.ts +108 -5
  39. package/dist/core/data_types.js +50 -5
  40. package/dist/core/fanout.d.ts +9 -0
  41. package/dist/core/fanout.js +6 -2
  42. package/dist/core/gates.js +24 -1
  43. package/dist/core/issues/github_provider.d.ts +39 -5
  44. package/dist/core/issues/github_provider.js +103 -4
  45. package/dist/core/issues/jira_provider.d.ts +79 -12
  46. package/dist/core/issues/jira_provider.js +97 -2
  47. package/dist/core/issues/provider.d.ts +73 -19
  48. package/dist/core/issues/provider.js +24 -7
  49. package/dist/core/notify/channel.d.ts +1 -1
  50. package/dist/core/refine.d.ts +45 -8
  51. package/dist/core/refine.js +98 -24
  52. package/dist/core/runner.d.ts +5 -1
  53. package/dist/core/runner.js +2 -1
  54. package/dist/core/session.d.ts +7 -1
  55. package/dist/core/session.js +5 -1
  56. package/dist/core/watch.d.ts +86 -3
  57. package/dist/core/watch.js +353 -29
  58. 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
- /** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
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; turning it on with
549
- * `issue_provider: jira` fails loudly at `spf watch` startup, since
550
- * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
551
- * yet see its module comment.
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;
@@ -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
- /** Only consulted when `issue_provider: jira`. Auth is `JIRA_EMAIL` + `JIRA_API_TOKEN` env vars, checked at startup like `GITHUB_TOKEN`. */
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; turning it on with
453
- * `issue_provider: jira` fails loudly at `spf watch` startup, since
454
- * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
455
- * yet see its module comment.
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),
@@ -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
@@ -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
- attempts.push(skipped(index));
287
+ const attempt = skipped(index);
288
+ attempts.push(attempt);
289
+ deps.onAttempt?.(attempt);
288
290
  continue;
289
291
  }
290
- attempts.push(await runOne(index));
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());
@@ -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. No pagination
14
- * beyond one page of 100: fine for the label-scoped queries a lean v1
15
- * makes (a repo with >100 open `<prefix>:ready` issues at once is not this
16
- * version's problem to solve).
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
- /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
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
- const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
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 raw = await this.gh(`/repos/${this.repo}/issues?labels=${encodeURIComponent(label)}&state=${state}&per_page=100`);
132
- return raw.filter((i) => !i.pull_request).map((i) => this.toIssue(i));
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
- /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
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",