diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index b8b0283..8d05b15 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -21,15 +21,19 @@ func newAlertEventCmd() *cobra.Command { } func newAlertEventListCmd() *cobra.Command { - var severity, channel, integrationType, since, until string + var severity, channel, integrationType, since, until, fields string var limit, page int cmd := &cobra.Command{ Use: "list", Short: "List alert events globally", - Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type.", "Alerts", "EventReadList"), + Long: curatedLong("List alert events across all alerts within a time window, optionally filtered by severity, channel, or integration type. In json/toon mode, output defaults to compact event fields; use --fields to choose a different projection.", "Alerts", "EventReadList"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return fmt.Errorf("--fields must name at least one field") + } + startTime, err := timeutil.Parse(since) if err != nil { return fmt.Errorf("invalid --since: %w", err) @@ -77,6 +81,21 @@ func newAlertEventListCmd() *cobra.Command { {Header: "TITLE", MaxWidth: 50, Field: func(v any) string { return v.(flashduty.AlertEventItem).Title }}, } + if ctx.Structured() { + fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } + return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) + } + return ctx.PrintList(result.Items, cols, len(result.Items), page, int(result.Total)) }) }, @@ -90,6 +109,7 @@ func newAlertEventListCmd() *cobra.Command { cmd.Flags().StringVar(&until, "until", "now", "End time") cmd.Flags().IntVar(&limit, "limit", 20, "Max results") 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.") return cmd } diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index e421397..7bcb12c 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -5,6 +5,12 @@ import ( "reflect" "sort" "strings" + "unicode/utf8" +) + +const ( + compactListOutputLimit = 16 * 1024 + compactDetailOutputLimit = 8 * 1024 ) // projectFields reduces each struct element of items to a map containing only @@ -63,6 +69,89 @@ func projectFields(items any, fields []string) ([]map[string]any, error) { return out, nil } +// 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. +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} + case []map[string]any: + rows = value + default: + return fmt.Errorf("internal error: unsupported projected output %T", data) + } + + encoded, err := marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + + stringCount := 0 + for _, row := range rows { + for _, value := range row { + if _, ok := value.(string); ok { + stringCount++ + } + } + } + if stringCount == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + + fieldLimit := maxBytes / stringCount + for { + for _, row := range rows { + for key, value := range row { + if text, ok := value.(string); ok { + row[key] = truncateUTF8Bytes(text, fieldLimit) + } + } + } + + encoded, err = marshalStructured(data) + if err != nil { + return err + } + if len(encoded)+1 < maxBytes { + return nil + } + if fieldLimit == 0 { + return fmt.Errorf("structured projection exceeds %d-byte limit; request fewer rows or fields", maxBytes) + } + fieldLimit /= 2 + } +} + +func truncateUTF8Bytes(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + if maxBytes <= 0 { + return "" + } + if maxBytes <= 3 { + end := maxBytes + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + } + + end := maxBytes - 3 + for end > 0 && !utf8.ValidString(value[:end]) { + end-- + } + return value[:end] + "..." +} + // jsonTagIndex maps each exported field's json tag name (leading component, sans // `,omitempty`) to its index in the struct. Fields tagged `json:"-"`, untagged // fields, and embedded/anonymous fields are skipped — only declared, named, diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 6dc4535..01d847b 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -1,9 +1,13 @@ package cli import ( + "bytes" "encoding/json" "strings" "testing" + "unicode/utf8" + + "github.com/spf13/cobra" ) // incidentRow / alertRow are multi-field stub payloads with the nested blobs @@ -26,6 +30,48 @@ func incidentRow() map[string]any { } } +func TestBoundProjectedOutputCapsStructuredFormats(t *testing.T) { + for _, format := range []string{"json", "toon"} { + t.Run(format, func(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = format + rows := []map[string]any{{ + "incident_id": "inc-1", + "title": strings.Repeat("数据库故障", 2000), + }} + + if err := boundProjectedOutput(rows, 512); err != nil { + t.Fatalf("bound projected output: %v", err) + } + encoded, err := marshalStructured(rows) + if err != nil { + t.Fatalf("marshal bounded output: %v", err) + } + if len(encoded)+1 >= 512 { + t.Fatalf("bounded %s output is %d bytes, want <512", format, len(encoded)+1) + } + title := rows[0]["title"].(string) + if !utf8.ValidString(title) || !strings.HasSuffix(title, "...") { + t.Fatalf("truncated title = %q, want valid UTF-8 with marker", title) + } + }) + } +} + +func TestBoundProjectedOutputRejectsIrreducibleMetadata(t *testing.T) { + saveAndResetGlobals(t) + flagOutputFormat = "json" + rows := make([]map[string]any, 200) + for i := range rows { + rows[i] = map[string]any{"count": i} + } + + err := boundProjectedOutput(rows, 512) + if err == nil || !strings.Contains(err.Error(), "request fewer rows or fields") { + t.Fatalf("irreducible output error = %v, want bounded guidance", err) + } +} + func alertRow() map[string]any { return map[string]any{ "alert_id": "al-1", @@ -46,6 +92,27 @@ func alertRow() map[string]any { // 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) { + for _, format := range []string{"json", "toon"} { + t.Run(format+" long title stays bounded", func(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + row := incidentRow() + row["title"] = strings.Repeat("数据库故障", 5000) + stub.data = map[string]any{"items": []any{row}, "total": 1} + + out, err := execCommand("incident", "list", "--output-format", format) + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len([]byte(out)) >= compactListOutputLimit { + t.Fatalf("bounded %s incident list is %d bytes, want <%d", format, len([]byte(out)), compactListOutputLimit) + } + if !utf8.ValidString(out) || !strings.Contains(out, "...") { + t.Fatalf("bounded %s incident list must retain valid UTF-8 and show truncation", format) + } + }) + } + t.Run("json default", func(t *testing.T) { saveAndResetGlobals(t) stub := newGFStub(t) @@ -307,3 +374,165 @@ func TestFieldsUnknownFieldErrors(t *testing.T) { }) } } + +func TestIncidentSimilarStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 20) + for i := range items { + row := incidentRow() + row["incident_id"] = "similar-" + string(rune('a'+i)) + row["close_time"] = 1712000060 + row["ack_time"] = 1712000030 + row["alert_cnt"] = 3 + row["title"] = strings.Repeat("very long incident title ", 2000) + row["root_cause"] = strings.Repeat("disk exhaustion details ", 2000) + row["score"] = 0.9 + row["description"] = strings.Repeat("large description ", 100) + row["images"] = []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}} + items[i] = row + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out)) + } + + var rows []map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { + t.Fatalf("parse compact similar json: %v\n%s", err, out) + } + want := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + if _, ok := row["description"]; ok { + t.Errorf("projected row includes description: %v", row) + } + } + +} + +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) + stub.data = row + + fields := []string{"incident_id", "title", "incident_severity", "progress", "ai_summary", "root_cause", "resolution", "alert_cnt", "start_time", "channel_id"} + out, err := execCommand("incident", "detail", "inc-1", "--fields", strings.Join(fields, ","), "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 8*1024 { + t.Fatalf("projected detail output is %d bytes, want <8 KiB", len(out)) + } + var detail map[string]json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &detail); err != nil { + t.Fatalf("parse projected detail json: %v\n%s", err, out) + } + if len(detail) != len(fields) { + t.Fatalf("projected detail keys = %v, want exactly %v", detail, fields) + } + for _, field := range fields { + if detail[field] == nil { + t.Errorf("projected detail missing %q: %v", field, detail) + } + } + if _, ok := detail["description"]; ok { + t.Errorf("projected detail includes description: %v", detail) + } + +} + +func TestAlertEventListStructuredProjection(t *testing.T) { + saveAndResetGlobals(t) + stub := newGFStub(t) + items := make([]any, 30) + for i := range items { + items[i] = map[string]any{ + "event_id": "event-" + string(rune('a'+i)), + "alert_id": "alert-1", + "event_severity": "Warning", + "event_status": "Triggered", + "event_time": 1712000000, + "title": strings.Repeat("very long alert event title ", 2000), + "description": strings.Repeat("large description ", 100), + "labels": map[string]any{"host": "web-01"}, + "images": []map[string]any{{"src": strings.Repeat("https://example.test/image/", 100)}}, + } + } + stub.data = map[string]any{"items": items, "total": len(items)} + + out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json") + if err != nil { + t.Fatalf("execCommand: %v", err) + } + if len(out) >= 16*1024 { + t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out)) + } + 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) + } + want := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} + if len(rows) != len(items) { + t.Fatalf("got %d rows, want %d", len(rows), len(items)) + } + for _, row := range rows { + if len(row) != len(want) { + t.Fatalf("got keys %v, want exactly %v", row, want) + } + for _, field := range want { + if _, ok := row[field]; !ok { + t.Errorf("projected row missing %q", field) + } + } + } + +} + +func TestStructuredFieldsEmptyErrors(t *testing.T) { + cases := []struct { + name string + cmd func() *cobra.Command + args []string + }{ + {"incident similar", newIncidentSimilarCmd, []string{"inc-1", "--fields", ""}}, + {"incident detail", newIncidentDetailCmd, []string{"inc-1", "--fields", ""}}, + {"alert-event list", newAlertEventListCmd, []string{"--fields", ""}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + saveAndResetGlobals(t) + newGFStub(t) + flagOutputFormat = "json" + cmd := tc.cmd() + cmd.SetOut(new(bytes.Buffer)) + cmd.SetErr(cmd.OutOrStderr()) + cmd.SetArgs(tc.args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--fields") { + t.Fatalf("empty --fields error = %v, want --fields validation", err) + } + }) + } +} diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 4685a1a..da684fe 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -126,6 +126,9 @@ func newIncidentListCmd() *cobra.Command { if err != nil { return err } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } return ctx.PrintList(proj, nil, len(result.Items), page, int(result.Total)) } @@ -572,6 +575,7 @@ func newIncidentAlertsCmd() *cobra.Command { } func newIncidentSimilarCmd() *cobra.Command { + var fields string var limit int cmd := &cobra.Command{ @@ -581,6 +585,10 @@ func newIncidentSimilarCmd() *cobra.Command { Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + result, _, err := ctx.Client.Incidents.PastList(cmdContext(ctx.Cmd), &flashduty.ListPastIncidentsRequest{ IncidentID: ctx.Args[0], Limit: flashduty.Int64(int64(limit)), @@ -594,12 +602,28 @@ func newIncidentSimilarCmd() *cobra.Command { return nil } + if ctx.Structured() { + fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} + if fields != "" { + fieldNames = parseStringSlice(fields) + } + proj, err := projectFields(result.Items, fieldNames) + if err != nil { + return err + } + if err := boundProjectedOutput(proj, compactListOutputLimit); err != nil { + return err + } + return ctx.Printer.Print(proj, nil) + } + return ctx.Printer.Print(result.Items, pastIncidentColumns()) }) }, } cmd.Flags().IntVar(&limit, "limit", 5, "Max results") + cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to project in json/toon output (e.g. incident_id,title,incident_severity,progress,start_time); ignored in table mode. Defaults to a compact incident summary. Long strings are truncated as needed to keep structured output below 16 KiB.") return cmd } @@ -1308,13 +1332,19 @@ func resolveFeedOperators(rc *RunContext, items []flashduty.IncidentFeedItem) ma } func newIncidentDetailCmd() *cobra.Command { - return &cobra.Command{ + var fields string + + cmd := &cobra.Command{ Use: "detail ", Short: "View full incident detail with AI summary", Long: curatedLong("View full incident detail, including the AI summary, root cause, and resolution.", "Incidents", "Info"), Args: requireArgs("incident_id"), RunE: func(cmd *cobra.Command, args []string) error { return runCommand(cmd, args, func(ctx *RunContext) error { + if ctx.Structured() && cmd.Flags().Changed("fields") && len(parseStringSlice(fields)) == 0 { + return errors.New("--fields must name at least one field") + } + fullID, candidates, err := resolveIncidentArg(ctx, ctx.Args[0]) if err != nil { return err @@ -1331,6 +1361,16 @@ func newIncidentDetailCmd() *cobra.Command { } if ctx.Structured() { + if fields != "" { + proj, err := projectFields([]flashduty.IncidentInfo{*result}, parseStringSlice(fields)) + if err != nil { + return err + } + if err := boundProjectedOutput(proj[0], compactDetailOutputLimit); err != nil { + return err + } + return ctx.Printer.Print(proj[0], nil) + } return ctx.Printer.Print(result, nil) } @@ -1339,6 +1379,8 @@ 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.") + return cmd } func printIncidentFullDetail(w io.Writer, inc *flashduty.IncidentInfo) { diff --git a/internal/cli/incident_summary_script_test.go b/internal/cli/incident_summary_script_test.go new file mode 100644 index 0000000..2eeab13 --- /dev/null +++ b/internal/cli/incident_summary_script_test.go @@ -0,0 +1,49 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestIncidentSummaryScriptCompactOutput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Bash fixture is unavailable on Windows") + } + + root, err := filepath.Abs("../..") + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + script := filepath.Join(root, "skills", "flashduty", "scripts", "incident-summary.sh") + log := filepath.Join(t.TempDir(), "fduty.log") + bin := filepath.Join(t.TempDir(), "fduty") + if err := os.WriteFile(bin, []byte("#!/usr/bin/env bash\nprintf '%s\\n' \"$*\" >> \"$FDUTY_LOG\"\nprintf 'compact result\\n'\n"), 0o755); err != nil { + t.Fatalf("write fake fduty: %v", err) + } + t.Setenv("FDUTY_LOG", log) + t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH")) + + output, err := exec.Command("bash", script, "inc-1").CombinedOutput() + if err != nil { + t.Fatalf("run incident summary: %v\n%s", err, output) + } + invocations, err := os.ReadFile(log) + if err != nil { + t.Fatalf("read fake fduty log: %v", err) + } + lines := strings.FieldsFunc(strings.TrimSpace(string(invocations)), func(r rune) bool { return r == '\n' }) + if len(lines) != 6 { + t.Fatalf("fduty calls = %d, want 6:\n%s", len(lines), invocations) + } + wantDetail := "incident detail inc-1 --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon" + if lines[0] != wantDetail { + t.Fatalf("detail call = %q, want compact projection %q", lines[0], wantDetail) + } + if strings.Contains(strings.Join(lines[1:], "\n"), "--output-format toon") { + t.Fatalf("non-detail reads force raw toon instead of their compact defaults:\n%s", invocations) + } +} diff --git a/internal/skilldoc/incident_projection_guidance_test.go b/internal/skilldoc/incident_projection_guidance_test.go new file mode 100644 index 0000000..b96bf28 --- /dev/null +++ b/internal/skilldoc/incident_projection_guidance_test.go @@ -0,0 +1,50 @@ +package skilldoc + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIncidentCardAvoidsUnboundedStructuredHotFlows(t *testing.T) { + card, err := os.ReadFile(filepath.Join("..", "..", "skills", "flashduty", "reference", "incident.md")) + if err != nil { + t.Fatal(err) + } + + body := string(card) + for description, command := range map[string]string{ + "triage list": "incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon", + "triage detail": "incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon", + "summary detail": `incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon`, + } { + if !strings.Contains(body, command) { + t.Errorf("incident %s must project structured output to the fields needed by the workflow", description) + } + } + + for _, command := range []string{ + "incident alerts --output-format toon", + `incident alerts "$ID" --output-format toon`, + "incident post-mortem-list --channel-ids --output-format toon", + "change list --since 24h --output-format toon", + "incident timeline --output-format toon", + } { + if strings.Contains(body, command) { + t.Errorf("incident hot flow must use the compact default instead of %q", command) + } + } + + _, summary, found := strings.Cut(body, "## Hot flow — full fault analysis (read-only summary)") + if !found { + t.Fatal("incident card is missing the full fault analysis section") + } + summary, _, found = strings.Cut(summary, "## Hot flow — resolve, document, and merge duplicates") + if !found { + t.Fatal("incident card is missing the section after full fault analysis") + } + if strings.Contains(summary, `incident timeline "$ID" --output-format toon`) { + t.Error("full fault analysis must use timeline's compact default; structured timeline is reserved for comment read-back") + } +} diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index e940a9f..c7c9ee3 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -34,8 +34,12 @@ fduty alert get --output-format toon fduty alert events --output-format toon # 4. view state transitions (mute/severity changes/operator actions) fduty alert feed --output-format toon +# 5. for a time-window view across alerts, alert-event list is compact by default +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. + ## Hot flow — merge noisy alerts into an existing incident ```bash diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 67b9208..c976d17 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -42,16 +42,14 @@ 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). -# 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 +# 1. Find unacknowledged critical incidents (last 4h) +fduty incident list --severity Critical --progress Triggered --since 4h --fields incident_id,title,incident_severity,progress,start_time,channel_id --output-format toon # 2. Get AI summary + full detail (use the 24-char incident_id from step 1) -fduty incident detail --output-format toon +fduty incident detail --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # 3. See contributing alerts -fduty incident alerts --output-format toon +fduty incident alerts # 4. Check for prior similar incidents (channel-backed only; see Gotchas) fduty incident similar --limit 5 --output-format toon @@ -74,6 +72,8 @@ fduty incident timeline "$ID" --output-format toon 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. + After every comment write, read back every target and verify the intended comment is present before reporting success. `Commented on ...` proves acceptance, not content fidelity. > `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. @@ -92,10 +92,10 @@ 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" # ① 详情 + AI summary + alert counts + channel_id +fduty incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + AI summary + alert counts + channel 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 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 change list --since 24h # ③ correlated changes — by shared labels + time; see reference/change.md ``` @@ -115,7 +115,7 @@ fduty incident reset \ --resolution "Increased memory limit; deployed hot patch" # Review the event timeline -fduty incident timeline --output-format toon +fduty incident timeline ``` @@ -175,6 +175,7 @@ Execute custom action ### detail View full incident detail with AI summary +- `--fields` string ### disable-merge [...] Disable incident merge @@ -340,6 +341,7 @@ Add incident responder ### similar Find similar incidents +- `--fields` string - `--limit` int ### snooze [ ...] diff --git a/skills/flashduty/scripts/incident-summary.sh b/skills/flashduty/scripts/incident-summary.sh index f9a52ac..2833e17 100644 --- a/skills/flashduty/scripts/incident-summary.sh +++ b/skills/flashduty/scripts/incident-summary.sh @@ -8,8 +8,8 @@ # usage: bash incident-summary.sh # # 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 +# channel, read channel_id from the projected detail section 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 @@ -22,14 +22,12 @@ if [ -z "$ID" ]; then exit 2 fi -# 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/…). +# Project detail explicitly because its default table includes unbounded narrative +# fields. The other read verbs use their compact default renderers; raw toon would +# dump every empty field plus heavy blobs like a change's labels.steps. run() { echo "===== fduty $* ====="; fduty "$@" 2>&1; echo; } -run incident detail "$ID" # ① 详情 + AI summary + alert counts + channel +run incident detail "$ID" --fields incident_id,title,incident_severity,progress,ai_summary,root_cause,resolution,alert_cnt,start_time,channel_id --output-format toon # ① 详情 + 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)