@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
@@ -1,6 +1,8 @@
1
1
  const API = "https://api.github.com";
2
- const STATES = ["ready", "working", "review", "done", "blocked"];
2
+ const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
3
3
  const MARKER_RE = /<!--\s*spf-watch:\s*(\{.*?\})\s*-->/s;
4
+ /** 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. */
5
+ export const ISSUE_KINDS = ["epic", "feature", "story", "bug", "task"];
4
6
  // GitHub label colors are 6 hex digits, no leading '#'.
5
7
  const LABEL_META = {
6
8
  ready: { color: "0e8a16", description: "spf watch will claim this issue on its next poll" },
@@ -8,6 +10,16 @@ const LABEL_META = {
8
10
  review: { color: "1d76db", description: "spf watch opened a PR for this issue — awaiting merge" },
9
11
  done: { color: "5319e7", description: "spf watch's PR for this issue merged" },
10
12
  blocked: { color: "d93f0b", description: "spf watch gave up — needs a human" },
13
+ "spec-ready": { color: "0e8a16", description: "spf watch's refine lane will claim this spec on its next poll" },
14
+ refining: { color: "fbca04", description: "spf watch has claimed this spec and is decomposing it into issues" },
15
+ refined: { color: "c2e0c6", description: "generated by spf watch's refine lane — promote to spf:ready when it's worth building" },
16
+ };
17
+ const TYPE_LABEL_META = {
18
+ epic: { color: "5319e7", description: "a container generated by spf watch's refine lane — not directly workable" },
19
+ feature: { color: "1d76db", description: "a container generated by spf watch's refine lane — not directly workable" },
20
+ story: { color: "bfd4f2", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
21
+ bug: { color: "e99695", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
22
+ task: { color: "d4c5f9", description: "a leaf generated by spf watch's refine lane — vertical-slice, independently workable" },
11
23
  };
12
24
  export class GitHubProvider {
13
25
  repo;
@@ -41,6 +53,9 @@ export class GitHubProvider {
41
53
  label(state) {
42
54
  return `${this.labelPrefix}:${state}`;
43
55
  }
56
+ typeLabel(kind) {
57
+ return `${this.labelPrefix}:type:${kind}`;
58
+ }
44
59
  /** `null` on a real 404 (label doesn't exist yet) — any other non-2xx still throws, same as `gh()`. */
45
60
  async getLabel(name) {
46
61
  const response = await fetch(`${API}/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
@@ -55,39 +70,45 @@ export class GitHubProvider {
55
70
  return (await response.json());
56
71
  }
57
72
  /**
58
- * Idempotent by inspection, not by "create and catch a 422": GET each
73
+ * Idempotent by inspection, not by "create and catch a 422": GET the
59
74
  * label first, then create/update/leave alone depending on what's
60
75
  * actually there. One fewer request in the common "already correct"
61
76
  * case, and no brittle matching against GitHub's error-message text.
77
+ * Shared by `ensureLabels()`'s state-label and type-label passes.
62
78
  */
79
+ async ensureOneLabel(name, color, description, result) {
80
+ const existing = await this.getLabel(name);
81
+ if (!existing) {
82
+ await this.gh(`/repos/${this.repo}/labels`, { method: "POST", body: JSON.stringify({ name, color, description }) });
83
+ result.created.push(name);
84
+ }
85
+ else if (existing.color !== color || (existing.description ?? "") !== description) {
86
+ await this.gh(`/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
87
+ method: "PATCH",
88
+ body: JSON.stringify({ color, description }),
89
+ });
90
+ result.updated.push(name);
91
+ }
92
+ else {
93
+ result.unchanged.push(name);
94
+ }
95
+ }
63
96
  async ensureLabels() {
64
- const created = [];
65
- const updated = [];
66
- const unchanged = [];
97
+ const result = { created: [], updated: [], unchanged: [] };
67
98
  for (const state of STATES) {
68
- const name = this.label(state);
69
99
  const { color, description } = LABEL_META[state];
70
- const existing = await this.getLabel(name);
71
- if (!existing) {
72
- await this.gh(`/repos/${this.repo}/labels`, { method: "POST", body: JSON.stringify({ name, color, description }) });
73
- created.push(name);
74
- }
75
- else if (existing.color !== color || (existing.description ?? "") !== description) {
76
- await this.gh(`/repos/${this.repo}/labels/${encodeURIComponent(name)}`, {
77
- method: "PATCH",
78
- body: JSON.stringify({ color, description }),
79
- });
80
- updated.push(name);
81
- }
82
- else {
83
- unchanged.push(name);
84
- }
100
+ await this.ensureOneLabel(this.label(state), color, description, result);
85
101
  }
86
- return { created, updated, unchanged };
102
+ for (const kind of ISSUE_KINDS) {
103
+ const { color, description } = TYPE_LABEL_META[kind];
104
+ await this.ensureOneLabel(this.typeLabel(kind), color, description, result);
105
+ }
106
+ return result;
87
107
  }
88
108
  toIssue(raw) {
89
109
  return {
90
110
  id: String(raw.number),
111
+ internal_id: String(raw.id),
91
112
  title: raw.title,
92
113
  body: raw.body ?? "",
93
114
  labels: raw.labels.map((l) => (typeof l === "string" ? l : l.name)),
@@ -103,22 +124,24 @@ export class GitHubProvider {
103
124
  async listInState(state, opts) {
104
125
  return this.listByLabel(this.label(state), opts?.includeAll ? "all" : "open");
105
126
  }
106
- async claim(issue) {
107
- await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(this.label("ready"))}`, {
127
+ async claim(issue, opts) {
128
+ const from = this.label(opts?.from ?? "ready");
129
+ const to = this.label(opts?.to ?? "working");
130
+ await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels/${encodeURIComponent(from)}`, {
108
131
  method: "DELETE",
109
132
  }).catch(() => undefined); // already gone is fine
110
133
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
111
134
  method: "POST",
112
- body: JSON.stringify({ labels: [this.label("working")] }),
135
+ body: JSON.stringify({ labels: [to] }),
113
136
  });
114
137
  const fresh = await this.gh(`/repos/${this.repo}/issues/${issue.id}`);
115
138
  const labels = this.toIssue(fresh).labels;
116
- const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
139
+ const claimed = labels.includes(to) && !labels.includes(from);
117
140
  if (!claimed) {
118
- // Lost the race (or something else relabeled it) — put ready back so it's not stuck.
141
+ // Lost the race (or something else relabeled it) — put the source label back so it's not stuck.
119
142
  await this.gh(`/repos/${this.repo}/issues/${issue.id}/labels`, {
120
143
  method: "POST",
121
- body: JSON.stringify({ labels: [this.label("ready")] }),
144
+ body: JSON.stringify({ labels: [from] }),
122
145
  }).catch(() => undefined);
123
146
  }
124
147
  return claimed;
@@ -173,6 +196,31 @@ export class GitHubProvider {
173
196
  }
174
197
  return { merged: detail.merged, state: detail.state, ciStatus };
175
198
  }
199
+ /** `IssueAuthoringProvider` — the refine lane's own need (see `provider.ts`'s module doc). */
200
+ async createIssue(input) {
201
+ const raw = await this.gh(`/repos/${this.repo}/issues`, {
202
+ method: "POST",
203
+ body: JSON.stringify({ title: input.title, body: input.body, labels: input.labels }),
204
+ });
205
+ return this.toIssue(raw);
206
+ }
207
+ /**
208
+ * `POST /repos/{o}/{r}/issues/{parent_number}/sub_issues` — GitHub's
209
+ * native sub-issue link. Confirmed against GitHub's own REST docs: the
210
+ * body param is `sub_issue_id`, the CHILD's database id, not its issue
211
+ * number — hence `linkChild` requiring `child.internal_id` rather than
212
+ * `child.id`. GitHub's documented limits (not enforced client-side here):
213
+ * 100 sub-issues per parent, 8 levels of nesting.
214
+ */
215
+ async linkChild(parent, child) {
216
+ if (!child.internal_id) {
217
+ throw new Error(`linkChild: child issue #${child.id} has no internal_id — only an issue this provider just created/fetched can be linked`);
218
+ }
219
+ await this.gh(`/repos/${this.repo}/issues/${parent.id}/sub_issues`, {
220
+ method: "POST",
221
+ body: JSON.stringify({ sub_issue_id: Number(child.internal_id) }),
222
+ });
223
+ }
176
224
  async findMarkerComment(issueId) {
177
225
  const comments = await this.gh(`/repos/${this.repo}/issues/${issueId}/comments?per_page=100`);
178
226
  let found = null;
@@ -29,6 +29,16 @@
29
29
  * `ensureLabels()` is a no-op that reports the labels this run will use:
30
30
  * Jira labels are freeform strings with no color/description registry to
31
31
  * seed, unlike GitHub's.
32
+ *
33
+ * Does NOT implement `IssueAuthoringProvider` — the refine lane's create/
34
+ * link seam. Jira maps cleanly in principle (native issue types plus a
35
+ * `parent` field give a real hierarchy, unlike the label trick this file
36
+ * already leans on for state), but that is a real implementation, not a
37
+ * one-line stub, so it is a deliberate follow-on rather than done here.
38
+ * `resolveIssueAuthoringProvider()` (`cli/commands/watch.ts`) returns `null`
39
+ * for this provider, and `watch.refine.enabled: true` with
40
+ * `issue_provider: jira` fails loudly at startup rather than silently
41
+ * running a refine lane that can never publish anything.
32
42
  */
33
43
  import type { EnsureLabelsResult, Issue, IssueProvider, WatchMarker, WatchState } from "./provider.ts";
34
44
  export declare class JiraProvider implements IssueProvider {
@@ -63,7 +73,10 @@ export declare class JiraProvider implements IssueProvider {
63
73
  * could make a `review`-labeled issue vanish from an unfiltered query.
64
74
  */
65
75
  listInState(state: WatchState): Promise<Issue[]>;
66
- claim(issue: Issue): Promise<boolean>;
76
+ claim(issue: Issue, opts?: {
77
+ from?: WatchState;
78
+ to?: WatchState;
79
+ }): Promise<boolean>;
67
80
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
68
81
  comment(issue: Issue, body: string): Promise<void>;
69
82
  private findMarkerComment;
@@ -1,4 +1,4 @@
1
- const STATES = ["ready", "working", "review", "done", "blocked"];
1
+ const STATES = ["ready", "working", "review", "done", "blocked", "spec-ready", "refining", "refined"];
2
2
  const MARKER_RE = /\[spf-watch-marker\]\s*(\{.*?\})/s;
3
3
  function toAdf(text) {
4
4
  return {
@@ -103,16 +103,18 @@ export class JiraProvider {
103
103
  async listInState(state) {
104
104
  return this.searchByLabel(this.label(state));
105
105
  }
106
- async claim(issue) {
107
- const next = issue.labels.filter((l) => l !== this.label("ready"));
108
- next.push(this.label("working"));
106
+ async claim(issue, opts) {
107
+ const from = this.label(opts?.from ?? "ready");
108
+ const to = this.label(opts?.to ?? "working");
109
+ const next = issue.labels.filter((l) => l !== from);
110
+ next.push(to);
109
111
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: next } }) });
110
112
  const fresh = await this.jira(`/rest/api/3/issue/${issue.id}?fields=summary,description,labels`);
111
113
  const labels = fresh.fields.labels;
112
- const claimed = labels.includes(this.label("working")) && !labels.includes(this.label("ready"));
114
+ const claimed = labels.includes(to) && !labels.includes(from);
113
115
  if (!claimed) {
114
- const revert = labels.filter((l) => l !== this.label("working"));
115
- revert.push(this.label("ready"));
116
+ const revert = labels.filter((l) => l !== to);
117
+ revert.push(from);
116
118
  await this.jira(`/rest/api/3/issue/${issue.id}`, { method: "PUT", body: JSON.stringify({ fields: { labels: revert } }) }).catch(() => undefined);
117
119
  }
118
120
  return claimed;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The two seams `spf watch` drives — the abstraction the user's own
3
- * reference implementation (a GitHub-issues SDLC poller) never had: its
4
- * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself.
2
+ * The seams `spf watch` drives — the abstraction the user's own reference
3
+ * implementation (a GitHub-issues SDLC poller) never had: its GitHub client
4
+ * is a concrete class referenced by type everywhere, so adding a second
5
+ * tracker would mean reworking the poll loop itself.
6
6
  *
7
7
  * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
8
  * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
@@ -13,19 +13,45 @@
13
13
  * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
14
  * `CodeHostProvider` — any tracker x host combination is just config
15
15
  * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
16
+ * `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
17
+ * third, again separate — the refine lane's own need, optional per tracker,
18
+ * and orthogonal to which one is the code host.
16
19
  *
17
20
  * The label-as-state-machine design is deliberate, copied from that same
18
21
  * reference: `transition()` is the ONE mutator, so every state change is
19
22
  * traceable to one call site, and a provider can layer notifications
20
23
  * (Slack, a webhook, whatever) on top of it without the poll loop caring.
21
24
  */
22
- export type WatchState = "ready" | "working" | "review" | "done" | "blocked";
25
+ /**
26
+ * `spec-ready`/`refining` drive the SECOND lane's state machine (a product
27
+ * spec being decomposed — see `reconcileRefining`/`claimSpecs` in
28
+ * `watch.ts`), independent of the build lane's own `ready..blocked` states.
29
+ * `refined` is not a lane state at all — it never appears on the left of a
30
+ * `transition()` call. It is the terminal label a generated LEAF issue
31
+ * (story/bug/task) gets, marking it awaiting a human's promotion to `ready`.
32
+ * All eight still live in one `WatchState` union (not two separate unions)
33
+ * because `transition()`'s "strip every `<prefix>:<state>` label, then add
34
+ * one" logic (see `github_provider.ts`/`jira_provider.ts`) has to know about
35
+ * every one of them to strip correctly, and `ensureLabels()` seeds all of
36
+ * them from one `STATES` array.
37
+ */
38
+ export type WatchState = "ready" | "working" | "review" | "done" | "blocked" | "spec-ready" | "refining" | "refined";
23
39
  export interface Issue {
24
40
  /** Opaque tracker identifier: a GitHub issue number stringified ("42"), a Jira key ("PROJ-123"). */
25
41
  id: string;
26
42
  title: string;
27
43
  body: string;
28
44
  labels: string[];
45
+ /**
46
+ * The tracker's own internal/database id, distinct from `id` (the
47
+ * human-facing number/key) — only populated where an authoring operation
48
+ * needs it. GitHub's sub-issue API is the reason this exists: `POST
49
+ * /repos/{o}/{r}/issues/{n}/sub_issues` takes `sub_issue_id` as the
50
+ * issue's database id, not its issue number, so `linkChild()` cannot work
51
+ * from `id` alone. `undefined` on any issue this provider didn't just
52
+ * create/fetch with that field available.
53
+ */
54
+ internal_id?: string;
29
55
  }
30
56
  export interface PrRef {
31
57
  number: number;
@@ -42,12 +68,18 @@ export interface PrStatus {
42
68
  * on the issue itself — zero infrastructure, survives a daemon crash,
43
69
  * human-readable. `attempt` bounds orphan-retry (see `watch.ts`); `ciFixes`
44
70
  * is reserved for a future fix-loop, unused by the lean v1 poll logic.
71
+ * `refined` is the refine lane's own idempotency record: the ids of every
72
+ * issue a completed publish pass created for this spec. A re-claimed spec
73
+ * whose marker already lists them skips creation entirely — `to-tickets`
74
+ * (the skill this lane's prompt is ported from) has no such guard and
75
+ * duplicates every ticket on a re-run; this is what closes that gap.
45
76
  */
46
77
  export interface WatchMarker {
47
78
  worktree?: string;
48
79
  branch?: string;
49
80
  pr?: number;
50
81
  attempt?: number;
82
+ refined?: string[];
51
83
  }
52
84
  /** What `ensureLabels()` actually did, per label — for `spf watch init`'s report. */
53
85
  export interface EnsureLabelsResult {
@@ -58,11 +90,12 @@ export interface EnsureLabelsResult {
58
90
  export interface IssueProvider {
59
91
  /**
60
92
  * Idempotently seed whatever this tracker needs for the state machine to
61
- * work at all — GitHub: the five `<prefix>:*` labels, with a color and
62
- * description, created if missing and corrected if drifted. A tracker
63
- * with no such concept (Jira labels are freeform strings, not seedable
64
- * objects) can make this a no-op `spf watch init` just reports
65
- * whatever comes back, empty results included.
93
+ * work at all — GitHub: every `<prefix>:*` state label plus the
94
+ * `<prefix>:type:*` vocabulary the refine lane's generated issues carry,
95
+ * each with a color and description, created if missing and corrected if
96
+ * drifted. A tracker with no such concept (Jira labels are freeform
97
+ * strings, not seedable objects) can make this a no-op — `spf watch init`
98
+ * just reports whatever comes back, empty results included.
66
99
  */
67
100
  ensureLabels(): Promise<EnsureLabelsResult>;
68
101
  /** Issues currently labeled `<prefix>:ready`. */
@@ -81,12 +114,20 @@ export interface IssueProvider {
81
114
  includeAll?: boolean;
82
115
  }): Promise<Issue[]>;
83
116
  /**
84
- * Move `ready` -> `working`, with a read-back verify (like the reference
85
- * implementation's `claimIssue`) — not a true atomic claim, but enough to
86
- * catch the common case; the real safety net against two daemons racing
87
- * the same issue is `spf watch`'s own single-instance lockfile.
117
+ * Move `opts.from` (default `ready`) -> `opts.to` (default `working`),
118
+ * with a read-back verify (like the reference implementation's
119
+ * `claimIssue`) not a true atomic claim, but enough to catch the common
120
+ * case; the real safety net against two daemons racing the same issue is
121
+ * `spf watch`'s own single-instance lockfile. Parameterized so the refine
122
+ * lane's `spec-ready -> refining` claim (see `claimSpecs` in `watch.ts`)
123
+ * reuses the identical DELETE-from/POST-to/read-back-verify dance the
124
+ * build lane's `ready -> working` claim already does, rather than a
125
+ * second copy of it per provider.
88
126
  */
89
- claim(issue: Issue): Promise<boolean>;
127
+ claim(issue: Issue, opts?: {
128
+ from?: WatchState;
129
+ to?: WatchState;
130
+ }): Promise<boolean>;
90
131
  /** The one state-mutating call. `detail`, if given, is also posted as a comment. */
91
132
  transition(issue: Issue, to: WatchState, detail?: string): Promise<void>;
92
133
  comment(issue: Issue, body: string): Promise<void>;
@@ -111,3 +152,24 @@ export interface CodeHostProvider {
111
152
  }): Promise<PrRef>;
112
153
  prStatus(pr: PrRef): Promise<PrStatus>;
113
154
  }
155
+ /**
156
+ * The third seam: creating issues and linking them into a hierarchy — what
157
+ * the refine lane needs and neither `IssueProvider` nor `CodeHostProvider`
158
+ * provides (a tracker's read/claim/transition surface has no reason to
159
+ * create new work items). Kept separate rather than folded into
160
+ * `IssueProvider` for the same reason `CodeHostProvider` is separate: not
161
+ * every tracker can do this (Jira could, in principle, via its native issue
162
+ * types + `parent` field, but that is a real future implementation, not a
163
+ * one-line stub — see `jira_provider.ts`'s module comment), and a tracker
164
+ * that can't should be a `null` from `resolveIssueAuthoringProvider()`
165
+ * (`cli/commands/watch.ts`), not a method that throws at call time.
166
+ */
167
+ export interface IssueAuthoringProvider {
168
+ createIssue(input: {
169
+ title: string;
170
+ body: string;
171
+ labels: string[];
172
+ }): Promise<Issue>;
173
+ /** Link `child` under `parent` using the tracker's native hierarchy — GitHub's sub-issues API today. */
174
+ linkChild(parent: Issue, child: Issue): Promise<void>;
175
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The two seams `spf watch` drives — the abstraction the user's own
3
- * reference implementation (a GitHub-issues SDLC poller) never had: its
4
- * GitHub client is a concrete class referenced by type everywhere, so
5
- * adding a second tracker would mean reworking the poll loop itself.
2
+ * The seams `spf watch` drives — the abstraction the user's own reference
3
+ * implementation (a GitHub-issues SDLC poller) never had: its GitHub client
4
+ * is a concrete class referenced by type everywhere, so adding a second
5
+ * tracker would mean reworking the poll loop itself.
6
6
  *
7
7
  * `IssueProvider` (tracker: list/claim/transition/comment/markers) and
8
8
  * `CodeHostProvider` (PR lifecycle: open/status) are deliberately separate
@@ -13,6 +13,9 @@
13
13
  * implements only `IssueProvider`, `bitbucket_provider.ts` only
14
14
  * `CodeHostProvider` — any tracker x host combination is just config
15
15
  * (`watch.issue_provider` x `watch.code_host`), never a poll-loop change.
16
+ * `IssueAuthoringProvider` (create/link, at the bottom of this file) is a
17
+ * third, again separate — the refine lane's own need, optional per tracker,
18
+ * and orthogonal to which one is the code host.
16
19
  *
17
20
  * The label-as-state-machine design is deliberate, copied from that same
18
21
  * reference: `transition()` is the ONE mutator, so every state change is
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The vocabulary a `NotificationChannel` speaks, and the seam itself —
3
+ * mirrors `core/issues/provider.ts`'s shape (an interface per concern,
4
+ * one small implementation module per backend).
5
+ *
6
+ * `NotifyKind` is a curated set of milestones, deliberately not the raw
7
+ * tracer event stream (`Tracer.event()` — see `core/tracer.ts`): a single
8
+ * chain run emits hundreds of `tool_call`/`log` events, which would need
9
+ * batching/rate-limiting to be a usable Slack message and would cut against
10
+ * the tracer's own "no push transport" design. `level` is the ENTIRE filter
11
+ * predicate a `Notifier` applies — no separate per-kind severity table to
12
+ * keep in sync with this list.
13
+ */
14
+ export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked" | "spec_refined";
15
+ export interface NotifyEvent {
16
+ kind: NotifyKind;
17
+ /** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
18
+ level: "info" | "error";
19
+ /** One line, e.g. "run failed — plan-build-test". */
20
+ title: string;
21
+ /** The error text / PR body / block detail, if any. */
22
+ detail?: string;
23
+ /** Ordered label/value pairs — adw_id, chain, phase, tokens, cost, issue, pr, repo, ... */
24
+ fields: Array<[string, string]>;
25
+ /** A PR or issue link, when there is one. */
26
+ url?: string;
27
+ }
28
+ export interface NotificationChannel {
29
+ /** For warning lines — "slack", "teams (ops-bus)". */
30
+ readonly label: string;
31
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
32
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The vocabulary a `NotificationChannel` speaks, and the seam itself —
3
+ * mirrors `core/issues/provider.ts`'s shape (an interface per concern,
4
+ * one small implementation module per backend).
5
+ *
6
+ * `NotifyKind` is a curated set of milestones, deliberately not the raw
7
+ * tracer event stream (`Tracer.event()` — see `core/tracer.ts`): a single
8
+ * chain run emits hundreds of `tool_call`/`log` events, which would need
9
+ * batching/rate-limiting to be a usable Slack message and would cut against
10
+ * the tracer's own "no push transport" design. `level` is the ENTIRE filter
11
+ * predicate a `Notifier` applies — no separate per-kind severity table to
12
+ * keep in sync with this list.
13
+ */
14
+ export {};
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Filter, fan-out, and lifecycle for outbound notifications. A notification
3
+ * must never be able to break a run: `send()` never throws and never
4
+ * blocks — it fires the request and tracks it in a pending set, and a
5
+ * failure is swallowed after logging one line (the URL itself never
6
+ * appears in that line, or anywhere else — see `resolveNotifier` below).
7
+ *
8
+ * `resolveNotifier` returns `null` when notifications are off or no
9
+ * channel resolved, so every call site uses the same `notifier?.send(...)`
10
+ * shape as an optional dependency, not a conditional branch.
11
+ */
12
+ import type { NotifyEvent, NotificationChannel } from "./channel.ts";
13
+ import type { NotifyScope, SFConfig } from "../data_types.ts";
14
+ /** Exported so `spf doctor` and the init interview can name the same key without duplicating this table. */
15
+ export declare const DEFAULT_NOTIFY_ENV_KEY: Record<string, string>;
16
+ export declare class Notifier {
17
+ private readonly channels;
18
+ private readonly timeoutMs;
19
+ private readonly dryRun;
20
+ private readonly log;
21
+ private pending;
22
+ constructor(channels: Array<{
23
+ channel: NotificationChannel;
24
+ scope: NotifyScope;
25
+ }>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
26
+ /** Sync, fire-and-forget — every call site is sync and must stay that way. */
27
+ send(event: NotifyEvent): void;
28
+ /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
29
+ flush(): Promise<void>;
30
+ }
31
+ /**
32
+ * Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
33
+ * channel resolved. A channel whose env var isn't set is skipped with one
34
+ * warning naming the missing key — never a hard failure, since a broken
35
+ * notification setup shouldn't stop the work it's supposed to report on.
36
+ */
37
+ export declare function resolveNotifier(cfg: SFConfig, opts?: {
38
+ dryRun?: boolean;
39
+ log?: (message: string) => void;
40
+ }): Notifier | null;
41
+ /** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
42
+ export declare function flushAll(): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { SlackChannel } from "./slack_channel.js";
2
+ import { TeamsChannel } from "./teams_channel.js";
3
+ import { WebhookChannel } from "./webhook_channel.js";
4
+ /** Exported so `spf doctor` and the init interview can name the same key without duplicating this table. */
5
+ export const DEFAULT_NOTIFY_ENV_KEY = {
6
+ slack: "SLACK_WEBHOOK_URL",
7
+ teams: "TEAMS_WEBHOOK_URL",
8
+ webhook: "SPF_WEBHOOK_URL",
9
+ };
10
+ /** `errors` mode only sends `level: "error"`; `all` sends everything; `off` sends nothing. */
11
+ function scopeAllows(scope, level) {
12
+ if (scope === "off")
13
+ return false;
14
+ if (scope === "all")
15
+ return true;
16
+ return level === "error";
17
+ }
18
+ export class Notifier {
19
+ channels;
20
+ timeoutMs;
21
+ dryRun;
22
+ log;
23
+ pending = new Set();
24
+ constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m)) {
25
+ this.channels = channels;
26
+ this.timeoutMs = timeoutMs;
27
+ this.dryRun = dryRun;
28
+ this.log = log;
29
+ }
30
+ /** Sync, fire-and-forget — every call site is sync and must stay that way. */
31
+ send(event) {
32
+ for (const { channel, scope } of this.channels) {
33
+ if (!scopeAllows(scope, event.level))
34
+ continue;
35
+ if (this.dryRun) {
36
+ this.log(`spf: would notify (${channel.label}): ${event.title}`);
37
+ continue;
38
+ }
39
+ const task = channel.send(event, this.timeoutMs).catch((error) => {
40
+ this.log(`spf: ${channel.label} notification failed: ${error.message}`);
41
+ });
42
+ this.pending.add(task);
43
+ task.finally(() => this.pending.delete(task));
44
+ }
45
+ }
46
+ /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
47
+ async flush() {
48
+ await Promise.all([...this.pending]);
49
+ }
50
+ }
51
+ function makeChannel(kind, url, name) {
52
+ switch (kind) {
53
+ case "slack":
54
+ return new SlackChannel(url, name);
55
+ case "teams":
56
+ return new TeamsChannel(url, name);
57
+ case "webhook":
58
+ return new WebhookChannel(url, name);
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ // Every live Notifier this process created — so the CLI's shutdown path can
64
+ // flush all of them without threading a handle through every call site,
65
+ // matching agent_flue.shutdown()/agent_cc.shutdown()'s module-level shape.
66
+ const LIVE = [];
67
+ /**
68
+ * Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
69
+ * channel resolved. A channel whose env var isn't set is skipped with one
70
+ * warning naming the missing key — never a hard failure, since a broken
71
+ * notification setup shouldn't stop the work it's supposed to report on.
72
+ */
73
+ export function resolveNotifier(cfg, opts = {}) {
74
+ const nc = cfg.notifications;
75
+ if (nc.events === "off" || nc.channels.length === 0)
76
+ return null;
77
+ const log = opts.log ?? ((m) => console.error(m));
78
+ const resolved = [];
79
+ for (const entry of nc.channels) {
80
+ const envKey = entry.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[entry.kind];
81
+ const url = process.env[envKey];
82
+ if (!url) {
83
+ log(`spf: notifications.channels[kind=${entry.kind}] is configured but ${envKey} is not set — skipping this channel`);
84
+ continue;
85
+ }
86
+ const channel = makeChannel(entry.kind, url, entry.name);
87
+ if (!channel)
88
+ continue;
89
+ resolved.push({ channel, scope: entry.events ?? nc.events });
90
+ }
91
+ if (resolved.length === 0)
92
+ return null;
93
+ const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log);
94
+ LIVE.push(notifier);
95
+ return notifier;
96
+ }
97
+ /** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
98
+ export async function flushAll() {
99
+ await Promise.all(LIVE.map((n) => n.flush()));
100
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Slack Incoming Webhook — a plain `POST` of a Block Kit payload, via native
3
+ * `fetch()` (same no-dependency stance as `core/issues/github_provider.ts`).
4
+ * Set up: Slack app -> Incoming Webhooks -> "Add New Webhook to Workspace".
5
+ * https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks
6
+ */
7
+ import type { NotificationChannel, NotifyEvent } from "./channel.ts";
8
+ export declare class SlackChannel implements NotificationChannel {
9
+ private readonly webhookUrl;
10
+ readonly label: string;
11
+ constructor(webhookUrl: string, name?: string);
12
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
13
+ }
@@ -0,0 +1,30 @@
1
+ export class SlackChannel {
2
+ webhookUrl;
3
+ label;
4
+ constructor(webhookUrl, name = "") {
5
+ this.webhookUrl = webhookUrl;
6
+ this.label = name ? `slack (${name})` : "slack";
7
+ }
8
+ async send(event, timeoutMs) {
9
+ const emoji = event.level === "error" ? ":x:" : ":white_check_mark:";
10
+ const fieldsText = event.fields.map(([k, v]) => `*${k}:* ${v}`).join(" · ");
11
+ const body = {
12
+ text: `${emoji} ${event.title}`,
13
+ blocks: [
14
+ { type: "section", text: { type: "mrkdwn", text: `${emoji} *${event.title}*` } },
15
+ ...(event.detail ? [{ type: "section", text: { type: "mrkdwn", text: event.detail.slice(0, 2900) } }] : []),
16
+ ...(fieldsText ? [{ type: "context", elements: [{ type: "mrkdwn", text: fieldsText }] }] : []),
17
+ ...(event.url ? [{ type: "section", text: { type: "mrkdwn", text: `<${event.url}|open>` } }] : []),
18
+ ],
19
+ };
20
+ const response = await fetch(this.webhookUrl, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.timeout(timeoutMs),
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`slack webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
28
+ }
29
+ }
30
+ }