@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 +17 -12
- package/assets/skill/references/config.md +2 -2
- package/assets/templates/ts.spf.config.yaml +5 -4
- package/dist/cli/commands/doctor.js +44 -11
- package/dist/cli/commands/init.js +6 -5
- package/dist/cli/interview.js +4 -3
- package/dist/core/data_types.d.ts +17 -12
- package/dist/core/data_types.js +10 -5
- package/dist/core/notify/channel.d.ts +8 -2
- package/dist/core/notify/notifier.js +6 -1
- package/dist/core/notify/slack_channel.js +1 -1
- package/dist/core/notify/teams_channel.js +1 -1
- package/dist/core/watch.js +11 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -608,23 +608,28 @@ for those.
|
|
|
608
608
|
```yaml
|
|
609
609
|
# .spf/spf.config.yaml
|
|
610
610
|
notifications:
|
|
611
|
-
events:
|
|
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
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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,
|
|
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:
|
|
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
|
|
95
|
-
#
|
|
96
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
# section for how to
|
|
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:
|
|
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
|
package/dist/cli/interview.js
CHANGED
|
@@ -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
|
|
349
|
-
{ value: "
|
|
350
|
-
|
|
348
|
+
{ value: "errors", label: "errors — failed runs and watch errors only" },
|
|
349
|
+
{ value: "attention", label: "attention — errors, 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
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
*
|
|
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
|
}>;
|
package/dist/core/data_types.js
CHANGED
|
@@ -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
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
/**
|
|
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",
|
package/dist/core/watch.js
CHANGED
|
@@ -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: "
|
|
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
|
-
// "
|
|
352
|
-
// issue_blocked ("spf needs a human"), and it belongs on an
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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]],
|