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..8bbf251 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) { @@ -131,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) @@ -394,13 +402,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 +493,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/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 diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 9c08daf..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. +> `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)