@gr8ful/spf 0.8.1 → 0.8.3

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
@@ -608,23 +608,28 @@ for those.
608
608
  ```yaml
609
609
  # .spf/spf.config.yaml
610
610
  notifications:
611
- events: errors # off (default) | errors | all
611
+ events: attention # off (default) | errors | attention | all
612
612
  channels:
613
613
  - kind: slack # slack | teams | webhook
614
614
  webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
615
615
  ```
616
616
 
617
- `events` is the whole filter: `errors` sends only failed runs/phases, blocked
618
- issues, watch errors, and a spec needing feedback (`spec_needs_feedback` —
619
- see "Human-in-the-loop escalation" above; it's `error`-level on purpose, the
620
- same class of event as a blocked issue, so an `errors`-scope channel sees it
621
- too); `all` adds every milestone run started/finished, issue claimed, PR
622
- opened, issue done, a container's roll-up (`feature_done` see "Container
623
- roll-up" above), and a spec reaching actual completion (`spec_done` —
624
- distinct from `spec_refined`, which fires the moment a tree is published;
625
- see "Human-in-the-loop escalation" above). A channel's own `events`
626
- overrides the top-level scope for just that channel. `spf doctor` reports
627
- whether each configured channel's env var is set.
617
+ `events` is the whole filter, narrowest to widest:
618
+
619
+ - `errors` sends only true failures a failed run/phase (`run_failed`,
620
+ `phase_failed`) or a `watch_error`.
621
+ - `attention` adds anything that needs a human but isn't itself a failure —
622
+ a blocked issue (`issue_blocked`) or a spec needing feedback
623
+ (`spec_needs_feedback` — see "Human-in-the-loop escalation" above).
624
+ - `all` adds every remaining milestone run started/finished, issue
625
+ claimed, PR opened, issue done, a container's roll-up (`feature_done`
626
+ see "Container roll-up" above), and a spec reaching actual completion
627
+ (`spec_done` distinct from `spec_refined`, which fires the moment a
628
+ tree is published; see "Human-in-the-loop escalation" above).
629
+
630
+ Each tier includes everything the narrower tiers send. A channel's own
631
+ `events` overrides the top-level scope for just that channel. `spf doctor`
632
+ reports whether each configured channel's env var is set.
628
633
 
629
634
  The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
630
635
  names the key, never the URL itself, matching `GITHUB_TOKEN`/
@@ -247,7 +247,7 @@ default; adding it is entirely additive.
247
247
 
248
248
  | Field | Type | Meaning |
249
249
  |---|---|---|
250
- | `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases, blocked issues, watch errors, and a spec needing feedback (`spec_needs_feedback` `error`-level on purpose, same class as a blocked issue). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
250
+ | `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`. |
251
251
  | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
252
252
  | `channels[]` | array | See below. |
253
253
 
@@ -261,7 +261,7 @@ kind.
261
261
 
262
262
  ```yaml
263
263
  notifications:
264
- events: errors
264
+ events: attention
265
265
  channels:
266
266
  - kind: slack
267
267
  webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
@@ -91,11 +91,12 @@ agents:
91
91
  # Optional: push notifications for unattended work — spf watch's daemon
92
92
  # lifecycle, and every chain run (including watch's own per-issue runs).
93
93
  # Interactive commands (doctor, list, sessions, ...) never notify. events:
94
- # "errors" sends only failures/blocked issues; "all" adds every milestone.
95
- # The webhook URL is a secret — put it in .env under the key named below,
96
- # never here. See the main README's "Notifications" section.
94
+ # "errors" sends only true failures; "attention" adds blocked issues and
95
+ # feedback requests; "all" adds every milestone too. The webhook URL is a
96
+ # secret — put it in .env under the key named below, never here. See the
97
+ # main README's "Notifications" section.
97
98
  # notifications:
98
- # events: errors # off (default) | errors | all
99
+ # events: attention # off (default) | errors | attention | all
99
100
  # channels:
100
101
  # - kind: slack # slack | teams | webhook
101
102
  # webhook_url_env: SLACK_WEBHOOK_URL
@@ -53,6 +53,33 @@ async function withProbeStatus(label, run) {
53
53
  * a plain `✓` so a real negative finding (e.g. "unreachable", a `/v1`
54
54
  * double-path warning) can't be mistaken for a pass at a glance.
55
55
  */
56
+ /**
57
+ * Cross-check ONE chain's own `requiredAgents`/`requiredSuites` (derived
58
+ * from what it actually does — `resolveRequiredAgents`/`resolveRequiredSuites`,
59
+ * same as `spf list`/a real dispatch would resolve) against what `cfg`
60
+ * actually has. Shared by the repo-chain loop below (every `.spf/chains/
61
+ * *.yaml` chain) and `watch.chain`/`watch.refine.chain` — the two chains
62
+ * `spf watch` will ACTUALLY dispatch, built-in or not. Extracted specifically
63
+ * because the repo-chain loop used to be the ONLY place this ran, and it
64
+ * skips every built-in chain by construction (`.filter((c) => c.source !==
65
+ * undefined)`) — so a `watch.chain` naming a built-in like `plan-build-test`
66
+ * never went through it at all: `spf doctor` could report clean while `spf
67
+ * watch`'s very first claimed issue failed at runtime on a missing
68
+ * `quality.suites` entry doctor never actually looked for.
69
+ */
70
+ function checkChainRequirements(report, label, chain, cfg) {
71
+ const owners = resolveRequiredAgents(chain, {});
72
+ const missingOwners = owners.filter((o) => !cfg.agents.some((a) => a.name === o));
73
+ check(report, `${label} owners`, missingOwners.length === 0, missingOwners.length === 0 ? owners.join(", ") || "(none)" : `unknown agent(s): ${missingOwners.join(", ")} — not in cfg.agents`);
74
+ const suiteNames = resolveRequiredSuites(chain, {});
75
+ const missingSuites = suiteNames.filter((s) => !(s in cfg.quality.suites));
76
+ const missingChecks = missingSuites.length === 0 ? suiteNames.flatMap((s) => cfg.quality.suites[s].filter((n) => !cfg.quality.checks.some((c) => c.name === n))) : [];
77
+ check(report, `${label} suites`, missingSuites.length === 0 && missingChecks.length === 0, missingSuites.length > 0
78
+ ? `unknown suite(s): ${missingSuites.join(", ")} — not in cfg.quality.suites`
79
+ : missingChecks.length > 0
80
+ ? `suite(s) name unknown check(s): ${missingChecks.join(", ")}`
81
+ : suiteNames.join(", ") || "(none)");
82
+ }
56
83
  function check(report, name, ok, detail, severity) {
57
84
  report.checks.push({ name, ok, detail, severity });
58
85
  if (!ok)
@@ -468,17 +495,7 @@ export async function doctorCommand(argv) {
468
495
  check(report, `repo chain ${problem.file}`, false, problem.message);
469
496
  }
470
497
  for (const chain of allChains().filter((c) => c.source !== undefined)) {
471
- const owners = resolveRequiredAgents(chain, {});
472
- const missingOwners = owners.filter((o) => !cfg.agents.some((a) => a.name === o));
473
- check(report, `repo chain "${chain.name}" owners`, missingOwners.length === 0, missingOwners.length === 0 ? owners.join(", ") || "(none)" : `unknown agent(s): ${missingOwners.join(", ")} — not in cfg.agents`);
474
- const suiteNames = resolveRequiredSuites(chain, {});
475
- const missingSuites = suiteNames.filter((s) => !(s in cfg.quality.suites));
476
- const missingChecks = missingSuites.length === 0 ? suiteNames.flatMap((s) => cfg.quality.suites[s].filter((n) => !cfg.quality.checks.some((c) => c.name === n))) : [];
477
- check(report, `repo chain "${chain.name}" suites`, missingSuites.length === 0 && missingChecks.length === 0, missingSuites.length > 0
478
- ? `unknown suite(s): ${missingSuites.join(", ")} — not in cfg.quality.suites`
479
- : missingChecks.length > 0
480
- ? `suite(s) name unknown check(s): ${missingChecks.join(", ")}`
481
- : suiteNames.join(", ") || "(none)");
498
+ checkChainRequirements(report, `repo chain "${chain.name}"`, chain, cfg);
482
499
  // Informational: the derived sequence itself, same string `spf list`
483
500
  // shows — printed here so the chain's author can eyeball what they
484
501
  // actually wrote without a second command.
@@ -537,6 +554,19 @@ export async function doctorCommand(argv) {
537
554
  // same way it would for `spf watch` itself, no special-casing needed.
538
555
  const watchChain = findChain(cfg.watch.chain);
539
556
  check(report, "watch.chain", Boolean(watchChain), watchChain ? `${cfg.watch.chain}${watchChain.source ? ` (repo: ${repoChainLabel(watchChain.source)})` : ""}` : `"${cfg.watch.chain}" is not a registered chain`);
557
+ // The "repo chain X suites/owners" loop above only covers chains with a
558
+ // `.source` (loaded from `.spf/chains/*.yaml`) — a BUILT-IN chain named
559
+ // by `watch.chain` (the common case: `plan-build-test`, etc.) never went
560
+ // through that check, and the "roster + suites validate" call earlier
561
+ // only validates suites already present in `cfg.quality.suites`, not
562
+ // whether `watch.chain` actually NEEDS one that's missing entirely. That
563
+ // gap is exactly how `spf doctor` can report clean while `spf watch`'s
564
+ // very first claimed issue fails at runtime with "quality.suites.\"test\"
565
+ // is not configured" — this closes it by checking the chain `spf watch`
566
+ // will ACTUALLY dispatch, the same way the repo-chain loop already does.
567
+ if (watchChain) {
568
+ checkChainRequirements(report, `watch.chain "${cfg.watch.chain}"`, watchChain, cfg);
569
+ }
540
570
  // Informational only, never a failure: a chain that skips review and/or
541
571
  // never commits is a legitimate choice (e.g. `scout`, `plan`) — this is
542
572
  // here so an unattended `spf watch` posture is a visible fact, not a
@@ -558,6 +588,9 @@ export async function doctorCommand(argv) {
558
588
  if (cfg.watch.refine.enabled) {
559
589
  const refineChain = findChain(cfg.watch.refine.chain);
560
590
  check(report, "watch.refine.chain", Boolean(refineChain), refineChain ? `${cfg.watch.refine.chain}${refineChain.source ? ` (repo: ${repoChainLabel(refineChain.source)})` : ""}` : `"${cfg.watch.refine.chain}" is not a registered chain`);
591
+ if (refineChain) {
592
+ checkChainRequirements(report, `watch.refine.chain "${cfg.watch.refine.chain}"`, refineChain, cfg);
593
+ }
561
594
  check(report, "watch.refine issue authoring", cfg.watch.issue_provider === "github" || cfg.watch.issue_provider === "jira", cfg.watch.issue_provider === "github"
562
595
  ? "github supports issue authoring (createIssue/sub-issues)"
563
596
  : cfg.watch.issue_provider === "jira"
@@ -112,12 +112,13 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
112
112
  # lifecycle, and every chain run (spf <chain> / spf run, including watch's
113
113
  # own per-issue runs). Interactive commands (doctor, list, sessions, ...)
114
114
  # never notify — you're already looking at the terminal for those. events:
115
- # "errors" sends only failures/blocked issues; "all" adds every milestone
116
- # (run started, issue claimed, PR opened, ...). The URL is a secret and
117
- # lives only in .env never in this file. See README.md's "Notifications"
118
- # section for how to get each webhook URL.
115
+ # "errors" sends only true failures; "attention" adds blocked issues and
116
+ # feedback requests; "all" adds every milestone too (run started, issue
117
+ # claimed, PR opened, ...). The URL is a secret and lives only in .env
118
+ # never in this file. See README.md's "Notifications" section for how to
119
+ # get each webhook URL.
119
120
  # notifications:
120
- # events: errors # off (default) | errors | all
121
+ # events: attention # off (default) | errors | attention | all
121
122
  # channels:
122
123
  # - kind: slack # slack | teams | webhook
123
124
  # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
@@ -345,9 +345,10 @@ export async function runInterview(asker, ctx) {
345
345
  let notifications = null;
346
346
  if (enableNotify) {
347
347
  const events = await asker.select("Notify on", [
348
- { value: "errors", label: "errors — failed runs, blocked issues, watch errors" },
349
- { value: "all", label: "allevery milestone (claimed, PR opened, done, ...) plus errors" },
350
- ], "errors");
348
+ { value: "errors", label: "errors — failed runs and watch errors only" },
349
+ { value: "attention", label: "attentionerrors, plus blocked issues and feedback requests" },
350
+ { value: "all", label: "all — every milestone (claimed, PR opened, done, ...) plus attention and errors" },
351
+ ], "attention");
351
352
  const channels = [];
352
353
  for (;;) {
353
354
  const kind = await asker.select("Channel", [
@@ -681,33 +681,38 @@ export type WatchConfig = v.InferOutput<typeof WatchConfigSchema>;
681
681
  * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
682
682
  * it stays console-only on purpose; see `core/notify/notifier.ts`.
683
683
  *
684
- * `events` is the whole filter: "off" sends nothing, "errors" sends only
685
- * NotifyEvents whose `level` is "error", "all" sends every curated
686
- * milestone. A channel's own `events` overrides the top-level scope for
687
- * just that channel (e.g. Slack gets everything, Teams gets errors only).
684
+ * `events` is the whole filter, from narrowest to widest:
685
+ * - "off": sends nothing.
686
+ * - "errors": only NotifyEvents whose `level` is "error" (true failures
687
+ * run_failed, phase_failed, watch_error).
688
+ * - "attention": "errors" PLUS `level: "notice"` events — things that
689
+ * need a human but aren't a failure (issue_blocked, spec_needs_feedback).
690
+ * - "all": every curated milestone, `level: "info"` included.
691
+ * A channel's own `events` overrides the top-level scope for just that
692
+ * channel (e.g. Slack gets everything, Teams gets errors only).
688
693
  *
689
694
  * `webhook_url_env` names the .env key holding the secret URL — never the
690
695
  * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
691
696
  * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
692
697
  */
693
- export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "all"], undefined>;
698
+ export declare const NotifyScopeSchema: v.PicklistSchema<["off", "errors", "attention", "all"], undefined>;
694
699
  export type NotifyScope = v.InferOutput<typeof NotifyScopeSchema>;
695
700
  export declare const NotifyChannelKindSchema: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
696
701
  export type NotifyChannelKind = v.InferOutput<typeof NotifyChannelKindSchema>;
697
702
  export declare const NotifyChannelSchema: v.ObjectSchema<{
698
703
  readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
699
704
  readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
700
- readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
705
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
701
706
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
702
707
  }, undefined>;
703
708
  export type NotifyChannel = v.InferOutput<typeof NotifyChannelSchema>;
704
709
  export declare const NotificationsConfigSchema: v.ObjectSchema<{
705
- readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
710
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, "off">;
706
711
  readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
707
712
  readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
708
713
  readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
709
714
  readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
710
- readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
715
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
711
716
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
712
717
  }, undefined>, undefined>, () => never[]>;
713
718
  }, undefined>;
@@ -1013,21 +1018,21 @@ export declare const SFConfigSchema: v.ObjectSchema<{
1013
1018
  };
1014
1019
  }>;
1015
1020
  readonly notifications: v.OptionalSchema<v.ObjectSchema<{
1016
- readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, "off">;
1021
+ readonly events: v.OptionalSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, "off">;
1017
1022
  readonly timeout_ms: v.OptionalSchema<v.NumberSchema<undefined>, 5000>;
1018
1023
  readonly channels: v.OptionalSchema<v.ArraySchema<v.ObjectSchema<{
1019
1024
  readonly kind: v.PicklistSchema<["slack", "teams", "webhook"], undefined>;
1020
1025
  readonly webhook_url_env: v.OptionalSchema<v.StringSchema<undefined>, "">;
1021
- readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "all"], undefined>, undefined>, undefined>;
1026
+ readonly events: v.OptionalSchema<v.NullableSchema<v.PicklistSchema<["off", "errors", "attention", "all"], undefined>, undefined>, undefined>;
1022
1027
  readonly name: v.OptionalSchema<v.StringSchema<undefined>, "">;
1023
1028
  }, undefined>, undefined>, () => never[]>;
1024
1029
  }, undefined>, () => {
1025
- events: "all" | "errors" | "off";
1030
+ events: "all" | "attention" | "errors" | "off";
1026
1031
  timeout_ms: number;
1027
1032
  channels: {
1028
1033
  kind: "slack" | "teams" | "webhook";
1029
1034
  webhook_url_env: string;
1030
- events?: "all" | "errors" | "off" | null | undefined;
1035
+ events?: "all" | "attention" | "errors" | "off" | null | undefined;
1031
1036
  name: string;
1032
1037
  }[];
1033
1038
  }>;
@@ -538,16 +538,21 @@ export const WatchConfigSchema = v.object({
538
538
  * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
539
539
  * it stays console-only on purpose; see `core/notify/notifier.ts`.
540
540
  *
541
- * `events` is the whole filter: "off" sends nothing, "errors" sends only
542
- * NotifyEvents whose `level` is "error", "all" sends every curated
543
- * milestone. A channel's own `events` overrides the top-level scope for
544
- * just that channel (e.g. Slack gets everything, Teams gets errors only).
541
+ * `events` is the whole filter, from narrowest to widest:
542
+ * - "off": sends nothing.
543
+ * - "errors": only NotifyEvents whose `level` is "error" (true failures
544
+ * run_failed, phase_failed, watch_error).
545
+ * - "attention": "errors" PLUS `level: "notice"` events — things that
546
+ * need a human but aren't a failure (issue_blocked, spec_needs_feedback).
547
+ * - "all": every curated milestone, `level: "info"` included.
548
+ * A channel's own `events` overrides the top-level scope for just that
549
+ * channel (e.g. Slack gets everything, Teams gets errors only).
545
550
  *
546
551
  * `webhook_url_env` names the .env key holding the secret URL — never the
547
552
  * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
548
553
  * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
549
554
  */
550
- export const NotifyScopeSchema = v.picklist(["off", "errors", "all"]);
555
+ export const NotifyScopeSchema = v.picklist(["off", "errors", "attention", "all"]);
551
556
  export const NotifyChannelKindSchema = v.picklist(["slack", "teams", "webhook"]);
552
557
  export const NotifyChannelSchema = v.object({
553
558
  kind: NotifyChannelKindSchema,
@@ -14,8 +14,14 @@
14
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" | "spec_needs_feedback" | "feature_done" | "spec_done";
15
15
  export interface NotifyEvent {
16
16
  kind: NotifyKind;
17
- /** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
18
- level: "info" | "error";
17
+ /**
18
+ * The whole filter predicate a scope applies (see `NotifyScopeSchema` in
19
+ * `core/data_types.ts`): "error" is a true failure (sends under `errors`,
20
+ * `attention`, and `all`); "notice" needs a human but isn't a failure
21
+ * (sends under `attention` and `all`); "info" is a routine milestone
22
+ * (sends only under `all`).
23
+ */
24
+ level: "info" | "notice" | "error";
19
25
  /** One line, e.g. "run failed — plan-build-test". */
20
26
  title: string;
21
27
  /** The error text / PR body / block detail, if any. */
@@ -7,12 +7,17 @@ export const DEFAULT_NOTIFY_ENV_KEY = {
7
7
  teams: "TEAMS_WEBHOOK_URL",
8
8
  webhook: "SPF_WEBHOOK_URL",
9
9
  };
10
- /** `errors` mode only sends `level: "error"`; `all` sends everything; `off` sends nothing. */
10
+ /**
11
+ * `off` sends nothing; `errors` only `level: "error"`; `attention` also
12
+ * lets `level: "notice"` through; `all` sends everything.
13
+ */
11
14
  function scopeAllows(scope, level) {
12
15
  if (scope === "off")
13
16
  return false;
14
17
  if (scope === "all")
15
18
  return true;
19
+ if (scope === "attention")
20
+ return level === "error" || level === "notice";
16
21
  return level === "error";
17
22
  }
18
23
  export class Notifier {
@@ -6,7 +6,7 @@ export class SlackChannel {
6
6
  this.label = name ? `slack (${name})` : "slack";
7
7
  }
8
8
  async send(event, timeoutMs) {
9
- const emoji = event.level === "error" ? ":x:" : ":white_check_mark:";
9
+ const emoji = event.level === "error" ? ":x:" : event.level === "notice" ? ":warning:" : ":white_check_mark:";
10
10
  const fieldsText = event.fields.map(([k, v]) => `*${k}:* ${v}`).join(" · ");
11
11
  const body = {
12
12
  text: `${emoji} ${event.title}`,
@@ -6,7 +6,7 @@ export class TeamsChannel {
6
6
  this.label = name ? `teams (${name})` : "teams";
7
7
  }
8
8
  async send(event, timeoutMs) {
9
- const color = event.level === "error" ? "attention" : "good";
9
+ const color = event.level === "error" ? "attention" : event.level === "notice" ? "warning" : "good";
10
10
  const facts = event.fields.map(([title, value]) => ({ title, value }));
11
11
  const card = {
12
12
  type: "AdaptiveCard",
@@ -134,7 +134,7 @@ export async function reconcileOrphans(deps, state) {
134
134
  deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
135
135
  deps.notify({
136
136
  kind: "issue_blocked",
137
- level: "error",
137
+ level: "notice",
138
138
  title: `issue ${issue.id} blocked`,
139
139
  detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
140
140
  fields: [["issue", issue.id], ["title", issue.title]],
@@ -348,11 +348,11 @@ async function escalateSpec(deps, issue, marker, questions, adwId, round) {
348
348
  .join("\n\n---\n\n") +
349
349
  `\n\n---\n\nAnswer inline, then add the \`${deps.labelPrefix}:continue-refinement\` label — refinement resumes from where it left off (adw_id \`${adwId}\`).`;
350
350
  deps.notify({
351
- // "error" level, not "info" — this is the same class of event as
352
- // issue_blocked ("spf needs a human"), and it belongs on an `errors`-scope
353
- // channel just as much as an `all`-scope one.
351
+ // "notice" level, not "info" — this is the same class of event as
352
+ // issue_blocked ("spf needs a human"), and it belongs on an
353
+ // `attention`-scope channel just as much as an `all`-scope one.
354
354
  kind: "spec_needs_feedback",
355
- level: "error",
355
+ level: "notice",
356
356
  title: `spec ${issue.id} needs feedback`,
357
357
  detail: `${questions.length} question(s), round ${round}.`,
358
358
  fields: [
@@ -398,7 +398,7 @@ export async function reconcileRefining(deps, state) {
398
398
  deps.log(`watch: spec ${issue.id} orphaned after asking round ${marker.feedback.rounds} — finishing the transition to needs-feedback`);
399
399
  deps.notify({
400
400
  kind: "spec_needs_feedback",
401
- level: "error",
401
+ level: "notice",
402
402
  title: `spec ${issue.id} needs feedback`,
403
403
  detail: `Round ${marker.feedback.rounds}.`,
404
404
  fields: [["issue", issue.id], ["title", issue.title], ["round", String(marker.feedback.rounds)]],
@@ -419,7 +419,7 @@ export async function reconcileRefining(deps, state) {
419
419
  deps.log(`watch: spec ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
420
420
  deps.notify({
421
421
  kind: "issue_blocked",
422
- level: "error",
422
+ level: "notice",
423
423
  title: `spec ${issue.id} blocked`,
424
424
  detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned refine attempts.`,
425
425
  fields: [["issue", issue.id], ["title", issue.title]],
@@ -530,7 +530,7 @@ export async function finishReviews(deps) {
530
530
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
531
531
  deps.notify({
532
532
  kind: "issue_blocked",
533
- level: "error",
533
+ level: "notice",
534
534
  title: `issue ${issue.id} blocked`,
535
535
  detail: `PR #${marker.pr} was closed without merging.`,
536
536
  fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
@@ -569,7 +569,7 @@ async function runIssue(deps, issue) {
569
569
  const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
570
570
  deps.notify({
571
571
  kind: "issue_blocked",
572
- level: "error",
572
+ level: "notice",
573
573
  title: `issue ${issue.id} blocked`,
574
574
  detail,
575
575
  fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
@@ -583,7 +583,7 @@ async function runIssue(deps, issue) {
583
583
  deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
584
584
  deps.notify({
585
585
  kind: "issue_blocked",
586
- level: "error",
586
+ level: "notice",
587
587
  title: `issue ${issue.id} blocked`,
588
588
  detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
589
589
  fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
@@ -722,7 +722,7 @@ async function runSpec(deps, issue) {
722
722
  const detail = result.detail || `Refine chain "${deps.refineChain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
723
723
  deps.notify({
724
724
  kind: "issue_blocked",
725
- level: "error",
725
+ level: "notice",
726
726
  title: `spec ${issue.id} blocked`,
727
727
  detail,
728
728
  fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain], ["adw_id", adwId]],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",