diff --git a/internal/cmd/skilldoc/main.go b/internal/cmd/skilldoc/main.go index 16e57a2..0cef477 100644 --- a/internal/cmd/skilldoc/main.go +++ b/internal/cmd/skilldoc/main.go @@ -39,7 +39,7 @@ func main() { func genCmd() *cobra.Command { return &cobra.Command{ Use: "gen [group]", - Short: "Rewrite the generated fence in skills/flashduty/reference/.md (every card if no group given)", + Short: "Rewrite every GENERATED: fence across the skills/flashduty cards (every group if none given)", Args: cobra.MaximumNArgs(1), RunE: func(_ *cobra.Command, args []string) error { base, err := cardBase() @@ -81,40 +81,91 @@ func checkCmd() *cobra.Command { // dump builds the command-tree dump from the live CLI root, in-process. func dump() skilldoc.Dump { return skilldoc.Build(cli.RootForDump()) } -// runGen rewrites the GENERATED: fence inside /reference/.md -// with a fresh render, leaving all hand-written content outside the fence -// untouched. -func runGen(d skilldoc.Dump, base, group string) error { - card := filepath.Join(base, "reference", group+".md") - raw, err := os.ReadFile(card) - if err != nil { - return fmt.Errorf("read card: %w", err) +// genGroup regenerates every GENERATED fence of group across the already- +// loaded docs, leaving hand-written content outside the fences untouched. A +// group may split its fences across cards (subset fences claiming verb +// prefixes, plus the catch-all for the rest — see skilldoc.RenderGroupFences), +// so the fresh render is computed for the group as a whole, then spliced per +// card. Rewritten bodies are written to disk AND updated in docs, so a caller +// looping over groups keeps seeing current content. found is false when no +// fence of the group exists anywhere. +func genGroup(d skilldoc.Dump, base string, docs []skilldoc.Doc, group string) (found bool, err error) { + var ids []string + perDoc := map[string][]string{} + for _, doc := range docs { + for _, fl := range skilldoc.FenceLocs(doc.Body) { + spec, err := skilldoc.ParseFenceID(fl.ID) + if err != nil { + return false, fmt.Errorf("%s: %w", doc.Path, err) + } + if spec.Group != group { + continue + } + perDoc[doc.Path] = append(perDoc[doc.Path], fl.ID) + ids = append(ids, fl.ID) + } + } + if len(ids) == 0 { + return false, nil } - body := normalizeEOL(string(raw)) - start, end := skilldoc.FenceStart(group), skilldoc.FenceEnd(group) - si := strings.Index(body, start) - ei := strings.Index(body, end) - if si < 0 || ei < 0 || ei < si { - return fmt.Errorf("%s: no GENERATED:%s fence to fill (add the start/end markers first)", card, group) + rendered, violations := skilldoc.RenderGroupFences(d, group, ids) + if len(violations) > 0 { + return true, fmt.Errorf("group %s fence topology: %s", group, strings.Join(violations, "; ")) } - fresh := skilldoc.GenerateFence(d, group) - updated := body[:si] + fresh + body[ei+len(end):] - if updated == body { - return nil // already fresh + for i, doc := range docs { + docIDs := perDoc[doc.Path] + if len(docIDs) == 0 { + continue + } + body := doc.Body + for _, id := range docIDs { + start, end, ok := skilldoc.FindFence(body, id) + if !ok { + return true, fmt.Errorf("%s: unterminated GENERATED:%s fence", doc.Path, id) + } + body = body[:start] + rendered[id] + body[end:] + } + if body == doc.Body { + continue // already fresh + } + if err := os.WriteFile(filepath.Join(base, doc.Path), []byte(body), 0o644); err != nil { + return true, fmt.Errorf("write card: %w", err) + } + docs[i].Body = body } - if err := os.WriteFile(card, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write card: %w", err) + return true, nil +} + +// runGen regenerates one group's fences. +func runGen(d skilldoc.Dump, base, group string) error { + docs, err := loadDocs(base) + if err != nil { + return err + } + found, err := genGroup(d, base, docs, group) + if err != nil { + return err + } + if !found { + return fmt.Errorf("no GENERATED:%s fence found under %s (add the start/end markers first)", group, base) } return nil } -// runGenAll regenerates the fence of every dump group that has a card file under -// /reference. The group set is derived from the dump (intersected with the -// cards that actually exist), so it stays correct as domains are added or -// renamed — no hardcoded list. Groups without a card (e.g. webhook) are skipped. +// runGenAll regenerates the fences of every dump group that has at least one +// GENERATED marker in a card under . The group set is derived from the +// dump (intersected with the fences that actually exist, which genGroup +// reports via found), so it stays correct as domains are added or renamed — +// no hardcoded list. Groups without any fence (e.g. webhook) are skipped. +// The corpus is loaded once and threaded through every group. func runGenAll(d skilldoc.Dump, base string) error { + docs, err := loadDocs(base) + if err != nil { + return err + } + seen := map[string]bool{} var groups []string for _, c := range d.Commands { @@ -125,10 +176,7 @@ func runGenAll(d skilldoc.Dump, base string) error { } sort.Strings(groups) for _, g := range groups { - if _, err := os.Stat(filepath.Join(base, "reference", g+".md")); err != nil { - continue // no card for this group - } - if err := runGen(d, base, g); err != nil { + if _, err := genGroup(d, base, docs, g); err != nil { return fmt.Errorf("gen %s: %w", g, err) } } diff --git a/internal/cmd/skilldoc/main_test.go b/internal/cmd/skilldoc/main_test.go index 758b59c..922b7c9 100644 --- a/internal/cmd/skilldoc/main_test.go +++ b/internal/cmd/skilldoc/main_test.go @@ -337,3 +337,105 @@ func TestRunGen_FillsFence(t *testing.T) { t.Errorf("gen clobbered hand-written content:\n%s", updated) } } + +// TestRunGen_SplitAcrossCards is the split-card path: one group whose subset +// fence and catch-all fence live in different files. gen must fill both from +// one group-wide render, and check must then be clean. +func TestRunGen_SplitAcrossCards(t *testing.T) { + dir := t.TempDir() + mk := func(verb string) skilldoc.Command { + return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb} + } + d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create"), mk("rule-delete")}} + + rules := filepath.Join(dir, "reference", "rules.md") + svc := filepath.Join(dir, "reference", "svc.md") + writeFile(t, rules, "# rules\n\n"+skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n") + writeFile(t, svc, "# svc\n\nintro\n\n"+skilldoc.FenceStart("svc")+"\n"+skilldoc.FenceEnd("svc")+"\n") + + if err := runGen(d, dir, "svc"); err != nil { + t.Fatalf("runGen: %v", err) + } + + rulesBody, err := os.ReadFile(rules) + if err != nil { + t.Fatal(err) + } + svcBody, err := os.ReadFile(svc) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(rulesBody), "### rule-create") || strings.Contains(string(rulesBody), "### list") { + t.Errorf("rules card should carry exactly the claimed verbs:\n%s", rulesBody) + } + if !strings.Contains(string(svcBody), "### list") || strings.Contains(string(svcBody), "### rule-create") { + t.Errorf("svc card should carry exactly the unclaimed remainder:\n%s", svcBody) + } + if !strings.Contains(string(svcBody), "intro") { + t.Errorf("gen clobbered hand-written content:\n%s", svcBody) + } + + var out bytes.Buffer + if n, _ := runCheck(d, dir, &out); n != 0 { + t.Errorf("after gen, check should be clean; got %d:\n%s", n, out.String()) + } +} + +// TestRunGen_TopologyViolationFails asserts gen refuses to write anything when +// the group's fences do not partition its verbs. +func TestRunGen_TopologyViolationFails(t *testing.T) { + dir := t.TempDir() + mk := func(verb string) skilldoc.Command { + return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb} + } + d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create")}} + + // Subset fence only — "list" has no home. + writeFile(t, filepath.Join(dir, "reference", "rules.md"), + "# rules\n\n"+skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n") + + err := runGen(d, dir, "svc") + if err == nil || !strings.Contains(err.Error(), "no catch-all") { + t.Fatalf("want topology error mentioning the missing catch-all, got %v", err) + } +} + +// TestRunGen_TwoFencesInOneFile pins the sequential splice loop: a single card +// carrying both a subset fence and the catch-all fence of the same group must +// have both rewritten in one pass (offsets are re-resolved by marker text +// after each splice, so the first replacement must not derail the second). +func TestRunGen_TwoFencesInOneFile(t *testing.T) { + dir := t.TempDir() + mk := func(verb string) skilldoc.Command { + return skilldoc.Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb} + } + d := skilldoc.Dump{Commands: []skilldoc.Command{mk("list"), mk("rule-create"), mk("rule-delete")}} + + card := filepath.Join(dir, "reference", "svc.md") + writeFile(t, card, "# svc\n\nrules first\n\n"+ + skilldoc.FenceStart("svc[rule]")+"\n"+skilldoc.FenceEnd("svc[rule]")+"\n\nthen the rest\n\n"+ + skilldoc.FenceStart("svc")+"\n"+skilldoc.FenceEnd("svc")+"\n") + + if err := runGen(d, dir, "svc"); err != nil { + t.Fatalf("runGen: %v", err) + } + + body, err := os.ReadFile(card) + if err != nil { + t.Fatal(err) + } + got := string(body) + ruleAt := strings.Index(got, "### rule-create") + listAt := strings.Index(got, "### list") + if ruleAt < 0 || listAt < 0 || ruleAt > listAt { + t.Fatalf("both fences must be filled, subset before catch-all:\n%s", got) + } + if !strings.Contains(got, "rules first") || !strings.Contains(got, "then the rest") { + t.Errorf("gen clobbered hand-written content between fences:\n%s", got) + } + + var out bytes.Buffer + if n, _ := runCheck(d, dir, &out); n != 0 { + t.Errorf("after gen, check should be clean; got %d:\n%s", n, out.String()) + } +} diff --git a/internal/skilldoc/fence.go b/internal/skilldoc/fence.go new file mode 100644 index 0000000..6711080 --- /dev/null +++ b/internal/skilldoc/fence.go @@ -0,0 +1,181 @@ +package skilldoc + +// Fence topology: which GENERATED fence carries which commands of a group. +// +// A fence id is either the bare group name ("channel") — the group's +// catch-all fence — or the group plus a bracketed verb-prefix claim list +// ("channel[silence-rule,inhibit-rule]") — a subset fence that claims every +// verb starting with one of the prefixes. A group's fences may live in +// different cards; together they must cover the group exactly: every verb +// lands in exactly one fence, each prefix claims at least one verb, and any +// unclaimed remainder requires the catch-all fence to exist. + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// FenceSpec is one parsed fence id. +type FenceSpec struct { + Group string + Prefixes []string // empty → the group's catch-all fence +} + +// ID renders the spec back to its marker id ("group" or "group[p1,p2]"). +func (s FenceSpec) ID() string { + if len(s.Prefixes) == 0 { + return s.Group + } + return s.Group + "[" + strings.Join(s.Prefixes, ",") + "]" +} + +// fenceIDRe accepts "group" or "group[prefix,prefix,...]". Group and prefix +// share the verb charset; no spaces, so a malformed claim list fails loudly +// instead of silently truncating at the first space. +var fenceIDRe = regexp.MustCompile(`^([a-z0-9-]+)(?:\[([a-z0-9-]+(?:,[a-z0-9-]+)*)\])?$`) + +// ParseFenceID parses a fence id as found in a GENERATED marker. +func ParseFenceID(id string) (FenceSpec, error) { + m := fenceIDRe.FindStringSubmatch(id) + if m == nil { + return FenceSpec{}, fmt.Errorf("malformed fence id %q (want group or group[verb-prefix,…])", id) + } + spec := FenceSpec{Group: m[1]} + if m[2] != "" { + spec.Prefixes = strings.Split(m[2], ",") + } + return spec, nil +} + +// FenceLoc is one GENERATED start marker found in a doc body. +type FenceLoc struct { + ID string + Offset int // byte offset of the start marker +} + +// fenceStartRe matches a start marker and captures its fence id; the literal +// " START " cannot appear in an end marker, so ends never match. +var fenceStartRe = regexp.MustCompile(`" ) -// GenerateFence renders the factual fenced block for one command group: a -// section per leaf verb with its short description and a flag table (name, -// type, required, usage + enum), plus a body-only (--data) note when the -// command has nested JSON-only fields, plus a one-line response-shape summary +// GenerateFence renders the fenced block for a group whose only fence is the +// catch-all — i.e. all of the group's commands in one block. Groups split +// across several cards must go through RenderGroupFences instead, which knows +// the sibling subset fences. +func GenerateFence(d Dump, group string) string { + out, _ := RenderGroupFences(d, group, []string{group}) + return out[group] +} + +// renderFence renders one fenced block: the id's markers around a section per +// command — each with its short description and a flag list (name, type, +// required, usage + enum), plus a body-only (--data) note when the command +// has nested JSON-only fields, plus a one-line response-shape summary // (top-level object vs. bare array vs. `{items: [...]}` page wrapper, and the // field names at that level) when the command documents one. Required-ness // and enums are sourced from the authoritative "Request fields:" text in each @@ -26,25 +35,24 @@ const ( // responseShapeLine), not re-derived or hand-curated. The flag list falls // back to the dump's Flags when no Request-fields block exists (read-only // verbs). Output is deterministic. -func GenerateFence(d Dump, group string) string { - cmds := groupCommands(d, group) - +func renderFence(id string, cmds []Command) string { var b strings.Builder - fmt.Fprintf(&b, fenceStartFmt+"\n\n", group) + fmt.Fprintf(&b, fenceStartFmt+"\n\n", id) for i, c := range cmds { if i > 0 { b.WriteString("\n") } writeCommand(&b, c) } - fmt.Fprintf(&b, "\n"+fenceEndFmt, group) + fmt.Fprintf(&b, "\n"+fenceEndFmt, id) return b.String() } -// FenceStart / FenceEnd return the literal markers for a group, used by the -// freshness check to locate fences in docs. -func FenceStart(group string) string { return fmt.Sprintf(fenceStartFmt, group) } -func FenceEnd(group string) string { return fmt.Sprintf(fenceEndFmt, group) } +// FenceStart / FenceEnd return the literal markers for a fence id (a bare +// group, or group[prefix,…] — see ParseFenceID), used to locate fences in +// docs. +func FenceStart(id string) string { return fmt.Sprintf(fenceStartFmt, id) } +func FenceEnd(id string) string { return fmt.Sprintf(fenceEndFmt, id) } func groupCommands(d Dump, group string) []Command { var cmds []Command diff --git a/internal/skilldoc/validate.go b/internal/skilldoc/validate.go index 41536ec..740d6cc 100644 --- a/internal/skilldoc/validate.go +++ b/internal/skilldoc/validate.go @@ -16,7 +16,7 @@ type Doc struct { type Issue struct { Doc string Line int - Kind string // "unknown-command" | "unknown-flag" | "positional-as-flag" | "stale-fence" + Kind string // "unknown-command" | "unknown-flag" | "positional-as-flag" | "stale-fence" | "fence-topology" Detail string } @@ -47,37 +47,87 @@ func Validate(d Dump, docs []Doc) []Issue { return issues } -// CheckFences asserts every GENERATED: fence embedded in docs matches a -// fresh render from the dump. A fence whose inner content has drifted, or a -// start marker with no matching end marker, yields a stale-fence issue. Docs -// with no generated fence for a group are silently fine. +// CheckFences asserts every GENERATED fence embedded in docs matches a fresh +// render from the dump, and that each group's fences form a valid partition +// of the group's commands (see RenderGroupFences). A drifted fence or a start +// marker with no matching end marker yields a stale-fence issue; a malformed +// or unknown-group marker, and any partition violation, yields a +// fence-topology issue anchored at the group's first fence. func CheckFences(d Dump, docs []Doc) []Issue { + dumpGroups := map[string]bool{} + for _, g := range groups(d) { + dumpGroups[g] = true + } + + type loc struct { + doc string + body string + off int + id string + } var issues []Issue - for _, group := range groups(d) { - fresh := GenerateFence(d, group) - start, end := FenceStart(group), FenceEnd(group) - for _, doc := range docs { - si := strings.Index(doc.Body, start) - if si < 0 { - continue // no fence for this group in this doc + byGroup := map[string][]loc{} + for _, doc := range docs { + for _, fl := range FenceLocs(doc.Body) { + spec, err := ParseFenceID(fl.ID) + if err != nil { + issues = append(issues, Issue{ + Doc: doc.Path, + Line: lineOf(doc.Body, fl.Offset), + Kind: "fence-topology", + Detail: err.Error(), + }) + continue } - ei := strings.Index(doc.Body[si:], end) - if ei < 0 { + if !dumpGroups[spec.Group] { issues = append(issues, Issue{ Doc: doc.Path, - Line: lineOf(doc.Body, si), + Line: lineOf(doc.Body, fl.Offset), + Kind: "fence-topology", + Detail: "GENERATED:" + fl.ID + " names unknown command group " + spec.Group, + }) + continue + } + byGroup[spec.Group] = append(byGroup[spec.Group], loc{doc: doc.Path, body: doc.Body, off: fl.Offset, id: fl.ID}) + } + } + + // groups(d) is already sorted; every byGroup key is a member of it. + for _, group := range groups(d) { + locs, present := byGroup[group] + if !present { + continue + } + ids := make([]string, len(locs)) + for i, l := range locs { + ids[i] = l.id + } + rendered, violations := RenderGroupFences(d, group, ids) + for _, v := range violations { + issues = append(issues, Issue{ + Doc: locs[0].doc, + Line: lineOf(locs[0].body, locs[0].off), + Kind: "fence-topology", + Detail: v, + }) + } + for _, l := range locs { + start, end, ok := FindFence(l.body, l.id) + if !ok { + issues = append(issues, Issue{ + Doc: l.doc, + Line: lineOf(l.body, l.off), Kind: "stale-fence", - Detail: "unterminated GENERATED:" + group + " fence", + Detail: "unterminated GENERATED:" + l.id + " fence", }) continue } - block := doc.Body[si : si+ei+len(end)] - if block != fresh { + if fresh, rok := rendered[l.id]; rok && l.body[start:end] != fresh { issues = append(issues, Issue{ - Doc: doc.Path, - Line: lineOf(doc.Body, si), + Doc: l.doc, + Line: lineOf(l.body, l.off), Kind: "stale-fence", - Detail: "GENERATED:" + group + " fence is out of date — run `make gen-cards`", + Detail: "GENERATED:" + l.id + " fence is out of date — run `make gen-cards`", }) } } diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index cb2ea26..fe25a40 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -57,14 +57,17 @@ Some asks span several commands. For those the skill ships a script that fetches | intent / 意图 (terms route in either language) | card | |---|---| -| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / postmortem 复盘 / **summarize or analyze an incident 故障汇总分析** | **`reference/incident.md`** | +| incident / fault / 故障 / 事件 / triage 分诊 / acknowledge 认领 / merge 合并 / escalate 升级 / **summarize or analyze an incident 故障汇总分析** | **`reference/incident.md`** | +| post-mortem / postmortem 复盘 / 复盘报告 / 复盘模板 / post-incident review / RCA report | **`reference/postmortem.md`** | | alert / 告警 / dedup 去重 / alert fields 告警字段 / alert pipeline 告警管道 | **`reference/alert.md`** | | change / 变更 / deployment 部署 / release 发布 / correlated change 变更关联 / what changed | **`reference/change.md`** | | monitor / 监控 / alert rule 告警规则 / datasource 数据源 / inspection 巡检 / rule config 规则配置 | **`reference/monit.md`** | | automation / 自动化 / 定时 AI SRE / scheduled AI task / daily brief / weekly report / webhook trigger / POST trigger / chat-created automation | **`reference/automation.md`** | | metric/log query / 指标查询 / 日志查询 / PromQL / LogsQL / SQL / trend 趋势 / log clustering 日志聚类 / datasource RCA 数据源排查 | **`reference/monit-query.md`** | | host diagnostics / 主机诊断 / on-box / process 进程 / load 负载 / lock 锁 / slow query 慢查询 / mysql / reachability 可达性 | **`reference/monit-agent.md`** | -| channel / 协作空间 / collaboration space / 频道 / integration 集成 / dispatch rule 分派规则 / escalation 升级规则 / noise reduction 降噪 / silence 静默 / inhibit 抑制 | **`reference/channel.md`** | +| channel / 协作空间 / collaboration space / 频道 / integration 集成 / alert grouping 告警分组 | **`reference/channel.md`** | +| dispatch rule 分派策略 / 分派规则 / escalation rule 升级规则 / notify layers 通知层级 / who gets paged | **`reference/escalation.md`** | +| silence 静默 / 屏蔽 / inhibit 抑制 / drop rule 丢弃 / noise reduction 降噪 / maintenance silence 维护窗口静默 | **`reference/noise.md`** | | enrichment / 数据加工 / 富化 / label mapping 字段映射 / extraction 提取 / mapping schema 集成 schema | **`reference/enrichment.md`** | | insight / 洞察 / stats 统计 / trend 趋势 / MTTA / MTTR / top alerts Top 告警 / incident export 故障导出 | **`reference/insight.md`** | | schedule / on-call / 值班 / 排班 / rotation 轮值 / who is on call 谁在值班 / shift 班次 / next responder 下一班 | **`reference/schedule.md`** | @@ -79,3 +82,5 @@ Some asks span several commands. For those the skill ships a script that fetches | sourcemap / source map / source mapping / symbolication / deobfuscate / stack enrich / dSYM / miniprogram source map | **`reference/sourcemap.md`** | | status page / 状态页 / public incident 公开事件 / public timeline 公开时间线 / maintenance window 维护窗口 / subscriber 订阅者 | **`reference/status-page.md`** | | AI-SRE platform / customize / 安装配置 MCP server (connector) 连接器 / install mcp / skill upload 上传技能 / A2A agent / session export 会话导出 | **`reference/safari.md`** | + +Shared reference: `reference/filters.md` — read it before composing any `filters` / `source_filters` / `target_filters` value (silence / inhibit / drop / escalation rules); it carries the condition shape, operators, and the valid key set per rule family. diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index db6afe1..37015b2 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -143,7 +143,7 @@ View alert timeline ## Pipeline rule kinds -`pipeline-upsert` replaces the whole pipeline; `rules[].kind` values: `title_reset` · `description_reset` · `severity_reset` · `alert_drop` · `alert_inhibit`. The `rules` array has no typed flag — pass it via `--data '{"rules":[...]}'`. The call is idempotent (upsert), so re-running with the same body is safe. +`pipeline-upsert` replaces the whole pipeline; `rules[].kind` values: `title_reset` · `description_reset` · `severity_reset` · `alert_drop` · `alert_inhibit`. The `rules` array has no typed flag — pass it via `--data '{"rules":[...]}'`. The call is idempotent (upsert), so re-running with the same body is safe. `rules[].if` and `alert_inhibit`'s `source_filters` are OR-of-AND condition trees — read `reference/filters.md` before composing them. ## Gotchas diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index 7afe08f..711ec97 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -1,10 +1,12 @@ # fduty channel — command card -Prereq: `SKILL.md` read. **SKILL.md + this card = full competence on channels — no `--help` needed.** Read verbs are free; `create`, `update`, `delete`, `escalate-rule-create/update/delete`, `inhibit-rule-*`, `silence-rule-*`, `unsubscribe-rule-*` all mutate state — confirm before acting. `delete` is **irreversible**. +Prereq: `SKILL.md` read. Read verbs are free; `create`, `update`, `delete`, `disable`, `enable` mutate state — confirm before acting. `delete` is **irreversible**. ## Route here when -"协作空间 / 频道 / 渠道 / 告警分组 / 降噪 / 静默 / 抑制 / 丢弃 / 升级策略 / 告警收敛 / channel / collaboration space / escalation rule / silence / inhibit / drop rule" → **channel**, NOT `incident` (incidents live _inside_ a channel) or `alert` (alerts are routed _into_ a channel). **`协作空间` (collaboration space) IS the `channel` API noun** — a naive translation would be "频道", but Flashduty's product surfaces it as 协作空间. Key IDs: **`channel-id` (int)** from `channel list`; **`rule-id` (MongoDB ObjectID string)** from `escalate-rule-list`, `inhibit-rule-list`, `silence-rule-list`, `unsubscribe-rule-list`. +"协作空间 / 频道 / 渠道 / 告警分组 / channel / collaboration space / alert grouping / flapping" → **channel**, NOT `incident` (incidents live _inside_ a channel) or `alert` (alerts are routed _into_ a channel). **`协作空间` (collaboration space) IS the `channel` API noun** — a naive translation would be "频道", but Flashduty's product surfaces it as 协作空间. Key ID: **`channel-id` (int)** from `channel list`. + +Rules INSIDE a channel have their own cards: escalation / 分派策略 → `reference/escalation.md`; silence / inhibit / drop (静默 / 抑制 / 丢弃 / 降噪) → `reference/noise.md`. **Flashcat workspace exception.** When the user asks whether a "空间" is healthy, red/green, or specifically mentions **灭火图 / firemap**, do not assume they mean a Flashduty channel. In that context, "空间" may be a **Flashcat workspace**, and the answer must come from the Flashcat/firemap surface rather than channel incident stats. If you first resolved a name as a Flashduty `channel-id` and later resolve the same visible name as a Flashcat `workspace-id`, **do not silently switch** — tell the user these are different objects and state which ID/surface each conclusion uses. @@ -19,17 +21,10 @@ Prereq: `SKILL.md` read. **SKILL.md + this card = full competence on channels | rename / reconfigure a channel | `update ` | | disable / re-enable a channel | `disable ` / `enable ` | | delete a channel | `delete ` | -| list escalation rules | `escalate-rule-list ` | -| escalation rule detail | `escalate-rule-info` | -| add escalation rule | `escalate-rule-create` | -| edit escalation rule | `escalate-rule-update` | -| toggle escalation rule | `escalate-rule-enable` / `escalate-rule-disable` | -| remove escalation rule | `escalate-rule-delete` | -| list / create / update / toggle / delete inhibit rules | `inhibit-rule-list ` / `inhibit-rule-create ` / `inhibit-rule-update` / `inhibit-rule-enable` / `inhibit-rule-disable` / `inhibit-rule-delete` | -| list / create / update / toggle / delete silence rules | `silence-rule-list ` / `silence-rule-create ` / `silence-rule-update` / `silence-rule-enable` / `silence-rule-disable` / `silence-rule-delete` | -| list / create / update / toggle / delete drop (unsubscribe) rules | `unsubscribe-rule-list ` / `unsubscribe-rule-create ` / `unsubscribe-rule-update` / `unsubscribe-rule-enable` / `unsubscribe-rule-disable` / `unsubscribe-rule-delete` | +| escalation rules (分派策略) | `reference/escalation.md` | +| silence / inhibit / drop rules (降噪) | `reference/noise.md` | -## Hot flow — create channel + add escalation rule +## Hot flow — create a channel ```bash # 1. find owning team-id (from `fduty team list --output-format toon`) @@ -37,38 +32,8 @@ fduty channel list --output-format toon # 2. create the channel (no positional; --channel-name and --team-id are required) fduty channel create --channel-name "production-api" --team-id \ --auto-resolve-timeout 3600 --auto-resolve-mode trigger -# → returns channel_id; use it below - -# 3. add an escalation rule (all flags; layers is required via --data) -# API field `person_ids` expects member IDs from `fduty member list`. -fduty channel escalate-rule-create \ - --channel-id --rule-name "P1 on-call" --template-id \ - --data '{"layers":[{"target":{"person_ids":[],"by":{"critical":["voice","sms"],"warning":["feishu"]}},"notify_step":5,"max_times":3,"escalate_window":30}]}' -``` - -## Hot flow — add a silence rule during maintenance - -A silence rule needs BOTH a time window (`time_filter` or `time_filters`) AND -`filters` naming which alerts the window applies to — a `time_filter`-only -rule matches nothing and the server rejects it. Build `filters` from the -target incident's own labels (see "Building `filters` from incident labels" -below for the general rule). - -```bash -# 1. inspect the incident to silence around — pulls incident_severity + labels -fduty incident detail --output-format toon - -# 2. channel-id is POSITIONAL on silence-rule-create (see use: "silence-rule-create ") -# filters is one AND group: a severity condition plus one labels. condition -# per distinguishing label — id-shaped/long/date-shaped/noise-key label values are -# dropped, not passed through (see "Building filters from incident labels" below). -fduty channel silence-rule-create \ - --rule-name "planned-maintenance-2026-07-01" \ - --is-auto-delete \ - --data '{"time_filter":{"start_time":1751328000,"end_time":1751371200},"filters":[[{"key":"alert_severity","oper":"IN","vals":["Critical"]},{"key":"labels.service","oper":"IN","vals":["payments-api"]},{"key":"labels.env","oper":"IN","vals":["prod"]}]]}' - -# 3. verify — read back `filters` to confirm the conditions round-tripped -fduty channel silence-rule-list --output-format toon +# → returns channel_id; next, add an escalation rule so incidents page someone: +# see reference/escalation.md ``` @@ -101,54 +66,6 @@ Disable channel Enable channel - `` (positional, required) int64 — Channel ID. -### escalate-rule-create -Create escalation rule -- `--aggr-window` int64 — Delay window in seconds. 0 disables delay. (0-3600) -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--priority` int64 — Evaluation priority. Lower runs first. (0-200) -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- `--template-id` string (required) — Notification template ID (MongoDB ObjectID). -- body-only (`--data`): filters (array>); layers (array) (required); time_filters (array) -- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) - -### escalate-rule-delete -Delete escalation rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### escalate-rule-disable -Disable escalation rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### escalate-rule-enable -Enable escalation rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### escalate-rule-info -Get escalation rule detail -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). -- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (integer); deleted_at (integer); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (integer); updated_by (integer) - -### escalate-rule-list -List escalation rules -- `` (positional, required) int64 — Channel to list rules for. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (integer); deleted_at (integer); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (integer); updated_by (integer) - -### escalate-rule-update -Update escalation rule -- `--aggr-window` int64 — Delay window in seconds. 0 disables delay. -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-id` string (required) — Escalation rule ID (MongoDB ObjectID). -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- `--template-id` string (required) — Notification template ID (MongoDB ObjectID). -- body-only (`--data`): filters (object); layers (array) (required); time_filters (array) - ### info Get channel detail - `` (positional, required) int64 — Channel ID to fetch. @@ -159,135 +76,12 @@ Batch get channels - `` (positional, required) intSlice — Channel IDs to look up. Up to 1000. - response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: channel_id (integer); channel_name (string); status (string) -### inhibit-rule-create -Create inhibit rule -- `` (positional, required) int64 — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--equals` stringSlice (required) — Label keys used to pair source and target alerts. -- `--is-directly-discard` bool — When true, suppressed target alerts are dropped instead of merged. -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): source_filters (array>); target_filters (array>) -- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) - -### inhibit-rule-delete -Delete inhibit rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### inhibit-rule-disable -Disable inhibit rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### inhibit-rule-enable -Enable inhibit rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### inhibit-rule-list -List inhibit rules -- `` (positional, required) int64 — Channel to list rules for. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); equals (array); is_directly_discard (boolean); priority (integer); rule_id (string); rule_name (string); source_filters (object); status (string); target_filters (object); updated_at (integer); updated_by (integer) - -### inhibit-rule-update -Update inhibit rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--equals` stringSlice (required) — Label keys used to pair source and target alerts. -- `--is-directly-discard` bool — When true, suppressed target alerts are dropped instead of merged. -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-id` string (required) — Inhibit rule ID (MongoDB ObjectID). -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): source_filters (object); target_filters (object) - ### list List channels - `--name` string - `--team-ids` int64Slice - response: TOP-LEVEL array — pipe `--json | jq '.[]'` (NOT `.items[]`) — fields: account_id (integer); active_incident_highest_severity (string); auto_resolve_mode (string); auto_resolve_timeout (integer); channel_id (integer); channel_name (string); created_at (integer); creator_id (integer); creator_name (string); deleted_at (integer); description (string); disable_auto_close (boolean); disable_outlier_detection (boolean); external_report_token (string); flapping (object); group (object); is_external_report_enabled (boolean); is_private (boolean); is_starred (boolean); last_incident_at (integer); managing_team_ids (array); progress_to_incident_cnts (object); status (string); team_id (integer); team_name (string); updated_at (integer) -### silence-rule-create -Create silence rule -- `` (positional, required) int64 — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--from-incident-id` string — Source incident ID when the silence was created from an incident. -- `--is-auto-delete` bool — When true, the silence rule is automatically deleted after its time window expires. Defaults to false. -- `--is-directly-discard` bool — When true, silenced alerts are dropped instead of suppressed into incidents. -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): filters (array>); time_filter (object); time_filters (array) -- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) - -### silence-rule-delete -Delete silence rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### silence-rule-disable -Disable silence rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### silence-rule-enable -Enable silence rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### silence-rule-list -List silence rules -- `` (positional, required) int64 — Channel to list rules for. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); filters (object); from_incident_id (string); is_auto_delete (boolean); is_directly_discard (boolean); is_effective (boolean); priority (integer); rule_id (string); rule_name (string); status (string); time_filter (object); time_filters (array); updated_at (integer); updated_by (integer) - -### silence-rule-update -Update silence rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--is-auto-delete` bool — When true, the silence rule is automatically deleted after its time window expires. Defaults to false. -- `--is-directly-discard` bool — When true, silenced alerts are dropped instead of suppressed into incidents. -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-id` string (required) — Silence rule ID (MongoDB ObjectID). -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): filters (object); time_filter (object); time_filters (array) - -### unsubscribe-rule-create -Create drop rule -- `` (positional, required) int64 — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): filters (array>) -- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) - -### unsubscribe-rule-delete -Delete drop rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### unsubscribe-rule-disable -Disable drop rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### unsubscribe-rule-enable -Enable drop rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). - -### unsubscribe-rule-list -List drop rules -- `` (positional, required) int64 — Channel to list rules for. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); filters (object); priority (integer); rule_id (string); rule_name (string); status (string); updated_at (integer); updated_by (integer) - -### unsubscribe-rule-update -Update drop rule -- `--channel-id` int64 (required) — Channel the rule belongs to. -- `--description` string — Rule description, up to 500 characters. (≤500 chars) -- `--priority` int64 — Evaluation priority. Lower runs first. -- `--rule-id` string (required) — Drop rule ID (MongoDB ObjectID). -- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) -- body-only (`--data`): filters (object) - ### update Update channel - `--auto-resolve-mode` string — Auto-resolve timer reset mode. · enum: trigger | update @@ -310,58 +104,9 @@ Update channel - **`--auto-resolve-mode`** enum: `trigger` (timer resets on each new alert trigger) | `update` (timer resets on any alert update). - **Alert grouping `group.method`**: `i` = intelligent (embedding similarity), `p` = pattern (label equality), `n` = none. Set via `--data '{"group":{"method":"p","equals":[["service","env"]],"time_window":300}}'` on `create`/`update`. -- **Rule status**: `enabled` | `disabled` — apply to escalation, inhibit, silence, and drop rules alike. -- **Inhibit `--equals`**: label keys that must be **equal** between the source (high-priority) and target (suppressed) alert to form a pair (e.g. `--equals service,env`). -- **Silence time windows**: `time_filter` (one-off, unix seconds, mutually exclusive) vs `time_filters` (recurring weekly HH:MM windows). Pass via `--data`. -- **Escalation `layers`** (required via `--data` on create/update): each layer needs `target` (with `person_ids`/`team_ids`/`schedule_to_role_ids`/`emails` + `by` OR `webhooks`) and optionally `notify_step`, `max_times`, `escalate_window`, `force_escalate`. - -### Building `filters` from incident labels - -`filters` (silence-rule, inhibit-rule's `source_filters`/`target_filters`, -unsubscribe-rule) is an OR-of-AND condition tree: the outer array holds AND -groups, each inner array holds `{key, oper, vals}` conditions that must ALL -match. To scope a rule to one incident's blast radius, build a single AND -group from that incident's own data (`fduty incident detail `): - -1. Start the group with a severity condition: - `{"key":"alert_severity","oper":"IN","vals":[""]}`. -2. For each entry in the incident's `labels` object, add one more condition - `{"key":"labels.","oper":"IN","vals":[""]}` — but - only when the label is actually distinguishing. Drop a label if its value - is: - - purely numeric (any kind of ID — `instance_id`, `pod_id`, …), - - longer than 256 characters (embedded JSON, stack traces, long text), - - a date/time value (`2026-07-01T10:00:00Z`, unix timestamps, …), or - - under a generically noisy key regardless of value — e.g. `trigger_value`, - `prom_ql`, `detail_url`, any `*_url` key, `first_trigger_time`, other - `*timestamp*` keys, `rule_config`. -3. `oper` is `IN` (value must match one of `vals`) or `NOTIN` (must not match - any); `vals` entries also accept `/regex/` patterns. The valid `key` set is - any `labels.` for a custom label, or one of the fixed built-in names: - `severity`, `event_severity`, `alert_severity`, `status`, `title`, - `title_rule`, `description`, `alert_key`, `data_source_id`, - `integration_id`. A `key` outside this set (e.g. `dedup_key`) is not - rejected at create time — it silently produces a rule that never matches - anything, so check the `key` against this list before creating. -4. After creating the rule, confirm it with the matching `*-rule-list` - command and read back its `filters` to make sure the conditions - round-tripped as intended. ## Gotchas -- **Positional trap**: `channel-id` is **positional** on `info`, `infos`, `update`, `delete`, `disable`, `enable`, `escalate-rule-list`, `inhibit-rule-create`, `inhibit-rule-list`, `silence-rule-create`, `silence-rule-list`, `unsubscribe-rule-create`, `unsubscribe-rule-list`. It is a **flag** (`--channel-id`) on all `escalate-rule-*`, `inhibit-rule-update/delete/enable/disable`, `silence-rule-update/delete/enable/disable`, `unsubscribe-rule-update/delete/enable/disable`. When in doubt, the fence heading `### verb ` = positional; heading without `<…>` = flag. -- **`escalate-rule-create` needs `layers` via `--data`** — it is required and cannot be expressed as a flat flag. Omitting it returns a validation error. -- **`rule-id` is a MongoDB ObjectID string**, not an integer. Retrieve it from `escalate-rule-list`, `inhibit-rule-list`, `silence-rule-list`, or `unsubscribe-rule-list` before any update/delete/enable/disable. +- **`channel-id` is positional** on every verb of this card (`info`, `infos`, `update`, `delete`, `disable`, `enable`). - **`channel create` requires `--channel-name` and `--team-id`** even though they are not marked `required` in the flag list — the server rejects the request without them. -- **`delete` on a channel is irreversible** — all rules within it are also removed. Confirm the `channel-id` against `list` before proceeding. -- **Empty rule list is authoritative** — if `escalate-rule-list` / `silence-rule-list` / etc. returns no rows, no rules exist; do not widen the query. - -## Worked example — look up a channel and inspect its escalation policy - -```bash -fduty channel list --name "payments" --output-format toon -# → find channel_id (e.g. 4201) -fduty channel escalate-rule-list 4201 --output-format toon -# → find rule_id (MongoDB ObjectID string, e.g. "6643abc123def456789012aa") -fduty channel escalate-rule-info --channel-id 4201 --rule-id "6643abc123def456789012aa" --output-format toon -``` +- **`delete` on a channel is irreversible** — all rules within it (escalation, silence, inhibit, drop) are also removed. Confirm the `channel-id` against `list` before proceeding. diff --git a/skills/flashduty/reference/escalation.md b/skills/flashduty/reference/escalation.md new file mode 100644 index 0000000..19d8998 --- /dev/null +++ b/skills/flashduty/reference/escalation.md @@ -0,0 +1,122 @@ +# fduty channel escalation rules — 分派策略 + +Prereq: `SKILL.md` read. `escalate-rule-list` / `escalate-rule-info` are free +reads; `escalate-rule-create/update/delete/enable/disable` mutate who gets +paged — confirm before acting. + +## Route here when + +"分派策略 / 分派规则 / 升级规则 / 升级策略 / escalation rule / escalation +policy / notify layers / who gets paged / on-call notification chain" → this +card. Escalation rules live INSIDE a channel (协作空间) and pick the PEOPLE +notified once an incident lands there — NOT `reference/route.md` (alert +routing picks the *channel*), NOT `reference/schedule.md` (on-call schedules +are a notify *target* referenced from layers). Key IDs: **`channel-id` +(int)** from `fduty channel list`; **`rule-id` (MongoDB ObjectID string)** +from `escalate-rule-list`; **`template-id`** from `fduty template list`. + +## Intent → verb + +| want | verb | +|---|---| +| list escalation rules | `escalate-rule-list ` | +| escalation rule detail | `escalate-rule-info` | +| add escalation rule | `escalate-rule-create` | +| edit escalation rule | `escalate-rule-update` | +| toggle escalation rule | `escalate-rule-enable` / `escalate-rule-disable` | +| remove escalation rule | `escalate-rule-delete` | + +## Hot flow — add an escalation rule + +```bash +# 1. find the channel and its existing rules +fduty channel escalate-rule-list --output-format toon +# 2. add the rule (layers is required via --data) +# API field `person_ids` expects member IDs from `fduty member list`. +fduty channel escalate-rule-create \ + --channel-id --rule-name "P1 on-call" --template-id \ + --data '{"layers":[{"target":{"person_ids":[],"by":{"critical":["voice","sms"],"warning":["feishu"]}},"notify_step":5,"max_times":3,"escalate_window":30}]}' +``` + + + +### escalate-rule-create +Create escalation rule +- `--aggr-window` int64 — Delay window in seconds. 0 disables delay. (0-3600) +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--priority` int64 — Evaluation priority. Lower runs first. (0-200) +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- `--template-id` string (required) — Notification template ID (MongoDB ObjectID). +- body-only (`--data`): filters (array>); layers (array) (required); time_filters (array) +- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) + +### escalate-rule-delete +Delete escalation rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### escalate-rule-disable +Disable escalation rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### escalate-rule-enable +Enable escalation rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### escalate-rule-info +Get escalation rule detail +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). +- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (integer); deleted_at (integer); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (integer); updated_by (integer) + +### escalate-rule-list +List escalation rules +- `` (positional, required) int64 — Channel to list rules for. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); aggr_window (integer); channel_id (integer); channel_name (string); created_at (integer); deleted_at (integer); description (string); filters (object); layers (array); priority (integer); rule_id (string); rule_name (string); status (string); template_id (string); time_filters (array); updated_at (integer); updated_by (integer) + +### escalate-rule-update +Update escalation rule +- `--aggr-window` int64 — Delay window in seconds. 0 disables delay. +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-id` string (required) — Escalation rule ID (MongoDB ObjectID). +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- `--template-id` string (required) — Notification template ID (MongoDB ObjectID). +- body-only (`--data`): filters (object); layers (array) (required); time_filters (array) + + + +## Key concepts + +- **`layers`** (required via `--data` on create/update): each layer needs + `target` (with `person_ids` / `team_ids` / `schedule_to_role_ids` / + `emails` + `by`, OR `webhooks`) and optionally `notify_step`, `max_times`, + `escalate_window`, `force_escalate`. +- **`filters`** (optional) scope the rule to matching incidents — read + `reference/filters.md` BEFORE composing them. Escalation filters match + against the *incident* (its keys include `dedup_key`; alert-event keys like + `alert_key` do not exist here). +- **Rule status**: `enabled` | `disabled`. + +## Gotchas + +- **`escalate-rule-create` needs `layers` via `--data`** — it is required and + cannot be expressed as a flat flag. Omitting it returns a validation error. +- **`channel-id` is positional ONLY on `escalate-rule-list`**; every other + `escalate-rule-*` verb takes it as the `--channel-id` flag. +- **`rule-id` is a MongoDB ObjectID string**, not an integer. Retrieve it + from `escalate-rule-list` before any update/delete/enable/disable. + +## Worked example — inspect a channel's escalation policy + +```bash +fduty channel list --name "payments" --output-format toon +# → find channel_id (e.g. 4201) +fduty channel escalate-rule-list 4201 --output-format toon +# → find rule_id (MongoDB ObjectID string, e.g. "6643abc123def456789012aa") +fduty channel escalate-rule-info --channel-id 4201 --rule-id "6643abc123def456789012aa" --output-format toon +``` diff --git a/skills/flashduty/reference/filters.md b/skills/flashduty/reference/filters.md new file mode 100644 index 0000000..6a5d9ba --- /dev/null +++ b/skills/flashduty/reference/filters.md @@ -0,0 +1,79 @@ +# Building `filters` conditions — shared reference + +Read this card BEFORE composing any `filters`, `source_filters`, or +`target_filters` value (silence / inhibit / drop rules in +`reference/noise.md`, escalation rules in `reference/escalation.md`). These +fields share one shape and one key vocabulary — and a wrong key is the worst +kind of error: the server may accept the rule, yet it silently never matches +anything. + +## Shape — OR of ANDs + +The value is an OR-of-AND condition tree: the outer array holds AND groups, +the rule fires when ANY group matches; each inner array holds +`{key, oper, vals}` conditions that must ALL match. + +```json +[ + [{"key":"severity","oper":"IN","vals":["Critical"]}, + {"key":"labels.service","oper":"IN","vals":["payments-api"]}], + [{"key":"labels.env","oper":"IN","vals":["staging"]}] +] +``` + +→ (Critical AND service=payments-api) OR (env=staging). + +## Operators and values + +- `oper` is `IN` (the object's value for `key` must equal one of `vals`) or + `NOTIN` (must equal none of them). +- `vals` entries also accept `/regex/` patterns, e.g. + `{"key":"title","oper":"IN","vals":["/timeout|connection refused/"]}`. +- **Missing-key trap**: when the object does not carry `key` at all, `IN` + never matches — and `NOTIN` ALWAYS matches. A `NOTIN` condition on a + misspelled key doesn't narrow the rule; it silently matches everything. + +## Keys — use the canonical names only + +Common to every rule family: `severity`, `status`, `title`, `description`, +`data_source_id` / `integration_id` (interchangeable — the server accepts +both and they always carry the same value), and `labels.` for any +custom label. Per family: + +| rule family | matched against | extra keys | keys that DO NOT exist here | +|---|---|---|---| +| silence / drop (`filters`), inhibit (`source_filters` / `target_filters`), alert pipeline (`rules[].if`, `alert_inhibit.source_filters`) | each alert event | `alert_key`, `title_rule` | `dedup_key` | +| escalation (`filters`) | the incident | `dedup_key` | `alert_key`, `title_rule` | + +A key outside the family's vocabulary (e.g. `dedup_key` in a silence rule) +produces a rule that never matches while looking configured — check the table +before creating, and prefer server-rejected over silently-dead if unsure. + +**Legacy severity/status aliases — never use them.** `event_severity`, +`alert_severity`, `incident_severity`, `alert_status`, and `incident_status` +are stored aliases that, at rule-evaluation time, always carry the SAME +value as the canonical `severity` / `status` on their surface. Spelling them +adds no precision and invites wrong reads — on escalation rules +`alert_severity` is an alias of the *incident's* severity, not of any +alert's. Always write the canonical key. + +## Building filters from incident labels (scoping a rule to one incident) + +To scope a rule to one incident's blast radius, build a single AND group from +that incident's own data (`fduty incident detail `): + +1. Start the group with a severity condition: + `{"key":"severity","oper":"IN","vals":[""]}`. +2. For each entry in the incident's `labels` object, add one condition + `{"key":"labels.","oper":"IN","vals":[""]}` — but + only when the label is actually distinguishing. Drop a label if its value + is: + - purely numeric (any kind of ID — `instance_id`, `pod_id`, …), + - longer than 256 characters (embedded JSON, stack traces, long text), + - a date/time value (`2026-07-01T10:00:00Z`, unix timestamps, …), or + - under a generically noisy key regardless of value — e.g. + `trigger_value`, `prom_ql`, `detail_url`, any `*_url` key, + `first_trigger_time`, other `*timestamp*` keys, `rule_config`. +3. After creating the rule, confirm it with the matching `*-rule-list` + command and read back its `filters` to make sure the conditions + round-tripped as intended. diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 3a9fd54..9c08daf 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -4,7 +4,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders ## Route here when -"告警 / 故障 / 事件 / 响应 / 值班 / incident / page / outage / triage / acknowledge / resolve / snooze / escalate / post-mortem" → **incident**, NOT `alert` (alert = deduplicated signal; incident = actionable item responders work). NOT `insight` (metrics/MTTA/MTTR). You need **`incident_id` (24-char MongoDB ObjectID)** for most verbs — not the 6-char `num` shown in the UI. If you only have a num, use `incident info --num ` first. +"告警 / 故障 / 事件 / 响应 / 值班 / incident / page / outage / triage / acknowledge / resolve / snooze / escalate" → **incident**, NOT `alert` (alert = deduplicated signal; incident = actionable item responders work). NOT `insight` (metrics/MTTA/MTTR). Post-mortem reports (复盘) have their own card: `reference/postmortem.md`. You need **`incident_id` (24-char MongoDB ObjectID)** for most verbs — not the 6-char `num` shown in the UI. If you only have a num, use `incident info --num ` first. ## Intent → verb @@ -36,7 +36,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders | merge duplicates (IRREVERSIBLE) | `merge ` | | stop auto-merging alerts in | `disable-merge [...]` | | permanently delete (IRREVERSIBLE) | `remove [...]` | -| post-mortem reports | `post-mortem-list` / `post-mortem-info ` / `post-mortem-delete ` | +| post-mortem reports (复盘) | `reference/postmortem.md` | | war room (IM chat) | `war-room-list ` → `war-room-create ` | ## Hot flow — triage an active incident @@ -95,7 +95,7 @@ fduty incident detail "$ID" --fields incident_id,title,incident_severity,progr fduty incident alerts "$ID" # ② contributing alerts (detail's embedded alerts are empty here) fduty incident timeline "$ID" # ④ timeline (or `incident feed "$ID"` for the paginated view) fduty incident similar "$ID" --limit 5 --output-format toon # ⑤ similar past incidents (channel-backed; see Gotchas; compact by default) -fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel +fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel (verb card: reference/postmortem.md) fduty change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md ``` @@ -235,94 +235,6 @@ List past incidents - `--limit` int64 — Maximum number of similar incidents to return. (0-100) - response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); account_locale (string); account_name (string); account_time_zone (string); ack_time (integer); active_alert_cnt (integer); ai_summary (string); alert_cnt (integer); alert_event_cnt (integer); alerts (array); assigned_to (object); channel_id (integer); channel_name (string); channel_status (string); close_time (integer); closer (object); closer_id (integer); created_at (integer); creator (object); creator_id (integer); data_source_id (integer); data_source_ids (array); data_source_type (string); data_source_types (array); dedup_key (string); deleted_at (integer); description (string); detail_url (string); end_time (integer); equals_md5 (string); ever_muted (boolean); fields (object); frequency (string); group_method (string); images (array); impact (string); incident_id (string); incident_severity (string); incident_status (string); integration_id (integer); integration_ids (array); integration_type (string); integration_types (array); labels (object); last_time (integer); links (array); manual_overrides (array); num (string); owner (object); owner_id (integer); post_mortem_id (string); progress (string); reporter_email (string); resolution (string); responders (array); root_cause (string); score (number); silence_url (string); snoozed_before (integer); start_time (integer); title (string); updated_at (integer) -### post-mortem-basics-reset -Update post-mortem basics -- `--incidents-earliest-start-seconds` string (required) — Unix timestamp in seconds for the earliest linked incident start time. (min 1) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. -- `--incidents-highest-severity` string (required) — Highest severity among linked incidents. -- `--incidents-latest-close-seconds` string — Unix timestamp in seconds for the latest linked incident close time. 0 when still open. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. -- `--incidents-total-duration-seconds` int64 — Total incident duration in seconds. (min 0) -- `` (positional, required) string — Post-mortem ID. -- `--responder-ids` intSlice — Responder member IDs to store on the report. - -### post-mortem-content-reset -Reset post-mortem Markdown content -- `--expected-revision` int64 -- `--idempotency-key` string -- `--markdown-file` string -- response: single object (`data` unwrapped to the top level) — fields: generation (integer); markdown_bytes (integer); markdown_sha256 (string); post_mortem_id (string); previous_generation (integer); previous_revision (integer); revision (integer) - -### post-mortem-delete -Delete post-mortem -- `` (positional, required) string — Post-mortem ID. - -### post-mortem-follow-ups-reset -Update post-mortem follow-ups -- `--follow-ups` string — Follow-up action items as free text. -- `` (positional, required) string — Post-mortem ID. - -### post-mortem-info -Get post-mortem -- `` (positional, required) string — Post-mortem ID. Deterministic hash derived from account ID and the set of linked incident IDs. -- response: single object (`data` unwrapped to the top level) — fields: basics (object); content (object); follow_ups (string); meta (object) - -### post-mortem-init [...] -Initialize post-mortem -- `` (positional, required) stringSlice — Incident IDs to link to the report. 1-10 incidents. -- `--template-id` string (required) — Template ID used to initialize the report. -- response: single object (`data` unwrapped to the top level) — fields: basics (object); content (object); follow_ups (string); meta (object) - -### post-mortem-list -List post-mortems -- `--asc` bool — Ascending order when true. -- `--channel-ids` intSlice — Channel IDs to restrict the query to. -- `--created-at-end-seconds` string — Filter by creation time: upper bound in seconds. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. -- `--created-at-start-seconds` string — Filter by creation time: lower bound in seconds. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. -- `--limit` int64 — Page size, at most 100. (0-100) -- `--order-by` string — Field used to order results. · enum: created_at_seconds | updated_at_seconds -- `--page` int64 — Page number starting at 1. (min 0) -- `--search-after-ctx` string — Cursor from a previous response for forward pagination. -- `--status` string — Report status. Defaults to 'published' on the server when omitted. · enum: drafting | published -- `--team-ids` intSlice — Team IDs to restrict the query to. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); author_ids (array); channel_id (integer); channel_name (string); created_at_seconds (integer); generation (integer); incident_ids (array); is_private (boolean); media_count (integer); post_mortem_id (string); revision (integer); status (string); team_id (integer); template_id (string); title (string); updated_at_seconds (integer) - -### post-mortem-status-reset -Update post-mortem status -- `` (positional, required) string — Post-mortem ID. -- `--status` string (required) — Target report status. · enum: drafting | published - -### post-mortem-template-delete -Delete post-mortem template -- `` (positional, required) string — Template ID. - -### post-mortem-template-info -Get post-mortem template detail -- `` (positional, required) string — Template ID. -- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) - -### post-mortem-template-list -List post-mortem templates -- `--asc` bool — Ascending order when true. -- `--limit` int64 — Page size, at most 100. (0-100) -- `--order-by` string — Field used to order results. · enum: created_at_seconds -- `--page` int64 — Page number starting at 1. (min 0) -- `--search-after-ctx` string — Cursor from a previous response for forward pagination. -- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) - -### post-mortem-template-upsert -Create or update post-mortem template -- `--content` string (required) — BlockNote JSON template content. -- `--content-markdown` string — Markdown version of the template content. -- `--description` string — Template description. -- `--name` string (required) — Template name. -- `--team-id` int64 — Managing team ID. Required when creating a custom template. -- `--template-id` string — Template ID. Omit to create a new template; provide it to update an existing template. -- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) - -### post-mortem-title-reset -Update post-mortem title -- `` (positional, required) string — Post-mortem ID. -- `--title` string (required) — New report title. - ### reassign Reassign an incident to new responders - `--person` string diff --git a/skills/flashduty/reference/noise.md b/skills/flashduty/reference/noise.md new file mode 100644 index 0000000..0f38049 --- /dev/null +++ b/skills/flashduty/reference/noise.md @@ -0,0 +1,205 @@ +# fduty channel noise rules — silence / inhibit / drop + +Prereq: `SKILL.md` read. Read verbs (`*-rule-list`) are free; every +`silence-rule-*`, `inhibit-rule-*`, `unsubscribe-rule-*` create / update / +enable / disable / delete mutates state — confirm before acting. + +## Route here when + +"静默 / 屏蔽 / 抑制 / 丢弃 / 降噪 / 维护窗口 / silence / mute / inhibit / +suppress / drop / discard / noise reduction / maintenance window" → this +card. These rules live INSIDE a channel (协作空间): **`channel-id` (int)** +from `fduty channel list` (channel management: `reference/channel.md`); +**`rule-id` (MongoDB ObjectID string)** from the matching `*-rule-list`. +Escalation / 分派策略 → `reference/escalation.md`. + +Rule semantics: **silence** suppresses notifications for matching alerts in a +time window (alerts still arrive); **inhibit** suppresses target alerts while +a matching source alert is active; **drop (unsubscribe)** discards matching +alerts outright. Silence and inhibit can also discard instead of suppress via +`--is-directly-discard`. + +## Intent → verb + +| want | verb | +|---|---| +| list / create / update / toggle / delete silence rules | `silence-rule-list ` / `silence-rule-create ` / `silence-rule-update` / `silence-rule-enable` / `silence-rule-disable` / `silence-rule-delete` | +| list / create / update / toggle / delete inhibit rules | `inhibit-rule-list ` / `inhibit-rule-create ` / `inhibit-rule-update` / `inhibit-rule-enable` / `inhibit-rule-disable` / `inhibit-rule-delete` | +| list / create / update / toggle / delete drop (unsubscribe) rules | `unsubscribe-rule-list ` / `unsubscribe-rule-create ` / `unsubscribe-rule-update` / `unsubscribe-rule-enable` / `unsubscribe-rule-disable` / `unsubscribe-rule-delete` | + +## Hot flow — add a silence rule during maintenance + +A silence rule needs BOTH a time window (`time_filter` or `time_filters`) AND +`filters` naming which alerts the window applies to — a `time_filter`-only +rule matches nothing and the server rejects it. Build `filters` from the +target incident's own labels — read `reference/filters.md` first for the +construction rules and the valid key set. + +```bash +# 1. inspect the incident to silence around — pulls incident_severity + labels +fduty incident detail --output-format toon + +# 2. channel-id is POSITIONAL on silence-rule-create (see use: "silence-rule-create ") +# filters is one AND group: a severity condition plus one labels. condition +# per distinguishing label — id-shaped/long/date-shaped/noise-key label values are +# dropped, not passed through (construction rules: reference/filters.md). +fduty channel silence-rule-create \ + --rule-name "planned-maintenance-2026-07-01" \ + --is-auto-delete \ + --data '{"time_filter":{"start_time":1751328000,"end_time":1751371200},"filters":[[{"key":"severity","oper":"IN","vals":["Critical"]},{"key":"labels.service","oper":"IN","vals":["payments-api"]},{"key":"labels.env","oper":"IN","vals":["prod"]}]]}' + +# 3. verify — read back `filters` to confirm the conditions round-tripped +fduty channel silence-rule-list --output-format toon +``` + + + +### inhibit-rule-create +Create inhibit rule +- `` (positional, required) int64 — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--equals` stringSlice (required) — Label keys used to pair source and target alerts. +- `--is-directly-discard` bool — When true, suppressed target alerts are dropped instead of merged. +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): source_filters (array>); target_filters (array>) +- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) + +### inhibit-rule-delete +Delete inhibit rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### inhibit-rule-disable +Disable inhibit rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### inhibit-rule-enable +Enable inhibit rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### inhibit-rule-list +List inhibit rules +- `` (positional, required) int64 — Channel to list rules for. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); equals (array); is_directly_discard (boolean); priority (integer); rule_id (string); rule_name (string); source_filters (object); status (string); target_filters (object); updated_at (integer); updated_by (integer) + +### inhibit-rule-update +Update inhibit rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--equals` stringSlice (required) — Label keys used to pair source and target alerts. +- `--is-directly-discard` bool — When true, suppressed target alerts are dropped instead of merged. +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-id` string (required) — Inhibit rule ID (MongoDB ObjectID). +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): source_filters (object); target_filters (object) + +### silence-rule-create +Create silence rule +- `` (positional, required) int64 — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--from-incident-id` string — Source incident ID when the silence was created from an incident. +- `--is-auto-delete` bool — When true, the silence rule is automatically deleted after its time window expires. Defaults to false. +- `--is-directly-discard` bool — When true, silenced alerts are dropped instead of suppressed into incidents. +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): filters (array>); time_filter (object); time_filters (array) +- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) + +### silence-rule-delete +Delete silence rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### silence-rule-disable +Disable silence rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### silence-rule-enable +Enable silence rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### silence-rule-list +List silence rules +- `` (positional, required) int64 — Channel to list rules for. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); filters (object); from_incident_id (string); is_auto_delete (boolean); is_directly_discard (boolean); is_effective (boolean); priority (integer); rule_id (string); rule_name (string); status (string); time_filter (object); time_filters (array); updated_at (integer); updated_by (integer) + +### silence-rule-update +Update silence rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--is-auto-delete` bool — When true, the silence rule is automatically deleted after its time window expires. Defaults to false. +- `--is-directly-discard` bool — When true, silenced alerts are dropped instead of suppressed into incidents. +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-id` string (required) — Silence rule ID (MongoDB ObjectID). +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): filters (object); time_filter (object); time_filters (array) + +### unsubscribe-rule-create +Create drop rule +- `` (positional, required) int64 — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): filters (array>) +- response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) + +### unsubscribe-rule-delete +Delete drop rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### unsubscribe-rule-disable +Disable drop rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### unsubscribe-rule-enable +Enable drop rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--rule-id` string (required) — Rule ID (MongoDB ObjectID). + +### unsubscribe-rule-list +List drop rules +- `` (positional, required) int64 — Channel to list rules for. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); channel_id (integer); created_at (integer); deleted_at (integer); description (string); filters (object); priority (integer); rule_id (string); rule_name (string); status (string); updated_at (integer); updated_by (integer) + +### unsubscribe-rule-update +Update drop rule +- `--channel-id` int64 (required) — Channel the rule belongs to. +- `--description` string — Rule description, up to 500 characters. (≤500 chars) +- `--priority` int64 — Evaluation priority. Lower runs first. +- `--rule-id` string (required) — Drop rule ID (MongoDB ObjectID). +- `--rule-name` string (required) — Rule name, 1 to 39 characters. (1-39 chars) +- body-only (`--data`): filters (object) + + + +## Key concepts + +- **`filters` / `source_filters` / `target_filters` construction** — read + `reference/filters.md` BEFORE composing any of them (shape, operators, and + the valid key set; a wrong key silently never matches). +- **Silence time windows**: `time_filter` (one-off, unix seconds) vs + `time_filters` (recurring weekly HH:MM windows) — mutually exclusive. Pass + via `--data`. +- **Inhibit `--equals`**: label keys that must be **equal** between the + source (high-priority) and target (suppressed) alert to form a pair (e.g. + `--equals service,env`). +- **Rule status**: `enabled` | `disabled` — applies to all three rule kinds. + +## Gotchas + +- **Positional trap**: `channel-id` is **positional** on every `*-rule-create` + and `*-rule-list` verb here; it is a **flag** (`--channel-id`) on every + `*-rule-update/delete/enable/disable`. The fence heading + `### verb ` = positional; heading without `<…>` = flag. +- **`rule-id` is a MongoDB ObjectID string**, not an integer. Retrieve it + from the matching `*-rule-list` before any update/delete/enable/disable. +- **Empty rule list is authoritative** — if `silence-rule-list` / + `inhibit-rule-list` / `unsubscribe-rule-list` returns no rows, no rules + exist; do not widen the query. diff --git a/skills/flashduty/reference/postmortem.md b/skills/flashduty/reference/postmortem.md new file mode 100644 index 0000000..8081fbc --- /dev/null +++ b/skills/flashduty/reference/postmortem.md @@ -0,0 +1,161 @@ +# fduty incident post-mortems — command card + +Prereq: `SKILL.md` read. `post-mortem-list` / `post-mortem-info` / +`post-mortem-template-list` / `post-mortem-template-info` are free reads; +init / reset / upsert / delete verbs mutate report state — confirm before +acting. `post-mortem-delete` and `post-mortem-template-delete` are +**irreversible**. + +## Route here when + +"复盘 / 复盘报告 / 复盘模板 / post-mortem / postmortem / post-incident +review / RCA report / retrospective" → this card. A post-mortem is a report +linked to 1–10 incidents; the incidents themselves (triage, resolve, merge) +are `reference/incident.md`. Key IDs: **`post-mortem-id` (string)** from +`post-mortem-list` (deterministic hash of the linked incident set); +**`template-id` (string)** from `post-mortem-template-list`; +**`incident-id` (24-char MongoDB ObjectID)** from `fduty incident list`. + +## Intent → verb + +| want | verb | +|---|---| +| list post-mortems (by channel / team / time) | `post-mortem-list` | +| read one report | `post-mortem-info ` | +| start a report from incident(s) | `post-mortem-init [...]` | +| replace the report's Markdown body | `post-mortem-content-reset ` | +| set title | `post-mortem-title-reset ` | +| set timeline/severity/responders metadata | `post-mortem-basics-reset ` | +| set follow-up action items | `post-mortem-follow-ups-reset ` | +| publish or send back to draft | `post-mortem-status-reset ` | +| delete a report (IRREVERSIBLE) | `post-mortem-delete ` | +| list / read / upsert / delete templates | `post-mortem-template-list` / `post-mortem-template-info ` / `post-mortem-template-upsert` / `post-mortem-template-delete ` | + +## Hot flow — write up a resolved incident + +```bash +# 1. pick a template +fduty incident post-mortem-template-list --output-format toon +# 2. initialize the report from the incident (returns post_mortem_id in meta) +fduty incident post-mortem-init --template-id +# 3. write the narrative — Markdown goes into a file, then content-reset +BODY_FILE=$(mktemp) +cat > "$BODY_FILE" <<'FDUTY_PM_7F3A9C2E_EOF' +## What happened +... +## Root cause +... +FDUTY_PM_7F3A9C2E_EOF +fduty incident post-mortem-content-reset --markdown-file "$BODY_FILE" +# 4. follow-ups + publish +fduty incident post-mortem-follow-ups-reset --follow-ups "Add alert on replica lag; tune failover timeout" +fduty incident post-mortem-status-reset --status published +``` + + + +### post-mortem-basics-reset +Update post-mortem basics +- `--incidents-earliest-start-seconds` string (required) — Unix timestamp in seconds for the earliest linked incident start time. (min 1) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. +- `--incidents-highest-severity` string (required) — Highest severity among linked incidents. +- `--incidents-latest-close-seconds` string — Unix timestamp in seconds for the latest linked incident close time. 0 when still open. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. +- `--incidents-total-duration-seconds` int64 — Total incident duration in seconds. (min 0) +- `` (positional, required) string — Post-mortem ID. +- `--responder-ids` intSlice — Responder member IDs to store on the report. + +### post-mortem-content-reset +Reset post-mortem Markdown content +- `--expected-revision` int64 +- `--idempotency-key` string +- `--markdown-file` string +- response: single object (`data` unwrapped to the top level) — fields: generation (integer); markdown_bytes (integer); markdown_sha256 (string); post_mortem_id (string); previous_generation (integer); previous_revision (integer); revision (integer) + +### post-mortem-delete +Delete post-mortem +- `` (positional, required) string — Post-mortem ID. + +### post-mortem-follow-ups-reset +Update post-mortem follow-ups +- `--follow-ups` string — Follow-up action items as free text. +- `` (positional, required) string — Post-mortem ID. + +### post-mortem-info +Get post-mortem +- `` (positional, required) string — Post-mortem ID. Deterministic hash derived from account ID and the set of linked incident IDs. +- response: single object (`data` unwrapped to the top level) — fields: basics (object); content (object); follow_ups (string); meta (object) + +### post-mortem-init [...] +Initialize post-mortem +- `` (positional, required) stringSlice — Incident IDs to link to the report. 1-10 incidents. +- `--template-id` string (required) — Template ID used to initialize the report. +- response: single object (`data` unwrapped to the top level) — fields: basics (object); content (object); follow_ups (string); meta (object) + +### post-mortem-list +List post-mortems +- `--asc` bool — Ascending order when true. +- `--channel-ids` intSlice — Channel IDs to restrict the query to. +- `--created-at-end-seconds` string — Filter by creation time: upper bound in seconds. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. +- `--created-at-start-seconds` string — Filter by creation time: lower bound in seconds. (min 0) Accepts a duration (7d, 24h), '+7d' for the future, 'now', a date, or Unix seconds. +- `--limit` int64 — Page size, at most 100. (0-100) +- `--order-by` string — Field used to order results. · enum: created_at_seconds | updated_at_seconds +- `--page` int64 — Page number starting at 1. (min 0) +- `--search-after-ctx` string — Cursor from a previous response for forward pagination. +- `--status` string — Report status. Defaults to 'published' on the server when omitted. · enum: drafting | published +- `--team-ids` intSlice — Team IDs to restrict the query to. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); author_ids (array); channel_id (integer); channel_name (string); created_at_seconds (integer); generation (integer); incident_ids (array); is_private (boolean); media_count (integer); post_mortem_id (string); revision (integer); status (string); team_id (integer); template_id (string); title (string); updated_at_seconds (integer) + +### post-mortem-status-reset +Update post-mortem status +- `` (positional, required) string — Post-mortem ID. +- `--status` string (required) — Target report status. · enum: drafting | published + +### post-mortem-template-delete +Delete post-mortem template +- `` (positional, required) string — Template ID. + +### post-mortem-template-info +Get post-mortem template detail +- `` (positional, required) string — Template ID. +- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) + +### post-mortem-template-list +List post-mortem templates +- `--asc` bool — Ascending order when true. +- `--limit` int64 — Page size, at most 100. (0-100) +- `--order-by` string — Field used to order results. · enum: created_at_seconds +- `--page` int64 — Page number starting at 1. (min 0) +- `--search-after-ctx` string — Cursor from a previous response for forward pagination. +- response: `{items: [...]}` page wrapper — pipe `--json | jq '.items[]'` (NOT top-level `.[]`) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) + +### post-mortem-template-upsert +Create or update post-mortem template +- `--content` string (required) — BlockNote JSON template content. +- `--content-markdown` string — Markdown version of the template content. +- `--description` string — Template description. +- `--name` string (required) — Template name. +- `--team-id` int64 — Managing team ID. Required when creating a custom template. +- `--template-id` string — Template ID. Omit to create a new template; provide it to update an existing template. +- response: single object (`data` unwrapped to the top level) — fields: account_id (integer); content (string); content_markdown (string); created_at_seconds (integer); description (string); name (string); team_id (integer); template_id (string); updated_at_seconds (integer) + +### post-mortem-title-reset +Update post-mortem title +- `` (positional, required) string — Post-mortem ID. +- `--title` string (required) — New report title. + + + +## Gotchas + +- **Post-mortem verbs live under the `incident` command group** — + `fduty incident post-mortem-list`; there is no standalone post-mortem + group. +- **`post-mortem-id` ≠ `incident-id`**: init takes incident IDs and returns + the report; every later verb takes the `post-mortem-id` from + `post-mortem-list` / init's response `meta`. +- **`post-mortem-list` defaults to `--status published`** on the server — + pass `--status drafting` to see unpublished reports. +- **Tie reports to one incident's channel** with + `post-mortem-list --channel-ids ` (channel_id from + `incident detail`); there is no per-incident list verb. +- **`post-mortem-template-upsert` without `--template-id` creates** a new + template; with it, updates in place. `--team-id` is required when creating.