@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.
@@ -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: JiraProvider
220
- // doesn't implement IssueAuthoringProvider yet (see its module
221
- // comment) — a refine lane that can never publish would otherwise
222
- // just claim every spec-ready spec and block it, forever.
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" (issue authoring isn't implemented for Jira yet)`);
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. */
@@ -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 only implemented on
290
- // GitHubProvider today — see jira_provider.ts's module comment — so
291
- // this lane isn't offered at all on a Jira tracker rather than asking a
292
- // question that would just fail at `spf watch` startup.
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
- /** 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),
@@ -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