@gr8ful/spf 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +106 -6
  2. package/assets/defaults/spf.config.yaml +16 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/skill/references/config.md +83 -3
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +22 -2
  8. package/dist/chains/context.d.ts +9 -0
  9. package/dist/chains/index.js +5 -0
  10. package/dist/chains/steps.d.ts +24 -0
  11. package/dist/chains/steps.js +55 -4
  12. package/dist/cli/commands/doctor.js +18 -0
  13. package/dist/cli/commands/init.js +44 -3
  14. package/dist/cli/commands/install-skill.js +5 -2
  15. package/dist/cli/commands/list.js +1 -0
  16. package/dist/cli/commands/run.js +5 -1
  17. package/dist/cli/commands/watch.js +86 -8
  18. package/dist/cli/index.js +7 -3
  19. package/dist/cli/interview.d.ts +2 -0
  20. package/dist/cli/interview.js +107 -3
  21. package/dist/core/agents.js +4 -1
  22. package/dist/core/console.d.ts +13 -1
  23. package/dist/core/console.js +51 -1
  24. package/dist/core/data_types.d.ts +133 -0
  25. package/dist/core/data_types.js +72 -0
  26. package/dist/core/gates.d.ts +13 -0
  27. package/dist/core/gates.js +103 -0
  28. package/dist/core/issues/github_provider.d.ts +35 -9
  29. package/dist/core/issues/github_provider.js +76 -28
  30. package/dist/core/issues/jira_provider.d.ts +14 -1
  31. package/dist/core/issues/jira_provider.js +9 -7
  32. package/dist/core/issues/provider.d.ts +77 -15
  33. package/dist/core/issues/provider.js +7 -4
  34. package/dist/core/notify/channel.d.ts +32 -0
  35. package/dist/core/notify/channel.js +14 -0
  36. package/dist/core/notify/notifier.d.ts +42 -0
  37. package/dist/core/notify/notifier.js +100 -0
  38. package/dist/core/notify/slack_channel.d.ts +13 -0
  39. package/dist/core/notify/slack_channel.js +30 -0
  40. package/dist/core/notify/teams_channel.d.ts +17 -0
  41. package/dist/core/notify/teams_channel.js +38 -0
  42. package/dist/core/notify/webhook_channel.d.ts +13 -0
  43. package/dist/core/notify/webhook_channel.js +19 -0
  44. package/dist/core/refine.d.ts +39 -0
  45. package/dist/core/refine.js +144 -0
  46. package/dist/core/runner.d.ts +7 -0
  47. package/dist/core/runner.js +4 -1
  48. package/dist/core/session.js +3 -0
  49. package/dist/core/watch.d.ts +66 -1
  50. package/dist/core/watch.js +267 -15
  51. package/dist/test/chains.test.js +1 -0
  52. package/dist/test/data_types.test.js +34 -1
  53. package/dist/test/init_command.test.js +17 -0
  54. package/dist/test/interview.test.js +119 -0
  55. package/dist/test/notify.test.d.ts +1 -0
  56. package/dist/test/notify.test.js +174 -0
  57. package/dist/test/refine.test.d.ts +1 -0
  58. package/dist/test/refine.test.js +126 -0
  59. package/dist/test/watch.test.js +286 -5
  60. package/package.json +1 -1
@@ -45,13 +45,21 @@ function panel(lines, title, borderColor) {
45
45
  export class Console {
46
46
  tracer;
47
47
  adwId;
48
+ notifier;
49
+ chainName;
48
50
  phaseId = ""; // current lane — log events attach to it
49
51
  phaseName = "";
50
52
  results = []; // phase statuses, for the summary
51
53
  finished = false; // the summary panel prints once
52
- constructor(tracer, adwId) {
54
+ constructor(tracer, adwId,
55
+ /** `null` when notifications are off — every call site below guards with `?.`. */
56
+ notifier = null,
57
+ /** The CLI chain name (`"plan-build-test"`), for a notification's title — see session.ts. */
58
+ chainName = "adw") {
53
59
  this.tracer = tracer;
54
60
  this.adwId = adwId;
61
+ this.notifier = notifier;
62
+ this.chainName = chainName;
55
63
  }
56
64
  // ── the one helper: print AND trace, always together ────────────────────
57
65
  emit(line, level = "info") {
@@ -67,6 +75,15 @@ export class Console {
67
75
  // ── session ─────────────────────────────────────────────────────────────
68
76
  sessionStarted(adwId, engineer) {
69
77
  this.emit(`${paint("bold cyan", "adw_id:")} ${paint("bold", adwId)} ${paint("dim", "engineer")} ${engineer}`);
78
+ this.notifier?.send({
79
+ kind: "run_started",
80
+ level: "info",
81
+ title: `run started — ${this.chainName}`,
82
+ fields: [
83
+ ["adw_id", adwId],
84
+ ["engineer", engineer],
85
+ ],
86
+ });
70
87
  }
71
88
  sessionFinished(ok, tokens, cost, dbPath) {
72
89
  if (this.finished)
@@ -93,6 +110,17 @@ export class Console {
93
110
  name: this.phaseName || "console",
94
111
  payload: { message: plain, level: ok ? "info" : "error" },
95
112
  }));
113
+ this.notifier?.send({
114
+ kind: ok ? "run_finished" : "run_failed",
115
+ level: ok ? "info" : "error",
116
+ title: `run ${ok ? "finished" : "failed"} — ${this.chainName}`,
117
+ fields: [
118
+ ["adw_id", this.adwId],
119
+ ["phases", `${passed}/${this.results.length}`],
120
+ ["tokens", tokens.toLocaleString()],
121
+ ["cost", `$${cost.toFixed(4)}`],
122
+ ],
123
+ });
96
124
  }
97
125
  // ── phases ──────────────────────────────────────────────────────────────
98
126
  phaseStarted(phase) {
@@ -113,6 +141,19 @@ export class Console {
113
141
  if (!ok && phase.error)
114
142
  line += ` ${paint("red", clip(phase.error))}`;
115
143
  this.emit(line, ok ? "info" : "error");
144
+ if (!ok) {
145
+ this.notifier?.send({
146
+ kind: "phase_failed",
147
+ level: "error",
148
+ title: `phase failed — ${phase.params.name}`,
149
+ detail: phase.error ?? undefined,
150
+ fields: [
151
+ ["adw_id", this.adwId],
152
+ ["chain", this.chainName],
153
+ ["owner", phase.params.owner],
154
+ ],
155
+ });
156
+ }
116
157
  this.phaseId = "";
117
158
  this.phaseName = "";
118
159
  }
@@ -129,6 +170,15 @@ export class Console {
129
170
  }
130
171
  retry(name, attempt, limit, reason) {
131
172
  this.emit(` ${paint("yellow", "⟳")} ${name} retry ${attempt}/${limit} ${paint("dim", `— same session · ${clip(reason)}`)}`, "warn");
173
+ // info-level: routine self-healing, same as watch's own untracked
174
+ // orphan retries — visible under `events: all`, silent under `errors`.
175
+ this.notifier?.send({
176
+ kind: "phase_retry",
177
+ level: "info",
178
+ title: `retry ${attempt}/${limit} — ${name}`,
179
+ detail: reason,
180
+ fields: [["adw_id", this.adwId]],
181
+ });
132
182
  }
133
183
  // ── verification ────────────────────────────────────────────────────────
134
184
  /** A gate reports WHAT it checked, not just whether it passed. */
@@ -150,6 +150,45 @@ export declare const DocumentOutput: EnvelopeType<{
150
150
  commit_message: string;
151
151
  }>;
152
152
  export type DocumentOutputT = v.InferOutput<typeof DocumentOutput.schema>;
153
+ /**
154
+ * One node in a decomposed product spec — a feature/epic container, or a
155
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
156
+ * emits a flat list far more reliably than a recursive tree, and a flat
157
+ * shape is what lets `blocked_by` reference ANY other node, container or
158
+ * leaf, not just siblings under the same parent.
159
+ *
160
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
161
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
162
+ * resolves `key`s to real issue numbers as it creates them, in dependency
163
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
164
+ * this list before publish ever runs (unique keys, resolvable references,
165
+ * no cycles, container/leaf kind agreement, at least one leaf).
166
+ */
167
+ export declare const RefinedIssueSchema: v.ObjectSchema<{
168
+ readonly key: v.StringSchema<undefined>;
169
+ readonly kind: v.PicklistSchema<["epic", "feature", "story", "bug", "task"], undefined>;
170
+ readonly title: v.StringSchema<undefined>;
171
+ readonly body: v.StringSchema<undefined>;
172
+ readonly parent: v.OptionalSchema<v.StringSchema<undefined>, "">;
173
+ readonly blocked_by: v.OptionalSchema<v.ArraySchema<v.StringSchema<undefined>, undefined>, () => never[]>;
174
+ }, undefined>;
175
+ export type RefinedIssue = v.InferOutput<typeof RefinedIssueSchema>;
176
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
177
+ export declare const RefineOutput: EnvelopeType<{
178
+ status: "fail" | "success";
179
+ summary: string;
180
+ artifacts: string[];
181
+ notes_for_next_agent: string;
182
+ issues: {
183
+ key: string;
184
+ kind: "bug" | "epic" | "feature" | "story" | "task";
185
+ title: string;
186
+ body: string;
187
+ parent: string;
188
+ blocked_by: string[];
189
+ }[];
190
+ }>;
191
+ export type RefineOutputT = v.InferOutput<typeof RefineOutput.schema>;
153
192
  export declare const QualityAreaSchema: v.PicklistSchema<["frontend", "backend"], undefined>;
154
193
  export type QualityArea = v.InferOutput<typeof QualityAreaSchema>;
155
194
  export declare const QualityOperationSchema: v.PicklistSchema<["lint", "typecheck", "build"], undefined>;
@@ -383,6 +422,22 @@ export declare const WatchJiraConfigSchema: v.ObjectSchema<{
383
422
  readonly project_key: v.OptionalSchema<v.StringSchema<undefined>, "">;
384
423
  }, undefined>;
385
424
  export type WatchJiraConfig = v.InferOutput<typeof WatchJiraConfigSchema>;
425
+ /**
426
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
427
+ * spec into a feature/story tree of real issues, instead of running
428
+ * `watch.chain` against it directly (a spec is not individually workable —
429
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
430
+ * behavior is unchanged by upgrading; turning it on with
431
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
432
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
433
+ * yet — see its module comment.
434
+ */
435
+ export declare const WatchRefineConfigSchema: v.ObjectSchema<{
436
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
437
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
438
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
439
+ }, undefined>;
440
+ export type WatchRefineConfig = v.InferOutput<typeof WatchRefineConfigSchema>;
386
441
  export declare const WatchConfigSchema: v.ObjectSchema<{
387
442
  readonly issue_provider: v.OptionalSchema<v.PicklistSchema<["github", "jira"], undefined>, "github">;
388
443
  readonly code_host: v.OptionalSchema<v.PicklistSchema<["github", "bitbucket"], undefined>, "github">;
@@ -399,8 +454,53 @@ export declare const WatchConfigSchema: v.ObjectSchema<{
399
454
  base_url: string;
400
455
  project_key: string;
401
456
  }>;
457
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
458
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
459
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
460
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
461
+ }, undefined>, () => {
462
+ enabled: boolean;
463
+ chain: string;
464
+ concurrency: number;
465
+ }>;
402
466
  }, undefined>;
403
467
  export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
468
+ /**
469
+ * Optional outbound push for unattended work (`spf watch`, any chain run) —
470
+ * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
471
+ * it stays console-only on purpose; see `core/notify/notifier.ts`.
472
+ *
473
+ * `events` is the whole filter: "off" sends nothing, "errors" sends only
474
+ * NotifyEvents whose `level` is "error", "all" sends every curated
475
+ * milestone. A channel's own `events` overrides the top-level scope for
476
+ * just that channel (e.g. Slack gets everything, Teams gets errors only).
477
+ *
478
+ * `webhook_url_env` names the .env key holding the secret URL — never the
479
+ * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
480
+ * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
481
+ */
482
+ export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "all"], undefined>;
483
+ export type NotifyScope = v.InferOutput<typeof NotifyScopeSchema>;
484
+ export declare const NotifyChannelKindSchema: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
485
+ export type NotifyChannelKind = v.InferOutput<typeof NotifyChannelKindSchema>;
486
+ export declare const NotifyChannelSchema: v.ObjectSchema<{
487
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
488
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
489
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
490
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
491
+ }, undefined>;
492
+ export type NotifyChannel = v.InferOutput<typeof NotifyChannelSchema>;
493
+ export declare const NotificationsConfigSchema: v.ObjectSchema<{
494
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
495
+ readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
496
+ readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
497
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
498
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
499
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
500
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
501
+ }, undefined>, undefined>, () => never[]>;
502
+ }, undefined>;
503
+ export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
404
504
  export declare const SFConfigSchema: v.ObjectSchema<{
405
505
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
406
506
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
@@ -480,6 +580,15 @@ export declare const SFConfigSchema: v.ObjectSchema<{
480
580
  base_url: string;
481
581
  project_key: string;
482
582
  }>;
583
+ readonly refine: v.OptionalSchema<v.ObjectSchema<{
584
+ readonly enabled: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
585
+ readonly chain: v.OptionalSchema<v.StringSchema<undefined>, "refine">;
586
+ readonly concurrency: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 1, undefined>]>, 1>;
587
+ }, undefined>, () => {
588
+ enabled: boolean;
589
+ chain: string;
590
+ concurrency: number;
591
+ }>;
483
592
  }, undefined>, () => {
484
593
  issue_provider: "github" | "jira";
485
594
  code_host: "bitbucket" | "github";
@@ -493,6 +602,30 @@ export declare const SFConfigSchema: v.ObjectSchema<{
493
602
  base_url: string;
494
603
  project_key: string;
495
604
  };
605
+ refine: {
606
+ enabled: boolean;
607
+ chain: string;
608
+ concurrency: number;
609
+ };
610
+ }>;
611
+ readonly notifications: v.OptionalSchema<v.ObjectSchema<{
612
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
613
+ readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
614
+ readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
615
+ readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
616
+ readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
617
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
618
+ readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
619
+ }, undefined>, undefined>, () => never[]>;
620
+ }, undefined>, () => {
621
+ events: "all" | "errors" | "off";
622
+ timeout_ms: number;
623
+ channels: {
624
+ kind: "slack" | "teams" | "webhook";
625
+ webhook_url_env: string;
626
+ events?: "all" | "errors" | "off" | null | undefined;
627
+ name: string;
628
+ }[];
496
629
  }>;
497
630
  }, undefined>;
498
631
  export type SFConfig = v.InferOutput<typeof SFConfigSchema>;
@@ -103,6 +103,32 @@ export const DocumentOutput = envelopeType("DocumentOutput", {
103
103
  documented_files: v.optional(v.array(v.string()), () => []),
104
104
  commit_message: v.optional(v.string(), ""),
105
105
  });
106
+ /**
107
+ * One node in a decomposed product spec — a feature/epic container, or a
108
+ * story/bug/task leaf. Flat with a `parent` key, not nested JSON: a model
109
+ * emits a flat list far more reliably than a recursive tree, and a flat
110
+ * shape is what lets `blocked_by` reference ANY other node, container or
111
+ * leaf, not just siblings under the same parent.
112
+ *
113
+ * `key` is the refiner's own local id for this run (e.g. "F1", "S1.1") —
114
+ * scoped to one `RefineOutput`, never a tracker id; `core/refine.ts`
115
+ * resolves `key`s to real issue numbers as it creates them, in dependency
116
+ * order. See `gates.refinementWellFormed` for the shape rules enforced on
117
+ * this list before publish ever runs (unique keys, resolvable references,
118
+ * no cycles, container/leaf kind agreement, at least one leaf).
119
+ */
120
+ export const RefinedIssueSchema = v.object({
121
+ key: v.string(),
122
+ kind: v.picklist(["epic", "feature", "story", "bug", "task"]),
123
+ title: v.string(),
124
+ body: v.string(), // "## What to build" / "## Acceptance criteria" — see assets/prompts/refiner/user.md
125
+ parent: v.optional(v.string(), ""), // another node's `key`; "" = top level
126
+ blocked_by: v.optional(v.array(v.string()), () => []), // other nodes' `key`s that must land first
127
+ });
128
+ /** A product spec decomposed into a feature/story tree — see `steps.refine()` and `core/refine.ts`. */
129
+ export const RefineOutput = envelopeType("RefineOutput", {
130
+ issues: v.optional(v.array(RefinedIssueSchema), () => []),
131
+ });
106
132
  // ── Deterministic quality blocks ─────────────────────────────────────────────
107
133
  export const QualityAreaSchema = v.picklist(["frontend", "backend"]);
108
134
  export const QualityOperationSchema = v.picklist(["lint", "typecheck", "build"]);
@@ -299,6 +325,21 @@ export const WatchJiraConfigSchema = v.object({
299
325
  base_url: v.optional(v.string(), ""), // e.g. "https://your-domain.atlassian.net"
300
326
  project_key: v.optional(v.string(), ""), // e.g. "PROJ"
301
327
  });
328
+ /**
329
+ * The second `spf watch` lane: decompose a `<prefix>:spec-ready` product
330
+ * spec into a feature/story tree of real issues, instead of running
331
+ * `watch.chain` against it directly (a spec is not individually workable —
332
+ * see `core/refine.ts`). Off by default so an existing `watch:` config's
333
+ * behavior is unchanged by upgrading; turning it on with
334
+ * `issue_provider: jira` fails loudly at `spf watch` startup, since
335
+ * `JiraProvider` doesn't implement `IssueAuthoringProvider` (create/link)
336
+ * yet — see its module comment.
337
+ */
338
+ export const WatchRefineConfigSchema = v.object({
339
+ enabled: v.optional(v.boolean(), false),
340
+ chain: v.optional(v.string(), "refine"),
341
+ concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 1),
342
+ });
302
343
  export const WatchConfigSchema = v.object({
303
344
  issue_provider: v.optional(WatchIssueProviderSchema, "github"),
304
345
  code_host: v.optional(WatchCodeHostSchema, "github"),
@@ -309,6 +350,36 @@ export const WatchConfigSchema = v.object({
309
350
  poll_ms: v.optional(v.number(), 60_000),
310
351
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
311
352
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
353
+ refine: v.optional(WatchRefineConfigSchema, () => v.parse(WatchRefineConfigSchema, {})),
354
+ });
355
+ /**
356
+ * Optional outbound push for unattended work (`spf watch`, any chain run) —
357
+ * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
358
+ * it stays console-only on purpose; see `core/notify/notifier.ts`.
359
+ *
360
+ * `events` is the whole filter: "off" sends nothing, "errors" sends only
361
+ * NotifyEvents whose `level` is "error", "all" sends every curated
362
+ * milestone. A channel's own `events` overrides the top-level scope for
363
+ * just that channel (e.g. Slack gets everything, Teams gets errors only).
364
+ *
365
+ * `webhook_url_env` names the .env key holding the secret URL — never the
366
+ * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
367
+ * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
368
+ */
369
+ export const NotifyScopeSchema = v.picklist(["off", "errors", "all"]);
370
+ export const NotifyChannelKindSchema = v.picklist(["slack", "teams", "webhook"]);
371
+ export const NotifyChannelSchema = v.object({
372
+ kind: NotifyChannelKindSchema,
373
+ webhook_url_env: v.optional(v.string(), ""),
374
+ events: v.optional(v.nullable(NotifyScopeSchema)),
375
+ // Shown in warning lines / message footers to tell two channels of the
376
+ // same kind apart (e.g. two webhook: entries) — cosmetic only.
377
+ name: v.optional(v.string(), ""),
378
+ });
379
+ export const NotificationsConfigSchema = v.object({
380
+ events: v.optional(NotifyScopeSchema, "off"),
381
+ timeout_ms: v.optional(v.number(), 5_000),
382
+ channels: v.optional(v.array(NotifyChannelSchema), () => []),
312
383
  });
313
384
  export const SFConfigSchema = v.object({
314
385
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
@@ -316,6 +387,7 @@ export const SFConfigSchema = v.object({
316
387
  agents: v.optional(v.array(AgentConfigSchema), () => []),
317
388
  quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
318
389
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
390
+ notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
319
391
  });
320
392
  export function makeEventRecord(input) {
321
393
  return {
@@ -29,5 +29,18 @@ export declare function diffMatchesClaims(envelope: EnvelopeBase, run: RunContex
29
29
  * reading a line of the diff.
30
30
  */
31
31
  export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
+ /**
33
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
34
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
35
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
36
+ * against itself, never anything already published: `core/refine.ts` never
37
+ * gets a chance to publish a malformed tree in the first place, because a
38
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
39
+ * ever hands off to `steps.publishIssues()`.
40
+ *
41
+ * "Container" and "leaf" are derived from the graph, not asserted by the
42
+ * agent: a node is a container iff some other node names it as `parent`.
43
+ */
44
+ export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
32
45
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
33
46
  export declare function testsPass(command: string): GateFn;
@@ -128,6 +128,109 @@ export function verdictConsistent(envelope, _run) {
128
128
  : "approved=false but no blocking item or unmet requirement was given");
129
129
  return report;
130
130
  }
131
+ const CONTAINER_KINDS = new Set(["epic", "feature"]);
132
+ const LEAF_KINDS = new Set(["story", "bug", "task"]);
133
+ /**
134
+ * The gate that turns `to-tickets`' flat, untyped ticket list into an
135
+ * actually-enforced feature/story-or-bug tree — see `RefinedIssueSchema`'s
136
+ * doc comment in `data_types.ts`. Checks the envelope's `issues` list
137
+ * against itself, never anything already published: `core/refine.ts` never
138
+ * gets a chance to publish a malformed tree in the first place, because a
139
+ * violation here re-prompts the SAME refiner session before `steps.refine()`
140
+ * ever hands off to `steps.publishIssues()`.
141
+ *
142
+ * "Container" and "leaf" are derived from the graph, not asserted by the
143
+ * agent: a node is a container iff some other node names it as `parent`.
144
+ */
145
+ export function refinementWellFormed(envelope, _run) {
146
+ const report = new GateReport();
147
+ const issues = envelope.issues ?? [];
148
+ if (issues.length === 0) {
149
+ report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
150
+ return report;
151
+ }
152
+ const byKey = new Map();
153
+ for (const issue of issues) {
154
+ if (byKey.has(issue.key)) {
155
+ report.check(`key ${JSON.stringify(issue.key)}`, false, "duplicate key — every node needs a unique key within this refinement");
156
+ }
157
+ else {
158
+ byKey.set(issue.key, issue);
159
+ }
160
+ }
161
+ const childKeys = new Set(); // keys named as some OTHER node's parent -> that node is a container
162
+ for (const issue of issues) {
163
+ if (!issue.parent)
164
+ continue;
165
+ if (!byKey.has(issue.parent)) {
166
+ report.check(`${issue.key}.parent`, false, `parent ${JSON.stringify(issue.parent)} does not match any issue's key`);
167
+ continue;
168
+ }
169
+ childKeys.add(issue.parent);
170
+ }
171
+ for (const issue of issues) {
172
+ for (const blocker of issue.blocked_by) {
173
+ if (!byKey.has(blocker)) {
174
+ report.check(`${issue.key}.blocked_by`, false, `blocked_by ${JSON.stringify(blocker)} does not match any issue's key`);
175
+ }
176
+ }
177
+ }
178
+ // Cycle check over the union of parent + blocked_by edges — both mean
179
+ // "must exist before this node" from core/refine.ts's own topological
180
+ // publish order, so a cycle in either (or across both) would hang it.
181
+ const WHITE = 0;
182
+ const GRAY = 1;
183
+ const BLACK = 2;
184
+ const color = new Map();
185
+ let cyclic = false;
186
+ const edgesFrom = (key) => {
187
+ const issue = byKey.get(key);
188
+ if (!issue)
189
+ return [];
190
+ const out = [];
191
+ if (issue.parent && byKey.has(issue.parent))
192
+ out.push(issue.parent);
193
+ for (const b of issue.blocked_by)
194
+ if (byKey.has(b))
195
+ out.push(b);
196
+ return out;
197
+ };
198
+ const visit = (key) => {
199
+ if (cyclic)
200
+ return;
201
+ color.set(key, GRAY);
202
+ for (const next of edgesFrom(key)) {
203
+ const c = color.get(next) ?? WHITE;
204
+ if (c === GRAY) {
205
+ cyclic = true;
206
+ return;
207
+ }
208
+ if (c === WHITE)
209
+ visit(next);
210
+ }
211
+ color.set(key, BLACK);
212
+ };
213
+ for (const issue of issues) {
214
+ if ((color.get(issue.key) ?? WHITE) === WHITE)
215
+ visit(issue.key);
216
+ }
217
+ report.check("dependency graph", !cyclic, cyclic ? "parent/blocked_by edges form a cycle — nothing to publish first" : "acyclic");
218
+ for (const issue of issues) {
219
+ const isContainer = childKeys.has(issue.key);
220
+ if (isContainer && !CONTAINER_KINDS.has(issue.kind)) {
221
+ report.check(`${issue.key}.kind`, false, `has children but kind is ${JSON.stringify(issue.kind)} — a container must be "epic" or "feature"`);
222
+ }
223
+ else if (!isContainer && !LEAF_KINDS.has(issue.kind)) {
224
+ report.check(`${issue.key}.kind`, false, `has no children but kind is ${JSON.stringify(issue.kind)} — a leaf must be "story", "bug", or "task"`);
225
+ }
226
+ else {
227
+ report.check(`${issue.key}.kind`, true, isContainer ? "container" : "leaf");
228
+ }
229
+ }
230
+ const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
231
+ report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
232
+ return report;
233
+ }
131
234
  /** Gate factory: the given shell command must exit 0, run from run.repo_root. */
132
235
  export function testsPass(command) {
133
236
  const gate = (_envelope, run) => {
@@ -1,10 +1,12 @@
1
1
  /**
2
- * GitHub REST implementation of both `IssueProvider` and `CodeHostProvider`
3
- * — one class, since GitHub natively is both an issue tracker and a code
4
- * host via Node 22's native `fetch()`: deliberately not `octokit`, whose
2
+ * GitHub REST implementation of `IssueProvider`, `CodeHostProvider`, AND
3
+ * `IssueAuthoringProvider` — one class, since GitHub natively is an issue
4
+ * tracker, a code host, and (via sub-issues) an issue-hierarchy API all at
5
+ * once — via Node 22's native `fetch()`: deliberately not `octokit`, whose
5
6
  * full meta-package resolves to ~82MB of installed dependencies (`@octokit/app`,
6
- * `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which
7
- * is six REST calls. `spf`'s own package stays dependency-free either way.
7
+ * `oauth-app`, `webhooks`, ...) for what `spf watch` actually needs, which is
8
+ * a couple dozen REST calls, none of them exotic. `spf`'s own package stays
9
+ * dependency-free either way.
8
10
  *
9
11
  * Auth is a classic PAT via `GITHUB_TOKEN` (`repo` scope), read once at
10
12
  * construction — matching the reference implementation's pattern and this
@@ -13,8 +15,11 @@
13
15
  * makes (a repo with >100 open `<prefix>:ready` issues at once is not this
14
16
  * version's problem to solve).
15
17
  */
16
- import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
17
- export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
18
+ import type { CodeHostProvider, EnsureLabelsResult, Issue, IssueAuthoringProvider, IssueProvider, PrRef, PrStatus, WatchMarker, WatchState } from "./provider.ts";
19
+ /** The refine lane's leaf/container taxonomy — see `data_types.ts`'s `RefinedIssueSchema.kind`. Not a `WatchState`: these never appear on the left of a `transition()` call, so `transition()` never strips them. */
20
+ export declare const ISSUE_KINDS: readonly ["epic", "feature", "story", "bug", "task"];
21
+ export type IssueKind = (typeof ISSUE_KINDS)[number];
22
+ export declare class GitHubProvider implements IssueProvider, CodeHostProvider, IssueAuthoringProvider {
18
23
  private readonly repo;
19
24
  private readonly labelPrefix;
20
25
  private readonly token;
@@ -22,14 +27,17 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
22
27
  labelPrefix: string, token: string);
23
28
  private gh;
24
29
  private label;
30
+ private typeLabel;
25
31
  /** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
26
32
  private getLabel;
27
33
  /**
28
- * Idempotent by inspection, not by "create and catch a 422": GET each
34
+ * Idempotent by inspection, not by "create and catch a 422": GET the
29
35
  * label first, then create/update/leave alone depending on what's
30
36
  * actually there. One fewer request in the common "already correct"
31
37
  * case, and no brittle matching against GitHub's error-message text.
38
+ * Shared by `ensureLabels()`'s state-label and type-label passes.
32
39
  */
40
+ private ensureOneLabel;
33
41
  ensureLabels(): Promise<EnsureLabelsResult>;
34
42
  private toIssue;
35
43
  private listByLabel;
@@ -37,7 +45,10 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
37
45
  listInState(state: WatchState, opts?: {
38
46
  includeAll?: boolean;
39
47
  }): Promise<Issue[]>;
40
- claim(issue: Issue): Promise<boolean>;
48
+ claim(issue: Issue, opts?: {
49
+ from?: WatchState;
50
+ to?: WatchState;
51
+ }): Promise<boolean>;
41
52
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
42
53
  comment(issue: Issue, body: string): Promise<void>;
43
54
  openPr(opts: {
@@ -47,6 +58,21 @@ export declare class GitHubProvider implements IssueProvider, CodeHostProvider {
47
58
  base: string;
48
59
  }): Promise<PrRef>;
49
60
  prStatus(pr: PrRef): Promise<PrStatus>;
61
+ /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
62
+ createIssue(input: {
63
+ title: string;
64
+ body: string;
65
+ labels: string[];
66
+ }): Promise<Issue>;
67
+ /**
68
+ * `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
69
+ * native sub-issue link. Confirmed against GitHub's own REST docs: the
70
+ * body param is `sub_issue_id`, the CHILD's database id, not its issue
71
+ * number — hence `linkChild` requiring `child.internal_id` rather than
72
+ * `child.id`. GitHub's documented limits (not enforced client-side here):
73
+ * 100 sub-issues per parent, 8 levels of nesting.
74
+ */
75
+ linkChild(parent: Issue, child: Issue): Promise<void>;
50
76
  private findMarkerComment;
51
77
  readMarker(issue: Issue): Promise<WatchMarker | null>;
52
78
  writeMarker(issue: Issue, marker: WatchMarker): Promise<void>;