From bb9ce015245354c8c70807795025296c25bf7c0e Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 26 Jun 2026 21:40:56 +0800 Subject: [PATCH 01/10] fix(skill): resolve person_id via `person infos`, not member-list pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule/oncall output returns `person_id`s — a different id namespace from `member_id`. The schedule.md card and the `oncall who` help told agents to resolve names by joining `fduty member list` on `member_id`: wrong namespace, and it forces a full-roster scan that silently drops people on later pages. In prod this produced a confidently-incomplete on-call answer (a responder on page 21 of 22 was missed). The batch resolver already exists: `fduty person infos ...` (POST /person/infos) returns person_id + person_name in one call. Point the cards and the oncall help at it, and warn that person_id != member_id. - schedule.md: replace the member_id-join flow with `person infos` - member.md: cross-reference `person infos`; add a person_id gotcha - oncall.go: correct the `oncall who` Long help (table already enriches names; use `person infos` for raw ids elsewhere) Surfaced by /audit-ai-sre-sessions (run audit-2026-06-26): sess_8fKpUejeyKfHt2hx5XNSsc, sess_UtK6eDMFY4TVZCSAj64gKF. --- internal/cli/oncall.go | 2 +- skills/flashduty/reference/member.md | 3 ++- skills/flashduty/reference/schedule.md | 11 ++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/internal/cli/oncall.go b/internal/cli/oncall.go index 7b3342f..66bab64 100644 --- a/internal/cli/oncall.go +++ b/internal/cli/oncall.go @@ -39,7 +39,7 @@ func newOncallWhoCmd() *cobra.Command { cmd := &cobra.Command{ Use: "who", Short: "Show who is currently on call", - Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. Returns person_ids (numeric) only; resolve names/phones by dumping 'fduty member list' and joining client-side (member list has no by-id lookup).", "Schedules", "List"), + Long: curatedLong("Show who is currently on call across schedules within a time window, optionally filtered by team or schedule name. The table output already resolves person_ids to display names; when you have raw person_ids elsewhere, batch-resolve them with 'fduty person infos ...' (NOT by paginating 'fduty member list' — person_id and member_id are different id namespaces).", "Schedules", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) diff --git a/skills/flashduty/reference/member.md b/skills/flashduty/reference/member.md index bf1b3c5..a13347a 100644 --- a/skills/flashduty/reference/member.md +++ b/skills/flashduty/reference/member.md @@ -4,7 +4,7 @@ Prereq: `SKILL.md` read. `invite` sends invitation emails immediately (up to 20 ## Route here when -"成员 / 邀请 / 用户 / 角色 / member / invite / user profile / role assignment / org roster" → **member**. Sibling domains: `team` (team membership lists, not org-level members); `role` (role definitions — get role IDs here first). Key IDs: **`member_id` (int)** from `member list`; **`role_id` (int)** from `fduty role list`. +"成员 / 邀请 / 用户 / 角色 / member / invite / user profile / role assignment / org roster" → **member**. Sibling domains: `team` (team membership lists, not org-level members); `role` (role definitions — get role IDs here first); `person` (resolve a `person_id` → name with `fduty person infos …`, e.g. ids returned by `schedule`/`oncall`/`incident`/`alert` output). Key IDs: **`member_id` (int)** from `member list`; **`role_id` (int)** from `fduty role list`. ## Intent → verb @@ -119,6 +119,7 @@ Update member roles ## Gotchas +- **Resolving a `person_id` → name: use `fduty person infos …`, NOT `member list`.** `schedule`/`oncall`/`incident`/`alert` output returns `person_id`s, a **different namespace from `member_id`**. `fduty person infos` (the sibling `person` group) batch-resolves any number of `person_id`s to `person_name` in one call (rows under `.items[]`). Matching `member list` rows on `member_id == ` is wrong, and paginating the full roster to find them silently misses people on later pages. - **`invite` members array is body-only — use `--data`.** Individual members cannot be passed as flat flags; the `members` array (with nested `role_ids`, `email`, `phone`, etc.) lives only in the JSON body. Up to 20 members per call. - **`info-reset ` is POSITIONAL.** Pass the member ID as the first bare argument, not `--member-id`: `fduty member info-reset --member-name "New Name"`. The `--member-id` flag exists but the positional form is required per the `use` field. - **`role-grant / role-revoke / role-update` — role IDs are POSITIONAL.** All three verbs take role IDs as positional args: `fduty member role-grant [...] --member-id `. The `--role-ids` flag also exists but the positional form is authoritative. diff --git a/skills/flashduty/reference/schedule.md b/skills/flashduty/reference/schedule.md index 2a4faa5..c7bea34 100644 --- a/skills/flashduty/reference/schedule.md +++ b/skills/flashduty/reference/schedule.md @@ -37,15 +37,16 @@ fduty schedule info --start now --end +1h --output-format toon fduty oncall who --output-format toon ``` -Both return `person_ids` (integers), not names. Resolve names by joining `member list` client-side — its rows live under `.items[]` keyed by `member_id` (+ `member_name`): +Both return `person_ids` (integers), not names. Resolve every id in **one batch call** with `fduty person infos` (the sibling `person` group — takes positional ids or `--person-ids`): ```bash -members=$(fduty member list --json) -fduty schedule info --start now --end +1h --json | jq --argjson m "$members" ' - [.. | .person_ids? // empty | .[]] | unique | map(. as $id | ($m.items[]? | select(.member_id==$id) | .member_name))' -# If the join is fiddly, just report person_ids — do NOT loop refining jq. +# person_ids come straight from the schedule/oncall output above +fduty person infos [ …] --output-format toon +# → rows under .items[] with person_id + person_name; join on person_id client-side ``` +**`person_id` ≠ `member_id` — do NOT resolve schedule/oncall people via `member list`.** They are different id namespaces, so matching `member list` rows on `member_id == ` is wrong, and paginating the full roster (often 20+ pages) silently drops people who land on later pages — a real prod miss. Always feed the `person_id`s to `fduty person infos`. If a lookup genuinely fails, report the bare `person_id` rather than guessing. + ## Hot flow — inspect a schedule's upcoming shifts ```bash From d32d4f93208769f276945f45f75215db3f840f77 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 26 Jun 2026 21:43:18 +0800 Subject: [PATCH 02/10] fix(skill): enumerate configured rules via `rule-list-basic --folder-id 0`, never via fired alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rule-counter-status` 400s "too many rules" on large accounts, and a guessed `--folder-id N` 400s "Folder not found". With both paths blocked, a prod agent fell back to FIRED alerts (`insight top-alerts 90d`) as a proxy for CONFIGURED rules and produced a confidently-wrong coverage report — marking P0 checks as "missing" when it had only verified them as not-fired-in-90d. The enumerate-all path already exists: `rule-list-basic --folder-id 0` returns every configured rule with no folder id needed. Make it the documented path, add the two-error fallback, and add a hard CONFIGURED != FIRED warning. monit.md only (skill card; edits are outside the GENERATED fence). Note: `rule-counter-status` itself cannot be scoped/paginated from the CLI — the SDK `ReadCounterStatus(ctx)` and `POST /monit/rule/counter/status` take no params; making it not 400 on large accounts is backend work, out of scope here. Surfaced by /audit-ai-sre-sessions (run audit-2026-06-26): sess_DiRDzjygi4NuYyAcsgzB6o. --- skills/flashduty/reference/monit.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/skills/flashduty/reference/monit.md b/skills/flashduty/reference/monit.md index e6577bf..6008706 100644 --- a/skills/flashduty/reference/monit.md +++ b/skills/flashduty/reference/monit.md @@ -68,6 +68,20 @@ fduty monit tools-invoke --target-locator --output-format toon EOF ``` +## Hot flow — enumerate ALL configured rules (coverage / completeness) + +```bash +# rule-list-basic with folder 0 returns EVERY configured rule — no folder id needed. +fduty monit rule-list-basic --folder-id 0 --output-format json \ + | jq '[.[] | {id, name, ds_type, enabled, triggered, folder_id}]' + +# Is a specific rule configured? Filter the full list by name / datasource: +fduty monit rule-list-basic --folder-id 0 --output-format json \ + | jq '[.[] | select(.name | test("emqx"; "i"))]' +``` + +**CONFIGURED ≠ FIRED.** The authoritative list of what rules *exist* is `rule-list-basic --folder-id 0`. Never infer rule coverage from *fired* alerts (`insight top-alerts`, alert feeds): "not fired in 90d" does **not** mean "not configured", and reporting a rule as missing on that basis is confidently wrong. Fired-alert queries answer "what is noisy", not "what is monitored". + ### datasource-create @@ -338,6 +352,7 @@ Invoke target tools - **`tools-catalog` / `tools-invoke` `--target-locator` is required and not guessable.** If the user has not provided a host or IP, ask — do not invent one. Tool names in `invoke` must come from the `tools-catalog` response — never hallucinate them. - **`rule-delete-batch` and `datasource-delete` are irreversible.** Confirm IDs with `rule-list-basic` / `datasource-info` first. - **`rule-audit-detail --id` takes the audit record ID**, not the rule ID. Get audit record IDs from `rule-audits --id ` first; passing the rule ID returns HTTP 400. +- **To list every configured rule, use `rule-list-basic --folder-id 0`** — no folder id needed. `rule-counter-status` 400s "too many rules" on large accounts, and a guessed `--folder-id N` 400s "Folder not found"; neither is a dead end — fall through to `--folder-id 0`. Do **not** substitute fired-alert queries (`insight top-alerts`) to infer which rules exist (see the enumerate-all hot flow). ## Worked example — inspect a firing rule then batch-disable it From ddf6366ae7d79d69243cded7646773c21b9a7968 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 26 Jun 2026 22:02:44 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(skill):=20correct=20rule=20enumeratio?= =?UTF-8?q?n=20=E2=80=94=20folder-0=20400s;=20no=20account-wide=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit on this branch claimed `rule-list-basic --folder-id 0` lists all rules. That is FALSE — verified against the live API (400 "Folder not found") and confirmed in monit-webapi: ListRuleBasic -> SafeFolder -> GetByID(0) -> nil -> 400. `rule-list-basic` also returns only a folder's DIRECT rules, not its descendants, and there is no account-wide rule list; `rule-counter-status`/`rule-status` abort with "too many rules" past a server cap (default 100). Corrected guidance: enumerate by walking the folder tree (rule-counter-status -> rule-status -> rule-list-basic per node); past the cap, report the limit honestly ("cannot fully enumerate configured rules on this account") instead of fabricating a completeness %. Kept the CONFIGURED != FIRED guardrail. The generated `--folder-id` help ("0 to list all accessible rules") is a known SDK/OpenAPI bug, flagged for a backend round. Surfaced by /audit-ai-sre-sessions (run audit-2026-06-26): sess_DiRDzjygi4NuYyAcsgzB6o. --- skills/flashduty/reference/monit.md | 36 +++++++++++++++++------------ 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/skills/flashduty/reference/monit.md b/skills/flashduty/reference/monit.md index 6008706..8db4356 100644 --- a/skills/flashduty/reference/monit.md +++ b/skills/flashduty/reference/monit.md @@ -15,7 +15,8 @@ Prereq: `SKILL.md` read. **SKILL.md + this card = full competence on monitors | create / update a datasource | `datasource-create` / `datasource-update` | | delete a datasource | `datasource-delete` | | SLS project/logstore discovery | `datasource-sls-projects` / `datasource-sls-logstores` | -| list alert rules (all or by folder) | `rule-list-basic` | +| list rules directly in ONE folder (needs a real folder-id) | `rule-list-basic` | +| count rules per top-level folder (subtree totals) | `rule-counter-status` | | full rule config | `rule-info` | | create / update a rule | `rule-create` / `rule-update` | | delete one or many rules | `rule-delete` / `rule-delete-batch` | @@ -68,19 +69,22 @@ fduty monit tools-invoke --target-locator --output-format toon EOF ``` -## Hot flow — enumerate ALL configured rules (coverage / completeness) +## Hot flow — enumerate configured rules (and its hard limit) -```bash -# rule-list-basic with folder 0 returns EVERY configured rule — no folder id needed. -fduty monit rule-list-basic --folder-id 0 --output-format json \ - | jq '[.[] | {id, name, ds_type, enabled, triggered, folder_id}]' +`rule-list-basic --folder-id ` lists only the rules **directly in that folder**, NOT its sub-folders; `--folder-id 0` or omitting it **400s "Folder not found"**. There is no "all rules" call, so enumeration means walking the folder tree: -# Is a specific rule configured? Filter the full list by name / datasource: -fduty monit rule-list-basic --folder-id 0 --output-format json \ - | jq '[.[] | select(.name | test("emqx"; "i"))]' +```bash +# 1. top-level folders, each with its whole-subtree rule_total +fduty monit rule-counter-status --output-format toon +# 2. descend a folder to its DIRECT child folders (recurse until a folder has no children) +fduty monit rule-status --folder-id --output-format toon +# 3. list the rules sitting directly in each folder you reach +fduty monit rule-list-basic --folder-id --output-format toon ``` -**CONFIGURED ≠ FIRED.** The authoritative list of what rules *exist* is `rule-list-basic --folder-id 0`. Never infer rule coverage from *fired* alerts (`insight top-alerts`, alert feeds): "not fired in 90d" does **not** mean "not configured", and reporting a rule as missing on that basis is confidently wrong. Fired-alert queries answer "what is noisy", not "what is monitored". +**Hard limit — large accounts cannot be fully enumerated.** `rule-counter-status` / `rule-status` abort with 400 "too many rules" past a server cap (default 100 rules; "too many folders" past 500), and no account-wide rule list exists. When you hit that cap you **cannot** enumerate every configured rule from the CLI — say so plainly ("cannot fully enumerate configured rules on this account") instead of fabricating a completeness percentage. + +**CONFIGURED ≠ FIRED.** Never infer rule coverage from *fired* alerts (`insight top-alerts`, alert feeds): "not fired in 90d" does **not** mean "not configured", and reporting a rule as missing on that basis is confidently wrong. Fired-alert queries answer "what is noisy", not "what is monitored". @@ -352,18 +356,20 @@ Invoke target tools - **`tools-catalog` / `tools-invoke` `--target-locator` is required and not guessable.** If the user has not provided a host or IP, ask — do not invent one. Tool names in `invoke` must come from the `tools-catalog` response — never hallucinate them. - **`rule-delete-batch` and `datasource-delete` are irreversible.** Confirm IDs with `rule-list-basic` / `datasource-info` first. - **`rule-audit-detail --id` takes the audit record ID**, not the rule ID. Get audit record IDs from `rule-audits --id ` first; passing the rule ID returns HTTP 400. -- **To list every configured rule, use `rule-list-basic --folder-id 0`** — no folder id needed. `rule-counter-status` 400s "too many rules" on large accounts, and a guessed `--folder-id N` 400s "Folder not found"; neither is a dead end — fall through to `--folder-id 0`. Do **not** substitute fired-alert queries (`insight top-alerts`) to infer which rules exist (see the enumerate-all hot flow). +- **`rule-list-basic` needs a REAL `--folder-id` and returns only that folder's *direct* rules.** `--folder-id 0` / omitting it 400s "Folder not found" — the generated `--folder-id` help below ("0 to list all accessible rules") is a known SDK/OpenAPI bug; ignore it. Enumerate by walking the tree (`rule-counter-status` → `rule-status` → `rule-list-basic`); past the server cap the counters 400 "too many rules" and full enumeration isn't possible from the CLI — report that limit, never substitute fired alerts (see the enumerate hot flow). ## Worked example — inspect a firing rule then batch-disable it ```bash -# 1. find triggered rules in folder 0 (all accessible) -fduty monit rule-list-basic --folder-id 0 --output-format toon +# 1. find a folder with triggered rules (top-level folders + subtree counts) +fduty monit rule-counter-status --output-format toon +# 2. list the rules directly in a chosen folder (descend with rule-status if empty) +fduty monit rule-list-basic --folder-id --output-format toon # look at triggered=true rows; note their ids -# 2. get full config of one rule +# 3. get full config of one rule fduty monit rule-info --id --output-format toon -# 3. disable several rules at once without touching other fields +# 4. disable several rules at once without touching other fields fduty monit rule-update-fields --ids , --fields enabled --enabled false ``` From b5c1efe8e42be903aec3c175ca0eed39cc9cf834 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Fri, 26 Jun 2026 21:44:40 +0800 Subject: [PATCH 04/10] perf(skill): slim incident-summary output and serialize same-host monit-agent probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P6 — incident-summary.sh dumped ~80K chars of toon per incident (81042/79075 in the audited sessions), most of it empty boilerplate, overflowing the inline output cap and forcing 3-4 sequential reads to page it back in. Root cause: the script appended `--output-format toon` to every command, which takes each verb's machine-readable branch and marshals the full raw response object (every empty field on `incident detail`, plus heavy blobs like a change's `labels.steps`). The DEFAULT (table/summary) renderer of each of these read verbs is a curated projection of exactly the summary-relevant fields (id/severity/status/title/ channel/timestamps/ai_summary/root_cause/…). Fix: drop `--output-format toon` from run() so each command uses its lean default renderer; that default IS the field projection a fault summary needs. Kept all six commands, set -uo pipefail, and the read-only "print real output" intent. P8 — monit-agent.md: parallelizing multiple probes against the SAME host hit the per-target concurrency limit and returned `code=overloaded`, forcing an identical retry that re-sent the growing context. Added a Gotchas line steering fan-out to serialize probes per target (batch a host's tools into one `invoke`) and parallelize only across distinct targets. Evidence: audit run audit-2026-06-26, sessions sess_TRdrnq5oA345qF66GgbYrY (P6) and sess_QmoruPkRjrwA2tcHvEbNvT (P8). Surfaced by /audit-ai-sre-sessions. These two files are kept byte-identical with their embedded copies in fc-safari (logic/runtime/bootstrap/skills/flashduty/); mirrored there in a linked PR. --- skills/flashduty/reference/monit-agent.md | 1 + skills/flashduty/scripts/incident-summary.sh | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/skills/flashduty/reference/monit-agent.md b/skills/flashduty/reference/monit-agent.md index 5964907..f7572ef 100644 --- a/skills/flashduty/reference/monit-agent.md +++ b/skills/flashduty/reference/monit-agent.md @@ -54,6 +54,7 @@ Run up to 8 monit-agent tools concurrently on a target - **`ambiguous_target_kind` error** ⇒ the locator matched multiple kinds; re-issue with `--target-kind`. - A `target_unavailable` / `target_unreachable` error means the agent isn't connected — report it; don't retry endlessly or fall back to SSH. - Per-tool errors (`timeout`, `denied`, `unknown_tool`…) are reported per result, mutually exclusive with that tool's `data`. +- **Serialize per target; parallelize only across targets.** Each target enforces a per-target concurrency limit, so two `invoke`/`catalog` calls fired at the *same* locator at once make the second come back `code=overloaded` — forcing a context-bloating retry. Batch every tool for one host into a single `invoke` (its `tools` array already runs them concurrently agent-side); fan out in parallel across *distinct* targets, never against one. ## Worked example — top processes + disk on a host diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index 8288100..f9a52ac 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -7,8 +7,9 @@ # # usage: bash incident-summary.sh # -# To tie post-mortems to this incident specifically, re-run the last section with the -# channel_id from "incident detail": fduty incident post-mortem-list --channel-ids +# Section ⑥ lists recent post-mortems account-wide. To scope them to THIS incident's +# channel, read its channel_id (fduty incident info --incident-id --output-format +# toon | grep '^channel_id:') and re-run: fduty incident post-mortem-list --channel-ids # # Note: errexit (-e) is intentionally NOT set — every section must run even if one # command fails, so the summary stays as complete as possible. Each command's own @@ -21,9 +22,14 @@ if [ -z "$ID" ]; then exit 2 fi -run() { echo "===== fduty $* ====="; fduty "$@" --output-format toon 2>&1; echo; } +# Print each command's DEFAULT renderer (a curated table/summary that projects the +# summary-relevant fields), NOT --output-format toon: toon dumps the full raw objects +# — every empty field plus heavy blobs like a change's labels.steps — which overflowed +# the output cap and forced repeated paging. For these read verbs the lean default IS +# the field projection a fault summary needs (id/severity/status/title/channel/times/…). +run() { echo "===== fduty $* ====="; fduty "$@" 2>&1; echo; } -run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel_id +run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel run incident alerts "$ID" # ② contributing alerts run incident timeline "$ID" # ④ timeline run incident similar "$ID" --limit 5 # ⑤ similar past incidents (channel-backed) From 715d38f1b3b062490ca47e4ed9006373eb6aaafc Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 21:46:19 -0700 Subject: [PATCH 05/10] fix: compact incident list structured output --- internal/cli/fieldproject_test.go | 116 ++++++++++++++++++++----- internal/cli/incident.go | 14 ++- internal/cli/incident_test.go | 13 +++ skills/flashduty/reference/incident.md | 7 +- 4 files changed, 124 insertions(+), 26 deletions(-) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 215fdc0..6dc4535 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -17,6 +17,7 @@ func incidentRow() map[string]any { "incident_severity": "Critical", "progress": "Triggered", "start_time": 1712000000, + "channel_id": 12345, "description": "root volume at 98%", "labels": map[string]any{"service": "db", "env": "prod"}, "responders": []map[string]any{ @@ -41,10 +42,77 @@ func alertRow() map[string]any { } } -// TestFieldsProjectionDefaultUnchanged is the conductor constraint: with NO -// --fields, the structured (toon and json) output must still be the full nested -// record — the nested blobs the proposal deliberately preserves as the default. -func TestFieldsProjectionDefaultUnchanged(t *testing.T) { +// TestIncidentListStructuredDefaultUsesCompactProjection is the default agent +// path: incident list in json/toon mode must not dump the full nested SDK row +// when --fields is omitted, while an explicit --fields still wins. +func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { + t.Run("json default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"}) + }) + + t.Run("toon default", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", "toon") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + 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) + } + } + 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) + } + } + }) + + t.Run("explicit fields win", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + out, err := execCommand("incident", "list", "--fields", "incident_id,title", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + + assertProjectedJSONFields(t, out, []string{"incident_id", "title"}) + }) + + t.Run("explicit empty fields errors", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} + + _, err := execCommand("incident", "list", "--fields", "", "--output-format", "json") + if err == nil { + t.Fatal("expected an error for empty --fields, got nil") + } + if !strings.Contains(err.Error(), "--fields") { + t.Errorf("error should name --fields, got: %v", err) + } + }) +} + +// TestAlertFieldsProjectionDefaultUnchanged is the conductor constraint for the +// sibling command: with NO --fields, alert list structured output still emits +// the full nested record. The compact default is incident-list-only. +func TestAlertFieldsProjectionDefaultUnchanged(t *testing.T) { cases := []struct { name string cmd []string @@ -52,8 +120,6 @@ func TestFieldsProjectionDefaultUnchanged(t *testing.T) { format string mustHave []string // nested keys that must survive in the full dump }{ - {"incident toon", []string{"incident", "list"}, incidentRow(), "toon", []string{"responders", "labels", "description"}}, - {"incident json", []string{"incident", "list"}, incidentRow(), "json", []string{"responders", "labels", "description"}}, {"alert toon", []string{"alert", "list"}, alertRow(), "toon", []string{"events", "incident", "labels", "description"}}, {"alert json", []string{"alert", "list"}, alertRow(), "json", []string{"events", "incident", "labels", "description"}}, } @@ -77,6 +143,27 @@ func TestFieldsProjectionDefaultUnchanged(t *testing.T) { } } +func assertProjectedJSONFields(t *testing.T, out string, fields []string) { + t.Helper() + + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out) + } + if len(rows) != 1 { + t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out) + } + row := rows[0] + if len(row) != len(fields) { + t.Fatalf("expected exactly %d keys, got %d (%v)", len(fields), len(row), row) + } + for _, f := range fields { + if _, ok := row[f]; !ok { + t.Errorf("projected row missing key %q, got keys %v", f, row) + } + } +} + // TestFieldsProjectionTOON: --fields in toon mode emits exactly the requested // keys and drops everything else. func TestFieldsProjectionTOON(t *testing.T) { @@ -154,22 +241,7 @@ func TestFieldsProjectionJSON(t *testing.T) { t.Fatalf("execCommand: %v", err) } - var rows []map[string]json.RawMessage - if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { - t.Fatalf("failed to parse projected json: %v\nraw:\n%s", err, out) - } - if len(rows) != 1 { - t.Fatalf("expected 1 projected row, got %d:\n%s", len(rows), out) - } - row := rows[0] - if len(row) != len(tc.fields) { - t.Fatalf("expected exactly %d keys, got %d (%v)", len(tc.fields), len(row), row) - } - for _, f := range tc.fields { - if _, ok := row[f]; !ok { - t.Errorf("projected row missing key %q, got keys %v", f, row) - } - } + assertProjectedJSONFields(t, out, tc.fields) }) } } diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 948aa8a..56d521b 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -76,11 +76,12 @@ func newIncidentListCmd() *cobra.Command { var progress, severity, query, since, until, nums, fields string var channelID int64 var limit, page int + defaultStructuredFields := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} cmd := &cobra.Command{ Use: "list", Short: "List incidents", - Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, --fields projects each row to just the named fields (e.g. --fields incident_id,title,incident_severity,progress,start_time) so you get a compact record without piping to jq.\n\nSee also: fduty insight for aggregated metrics (MTTA, MTTR, noise reduction) instead of paginating raw incidents.", "Incidents", "List"), + Long: curatedLong("List incidents matching the given filters. The --since/--until window must be < 31 days; --limit max is 100. In json/toon mode, rows default to the compact fields incident_id,title,incident_severity,progress,start_time,channel_id; pass --fields to choose a different projection.\n\nSee also: fduty insight for aggregated metrics (MTTA, MTTR, noise reduction), fduty insight incident-list for metric-rich filtered incident rows, and fduty insight incident-export for CSV incident exports.", "Incidents", "List"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { startTime, err := timeutil.Parse(since) @@ -113,8 +114,15 @@ func newIncidentListCmd() *cobra.Command { return err } - if fields != "" && ctx.Structured() { - proj, err := projectFields(result.Items, parseStringSlice(fields)) + if ctx.Structured() { + selectedFields := defaultStructuredFields + if cmd.Flags().Changed("fields") { + selectedFields = parseStringSlice(fields) + if len(selectedFields) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + } + proj, err := projectFields(result.Items, selectedFields) if err != nil { return err } diff --git a/internal/cli/incident_test.go b/internal/cli/incident_test.go index 61156a7..76e4e36 100644 --- a/internal/cli/incident_test.go +++ b/internal/cli/incident_test.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "strings" "testing" ) @@ -47,3 +48,15 @@ func TestCommandIncidentListChannelIDFlag(t *testing.T) { t.Fatalf("channel_ids = %q, want %q", got, want) } } + +func TestCommandIncidentListHelpSurfacesInsightIncidentExport(t *testing.T) { + saveAndResetGlobals(t) + + out, err := execCommand("incident", "list", "--help") + if err != nil { + t.Fatalf("incident list --help: %v", err) + } + if !strings.Contains(out, "fduty insight incident-export") { + t.Fatalf("help output missing incident export discovery hint:\n%s", out) + } +} diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 93c1677..779ee08 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -11,6 +11,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders | want | verb | |---|---| | list / search active incidents | `list` | +| CSV export of incidents | `fduty insight incident-export` | | look up by 6-char UI num | `info --num ` | | full detail + AI summary for a 24-char id | `detail ` (narrative) or `info --incident-id ` (same endpoint) | | get structured data for one or more ids | `get [...]` | @@ -41,7 +42,9 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders ## Hot flow — triage an active incident ```bash -# 1. Find unacknowledged critical incidents (last 4h) +# 1. Find unacknowledged critical incidents (last 4h). +# toon/json list output is compact by default: +# incident_id,title,incident_severity,progress,start_time,channel_id fduty incident list --severity Critical --progress Triggered --since 4h --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) @@ -63,6 +66,8 @@ fduty incident comment --comment "Root cause identified: DB failov fduty incident resolve --root-cause "DB primary failover delay" --resolution "Failover completed; latency normal." ``` +> `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. + ## Hot flow — full fault analysis (read-only summary) When asked to **summarize / analyze** an incident — 详情 + 关联告警 + 变更 + 时间线 + 相似故障 + 复盘 — `incident detail` does **not** contain the alerts / timeline / similar / post-mortem / change data; each is its own command. **Your first action must be the bundled script** — do not hand-pick one or two commands and write the rest from memory. One call fetches all six aspects: From c853af349fc53823815879ee13bc2e1939192cc8 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 21:46:19 -0700 Subject: [PATCH 06/10] fix: render mcp server create details --- internal/cli/generic_table.go | 46 +++++++++++++++++++++++++-- internal/cli/generic_table_test.go | 51 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go index 6b22fd6..89f1062 100644 --- a/internal/cli/generic_table.go +++ b/internal/cli/generic_table.go @@ -20,6 +20,8 @@ const maxHeuristicColumns = 8 // can't blow out the table width. const genericStringMaxWidth = 40 +const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Customize -> Connectors; tools will not appear until authorized." + // instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output // package's unexported instant) so the renderer can recognise timestamp fields // by reflection. @@ -62,15 +64,18 @@ func renderGenericTable(ctx *RunContext, data any) error { if rows, total, ok := listEnvelope(v); ok { return renderRowTable(ctx, rows, total) } - return renderVertical(ctx, v) + if err := renderVertical(ctx, v); err != nil { + return err + } + return renderMcpPerUserOAuthNotice(ctx, v) default: return jsonFallback(ctx, data) } } // listEnvelope reports whether struct v is a paginated list envelope: exactly -// one exported field that is a slice of structs (the rows), with the remaining -// fields being pagination metadata. It returns the rows value and the total +// one exported field that is a slice of structs (the rows), with any remaining +// fields limited to pagination metadata. It returns the rows value and the total // (the int field named "Total" when present, else the row count). func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { t := v.Type() @@ -92,6 +97,9 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { if total < 0 && f.Name == "Total" && fv.CanInt() { total = int(fv.Int()) } + if !isListMetadataField(f, fv) { + return reflect.Value{}, 0, false + } } if !found { return reflect.Value{}, 0, false @@ -102,6 +110,22 @@ func listEnvelope(v reflect.Value) (rows reflect.Value, total int, ok bool) { return rows, total, true } +func isListMetadataField(f reflect.StructField, fv reflect.Value) bool { + if f.Anonymous && f.Name == "ListOptions" { + return true + } + switch f.Name { + case "Total": + return fv.CanInt() + case "HasNextPage": + return fv.Kind() == reflect.Bool + case "SearchAfterCtx", "NextCursor": + return fv.Kind() == reflect.String + default: + return false + } +} + // isRowSlice reports whether t is a slice whose element (after pointer deref) is // a struct — i.e. a table-able row collection. func isRowSlice(t reflect.Type) bool { @@ -210,6 +234,22 @@ func renderVertical(ctx *RunContext, v reflect.Value) error { return ctx.Printer.Print(rows, cols) } +func renderMcpPerUserOAuthNotice(ctx *RunContext, v reflect.Value) error { + if !isMcpPerUserOAuth(v) { + return nil + } + _, err := fmt.Fprintln(ctx.Writer, mcpPerUserOAuthNotice) + return err +} + +func isMcpPerUserOAuth(v reflect.Value) bool { + if v.Type().Name() != "McpServerItem" { + return false + } + auth := v.FieldByName("AuthMode") + return auth.IsValid() && auth.Kind() == reflect.String && auth.String() == "per_user_oauth" +} + // derefStruct dereferences pointer chains and returns the underlying struct // reflect.Value. The second return is false when item is nil, not a struct after // dereferencing, or any pointer in the chain is nil. diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index 5cc279e..0be1327 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -144,6 +144,57 @@ func TestRenderGenericTable_DetailVertical(t *testing.T) { } } +func TestRenderGenericTable_McpServerItemWithEmptyToolsRendersDetail(t *testing.T) { + var buf bytes.Buffer + item := &flashduty.McpServerItem{ + ServerID: "mcp_test", + ServerName: "github", + Description: "GitHub connector", + AuthMode: "shared", + Status: "enabled", + Transport: "streamable-http", + URL: "https://mcp.example.com/github", + Tools: []flashduty.McpToolInfo{}, + } + if err := renderGenericTable(tableCtx(&buf), item); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + if strings.Contains(got, "No results.") { + t.Fatalf("single MCP server item with empty tools was rendered as an empty list:\n%s", got) + } + for _, want := range []string{"FIELD", "VALUE", "SERVER_ID", "mcp_test", "SERVER_NAME", "github"} { + if !strings.Contains(got, want) { + t.Errorf("MCP server detail output missing %q\n---\n%s", want, got) + } + } +} + +func TestRenderGenericTable_McpServerPerUserOAuthNotice(t *testing.T) { + var buf bytes.Buffer + item := &flashduty.McpServerItem{ + ServerID: "mcp_oauth", + ServerName: "github", + Description: "GitHub connector", + AuthMode: "per_user_oauth", + Status: "enabled", + Transport: "streamable-http", + URL: "https://mcp.example.com/github", + } + if err := renderGenericTable(tableCtx(&buf), item); err != nil { + t.Fatalf("render: %v", err) + } + got := buf.String() + for _, want := range []string{ + "registered but not usable until OAuth is completed in Flashduty Customize -> Connectors", + "tools will not appear until authorized", + } { + if !strings.Contains(got, want) { + t.Errorf("per-user OAuth notice missing %q\n---\n%s", want, got) + } + } +} + func TestPrintGenericResult_StructuredUnchanged(t *testing.T) { resp := &fakeListResp{Items: []heuristicRow{{Name: "alpha", Count: 7}}, Total: 1} From 3babddadeef0f37681286f678a4ed6d8770f4e7d Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 22:06:29 -0700 Subject: [PATCH 07/10] fix: point oauth notice to plugins mcp --- internal/cli/generic_table.go | 2 +- internal/cli/generic_table_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/generic_table.go b/internal/cli/generic_table.go index 89f1062..6e33023 100644 --- a/internal/cli/generic_table.go +++ b/internal/cli/generic_table.go @@ -20,7 +20,7 @@ const maxHeuristicColumns = 8 // can't blow out the table width. const genericStringMaxWidth = 40 -const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Customize -> Connectors; tools will not appear until authorized." +const mcpPerUserOAuthNotice = "Note: registered but not usable until OAuth is completed in Flashduty Plugins -> MCP; tools will not appear until authorized." // instantLike mirrors go-flashduty's Timestamp/TimestampMilli (and the output // package's unexported instant) so the renderer can recognise timestamp fields diff --git a/internal/cli/generic_table_test.go b/internal/cli/generic_table_test.go index 0be1327..0aa2f39 100644 --- a/internal/cli/generic_table_test.go +++ b/internal/cli/generic_table_test.go @@ -186,7 +186,7 @@ func TestRenderGenericTable_McpServerPerUserOAuthNotice(t *testing.T) { } got := buf.String() for _, want := range []string{ - "registered but not usable until OAuth is completed in Flashduty Customize -> Connectors", + "registered but not usable until OAuth is completed in Flashduty Plugins -> MCP", "tools will not appear until authorized", } { if !strings.Contains(got, want) { From f646fbc5354375fad7c3b1bc4370cd9ef66e612f Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 22:29:51 -0700 Subject: [PATCH 08/10] fix: align skill id guidance --- internal/cli/incident.go | 2 +- skills/flashduty/reference/incident.md | 12 ++++++------ skills/flashduty/reference/team.md | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 56d521b..be21c86 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -361,7 +361,7 @@ func newIncidentCreateCmd() *cobra.Command { cmd.Flags().Int64Var(&channelID, "channel", 0, "Channel ID") registerEnumFlag(cmd, "severity", severityEnum...) cmd.Flags().StringVar(&description, "description", "", "Description (max 6144 chars)") - cmd.Flags().IntSliceVar(&assign, "assign", nil, "Person IDs to assign (use 'flashduty member list' to look up IDs)") + cmd.Flags().IntSliceVar(&assign, "assign", nil, "Member IDs to assign directly (use 'flashduty member list' to look up member IDs)") return cmd } diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 779ee08..66afb86 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -82,12 +82,12 @@ If you fetch the pieces by hand instead, run **all six** — they are cheap read ```bash ID= # 24-char id from `incident list` -fduty incident detail "$ID" --output-format toon # ① 详情 + AI summary + alert counts + channel_id -fduty incident alerts "$ID" --output-format toon # ② contributing alerts (detail's embedded alerts are empty here) -fduty incident timeline "$ID" --output-format toon # ④ 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) -fduty incident post-mortem-list --channel-ids --output-format toon # ⑥ post-mortems for this incident's channel -fduty change list --since 24h --output-format toon # ③ correlated changes — by shared labels + time; see reference/change.md +fduty incident detail "$ID" # ① 详情 + AI summary + alert counts + channel_id +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 # ⑤ similar past incidents (channel-backed; see Gotchas) +fduty incident post-mortem-list --channel-ids # ⑥ post-mortems for this incident's channel +fduty change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md ``` > **Never report a result you didn't fetch.** Do not write "返回空" / "无" / a count for any aspect whose command is **absent from your tool-call history this turn** — write `未查询 — 可运行 ` instead. "Empty" is a claim only a command you actually ran can make; inventing it is the worst failure mode of a fault summary. diff --git a/skills/flashduty/reference/team.md b/skills/flashduty/reference/team.md index 9f023ce..02bc7b0 100644 --- a/skills/flashduty/reference/team.md +++ b/skills/flashduty/reference/team.md @@ -6,7 +6,7 @@ Prereq: `SKILL.md` read. **SKILL.md + this card = full competence on teams — n "团队 / 成员管理 / 创建团队 / 查找团队 / HR同步 / team ID / person ID归属" → **team**. Key IDs: - **`team_id` (int64)** — from `fduty team list` or `team get --name`. -- **`person_id` (int64)** — look up via `fduty member list --query ` (member card, not here). +- **`--person-ids` inputs are member IDs** — look up via `fduty member list --query ` (member card, not here). The API field is named `person_ids`, but team membership expects member IDs. NOT this card: on-call schedules (oncall), incidents (incident), channels (channel). @@ -28,7 +28,7 @@ NOT this card: on-call schedules (oncall), incidents (incident), channels (chann ```bash # 1. Check name doesn't already exist fduty team list --name "SRE Platform" --output-format toon -# 2. Create with initial members (person IDs from member list) +# 2. Create with initial members (member IDs from member list) fduty team create --name "SRE Platform" --description "Site Reliability" \ --person-ids 1001,1002,1003 # 3. Verify — note the returned team_id From e12be1951655dba04eca734cb8bfc0044cde50e9 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 22:37:36 -0700 Subject: [PATCH 09/10] fix: clarify credential and member id guidance --- internal/cli/incident.go | 21 ++++++++++----------- internal/cli/zz_generated_incidents.go | 8 ++++---- skills/flashduty/reference/automation.md | 2 +- skills/flashduty/reference/channel.md | 1 + skills/flashduty/reference/incident.md | 10 +++++----- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index be21c86..4685a1a 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -759,7 +759,7 @@ func newIncidentReassignCmd() *cobra.Command { }, } - cmd.Flags().StringVar(&person, "person", "", "Comma-separated person IDs") + cmd.Flags().StringVar(&person, "person", "", "Comma-separated member IDs from 'flashduty member list'") _ = cmd.MarkFlagRequired("person") return cmd @@ -774,8 +774,8 @@ func newIncidentAddResponderCmd() *cobra.Command { Short: "Add responders to an incident", Long: `Add one or more responders to an incident. -Responder IDs are person IDs. Use 'flashduty member list' to find the right -person ID before running this command. Optional notification flags let you ask +Responder IDs are member IDs from 'flashduty member list'. Optional +notification flags let you ask FlashDuty to notify added responders through their preferences, explicit personal channels, or a template.`, Example: ` flashduty member list --name "Ada" @@ -816,7 +816,7 @@ personal channels, or a template.`, }, } - cmd.Flags().StringVar(&person, "person", "", "Comma-separated person IDs to add") + cmd.Flags().StringVar(&person, "person", "", "Comma-separated member IDs from 'flashduty member list'") cmd.Flags().BoolVar(&followPreference, "follow-preference", false, "Follow each responder's notification preferences") cmd.Flags().StringVar(¬ifyChannel, "notify-channel", "", "Comma-separated notification channels, e.g. voice,sms,email") cmd.Flags().StringVar(&templateID, "template-id", "", "Notification template ID") @@ -945,8 +945,8 @@ func newIncidentWarRoomCreateCmd() *cobra.Command { Long: `Create an incident war room in a configured IM integration. If --integration is omitted, the CLI uses the first war-room-enabled IM -integration returned by FlashDuty. Use --member to invite person IDs directly. -Use 'flashduty member list' to find person IDs. Use --add-observers to also +integration returned by FlashDuty. Use --member to invite member IDs directly. +Use 'flashduty member list' to find member IDs. Use --add-observers to also invite historical responders selected by FlashDuty.`, Example: ` flashduty incident war-room create inc_123 flashduty incident war-room create inc_123 --integration 42 --member 101,202 @@ -982,7 +982,7 @@ invite historical responders selected by FlashDuty.`, } cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID; if omitted, first war-room-enabled IM integration is used") - cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs to invite") + cmd.Flags().StringVar(&member, "member", "", "Comma-separated member IDs to invite") cmd.Flags().BoolVar(&addObservers, "add-observers", false, "Invite historical responders as extra war-room members") return cmd } @@ -1125,9 +1125,8 @@ func newIncidentWarRoomAddMemberCmd() *cobra.Command { Long: `Add members to an existing incident war room by IM chat ID. This command requires --integration because chat IDs are scoped to an IM -integration. Member IDs are person IDs. Use 'flashduty member list' to find -person IDs, and 'flashduty incident war-room list' to find chat and integration -IDs.`, +integration. Use 'flashduty member list' to find member IDs, and +'flashduty incident war-room list' to find chat and integration IDs.`, Example: ` flashduty member list --name "Ada" flashduty incident war-room list inc_123 flashduty incident war-room add-member chat_123 --integration 42 --member 101,202`, @@ -1155,7 +1154,7 @@ IDs.`, } cmd.Flags().Int64Var(&integrationID, "integration", 0, "IM integration ID (required)") - cmd.Flags().StringVar(&member, "member", "", "Comma-separated member person IDs (required)") + cmd.Flags().StringVar(&member, "member", "", "Comma-separated member IDs (required)") _ = cmd.MarkFlagRequired("integration") _ = cmd.MarkFlagRequired("member") return cmd diff --git a/internal/cli/zz_generated_incidents.go b/internal/cli/zz_generated_incidents.go index bdd93b3..10c7620 100644 --- a/internal/cli/zz_generated_incidents.go +++ b/internal/cli/zz_generated_incidents.go @@ -86,7 +86,7 @@ API: POST /incident/war-room/add-member (incident-write-add-war-room-member) Request fields: --chat-id string (required) — Chat ID of the war room within the IM platform. --integration-id int (required) — IM integration that hosts the war room. - --member-ids []int (required) — Person IDs to add to the war room. + --member-ids []int (required) — Member IDs to add to the war room. `, Args: requireExactArg("chat_id"), Example: ` flashduty incident war-room-add-member --data '{"chat_id":"oc_5ce6d572455d361153b7cb51da133945","integration_id":362,"member_ids":[20001,20002]}'`, @@ -124,7 +124,7 @@ Request fields: } cmd.Flags().StringVar(&fChatID, "chat-id", "", "Chat ID of the war room within the IM platform. (required)") cmd.Flags().Int64Var(&fIntegrationID, "integration-id", 0, "IM integration that hosts the war room. (required)") - cmd.Flags().IntSliceVar(&fMemberIDs, "member-ids", nil, "Person IDs to add to the war room. (required)") + cmd.Flags().IntSliceVar(&fMemberIDs, "member-ids", nil, "Member IDs to add to the war room. (required)") cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.") return cmd } @@ -2346,7 +2346,7 @@ func genIncidentsResponderAddCmd() *cobra.Command { var fIncidentID string var fPersonIDs []int cmd := &cobra.Command{ - Use: "responder-add [...]", + Use: "responder-add [...]", Short: "Add incident responder", Long: `Add incident responder. @@ -2356,7 +2356,7 @@ API: POST /incident/responder/add (incidentResponderAdd) Request fields: --incident-id string (required) — Incident ID (MongoDB ObjectID). - --person-ids []int (required) — Member IDs to add as responders. + --person-ids []int (required) — Member IDs from 'flashduty member list' to add as responders. notify (object, via --data) — Optional notification override. Defaults to following each person's personal preference. - follow_preference (boolean) — When true, fall back to each responder's personal preference. - personal_channels (array) — Channels to use (e.g. 'voice', 'sms', 'email'). diff --git a/skills/flashduty/reference/automation.md b/skills/flashduty/reference/automation.md index c173923..8296c80 100644 --- a/skills/flashduty/reference/automation.md +++ b/skills/flashduty/reference/automation.md @@ -101,7 +101,7 @@ fduty automation fire \ --output-format toon ``` -`--dedup-key` makes retries idempotent for the same trigger. Do not invent a token; if it is missing, ask the user for the token or rotate the trigger token through `update`. +`--dedup-key` makes retries idempotent for the same trigger. Do not invent a token. If it is missing, rotate the trigger token through `update` or ask the user to provide it through their secure shell/environment, not in chat. diff --git a/skills/flashduty/reference/channel.md b/skills/flashduty/reference/channel.md index 6b6f5b1..4da4110 100644 --- a/skills/flashduty/reference/channel.md +++ b/skills/flashduty/reference/channel.md @@ -38,6 +38,7 @@ fduty channel create --channel-name "production-api" --team-id \ # → 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}]}' diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 66afb86..da2030a 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -31,7 +31,7 @@ Prereq: `SKILL.md` read. Read verbs are free. **Mutating verbs notify responders | resolve with optional note | `resolve [...]` | | snooze / un-snooze | `snooze [...]` / `wake [...]` | | add comment | `comment [...]` | -| add responder by person ID | `add-responder ` | +| add responder by member ID | `add-responder ` | | replace responder list | `reassign ` | | merge duplicates (IRREVERSIBLE) | `merge ` | | stop auto-merging alerts in | `disable-merge [...]` | @@ -317,10 +317,10 @@ Resolve incident - `--resolution` string — Optional resolution note applied to every resolved incident. (≤1024 chars) - `--root-cause` string — Optional root cause note applied to every resolved incident. (≤1024 chars) -### responder-add [...] +### responder-add [...] Add incident responder - `--incident-id` string (required) — Incident ID (MongoDB ObjectID). -- `` (positional, required) intSlice — Member IDs to add as responders. +- `` (positional, required) intSlice — Member IDs from `member list` to add as responders. The API field is named `person_ids`. - body-only (`--data`): notify (object) ### similar @@ -380,7 +380,7 @@ List incident war rooms Add war-room member - `` (positional, required) string — Chat ID of the war room within the IM platform. - `--integration-id` int64 (required) — IM integration that hosts the war room. -- `--member-ids` intSlice (required) — Person IDs to add to the war room. +- `--member-ids` intSlice (required) — Member IDs to add to the war room. ### war-room-create Create war room @@ -424,7 +424,7 @@ List war rooms - **`--list` window cap**: `--since`/`--until` window must be < 31 days; `--limit` max 100. Empty result is authoritative — do not widen filters or retry. - **`merge` is irreversible**: source incidents are absorbed into target permanently. Always list and confirm both IDs before running. - **`remove --force`** bypasses the interactive confirmation prompt — never pass `--force` unless the user has explicitly said so. -- **`assign` needs `--data` for the nested `assigned_to` object** (either `person_ids` or `escalate_rule_id`). Pass via `--data '{"incident_ids":[""],"assigned_to":{"person_ids":[101]}}'`. `reassign --person ` is simpler for direct-person assignment. +- **`assign` needs `--data` for the nested `assigned_to` object** (either `person_ids` or `escalate_rule_id`). Pass member IDs from `member list` in the API field: `--data '{"incident_ids":[""],"assigned_to":{"person_ids":[101]}}'`. `reassign --person ` is simpler for direct member assignment. ## Worked example From 8400d8baa8d0832726383957a5d8191f72b1a403 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Wed, 1 Jul 2026 22:39:59 -0700 Subject: [PATCH 10/10] fix: sync generated incident skill card --- 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 da2030a..3a1f195 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -320,7 +320,7 @@ Resolve incident ### responder-add [...] Add incident responder - `--incident-id` string (required) — Incident ID (MongoDB ObjectID). -- `` (positional, required) intSlice — Member IDs from `member list` to add as responders. The API field is named `person_ids`. +- `--person-ids` intSlice (required) — Member IDs from 'flashduty member list' to add as responders. - body-only (`--data`): notify (object) ### similar