From 454c807b293dddf238684fcb57c17f06254dd8f6 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 31 Jul 2026 00:43:27 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs(skills):=20status-page=20card=20?= =?UTF-8?q?=E2=80=94=20confirm=20gate=20+=20rollback=20reporting=20for=20p?= =?UTF-8?q?ublic-page=20structure=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/flashduty/reference/status-page.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/flashduty/reference/status-page.md b/skills/flashduty/reference/status-page.md index b83f670..f165182 100644 --- a/skills/flashduty/reference/status-page.md +++ b/skills/flashduty/reference/status-page.md @@ -2,6 +2,8 @@ Prereq: `SKILL.md` read. **SKILL.md + this card = full competence on status pages — no `--help` needed.** Read verbs are free; any `change-*` create/update with `--notify-subscribers` pages subscribers immediately — confirm scope first. +Structure mutations (`component-upsert` / `component-delete` / `section-upsert` / `section-delete` / `update` / `delete`) on a **public** page change what visitors see immediately, though none of them notify subscribers (only `change-*` with `--notify-subscribers` does). If the task was advisory (「推荐 / 建议 / 看一下」), the user endorsing your proposed structure approves the design, not the write — confirm once before the first mutation. After mutating, report the public page URL, what you verified, and the exact undo (`component-delete` / `section-delete` with the returned IDs). + ## Route here when "公开事件 / 公开时间线 / 状态页 / 维护窗口 / 订阅者 / 状态页迁移" → **status-page**, NOT `incident` (incident = the internal alert graph; status-page = the public-facing page). You need two IDs, both from `status-page list`: **`page_id` (int)** and **`component_id` (ULID string)**. From 19937ade7bc2d41c2aacb369625461179f3dd51a Mon Sep 17 00:00:00 2001 From: ysyneu Date: Tue, 4 Aug 2026 03:07:11 -0700 Subject: [PATCH 02/13] =?UTF-8?q?docs(skills):=20back=20up=20critical=20co?= =?UTF-8?q?nfig=20before=20mutating=20=E2=80=94=20undo-path=20discipline?= =?UTF-8?q?=20in=20SKILL.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/flashduty/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/skills/flashduty/SKILL.md b/skills/flashduty/SKILL.md index cb2ea26..1ff8df1 100644 --- a/skills/flashduty/SKILL.md +++ b/skills/flashduty/SKILL.md @@ -47,6 +47,8 @@ Configuration, permission-model, enrichment, monitor, and on-call questions are Read verbs (`list`, `get`, `info`, `detail`, `timeline`) are free. Mutating verbs (`create`, `update`, `delete`, `merge`, `ack`, `close`, `assign`, `move`, …) change state — recommend the action and get explicit per-target confirmation first. `merge` / `delete` are **irreversible** — double-check IDs. `create` notifies responders/subscribers. `list` before any bulk mutate to confirm the IDs. +**Keep an undo path — back up before overwriting.** Before `update` / `upsert` / `delete` on critical config (escalation policies, schedules, integrations, routing, status pages, alert rules, notification templates), fetch the current object with the matching read verb and save it to `backups/__.json` in your workspace. Your completion report must state: what changed, where the backup file is, and how to restore (re-upsert the saved JSON / delete the created IDs). For `create`-only changes skip the backup — report the undo (delete) instead. This is the only undo path for resources without server-side history: one read call buys recoverability. + ## Compound flows — bundled scripts Some asks span several commands. For those the skill ships a script that fetches everything in one call — run it as your **first action** for that ask, rather than hand-picking commands and writing the rest from memory: From 91e5b584246b10e6da599012d42010573314a12d Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 5 Aug 2026 21:08:14 -0700 Subject: [PATCH 03/13] Fix cligen schema walker to recurse through nested array items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The request/response field-tree walker only unwrapped one array level before checking whether the element schema was an object, so a nested array field like an OR-of-AND filter tree (array>) never expanded its item fields the way a plain array field did — and its rendered type label collapsed to the uninformative "array". Both --help and the skill-card generator read from this same walker, so every filters/source_filters/target_filters field across silence, escalate, inhibit, and unsubscribe rules (and any other nested-array field) silently dropped its key/oper/vals schema. Generalize the array case to unwrap items through any depth of nested arrays (bounded, not per-command special-cased) before testing for an object leaf, and apply the same fix to the type-label formatter. Add unit tests covering the nested-object, nested-scalar, and single-level regression cases. Also rewrite the channel card's maintenance-silence example, which built a rule with only a time window — servers reject that as having no matching scope — and add a general rule for constructing `filters` from an incident's own labels (severity + distinguishing labels, with the value/key exclusions and key vocabulary needed to avoid silently building a rule that never matches). --- internal/cli/zz_generated_alerts.go | 27 +++-- internal/cli/zz_generated_channels.go | 33 ++++-- internal/cli/zz_generated_response_help.go | 8 +- internal/cmd/cligen/main.go | 37 ++++--- internal/cmd/cligen/nested_array_test.go | 114 +++++++++++++++++++++ skills/flashduty/reference/channel.md | 59 +++++++++-- 6 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 internal/cmd/cligen/nested_array_test.go diff --git a/internal/cli/zz_generated_alerts.go b/internal/cli/zz_generated_alerts.go index d0ae944..8ec11cc 100644 --- a/internal/cli/zz_generated_alerts.go +++ b/internal/cli/zz_generated_alerts.go @@ -786,13 +786,19 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le - creator_id (integer) — Member ID who created the pipeline. - integration_id (integer) — Integration ID this pipeline applies to. - rules (array) — Ordered list of processing rules. - - if (array) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - if (array>) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. 'alert_severity', 'alert_key', 'check', 'resource', 'service', 'cluster') or the 'labels.' prefix for custom alert labels (e.g. 'labels.env', 'labels.region'). + - oper (string) (required) — Filter operator. 'IN' — value must match one of 'vals'; 'NOTIN' — value must not match any of 'vals'. Supports regex patterns wrapped in '/pattern/'. [IN, NOTIN] + - vals (array) (required) — List of values to match against. Each entry is a plain string or a '/regex/' pattern. - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit] - settings (object) — Kind-specific settings. Shape depends on 'kind': - 'title_reset': '{ "title": "" }' - 'description_reset': '{ "description": "" }' - 'severity_reset': '{ "severity": "Critical"|"Warning"|"Info" }' - 'alert_drop': '{}' (empty object) - 'alert_inhibit': '{ "equals": ["", ...], "source_filters": }' - description (string) — New description template. - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply. - severity (string) — Target severity level. [Critical, Warning, Info] - - source_filters (array) — Filter that identifies the source alerts to inhibit. + - source_filters (array>) — Filter that identifies the source alerts to inhibit. + - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. 'alert_severity', 'alert_key', 'check', 'resource', 'service', 'cluster') or the 'labels.' prefix for custom alert labels (e.g. 'labels.env', 'labels.region'). + - oper (string) (required) — Filter operator. 'IN' — value must match one of 'vals'; 'NOTIN' — value must not match any of 'vals'. Supports regex patterns wrapped in '/pattern/'. [IN, NOTIN] + - vals (array) (required) — List of values to match against. Each entry is a plain string or a '/regex/' pattern. - title (string) — New title template. Supports Golang template syntax referencing alert fields. - status (string) — Pipeline status. Possible values: 'enabled', 'disabled'. - updated_at (integer) — Last update timestamp, Unix epoch seconds. @@ -852,13 +858,16 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; - creator_id (integer) — Member ID who created the pipeline. - integration_id (integer) — Integration ID this pipeline applies to. - rules (array) — Ordered list of processing rules. - - if (array) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - if (array>) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. 'alert_severity', 'alert_key', 'check', 'resource', 'service', 'cluster') or the 'labels.' prefix for custom alert labels (e.g. 'labels.env', 'labels.region'). + - oper (string) (required) — Filter operator. 'IN' — value must match one of 'vals'; 'NOTIN' — value must not match any of 'vals'. Supports regex patterns wrapped in '/pattern/'. [IN, NOTIN] + - vals (array) (required) — List of values to match against. Each entry is a plain string or a '/regex/' pattern. - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit] - settings (object) — Kind-specific settings. Shape depends on 'kind': - 'title_reset': '{ "title": "" }' - 'description_reset': '{ "description": "" }' - 'severity_reset': '{ "severity": "Critical"|"Warning"|"Info" }' - 'alert_drop': '{}' (empty object) - 'alert_inhibit': '{ "equals": ["", ...], "source_filters": }' - description (string) — New description template. - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply. - severity (string) — Target severity level. [Critical, Warning, Info] - - source_filters (array) — Filter that identifies the source alerts to inhibit. + - source_filters (array>) — Filter that identifies the source alerts to inhibit. - title (string) — New title template. Supports Golang template syntax referencing alert fields. - status (string) — Pipeline status. Possible values: 'enabled', 'disabled'. - updated_at (integer) — Last update timestamp, Unix epoch seconds. @@ -988,13 +997,19 @@ API: POST /alert/pipeline/upsert (alert-write-pipeline-upsert) Request fields: --integration-id int (required) — Integration ID to configure. rules (array, via --data) (required) — Rules to apply. Max 50. - - if (array) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - if (array>) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts. + - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. 'alert_severity', 'alert_key', 'check', 'resource', 'service', 'cluster') or the 'labels.' prefix for custom alert labels (e.g. 'labels.env', 'labels.region'). + - oper (string) (required) — Filter operator. 'IN' — value must match one of 'vals'; 'NOTIN' — value must not match any of 'vals'. Supports regex patterns wrapped in '/pattern/'. [IN, NOTIN] + - vals (array) (required) — List of values to match against. Each entry is a plain string or a '/regex/' pattern. - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit] - settings (object) — Kind-specific settings. Shape depends on 'kind': - 'title_reset': '{ "title": "" }' - 'description_reset': '{ "description": "" }' - 'severity_reset': '{ "severity": "Critical"|"Warning"|"Info" }' - 'alert_drop': '{}' (empty object) - 'alert_inhibit': '{ "equals": ["", ...], "source_filters": }' - description (string) — New description template. - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply. - severity (string) — Target severity level. [Critical, Warning, Info] - - source_filters (array) — Filter that identifies the source alerts to inhibit. + - source_filters (array>) — Filter that identifies the source alerts to inhibit. + - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. 'alert_severity', 'alert_key', 'check', 'resource', 'service', 'cluster') or the 'labels.' prefix for custom alert labels (e.g. 'labels.env', 'labels.region'). + - oper (string) (required) — Filter operator. 'IN' — value must match one of 'vals'; 'NOTIN' — value must not match any of 'vals'. Supports regex patterns wrapped in '/pattern/'. [IN, NOTIN] + - vals (array) (required) — List of values to match against. Each entry is a plain string or a '/regex/' pattern. - title (string) — New title template. Supports Golang template syntax referencing alert fields. `, Args: requireBodyFieldOrExactArg("integration_id", "integration-id"), diff --git a/internal/cli/zz_generated_channels.go b/internal/cli/zz_generated_channels.go index bf284ef..5507aef 100644 --- a/internal/cli/zz_generated_channels.go +++ b/internal/cli/zz_generated_channels.go @@ -66,7 +66,7 @@ Request fields: group (object, via --data) — Alert grouping configuration. - all_equals_required (boolean) — When true, all listed keys must be present for grouping. - cases (array) — Per-filter grouping overrides. - - equals (array) — Groups of label keys whose equality defines a bucket. + - equals (array>) — Groups of label keys whose equality defines a bucket. - i_keys (array) — Label keys used for intelligent grouping embeddings. - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1) - method (string) (required) — Grouping method: 'i' intelligent, 'p' pattern, 'n' none. [i, p, n] @@ -329,7 +329,10 @@ Request fields: --priority int — 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). - filters (array, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + filters (array>, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + - key (string) (required) — Field key (e.g. 'alert_severity', 'labels.service'). + - oper (string) (required) — Filter operator. [IN, NOTIN] + - vals (array) (required) — Values to match. layers (array, via --data) (required) — Escalation levels in order. At least one level is required. - escalate_window (integer) — Wait before moving to the next level, in minutes. (0-720) - force_escalate (boolean) — When true, always escalate regardless of acknowledgement. @@ -892,7 +895,7 @@ Response fields ('data' envelope is unwrapped — these fields are at the top le - group (object) — Alert grouping configuration. - all_equals_required (boolean) — When true, all listed keys must be present for grouping. - cases (array) — Per-filter grouping overrides. - - equals (array) — Groups of label keys whose equality defines a bucket. + - equals (array>) — Groups of label keys whose equality defines a bucket. - i_keys (array) — Label keys used for intelligent grouping embeddings. - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1) - method (string) (required) — Grouping method: 'i' intelligent, 'p' pattern, 'n' none. [i, p, n] @@ -1024,8 +1027,14 @@ Request fields: --is-directly-discard bool — When true, suppressed target alerts are dropped instead of merged. --priority int — Evaluation priority. Lower runs first. --rule-name string (required) — Rule name, 1 to 39 characters. (1-39 chars) - source_filters (array, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. - target_filters (array, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + source_filters (array>, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + - key (string) (required) — Field key (e.g. 'alert_severity', 'labels.service'). + - oper (string) (required) — Filter operator. [IN, NOTIN] + - vals (array) (required) — Values to match. + target_filters (array>, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + - key (string) (required) — Field key (e.g. 'alert_severity', 'labels.service'). + - oper (string) (required) — Filter operator. [IN, NOTIN] + - vals (array) (required) — Values to match. Response fields ('data' envelope is unwrapped — these fields are at the top level): - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID). @@ -1462,7 +1471,7 @@ Response fields ('data' envelope is unwrapped — rows are nested under items[]; - group (object) — Alert grouping configuration. - all_equals_required (boolean) — When true, all listed keys must be present for grouping. - cases (array) — Per-filter grouping overrides. - - equals (array) — Groups of label keys whose equality defines a bucket. + - equals (array>) — Groups of label keys whose equality defines a bucket. - i_keys (array) — Label keys used for intelligent grouping embeddings. - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1) - method (string) (required) — Grouping method: 'i' intelligent, 'p' pattern, 'n' none. [i, p, n] @@ -1587,7 +1596,10 @@ Request fields: --is-directly-discard bool — When true, silenced alerts are dropped instead of suppressed into incidents. --priority int — Evaluation priority. Lower runs first. --rule-name string (required) — Rule name, 1 to 39 characters. (1-39 chars) - filters (array, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + filters (array>, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + - key (string) (required) — Field key (e.g. 'alert_severity', 'labels.service'). + - oper (string) (required) — Filter operator. [IN, NOTIN] + - vals (array) (required) — Values to match. time_filter (object, via --data) — One-off time window defined by unix seconds. - end_time (integer) (required) — Window end (unix seconds). - start_time (integer) (required) — Window start (unix seconds). Must be less than 'end_time'. @@ -2011,7 +2023,10 @@ Request fields: --description string — Rule description, up to 500 characters. (≤500 chars) --priority int — Evaluation priority. Lower runs first. --rule-name string (required) — Rule name, 1 to 39 characters. (1-39 chars) - filters (array, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + filters (array>, via --data) — Or-of-and filter tree. Each outer element is an AND group; within each group, all conditions must match. + - key (string) (required) — Field key (e.g. 'alert_severity', 'labels.service'). + - oper (string) (required) — Filter operator. [IN, NOTIN] + - vals (array) (required) — Values to match. Response fields ('data' envelope is unwrapped — these fields are at the top level): - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID). @@ -2402,7 +2417,7 @@ Request fields: group (object, via --data) — Alert grouping configuration. - all_equals_required (boolean) — When true, all listed keys must be present for grouping. - cases (array) — Per-filter grouping overrides. - - equals (array) — Groups of label keys whose equality defines a bucket. + - equals (array>) — Groups of label keys whose equality defines a bucket. - i_keys (array) — Label keys used for intelligent grouping embeddings. - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1) - method (string) (required) — Grouping method: 'i' intelligent, 'p' pattern, 'n' none. [i, p, n] diff --git a/internal/cli/zz_generated_response_help.go b/internal/cli/zz_generated_response_help.go index 2709736..784565c 100644 --- a/internal/cli/zz_generated_response_help.go +++ b/internal/cli/zz_generated_response_help.go @@ -43,8 +43,8 @@ var responseHelpBySDKMethod = map[string]string{ "Alerts.ReadInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) — Account ID.\n - alert_id (string) — Unique alert ID (ObjectID hex string).\n - alert_key (string) — Deduplication key.\n - alert_severity (string) — Current severity. [Critical, Warning, Info, Ok]\n - alert_status (string) — Current status. [Critical, Warning, Info, Ok]\n - channel_id (integer) — ID of the channel the alert belongs to.\n - channel_name (string) — Display name of the channel.\n - channel_status (string) — Status of the channel (e.g. `enabled`, `disabled`).\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead. Deprecated: use `integration_id` instead.\n - data_source_name (string) — Deprecated. Use `integration_name` instead.\n - data_source_ref_id (string) — Deprecated. Use `integration_ref_id` instead.\n - data_source_type (string) — Deprecated. Use `integration_type` instead.\n - description (string) — Alert description.\n - end_time (integer) — Resolution time, Unix epoch seconds. 0 if still active.\n - event_cnt (integer) — Total number of raw events received by this alert.\n - events (array) — Recent raw events attached to this alert. Populated only by some endpoints.\n - account_id (integer) — Account ID.\n - alert_id (string) — Parent alert ID (MongoDB ObjectID).\n - alert_key (string) — Deduplication key used to merge events into an alert.\n - channel_id (integer) — Channel ID the event is routed to.\n - created_at (integer) — Record creation time, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead.\n - deleted_at (integer) — Soft-delete timestamp (seconds). Zero if not deleted.\n - description (string) — Event description.\n - event_id (string) — Event ID (MongoDB ObjectID).\n - event_severity (string) — Severity of this event. [Critical, Warning, Info, Ok]\n - event_status (string) — Status of this event. [Critical, Warning, Info, Ok]\n - event_time (integer) — Event timestamp, Unix epoch seconds.\n - images (array) — Images attached to the event.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - integration_id (integer) — Integration that produced this event.\n - integration_type (string) — Type/plugin key of the integration that produced this event.\n - labels (object) — Label key-value pairs.\n - title (string) — Event title.\n - title_rule (string) — Title template used to derive `title` from labels.\n - updated_at (integer) — Record update time, Unix epoch seconds.\n - ever_muted (boolean) — True if this alert has ever been silenced.\n - images (array) — Images attached to the alert.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - incident (object) — Associated incident, if any.\n - incident_id (string) — Incident ID (ObjectID hex string).\n - progress (string) — Incident progress — one of `Triggered`, `Processing`, `Closed`.\n - title (string) — Incident title.\n - integration_id (integer) — ID of the integration that produced this alert.\n - integration_name (string) — Display name of the integration.\n - integration_ref_id (string) — External reference ID of the integration.\n - integration_type (string) — Type/plugin key of the integration.\n - labels (object) — Label key-value pairs.\n - last_time (integer) — Last-event time, Unix epoch seconds.\n - responder_email (string) — Email of the current responder (from the associated incident).\n - responder_name (string) — Display name of the current responder (from the associated incident).\n - start_time (integer) — First-seen time, Unix epoch seconds.\n - title (string) — Alert title.\n - title_rule (string) — Title template used to derive `title` from the event labels (e.g. `$service::$cluster`).\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n", "Alerts.ReadList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) — Account ID.\n - alert_id (string) — Unique alert ID (ObjectID hex string).\n - alert_key (string) — Deduplication key.\n - alert_severity (string) — Current severity. [Critical, Warning, Info, Ok]\n - alert_status (string) — Current status. [Critical, Warning, Info, Ok]\n - channel_id (integer) — ID of the channel the alert belongs to.\n - channel_name (string) — Display name of the channel.\n - channel_status (string) — Status of the channel (e.g. `enabled`, `disabled`).\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead. Deprecated: use `integration_id` instead.\n - data_source_name (string) — Deprecated. Use `integration_name` instead.\n - data_source_ref_id (string) — Deprecated. Use `integration_ref_id` instead.\n - data_source_type (string) — Deprecated. Use `integration_type` instead.\n - description (string) — Alert description.\n - end_time (integer) — Resolution time, Unix epoch seconds. 0 if still active.\n - event_cnt (integer) — Total number of raw events received by this alert.\n - events (array) — Recent raw events attached to this alert. Populated only by some endpoints.\n - account_id (integer) — Account ID.\n - alert_id (string) — Parent alert ID (MongoDB ObjectID).\n - alert_key (string) — Deduplication key used to merge events into an alert.\n - channel_id (integer) — Channel ID the event is routed to.\n - created_at (integer) — Record creation time, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead.\n - deleted_at (integer) — Soft-delete timestamp (seconds). Zero if not deleted.\n - description (string) — Event description.\n - event_id (string) — Event ID (MongoDB ObjectID).\n - event_severity (string) — Severity of this event. [Critical, Warning, Info, Ok]\n - event_status (string) — Status of this event. [Critical, Warning, Info, Ok]\n - event_time (integer) — Event timestamp, Unix epoch seconds.\n - images (array) — Images attached to the event.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - integration_id (integer) — Integration that produced this event.\n - integration_type (string) — Type/plugin key of the integration that produced this event.\n - labels (object) — Label key-value pairs.\n - title (string) — Event title.\n - title_rule (string) — Title template used to derive `title` from labels.\n - updated_at (integer) — Record update time, Unix epoch seconds.\n - ever_muted (boolean) — True if this alert has ever been silenced.\n - images (array) — Images attached to the alert.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - incident (object) — Associated incident, if any.\n - incident_id (string) — Incident ID (ObjectID hex string).\n - progress (string) — Incident progress — one of `Triggered`, `Processing`, `Closed`.\n - title (string) — Incident title.\n - integration_id (integer) — ID of the integration that produced this alert.\n - integration_name (string) — Display name of the integration.\n - integration_ref_id (string) — External reference ID of the integration.\n - integration_type (string) — Type/plugin key of the integration.\n - labels (object) — Label key-value pairs.\n - last_time (integer) — Last-event time, Unix epoch seconds.\n - responder_email (string) — Email of the current responder (from the associated incident).\n - responder_name (string) — Display name of the current responder (from the associated incident).\n - start_time (integer) — First-seen time, Unix epoch seconds.\n - title (string) — Alert title.\n - title_rule (string) — Title template used to derive `title` from the event labels (e.g. `$service::$cluster`).\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n", "Alerts.ReadListByIDs": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) — Account ID.\n - alert_id (string) — Unique alert ID (ObjectID hex string).\n - alert_key (string) — Deduplication key.\n - alert_severity (string) — Current severity. [Critical, Warning, Info, Ok]\n - alert_status (string) — Current status. [Critical, Warning, Info, Ok]\n - channel_id (integer) — ID of the channel the alert belongs to.\n - channel_name (string) — Display name of the channel.\n - channel_status (string) — Status of the channel (e.g. `enabled`, `disabled`).\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead. Deprecated: use `integration_id` instead.\n - data_source_name (string) — Deprecated. Use `integration_name` instead.\n - data_source_ref_id (string) — Deprecated. Use `integration_ref_id` instead.\n - data_source_type (string) — Deprecated. Use `integration_type` instead.\n - description (string) — Alert description.\n - end_time (integer) — Resolution time, Unix epoch seconds. 0 if still active.\n - event_cnt (integer) — Total number of raw events received by this alert.\n - events (array) — Recent raw events attached to this alert. Populated only by some endpoints.\n - account_id (integer) — Account ID.\n - alert_id (string) — Parent alert ID (MongoDB ObjectID).\n - alert_key (string) — Deduplication key used to merge events into an alert.\n - channel_id (integer) — Channel ID the event is routed to.\n - created_at (integer) — Record creation time, Unix epoch seconds.\n - data_source_id (integer) — Deprecated. Use `integration_id` instead.\n - deleted_at (integer) — Soft-delete timestamp (seconds). Zero if not deleted.\n - description (string) — Event description.\n - event_id (string) — Event ID (MongoDB ObjectID).\n - event_severity (string) — Severity of this event. [Critical, Warning, Info, Ok]\n - event_status (string) — Status of this event. [Critical, Warning, Info, Ok]\n - event_time (integer) — Event timestamp, Unix epoch seconds.\n - images (array) — Images attached to the event.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - integration_id (integer) — Integration that produced this event.\n - integration_type (string) — Type/plugin key of the integration that produced this event.\n - labels (object) — Label key-value pairs.\n - title (string) — Event title.\n - title_rule (string) — Title template used to derive `title` from labels.\n - updated_at (integer) — Record update time, Unix epoch seconds.\n - ever_muted (boolean) — True if this alert has ever been silenced.\n - images (array) — Images attached to the alert.\n - alt (string) — Alt text.\n - href (string) — Optional link URL when the image is clicked.\n - src (string) (required) — Image source URL or internal image reference (starts with `img_` or `http`).\n - incident (object) — Associated incident, if any.\n - incident_id (string) — Incident ID (ObjectID hex string).\n - progress (string) — Incident progress — one of `Triggered`, `Processing`, `Closed`.\n - title (string) — Incident title.\n - integration_id (integer) — ID of the integration that produced this alert.\n - integration_name (string) — Display name of the integration.\n - integration_ref_id (string) — External reference ID of the integration.\n - integration_type (string) — Type/plugin key of the integration.\n - labels (object) — Label key-value pairs.\n - last_time (integer) — Last-event time, Unix epoch seconds.\n - responder_email (string) — Email of the current responder (from the associated incident).\n - responder_name (string) — Display name of the current responder (from the associated incident).\n - start_time (integer) — First-seen time, Unix epoch seconds.\n - title (string) — Alert title.\n - title_rule (string) — Title template used to derive `title` from the event labels (e.g. `$service::$cluster`).\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n", - "Alerts.ReadPipelineInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - creator_id (integer) — Member ID who created the pipeline.\n - integration_id (integer) — Integration ID this pipeline applies to.\n - rules (array) — Ordered list of processing rules.\n - if (array) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts.\n - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit]\n - settings (object) — Kind-specific settings. Shape depends on `kind`: - `title_reset`: `{ \"title\": \"\" }` - `description_reset`: `{ \"description\": \"\" }` - `severity_reset`: `{ \"severity\": \"Critical\"|\"Warning\"|\"Info\" }` - `alert_drop`: `{}` (empty object) - `alert_inhibit`: `{ \"equals\": [\"\", ...], \"source_filters\": }`\n - description (string) — New description template.\n - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply.\n - severity (string) — Target severity level. [Critical, Warning, Info]\n - source_filters (array) — Filter that identifies the source alerts to inhibit.\n - title (string) — New title template. Supports Golang template syntax referencing alert fields.\n - status (string) — Pipeline status. Possible values: `enabled`, `disabled`.\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n - updated_by (integer) — Member ID who last updated the pipeline.\n", - "Alerts.ReadPipelineList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - creator_id (integer) — Member ID who created the pipeline.\n - integration_id (integer) — Integration ID this pipeline applies to.\n - rules (array) — Ordered list of processing rules.\n - if (array) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts.\n - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit]\n - settings (object) — Kind-specific settings. Shape depends on `kind`: - `title_reset`: `{ \"title\": \"\" }` - `description_reset`: `{ \"description\": \"\" }` - `severity_reset`: `{ \"severity\": \"Critical\"|\"Warning\"|\"Info\" }` - `alert_drop`: `{}` (empty object) - `alert_inhibit`: `{ \"equals\": [\"\", ...], \"source_filters\": }`\n - description (string) — New description template.\n - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply.\n - severity (string) — Target severity level. [Critical, Warning, Info]\n - source_filters (array) — Filter that identifies the source alerts to inhibit.\n - title (string) — New title template. Supports Golang template syntax referencing alert fields.\n - status (string) — Pipeline status. Possible values: `enabled`, `disabled`.\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n - updated_by (integer) — Member ID who last updated the pipeline.\n", + "Alerts.ReadPipelineInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - creator_id (integer) — Member ID who created the pipeline.\n - integration_id (integer) — Integration ID this pipeline applies to.\n - rules (array) — Ordered list of processing rules.\n - if (array>) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts.\n - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. `alert_severity`, `alert_key`, `check`, `resource`, `service`, `cluster`) or the `labels.` prefix for custom alert labels (e.g. `labels.env`, `labels.region`).\n - oper (string) (required) — Filter operator. `IN` — value must match one of `vals`; `NOTIN` — value must not match any of `vals`. Supports regex patterns wrapped in `/pattern/`. [IN, NOTIN]\n - vals (array) (required) — List of values to match against. Each entry is a plain string or a `/regex/` pattern.\n - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit]\n - settings (object) — Kind-specific settings. Shape depends on `kind`: - `title_reset`: `{ \"title\": \"\" }` - `description_reset`: `{ \"description\": \"\" }` - `severity_reset`: `{ \"severity\": \"Critical\"|\"Warning\"|\"Info\" }` - `alert_drop`: `{}` (empty object) - `alert_inhibit`: `{ \"equals\": [\"\", ...], \"source_filters\": }`\n - description (string) — New description template.\n - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply.\n - severity (string) — Target severity level. [Critical, Warning, Info]\n - source_filters (array>) — Filter that identifies the source alerts to inhibit.\n - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. `alert_severity`, `alert_key`, `check`, `resource`, `service`, `cluster`) or the `labels.` prefix for custom alert labels (e.g. `labels.env`, `labels.region`).\n - oper (string) (required) — Filter operator. `IN` — value must match one of `vals`; `NOTIN` — value must not match any of `vals`. Supports regex patterns wrapped in `/pattern/`. [IN, NOTIN]\n - vals (array) (required) — List of values to match against. Each entry is a plain string or a `/regex/` pattern.\n - title (string) — New title template. Supports Golang template syntax referencing alert fields.\n - status (string) — Pipeline status. Possible values: `enabled`, `disabled`.\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n - updated_by (integer) — Member ID who last updated the pipeline.\n", + "Alerts.ReadPipelineList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - created_at (integer) — Creation timestamp, Unix epoch seconds.\n - creator_id (integer) — Member ID who created the pipeline.\n - integration_id (integer) — Integration ID this pipeline applies to.\n - rules (array) — Ordered list of processing rules.\n - if (array>) — Optional OR-of-AND filter. When omitted, the rule applies to all alerts.\n - key (string) (required) — Field name to filter on. Use plain names for built-in alert fields (e.g. `alert_severity`, `alert_key`, `check`, `resource`, `service`, `cluster`) or the `labels.` prefix for custom alert labels (e.g. `labels.env`, `labels.region`).\n - oper (string) (required) — Filter operator. `IN` — value must match one of `vals`; `NOTIN` — value must not match any of `vals`. Supports regex patterns wrapped in `/pattern/`. [IN, NOTIN]\n - vals (array) (required) — List of values to match against. Each entry is a plain string or a `/regex/` pattern.\n - kind (string) — Rule type. [title_reset, description_reset, severity_reset, alert_drop, alert_inhibit]\n - settings (object) — Kind-specific settings. Shape depends on `kind`: - `title_reset`: `{ \"title\": \"\" }` - `description_reset`: `{ \"description\": \"\" }` - `severity_reset`: `{ \"severity\": \"Critical\"|\"Warning\"|\"Info\" }` - `alert_drop`: `{}` (empty object) - `alert_inhibit`: `{ \"equals\": [\"\", ...], \"source_filters\": }`\n - description (string) — New description template.\n - equals (array) — Label keys whose values must be equal between the source and current alert for inhibition to apply.\n - severity (string) — Target severity level. [Critical, Warning, Info]\n - source_filters (array>) — Filter that identifies the source alerts to inhibit.\n - title (string) — New title template. Supports Golang template syntax referencing alert fields.\n - status (string) — Pipeline status. Possible values: `enabled`, `disabled`.\n - updated_at (integer) — Last update timestamp, Unix epoch seconds.\n - updated_by (integer) — Member ID who last updated the pipeline.\n", "Analytics.ByAccount": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer)\n - acknowledgement_pct (number)\n - channel_id (integer)\n - channel_name (string)\n - hours (string) — Hour bucket when `split_hours` is enabled. [work, sleep, off]\n - mean_seconds_to_ack (number)\n - mean_seconds_to_close (number)\n - noise_reduction_pct (number)\n - responder_id (integer)\n - responder_name (string)\n - team_id (integer)\n - team_name (string)\n - total_alert_cnt (integer)\n - total_alert_event_cnt (integer)\n - total_engaged_seconds (integer)\n - total_incident_cnt (integer)\n - total_incidents_acknowledged (integer)\n - total_incidents_auto_closed (integer)\n - total_incidents_closed (integer)\n - total_incidents_escalated (integer)\n - total_incidents_manually_closed (integer)\n - total_incidents_manually_escalated (integer)\n - total_incidents_reassigned (integer)\n - total_incidents_timeout_closed (integer)\n - total_incidents_timeout_escalated (integer)\n - total_interruptions (integer)\n - total_notifications (integer)\n - total_seconds_to_ack (integer)\n - total_seconds_to_close (integer)\n - ts (integer) — Aggregation bucket start time, Unix seconds. Present when `aggregate_unit` is used.\n", "Analytics.ByChannel": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer)\n - acknowledgement_pct (number)\n - channel_id (integer)\n - channel_name (string)\n - hours (string) — Hour bucket when `split_hours` is enabled. [work, sleep, off]\n - mean_seconds_to_ack (number)\n - mean_seconds_to_close (number)\n - noise_reduction_pct (number)\n - responder_id (integer)\n - responder_name (string)\n - team_id (integer)\n - team_name (string)\n - total_alert_cnt (integer)\n - total_alert_event_cnt (integer)\n - total_engaged_seconds (integer)\n - total_incident_cnt (integer)\n - total_incidents_acknowledged (integer)\n - total_incidents_auto_closed (integer)\n - total_incidents_closed (integer)\n - total_incidents_escalated (integer)\n - total_incidents_manually_closed (integer)\n - total_incidents_manually_escalated (integer)\n - total_incidents_reassigned (integer)\n - total_incidents_timeout_closed (integer)\n - total_incidents_timeout_escalated (integer)\n - total_interruptions (integer)\n - total_notifications (integer)\n - total_seconds_to_ack (integer)\n - total_seconds_to_close (integer)\n - ts (integer) — Aggregation bucket start time, Unix seconds. Present when `aggregate_unit` is used.\n", "Analytics.ByResponder": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer)\n - acknowledgement_pct (number)\n - channel_id (integer)\n - channel_name (string)\n - hours (string) — Hour bucket when `split_hours` is enabled. [work, sleep, off]\n - mean_seconds_to_ack (number)\n - responder_id (integer)\n - responder_name (string)\n - team_id (integer)\n - team_name (string)\n - total_engaged_seconds (integer)\n - total_incident_cnt (integer)\n - total_incidents_acknowledged (integer)\n - total_incidents_escalated (integer)\n - total_incidents_manually_escalated (integer)\n - total_incidents_reassigned (integer)\n - total_incidents_timeout_escalated (integer)\n - total_interruptions (integer)\n - total_notifications (integer)\n - total_seconds_to_ack (integer)\n - ts (integer) — Aggregation bucket start time, Unix seconds. Present when `aggregate_unit` is used.\n", @@ -75,11 +75,11 @@ var responseHelpBySDKMethod = map[string]string{ "Channels.ChannelEscalateRuleCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name echoed back from the request.\n", "Channels.ChannelEscalateRuleInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) (required) — Owning account ID.\n - aggr_window (integer) (required) — Delay window in seconds.\n - channel_id (integer) (required) — Channel the rule belongs to.\n - channel_name (string) — Channel name, populated for cross-channel listing responses.\n - created_at (integer) (required) — Creation timestamp (unix seconds).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Emitted only for soft-deleted rules.\n - description (string) (required) — Rule description.\n - filters (object) (required)\n - layers (array) (required) — Escalation levels in order.\n - escalate_window (integer) — Wait before moving to the next level, in minutes. (0-720)\n - force_escalate (boolean) — When true, always escalate regardless of acknowledgement.\n - max_times (integer) — Max repeat notifications within the level. (0-6)\n - notify_step (number) — Repeat interval in minutes. (0.5-120)\n - target (object) (required) — Notification target. At least one of `person_ids`, `team_ids`, `schedule_to_role_ids`, or `emails` must be set, together with either `by` or `webhooks`.\n - by (object) — Per-severity personal notification channels. Required unless `webhooks` is provided.\n - critical (array) — Channels for Critical events (e.g. `voice`, `sms`, `email`, `feishu`).\n - follow_preference (boolean) — When true, use each responder's personal preference instead of the lists below.\n - info (array) — Channels for Info events.\n - warning (array) — Channels for Warning events.\n - emails (array) — Email addresses to notify (push-only scenarios).\n - person_ids (array) — Member IDs to notify directly.\n - schedule_to_role_ids (object) — Map of schedule ID to the role IDs on that schedule to notify.\n - team_ids (array) — Team IDs to notify.\n - webhooks (array) — Group chat / webhook targets. Required unless `by` is provided.\n - settings (object) (required) — Type-specific settings (chat IDs, URLs, etc.).\n - type (string) (required) — Webhook type (e.g. `feishu`, `dingtalk_app`, `wecom_app`, `slack`, `teams`, `custom`).\n - priority (integer) (required) — Evaluation priority. Lower runs first.\n - rule_id (string) (required) — Escalation rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name.\n - status (string) (required) — Rule status. [enabled, disabled]\n - template_id (string) (required) — Notification template ID (MongoDB ObjectID).\n - time_filters (array) (required) — Recurring time windows during which the rule applies.\n - cal_id (string) — Optional calendar ID; restricts the window to days matching the calendar.\n - end (string) — End of the window in `HH:MM`.\n - is_off (boolean) — When true, match days marked as days-off in the calendar.\n - repeat (array) — Days of the week this window repeats on. Empty means every day.\n - start (string) — Start of the window in `HH:MM`.\n - updated_at (integer) (required) — Last update timestamp (unix seconds).\n - updated_by (integer) (required) — Member ID that last updated the rule.\n", "Channels.ChannelEscalateRuleList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required) — Owning account ID.\n - aggr_window (integer) (required) — Delay window in seconds.\n - channel_id (integer) (required) — Channel the rule belongs to.\n - channel_name (string) — Channel name, populated for cross-channel listing responses.\n - created_at (integer) (required) — Creation timestamp (unix seconds).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Emitted only for soft-deleted rules.\n - description (string) (required) — Rule description.\n - filters (object) (required)\n - layers (array) (required) — Escalation levels in order.\n - escalate_window (integer) — Wait before moving to the next level, in minutes. (0-720)\n - force_escalate (boolean) — When true, always escalate regardless of acknowledgement.\n - max_times (integer) — Max repeat notifications within the level. (0-6)\n - notify_step (number) — Repeat interval in minutes. (0.5-120)\n - target (object) (required) — Notification target. At least one of `person_ids`, `team_ids`, `schedule_to_role_ids`, or `emails` must be set, together with either `by` or `webhooks`.\n - by (object) — Per-severity personal notification channels. Required unless `webhooks` is provided.\n - emails (array) — Email addresses to notify (push-only scenarios).\n - person_ids (array) — Member IDs to notify directly.\n - schedule_to_role_ids (object) — Map of schedule ID to the role IDs on that schedule to notify.\n - team_ids (array) — Team IDs to notify.\n - webhooks (array) — Group chat / webhook targets. Required unless `by` is provided.\n - priority (integer) (required) — Evaluation priority. Lower runs first.\n - rule_id (string) (required) — Escalation rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name.\n - status (string) (required) — Rule status. [enabled, disabled]\n - template_id (string) (required) — Notification template ID (MongoDB ObjectID).\n - time_filters (array) (required) — Recurring time windows during which the rule applies.\n - cal_id (string) — Optional calendar ID; restricts the window to days matching the calendar.\n - end (string) — End of the window in `HH:MM`.\n - is_off (boolean) — When true, match days marked as days-off in the calendar.\n - repeat (array) — Days of the week this window repeats on. Empty means every day.\n - start (string) — Start of the window in `HH:MM`.\n - updated_at (integer) (required) — Last update timestamp (unix seconds).\n - updated_by (integer) (required) — Member ID that last updated the rule.\n", - "Channels.ChannelInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) — Owning account ID.\n - active_incident_highest_severity (string) — Highest severity among active incidents in the channel.\n - auto_resolve_mode (string) — Auto-resolve timer reset mode. [trigger, update]\n - auto_resolve_timeout (integer) — Auto-resolve timeout in seconds. 0 disables auto-resolve.\n - channel_id (integer) — Channel ID.\n - channel_name (string) — Channel name.\n - created_at (integer) — Creation timestamp (unix seconds).\n - creator_id (integer) — Member ID who created the channel.\n - creator_name (string) — Name of the member who created the channel (resolved from the member directory; empty when unavailable).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Non-zero only for soft-deleted channels.\n - description (string) — Free-form description.\n - disable_auto_close (boolean) — When true, automatic incident closing is disabled.\n - disable_outlier_detection (boolean) — When true, outlier incident detection is disabled.\n - external_report_token (string) — Token granted to external reporters when external reporting is enabled.\n - flapping (object) — Flapping detection configuration.\n - in_mins (integer) — Observation window in minutes. (1-1440)\n - is_disabled (boolean) — Disable flapping detection.\n - max_changes (integer) — Max state changes allowed within `in_mins`. (2-100)\n - mute_mins (integer) — Mute duration in minutes after flapping is detected. (0-1440)\n - group (object) — Alert grouping configuration.\n - all_equals_required (boolean) — When true, all listed keys must be present for grouping.\n - cases (array) — Per-filter grouping overrides.\n - equals (array) — Groups of label keys whose equality defines a bucket.\n - i_keys (array) — Label keys used for intelligent grouping embeddings.\n - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1)\n - method (string) (required) — Grouping method: `i` intelligent, `p` pattern, `n` none. [i, p, n]\n - storm_threshold (integer) — Alert storm threshold. (0-10000)\n - storm_thresholds (array) — Multi-level storm thresholds.\n - time_window (integer) — Grouping time window in minutes. Default max is 1440 minutes (24 h); extended accounts may allow up to 43200 minutes (30 days). (min 0)\n - window_type (string) — Window type. Defaults to `tumbling`. [tumbling, sliding]\n - is_external_report_enabled (boolean) — Whether external reporters can file incidents into this channel.\n - is_private (boolean) — When true, the channel is visible only to its managing teams.\n - is_starred (boolean) — Whether the current user has starred this channel.\n - last_incident_at (integer) — Timestamp of the most recent incident (unix seconds).\n - managing_team_ids (array) — Additional teams that can manage the channel.\n - progress_to_incident_cnts (object)\n - Processing (integer) (required) — Count of processing incidents in the last 30 days.\n - Triggered (integer) (required) — Count of triggered incidents in the last 30 days.\n - status (string) — Channel status. [enabled, disabled, deleted]\n - team_id (integer) — Owning team ID.\n - team_name (string) — Owning team name (resolved from the team directory; empty when unavailable).\n - updated_at (integer) — Last update timestamp (unix seconds).\n", + "Channels.ChannelInfo": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - account_id (integer) — Owning account ID.\n - active_incident_highest_severity (string) — Highest severity among active incidents in the channel.\n - auto_resolve_mode (string) — Auto-resolve timer reset mode. [trigger, update]\n - auto_resolve_timeout (integer) — Auto-resolve timeout in seconds. 0 disables auto-resolve.\n - channel_id (integer) — Channel ID.\n - channel_name (string) — Channel name.\n - created_at (integer) — Creation timestamp (unix seconds).\n - creator_id (integer) — Member ID who created the channel.\n - creator_name (string) — Name of the member who created the channel (resolved from the member directory; empty when unavailable).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Non-zero only for soft-deleted channels.\n - description (string) — Free-form description.\n - disable_auto_close (boolean) — When true, automatic incident closing is disabled.\n - disable_outlier_detection (boolean) — When true, outlier incident detection is disabled.\n - external_report_token (string) — Token granted to external reporters when external reporting is enabled.\n - flapping (object) — Flapping detection configuration.\n - in_mins (integer) — Observation window in minutes. (1-1440)\n - is_disabled (boolean) — Disable flapping detection.\n - max_changes (integer) — Max state changes allowed within `in_mins`. (2-100)\n - mute_mins (integer) — Mute duration in minutes after flapping is detected. (0-1440)\n - group (object) — Alert grouping configuration.\n - all_equals_required (boolean) — When true, all listed keys must be present for grouping.\n - cases (array) — Per-filter grouping overrides.\n - equals (array>) — Groups of label keys whose equality defines a bucket.\n - i_keys (array) — Label keys used for intelligent grouping embeddings.\n - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1)\n - method (string) (required) — Grouping method: `i` intelligent, `p` pattern, `n` none. [i, p, n]\n - storm_threshold (integer) — Alert storm threshold. (0-10000)\n - storm_thresholds (array) — Multi-level storm thresholds.\n - time_window (integer) — Grouping time window in minutes. Default max is 1440 minutes (24 h); extended accounts may allow up to 43200 minutes (30 days). (min 0)\n - window_type (string) — Window type. Defaults to `tumbling`. [tumbling, sliding]\n - is_external_report_enabled (boolean) — Whether external reporters can file incidents into this channel.\n - is_private (boolean) — When true, the channel is visible only to its managing teams.\n - is_starred (boolean) — Whether the current user has starred this channel.\n - last_incident_at (integer) — Timestamp of the most recent incident (unix seconds).\n - managing_team_ids (array) — Additional teams that can manage the channel.\n - progress_to_incident_cnts (object)\n - Processing (integer) (required) — Count of processing incidents in the last 30 days.\n - Triggered (integer) (required) — Count of triggered incidents in the last 30 days.\n - status (string) — Channel status. [enabled, disabled, deleted]\n - team_id (integer) — Owning team ID.\n - team_name (string) — Owning team name (resolved from the team directory; empty when unavailable).\n - updated_at (integer) — Last update timestamp (unix seconds).\n", "Channels.ChannelInfos": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - channel_id (integer) (required) — Channel ID.\n - channel_name (string) (required) — Channel name.\n - status (string) — Channel status. [enabled, disabled]\n", "Channels.ChannelInhibitRuleCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name echoed back from the request.\n", "Channels.ChannelInhibitRuleList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required)\n - channel_id (integer) (required)\n - created_at (integer) (required)\n - deleted_at (integer)\n - description (string) (required)\n - equals (array) (required) — Label keys used to pair source and target alerts.\n - is_directly_discard (boolean) (required)\n - priority (integer) (required)\n - rule_id (string) (required)\n - rule_name (string) (required)\n - source_filters (object) (required)\n - status (string) (required) [enabled, disabled]\n - target_filters (object) (required)\n - updated_at (integer) (required)\n - updated_by (integer) (required)\n", - "Channels.ChannelList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) — Owning account ID.\n - active_incident_highest_severity (string) — Highest severity among active incidents in the channel.\n - auto_resolve_mode (string) — Auto-resolve timer reset mode. [trigger, update]\n - auto_resolve_timeout (integer) — Auto-resolve timeout in seconds. 0 disables auto-resolve.\n - channel_id (integer) — Channel ID.\n - channel_name (string) — Channel name.\n - created_at (integer) — Creation timestamp (unix seconds).\n - creator_id (integer) — Member ID who created the channel.\n - creator_name (string) — Name of the member who created the channel (resolved from the member directory; empty when unavailable).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Non-zero only for soft-deleted channels.\n - description (string) — Free-form description.\n - disable_auto_close (boolean) — When true, automatic incident closing is disabled.\n - disable_outlier_detection (boolean) — When true, outlier incident detection is disabled.\n - external_report_token (string) — Token granted to external reporters when external reporting is enabled.\n - flapping (object) — Flapping detection configuration.\n - in_mins (integer) — Observation window in minutes. (1-1440)\n - is_disabled (boolean) — Disable flapping detection.\n - max_changes (integer) — Max state changes allowed within `in_mins`. (2-100)\n - mute_mins (integer) — Mute duration in minutes after flapping is detected. (0-1440)\n - group (object) — Alert grouping configuration.\n - all_equals_required (boolean) — When true, all listed keys must be present for grouping.\n - cases (array) — Per-filter grouping overrides.\n - equals (array) — Groups of label keys whose equality defines a bucket.\n - i_keys (array) — Label keys used for intelligent grouping embeddings.\n - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1)\n - method (string) (required) — Grouping method: `i` intelligent, `p` pattern, `n` none. [i, p, n]\n - storm_threshold (integer) — Alert storm threshold. (0-10000)\n - storm_thresholds (array) — Multi-level storm thresholds.\n - time_window (integer) — Grouping time window in minutes. Default max is 1440 minutes (24 h); extended accounts may allow up to 43200 minutes (30 days). (min 0)\n - window_type (string) — Window type. Defaults to `tumbling`. [tumbling, sliding]\n - is_external_report_enabled (boolean) — Whether external reporters can file incidents into this channel.\n - is_private (boolean) — When true, the channel is visible only to its managing teams.\n - is_starred (boolean) — Whether the current user has starred this channel.\n - last_incident_at (integer) — Timestamp of the most recent incident (unix seconds).\n - managing_team_ids (array) — Additional teams that can manage the channel.\n - progress_to_incident_cnts (object)\n - Processing (integer) (required) — Count of processing incidents in the last 30 days.\n - Triggered (integer) (required) — Count of triggered incidents in the last 30 days.\n - status (string) — Channel status. [enabled, disabled, deleted]\n - team_id (integer) — Owning team ID.\n - team_name (string) — Owning team name (resolved from the team directory; empty when unavailable).\n - updated_at (integer) — Last update timestamp (unix seconds).\n", + "Channels.ChannelList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) — Owning account ID.\n - active_incident_highest_severity (string) — Highest severity among active incidents in the channel.\n - auto_resolve_mode (string) — Auto-resolve timer reset mode. [trigger, update]\n - auto_resolve_timeout (integer) — Auto-resolve timeout in seconds. 0 disables auto-resolve.\n - channel_id (integer) — Channel ID.\n - channel_name (string) — Channel name.\n - created_at (integer) — Creation timestamp (unix seconds).\n - creator_id (integer) — Member ID who created the channel.\n - creator_name (string) — Name of the member who created the channel (resolved from the member directory; empty when unavailable).\n - deleted_at (integer) — Deletion timestamp (unix seconds). Non-zero only for soft-deleted channels.\n - description (string) — Free-form description.\n - disable_auto_close (boolean) — When true, automatic incident closing is disabled.\n - disable_outlier_detection (boolean) — When true, outlier incident detection is disabled.\n - external_report_token (string) — Token granted to external reporters when external reporting is enabled.\n - flapping (object) — Flapping detection configuration.\n - in_mins (integer) — Observation window in minutes. (1-1440)\n - is_disabled (boolean) — Disable flapping detection.\n - max_changes (integer) — Max state changes allowed within `in_mins`. (2-100)\n - mute_mins (integer) — Mute duration in minutes after flapping is detected. (0-1440)\n - group (object) — Alert grouping configuration.\n - all_equals_required (boolean) — When true, all listed keys must be present for grouping.\n - cases (array) — Per-filter grouping overrides.\n - equals (array>) — Groups of label keys whose equality defines a bucket.\n - i_keys (array) — Label keys used for intelligent grouping embeddings.\n - i_score_threshold (number) — Intelligent grouping similarity threshold. (0.5-1)\n - method (string) (required) — Grouping method: `i` intelligent, `p` pattern, `n` none. [i, p, n]\n - storm_threshold (integer) — Alert storm threshold. (0-10000)\n - storm_thresholds (array) — Multi-level storm thresholds.\n - time_window (integer) — Grouping time window in minutes. Default max is 1440 minutes (24 h); extended accounts may allow up to 43200 minutes (30 days). (min 0)\n - window_type (string) — Window type. Defaults to `tumbling`. [tumbling, sliding]\n - is_external_report_enabled (boolean) — Whether external reporters can file incidents into this channel.\n - is_private (boolean) — When true, the channel is visible only to its managing teams.\n - is_starred (boolean) — Whether the current user has starred this channel.\n - last_incident_at (integer) — Timestamp of the most recent incident (unix seconds).\n - managing_team_ids (array) — Additional teams that can manage the channel.\n - progress_to_incident_cnts (object)\n - Processing (integer) (required) — Count of processing incidents in the last 30 days.\n - Triggered (integer) (required) — Count of triggered incidents in the last 30 days.\n - status (string) — Channel status. [enabled, disabled, deleted]\n - team_id (integer) — Owning team ID.\n - team_name (string) — Owning team name (resolved from the team directory; empty when unavailable).\n - updated_at (integer) — Last update timestamp (unix seconds).\n", "Channels.ChannelSilenceRuleCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name echoed back from the request.\n", "Channels.ChannelSilenceRuleList": "Response fields (this command's `--json` is a TOP-LEVEL array of these row objects — pipe `jq '.[]'`, NOT `.items[]`):\n - account_id (integer) (required)\n - channel_id (integer) (required)\n - created_at (integer) (required)\n - deleted_at (integer)\n - description (string) (required)\n - filters (object) (required)\n - from_incident_id (string) — Source incident ID when the silence was created from an incident.\n - is_auto_delete (boolean) — When true, the silence rule is automatically deleted after its time window expires. Defaults to false.\n - is_directly_discard (boolean) (required) — When true, silenced alerts are dropped instead of suppressed into incidents.\n - is_effective (boolean) (required) — Whether the rule is currently in effect.\n - priority (integer) (required) — Evaluation priority. Lower runs first.\n - rule_id (string) (required)\n - rule_name (string) (required)\n - status (string) (required) [enabled, disabled]\n - time_filter (object) (required) — One-off time window defined by unix seconds.\n - end_time (integer) (required) — Window end (unix seconds). Must be > 0.\n - start_time (integer) (required) — Window start (unix seconds). Must be > 0 and less than `end_time`.\n - time_filters (array) (required) — Recurring time windows.\n - cal_id (string) — Optional calendar ID; restricts the window to days matching the calendar.\n - end (string) — End of the window in `HH:MM`.\n - is_off (boolean) — When true, match days marked as days-off in the calendar.\n - repeat (array) — Days of the week this window repeats on. Empty means every day.\n - start (string) — Start of the window in `HH:MM`.\n - updated_at (integer) (required)\n - updated_by (integer) (required)\n", "Channels.ChannelUnsubscribeRuleCreate": "Response fields (`data` envelope is unwrapped — these fields are at the top level):\n - rule_id (string) (required) — Newly created rule ID (MongoDB ObjectID).\n - rule_name (string) (required) — Rule name echoed back from the request.\n", diff --git a/internal/cmd/cligen/main.go b/internal/cmd/cligen/main.go index 4a0f70c..83b85cc 100644 --- a/internal/cmd/cligen/main.go +++ b/internal/cmd/cligen/main.go @@ -481,19 +481,14 @@ func (w *specWalker) enumOf(s map[string]any) []string { return nil } -// schemaType renders a compact type label for a (deref'd) property schema. +// schemaType renders a compact type label for a (deref'd) property schema, +// recursing through nested array `items` so an "Or-of-AND" filter tree +// (array>) renders its true element type instead of collapsing +// to the uninformative "array" one level down. func schemaType(s map[string]any) string { switch t := str(s, "type"); t { case "array": - it := asMap(s["items"]) - et := str(it, "type") - if et == "" && (it["properties"] != nil || it["allOf"] != nil || it["$ref"] != nil) { - et = "object" - } - if et == "" { - et = "any" - } - return "array<" + et + ">" + return "array<" + schemaType(asMap(s["items"])) + ">" case "": if s["properties"] != nil || s["allOf"] != nil || s["$ref"] != nil { return "object" @@ -552,8 +547,24 @@ func numStr(v any) (string, bool) { // maxSchemaDepth bounds how deep request/response trees are expanded in --help. const maxSchemaDepth = 3 +// arrayLeafSchema unwraps an array schema's `items` (deref'ing $ref at each +// level) through any number of nested array layers — e.g. the "Or-of-AND" +// filter trees (`array>`) — until it reaches the first +// non-array element schema. A plain `array` schema returns its object +// items unchanged (zero unwraps), so tree()'s array case handles any nesting +// depth the same way it always handled one level. Bounded by maxSchemaDepth as +// a defensive stop against a cyclic schema. +func (w *specWalker) arrayLeafSchema(s map[string]any) map[string]any { + it := w.deref(asMap(s["items"])) + for levels := 0; str(it, "type") == "array" && levels < maxSchemaDepth; levels++ { + it = w.deref(asMap(it["items"])) + } + return it +} + // tree walks an object schema (resolving $ref/allOf) into a sorted field tree, -// recursing into nested objects and array-element objects up to maxSchemaDepth. +// recursing into nested objects and array-element objects (through any depth +// of nested arrays, see arrayLeafSchema) up to maxSchemaDepth. func (w *specWalker) tree(schema map[string]any, depth int) []schemaField { if depth > maxSchemaDepth { return nil @@ -576,11 +587,11 @@ func (w *specWalker) tree(schema map[string]any, depth int) []schemaField { f.Type = "object" f.Children = w.tree(pv, depth+1) case str(pv, "type") == "array": - it := w.deref(asMap(pv["items"])) + it := w.arrayLeafSchema(pv) if w.isObjectSchema(it) { f.Children = w.tree(it, depth+1) } else if len(f.Enum) == 0 { - f.Enum = enumStrings(it) // array of constrained scalars + f.Enum = enumStrings(it) // array (possibly nested) of constrained scalars } } out = append(out, f) diff --git a/internal/cmd/cligen/nested_array_test.go b/internal/cmd/cligen/nested_array_test.go new file mode 100644 index 0000000..c43aeb0 --- /dev/null +++ b/internal/cmd/cligen/nested_array_test.go @@ -0,0 +1,114 @@ +package main + +import "testing" + +// TestTreeExpandsNestedArrayOfObject covers the "Or-of-AND" filter tree shape +// (array>, e.g. silence-rule-create's `filters`): the object +// item fields two array layers down must render as children exactly like a +// plain array field does one layer down. +func TestTreeExpandsNestedArrayOfObject(t *testing.T) { + w := &specWalker{schemas: map[string]any{ + "FilterCondition": map[string]any{ + "type": "object", + "required": []any{"key", "oper", "vals"}, + "properties": map[string]any{ + "key": map[string]any{"type": "string", "description": "e.g. `alert_severity`, `labels.service`"}, + "oper": map[string]any{"type": "string", "enum": []any{"IN", "NOTIN"}}, + "vals": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }, + }} + + fields := w.tree(map[string]any{"properties": map[string]any{ + "filters": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "array", + "items": map[string]any{"$ref": "#/components/schemas/FilterCondition"}, + }, + }, + }}, 0) + + if len(fields) != 1 || fields[0].Wire != "filters" { + t.Fatalf("fields = %#v, want single 'filters' field", fields) + } + children := fields[0].Children + if len(children) != 3 { + t.Fatalf("filters.Children = %#v, want 3 (key, oper, vals)", children) + } + byWire := map[string]schemaField{} + for _, c := range children { + byWire[c.Wire] = c + } + if byWire["key"].Type != "string" || byWire["key"].Desc == "" { + t.Fatalf("key child = %#v, want string with description", byWire["key"]) + } + if len(byWire["oper"].Enum) != 2 { + t.Fatalf("oper.Enum = %#v, want [IN NOTIN]", byWire["oper"].Enum) + } + if byWire["vals"].Type != "array" { + t.Fatalf("vals.Type = %q, want array", byWire["vals"].Type) + } +} + +// TestTreeLeavesNestedArrayOfScalarWithoutChildren guards the sibling shape +// (array>, e.g. the alert-grouping `equals` field): with no +// object at the bottom, tree() must not synthesize children. +func TestTreeLeavesNestedArrayOfScalarWithoutChildren(t *testing.T) { + w := &specWalker{} + + fields := w.tree(map[string]any{"properties": map[string]any{ + "equals": map[string]any{ + "type": "array", + "items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }, + }}, 0) + + if len(fields) != 1 || fields[0].Wire != "equals" { + t.Fatalf("fields = %#v, want single 'equals' field", fields) + } + if len(fields[0].Children) != 0 { + t.Fatalf("equals.Children = %#v, want none", fields[0].Children) + } +} + +// TestSchemaTypeLabelsNestedArrayDepth guards the type label alongside the +// child-expansion behavior above: "array" hid the same missing-depth +// information for the flag/summary line that the missing children hid for +// the field list, and both stem from the array case only unwrapping one +// `items` level. +func TestSchemaTypeLabelsNestedArrayDepth(t *testing.T) { + cases := []struct { + name string + s map[string]any + want string + }{ + {"scalar array", map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, "array"}, + {"array of object", map[string]any{"type": "array", "items": map[string]any{"type": "object"}}, "array"}, + {"array of array of object (filters)", map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": map[string]any{"$ref": "#/components/schemas/FilterCondition"}}}, "array>"}, + {"array of array of string (equals)", map[string]any{"type": "array", "items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, "array>"}, + } + for _, c := range cases { + if got := schemaType(c.s); got != c.want { + t.Errorf("%s: schemaType() = %q, want %q", c.name, got, c.want) + } + } +} + +// TestTreeExpandsPlainArrayOfObjectUnchanged is a regression guard: the +// existing single-level array behavior (e.g. `layers`) must be +// unaffected by generalizing the array case to unwrap nested arrays. +func TestTreeExpandsPlainArrayOfObjectUnchanged(t *testing.T) { + w := &specWalker{} + + fields := w.tree(map[string]any{"properties": map[string]any{ + "layers": map[string]any{ + "type": "array", + "items": map[string]any{"type": "object", "properties": map[string]any{"target": map[string]any{"type": "string"}}}, + }, + }}, 0) + + if len(fields) != 1 || len(fields[0].Children) != 1 || fields[0].Children[0].Wire != "target" { + t.Fatalf("layers field = %#v, want single child 'target'", fields[0]) + } +} diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index 82f98a4..7afe08f 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -48,13 +48,26 @@ fduty channel escalate-rule-create \ ## 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 -# channel-id is POSITIONAL on silence-rule-create (see use: "silence-rule-create ") +# 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}}' -# verify + --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 ``` @@ -96,7 +109,7 @@ Create escalation rule - `--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) +- 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 @@ -154,7 +167,7 @@ Create inhibit rule - `--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) +- 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 @@ -203,7 +216,7 @@ Create silence rule - `--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) +- 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 @@ -243,7 +256,7 @@ Create drop rule - `--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) +- body-only (`--data`): filters (array>) - response: single object (`data` unwrapped to the top level) — fields: rule_id (string); rule_name (string) ### unsubscribe-rule-delete @@ -302,6 +315,38 @@ Update channel - **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. From 8dfdac372a67a15f55d0234666a956aaf7935d28 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 5 Aug 2026 23:44:35 -0700 Subject: [PATCH 04/13] =?UTF-8?q?feat(skilldoc):=20subset=20fences=20?= =?UTF-8?q?=E2=80=94=20split=20one=20command=20group=20across=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fence id can now be group[verb-prefix,...]: a subset fence claiming every verb starting with one of the prefixes, while the bare-group fence renders the unclaimed remainder as the catch-all. gen/check discover fences by scanning the cards for markers instead of deriving one filename per group, so a large command group's reference card can be split along task lines. Topology violations (a verb claimed twice, a prefix claiming nothing, unclaimed verbs with no catch-all, a duplicated fence) fail gen and are reported by check as fence-topology issues. --- internal/cmd/skilldoc/main.go | 100 +++++++++++++----- internal/cmd/skilldoc/main_test.go | 62 +++++++++++ internal/skilldoc/fence.go | 159 ++++++++++++++++++++++++++++ internal/skilldoc/fence_test.go | 164 +++++++++++++++++++++++++++++ internal/skilldoc/fences_test.go | 6 +- internal/skilldoc/generate.go | 34 +++--- internal/skilldoc/validate.go | 96 +++++++++++++---- 7 files changed, 559 insertions(+), 62 deletions(-) create mode 100644 internal/skilldoc/fence.go create mode 100644 internal/skilldoc/fence_test.go diff --git a/internal/cmd/skilldoc/main.go b/internal/cmd/skilldoc/main.go index 16e57a2..5bb524f 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,90 @@ 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. +// runGen regenerates every GENERATED fence of group across the cards under +// , 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. func runGen(d skilldoc.Dump, base, group string) error { - card := filepath.Join(base, "reference", group+".md") - raw, err := os.ReadFile(card) + docs, err := loadDocs(base) if err != nil { - return fmt.Errorf("read card: %w", err) + return err + } + + var ids []string + perDoc := map[string][]string{} + var docOrder []string + for _, doc := range docs { + for _, fl := range skilldoc.FenceLocs(doc.Body) { + spec, err := skilldoc.ParseFenceID(fl.ID) + if err != nil { + return fmt.Errorf("%s: %w", doc.Path, err) + } + if spec.Group != group { + continue + } + if len(perDoc[doc.Path]) == 0 { + docOrder = append(docOrder, doc.Path) + } + perDoc[doc.Path] = append(perDoc[doc.Path], fl.ID) + ids = append(ids, fl.ID) + } + } + if len(ids) == 0 { + return fmt.Errorf("no GENERATED:%s fence found under %s (add the start/end markers first)", group, base) } - 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 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 + byPath := map[string]skilldoc.Doc{} + for _, doc := range docs { + byPath[doc.Path] = doc } - if err := os.WriteFile(card, []byte(updated), 0o644); err != nil { - return fmt.Errorf("write card: %w", err) + for _, p := range docOrder { + doc := byPath[p] + body := doc.Body + for _, id := range perDoc[p] { + start, end := skilldoc.FenceStart(id), skilldoc.FenceEnd(id) + si := strings.Index(body, start) + ei := strings.Index(body[si:], end) + if si < 0 || ei < 0 { + return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id) + } + body = body[:si] + rendered[id] + body[si+ei+len(end):] + } + if body == doc.Body { + continue // already fresh + } + if err := os.WriteFile(filepath.Join(base, p), []byte(body), 0o644); err != nil { + return fmt.Errorf("write card: %w", err) + } } 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), so it stays correct +// as domains are added or renamed — no hardcoded list. Groups without any +// fence (e.g. webhook) are skipped. func runGenAll(d skilldoc.Dump, base string) error { + docs, err := loadDocs(base) + if err != nil { + return err + } + withFence := map[string]bool{} + for _, doc := range docs { + for _, fl := range skilldoc.FenceLocs(doc.Body) { + if spec, err := skilldoc.ParseFenceID(fl.ID); err == nil { + withFence[spec.Group] = true + } + } + } + seen := map[string]bool{} var groups []string for _, c := range d.Commands { @@ -125,8 +175,8 @@ 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 !withFence[g] { + continue } if err := runGen(d, base, 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..53d59d1 100644 --- a/internal/cmd/skilldoc/main_test.go +++ b/internal/cmd/skilldoc/main_test.go @@ -337,3 +337,65 @@ 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) + } +} diff --git a/internal/skilldoc/fence.go b/internal/skilldoc/fence.go new file mode 100644 index 0000000..eb5b8b1 --- /dev/null +++ b/internal/skilldoc/fence.go @@ -0,0 +1,159 @@ +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..dd7d873 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,91 @@ 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}) + } + } + + groupOrder := make([]string, 0, len(byGroup)) + for g := range byGroup { + groupOrder = append(groupOrder, g) + } + sort.Strings(groupOrder) + + for _, group := range groupOrder { + locs := byGroup[group] + 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 { + end := FenceEnd(l.id) + ei := strings.Index(l.body[l.off:], end) + if ei < 0 { + 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 { + block := l.body[l.off : l.off+ei+len(end)] + if fresh, ok := rendered[l.id]; ok && block != 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`", }) } } From 10f9df554c1108f8060d16aaa08b49d325a1c690 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 5 Aug 2026 23:44:45 -0700 Subject: [PATCH 05/13] docs(skill): split channel/incident cards; add shared filters reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channel.md (367 lines) carried four rule families beside channel CRUD, and incident.md (508) carried the whole post-mortem surface — every task paid for the whole domain, and the guidance competing for attention is exactly how a hot-flow example gets misread. Split along task lines using subset fences: - channel.md → channel CRUD only; escalation.md (escalate-rule-*); noise.md (silence-rule-*, inhibit-rule-*, unsubscribe-rule-*) - incident.md → triage/investigation; postmortem.md (post-mortem-*) - filters.md: new shared reference for filters/source_filters/ target_filters — OR-of-AND shape, IN/NOTIN semantics incl. the missing-key trap, /regex/ values, construction-from-labels rules, and the per-family key vocabulary. Consumer cards and alert.md's pipeline section point here instead of restating it. - Canonical keys only: guidance now teaches severity/status/ data_source_id; the per-surface aliases (event_severity, alert_severity, incident_severity, alert_status, incident_status, integration_id) always carry the same stored value and are called out as never-use. - SKILL.md router: channel row narrowed; new escalation/noise/postmortem rows; shared-reference note for filters.md. --- skills/flashduty/SKILL.md | 9 +- skills/flashduty/reference/alert.md | 2 +- skills/flashduty/reference/channel.md | 277 +---------------------- skills/flashduty/reference/escalation.md | 122 ++++++++++ skills/flashduty/reference/filters.md | 77 +++++++ skills/flashduty/reference/incident.md | 94 +------- skills/flashduty/reference/noise.md | 205 +++++++++++++++++ skills/flashduty/reference/postmortem.md | 161 +++++++++++++ 8 files changed, 587 insertions(+), 360 deletions(-) create mode 100644 skills/flashduty/reference/escalation.md create mode 100644 skills/flashduty/reference/filters.md create mode 100644 skills/flashduty/reference/noise.md create mode 100644 skills/flashduty/reference/postmortem.md 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..8044cac --- /dev/null +++ b/skills/flashduty/reference/filters.md @@ -0,0 +1,77 @@ +# 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`, 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 aliases — never use them.** `event_severity`, `alert_severity`, +`incident_severity`, `alert_status`, `incident_status`, and `integration_id` +are stored aliases that always carry the SAME value as the canonical +`severity` / `status` / `data_source_id` 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..2d4d1e6 --- /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. From 0ba61796c1f0e7cb7ae47e9e7f8bc2a7d0d661ef Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 5 Aug 2026 23:49:08 -0700 Subject: [PATCH 06/13] =?UTF-8?q?docs(skill),fix(skilldoc):=20review=20fix?= =?UTF-8?q?es=20=E2=80=94=20integration=5Fid=20equivalence;=20guard=20spli?= =?UTF-8?q?ce=20index;=20single-file=20two-fence=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filters.md: integration_id is not a legacy alias — the server accepts it and data_source_id interchangeably (the API's response docs in fact deprecate data_source_id), so present the pair as equivalent and keep the never-use list to the severity/status aliases, scoped to rule-evaluation time. - runGen: check the start-marker index before slicing the body with it. - New TestRunGen_TwoFencesInOneFile pins the sequential splice loop for a subset fence and catch-all living in one card. --- internal/cmd/skilldoc/main.go | 5 +++- internal/cmd/skilldoc/main_test.go | 40 +++++++++++++++++++++++++++ skills/flashduty/reference/filters.md | 18 ++++++------ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/internal/cmd/skilldoc/main.go b/internal/cmd/skilldoc/main.go index 5bb524f..af3fd16 100644 --- a/internal/cmd/skilldoc/main.go +++ b/internal/cmd/skilldoc/main.go @@ -130,8 +130,11 @@ func runGen(d skilldoc.Dump, base, group string) error { for _, id := range perDoc[p] { start, end := skilldoc.FenceStart(id), skilldoc.FenceEnd(id) si := strings.Index(body, start) + if si < 0 { + return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id) + } ei := strings.Index(body[si:], end) - if si < 0 || ei < 0 { + if ei < 0 { return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id) } body = body[:si] + rendered[id] + body[si+ei+len(end):] diff --git a/internal/cmd/skilldoc/main_test.go b/internal/cmd/skilldoc/main_test.go index 53d59d1..cad868e 100644 --- a/internal/cmd/skilldoc/main_test.go +++ b/internal/cmd/skilldoc/main_test.go @@ -399,3 +399,43 @@ func TestRunGen_TopologyViolationFails(t *testing.T) { 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/skills/flashduty/reference/filters.md b/skills/flashduty/reference/filters.md index 8044cac..6a5d9ba 100644 --- a/skills/flashduty/reference/filters.md +++ b/skills/flashduty/reference/filters.md @@ -36,7 +36,9 @@ the rule fires when ANY group matches; each inner array holds ## Keys — use the canonical names only Common to every rule family: `severity`, `status`, `title`, `description`, -`data_source_id`, and `labels.` for any custom label. Per family: +`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 | |---|---|---|---| @@ -47,13 +49,13 @@ 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 aliases — never use them.** `event_severity`, `alert_severity`, -`incident_severity`, `alert_status`, `incident_status`, and `integration_id` -are stored aliases that always carry the SAME value as the canonical -`severity` / `status` / `data_source_id` 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. +**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) From 3b2baa4162a660aa545b2775de79d52c3e05798a Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 5 Aug 2026 23:54:23 -0700 Subject: [PATCH 07/13] =?UTF-8?q?refactor(skilldoc):=20simplify-pass=20?= =?UTF-8?q?=E2=80=94=20segment-bounded=20prefixes,=20single=20corpus=20loa?= =?UTF-8?q?d,=20FindFence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefix claims are now hyphen-boundary-bounded (verb == p or p + "-"): an unbounded match could misroute a near-miss verb (rule2-list under rule-) with a clean single-owner partition no topology check would flag. Fence ids normalized to the boundary-safe form (incident[post-mortem]); a trailing-hyphen prefix now claims nothing and dies loudly as a dead-prefix violation. - runGenAll loads the card corpus once and threads it through every group via genGroup (was one full reload per group); genGroup updates the in-memory docs after each write so later groups see current content. runGen's docOrder/byPath bookkeeping dropped — it iterates the docs slice directly. - New skilldoc.FindFence(body, id) centralizes start/end-marker location for both gen and check (the two ad-hoc copies had already drifted in error wording); hasCatchAll bool and a no-op map insert removed; CheckFences reuses groups(d)'s existing order instead of re-sorting. --- internal/cmd/skilldoc/main.go | 97 +++++++++++------------- internal/cmd/skilldoc/main_test.go | 6 +- internal/skilldoc/fence.go | 32 ++++++-- internal/skilldoc/fence_test.go | 49 +++++++++--- internal/skilldoc/validate.go | 22 +++--- skills/flashduty/reference/postmortem.md | 4 +- 6 files changed, 124 insertions(+), 86 deletions(-) diff --git a/internal/cmd/skilldoc/main.go b/internal/cmd/skilldoc/main.go index af3fd16..0cef477 100644 --- a/internal/cmd/skilldoc/main.go +++ b/internal/cmd/skilldoc/main.go @@ -81,92 +81,90 @@ 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 regenerates every GENERATED fence of group across the cards under -// , 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. -func runGen(d skilldoc.Dump, base, group string) error { - docs, err := loadDocs(base) - if err != nil { - return 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{} - var docOrder []string for _, doc := range docs { for _, fl := range skilldoc.FenceLocs(doc.Body) { spec, err := skilldoc.ParseFenceID(fl.ID) if err != nil { - return fmt.Errorf("%s: %w", doc.Path, err) + return false, fmt.Errorf("%s: %w", doc.Path, err) } if spec.Group != group { continue } - if len(perDoc[doc.Path]) == 0 { - docOrder = append(docOrder, doc.Path) - } perDoc[doc.Path] = append(perDoc[doc.Path], fl.ID) ids = append(ids, fl.ID) } } if len(ids) == 0 { - return fmt.Errorf("no GENERATED:%s fence found under %s (add the start/end markers first)", group, base) + return false, nil } rendered, violations := skilldoc.RenderGroupFences(d, group, ids) if len(violations) > 0 { - return fmt.Errorf("group %s fence topology: %s", group, strings.Join(violations, "; ")) + return true, fmt.Errorf("group %s fence topology: %s", group, strings.Join(violations, "; ")) } - byPath := map[string]skilldoc.Doc{} - for _, doc := range docs { - byPath[doc.Path] = doc - } - for _, p := range docOrder { - doc := byPath[p] + for i, doc := range docs { + docIDs := perDoc[doc.Path] + if len(docIDs) == 0 { + continue + } body := doc.Body - for _, id := range perDoc[p] { - start, end := skilldoc.FenceStart(id), skilldoc.FenceEnd(id) - si := strings.Index(body, start) - if si < 0 { - return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id) + 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) } - ei := strings.Index(body[si:], end) - if ei < 0 { - return fmt.Errorf("%s: unterminated GENERATED:%s fence", p, id) - } - body = body[:si] + rendered[id] + body[si+ei+len(end):] + body = body[:start] + rendered[id] + body[end:] } if body == doc.Body { continue // already fresh } - if err := os.WriteFile(filepath.Join(base, p), []byte(body), 0o644); err != nil { - return fmt.Errorf("write card: %w", err) + 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 + } + 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 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), so it stays correct -// as domains are added or renamed — no hardcoded list. Groups without any -// fence (e.g. webhook) are skipped. +// 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 } - withFence := map[string]bool{} - for _, doc := range docs { - for _, fl := range skilldoc.FenceLocs(doc.Body) { - if spec, err := skilldoc.ParseFenceID(fl.ID); err == nil { - withFence[spec.Group] = true - } - } - } seen := map[string]bool{} var groups []string @@ -178,10 +176,7 @@ func runGenAll(d skilldoc.Dump, base string) error { } sort.Strings(groups) for _, g := range groups { - if !withFence[g] { - continue - } - 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 cad868e..922b7c9 100644 --- a/internal/cmd/skilldoc/main_test.go +++ b/internal/cmd/skilldoc/main_test.go @@ -350,7 +350,7 @@ func TestRunGen_SplitAcrossCards(t *testing.T) { 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, 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 { @@ -392,7 +392,7 @@ func TestRunGen_TopologyViolationFails(t *testing.T) { // 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") + "# 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") { @@ -413,7 +413,7 @@ func TestRunGen_TwoFencesInOneFile(t *testing.T) { 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[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 { diff --git a/internal/skilldoc/fence.go b/internal/skilldoc/fence.go index eb5b8b1..6711080 100644 --- a/internal/skilldoc/fence.go +++ b/internal/skilldoc/fence.go @@ -68,6 +68,31 @@ func FenceLocs(body string) []FenceLoc { return locs } +// FindFence locates the full fenced block for id in body: start is the byte +// offset of the start marker, end is the offset just past the end marker. +// ok is false when either marker is missing. +func FindFence(body, id string) (start, end int, ok bool) { + si := strings.Index(body, FenceStart(id)) + if si < 0 { + return 0, 0, false + } + endMarker := FenceEnd(id) + ei := strings.Index(body[si:], endMarker) + if ei < 0 { + return 0, 0, false + } + return si, si + ei + len(endMarker), true +} + +// matchesPrefix reports whether verb falls under the claim prefix p: the verb +// IS p, or continues past it at a hyphen boundary — so "rule" claims +// "rule-create" but never "rule2-list". An unbounded prefix match would let a +// near-miss verb join the wrong card with a clean single-owner partition that +// no topology check could flag. +func matchesPrefix(verb, p string) bool { + return verb == p || strings.HasPrefix(verb, p+"-") +} + // RenderGroupFences renders the fenced block for every fence of one command // group. ids must be the complete set of fence ids that exist for the group // across all cards — the catch-all fence renders whatever its sibling subset @@ -99,12 +124,9 @@ func RenderGroupFences(d Dump, group string, ids []string) (map[string]string, [ byID := make(map[string][]Command, len(specs)) catchAll := "" - hasCatchAll := false for _, s := range specs { - byID[s.ID()] = nil if len(s.Prefixes) == 0 { catchAll = s.ID() - hasCatchAll = true } } @@ -119,7 +141,7 @@ func RenderGroupFences(d Dump, group string, ids []string) (map[string]string, [ for _, s := range specs { matched := false for _, p := range s.Prefixes { - if strings.HasPrefix(verb, p) { + if matchesPrefix(verb, p) { prefixHit[s.ID()+"\x00"+p] = true matched = true } @@ -133,7 +155,7 @@ func RenderGroupFences(d Dump, group string, ids []string) (map[string]string, [ violations = append(violations, fmt.Sprintf("verb %q claimed by %s", verb, strings.Join(owners, " and "))) case len(owners) == 1: byID[owners[0]] = append(byID[owners[0]], c) - case hasCatchAll: + case catchAll != "": byID[catchAll] = append(byID[catchAll], c) default: unclaimed = append(unclaimed, verb) diff --git a/internal/skilldoc/fence_test.go b/internal/skilldoc/fence_test.go index 3807244..b52de02 100644 --- a/internal/skilldoc/fence_test.go +++ b/internal/skilldoc/fence_test.go @@ -45,10 +45,10 @@ func TestParseFenceID_Shapes(t *testing.T) { func TestFenceLocs_FindsStartsOnly(t *testing.T) { body := "intro\n" + - FenceStart("svc[rule-]") + "\ncontent\n" + FenceEnd("svc[rule-]") + "\n\nmore prose\n" + + FenceStart("svc[rule]") + "\ncontent\n" + FenceEnd("svc[rule]") + "\n\nmore prose\n" + FenceStart("svc") + "\ncontent\n" + FenceEnd("svc") + "\n" locs := FenceLocs(body) - if len(locs) != 2 || locs[0].ID != "svc[rule-]" || locs[1].ID != "svc" { + if len(locs) != 2 || locs[0].ID != "svc[rule]" || locs[1].ID != "svc" { t.Fatalf("FenceLocs = %+v, want the two start markers in order", locs) } if locs[0].Offset >= locs[1].Offset { @@ -67,11 +67,11 @@ func partitionDump() Dump { func TestRenderGroupFences_SubsetPlusCatchAll(t *testing.T) { d := partitionDump() - out, violations := RenderGroupFences(d, "svc", []string{"svc[rule-]", "svc"}) + out, violations := RenderGroupFences(d, "svc", []string{"svc[rule]", "svc"}) if len(violations) != 0 { t.Fatalf("unexpected violations: %v", violations) } - subset, catchAll := out["svc[rule-]"], out["svc"] + subset, catchAll := out["svc[rule]"], out["svc"] if !strings.Contains(subset, "### rule-create") || !strings.Contains(subset, "### rule-delete") { t.Errorf("subset fence should carry the rule verbs:\n%s", subset) } @@ -84,7 +84,7 @@ func TestRenderGroupFences_SubsetPlusCatchAll(t *testing.T) { if strings.Contains(catchAll, "### rule-create") { t.Errorf("catch-all fence must not repeat claimed verbs:\n%s", catchAll) } - if !strings.HasPrefix(subset, FenceStart("svc[rule-]")) || !strings.HasSuffix(subset, FenceEnd("svc[rule-]")) { + if !strings.HasPrefix(subset, FenceStart("svc[rule]")) || !strings.HasSuffix(subset, FenceEnd("svc[rule]")) { t.Errorf("subset fence markers must carry the full fence id:\n%s", subset) } } @@ -96,9 +96,9 @@ func TestRenderGroupFences_Violations(t *testing.T) { ids []string want string // substring of some violation }{ - {name: "double claim", ids: []string{"svc[rule-]", "svc[rule-create]", "svc"}, want: `verb "rule-create" claimed by`}, + {name: "double claim", ids: []string{"svc[rule]", "svc[rule-create]", "svc"}, want: `verb "rule-create" claimed by`}, {name: "dead prefix", ids: []string{"svc[nope]", "svc"}, want: `prefix "nope" claims no verb`}, - {name: "no catch-all", ids: []string{"svc[rule-]"}, want: "no catch-all fence for unclaimed verbs: create, list"}, + {name: "no catch-all", ids: []string{"svc[rule]"}, want: "no catch-all fence for unclaimed verbs: create, list"}, {name: "duplicate id", ids: []string{"svc", "svc"}, want: `fence "svc" appears more than once`}, {name: "foreign group", ids: []string{"other", "svc"}, want: `fence "other" does not belong to group "svc"`}, } @@ -118,13 +118,13 @@ func TestRenderGroupFences_Violations(t *testing.T) { func TestCheckFences_SplitAcrossDocs(t *testing.T) { d := partitionDump() - fresh, violations := RenderGroupFences(d, "svc", []string{"svc[rule-]", "svc"}) + fresh, violations := RenderGroupFences(d, "svc", []string{"svc[rule]", "svc"}) if len(violations) != 0 { t.Fatalf("unexpected violations: %v", violations) } docs := []Doc{ - {Path: "rules", Body: "# Rules\n\n" + fresh["svc[rule-]"] + "\n"}, + {Path: "rules", Body: "# Rules\n\n" + fresh["svc[rule]"] + "\n"}, {Path: "svc", Body: "# Svc\n\n" + fresh["svc"] + "\n"}, } if issues := CheckFences(d, docs); len(issues) != 0 { @@ -132,7 +132,7 @@ func TestCheckFences_SplitAcrossDocs(t *testing.T) { } stale := []Doc{ - {Path: "rules", Body: "# Rules\n\n" + FenceStart("svc[rule-]") + "\n\nWRONG\n\n" + FenceEnd("svc[rule-]") + "\n"}, + {Path: "rules", Body: "# Rules\n\n" + FenceStart("svc[rule]") + "\n\nWRONG\n\n" + FenceEnd("svc[rule]") + "\n"}, {Path: "svc", Body: "# Svc\n\n" + fresh["svc"] + "\n"}, } issues := CheckFences(d, stale) @@ -145,8 +145,8 @@ func TestCheckFences_TopologyIssues(t *testing.T) { d := partitionDump() // A subset fence with no catch-all anywhere: the group's remaining verbs // have no home. - fresh, _ := RenderGroupFences(d, "svc", []string{"svc[rule-]", "svc"}) - docs := []Doc{{Path: "rules", Body: "# Rules\n\n" + fresh["svc[rule-]"] + "\n"}} + fresh, _ := RenderGroupFences(d, "svc", []string{"svc[rule]", "svc"}) + docs := []Doc{{Path: "rules", Body: "# Rules\n\n" + fresh["svc[rule]"] + "\n"}} issues := CheckFences(d, docs) if len(issues) != 1 || issues[0].Kind != "fence-topology" { t.Fatalf("want 1 fence-topology issue, got %+v", issues) @@ -162,3 +162,28 @@ func TestCheckFences_TopologyIssues(t *testing.T) { t.Errorf("unknown group marker should be flagged: %+v", issues) } } + +// TestRenderGroupFences_PrefixIsSegmentBounded pins the hyphen-boundary claim +// semantics: "rule" claims "rule-create" but must NOT claim "rule2-list" — an +// unbounded prefix match would misroute it with a clean single-owner +// partition that no topology check could flag. +func TestRenderGroupFences_PrefixIsSegmentBounded(t *testing.T) { + mk := func(verb string) Command { + return Command{Path: "svc " + verb, Group: "svc", Short: "S " + verb, Use: verb} + } + d := Dump{Commands: []Command{mk("rule-create"), mk("rule2-list"), mk("rule")}} + out, violations := RenderGroupFences(d, "svc", []string{"svc[rule]", "svc"}) + if len(violations) != 0 { + t.Fatalf("unexpected violations: %v", violations) + } + subset, catchAll := out["svc[rule]"], out["svc"] + if !strings.Contains(subset, "### rule-create") || !strings.Contains(subset, "### rule\n") { + t.Errorf("subset must claim the exact verb and its hyphen extensions:\n%s", subset) + } + if strings.Contains(subset, "### rule2-list") { + t.Errorf("subset must not claim the near-miss verb:\n%s", subset) + } + if !strings.Contains(catchAll, "### rule2-list") { + t.Errorf("near-miss verb must land in the catch-all:\n%s", catchAll) + } +} diff --git a/internal/skilldoc/validate.go b/internal/skilldoc/validate.go index dd7d873..740d6cc 100644 --- a/internal/skilldoc/validate.go +++ b/internal/skilldoc/validate.go @@ -92,14 +92,12 @@ func CheckFences(d Dump, docs []Doc) []Issue { } } - groupOrder := make([]string, 0, len(byGroup)) - for g := range byGroup { - groupOrder = append(groupOrder, g) - } - sort.Strings(groupOrder) - - for _, group := range groupOrder { - locs := byGroup[group] + // 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 @@ -114,9 +112,8 @@ func CheckFences(d Dump, docs []Doc) []Issue { }) } for _, l := range locs { - end := FenceEnd(l.id) - ei := strings.Index(l.body[l.off:], end) - if ei < 0 { + start, end, ok := FindFence(l.body, l.id) + if !ok { issues = append(issues, Issue{ Doc: l.doc, Line: lineOf(l.body, l.off), @@ -125,8 +122,7 @@ func CheckFences(d Dump, docs []Doc) []Issue { }) continue } - block := l.body[l.off : l.off+ei+len(end)] - if fresh, ok := rendered[l.id]; ok && block != fresh { + if fresh, rok := rendered[l.id]; rok && l.body[start:end] != fresh { issues = append(issues, Issue{ Doc: l.doc, Line: lineOf(l.body, l.off), diff --git a/skills/flashduty/reference/postmortem.md b/skills/flashduty/reference/postmortem.md index 2d4d1e6..8081fbc 100644 --- a/skills/flashduty/reference/postmortem.md +++ b/skills/flashduty/reference/postmortem.md @@ -52,7 +52,7 @@ fduty incident post-mortem-follow-ups-reset --follow-ups "Add a fduty incident post-mortem-status-reset --status published ``` - + ### post-mortem-basics-reset Update post-mortem basics @@ -142,7 +142,7 @@ Update post-mortem title - `` (positional, required) string — Post-mortem ID. - `--title` string (required) — New report title. - + ## Gotchas From c778b0a5a9e3086ef5738e59d7a22159945f34e2 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:25:27 -0700 Subject: [PATCH 08/13] fix(cli): state the 100-row server cap in alert/alert-event --limit flag help The alert list and alert-event list endpoints reject limit > 100 with InvalidParameter. incident list already documents the cap in its --limit flag help; mirror the same wording so the Flags section alone is enough to get the first call right (the long description mentions the cap, but --help readers who grep Flags never see it). --- internal/cli/alert.go | 2 +- internal/cli/alert_event.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/alert.go b/internal/cli/alert.go index d460193..1f216a5 100644 --- a/internal/cli/alert.go +++ b/internal/cli/alert.go @@ -111,7 +111,7 @@ func newAlertListCmd() *cobra.Command { cmd.Flags().BoolVar(&muted, "muted", false, "Show ever-muted only") cmd.Flags().StringVar(&since, "since", "24h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") - cmd.Flags().IntVar(&limit, "limit", 20, "Max results") + cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. alert_id,title,alert_severity,created_at); ignored in table mode. Use to avoid dumping the full nested record.") diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index dbb7024..4e8c0fb 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -104,7 +104,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&integrationType, "integration-type", "", "Comma-separated integration types") cmd.Flags().StringVar(&since, "since", "1h", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") - cmd.Flags().IntVar(&limit, "limit", 20, "Max results") + cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. event_id,alert_id,event_severity,event_status,event_time,title); ignored in table mode. Defaults to these compact event fields. Long strings are truncated as needed to keep structured output below 16 KiB.") From 0389c66f8705409e2ca79be715d5a286846787b7 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:35:35 -0700 Subject: [PATCH 09/13] feat(cli): announce the default compact projection on stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In json/toon mode, incident list, incident similar and alert-event list silently reduce rows to a compact default field set. A reader piping stdout to jq sees an unselected key (labels, description, ...) as null on every row and can reasonably conclude the server never returns it, when it is one --fields away — the incident list card even documents the projection, but nothing at invocation time points back to it. Print a one-line note on stderr whenever the default projection applies, naming the projected fields and the --fields escape hatch. stdout stays byte-identical, so existing jq/toon pipelines are unaffected. alert list is untouched: it has no default projection (bare --json dumps the full record). Also extend the incident card's projection note with the two facts the projection hides: any list-response field (labels included) is selectable via --fields, and wide fields over many rows will hit the 16 KiB structured-output bound — page with a smaller --limit or use insight aggregates for distributions. Tests: new execCommandSplit captures stdout/stderr separately; the projection tests now assert stdout stays pure JSON while the note lands on stderr. --- internal/cli/alert_event.go | 2 ++ internal/cli/command_test.go | 22 ++++++++++++++++++++++ internal/cli/fieldproject.go | 11 +++++++++++ internal/cli/fieldproject_test.go | 21 +++++++++++++++------ internal/cli/incident.go | 4 ++++ skills/flashduty/reference/incident.md | 2 +- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index dbb7024..f73dc7c 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -82,6 +82,8 @@ func newAlertEventListCmd() *cobra.Command { fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} if fields != "" { fieldNames = parseStringSlice(fields) + } else { + noteDefaultProjection(cmd.ErrOrStderr(), fieldNames) } proj, err := projectFields(result.Items, fieldNames) if err != nil { diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 65e0e70..23cf73b 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -84,6 +84,28 @@ func execCommand(args ...string) (string, error) { return buf.String(), err } +// execCommandSplit is execCommand with stdout and stderr captured separately, +// for tests that assert machine-readable stdout stays pure while advisory +// notices (e.g. the default-projection note) land on stderr. +func execCommandSplit(args ...string) (stdout, stderr string, err error) { + resetCommandFlags(rootCmd) + + outBuf := new(bytes.Buffer) + errBuf := new(bytes.Buffer) + rootCmd.SetOut(outBuf) + rootCmd.SetErr(errBuf) + rootCmd.SetArgs(args) + + err = rootCmd.Execute() + + rootCmd.SetArgs(nil) + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + resetCommandFlags(rootCmd) + + return outBuf.String(), errBuf.String(), err +} + func resetCommandFlags(cmd *cobra.Command) { if cmd == nil { return diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index 7bcb12c..02a7a63 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "io" "reflect" "sort" "strings" @@ -69,6 +70,16 @@ func projectFields(items any, fields []string) ([]map[string]any, error) { return out, nil } +// noteDefaultProjection announces on stderr that structured rows were reduced +// to the command's compact default projection. Without it, a reader piping +// stdout to jq sees an unselected key (labels, description, …) as null on +// every row and can conclude the server never returns it, when it is one +// --fields away. stderr keeps stdout byte-identical for jq/toon pipelines. +func noteDefaultProjection(w io.Writer, fields []string) { + _, _ = fmt.Fprintf(w, "note: rows projected to default compact fields (%s); other response fields are available via --fields\n", + strings.Join(fields, ",")) +} + // boundProjectedOutput keeps the new agent-oriented projections below their // command budget without changing the selected keys. Short values remain byte // identical; when retained strings alone would overflow the actual JSON/TOON diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 01d847b..bf17d8a 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -118,12 +118,15 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { stub := newGFStub(t) stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} - out, err := execCommand("incident", "list", "--output-format", "json") + out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"}) + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } }) t.Run("toon default", func(t *testing.T) { @@ -394,13 +397,16 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) { } stub.data = map[string]any{"items": items, "total": len(items)} - out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") + out, stderrText, err := execCommandSplit("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } if len(out) >= 16*1024 { t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out)) } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } var rows []map[string]json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { @@ -482,13 +488,16 @@ func TestAlertEventListStructuredProjection(t *testing.T) { } stub.data = map[string]any{"items": items, "total": len(items)} - out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json") + out, stderrText, err := execCommandSplit("alert-event", "list", "--limit", "30", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } if len(out) >= 16*1024 { t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out)) } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } var rows []map[string]json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { t.Fatalf("parse compact alert-event json: %v\n%s", err, out) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 23dcdf5..5deb072 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -119,6 +119,8 @@ func newIncidentListCmd() *cobra.Command { if len(selectedFields) == 0 { return fmt.Errorf("--fields must name at least one field") } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) } proj, err := projectFields(result.Items, selectedFields) if err != nil { @@ -604,6 +606,8 @@ func newIncidentSimilarCmd() *cobra.Command { fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} if fields != "" { fieldNames = parseStringSlice(fields) + } else { + noteDefaultProjection(cmd.ErrOrStderr(), fieldNames) } proj, err := projectFields(result.Items, fieldNames) if err != nil { diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 9c08daf..38af6d0 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. ## Hot flow — full fault analysis (read-only summary) From 9b1bebfe92afec067bc87f6f1062e8136e2b033f Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:38:42 -0700 Subject: [PATCH 10/13] docs(skills): carve alerts out of the selectable-fields note The alerts field appears in list/detail response shapes but no read endpoint ever fills it; an incident's alerts come only from the dedicated incident alerts command. Saying every response field is selectable via --fields would invite --fields alerts and an always-empty column. --- skills/flashduty/reference/incident.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 38af6d0..1cdbb0f 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. ## Hot flow — full fault analysis (read-only summary) From 99dc3dc4dac9bbcc81dbac10f2f8167bf1e62e81 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:40:51 -0700 Subject: [PATCH 11/13] fix(cli): state the 100-row cap on insight incidents --limit too Same shared page-query validator as alert/alert-event/incident list; same wording. --- internal/cli/insight.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/insight.go b/internal/cli/insight.go index f3b4a5f..a2e02bd 100644 --- a/internal/cli/insight.go +++ b/internal/cli/insight.go @@ -138,7 +138,7 @@ func newInsightIncidentsCmd() *cobra.Command { cmd.Flags().StringVar(&since, "since", "7d", "Start time") cmd.Flags().StringVar(&until, "until", "now", "End time") - cmd.Flags().IntVar(&limit, "limit", 20, "Max results") + cmd.Flags().IntVar(&limit, "limit", 20, "Max results (max 100)") cmd.Flags().IntVar(&page, "page", 1, "Page number") return cmd From 445bd1205c5345b95e34f995b20cc9c700b7d5f4 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:41:53 -0700 Subject: [PATCH 12/13] test(cli): assert toon default projection against stdout only; alert card parity The stderr note embeds the default field names, so the merged-capture toon subtest's positive assertions were satisfied by the note alone. Switch it to the split capture and check the note on stderr, mirroring the json subtest. Also bring alert.md's alert-event list section to parity with the incident card: name the default compact projection and the --fields escape hatch. --- internal/cli/fieldproject_test.go | 9 +++++++-- skills/flashduty/reference/alert.md | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index bf17d8a..8bbf251 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -134,16 +134,21 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { stub := newGFStub(t) stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} - out, err := execCommand("incident", "list", "--output-format", "toon") + out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "toon") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } + // Positive keys must come from stdout alone: the stderr note embeds the + // same field names, so a merged capture would satisfy this vacuously. for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} { if !strings.Contains(out, key) { t.Errorf("default toon output missing compact key %q, got:\n%s", key, out) } } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } for _, key := range []string{"responders", "labels", "description"} { if strings.Contains(out, key) { t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out) diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 37015b2..35670ca 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -38,7 +38,7 @@ fduty alert feed --output-format toon fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` -Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. +Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. ## Hot flow — merge noisy alerts into an existing incident From 1deaf6d159e15b5b178dbcb3ec82fc8bb52f137f Mon Sep 17 00:00:00 2001 From: ysyneu Date: Mon, 10 Aug 2026 21:21:21 -0700 Subject: [PATCH 13/13] fix(cli): never truncate incident detail --fields values boundProjectedOutput applied one budget strategy to both shapes it receives: list projections (many small rows) and the single-object projection behind `incident detail --fields`. On overflow it shortened every string in place, then halved the per-field limit and retried. On a single object that silently corrupts data. The limit is a global budget divided by the number of string slots, so short fields are punished for long ones sharing the object, and every round re-reads the already-shortened value, compounding it. Once the limit drops below 4, truncateUTF8Bytes stops appending the "..." marker, so a 24-character id can arrive as "6" and "Warning" as "W" -- indistinguishable from a genuinely short value. The loop's fieldLimit == 0 error exit is unreachable: by then every string is empty, so the payload always fits and the command exits 0 with hollowed-out fields. Split the two shapes. Lists keep the existing shorten-and-mark behavior byte for byte. A detail projection is now never modified: if it does not fit, the command fails and names the largest fields with their sizes, so the caller can drop some --fields, or omit --fields for the full, unbounded detail -- which returns more data, not less, so the error is always actionable. Dropping keys instead was rejected: an absent key is indistinguishable from a null one to a jq consumer, which reintroduces the same class of bug this removes. Ties in the largest-field ranking break on name, so the same oversized request never names different fields between runs. --- internal/cli/fieldproject.go | 72 +++++++++++-- internal/cli/fieldproject_test.go | 142 ++++++++++++++++++++++++- internal/cli/incident.go | 2 +- skills/flashduty/reference/incident.md | 2 +- 4 files changed, 201 insertions(+), 17 deletions(-) diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index 02a7a63..b754875 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -81,23 +81,75 @@ func noteDefaultProjection(w io.Writer, fields []string) { } // boundProjectedOutput keeps the new agent-oriented projections below their -// command budget without changing the selected keys. Short values remain byte -// identical; when retained strings alone would overflow the actual JSON/TOON -// encoding, they are shortened fairly and marked with "...". If keys and -// non-string values alone exceed the budget, the command fails with a small -// error instead of emitting an oversized payload. +// command budget without changing the selected keys. List rows (many small +// records) are shortened fairly when they overflow the budget, with +// shortened values marked with "...". A single-object detail projection is +// never modified: a truncated id or status string is indistinguishable from +// a genuinely short value, so silently shortening it would hand the caller +// wrong data instead of a compact one. If a detail projection doesn't fit, +// the command fails with an error instead. func boundProjectedOutput(data any, maxBytes int) error { - var rows []map[string]any switch value := data.(type) { case map[string]any: - rows = []map[string]any{value} + return boundProjectedDetail(value, maxBytes) case []map[string]any: - rows = value + return boundProjectedList(value, maxBytes) default: return fmt.Errorf("internal error: unsupported projected output %T", data) } +} + +// boundProjectedDetail rejects an oversized single-object projection instead +// of truncating it, naming the largest fields so the caller can fix the +// request in one pass: drop some of them from --fields, or drop --fields +// entirely for the full, unbounded detail. +func boundProjectedDetail(row map[string]any, maxBytes int) error { + encoded, err := marshalStructured(row) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + + type fieldSize struct { + name string + size int + } + sizes := make([]fieldSize, 0, len(row)) + for key, value := range row { + fieldEncoded, err := marshalStructured(map[string]any{key: value}) + if err != nil { + return err + } + sizes = append(sizes, fieldSize{key, len(fieldEncoded)}) + } + // Ties break on name so the same oversized request always names the same + // fields, despite Go's randomized map iteration order. + sort.Slice(sizes, func(i, j int) bool { + if sizes[i].size != sizes[j].size { + return sizes[i].size > sizes[j].size + } + return sizes[i].name < sizes[j].name + }) + if len(sizes) > 3 { + sizes = sizes[:3] + } + largest := make([]string, len(sizes)) + for i, f := range sizes { + largest[i] = fmt.Sprintf("%s (%d bytes)", f.name, f.size) + } + return fmt.Errorf("projected detail is %d bytes, exceeds the %d-byte limit; largest fields: %s; request fewer --fields, or omit --fields for the full, unbounded detail", + len(encoded), maxBytes, strings.Join(largest, ", ")) +} - encoded, err := marshalStructured(data) +// boundProjectedList shortens a list projection's string values fairly +// (across all rows) when the compact rows themselves overflow the budget, +// marking shortened values with "...". If keys and non-string values alone +// exceed the budget, the command fails with a small error instead of +// emitting an oversized payload. +func boundProjectedList(rows []map[string]any, maxBytes int) error { + encoded, err := marshalStructured(rows) if err != nil { return err } @@ -127,7 +179,7 @@ func boundProjectedOutput(data any, maxBytes int) error { } } - encoded, err = marshalStructured(data) + encoded, err = marshalStructured(rows) if err != nil { return err } diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 8bbf251..3437219 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "encoding/json" + "reflect" "strings" "testing" "unicode/utf8" @@ -72,6 +73,100 @@ func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) { } } +// TestBoundProjectedOutputDetailWithinBudgetLeavesValuesUnchanged covers the +// single-object (map[string]any) shape used by `incident detail --fields`: +// when the projection already fits, every value must come back byte-for-byte +// identical to what went in. +func TestBoundProjectedOutputDetailWithinBudgetLeavesValuesUnchanged(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + row := map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "progress": "Triggered", + } + want := map[string]any{ + "incident_id": "inc-1", + "title": "Disk full on db-01", + "progress": "Triggered", + } + + if err := boundProjectedOutput(row, compactDetailOutputLimit); err != nil { + t.Fatalf("bound projected output: %v", err) + } + if !reflect.DeepEqual(row, want) { + t.Fatalf("in-budget detail projection was modified: got %v, want %v", row, want) + } +} + +// TestBoundProjectedOutputDetailOversizedErrorsWithoutMutating is the +// regression guard for the truncation bug: a single-object projection that +// doesn't fit the budget must fail loudly instead of silently shipping +// truncated (and indistinguishable-from-real) values. The input map must +// come back completely untouched. +func TestBoundProjectedOutputDetailOversizedErrorsWithoutMutating(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + row := map[string]any{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 5000), + "root_cause": strings.Repeat("disk exhaustion details ", 3000), + } + want := map[string]any{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 5000), + "root_cause": strings.Repeat("disk exhaustion details ", 3000), + } + + err := boundProjectedOutput(row, 512) + if err == nil { + t.Fatal("expected an error for an oversized detail projection, got nil") + } + if !strings.Contains(err.Error(), "512") { + t.Errorf("error should name the byte budget (512), got: %v", err) + } + if !strings.Contains(err.Error(), "title") && !strings.Contains(err.Error(), "root_cause") { + t.Errorf("error should name the largest field, got: %v", err) + } + if !reflect.DeepEqual(row, want) { + t.Fatalf("oversized detail projection mutated the input map: got %v, want %v", row, want) + } +} + +// TestBoundProjectedOutputDetailErrorIsDeterministic pins the tie-break in the +// largest-field ranking: with several fields at exactly the same encoded size, +// Go's randomized map iteration order must not leak into the error text, or the +// same failing command would name different fields on each run. +func TestBoundProjectedOutputDetailErrorIsDeterministic(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + + first := "" + for i := range 20 { + row := map[string]any{ + "alpha": strings.Repeat("a", 400), + "bravo": strings.Repeat("b", 400), + "charlie": strings.Repeat("c", 400), + "delta": strings.Repeat("d", 400), + "echo": strings.Repeat("e", 400), + } + err := boundProjectedOutput(row, 512) + if err == nil { + t.Fatal("expected an error for an oversized detail projection, got nil") + } + if i == 0 { + first = err.Error() + continue + } + if err.Error() != first { + t.Fatalf("error text varies between runs on equal-sized fields:\n run 0: %s\n run %d: %s", first, i, err.Error()) + } + } + if !strings.Contains(first, "alpha") { + t.Errorf("equal-sized fields should be ranked by name, expected alpha first, got: %s", first) + } +} + func alertRow() map[string]any { return map[string]any{ "alert_id": "al-1", @@ -441,11 +536,11 @@ func TestIncidentDetailFieldsProjection(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) row := incidentRow() - row["description"] = strings.Repeat("large description ", 500) - row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} - row["ai_summary"] = strings.Repeat("long AI summary ", 4000) - row["root_cause"] = strings.Repeat("long root cause ", 4000) - row["resolution"] = strings.Repeat("long resolution ", 4000) + row["description"] = "root volume at 98%, mount point /var/lib/db" + row["images"] = []map[string]any{{"src": "https://example.test/image/1.png"}} + row["ai_summary"] = "Disk usage crossed 98% on db-01 at 03:14 UTC after log rotation stopped." + row["root_cause"] = "Log rotation was disabled by the previous deploy's config change." + row["resolution"] = "Re-enabled log rotation and cleared the stale archive files." stub.data = row fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"} @@ -472,6 +567,43 @@ func TestIncidentDetailFieldsProjection(t *testing.T) { t.Errorf("projected detail includes description: %v", detail) } + // The projected values must come back byte-for-byte identical to the + // source row — this command must never truncate a detail value. + var gotSummary string + if err := json.Unmarshal(detail["ai_summary"], &gotSummary); err != nil { + t.Fatalf("unmarshal ai_summary: %v", err) + } + if gotSummary != row["ai_summary"] { + t.Fatalf("ai_summary was altered: got %q, want %q", gotSummary, row["ai_summary"]) + } +} + +// TestIncidentDetailFieldsProjectionOversizedErrors: when the requested +// --fields don't fit the 8 KiB detail budget, the command must fail with an +// actionable error instead of silently shipping truncated values. +func TestIncidentDetailFieldsProjectionOversizedErrors(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := incidentRow() + row["ai_summary"] = strings.Repeat("long AI summary ", 4000) + row["root_cause"] = strings.Repeat("long root cause ", 4000) + row["resolution"] = strings.Repeat("long resolution ", 4000) + stub.data = row + + fields := []string{"incident_id", "title", "ai_summary", "root_cause", "resolution"} + _, err := execCommand("incident", "detail", "inc-1", "--fields", strings.Join(fields, ","), "--output-format", "json") + if err == nil { + t.Fatal("expected an error for an oversized detail projection, got nil") + } + if !strings.Contains(err.Error(), "8192") { + t.Errorf("error should name the byte budget (8192), got: %v", err) + } + if !strings.Contains(err.Error(), "ai_summary") && !strings.Contains(err.Error(), "root_cause") && !strings.Contains(err.Error(), "resolution") { + t.Errorf("error should name one of the largest fields, got: %v", err) + } + if !strings.Contains(err.Error(), "--fields") { + t.Errorf("error should point at --fields as the remedy, got: %v", err) + } } func TestAlertEventListStructuredProjection(t *testing.T) { diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 5deb072..9d9f2f5 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -1554,7 +1554,7 @@ func newIncidentDetailCmd() *cobra.Command { }) }, } - cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. Projected strings are truncated as needed to keep output below 8 KiB; omit --fields for full detail.") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,root_cause); ignored in table mode. The projection must fit within 8 KiB or the command fails and names the largest fields; omit --fields for the full, unbounded detail.") return cmd } diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 1cdbb0f..5239d73 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -71,7 +71,7 @@ fduty incident comment "$ID" --comment-file "$COMMENT_FILE" fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` -Projected `similar` lists stay below 16 KiB, and projected `detail --fields` output stays below 8 KiB. A trailing `...` means a long retained string was shortened; omit `--fields` only when the full unbounded detail is explicitly required. +Projected `similar` lists stay below 16 KiB; a trailing `...` in a list row means a long retained string was shortened. `detail --fields` is different: it never shortens values — the projection must fit within 8 KiB as requested or the command fails and names the largest fields, so drop some fields (or drop `--fields` for the full unbounded detail) and retry. `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly.