From 0389c66f8705409e2ca79be715d5a286846787b7 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:35:35 -0700 Subject: [PATCH 1/3] feat(cli): announce the default compact projection on stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In json/toon mode, incident list, incident similar and alert-event list silently reduce rows to a compact default field set. A reader piping stdout to jq sees an unselected key (labels, description, ...) as null on every row and can reasonably conclude the server never returns it, when it is one --fields away — the incident list card even documents the projection, but nothing at invocation time points back to it. Print a one-line note on stderr whenever the default projection applies, naming the projected fields and the --fields escape hatch. stdout stays byte-identical, so existing jq/toon pipelines are unaffected. alert list is untouched: it has no default projection (bare --json dumps the full record). Also extend the incident card's projection note with the two facts the projection hides: any list-response field (labels included) is selectable via --fields, and wide fields over many rows will hit the 16 KiB structured-output bound — page with a smaller --limit or use insight aggregates for distributions. Tests: new execCommandSplit captures stdout/stderr separately; the projection tests now assert stdout stays pure JSON while the note lands on stderr. --- internal/cli/alert_event.go | 2 ++ internal/cli/command_test.go | 22 ++++++++++++++++++++++ internal/cli/fieldproject.go | 11 +++++++++++ internal/cli/fieldproject_test.go | 21 +++++++++++++++------ internal/cli/incident.go | 4 ++++ skills/flashduty/reference/incident.md | 2 +- 6 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/cli/alert_event.go b/internal/cli/alert_event.go index dbb7024..f73dc7c 100644 --- a/internal/cli/alert_event.go +++ b/internal/cli/alert_event.go @@ -82,6 +82,8 @@ func newAlertEventListCmd() *cobra.Command { fieldNames := []string{"event_id", "alert_id", "event_severity", "event_status", "event_time", "title"} if fields != "" { fieldNames = parseStringSlice(fields) + } else { + noteDefaultProjection(cmd.ErrOrStderr(), fieldNames) } proj, err := projectFields(result.Items, fieldNames) if err != nil { diff --git a/internal/cli/command_test.go b/internal/cli/command_test.go index 65e0e70..23cf73b 100644 --- a/internal/cli/command_test.go +++ b/internal/cli/command_test.go @@ -84,6 +84,28 @@ func execCommand(args ...string) (string, error) { return buf.String(), err } +// execCommandSplit is execCommand with stdout and stderr captured separately, +// for tests that assert machine-readable stdout stays pure while advisory +// notices (e.g. the default-projection note) land on stderr. +func execCommandSplit(args ...string) (stdout, stderr string, err error) { + resetCommandFlags(rootCmd) + + outBuf := new(bytes.Buffer) + errBuf := new(bytes.Buffer) + rootCmd.SetOut(outBuf) + rootCmd.SetErr(errBuf) + rootCmd.SetArgs(args) + + err = rootCmd.Execute() + + rootCmd.SetArgs(nil) + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + resetCommandFlags(rootCmd) + + return outBuf.String(), errBuf.String(), err +} + func resetCommandFlags(cmd *cobra.Command) { if cmd == nil { return diff --git a/internal/cli/fieldproject.go b/internal/cli/fieldproject.go index 7bcb12c..02a7a63 100644 --- a/internal/cli/fieldproject.go +++ b/internal/cli/fieldproject.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "io" "reflect" "sort" "strings" @@ -69,6 +70,16 @@ func projectFields(items any, fields []string) ([]map[string]any, error) { return out, nil } +// noteDefaultProjection announces on stderr that structured rows were reduced +// to the command's compact default projection. Without it, a reader piping +// stdout to jq sees an unselected key (labels, description, …) as null on +// every row and can conclude the server never returns it, when it is one +// --fields away. stderr keeps stdout byte-identical for jq/toon pipelines. +func noteDefaultProjection(w io.Writer, fields []string) { + _, _ = fmt.Fprintf(w, "note: rows projected to default compact fields (%s); other response fields are available via --fields\n", + strings.Join(fields, ",")) +} + // boundProjectedOutput keeps the new agent-oriented projections below their // command budget without changing the selected keys. Short values remain byte // identical; when retained strings alone would overflow the actual JSON/TOON diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index 01d847b..bf17d8a 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -118,12 +118,15 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { stub := newGFStub(t) stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} - out, err := execCommand("incident", "list", "--output-format", "json") + out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } assertProjectedJSONFields(t, out, []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"}) + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } }) t.Run("toon default", func(t *testing.T) { @@ -394,13 +397,16 @@ func TestIncidentSimilarStructuredProjection(t *testing.T) { } stub.data = map[string]any{"items": items, "total": len(items)} - out, err := execCommand("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") + out, stderrText, err := execCommandSplit("incident", "similar", "inc-1", "--limit", "20", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } if len(out) >= 16*1024 { t.Fatalf("compact similar output is %d bytes, want <16 KiB", len(out)) } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } var rows []map[string]json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { @@ -482,13 +488,16 @@ func TestAlertEventListStructuredProjection(t *testing.T) { } stub.data = map[string]any{"items": items, "total": len(items)} - out, err := execCommand("alert-event", "list", "--limit", "30", "--output-format", "json") + out, stderrText, err := execCommandSplit("alert-event", "list", "--limit", "30", "--output-format", "json") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } if len(out) >= 16*1024 { t.Fatalf("compact alert-event output is %d bytes, want <16 KiB", len(out)) } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } var rows []map[string]json.RawMessage if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &rows); err != nil { t.Fatalf("parse compact alert-event json: %v\n%s", err, out) diff --git a/internal/cli/incident.go b/internal/cli/incident.go index 23dcdf5..5deb072 100644 --- a/internal/cli/incident.go +++ b/internal/cli/incident.go @@ -119,6 +119,8 @@ func newIncidentListCmd() *cobra.Command { if len(selectedFields) == 0 { return fmt.Errorf("--fields must name at least one field") } + } else { + noteDefaultProjection(cmd.ErrOrStderr(), selectedFields) } proj, err := projectFields(result.Items, selectedFields) if err != nil { @@ -604,6 +606,8 @@ func newIncidentSimilarCmd() *cobra.Command { fieldNames := []string{"incident_id", "title", "incident_severity", "progress", "start_time", "close_time", "ack_time", "alert_cnt", "root_cause", "score"} if fields != "" { fieldNames = parseStringSlice(fields) + } else { + noteDefaultProjection(cmd.ErrOrStderr(), fieldNames) } proj, err := projectFields(result.Items, fieldNames) if err != nil { diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 9c08daf..38af6d0 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. ## Hot flow — full fault analysis (read-only summary) From 9b1bebfe92afec067bc87f6f1062e8136e2b033f Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:38:42 -0700 Subject: [PATCH 2/3] docs(skills): carve alerts out of the selectable-fields note The alerts field appears in list/detail response shapes but no read endpoint ever fills it; an incident's alerts come only from the dedicated incident alerts command. Saying every response field is selectable via --fields would invite --fields alerts and an always-empty column. --- skills/flashduty/reference/incident.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/flashduty/reference/incident.md b/skills/flashduty/reference/incident.md index 38af6d0..1cdbb0f 100644 --- a/skills/flashduty/reference/incident.md +++ b/skills/flashduty/reference/incident.md @@ -75,7 +75,7 @@ Projected `similar` lists stay below 16 KiB, and projected `detail --fields` out `comment` never accepts the text as a command-line argument — only `--comment-file ` (or `--comment-file -` to read stdin), so backticks/`$()`/quotes inside the comment are inert. The command also reads back every target's timeline after writing and exits non-zero unless it finds an entry matching what it sent, so `Commented on ...` is proof of content fidelity, not just acceptance — no separate manual read-back is needed. Leading and trailing whitespace is stripped before sending (the server strips it too, so this is what gets stored); everything else, including interior blank lines, is preserved exactly. -> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. +> `incident list --output-format json|toon` defaults to the compact row projection `incident_id,title,incident_severity,progress,start_time,channel_id`. Pass `--fields incident_id,title,channel_id,start_time` when you need different list columns; use `incident detail ` / `incident get ` for full incident records. Any list-response field — including `labels` — is selectable this way (a key missing from the output means it wasn't selected, NOT that the server omits it; the command prints a stderr note when the default projection applies). The one exception is `alerts`: neither list nor detail responses ever fill it — use `incident alerts ` for an incident's alerts. Wide fields over many rows can exceed the 16 KiB structured-output bound and the command errors with "request fewer rows or fields" — lower `--limit`/page through, or use `insight` aggregates for distributions instead of dumping labels row by row. ## Hot flow — full fault analysis (read-only summary) From 445bd1205c5345b95e34f995b20cc9c700b7d5f4 Mon Sep 17 00:00:00 2001 From: ysyneu Date: Thu, 6 Aug 2026 02:41:53 -0700 Subject: [PATCH 3/3] test(cli): assert toon default projection against stdout only; alert card parity The stderr note embeds the default field names, so the merged-capture toon subtest's positive assertions were satisfied by the note alone. Switch it to the split capture and check the note on stderr, mirroring the json subtest. Also bring alert.md's alert-event list section to parity with the incident card: name the default compact projection and the --fields escape hatch. --- internal/cli/fieldproject_test.go | 9 +++++++-- skills/flashduty/reference/alert.md | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/cli/fieldproject_test.go b/internal/cli/fieldproject_test.go index bf17d8a..8bbf251 100644 --- a/internal/cli/fieldproject_test.go +++ b/internal/cli/fieldproject_test.go @@ -134,16 +134,21 @@ func TestIncidentListStructuredDefaultUsesCompactProjection(t *testing.T) { stub := newGFStub(t) stub.data = map[string]any{"items": []any{incidentRow()}, "total": 1} - out, err := execCommand("incident", "list", "--output-format", "toon") + out, stderrText, err := execCommandSplit("incident", "list", "--output-format", "toon") if err != nil { - t.Fatalf("execCommand: %v", err) + t.Fatalf("execCommandSplit: %v", err) } + // Positive keys must come from stdout alone: the stderr note embeds the + // same field names, so a merged capture would satisfy this vacuously. for _, key := range []string{"incident_id", "title", "incident_severity", "progress", "start_time", "channel_id"} { if !strings.Contains(out, key) { t.Errorf("default toon output missing compact key %q, got:\n%s", key, out) } } + if !strings.Contains(stderrText, "note: rows projected to default compact fields") { + t.Errorf("default projection should announce itself on stderr, got:\n%s", stderrText) + } for _, key := range []string{"responders", "labels", "description"} { if strings.Contains(out, key) { t.Errorf("default toon output should not contain full-record key %q, got:\n%s", key, out) diff --git a/skills/flashduty/reference/alert.md b/skills/flashduty/reference/alert.md index 37015b2..35670ca 100644 --- a/skills/flashduty/reference/alert.md +++ b/skills/flashduty/reference/alert.md @@ -38,7 +38,7 @@ fduty alert feed --output-format toon fduty alert-event list --channel --since 1h --limit 30 --output-format toon ``` -Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. +Structured `alert-event list` output stays below 16 KiB. A trailing `...` means a long retained string was shortened. In json/toon mode rows default to the compact projection `event_id,alert_id,event_severity,event_status,event_time,title` (a stderr note says so when it applies); any other response field is one `--fields` away — a key missing from the output means it wasn't selected, not that the server omits it. ## Hot flow — merge noisy alerts into an existing incident