Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
2209ce3
feat(friction): add detectors and provenance
salmonumbrella Sep 23, 2026
3b5e627
feat(signals): expose retry runs as spans
salmonumbrella Sep 23, 2026
53890c0
feat(signals): expose edit churn files as spans
salmonumbrella Sep 23, 2026
5dd6f4d
feat(signals): expose runaway loop span and high pressure threshold
salmonumbrella Sep 23, 2026
192b81b
feat(friction): synthesize tool envelopes and noise names
salmonumbrella Sep 23, 2026
ea89b64
feat(friction): extract text-only assistant content
salmonumbrella Sep 23, 2026
d682986
fix(friction): keep unmatched thinking blocks
salmonumbrella Sep 23, 2026
1c91707
feat(friction): derive seats from configured path patterns
salmonumbrella Sep 23, 2026
9fa67ea
feat(friction): map archived rows into session input
salmonumbrella Sep 23, 2026
b3359f9
feat(friction): detect pattern kinds from reused signals
salmonumbrella Sep 23, 2026
a111e2e
fix(friction): omit unknown pattern time ranges
salmonumbrella Sep 23, 2026
7c8b372
feat(friction): add frustration and interruption kinds and review a s…
salmonumbrella Sep 23, 2026
3d8da47
test(friction): cover the adapter on archived Claude rows
salmonumbrella Sep 23, 2026
50a9c2e
test(friction): distinguish compact boundaries in archived review
salmonumbrella Sep 23, 2026
b412706
fix(signals): avoid nullable edit churn map values
salmonumbrella Sep 23, 2026
d7cc450
style(tests): apply lint autofixes
salmonumbrella Sep 23, 2026
ee65689
chore: keep local SDD report out of branch
salmonumbrella Sep 23, 2026
3ac41b9
fix(friction): strip thinking before tool renderings
salmonumbrella Sep 23, 2026
b833b2f
feat(db): add friction findings schema and session columns
salmonumbrella Sep 23, 2026
6b549db
feat(db): store friction findings with the session signal write
salmonumbrella Sep 23, 2026
5b8d887
test(db): keep friction tables optional for read-only opens
salmonumbrella Sep 23, 2026
a1edce9
feat(db): carry friction findings through resync copies
salmonumbrella Sep 23, 2026
747cf9a
feat(sync): map stored sessions onto the friction detectors
salmonumbrella Sep 23, 2026
efad5c1
feat(sync): compute friction findings on every session write
salmonumbrella Sep 23, 2026
c585afd
feat(sync): backfill friction findings on the reconcile tick
salmonumbrella Sep 23, 2026
c13ea24
feat(postgres): add friction tables and session columns
salmonumbrella Sep 23, 2026
94ac8d1
feat(friction): fold observed spend and recurrence costs
salmonumbrella Sep 23, 2026
52ad51c
feat(postgres): push friction findings with their session
salmonumbrella Sep 23, 2026
8904697
feat(friction): summarize trailing-week archive spend
salmonumbrella Sep 23, 2026
2376ac3
feat(friction): persona rollup with display keys
salmonumbrella Sep 23, 2026
81da5a3
feat(friction): render the friction log digest markdown
salmonumbrella Sep 23, 2026
fc40265
docs(friction): describe digest renderer deltas
salmonumbrella Sep 23, 2026
2f78b25
feat(friction): render summary JSON v3 and human summary
salmonumbrella Sep 23, 2026
d728c88
feat(friction): resolve the digest time zone
salmonumbrella Sep 24, 2026
951c66b
test(friction): pin digest and summary JSON goldens
salmonumbrella Sep 24, 2026
c840a93
docs(changelog): note persisted friction findings
salmonumbrella Sep 24, 2026
18671ac
chore(friction): satisfy receiver and modernize lint checks
salmonumbrella Sep 24, 2026
7263fce
fix(friction): sanitize spend names and guard model limits
salmonumbrella Sep 24, 2026
5ceb63d
fix(friction): satisfy digest lint gates
salmonumbrella Sep 24, 2026
405dc80
chore(friction): combine local digest and finding bases
salmonumbrella Sep 24, 2026
1925ed7
feat(config): add friction review settings on by default
salmonumbrella Sep 24, 2026
4e6e86c
feat(db): store friction digests, membership and recurrence
salmonumbrella Sep 24, 2026
d618d16
feat(db): friction usage and archive spend from native usage
salmonumbrella Sep 24, 2026
03d0096
feat(postgres): friction review state with SQLite parity
salmonumbrella Sep 24, 2026
7b82484
feat(db): expose friction review state on every store
salmonumbrella Sep 24, 2026
32f4361
feat(sync): keep friction digests across full resync
salmonumbrella Sep 24, 2026
cc39760
feat(friction): review snapshot codec and spend fold
salmonumbrella Sep 24, 2026
a474c29
feat(friction): build dated digests from stored findings
salmonumbrella Sep 24, 2026
9d729f9
feat(friction): catch up complete local dates
salmonumbrella Sep 24, 2026
704dc2e
feat(friction): schedule the friction-review catch-up job
salmonumbrella Sep 24, 2026
0d09fe2
test(frontend): include friction capability in version fixtures
salmonumbrella Sep 24, 2026
4f5d7be
test(postgres): include review tables in schema probes
salmonumbrella Sep 24, 2026
5c42047
fix(friction): close PG rows per batch and satisfy lint
salmonumbrella Sep 24, 2026
02ab973
docs(friction): document dated digests and review state
salmonumbrella Sep 24, 2026
b508e19
fix(friction): make digest state guards explicit
salmonumbrella Sep 24, 2026
72d6835
feat(db): list friction findings, digests and patterns
salmonumbrella Sep 24, 2026
876c67f
feat(friction): resolve review run requests
salmonumbrella Sep 24, 2026
d5697e6
feat(server): friction log API routes
salmonumbrella Sep 24, 2026
bf889f2
feat(service): friction digest and pattern reads
salmonumbrella Sep 24, 2026
d00b39b
feat(cli): add friction run, digest, findings and patterns
salmonumbrella Sep 24, 2026
e88defb
feat(mcp): add read-only friction digest and pattern tools
salmonumbrella Sep 24, 2026
947f80f
docs: add friction log reader guide and command reference
salmonumbrella Sep 24, 2026
0d0343f
fix(cli): pair friction format and json flags
salmonumbrella Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ func newRootCommand() *cobra.Command {
root.AddCommand(newMCPCommand())
root.AddCommand(newRecallCommand())
root.AddCommand(newInsightCommand())
root.AddCommand(newFrictionCommand())
root.AddCommand(newStatsCommand())
root.AddCommand(newParseDiffCommand())
root.AddCommand(newClassifierCommand())
Expand Down
296 changes: 296 additions & 0 deletions cmd/agentsview/friction.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
package main

import (
"encoding/json/jsontext"
"encoding/json/v2"
"errors"
"fmt"
"io"
"slices"
"strconv"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"

"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/friction/review"
)

func newFrictionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "friction",
Short: "Build and read the Friction Log (heuristic session review)",
GroupID: groupData,
SilenceUsage: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmd.Help()
},
}
cmd.PersistentFlags().String("server", "", "Remote daemon URL for friction API requests")
cmd.PersistentFlags().String("server-token-file", "",
"File containing bearer token for explicit --server requests")
cmd.AddCommand(newFrictionRunCommand())
cmd.AddCommand(newFrictionDigestCommand())
cmd.AddCommand(newFrictionFindingsCommand())
cmd.AddCommand(newFrictionPatternsCommand())
return cmd
}

func newFrictionRunCommand() *cobra.Command {
var req review.RunRequest
var asJSON bool
var format string
cmd := &cobra.Command{
Use: "run",
Short: "Build missing digests, or one date with --date",
Long: "Without flags, builds every complete local date that has no digest yet\n" +
"(within [friction] backfill_days). --date builds one date; --rebuild\n" +
"re-renders an existing one; --dry-run computes without writing.",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if format != "human" && format != "json" {
return errors.New("--format must be human or json")
}
backend, cleanup, err := resolveFrictionBackend(cmd)
if err != nil {
return err
}
defer cleanup()
outcomes, err := backend.Run(cmd.Context(), req)
if err != nil {
return err
}
w := cmd.OutOrStdout()
if asJSON || format == "json" {
for _, o := range outcomes {
if _, err := w.Write(o.SummaryJSON); err != nil {
return err
}
}
return nil
}
if len(outcomes) == 0 {
_, err := fmt.Fprintln(w, "No complete dates need a digest.")
return err
}
for _, o := range outcomes {
human := o.Human
if !strings.HasSuffix(human, "\n") {
human += "\n"
}
if _, err := io.WriteString(w, human); err != nil {
return err
}
}
return nil
},
}
cmd.Flags().StringVar(&req.Date, "date", "", "Digest date (YYYY-MM-DD)")
cmd.Flags().BoolVar(&req.Rebuild, "rebuild", false, "Rebuild an existing digest, keeping its membership")
cmd.Flags().BoolVar(&req.DryRun, "dry-run", false, "Compute and render without writing")
cmd.Flags().StringVar(&format, "format", "human", "Output format: human or json")
cmd.Flags().BoolVar(&asJSON, "json", false, "Print the summary JSON object instead of the human summary")
return cmd
}

func newFrictionDigestCommand() *cobra.Command {
var date, from, to, format string
var asJSON bool
cmd := &cobra.Command{
Use: "digest",
Short: "Print a stored digest (Markdown or summary JSON)",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if format != "md" && format != "json" {
return errors.New("--format must be md or json")
}
if date != "" && (from != "" || to != "") {
return errors.New("--date cannot be combined with --from/--to")
}
backend, cleanup, err := resolveFrictionBackend(cmd)
if err != nil {
return err
}
defer cleanup()
w := cmd.OutOrStdout()
if from != "" || to != "" {
items, err := backend.Digests(cmd.Context(), from, to)
if err != nil {
return err
}
for _, item := range slices.Backward(items) { // oldest first
out, err := backend.Digest(cmd.Context(), item.Date)
if err != nil {
return err
}
payload := out.Markdown
if asJSON || format == "json" {
payload = out.SummaryJSON
}
if _, err := w.Write(payload); err != nil {
return err
}
}
return nil
}
out, err := backend.Digest(cmd.Context(), date)
if errors.Is(err, errFrictionDigestMissing) {
if date == "" {
return errors.New("no friction digests have been built yet; run `agentsview friction run`")
}
return fmt.Errorf("no friction digest for %s", date)
}
if err != nil {
return err
}
payload := out.Markdown
if asJSON || format == "json" {
payload = out.SummaryJSON
}
_, err = w.Write(payload)
return err
},
}
cmd.Flags().StringVar(&date, "date", "", "Digest date (YYYY-MM-DD); default is the latest digest")
cmd.Flags().StringVar(&from, "from", "", "First date of a range (YYYY-MM-DD)")
cmd.Flags().StringVar(&to, "to", "", "Last date of a range (YYYY-MM-DD)")
cmd.Flags().StringVar(&format, "format", "md", "Output format: md or json")
cmd.Flags().BoolVar(&asJSON, "json", false, "Print summary JSON (alias for --format json)")
return cmd
}

func newFrictionFindingsCommand() *cobra.Command {
var f db.FrictionFindingFilter
var format string
var asJSON bool
cmd := &cobra.Command{
Use: "findings",
Short: "List stored friction findings",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if format != "table" && format != "json" {
return errors.New("--format must be table or json")
}
backend, cleanup, err := resolveFrictionBackend(cmd)
if err != nil {
return err
}
defer cleanup()
page, err := backend.Findings(cmd.Context(), f)
if err != nil {
return err
}
if asJSON || format == "json" {
return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page)
}
return printFrictionFindings(cmd.OutOrStdout(), page)
},
}
cmd.Flags().StringVar(&f.Date, "date", "", "Only sessions in this digest date (YYYY-MM-DD)")
cmd.Flags().StringVar(&f.Kind, "kind", "", "correction, error, workaround, deferral, pattern, frustration or interruption")
cmd.Flags().StringVar(&f.SessionID, "session", "", "Session ID")
cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)")
cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page")
cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json")
cmd.Flags().BoolVar(&asJSON, "json", false, "Print JSON (alias for --format json)")
return cmd
}

func newFrictionPatternsCommand() *cobra.Command {
var f db.FrictionPatternFilter
var linked, unlinked bool
var format string
var asJSON bool
cmd := &cobra.Command{
Use: "patterns",
Short: "List recurring friction patterns",
Args: cobra.NoArgs,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, _ []string) error {
if format != "table" && format != "json" {
return errors.New("--format must be table or json")
}
switch {
case linked:
f.LinkState = db.FrictionLinkStateLinked
case unlinked:
f.LinkState = db.FrictionLinkStateUnlinked
}
backend, cleanup, err := resolveFrictionBackend(cmd)
if err != nil {
return err
}
defer cleanup()
page, err := backend.Patterns(cmd.Context(), f)
if err != nil {
return err
}
if asJSON || format == "json" {
return json.MarshalEncode(jsontext.NewEncoder(cmd.OutOrStdout()), page)
}
return printFrictionPatterns(cmd.OutOrStdout(), page)
},
}
cmd.Flags().StringVar(&f.Kind, "kind", "", "correction, error, workaround, deferral, pattern, frustration or interruption")
cmd.Flags().StringVar(&f.Since, "since", "", "Only patterns last seen on or after this date (YYYY-MM-DD)")
cmd.Flags().BoolVar(&linked, "linked", false, "Only patterns linked to a Kata issue")
cmd.Flags().BoolVar(&unlinked, "unlinked", false, "Only patterns without a Kata issue")
cmd.MarkFlagsMutuallyExclusive("linked", "unlinked")
cmd.Flags().IntVar(&f.Limit, "limit", 0, "Page size (default 100, max 1000)")
cmd.Flags().StringVar(&f.Cursor, "cursor", "", "Cursor from a previous page")
cmd.Flags().StringVar(&format, "format", "table", "Output format: table or json")
cmd.Flags().BoolVar(&asJSON, "json", false, "Print JSON (alias for --format json)")
return cmd
}

func printFrictionFindings(w io.Writer, page frictionFindingsPage) error {
if len(page.Findings) == 0 {
_, err := fmt.Fprintln(w, "(no friction findings)")
return err
}
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "SESSION\tMSG\tKIND\tDETECTOR\tTITLE")
for _, f := range page.Findings {
msg := "-"
if f.MessageOrdinal != nil {
msg = strconv.Itoa(*f.MessageOrdinal)
}
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", sanitizeTerminal(f.SessionID), msg,
sanitizeTerminal(f.Kind), sanitizeTerminal(f.Detector), sanitizeTerminal(f.Title))
}
if err := tw.Flush(); err != nil {
return err
}
if page.NextCursor != "" {
_, err := fmt.Fprintf(w, "next page: --cursor %s\n", page.NextCursor)
return err
}
return nil
}

func printFrictionPatterns(w io.Writer, page frictionPatternsPage) error {
if len(page.Patterns) == 0 {
_, err := fmt.Fprintln(w, "(no friction patterns)")
return err
}
tw := tabwriter.NewWriter(w, 0, 2, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "COUNT\tSESSIONS\tLAST SEEN\tKIND\tFINGERPRINT\tTITLE")
for _, p := range page.Patterns {
_, _ = fmt.Fprintf(tw, "%d\t%d\t%s\t%s\t%s\t%s\n", p.OccurrenceCount, p.SessionCount,
sanitizeTerminal(p.LastSeenDate), sanitizeTerminal(p.Kind),
sanitizeTerminal(p.Fingerprint), sanitizeTerminal(p.Title))
}
if err := tw.Flush(); err != nil {
return err
}
if page.NextCursor != "" {
_, err := fmt.Fprintf(w, "next page: --cursor %s\n", page.NextCursor)
return err
}
return nil
}
Loading