@gr8ful/spf 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -839,6 +839,22 @@ Getting each channel's URL:
839
839
  - **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
840
840
  n8n, Zapier, a homegrown receiver.
841
841
 
842
+ One webhook, many `spf` instances: if several repos' `spf watch` (or
843
+ `spf run`) all post to the same Slack/Teams/webhook endpoint, set
844
+ `notifications.project` to a short label so messages from each are
845
+ distinguishable — it prefixes every title (`[api] watch: reconcileOrphans
846
+ error`) and adds a `repo` field. Left unset, it falls back to `watch.repo`,
847
+ so most `spf watch` setups need nothing extra; set it explicitly when
848
+ `watch.repo` is blank or two watched repos share a basename.
849
+
850
+ ```yaml
851
+ notifications:
852
+ events: attention
853
+ project: api # optional; defaults to watch.repo
854
+ channels:
855
+ - kind: slack
856
+ ```
857
+
842
858
  Delivery never blocks or fails a run: an unconfigured/misconfigured channel
843
859
  is skipped with one warning, and a failed POST logs one line and is
844
860
  swallowed — never changes a run's exit code. One thing worth knowing under
@@ -277,6 +277,7 @@ default; adding it is entirely additive.
277
277
  |---|---|---|
278
278
  | `events` | `"off"` \| `"errors"` \| `"attention"` \| `"all"` | The whole filter, narrowest to widest. `off` (default): nothing. `errors`: only true failures — failed runs/phases (`run_failed`/`phase_failed`), `watch_error`. `attention`: `errors` plus anything needing a human but not itself a failure — a blocked issue (`issue_blocked`) or a spec needing feedback (`spec_needs_feedback`). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus `attention` and `errors`. |
279
279
  | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
280
+ | `project` | string | Label prefixed onto every outbound title (`[api] watch: ...`) and added as a `repo` field — for disambiguating multiple `spf` instances that share one webhook. Default `""`, which falls back to `watch.repo`. |
280
281
  | `channels[]` | array | See below. |
281
282
 
282
283
  `channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
@@ -1023,6 +1023,15 @@ export async function doctorCommand(argv) {
1023
1023
  const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
1024
1024
  check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
1025
1025
  }
1026
+ // See `NotificationsConfigSchema.project`'s doc comment / `resolveNotifier`'s
1027
+ // same fallback — informational only (never fails doctor), since an
1028
+ // unset project tag is harmless unless this webhook ends up shared.
1029
+ if (cfg.notifications.channels.length > 0) {
1030
+ const project = cfg.notifications.project.trim() || cfg.watch.repo.trim();
1031
+ check(report, "notifications.project", true, project
1032
+ ? `tag: "${project}"`
1033
+ : "not set, and watch.repo is empty — outbound messages won't carry a repo/project tag; if this webhook is ever shared across multiple spf instances, set notifications.project to tell them apart", project ? "info" : "warn");
1034
+ }
1026
1035
  }
1027
1036
  return finish(report, flags["json"]);
1028
1037
  }
@@ -527,6 +527,15 @@ export async function runInterview(asker, ctx) {
527
527
  const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
528
528
  if (timeoutMs !== "5000")
529
529
  notifications.timeout_ms = Number(timeoutMs);
530
+ // Only matters when one Slack/Teams/webhook endpoint is shared across
531
+ // several `spf` instances — defaults to watch.repo (resolveNotifier's
532
+ // own fallback), so most single-repo setups can just accept it and
533
+ // write nothing extra into the generated config.
534
+ const repoDefault = watch?.repo || "";
535
+ asker.note("Tags every outbound title/field so messages are distinguishable if this webhook is shared across repos — defaults to watch.repo.");
536
+ const project = await asker.text("notifications.project", { default: repoDefault });
537
+ if (project !== repoDefault)
538
+ notifications.project = project;
530
539
  }
531
540
  }
532
541
  // ── 6. review + confirm ─────────────────────────────────────────────────────
@@ -1099,6 +1099,7 @@ export declare const NotificationsConfigSchema: v.ObjectSchema<{
1099
1099
  readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
1100
1100
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1101
1101
  }, undefined>, undefined>, () => never[]>;
1102
+ readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
1102
1103
  }, undefined>;
1103
1104
  export type NotificationsConfig = v.InferOutput<typeof NotificationsConfigSchema>;
1104
1105
  /**
@@ -1443,6 +1444,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1443
1444
  readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
1444
1445
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1445
1446
  }, undefined>, undefined>, () => never[]>;
1447
+ readonly project: v.OptionalSchema<v.StringSchema<undefined>, "">;
1446
1448
  }, undefined>, () => {
1447
1449
  events: "all" | "attention" | "errors" | "off";
1448
1450
  timeout_ms: number;
@@ -1452,6 +1454,7 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1452
1454
  events?: "all" | "attention" | "errors" | "off" | null | undefined;
1453
1455
  name: string;
1454
1456
  }[];
1457
+ project: string;
1455
1458
  }>;
1456
1459
  readonly review: v.OptionalSchema<v.ObjectSchema<{
1457
1460
  readonly require_human_signoff: v.OptionalSchema<v.BooleanSchema<undefined>, false>;
@@ -834,6 +834,13 @@ export const NotificationsConfigSchema = v.object({
834
834
  events: v.optional(NotifyScopeSchema, "off"),
835
835
  timeout_ms: v.optional(v.number(), 5_000),
836
836
  channels: v.optional(v.array(NotifyChannelSchema), () => []),
837
+ // Prefixed onto every outbound title (e.g. "[api] watch: ..." ) and added
838
+ // as a `repo` field, so one Slack/Teams/webhook endpoint shared across
839
+ // several `spf watch` instances (one per repo) can tell them apart.
840
+ // Empty means "derive from watch.repo" (see `resolveNotifier`) — set this
841
+ // explicitly only when that repo string isn't a good enough label, e.g.
842
+ // it's blank, or two watched repos share a basename.
843
+ project: v.optional(v.string(), ""),
837
844
  });
838
845
  /**
839
846
  * `simple_sdlc`'s human-signoff gate — see the ACCEPTED ADVERSARIAL
@@ -18,13 +18,14 @@ export declare class Notifier {
18
18
  private readonly timeoutMs;
19
19
  private readonly dryRun;
20
20
  private readonly log;
21
+ private readonly project;
21
22
  private pending;
22
23
  constructor(channels: Array<{
23
24
  channel: NotificationChannel;
24
25
  scope: NotifyScope;
25
- }>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
26
+ }>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void, project?: string);
26
27
  /** Sync, fire-and-forget — every call site is sync and must stay that way. */
27
- send(event: NotifyEvent): void;
28
+ send(rawEvent: NotifyEvent): void;
28
29
  /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
29
30
  flush(): Promise<void>;
30
31
  /**
@@ -25,15 +25,21 @@ export class Notifier {
25
25
  timeoutMs;
26
26
  dryRun;
27
27
  log;
28
+ project;
28
29
  pending = new Set();
29
- constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m)) {
30
+ constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m),
31
+ // See `NotificationsConfigSchema.project`'s doc comment — empty disables
32
+ // tagging entirely, so a single-repo setup's outbound JSON is unchanged.
33
+ project = "") {
30
34
  this.channels = channels;
31
35
  this.timeoutMs = timeoutMs;
32
36
  this.dryRun = dryRun;
33
37
  this.log = log;
38
+ this.project = project;
34
39
  }
35
40
  /** Sync, fire-and-forget — every call site is sync and must stay that way. */
36
- send(event) {
41
+ send(rawEvent) {
42
+ const event = this.project ? tagEvent(rawEvent, this.project) : rawEvent;
37
43
  for (const { channel, scope } of this.channels) {
38
44
  if (!scopeAllows(scope, event.level))
39
45
  continue;
@@ -79,6 +85,23 @@ export class Notifier {
79
85
  }
80
86
  }
81
87
  }
88
+ /**
89
+ * Prefixes `event.title` with `[project]` and adds a `repo` field, so a
90
+ * webhook shared by several `spf watch` instances reads clearly even
91
+ * collapsed to one line (Slack's notification/thread-list view only shows
92
+ * `title`, never `fields`). Skips the field when one's already there
93
+ * (`watch_started`/`watch_stopped` already carry their own `repo` field —
94
+ * see `cli/commands/watch.ts`) rather than emit a duplicate key with the
95
+ * same value.
96
+ */
97
+ function tagEvent(event, project) {
98
+ const hasRepo = event.fields.some(([key]) => key === "repo");
99
+ return {
100
+ ...event,
101
+ title: `[${project}] ${event.title}`,
102
+ fields: hasRepo ? event.fields : [["repo", project], ...event.fields],
103
+ };
104
+ }
82
105
  function makeChannel(kind, url, name) {
83
106
  switch (kind) {
84
107
  case "slack":
@@ -121,7 +144,13 @@ export function resolveNotifier(cfg, opts = {}) {
121
144
  }
122
145
  if (resolved.length === 0)
123
146
  return null;
124
- const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log);
147
+ // See `NotificationsConfigSchema.project`'s doc comment: an explicit
148
+ // `notifications.project` wins; otherwise fall back to `watch.repo`
149
+ // (present whenever `spf watch` is what's sending — the common case for a
150
+ // shared webhook), and empty (a one-off `spf run` with no watch.repo set)
151
+ // disables tagging, same as today.
152
+ const project = nc.project.trim() || cfg.watch.repo.trim();
153
+ const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log, project);
125
154
  LIVE.push(notifier);
126
155
  return notifier;
127
156
  }
@@ -24,11 +24,11 @@ export declare function newId(length?: number): string;
24
24
  * retry before it's allowed to throw. Node's global `fetch` (undici) pools
25
25
  * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
26
26
  * Bitbucket) close idle ones from their end, which surfaces here as
27
- * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
28
- * a stale-socket race, not a real problem with the request. Only retries
29
- * error codes that mean "the transport failed," never an HTTP error status
30
- * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
31
- * on the first attempt so callers see it immediately).
27
+ * `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
28
+ * watch`) reuses one — a stale-socket race, not a real problem with the
29
+ * request. Only retries error codes that mean "the transport failed," never
30
+ * an HTTP error status (a 4xx/5xx response is not a thrown error here, and
31
+ * must keep surfacing on the first attempt so callers see it immediately).
32
32
  */
33
33
  export declare function fetchRetryTransient(input: string, init?: RequestInit): Promise<Response>;
34
34
  /** `process.kill(pid, 0)` sends no signal — it throws iff `pid` isn't running (or isn't ours to signal), the standard Node liveness probe. */
@@ -31,18 +31,25 @@ export function operatorEnv() {
31
31
  export function newId(length = 8) {
32
32
  return randomBytes(Math.floor(length / 2)).toString("hex");
33
33
  }
34
- /** Transport-level blips worth one silent retry — see `fetchRetryTransient` below. */
35
- const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN"]);
34
+ /**
35
+ * Transport-level blips worth one silent retry — see `fetchRetryTransient`
36
+ * below. `UND_ERR_SOCKET` is undici's own code for the same stale-keep-alive
37
+ * race as `ECONNRESET` (the far end closes a pooled socket between calls);
38
+ * without it here, every `spf watch` poll tick that lands on one of those
39
+ * sockets surfaces as a bare, undiagnosable "fetch failed (UND_ERR_SOCKET)"
40
+ * notification instead of being retried away like its `ECONNRESET` sibling.
41
+ */
42
+ const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN", "UND_ERR_SOCKET"]);
36
43
  /**
37
44
  * `fetch`, but a transport-level blip on the FIRST attempt gets one silent
38
45
  * retry before it's allowed to throw. Node's global `fetch` (undici) pools
39
46
  * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
40
47
  * Bitbucket) close idle ones from their end, which surfaces here as
41
- * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
42
- * a stale-socket race, not a real problem with the request. Only retries
43
- * error codes that mean "the transport failed," never an HTTP error status
44
- * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
45
- * on the first attempt so callers see it immediately).
48
+ * `ECONNRESET`/`UND_ERR_SOCKET` the next time a long-lived poller (`spf
49
+ * watch`) reuses one — a stale-socket race, not a real problem with the
50
+ * request. Only retries error codes that mean "the transport failed," never
51
+ * an HTTP error status (a 4xx/5xx response is not a thrown error here, and
52
+ * must keep surfacing on the first attempt so callers see it immediately).
46
53
  */
47
54
  export async function fetchRetryTransient(input, init) {
48
55
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",