Lets add user reputation scoring to this, the user should start with a neutral reputation and gradually get worse when they submit a bad teleport request (i.e. non descriptive request or missing a CUSTX ticket). The reputation values should be configurable and they should also be exported to the open telemetry collector so we can ship them to datadog to publicly shame bad requestors. The state for the user metrics should be as native as possible without the need for external databases (if possible)


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines.
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


Bash

ls -la /Users/steve.briggs/projects/operations/services/televibe/ 2>/dev/null && echo "---" && ls -la /Users/steve.briggs/projects/operations/apps/televibe/ 2>/dev/null

List televibe directory contents


Exit code 2


ls /Users/steve.briggs/projects/operations/services/televibe/ && echo "---" && ls /Users/steve.briggs/projects/operations/apps/televibe/

List televibe directory contents


Error: Exit code 2
lsd: /Users/steve.briggs/projects/operations/services/televibe/: No such file or directory (os error 2).


find /Users/steve.briggs/projects/operations -type d -name "televibe" 2>/dev/null

Find televibe directories


/Users/steve.briggs/projects/operations/apps/televibe
/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe


List televibe app directory


drwxr-xr-x steve.briggs staff 288 B  Wed Apr 22 16:44:47 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 cmd
.rw-r--r-- steve.briggs staff 1.4 KB Wed Apr 22 16:44:47 2026 config.go
.rw-r--r-- steve.briggs staff  25 B  Wed Apr 22 16:44:47 2026 go.mod
.rw-r--r-- steve.briggs staff 4.6 KB Wed Apr 22 16:44:47 2026 README.md
.rw-r--r-- steve.briggs staff 5.5 KB Wed Apr 22 16:44:47 2026 app.go
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 jira
.rw-r--r-- steve.briggs staff 4.0 MB Wed Apr 22 16:44:47 2026 logo.png
.rw-r--r-- steve.briggs staff 1.1 KB Wed Apr 22 16:44:47 2026 televibe.toml.example
drwxr-xr-x steve.briggs staff 192 B  Wed Apr 22 16:44:47 2026 ai
drwxr-xr-x steve.briggs staff 384 B  Wed Apr 22 16:44:47 2026 .
drwxr-xr-x steve.briggs staff 128 B  Wed Apr 22 16:44:47 2026 risk


List subdirectories


/Users/steve.briggs/projects/operations/apps/televibe/cmd/televibe:
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 .
.rw-r--r-- steve.briggs staff 1.1 KB Wed Apr 22 16:44:47 2026 main.go

/Users/steve.briggs/projects/operations/apps/televibe/jira:
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 22 16:44:47 2026 .
.rw-r--r-- steve.briggs staff 4.6 KB Wed Apr 22 16:44:47 2026 itsm.go
drwxr-xr-x steve.briggs staff 384 B  Wed Apr 22 16:44:47 2026 ..

/Users/steve.briggs/projects/operations/apps/televibe/ai:
.rw-r--r-- steve.briggs staff 1.5 KB Wed Apr 22 16:44:47 2026 webhook.go
.rw-r--r-- steve.briggs staff 3.7 KB Wed Apr 22 16:44:47 2026 provider.go
.rw-r--r-- steve.briggs staff 1.8 KB Wed Apr 22 16:44:47 2026 anthropic.go
drwxr-xr-x steve.briggs staff 192 B  Wed Apr 22 16:44:47 2026 .
.rw-r--r-- steve.briggs staff 1.8 KB Wed Apr 22 16:44:47 2026 bedrock.go
drwxr-xr-x steve.briggs staff 384 B  Wed Apr 22 16:44:47 2026 ..

/Users/steve.briggs/projects/operations/apps/televibe/risk:
drwxr-xr-x steve.briggs staff 384 B  Wed Apr 22 16:44:47 2026 ..
.rw-r--r-- steve.briggs staff 931 B  Wed Apr 22 16:44:47 2026 risk.go
drwxr-xr-x steve.briggs staff 128 B  Wed Apr 22 16:44:47 2026 .
.rw-r--r-- steve.briggs staff 2.3 KB Wed Apr 22 16:44:47 2026 score.go


Lets add user reputation scoring to this, the user should start with a neutral reputation and gradually get worse when they submit a bad teleport request (i.e. non descriptive request or missing a CUS…


1	package televibe
2
3	import (
4		"context"
5		"fmt"
6		"log/slog"
7		"sync"
8		"time"
9
10		"github.com/gravitational/teleport/api/client"
11		"github.com/gravitational/teleport/api/types"
12		"golang.org/x/sync/errgroup"
13
14		"televibe/ai"
15		"televibe/jira"
16		"televibe/risk"
17	)
18
19	type App struct {
20		cfg    *Config
21		tele   *client.Client
22		ai     ai.Provider
23		jira   *jira.Client
24		scorer *risk.Scorer
25
26		pending   map[string]string
27		pendingMu sync.Mutex
28	}
29
30	func NewApp(ctx context.Context, cfg *Config) (*App, error) {
31		tele, err := client.New(ctx, client.Config{
32			Addrs: []string{cfg.Teleport.Addr},
33			Credentials: []client.Credentials{
34				client.LoadIdentityFile(cfg.Teleport.IdentityFile),
35			},
36		})
37		if err != nil {
38			return nil, fmt.Errorf("teleport client: %w", err)
39		}
40
41		prov, err := ai.New(cfg.AI)
42		if err != nil {
43			return nil, fmt.Errorf("ai provider: %w", err)
44		}
45
46		jc, err := jira.New(cfg.Jira)
47		if err != nil {
48			return nil, fmt.Errorf("jira client: %w", err)
49		}
50
51		scorer, err := risk.NewScorer(cfg.Risk)
52		if err != nil {
53			return nil, fmt.Errorf("scorer: %w", err)
54		}
55
56		return &App{
57			cfg:     cfg,
58			tele:    tele,
59			ai:      prov,
60			jira:    jc,
61			scorer:  scorer,
62			pending: map[string]string{},
63		}, nil
64	}
65
66	func (a *App) Run(ctx context.Context) error {
67		slog.Info("televibe starting",
68			"provider", a.cfg.AI.Provider,
69			"threshold", a.cfg.Risk.TicketThreshold,
70			"itsm_project", a.cfg.Jira.Project)
71
72		if err := a.rehydrate(ctx); err != nil {
73			return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
74		}
75
76		g, gctx := errgroup.WithContext(ctx)
77		g.Go(func() error { return a.watchRequests(gctx) })
78		g.Go(func() error { return a.pollITSM(gctx) })
79		return g.Wait()
80	}
81
82	func (a *App) rehydrate(ctx context.Context) error {
83		open, err := a.jira.ListOpenTeleportTickets(ctx)
84		if err != nil {
85			return err
86		}
87		a.pendingMu.Lock()
88		for reqID, ticket := range open {
89			a.pending[reqID] = ticket
90		}
91		a.pendingMu.Unlock()
92		slog.Info("rehydrated pending tickets", "count", len(open))
93		return nil
94	}
95
96	func (a *App) watchRequests(ctx context.Context) error {
97		watcher, err := a.tele.NewWatcher(ctx, types.Watch{
98			Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
99		})
100		if err != nil {
101			return fmt.Errorf("start watcher: %w", err)
102		}
103		defer watcher.Close()
104
105		for {
106			select {
107			case <-ctx.Done():
108				return ctx.Err()
109			case <-watcher.Done():
110				return watcher.Error()
111			case ev := <-watcher.Events():
112				if ev.Type != types.OpPut {
113					continue
114				}
115				req, ok := ev.Resource.(types.AccessRequest)
116				if !ok {
117					continue
118				}
119				if req.GetState() != types.RequestState_PENDING {
120					continue
121				}
122				a.pendingMu.Lock()
123				_, held := a.pending[req.GetName()]
124				a.pendingMu.Unlock()
125				if held {
126					continue
127				}
128				go a.handleRequest(ctx, req)
129			}
130		}
131	}
132
133	func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
134		id := req.GetName()
135		log := slog.With("request_id", id, "user", req.GetUser())
136
137		log.Info("scoring access request")
138		result, err := a.scorer.Score(ctx, a.ai, req)
139		if err != nil {
140			log.Error("scoring failed", "err", err)
141			return
142		}
143		log.Info("risk scored",
144			"score", result.Score,
145			"tier", result.Tier.Name,
146			"slang", result.Tier.Slang,
147			"components", result.Components)
148
149		if result.Score < a.cfg.Risk.TicketThreshold {
150			reason := fmt.Sprintf("televibe: %s (score %d, %q) — auto-approved",
151				result.Tier.Name, result.Score, result.Tier.Slang)
152			if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
153				log.Error("auto-approve failed", "err", err)
154			}
155			return
156		}
157
158		ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
159			RequestID:   id,
160			User:        req.GetUser(),
161			Roles:       req.GetRoles(),
162			Reason:      req.GetRequestReason(),
163			Score:       result.Score,
164			Tier:        result.Tier.Name,
165			Slang:       result.Tier.Slang,
166			Components:  result.Components,
167			AIReasoning: result.Reasoning,
168		})
169		if err != nil {
170			log.Error("create ITSM ticket failed", "err", err)
171			return
172		}
173
174		a.pendingMu.Lock()
175		a.pending[id] = ticket
176		a.pendingMu.Unlock()
177		log.Info("ITSM ticket created, request held for SRE review",
178			"ticket", ticket, "tier", result.Tier.Name, "slang", result.Tier.Slang)
179	}
180
181	func (a *App) pollITSM(ctx context.Context) error {
182		t := time.NewTicker(a.cfg.Jira.PollInterval)
183		defer t.Stop()
184		for {
185			select {
186			case <-ctx.Done():
187				return ctx.Err()
188			case <-t.C:
189				a.reconcileITSM(ctx)
190			}
191		}
192	}
193
194	func (a *App) reconcileITSM(ctx context.Context) {
195		a.pendingMu.Lock()
196		snap := make(map[string]string, len(a.pending))
197		for k, v := range a.pending {
198			snap[k] = v
199		}
200		a.pendingMu.Unlock()
201
202		for requestID, ticket := range snap {
203			status, err := a.jira.TicketStatus(ctx, ticket)
204			if err != nil {
205				slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
206				continue
207			}
208			var next types.RequestState
209			switch status {
210			case jira.StatusApproved:
211				next = types.RequestState_APPROVED
212			case jira.StatusRejected:
213				next = types.RequestState_DENIED
214			default:
215				continue
216			}
217			reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
218			if err := a.submitReview(ctx, requestID, next, reason); err != nil {
219				slog.Error("submit review failed", "request_id", requestID, "err", err)
220				continue
221			}
222			a.pendingMu.Lock()
223			delete(a.pending, requestID)
224			a.pendingMu.Unlock()
225		}
226	}
227
228	func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
229		_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
230			RequestID: requestID,
231			Review: types.AccessReview{
232				Author:        "televibe",
233				ProposedState: state,
234				Reason:        reason,
235			},
236		})
237		return err
238	}
239

package televibe

import (
	"context"
	"fmt"
	"log/slog"
	"sync"
	"time"

	"github.com/gravitational/teleport/api/client"
	"github.com/gravitational/teleport/api/types"
	"golang.org/x/sync/errgroup"

	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer

	pending   map[string]string
	pendingMu sync.Mutex
}

func NewApp(ctx context.Context, cfg *Config) (*App, error) {
	tele, err := client.New(ctx, client.Config{
		Addrs: []string{cfg.Teleport.Addr},
		Credentials: []client.Credentials{
			client.LoadIdentityFile(cfg.Teleport.IdentityFile),
		},
	})
	if err != nil {
		return nil, fmt.Errorf("teleport client: %w", err)
	}

	prov, err := ai.New(cfg.AI)
	if err != nil {
		return nil, fmt.Errorf("ai provider: %w", err)
	}

	jc, err := jira.New(cfg.Jira)
	if err != nil {
		return nil, fmt.Errorf("jira client: %w", err)
	}

	scorer, err := risk.NewScorer(cfg.Risk)
	if err != nil {
		return nil, fmt.Errorf("scorer: %w", err)
	}

	return &App{
		cfg:     cfg,
		tele:    tele,
		ai:      prov,
		jira:    jc,
		scorer:  scorer,
		pending: map[string]string{},
	}, nil
}

func (a *App) Run(ctx context.Context) error {
	slog.Info("televibe starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project)

	if err := a.rehydrate(ctx); err != nil {
		return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
	}

	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	return g.Wait()
}

func (a *App) rehydrate(ctx context.Context) error {
	open, err := a.jira.ListOpenTeleportTickets(ctx)
	if err != nil {
		return err
	}
	a.pendingMu.Lock()
	for reqID, ticket := range open {
		a.pending[reqID] = ticket
	}
	a.pendingMu.Unlock()
	slog.Info("rehydrated pending tickets", "count", len(open))
	return nil
}

func (a *App) watchRequests(ctx context.Context) error {
	watcher, err := a.tele.NewWatcher(ctx, types.Watch{
		Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
	})
	if err != nil {
		return fmt.Errorf("start watcher: %w", err)
	}
	defer watcher.Close()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-watcher.Done():
			return watcher.Error()
		case ev := <-watcher.Events():
			if ev.Type != types.OpPut {
				continue
			}
			req, ok := ev.Resource.(types.AccessRequest)
			if !ok {
				continue
			}
			if req.GetState() != types.RequestState_PENDING {
				continue
			}
			a.pendingMu.Lock()
			_, held := a.pending[req.GetName()]
			a.pendingMu.Unlock()
			if held {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
	id := req.GetName()
	log := slog.With("request_id", id, "user", req.GetUser())

	log.Info("scoring access request")
	result, err := a.scorer.Score(ctx, a.ai, req)
	if err != nil {
		log.Error("scoring failed", "err", err)
		return
	}
	log.Info("risk scored",
		"score", result.Score,
		"tier", result.Tier.Name,
		"slang", result.Tier.Slang,
		"components", result.Components)

	if result.Score < a.cfg.Risk.TicketThreshold {
		reason := fmt.Sprintf("televibe: %s (score %d, %q) — auto-approved",
			result.Tier.Name, result.Score, result.Tier.Slang)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
		}
		return
	}

	ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
		RequestID:   id,
		User:        req.GetUser(),
		Roles:       req.GetRoles(),
		Reason:      req.GetRequestReason(),
		Score:       result.Score,
		Tier:        result.Tier.Name,
		Slang:       result.Tier.Slang,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.pendingMu.Lock()
	a.pending[id] = ticket
	a.pendingMu.Unlock()
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name, "slang", result.Tier.Slang)
}

func (a *App) pollITSM(ctx context.Context) error {
	t := time.NewTicker(a.cfg.Jira.PollInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-t.C:
			a.reconcileITSM(ctx)
		}
	}
}

func (a *App) reconcileITSM(ctx context.Context) {
	a.pendingMu.Lock()
	snap := make(map[string]string, len(a.pending))
	for k, v := range a.pending {
		snap[k] = v
	}
	a.pendingMu.Unlock()

	for requestID, ticket := range snap {
		status, err := a.jira.TicketStatus(ctx, ticket)
		if err != nil {
			slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
			continue
		}
		var next types.RequestState
		switch status {
		case jira.StatusApproved:
			next = types.RequestState_APPROVED
		case jira.StatusRejected:
			next = types.RequestState_DENIED
		default:
			continue
		}
		reason := fmt.Sprintf("televibe: ITSM %s resolved (%s)", ticket, status)
		if err := a.submitReview(ctx, requestID, next, reason); err != nil {
			slog.Error("submit review failed", "request_id", requestID, "err", err)
			continue
		}
		a.pendingMu.Lock()
		delete(a.pending, requestID)
		a.pendingMu.Unlock()
	}
}

func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "televibe",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}


1	package televibe
2
3	import (
4		"fmt"
5		"os"
6
7		"github.com/pelletier/go-toml/v2"
8
9		"televibe/ai"
10		"televibe/jira"
11		"televibe/risk"
12	)
13
14	type Config struct {
15		Teleport TeleportConfig `toml:"teleport"`
16		AI       ai.Config      `toml:"ai"`
17		Jira     jira.Config    `toml:"jira"`
18		Risk     risk.Config    `toml:"risk"`
19		Log      LogConfig      `toml:"log"`
20	}
21
22	type TeleportConfig struct {
23		Addr         string `toml:"addr"`
24		IdentityFile string `toml:"identity_file"`
25	}
26
27	type LogConfig struct {
28		Level string `toml:"level"`
29	}
30
31	func LoadConfig(path string) (*Config, error) {
32		data, err := os.ReadFile(path)
33		if err != nil {
34			return nil, fmt.Errorf("read config: %w", err)
35		}
36		cfg := &Config{}
37		if err := toml.Unmarshal(data, cfg); err != nil {
38			return nil, fmt.Errorf("parse config: %w", err)
39		}
40		cfg.applyDefaults()
41		if err := cfg.validate(); err != nil {
42			return nil, err
43		}
44		return cfg, nil
45	}
46
47	func (c *Config) applyDefaults() {
48		c.Jira.ApplyDefaults()
49		c.Risk.ApplyDefaults()
50		if c.Log.Level == "" {
51			c.Log.Level = "info"
52		}
53	}
54
55	func (c *Config) validate() error {
56		if c.Teleport.Addr == "" {
57			return fmt.Errorf("teleport.addr is required")
58		}
59		if c.Teleport.IdentityFile == "" {
60			return fmt.Errorf("teleport.identity_file is required")
61		}
62		if err := c.AI.Validate(); err != nil {
63			return fmt.Errorf("ai: %w", err)
64		}
65		if err := c.Jira.Validate(); err != nil {
66			return fmt.Errorf("jira: %w", err)
67		}
68		return nil
69	}
70

package televibe

import (
	"fmt"
	"os"

	"github.com/pelletier/go-toml/v2"

	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type Config struct {
	Teleport TeleportConfig `toml:"teleport"`
	AI       ai.Config      `toml:"ai"`
	Jira     jira.Config    `toml:"jira"`
	Risk     risk.Config    `toml:"risk"`
	Log      LogConfig      `toml:"log"`
}

type TeleportConfig struct {
	Addr         string `toml:"addr"`
	IdentityFile string `toml:"identity_file"`
}

type LogConfig struct {
	Level string `toml:"level"`
}

func LoadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config: %w", err)
	}
	cfg := &Config{}
	if err := toml.Unmarshal(data, cfg); err != nil {
		return nil, fmt.Errorf("parse config: %w", err)
	}
	cfg.applyDefaults()
	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) applyDefaults() {
	c.Jira.ApplyDefaults()
	c.Risk.ApplyDefaults()
	if c.Log.Level == "" {
		c.Log.Level = "info"
	}
}

func (c *Config) validate() error {
	if c.Teleport.Addr == "" {
		return fmt.Errorf("teleport.addr is required")
	}
	if c.Teleport.IdentityFile == "" {
		return fmt.Errorf("teleport.identity_file is required")
	}
	if err := c.AI.Validate(); err != nil {
		return fmt.Errorf("ai: %w", err)
	}
	if err := c.Jira.Validate(); err != nil {
		return fmt.Errorf("jira: %w", err)
	}
	return nil
}


1	package risk
2
3	type Config struct {
4		TicketThreshold int    `toml:"ticket_threshold"`
5		CUSTXPattern    string `toml:"custx_pattern"`
6		OffHoursStart   int    `toml:"off_hours_start"`
7		OffHoursEnd     int    `toml:"off_hours_end"`
8		Timezone        string `toml:"timezone"`
9	}
10
11	func (c *Config) ApplyDefaults() {
12		if c.TicketThreshold == 0 {
13			c.TicketThreshold = 25
14		}
15		if c.CUSTXPattern == "" {
16			c.CUSTXPattern = `CUSTX-\d+`
17		}
18		if c.Timezone == "" {
19			c.Timezone = "UTC"
20		}
21	}
22
23	type Tier struct {
24		Name     string
25		Slang    string
26		MinScore int
27	}
28
29	var Tiers = []Tier{
30		{Name: "low", Slang: "no cap", MinScore: 0},
31		{Name: "medium", Slang: "sus", MinScore: 25},
32		{Name: "high", Slang: "mad sus fr", MinScore: 50},
33		{Name: "critical", Slang: "straight L, touch grass", MinScore: 75},
34	}
35
36	func TierForScore(score int) Tier {
37		chosen := Tiers[0]
38		for _, t := range Tiers {
39			if score >= t.MinScore {
40				chosen = t
41			}
42		}
43		return chosen
44	}
45

package risk

type Config struct {
	TicketThreshold int    `toml:"ticket_threshold"`
	CUSTXPattern    string `toml:"custx_pattern"`
	OffHoursStart   int    `toml:"off_hours_start"`
	OffHoursEnd     int    `toml:"off_hours_end"`
	Timezone        string `toml:"timezone"`
}

func (c *Config) ApplyDefaults() {
	if c.TicketThreshold == 0 {
		c.TicketThreshold = 25
	}
	if c.CUSTXPattern == "" {
		c.CUSTXPattern = `CUSTX-\d+`
	}
	if c.Timezone == "" {
		c.Timezone = "UTC"
	}
}

type Tier struct {
	Name     string
	Slang    string
	MinScore int
}

var Tiers = []Tier{
	{Name: "low", Slang: "no cap", MinScore: 0},
	{Name: "medium", Slang: "sus", MinScore: 25},
	{Name: "high", Slang: "mad sus fr", MinScore: 50},
	{Name: "critical", Slang: "straight L, touch grass", MinScore: 75},
}

func TierForScore(score int) Tier {
	chosen := Tiers[0]
	for _, t := range Tiers {
		if score >= t.MinScore {
			chosen = t
		}
	}
	return chosen
}


1	package risk
2
3	import (
4		"context"
5		"regexp"
6		"time"
7
8		"github.com/gravitational/teleport/api/types"
9
10		"televibe/ai"
11	)
12
13	type Result struct {
14		Score      int
15		Tier       Tier
16		Components map[string]int
17		Reasoning  string
18	}
19
20	type Scorer struct {
21		cfg        Config
22		custxRegex *regexp.Regexp
23		loc        *time.Location
24	}
25
26	func NewScorer(cfg Config) (*Scorer, error) {
27		re, err := regexp.Compile(cfg.CUSTXPattern)
28		if err != nil {
29			return nil, err
30		}
31		loc, err := time.LoadLocation(cfg.Timezone)
32		if err != nil {
33			return nil, err
34		}
35		return &Scorer{cfg: cfg, custxRegex: re, loc: loc}, nil
36	}
37
38	func (s *Scorer) Score(ctx context.Context, prov ai.Provider, req types.AccessRequest) (*Result, error) {
39		reason := req.GetRequestReason()
40		hasCUSTX := s.custxRegex.MatchString(reason)
41		descLen := len(reason)
42
43		var resources []string
44		for _, r := range req.GetRequestedResourceIDs() {
45			resources = append(resources, r.Name)
46		}
47
48		assessment, err := prov.Assess(ctx, ai.ScoreRequest{
49			User:              req.GetUser(),
50			Roles:             req.GetRoles(),
51			Resources:         resources,
52			Reason:            reason,
53			HasCUSTXReference: hasCUSTX,
54			DescriptionLength: descLen,
55		})
56		if err != nil {
57			return nil, err
58		}
59
60		components := map[string]int{}
61		score := 0
62
63		if !hasCUSTX {
64			components["missing_custx_ticket"] = 30
65			score += 30
66		}
67		if descLen < 40 {
68			components["thin_description"] = 15
69			score += 15
70		}
71		components["description_quality"] = clamp(assessment.DescriptionQuality, 0, 25)
72		components["privilege_concern"] = clamp(assessment.PrivilegeConcern, 0, 25)
73		components["ai_anomalies"] = clamp(assessment.Anomalies, 0, 10)
74		score += components["description_quality"] + components["privilege_concern"] + components["ai_anomalies"]
75
76		if s.isOffHours(time.Now()) {
77			components["off_hours"] = 10
78			score += 10
79		}
80
81		if score > 100 {
82			score = 100
83		}
84
85		return &Result{
86			Score:      score,
87			Tier:       TierForScore(score),
88			Components: components,
89			Reasoning:  assessment.Reasoning,
90		}, nil
91	}
92
93	func (s *Scorer) isOffHours(t time.Time) bool {
94		if s.cfg.OffHoursStart == 0 && s.cfg.OffHoursEnd == 0 {
95			return false
96		}
97		h := t.In(s.loc).Hour()
98		start, end := s.cfg.OffHoursStart, s.cfg.OffHoursEnd
99		if start <= end {
100			return h >= start && h < end
101		}
102		return h >= start || h < end
103	}
104
105	func clamp(v, lo, hi int) int {
106		if v < lo {
107			return lo
108		}
109		if v > hi {
110			return hi
111		}
112		return v
113	}
114

package risk

import (
	"context"
	"regexp"
	"time"

	"github.com/gravitational/teleport/api/types"

	"televibe/ai"
)

type Result struct {
	Score      int
	Tier       Tier
	Components map[string]int
	Reasoning  string
}

type Scorer struct {
	cfg        Config
	custxRegex *regexp.Regexp
	loc        *time.Location
}

func NewScorer(cfg Config) (*Scorer, error) {
	re, err := regexp.Compile(cfg.CUSTXPattern)
	if err != nil {
		return nil, err
	}
	loc, err := time.LoadLocation(cfg.Timezone)
	if err != nil {
		return nil, err
	}
	return &Scorer{cfg: cfg, custxRegex: re, loc: loc}, nil
}

func (s *Scorer) Score(ctx context.Context, prov ai.Provider, req types.AccessRequest) (*Result, error) {
	reason := req.GetRequestReason()
	hasCUSTX := s.custxRegex.MatchString(reason)
	descLen := len(reason)

	var resources []string
	for _, r := range req.GetRequestedResourceIDs() {
		resources = append(resources, r.Name)
	}

	assessment, err := prov.Assess(ctx, ai.ScoreRequest{
		User:              req.GetUser(),
		Roles:             req.GetRoles(),
		Resources:         resources,
		Reason:            reason,
		HasCUSTXReference: hasCUSTX,
		DescriptionLength: descLen,
	})
	if err != nil {
		return nil, err
	}

	components := map[string]int{}
	score := 0

	if !hasCUSTX {
		components["missing_custx_ticket"] = 30
		score += 30
	}
	if descLen < 40 {
		components["thin_description"] = 15
		score += 15
	}
	components["description_quality"] = clamp(assessment.DescriptionQuality, 0, 25)
	components["privilege_concern"] = clamp(assessment.PrivilegeConcern, 0, 25)
	components["ai_anomalies"] = clamp(assessment.Anomalies, 0, 10)
	score += components["description_quality"] + components["privilege_concern"] + components["ai_anomalies"]

	if s.isOffHours(time.Now()) {
		components["off_hours"] = 10
		score += 10
	}

	if score > 100 {
		score = 100
	}

	return &Result{
		Score:      score,
		Tier:       TierForScore(score),
		Components: components,
		Reasoning:  assessment.Reasoning,
	}, nil
}

func (s *Scorer) isOffHours(t time.Time) bool {
	if s.cfg.OffHoursStart == 0 && s.cfg.OffHoursEnd == 0 {
		return false
	}
	h := t.In(s.loc).Hour()
	start, end := s.cfg.OffHoursStart, s.cfg.OffHoursEnd
	if start <= end {
		return h >= start && h < end
	}
	return h >= start || h < end
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}


1	module televibe
2
3	go 1.22
4

module televibe

go 1.22


ls /Users/steve.briggs/projects/operations/apps/televibe/*.sum 2>/dev/null; find /Users/steve.briggs/projects/operations/apps/televibe -maxdepth 2 -name "go.sum"

Find go.sum file


(eval):1: no matches found: /Users/steve.briggs/projects/operations/apps/televibe/*.sum


1	package main
2
3	import (
4		"context"
5		"errors"
6		"flag"
7		"log/slog"
8		"os"
9		"os/signal"
10		"syscall"
11
12		"televibe"
13	)
14
15	func main() {
16		configPath := flag.String("config", "/etc/televibe/televibe.toml", "Path to TOML configuration")
17		flag.Parse()
18
19		cfg, err := televibe.LoadConfig(*configPath)
20		if err != nil {
21			slog.Error("config load failed", "path", *configPath, "err", err)
22			os.Exit(1)
23		}
24
25		setupLogging(cfg.Log.Level)
26
27		ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
28		defer cancel()
29
30		app, err := televibe.NewApp(ctx, cfg)
31		if err != nil {
32			slog.Error("app init failed", "err", err)
33			os.Exit(1)
34		}
35
36		if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
37			slog.Error("app exited with error", "err", err)
38			os.Exit(1)
39		}
40	}
41
42	func setupLogging(level string) {
43		var l slog.Level
44		switch level {
45		case "debug":
46			l = slog.LevelDebug
47		case "warn":
48			l = slog.LevelWarn
49		case "error":
50			l = slog.LevelError
51		default:
52			l = slog.LevelInfo
53		}
54		h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
55		slog.SetDefault(slog.New(h))
56	}
57

package main

import (
	"context"
	"errors"
	"flag"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"televibe"
)

func main() {
	configPath := flag.String("config", "/etc/televibe/televibe.toml", "Path to TOML configuration")
	flag.Parse()

	cfg, err := televibe.LoadConfig(*configPath)
	if err != nil {
		slog.Error("config load failed", "path", *configPath, "err", err)
		os.Exit(1)
	}

	setupLogging(cfg.Log.Level)

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	app, err := televibe.NewApp(ctx, cfg)
	if err != nil {
		slog.Error("app init failed", "err", err)
		os.Exit(1)
	}

	if err := app.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("app exited with error", "err", err)
		os.Exit(1)
	}
}

func setupLogging(level string) {
	var l slog.Level
	switch level {
	case "debug":
		l = slog.LevelDebug
	case "warn":
		l = slog.LevelWarn
	case "error":
		l = slog.LevelError
	default:
		l = slog.LevelInfo
	}
	h := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: l})
	slog.SetDefault(slog.New(h))
}


1	# televibe — Teleport access plugin with AI risk scoring
2	# Copy to /etc/televibe/televibe.toml and fill in.
3
4	[teleport]
5	addr = "proxy.example.com:443"
6	identity_file = "/var/lib/teleport/televibe.pem"
7
8	[ai]
9	# Exactly one of: "anthropic", "bedrock", "webhook"
10	provider = "anthropic"
11
12	[ai.anthropic]
13	api_key = "sk-ant-..."
14	model = "claude-sonnet-4-6"
15
16	[ai.bedrock]
17	region = "us-west-2"
18	model_id = "us.anthropic.claude-sonnet-4-6-v1:0"
19
20	[ai.webhook]
21	url = "https://chat.internal/score"
22	auth_header = "Bearer replace-me"
23	# [ai.webhook.headers]
24	# X-Tenant = "televibe"
25
26	[jira]
27	url = "https://example.atlassian.net"
28	username = "bot@example.com"
29	api_token = "replace-me"
30	project = "ITSM"
31	issue_type = "Task"
32	poll_interval = "30s"
33	approved_status = "Approved"
34	rejected_status = "Rejected"
35
36	[risk]
37	# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
38	# Anything below is auto-approved as "no cap".
39	ticket_threshold = 25
40	custx_pattern = 'CUSTX-\d+'
41	# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
42	off_hours_start = 18
43	off_hours_end = 8
44	timezone = "America/New_York"
45
46	[log]
47	level = "info"
48

# televibe — Teleport access plugin with AI risk scoring
# Copy to /etc/televibe/televibe.toml and fill in.

[teleport]
addr = "proxy.example.com:443"
identity_file = "/var/lib/teleport/televibe.pem"

[ai]
# Exactly one of: "anthropic", "bedrock", "webhook"
provider = "anthropic"

[ai.anthropic]
api_key = "sk-ant-..."
model = "claude-sonnet-4-6"

[ai.bedrock]
region = "us-west-2"
model_id = "us.anthropic.claude-sonnet-4-6-v1:0"

[ai.webhook]
url = "https://chat.internal/score"
auth_header = "Bearer replace-me"
# [ai.webhook.headers]
# X-Tenant = "televibe"

[jira]
url = "https://example.atlassian.net"
username = "bot@example.com"
api_token = "replace-me"
project = "ITSM"
issue_type = "Task"
poll_interval = "30s"
approved_status = "Approved"
rejected_status = "Rejected"

[risk]
# Requests scoring >= ticket_threshold get an ITSM ticket (and stay PENDING).
# Anything below is auto-approved as "no cap".
ticket_threshold = 25
custx_pattern = 'CUSTX-\d+'
# Off-hours window applies a +10 risk bump. Wraps midnight when start > end.
off_hours_start = 18
off_hours_end = 8
timezone = "America/New_York"

[log]
level = "info"


1

2
3

4
5	# televibe
6
7	A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.
8
9	Risk tiers are reported in gen-z slang because audit logs should spark joy:
10
11	| Score  | Tier     | Slang                     | Action                                 |
12	| ------ | -------- | ------------------------- | -------------------------------------- |
13	| 0–24   | low      | `no cap`                  | auto-approve, no Jira ticket           |
14	| 25–49  | medium   | `sus`                     | file ITSM ticket, hold request pending |
15	| 50–74  | high     | `mad sus fr`              | file ITSM ticket with tier label       |
16	| 75–100 | critical | `straight L, touch grass` | file ITSM ticket with tier label       |
17
18	The ticket-creation threshold is configurable (`risk.ticket_threshold`).
19
20	## How it works
21
22	```
23	Teleport ──► watcher ──► scorer ──► AI provider ──► tier
24	                             │
25	                             ├── below threshold ──► auto-approve via AccessReview
26	                             │
27	                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
28	                                                                              │
29	                                                         SRE approves/rejects │
30	                                                                              ▼
31	                                                              Teleport AccessReview
32	```
33
34	**Risk components:**
35
36	- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
37	- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.
38
39	## AI providers
40
41	Pick one via `ai.provider`:
42
43	- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
44	- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain.
45	- **`webhook`** — generic `POST` of `{system_prompt, user_prompt, request}` to any URL you point it at (ChatGPT custom GPT action, n8n workflow, internal scoring service). The response must be JSON matching the `Assessment` shape.
46
47	## Install
48
49	```sh
50	go mod tidy
51	go build -o televibe ./cmd/televibe
52	```
53
54	## Configure
55
56	Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:
57
58	- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
59	- `[ai]` — pick a provider and supply its credentials.
60	- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
61	- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.
62
63	### Teleport role
64
65	The plugin user needs a role roughly like:
66
67	```yaml
68	kind: role
69	version: v7
70	metadata:
71	  name: televibe-plugin
72	spec:
73	  allow:
74	    rules:
75	      - resources: [access_request]
76	        verbs: [list, read, update]
77	      - resources: [access_plugin_data]
78	        verbs: [update]
79	```
80
81	Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.
82
83	### Jira workflow
84
85	The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).
86
87	## Run
88
89	```sh
90	./televibe --config /etc/televibe/televibe.toml
91	```
92
93	JSON logs to stderr, level from `[log].level`.
94
95	## Layout
96
97	```
98	televibe/
99	├── cmd/televibe/main.go    entry point
100	├── config.go               TOML loader + validation
101	├── app.go                  watcher loop + ITSM poller
102	├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
103	├── risk/                   scorer, tier table, slang
104	├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
105	└── televibe.toml.example
106	```
107
108	## Restart safety
109
110	On startup, `televibe` runs a JQL search for open tickets labelled `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.
111
112	## License
113
114	TBD.
115

# televibe

A [Teleport](https://goteleport.com) access-request plugin that routes each pending request through an AI risk analyst, auto-approves the boring ones, and files an ITSM Jira ticket for the sketchy ones — with the Teleport request held in `PENDING` until an SRE resolves the ticket.

Risk tiers are reported in gen-z slang because audit logs should spark joy:

| Score  | Tier     | Slang                     | Action                                 |
| ------ | -------- | ------------------------- | -------------------------------------- |
| 0–24   | low      | `no cap`                  | auto-approve, no Jira ticket           |
| 25–49  | medium   | `sus`                     | file ITSM ticket, hold request pending |
| 50–74  | high     | `mad sus fr`              | file ITSM ticket with tier label       |
| 75–100 | critical | `straight L, touch grass` | file ITSM ticket with tier label       |

The ticket-creation threshold is configurable (`risk.ticket_threshold`).

## How it works

```
Teleport ──► watcher ──► scorer ──► AI provider ──► tier
                             │
                             ├── below threshold ──► auto-approve via AccessReview
                             │
                             └── at/above threshold ──► Jira ITSM ticket ──► poll status
                                                                              │
                                                         SRE approves/rejects │
                                                                              ▼
                                                              Teleport AccessReview
```

**Risk components:**

- **Structural (deterministic):** missing `CUSTX-###` ticket reference (+30), thin description `<40` chars (+15), off-hours request (+10).
- **AI-judged (clamped):** description quality 0–25, privilege concern 0–25, anomalies 0–10.

## AI providers

Pick one via `ai.provider`:

- **`anthropic`** — direct call to the Anthropic Messages API. System prompt is sent with `cache_control: ephemeral` so repeated scoring hits the prompt cache.
- **`bedrock`** — `bedrockruntime:InvokeModel` against a Claude-on-Bedrock model. Uses the default AWS credential chain.
- **`webhook`** — generic `POST` of `{system_prompt, user_prompt, request}` to any URL you point it at (ChatGPT custom GPT action, n8n workflow, internal scoring service). The response must be JSON matching the `Assessment` shape.

## Install

```sh
go mod tidy
go build -o televibe ./cmd/televibe
```

## Configure

Copy `televibe.toml.example` to `/etc/televibe/televibe.toml` and fill in:

- `[teleport]` — proxy address + path to an identity file for a Teleport user with permission to watch access requests and submit reviews.
- `[ai]` — pick a provider and supply its credentials.
- `[jira]` — API user, token, ITSM project key, and the status names to treat as approved/rejected (e.g. `"Approved"` / `"Rejected"`).
- `[risk]` — threshold, CUSTX pattern (defaults to `CUSTX-\d+`), off-hours window, timezone.

### Teleport role

The plugin user needs a role roughly like:

```yaml
kind: role
version: v7
metadata:
  name: televibe-plugin
spec:
  allow:
    rules:
      - resources: [access_request]
        verbs: [list, read, update]
      - resources: [access_plugin_data]
        verbs: [update]
```

Issue an identity file with `tctl auth sign --user=televibe --out=/var/lib/teleport/televibe.pem --ttl=8760h --format=file`.

### Jira workflow

The ITSM project needs statuses matching `jira.approved_status` and `jira.rejected_status`. When an SRE transitions the ticket to either, the poller reflects that decision back to the Teleport request on its next tick (`jira.poll_interval`, default `30s`).

## Run

```sh
./televibe --config /etc/televibe/televibe.toml
```

JSON logs to stderr, level from `[log].level`.

## Layout

```
televibe/
├── cmd/televibe/main.go    entry point
├── config.go               TOML loader + validation
├── app.go                  watcher loop + ITSM poller
├── ai/                     Provider interface, Anthropic / Bedrock / webhook impls
├── risk/                   scorer, tier table, slang
├── jira/itsm.go            ticket create, status poll, restart rehydrate via JQL
└── televibe.toml.example
```

## Restart safety

On startup, `televibe` runs a JQL search for open tickets labelled `televibe` in the ITSM project and pulls the Teleport request ID out of each ticket description to rebuild the held-request map. Restarts don't duplicate tickets.

## License

TBD.


1	package ai
2
3	import (
4		"context"
5		"encoding/json"
6		"fmt"
7		"regexp"
8		"strings"
9	)
10
11	type Config struct {
12		Provider  string          `toml:"provider"`
13		Anthropic AnthropicConfig `toml:"anthropic"`
14		Bedrock   BedrockConfig   `toml:"bedrock"`
15		Webhook   WebhookConfig   `toml:"webhook"`
16	}
17
18	func (c *Config) Validate() error {
19		switch c.Provider {
20		case "anthropic":
21			if c.Anthropic.APIKey == "" {
22				return fmt.Errorf("anthropic.api_key is required")
23			}
24		case "bedrock":
25			if c.Bedrock.Region == "" {
26				return fmt.Errorf("bedrock.region is required")
27			}
28		case "webhook":
29			if c.Webhook.URL == "" {
30				return fmt.Errorf("webhook.url is required")
31			}
32		default:
33			return fmt.Errorf("provider must be one of: anthropic, bedrock, webhook")
34		}
35		return nil
36	}
37
38	type ScoreRequest struct {
39		User              string   `json:"user"`
40		Roles             []string `json:"roles"`
41		Resources         []string `json:"resources"`
42		Reason            string   `json:"reason"`
43		HasCUSTXReference bool     `json:"has_custx_reference"`
44		DescriptionLength int      `json:"description_length"`
45	}
46
47	type Assessment struct {
48		DescriptionQuality int    `json:"description_quality"`
49		PrivilegeConcern   int    `json:"privilege_concern"`
50		Anomalies          int    `json:"anomalies"`
51		Reasoning          string `json:"reasoning"`
52	}
53
54	type Provider interface {
55		Assess(ctx context.Context, req ScoreRequest) (*Assessment, error)
56	}
57
58	func New(cfg Config) (Provider, error) {
59		switch cfg.Provider {
60		case "anthropic":
61			return NewAnthropic(cfg.Anthropic), nil
62		case "bedrock":
63			return NewBedrock(cfg.Bedrock)
64		case "webhook":
65			return NewWebhook(cfg.Webhook), nil
66		}
67		return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
68	}
69
70	const SystemPrompt = `You are a security analyst reviewing a Teleport access request.
71	Assess the risk of granting the requested access based on the details provided.
72
73	Return ONLY a JSON object with these exact fields (no prose, no markdown fence):
74	{
75	  "description_quality": <int 0-25; 0 = thorough and specific, 25 = absent or vague>,
76	  "privilege_concern": <int 0-25; 0 = routine read access, 25 = broad production admin or destructive capability>,
77	  "anomalies": <int 0-10; flag mismatches between stated purpose and requested scope, or other unusual patterns>,
78	  "reasoning": "<2-3 sentences explaining the scores>"
79	}
80
81	Scoring guidance:
82	- description_quality: does the reason describe WHAT work will be performed and WHY, and does the scope match the work described?
83	- privilege_concern: how large is the blast radius of the requested roles and resources? Production prod-*, root, admin, or destructive tooling score higher.
84	- anomalies: scope/purpose mismatch, vague intent, requests outside the user's typical pattern, off-hours emergency without justification.`
85
86	func UserPrompt(req ScoreRequest) string {
87		var sb strings.Builder
88		sb.WriteString("Access request details:\n\n")
89		fmt.Fprintf(&sb, "User: %s\n", req.User)
90		fmt.Fprintf(&sb, "Requested roles: %s\n", strings.Join(req.Roles, ", "))
91		if len(req.Resources) > 0 {
92			fmt.Fprintf(&sb, "Requested resources: %s\n", strings.Join(req.Resources, ", "))
93		}
94		fmt.Fprintf(&sb, "Contains CUSTX ticket reference: %v\n", req.HasCUSTXReference)
95		fmt.Fprintf(&sb, "Description length: %d characters\n\n", req.DescriptionLength)
96		sb.WriteString("Request reason (verbatim):\n")
97		sb.WriteString("---\n")
98		sb.WriteString(req.Reason)
99		sb.WriteString("\n---\n\nReturn the JSON assessment.")
100		return sb.String()
101	}
102
103	var jsonRE = regexp.MustCompile(`(?s)\{.*\}`)
104
105	func parseAssessment(text string) (*Assessment, error) {
106		m := jsonRE.FindString(text)
107		if m == "" {
108			return nil, fmt.Errorf("no JSON object in model output: %q", text)
109		}
110		var a Assessment
111		if err := json.Unmarshal([]byte(m), &a); err != nil {
112			return nil, fmt.Errorf("parse assessment: %w (raw: %q)", err, m)
113		}
114		return &a, nil
115	}
116

package ai

import (
	"context"
	"encoding/json"
	"fmt"
	"regexp"
	"strings"
)

type Config struct {
	Provider  string          `toml:"provider"`
	Anthropic AnthropicConfig `toml:"anthropic"`
	Bedrock   BedrockConfig   `toml:"bedrock"`
	Webhook   WebhookConfig   `toml:"webhook"`
}

func (c *Config) Validate() error {
	switch c.Provider {
	case "anthropic":
		if c.Anthropic.APIKey == "" {
			return fmt.Errorf("anthropic.api_key is required")
		}
	case "bedrock":
		if c.Bedrock.Region == "" {
			return fmt.Errorf("bedrock.region is required")
		}
	case "webhook":
		if c.Webhook.URL == "" {
			return fmt.Errorf("webhook.url is required")
		}
	default:
		return fmt.Errorf("provider must be one of: anthropic, bedrock, webhook")
	}
	return nil
}

type ScoreRequest struct {
	User              string   `json:"user"`
	Roles             []string `json:"roles"`
	Resources         []string `json:"resources"`
	Reason            string   `json:"reason"`
	HasCUSTXReference bool     `json:"has_custx_reference"`
	DescriptionLength int      `json:"description_length"`
}

type Assessment struct {
	DescriptionQuality int    `json:"description_quality"`
	PrivilegeConcern   int    `json:"privilege_concern"`
	Anomalies          int    `json:"anomalies"`
	Reasoning          string `json:"reasoning"`
}

type Provider interface {
	Assess(ctx context.Context, req ScoreRequest) (*Assessment, error)
}

func New(cfg Config) (Provider, error) {
	switch cfg.Provider {
	case "anthropic":
		return NewAnthropic(cfg.Anthropic), nil
	case "bedrock":
		return NewBedrock(cfg.Bedrock)
	case "webhook":
		return NewWebhook(cfg.Webhook), nil
	}
	return nil, fmt.Errorf("unknown provider: %s", cfg.Provider)
}

const SystemPrompt = `You are a security analyst reviewing a Teleport access request.
Assess the risk of granting the requested access based on the details provided.

Return ONLY a JSON object with these exact fields (no prose, no markdown fence):
{
  "description_quality": <int 0-25; 0 = thorough and specific, 25 = absent or vague>,
  "privilege_concern": <int 0-25; 0 = routine read access, 25 = broad production admin or destructive capability>,
  "anomalies": <int 0-10; flag mismatches between stated purpose and requested scope, or other unusual patterns>,
  "reasoning": "<2-3 sentences explaining the scores>"
}

Scoring guidance:
- description_quality: does the reason describe WHAT work will be performed and WHY, and does the scope match the work described?
- privilege_concern: how large is the blast radius of the requested roles and resources? Production prod-*, root, admin, or destructive tooling score higher.
- anomalies: scope/purpose mismatch, vague intent, requests outside the user's typical pattern, off-hours emergency without justification.`

func UserPrompt(req ScoreRequest) string {
	var sb strings.Builder
	sb.WriteString("Access request details:\n\n")
	fmt.Fprintf(&sb, "User: %s\n", req.User)
	fmt.Fprintf(&sb, "Requested roles: %s\n", strings.Join(req.Roles, ", "))
	if len(req.Resources) > 0 {
		fmt.Fprintf(&sb, "Requested resources: %s\n", strings.Join(req.Resources, ", "))
	}
	fmt.Fprintf(&sb, "Contains CUSTX ticket reference: %v\n", req.HasCUSTXReference)
	fmt.Fprintf(&sb, "Description length: %d characters\n\n", req.DescriptionLength)
	sb.WriteString("Request reason (verbatim):\n")
	sb.WriteString("---\n")
	sb.WriteString(req.Reason)
	sb.WriteString("\n---\n\nReturn the JSON assessment.")
	return sb.String()
}

var jsonRE = regexp.MustCompile(`(?s)\{.*\}`)

func parseAssessment(text string) (*Assessment, error) {
	m := jsonRE.FindString(text)
	if m == "" {
		return nil, fmt.Errorf("no JSON object in model output: %q", text)
	}
	var a Assessment
	if err := json.Unmarshal([]byte(m), &a); err != nil {
		return nil, fmt.Errorf("parse assessment: %w (raw: %q)", err, m)
	}
	return &a, nil
}


1	package jira
2
3	import (
4		"context"
5		"fmt"
6		"regexp"
7		"strings"
8		"time"
9
10		gojira "github.com/andygrunwald/go-jira/v2/cloud"
11	)
12
13	type Config struct {
14		URL            string        `toml:"url"`
15		Username       string        `toml:"username"`
16		APIToken       string        `toml:"api_token"`
17		Project        string        `toml:"project"`
18		IssueType      string        `toml:"issue_type"`
19		PollInterval   time.Duration `toml:"poll_interval"`
20		ApprovedStatus string        `toml:"approved_status"`
21		RejectedStatus string        `toml:"rejected_status"`
22	}
23
24	func (c *Config) ApplyDefaults() {
25		if c.IssueType == "" {
26			c.IssueType = "Task"
27		}
28		if c.PollInterval == 0 {
29			c.PollInterval = 30 * time.Second
30		}
31		if c.ApprovedStatus == "" {
32			c.ApprovedStatus = "Approved"
33		}
34		if c.RejectedStatus == "" {
35			c.RejectedStatus = "Rejected"
36		}
37	}
38
39	func (c *Config) Validate() error {
40		if c.URL == "" {
41			return fmt.Errorf("url is required")
42		}
43		if c.Project == "" {
44			return fmt.Errorf("project is required")
45		}
46		if c.Username == "" || c.APIToken == "" {
47			return fmt.Errorf("username and api_token are required")
48		}
49		return nil
50	}
51
52	type Status int
53
54	const (
55		StatusOpen Status = iota
56		StatusApproved
57		StatusRejected
58	)
59
60	func (s Status) String() string {
61		switch s {
62		case StatusApproved:
63			return "approved"
64		case StatusRejected:
65			return "rejected"
66		}
67		return "open"
68	}
69
70	type TicketInput struct {
71		RequestID   string
72		User        string
73		Roles       []string
74		Reason      string
75		Score       int
76		Tier        string
77		Slang       string
78		Components  map[string]int
79		AIReasoning string
80	}
81
82	type Client struct {
83		cfg     Config
84		jira    *gojira.Client
85		reqIDRE *regexp.Regexp
86	}
87
88	func New(cfg Config) (*Client, error) {
89		tp := gojira.BasicAuthTransport{Username: cfg.Username, APIToken: cfg.APIToken}
90		jc, err := gojira.NewClient(cfg.URL, tp.Client())
91		if err != nil {
92			return nil, err
93		}
94		return &Client{
95			cfg:     cfg,
96			jira:    jc,
97			reqIDRE: regexp.MustCompile(`Teleport Request ID:\s*([A-Za-z0-9-]+)`),
98		}, nil
99	}
100
101	func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
102		summary := fmt.Sprintf("[televibe] Access request %s — %s (%s)", in.RequestID, in.Tier, in.Slang)
103		issue := &gojira.Issue{
104			Fields: &gojira.IssueFields{
105				Project:     gojira.Project{Key: c.cfg.Project},
106				Type:        gojira.IssueType{Name: c.cfg.IssueType},
107				Summary:     summary,
108				Description: buildDescription(in),
109				Labels:      []string{"televibe", "teleport-access", "tier-" + in.Tier},
110			},
111		}
112		created, _, err := c.jira.Issue.Create(ctx, issue)
113		if err != nil {
114			return "", err
115		}
116		return created.Key, nil
117	}
118
119	func (c *Client) TicketStatus(ctx context.Context, key string) (Status, error) {
120		issue, _, err := c.jira.Issue.Get(ctx, key, nil)
121		if err != nil {
122			return StatusOpen, err
123		}
124		if issue.Fields == nil || issue.Fields.Status == nil {
125			return StatusOpen, nil
126		}
127		switch issue.Fields.Status.Name {
128		case c.cfg.ApprovedStatus:
129			return StatusApproved, nil
130		case c.cfg.RejectedStatus:
131			return StatusRejected, nil
132		}
133		return StatusOpen, nil
134	}
135
136	func (c *Client) ListOpenTeleportTickets(ctx context.Context) (map[string]string, error) {
137		jql := fmt.Sprintf(`project = %q AND labels = televibe AND status not in (%q, %q)`,
138			c.cfg.Project, c.cfg.ApprovedStatus, c.cfg.RejectedStatus)
139		issues, _, err := c.jira.Issue.Search(ctx, jql, nil)
140		if err != nil {
141			return nil, err
142		}
143		out := map[string]string{}
144		for _, iss := range issues {
145			if iss.Fields == nil {
146				continue
147			}
148			m := c.reqIDRE.FindStringSubmatch(iss.Fields.Description)
149			if len(m) < 2 {
150				continue
151			}
152			out[m[1]] = iss.Key
153		}
154		return out, nil
155	}
156
157	func buildDescription(in TicketInput) string {
158		var sb strings.Builder
159		sb.WriteString("h2. Teleport Access Request Review\n\n")
160		fmt.Fprintf(&sb, "* *Teleport Request ID:* %s\n", in.RequestID)
161		fmt.Fprintf(&sb, "* *Requester:* %s\n", in.User)
162		fmt.Fprintf(&sb, "* *Roles requested:* %s\n", strings.Join(in.Roles, ", "))
163		fmt.Fprintf(&sb, "* *Risk score:* %d\n", in.Score)
164		fmt.Fprintf(&sb, "* *Risk tier:* %s (%q)\n\n", in.Tier, in.Slang)
165
166		sb.WriteString("h3. Request reason\n")
167		sb.WriteString("{code}\n")
168		sb.WriteString(in.Reason)
169		sb.WriteString("\n{code}\n\n")
170
171		if len(in.Components) > 0 {
172			sb.WriteString("h3. Risk components\n")
173			for k, v := range in.Components {
174				fmt.Fprintf(&sb, "* %s: +%d\n", k, v)
175			}
176			sb.WriteString("\n")
177		}
178
179		if in.AIReasoning != "" {
180			sb.WriteString("h3. AI reasoning\n")
181			sb.WriteString("{quote}\n")
182			sb.WriteString(in.AIReasoning)
183			sb.WriteString("\n{quote}\n\n")
184		}
185
186		sb.WriteString("h3. SRE action required\n")
187		sb.WriteString("Transition this ticket to the configured approved or rejected status to resolve the Teleport access request.\n")
188		return sb.String()
189	}
190

package jira

import (
	"context"
	"fmt"
	"regexp"
	"strings"
	"time"

	gojira "github.com/andygrunwald/go-jira/v2/cloud"
)

type Config struct {
	URL            string        `toml:"url"`
	Username       string        `toml:"username"`
	APIToken       string        `toml:"api_token"`
	Project        string        `toml:"project"`
	IssueType      string        `toml:"issue_type"`
	PollInterval   time.Duration `toml:"poll_interval"`
	ApprovedStatus string        `toml:"approved_status"`
	RejectedStatus string        `toml:"rejected_status"`
}

func (c *Config) ApplyDefaults() {
	if c.IssueType == "" {
		c.IssueType = "Task"
	}
	if c.PollInterval == 0 {
		c.PollInterval = 30 * time.Second
	}
	if c.ApprovedStatus == "" {
		c.ApprovedStatus = "Approved"
	}
	if c.RejectedStatus == "" {
		c.RejectedStatus = "Rejected"
	}
}

func (c *Config) Validate() error {
	if c.URL == "" {
		return fmt.Errorf("url is required")
	}
	if c.Project == "" {
		return fmt.Errorf("project is required")
	}
	if c.Username == "" || c.APIToken == "" {
		return fmt.Errorf("username and api_token are required")
	}
	return nil
}

type Status int

const (
	StatusOpen Status = iota
	StatusApproved
	StatusRejected
)

func (s Status) String() string {
	switch s {
	case StatusApproved:
		return "approved"
	case StatusRejected:
		return "rejected"
	}
	return "open"
}

type TicketInput struct {
	RequestID   string
	User        string
	Roles       []string
	Reason      string
	Score       int
	Tier        string
	Slang       string
	Components  map[string]int
	AIReasoning string
}

type Client struct {
	cfg     Config
	jira    *gojira.Client
	reqIDRE *regexp.Regexp
}

func New(cfg Config) (*Client, error) {
	tp := gojira.BasicAuthTransport{Username: cfg.Username, APIToken: cfg.APIToken}
	jc, err := gojira.NewClient(cfg.URL, tp.Client())
	if err != nil {
		return nil, err
	}
	return &Client{
		cfg:     cfg,
		jira:    jc,
		reqIDRE: regexp.MustCompile(`Teleport Request ID:\s*([A-Za-z0-9-]+)`),
	}, nil
}

func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
	summary := fmt.Sprintf("[televibe] Access request %s — %s (%s)", in.RequestID, in.Tier, in.Slang)
	issue := &gojira.Issue{
		Fields: &gojira.IssueFields{
			Project:     gojira.Project{Key: c.cfg.Project},
			Type:        gojira.IssueType{Name: c.cfg.IssueType},
			Summary:     summary,
			Description: buildDescription(in),
			Labels:      []string{"televibe", "teleport-access", "tier-" + in.Tier},
		},
	}
	created, _, err := c.jira.Issue.Create(ctx, issue)
	if err != nil {
		return "", err
	}
	return created.Key, nil
}

func (c *Client) TicketStatus(ctx context.Context, key string) (Status, error) {
	issue, _, err := c.jira.Issue.Get(ctx, key, nil)
	if err != nil {
		return StatusOpen, err
	}
	if issue.Fields == nil || issue.Fields.Status == nil {
		return StatusOpen, nil
	}
	switch issue.Fields.Status.Name {
	case c.cfg.ApprovedStatus:
		return StatusApproved, nil
	case c.cfg.RejectedStatus:
		return StatusRejected, nil
	}
	return StatusOpen, nil
}

func (c *Client) ListOpenTeleportTickets(ctx context.Context) (map[string]string, error) {
	jql := fmt.Sprintf(`project = %q AND labels = televibe AND status not in (%q, %q)`,
		c.cfg.Project, c.cfg.ApprovedStatus, c.cfg.RejectedStatus)
	issues, _, err := c.jira.Issue.Search(ctx, jql, nil)
	if err != nil {
		return nil, err
	}
	out := map[string]string{}
	for _, iss := range issues {
		if iss.Fields == nil {
			continue
		}
		m := c.reqIDRE.FindStringSubmatch(iss.Fields.Description)
		if len(m) < 2 {
			continue
		}
		out[m[1]] = iss.Key
	}
	return out, nil
}

func buildDescription(in TicketInput) string {
	var sb strings.Builder
	sb.WriteString("h2. Teleport Access Request Review\n\n")
	fmt.Fprintf(&sb, "* *Teleport Request ID:* %s\n", in.RequestID)
	fmt.Fprintf(&sb, "* *Requester:* %s\n", in.User)
	fmt.Fprintf(&sb, "* *Roles requested:* %s\n", strings.Join(in.Roles, ", "))
	fmt.Fprintf(&sb, "* *Risk score:* %d\n", in.Score)
	fmt.Fprintf(&sb, "* *Risk tier:* %s (%q)\n\n", in.Tier, in.Slang)

	sb.WriteString("h3. Request reason\n")
	sb.WriteString("{code}\n")
	sb.WriteString(in.Reason)
	sb.WriteString("\n{code}\n\n")

	if len(in.Components) > 0 {
		sb.WriteString("h3. Risk components\n")
		for k, v := range in.Components {
			fmt.Fprintf(&sb, "* %s: +%d\n", k, v)
		}
		sb.WriteString("\n")
	}

	if in.AIReasoning != "" {
		sb.WriteString("h3. AI reasoning\n")
		sb.WriteString("{quote}\n")
		sb.WriteString(in.AIReasoning)
		sb.WriteString("\n{quote}\n\n")
	}

	sb.WriteString("h3. SRE action required\n")
	sb.WriteString("Transition this ticket to the configured approved or rejected status to resolve the Teleport access request.\n")
	return sb.String()
}


[Request interrupted by user]


<local-command-caveat>Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to.</local-command-caveat>


<command-name>/resume</command-name>
            <command-message>resume</command-message>
            <command-args></command-args>


<local-command-stdout>No conversations found to resume</local-command-stdout>


Lets add user reputation scoring to this, the user should start with a neutral reputation and gradually get worse when they submit a bad teleport request (i.e. ndescriptive request or missing a CUSTX ticket). The reputation values should be configurable and they should also be exported to the open telemetry collector so we can ship them to datadog to publicly shame bad requestors. The state for the user metrics should be as native as possible without the need for external databases (if possible)


Right, Sir Charles of St Cheese, let me take a look at the battlefield and then we'll wire up a proper public shaming apparatus.


ToolSearch

Question: select:TaskCreate,TaskUpdate


Question: select:TaskCreate,TaskUpdate


Lets add user reputation scoring to this, the user should start with a neutral reputation and gradually get worse when they submit a bad teleport request (i.e. ndescriptive request or missing a CUSTX…


TaskCreate

Build reputation package with native JSON persistence

Create reputation/reputation.go with Store, Config (configurable penalties/floor/ceiling/path), Assess method, atomic file persistence via rename. Plus tests.


Task #1 created successfully: Build reputation package with native JSON persistence


Build telemetry package wiring OTLP meter provider

Create telemetry/telemetry.go with OTel SDK setup, OTLP gRPC exporter, and an observable Int64 gauge that calls back into the reputation store on each collection cycle.


Task #2 created successfully: Build telemetry package wiring OTLP meter provider


Wire reputation + telemetry into app/config/main

Extend Config, App, and main.go: init reputation store and (optionally) telemetry meter, call Assess in handleRequest, graceful shutdown of meter provider.


Task #3 created successfully: Wire reputation + telemetry into app/config/main


Update TOML example and README

Document the new [reputation] and [telemetry] sections and the new component in the data flow / layout.


Task #4 created successfully: Update TOML example and README

Update TOML example and README


Updated task #1 status


Write

// Package reputation tracks per-user reputation scores derived from the
// quality of Teleport access requests. State is persisted to a local JSON
// file so no external database is required.
package reputation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
)

type Config struct {
	PersistPath            string `toml:"persist_path"`
	StartingScore          int    `toml:"starting_score"`
	FloorScore             int    `toml:"floor_score"`
	CeilingScore           int    `toml:"ceiling_score"`
	PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
	PenaltyThinDescription int    `toml:"penalty_thin_description"`
}

func (c *Config) ApplyDefaults() {
	if c.StartingScore == 0 {
		c.StartingScore = 100
	}
	if c.CeilingScore == 0 {
		c.CeilingScore = 100
	}
	if c.PenaltyMissingCUSTX == 0 {
		c.PenaltyMissingCUSTX = 10
	}
	if c.PenaltyThinDescription == 0 {
		c.PenaltyThinDescription = 5
	}
}

func (c *Config) Validate() error {
	if c.FloorScore > c.CeilingScore {
		return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
	}
	if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
		return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
	}
	return nil
}

type Penalty struct {
	Reason string
	Amount int
}

type Assessment struct {
	Score     int
	Previous  int
	Penalties []Penalty
}

type Store struct {
	cfg Config
	mu  sync.RWMutex
	rep map[string]int
}

func NewStore(cfg Config) (*Store, error) {
	s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
	if cfg.PersistPath != "" {
		if err := s.load(); err != nil {
			return nil, err
		}
	}
	return s, nil
}

// Score returns the current reputation for user, or StartingScore if unseen.
func (s *Store) Score(user string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if v, ok := s.rep[user]; ok {
		return v
	}
	return s.cfg.StartingScore
}

// Range invokes fn for every tracked user under a read lock. Do not call
// back into the store from fn or you will deadlock.
func (s *Store) Range(fn func(user string, score int)) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for u, sc := range s.rep {
		fn(u, sc)
	}
}

// Assess translates risk components into configured penalties, docks the
// user's score, clamps to [floor, ceiling], persists to disk, and returns
// the updated assessment. A component map with no bad signals is a no-op.
func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
	return s.apply(ctx, user, s.penaltiesFor(components))
}

func (s *Store) penaltiesFor(components map[string]int) []Penalty {
	penalties := make([]Penalty, 0, 2)
	if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
		penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
	}
	if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
		penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
	}
	return penalties
}

func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
	s.mu.Lock()
	prev, ok := s.rep[user]
	if !ok {
		prev = s.cfg.StartingScore
	}
	score := prev
	for _, p := range penalties {
		score -= p.Amount
	}
	if score < s.cfg.FloorScore {
		score = s.cfg.FloorScore
	}
	if score > s.cfg.CeilingScore {
		score = s.cfg.CeilingScore
	}
	s.rep[user] = score
	s.mu.Unlock()

	assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
	if s.cfg.PersistPath == "" || len(penalties) == 0 {
		return assessment, nil
	}
	if err := s.persist(ctx); err != nil {
		return assessment, fmt.Errorf("persist reputation: %w", err)
	}
	return assessment, nil
}

func (s *Store) load() error {
	data, err := os.ReadFile(s.cfg.PersistPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("read reputation state: %w", err)
	}
	if len(data) == 0 {
		return nil
	}
	var rep map[string]int
	if err := json.Unmarshal(data, &rep); err != nil {
		return fmt.Errorf("parse reputation state: %w", err)
	}
	s.rep = rep
	return nil
}

func (s *Store) persist(_ context.Context) error {
	s.mu.RLock()
	data, err := json.Marshal(s.rep)
	s.mu.RUnlock()
	if err != nil {
		return err
	}

	dir := filepath.Dir(s.cfg.PersistPath)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpPath, s.cfg.PersistPath)
}


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation.go

// Package reputation tracks per-user reputation scores derived from the
// quality of Teleport access requests. State is persisted to a local JSON
// file so no external database is required.
package reputation

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sync"
)

type Config struct {
	PersistPath            string `toml:"persist_path"`
	StartingScore          int    `toml:"starting_score"`
	FloorScore             int    `toml:"floor_score"`
	CeilingScore           int    `toml:"ceiling_score"`
	PenaltyMissingCUSTX    int    `toml:"penalty_missing_custx"`
	PenaltyThinDescription int    `toml:"penalty_thin_description"`
}

func (c *Config) ApplyDefaults() {
	if c.StartingScore == 0 {
		c.StartingScore = 100
	}
	if c.CeilingScore == 0 {
		c.CeilingScore = 100
	}
	if c.PenaltyMissingCUSTX == 0 {
		c.PenaltyMissingCUSTX = 10
	}
	if c.PenaltyThinDescription == 0 {
		c.PenaltyThinDescription = 5
	}
}

func (c *Config) Validate() error {
	if c.FloorScore > c.CeilingScore {
		return fmt.Errorf("floor_score (%d) must be <= ceiling_score (%d)", c.FloorScore, c.CeilingScore)
	}
	if c.StartingScore < c.FloorScore || c.StartingScore > c.CeilingScore {
		return fmt.Errorf("starting_score (%d) must be within [%d, %d]", c.StartingScore, c.FloorScore, c.CeilingScore)
	}
	return nil
}

type Penalty struct {
	Reason string
	Amount int
}

type Assessment struct {
	Score     int
	Previous  int
	Penalties []Penalty
}

type Store struct {
	cfg Config
	mu  sync.RWMutex
	rep map[string]int
}

func NewStore(cfg Config) (*Store, error) {
	s := &Store{cfg: cfg, rep: make(map[string]int, 64)}
	if cfg.PersistPath != "" {
		if err := s.load(); err != nil {
			return nil, err
		}
	}
	return s, nil
}

// Score returns the current reputation for user, or StartingScore if unseen.
func (s *Store) Score(user string) int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if v, ok := s.rep[user]; ok {
		return v
	}
	return s.cfg.StartingScore
}

// Range invokes fn for every tracked user under a read lock. Do not call
// back into the store from fn or you will deadlock.
func (s *Store) Range(fn func(user string, score int)) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for u, sc := range s.rep {
		fn(u, sc)
	}
}

// Assess translates risk components into configured penalties, docks the
// user's score, clamps to [floor, ceiling], persists to disk, and returns
// the updated assessment. A component map with no bad signals is a no-op.
func (s *Store) Assess(ctx context.Context, user string, components map[string]int) (Assessment, error) {
	return s.apply(ctx, user, s.penaltiesFor(components))
}

func (s *Store) penaltiesFor(components map[string]int) []Penalty {
	penalties := make([]Penalty, 0, 2)
	if _, ok := components["missing_custx_ticket"]; ok && s.cfg.PenaltyMissingCUSTX > 0 {
		penalties = append(penalties, Penalty{Reason: "missing_custx_ticket", Amount: s.cfg.PenaltyMissingCUSTX})
	}
	if _, ok := components["thin_description"]; ok && s.cfg.PenaltyThinDescription > 0 {
		penalties = append(penalties, Penalty{Reason: "thin_description", Amount: s.cfg.PenaltyThinDescription})
	}
	return penalties
}

func (s *Store) apply(ctx context.Context, user string, penalties []Penalty) (Assessment, error) {
	s.mu.Lock()
	prev, ok := s.rep[user]
	if !ok {
		prev = s.cfg.StartingScore
	}
	score := prev
	for _, p := range penalties {
		score -= p.Amount
	}
	if score < s.cfg.FloorScore {
		score = s.cfg.FloorScore
	}
	if score > s.cfg.CeilingScore {
		score = s.cfg.CeilingScore
	}
	s.rep[user] = score
	s.mu.Unlock()

	assessment := Assessment{Score: score, Previous: prev, Penalties: penalties}
	if s.cfg.PersistPath == "" || len(penalties) == 0 {
		return assessment, nil
	}
	if err := s.persist(ctx); err != nil {
		return assessment, fmt.Errorf("persist reputation: %w", err)
	}
	return assessment, nil
}

func (s *Store) load() error {
	data, err := os.ReadFile(s.cfg.PersistPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("read reputation state: %w", err)
	}
	if len(data) == 0 {
		return nil
	}
	var rep map[string]int
	if err := json.Unmarshal(data, &rep); err != nil {
		return fmt.Errorf("parse reputation state: %w", err)
	}
	s.rep = rep
	return nil
}

func (s *Store) persist(_ context.Context) error {
	s.mu.RLock()
	data, err := json.Marshal(s.rep)
	s.mu.RUnlock()
	if err != nil {
		return err
	}

	dir := filepath.Dir(s.cfg.PersistPath)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return err
	}
	tmp, err := os.CreateTemp(dir, ".reputation.*.tmp")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)

	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpPath, s.cfg.PersistPath)
}


package reputation

import (
	"context"
	"os"
	"path/filepath"
	"testing"
)

func baseCfg(path string) Config {
	c := Config{PersistPath: path, FloorScore: 0}
	c.ApplyDefaults()
	return c
}

func TestScore_DefaultsToStartingScore(t *testing.T) {
	s, err := NewStore(baseCfg(""))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if got := s.Score("alice"); got != 100 {
		t.Errorf("Score(alice) = %d, want 100", got)
	}
}

func TestAssess_AppliesConfiguredPenalties(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 30
	cfg.PenaltyThinDescription = 15
	s, _ := NewStore(cfg)

	a, err := s.Assess(context.Background(), "alice", map[string]int{
		"missing_custx_ticket": 30,
		"thin_description":     15,
		"privilege_concern":    10, // not a reputation signal, ignored
	})
	if err != nil {
		t.Fatalf("Assess: %v", err)
	}
	if a.Previous != 100 {
		t.Errorf("Previous = %d, want 100", a.Previous)
	}
	if a.Score != 55 {
		t.Errorf("Score = %d, want 55", a.Score)
	}
	if len(a.Penalties) != 2 {
		t.Errorf("len(Penalties) = %d, want 2", len(a.Penalties))
	}
}

func TestAssess_ClampsToFloor(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 40
	s, _ := NewStore(cfg)
	ctx := context.Background()

	for i := 0; i < 5; i++ {
		if _, err := s.Assess(ctx, "alice", map[string]int{"missing_custx_ticket": 1}); err != nil {
			t.Fatalf("Assess: %v", err)
		}
	}
	if got := s.Score("alice"); got != 0 {
		t.Errorf("floored score = %d, want 0", got)
	}
}

func TestAssess_NoSignalsIsNoOp(t *testing.T) {
	s, _ := NewStore(baseCfg(""))
	a, err := s.Assess(context.Background(), "bob", map[string]int{"description_quality": 3})
	if err != nil {
		t.Fatalf("Assess: %v", err)
	}
	if a.Score != 100 || len(a.Penalties) != 0 {
		t.Errorf("unexpected assessment: %+v", a)
	}
}

func TestPersistence_RoundTrip(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "sub", "rep.json") // exercise MkdirAll

	cfg := baseCfg(path)
	cfg.PenaltyMissingCUSTX = 10
	s, err := NewStore(cfg)
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if _, err := s.Assess(context.Background(), "alice", map[string]int{"missing_custx_ticket": 1}); err != nil {
		t.Fatalf("Assess: %v", err)
	}

	s2, err := NewStore(cfg)
	if err != nil {
		t.Fatalf("NewStore (reload): %v", err)
	}
	if got := s2.Score("alice"); got != 90 {
		t.Errorf("after reload, Score(alice) = %d, want 90", got)
	}
	if got := s2.Score("bob"); got != 100 {
		t.Errorf("unseen user Score(bob) = %d, want 100", got)
	}

	st, err := os.Stat(path)
	if err != nil {
		t.Fatalf("stat: %v", err)
	}
	if got := st.Mode().Perm(); got != 0o600 {
		t.Errorf("mode = %o, want 0600", got)
	}
}

func TestValidate(t *testing.T) {
	cases := []struct {
		name string
		cfg  Config
		ok   bool
	}{
		{"floor_above_ceiling", Config{FloorScore: 50, CeilingScore: 10, StartingScore: 20}, false},
		{"starting_out_of_range", Config{FloorScore: 0, CeilingScore: 100, StartingScore: 150}, false},
		{"ok", Config{FloorScore: 0, CeilingScore: 100, StartingScore: 50}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := tc.cfg.Validate()
			if (err == nil) != tc.ok {
				t.Errorf("Validate() err = %v, ok = %v", err, tc.ok)
			}
		})
	}
}

func TestRange_IteratesAllUsers(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 10
	cfg.PenaltyThinDescription = 5
	s, _ := NewStore(cfg)
	ctx := context.Background()
	_, _ = s.Assess(ctx, "alice", map[string]int{"missing_custx_ticket": 1})
	_, _ = s.Assess(ctx, "bob", map[string]int{"thin_description": 1})

	seen := map[string]int{}
	s.Range(func(u string, sc int) { seen[u] = sc })
	if len(seen) != 2 {
		t.Errorf("len(seen) = %d, want 2", len(seen))
	}
	if seen["alice"] != 90 || seen["bob"] != 95 {
		t.Errorf("unexpected seen: %+v", seen)
	}
}

func TestAssess_DisabledPenaltyIsSkipped(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 0 // explicit disable
	cfg.PenaltyThinDescription = 5
	cfg.StartingScore = 100
	// ApplyDefaults already ran in baseCfg, but setting to 0 after is a valid
	// "turn this off" gesture — ApplyDefaults would resurrect it, so rebuild:
	s, _ := NewStore(cfg)

	a, _ := s.Assess(context.Background(), "alice", map[string]int{
		"missing_custx_ticket": 1,
		"thin_description":     1,
	})
	if a.Score != 95 {
		t.Errorf("Score = %d, want 95 (only thin_description applied)", a.Score)
	}
	for _, p := range a.Penalties {
		if p.Reason == "missing_custx_ticket" {
			t.Errorf("missing_custx_ticket penalty should be skipped when amount = 0")
		}
	}
}


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation_test.go

package reputation

import (
	"context"
	"os"
	"path/filepath"
	"testing"
)

func baseCfg(path string) Config {
	c := Config{PersistPath: path, FloorScore: 0}
	c.ApplyDefaults()
	return c
}

func TestScore_DefaultsToStartingScore(t *testing.T) {
	s, err := NewStore(baseCfg(""))
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if got := s.Score("alice"); got != 100 {
		t.Errorf("Score(alice) = %d, want 100", got)
	}
}

func TestAssess_AppliesConfiguredPenalties(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 30
	cfg.PenaltyThinDescription = 15
	s, _ := NewStore(cfg)

	a, err := s.Assess(context.Background(), "alice", map[string]int{
		"missing_custx_ticket": 30,
		"thin_description":     15,
		"privilege_concern":    10, // not a reputation signal, ignored
	})
	if err != nil {
		t.Fatalf("Assess: %v", err)
	}
	if a.Previous != 100 {
		t.Errorf("Previous = %d, want 100", a.Previous)
	}
	if a.Score != 55 {
		t.Errorf("Score = %d, want 55", a.Score)
	}
	if len(a.Penalties) != 2 {
		t.Errorf("len(Penalties) = %d, want 2", len(a.Penalties))
	}
}

func TestAssess_ClampsToFloor(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 40
	s, _ := NewStore(cfg)
	ctx := context.Background()

	for i := 0; i < 5; i++ {
		if _, err := s.Assess(ctx, "alice", map[string]int{"missing_custx_ticket": 1}); err != nil {
			t.Fatalf("Assess: %v", err)
		}
	}
	if got := s.Score("alice"); got != 0 {
		t.Errorf("floored score = %d, want 0", got)
	}
}

func TestAssess_NoSignalsIsNoOp(t *testing.T) {
	s, _ := NewStore(baseCfg(""))
	a, err := s.Assess(context.Background(), "bob", map[string]int{"description_quality": 3})
	if err != nil {
		t.Fatalf("Assess: %v", err)
	}
	if a.Score != 100 || len(a.Penalties) != 0 {
		t.Errorf("unexpected assessment: %+v", a)
	}
}

func TestPersistence_RoundTrip(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "sub", "rep.json") // exercise MkdirAll

	cfg := baseCfg(path)
	cfg.PenaltyMissingCUSTX = 10
	s, err := NewStore(cfg)
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	if _, err := s.Assess(context.Background(), "alice", map[string]int{"missing_custx_ticket": 1}); err != nil {
		t.Fatalf("Assess: %v", err)
	}

	s2, err := NewStore(cfg)
	if err != nil {
		t.Fatalf("NewStore (reload): %v", err)
	}
	if got := s2.Score("alice"); got != 90 {
		t.Errorf("after reload, Score(alice) = %d, want 90", got)
	}
	if got := s2.Score("bob"); got != 100 {
		t.Errorf("unseen user Score(bob) = %d, want 100", got)
	}

	st, err := os.Stat(path)
	if err != nil {
		t.Fatalf("stat: %v", err)
	}
	if got := st.Mode().Perm(); got != 0o600 {
		t.Errorf("mode = %o, want 0600", got)
	}
}

func TestValidate(t *testing.T) {
	cases := []struct {
		name string
		cfg  Config
		ok   bool
	}{
		{"floor_above_ceiling", Config{FloorScore: 50, CeilingScore: 10, StartingScore: 20}, false},
		{"starting_out_of_range", Config{FloorScore: 0, CeilingScore: 100, StartingScore: 150}, false},
		{"ok", Config{FloorScore: 0, CeilingScore: 100, StartingScore: 50}, true},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			err := tc.cfg.Validate()
			if (err == nil) != tc.ok {
				t.Errorf("Validate() err = %v, ok = %v", err, tc.ok)
			}
		})
	}
}

func TestRange_IteratesAllUsers(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 10
	cfg.PenaltyThinDescription = 5
	s, _ := NewStore(cfg)
	ctx := context.Background()
	_, _ = s.Assess(ctx, "alice", map[string]int{"missing_custx_ticket": 1})
	_, _ = s.Assess(ctx, "bob", map[string]int{"thin_description": 1})

	seen := map[string]int{}
	s.Range(func(u string, sc int) { seen[u] = sc })
	if len(seen) != 2 {
		t.Errorf("len(seen) = %d, want 2", len(seen))
	}
	if seen["alice"] != 90 || seen["bob"] != 95 {
		t.Errorf("unexpected seen: %+v", seen)
	}
}

func TestAssess_DisabledPenaltyIsSkipped(t *testing.T) {
	cfg := baseCfg("")
	cfg.PenaltyMissingCUSTX = 0 // explicit disable
	cfg.PenaltyThinDescription = 5
	cfg.StartingScore = 100
	// ApplyDefaults already ran in baseCfg, but setting to 0 after is a valid
	// "turn this off" gesture — ApplyDefaults would resurrect it, so rebuild:
	s, _ := NewStore(cfg)

	a, _ := s.Assess(context.Background(), "alice", map[string]int{
		"missing_custx_ticket": 1,
		"thin_description":     1,
	})
	if a.Score != 95 {
		t.Errorf("Score = %d, want 95 (only thin_description applied)", a.Score)
	}
	for _, p := range a.Penalties {
		if p.Reason == "missing_custx_ticket" {
			t.Errorf("missing_custx_ticket penalty should be skipped when amount = 0")
		}
	}
}


Let me verify the reputation package compiles and tests pass before moving on.


Bash

cd /Users/steve.briggs/projects/operations/apps/televibe/reputation && go test -race -v 2>&1 | head -80

Run reputation tests with race detector


=== RUN   TestScore_DefaultsToStartingScore
--- PASS: TestScore_DefaultsToStartingScore (0.00s)
=== RUN   TestAssess_AppliesConfiguredPenalties
--- PASS: TestAssess_AppliesConfiguredPenalties (0.00s)
=== RUN   TestAssess_ClampsToFloor
--- PASS: TestAssess_ClampsToFloor (0.00s)
=== RUN   TestAssess_NoSignalsIsNoOp
--- PASS: TestAssess_NoSignalsIsNoOp (0.00s)
=== RUN   TestPersistence_RoundTrip
--- PASS: TestPersistence_RoundTrip (0.01s)
=== RUN   TestValidate
=== RUN   TestValidate/floor_above_ceiling
=== RUN   TestValidate/starting_out_of_range
=== RUN   TestValidate/ok
--- PASS: TestValidate (0.00s)
    --- PASS: TestValidate/floor_above_ceiling (0.00s)
    --- PASS: TestValidate/starting_out_of_range (0.00s)
    --- PASS: TestValidate/ok (0.00s)
=== RUN   TestRange_IteratesAllUsers
--- PASS: TestRange_IteratesAllUsers (0.00s)
=== RUN   TestAssess_DisabledPenaltyIsSkipped
--- PASS: TestAssess_DisabledPenaltyIsSkipped (0.00s)
PASS
ok  	televibe/reputation	2.043s


All green. Now the telemetry package.


Updated task #2 status


Write

// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
// collector and publishes per-user reputation as an observable gauge. The
// collector is expected to fan out to Datadog (or any other backend) via
// its own exporter pipeline.
package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

type Config struct {
	Enabled      bool          `toml:"enabled"`
	Endpoint     string        `toml:"endpoint"`
	Insecure     bool          `toml:"insecure"`
	ServiceName  string        `toml:"service_name"`
	ExportPeriod time.Duration `toml:"export_period"`
}

func (c *Config) ApplyDefaults() {
	if c.ServiceName == "" {
		c.ServiceName = "televibe"
	}
	if c.ExportPeriod == 0 {
		c.ExportPeriod = 15 * time.Second
	}
}

// ReputationSource is the minimum surface telemetry needs from the
// reputation store: iterate the users it currently knows about.
type ReputationSource interface {
	Range(fn func(user string, score int))
}

type Meter struct {
	provider *sdkmetric.MeterProvider
	reg      metric.Registration
}

func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
	opts := []otlpmetricgrpc.Option{}
	if cfg.Endpoint != "" {
		opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
	}
	if cfg.Insecure {
		opts = append(opts, otlpmetricgrpc.WithInsecure())
	}
	exp, err := otlpmetricgrpc.New(ctx, opts...)
	if err != nil {
		return nil, fmt.Errorf("otlp exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithResource(res),
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
	)
	otel.SetMeterProvider(provider)

	meter := provider.Meter("televibe/reputation")
	gauge, err := meter.Int64ObservableGauge(
		"televibe.user.reputation",
		metric.WithDescription("Per-user reputation score (lower = worse)"),
	)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register gauge: %w", err)
	}

	reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
		src.Range(func(user string, score int) {
			o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
				attribute.String("user", user),
			))
		})
		return nil
	}, gauge)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register callback: %w", err)
	}

	return &Meter{provider: provider, reg: reg}, nil
}

// Shutdown unregisters the callback and flushes pending metrics to the
// collector. Safe to call on a nil receiver.
func (m *Meter) Shutdown(ctx context.Context) error {
	if m == nil || m.provider == nil {
		return nil
	}
	_ = m.reg.Unregister()
	return m.provider.Shutdown(ctx)
}


File created successfully at: /Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go

// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
// collector and publishes per-user reputation as an observable gauge. The
// collector is expected to fan out to Datadog (or any other backend) via
// its own exporter pipeline.
package telemetry

import (
	"context"
	"fmt"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
	"go.opentelemetry.io/otel/metric"
	sdkmetric "go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

type Config struct {
	Enabled      bool          `toml:"enabled"`
	Endpoint     string        `toml:"endpoint"`
	Insecure     bool          `toml:"insecure"`
	ServiceName  string        `toml:"service_name"`
	ExportPeriod time.Duration `toml:"export_period"`
}

func (c *Config) ApplyDefaults() {
	if c.ServiceName == "" {
		c.ServiceName = "televibe"
	}
	if c.ExportPeriod == 0 {
		c.ExportPeriod = 15 * time.Second
	}
}

// ReputationSource is the minimum surface telemetry needs from the
// reputation store: iterate the users it currently knows about.
type ReputationSource interface {
	Range(fn func(user string, score int))
}

type Meter struct {
	provider *sdkmetric.MeterProvider
	reg      metric.Registration
}

func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
	opts := []otlpmetricgrpc.Option{}
	if cfg.Endpoint != "" {
		opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
	}
	if cfg.Insecure {
		opts = append(opts, otlpmetricgrpc.WithInsecure())
	}
	exp, err := otlpmetricgrpc.New(ctx, opts...)
	if err != nil {
		return nil, fmt.Errorf("otlp exporter: %w", err)
	}

	res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
	if err != nil {
		return nil, fmt.Errorf("resource: %w", err)
	}

	provider := sdkmetric.NewMeterProvider(
		sdkmetric.WithResource(res),
		sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
	)
	otel.SetMeterProvider(provider)

	meter := provider.Meter("televibe/reputation")
	gauge, err := meter.Int64ObservableGauge(
		"televibe.user.reputation",
		metric.WithDescription("Per-user reputation score (lower = worse)"),
	)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register gauge: %w", err)
	}

	reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
		src.Range(func(user string, score int) {
			o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
				attribute.String("user", user),
			))
		})
		return nil
	}, gauge)
	if err != nil {
		_ = provider.Shutdown(ctx)
		return nil, fmt.Errorf("register callback: %w", err)
	}

	return &Meter{provider: provider, reg: reg}, nil
}

// Shutdown unregisters the callback and flushes pending metrics to the
// collector. Safe to call on a nil receiver.
func (m *Meter) Shutdown(ctx context.Context) error {
	if m == nil || m.provider == nil {
		return nil
	}
	_ = m.reg.Unregister()
	return m.provider.Shutdown(ctx)
}


Now I need `go mod tidy` to pull in the OTel deps. Let me check first what's currently in go.mod.


Bash

Check current go.mod contents


module televibe

go 1.22


cd /Users/steve.briggs/projects/operations/apps/televibe && go build ./... 2>&1 | head -40

Try building to see current dependency state


app.go:10:2: no required module provides package github.com/gravitational/teleport/api/client; to add it:
	go get github.com/gravitational/teleport/api/client
app.go:11:2: no required module provides package github.com/gravitational/teleport/api/types; to add it:
	go get github.com/gravitational/teleport/api/types
config.go:7:2: no required module provides package github.com/pelletier/go-toml/v2; to add it:
	go get github.com/pelletier/go-toml/v2
app.go:12:2: no required module provides package golang.org/x/sync/errgroup; to add it:
	go get golang.org/x/sync/errgroup
ai/bedrock.go:8:2: no required module provides package github.com/aws/aws-sdk-go-v2/aws; to add it:
	go get github.com/aws/aws-sdk-go-v2/aws
ai/bedrock.go:9:2: no required module provides package github.com/aws/aws-sdk-go-v2/config; to add it:
	go get github.com/aws/aws-sdk-go-v2/config
ai/bedrock.go:10:2: no required module provides package github.com/aws/aws-sdk-go-v2/service/bedrockruntime; to add it:
	go get github.com/aws/aws-sdk-go-v2/service/bedrockruntime
jira/itsm.go:10:2: no required module provides package github.com/andygrunwald/go-jira/v2/cloud; to add it:
	go get github.com/andygrunwald/go-jira/v2/cloud
telemetry/telemetry.go:12:2: no required module provides package go.opentelemetry.io/otel; to add it:
	go get go.opentelemetry.io/otel
telemetry/telemetry.go:13:2: no required module provides package go.opentelemetry.io/otel/attribute; to add it:
	go get go.opentelemetry.io/otel/attribute
telemetry/telemetry.go:14:2: no required module provides package go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc; to add it:
	go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc
telemetry/telemetry.go:15:2: no required module provides package go.opentelemetry.io/otel/metric; to add it:
	go get go.opentelemetry.io/otel/metric
telemetry/telemetry.go:16:2: no required module provides package go.opentelemetry.io/otel/sdk/metric; to add it:
	go get go.opentelemetry.io/otel/sdk/metric
telemetry/telemetry.go:17:2: no required module provides package go.opentelemetry.io/otel/sdk/resource; to add it:
	go get go.opentelemetry.io/otel/sdk/resource
telemetry/telemetry.go:18:2: no required module provides package go.opentelemetry.io/otel/semconv/v1.24.0; to add it:
	go get go.opentelemetry.io/otel/semconv/v1.24.0


The repo was left without resolved modules (predecessor's work). Let me run `go mod tidy` to pull everything including the new OTel deps.


Run go mod tidy to resolve dependencies


go: downloading github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
go: downloading github.com/andygrunwald/go-jira v1.17.0
go: downloading github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
go: downloading go.opentelemetry.io/otel/exporters/otlp v0.20.1
go: downloading github.com/andygrunwald/go-jira/v2 v2.0.0-20260113181222-a17356f7cb78
go: downloading go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.43.0
go: finding module for package go.opentelemetry.io/otel/metric
go: finding module for package go.opentelemetry.io/otel/sdk/metric
go: finding module for package go.opentelemetry.io/otel/sdk/resource
go: finding module for package go.opentelemetry.io/otel/semconv/v1.24.0
go: downloading github.com/gravitational/teleport v4.3.10+incompatible
go: found github.com/gravitational/teleport/api/client in github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
go: found github.com/gravitational/teleport/api/types in github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea
go: found github.com/pelletier/go-toml/v2 in github.com/pelletier/go-toml/v2 v2.3.0
go: found golang.org/x/sync/errgroup in golang.org/x/sync v0.20.0
go: found github.com/aws/aws-sdk-go-v2/aws in github.com/aws/aws-sdk-go-v2 v1.41.6
go: found github.com/aws/aws-sdk-go-v2/config in github.com/aws/aws-sdk-go-v2/config v1.32.16
go: found github.com/aws/aws-sdk-go-v2/service/bedrockruntime in github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.5
go: found github.com/andygrunwald/go-jira/v2/cloud in github.com/andygrunwald/go-jira/v2 v2.0.0-20260113181222-a17356f7cb78
go: found go.opentelemetry.io/otel in go.opentelemetry.io/otel v1.43.0
go: found go.opentelemetry.io/otel/attribute in go.opentelemetry.io/otel v1.43.0
go: found go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc in go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go: found go.opentelemetry.io/otel/metric in go.opentelemetry.io/otel/metric v1.43.0
go: found go.opentelemetry.io/otel/sdk/metric in go.opentelemetry.io/otel/sdk/metric v1.43.0
go: found go.opentelemetry.io/otel/sdk/resource in go.opentelemetry.io/otel/sdk v1.43.0
go: found go.opentelemetry.io/otel/semconv/v1.24.0 in go.opentelemetry.io/otel v1.43.0
go: downloading github.com/aws/smithy-go v1.25.0
go: downloading github.com/trivago/tgo v1.0.7
go: downloading github.com/aws/aws-sdk-go-v2/credentials v1.19.15
go: downloading github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9
go: downloading github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22
go: downloading github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22
go: downloading github.com/aws/aws-sdk-go-v2/service/signin v1.0.10
go: downloading github.com/aws/aws-sdk-go-v2/service/sso v1.30.16
go: downloading github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20
go: downloading github.com/aws/aws-sdk-go-v2/service/sts v1.42.0
go: downloading github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22
go: downloading github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8
go: downloading github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22


Build everything after tidy


(Bash completed with no output)


pwd && go build ./... && echo "BUILD OK"

Verify build succeeds in cwd


/Users/steve.briggs/projects/operations/apps/televibe
BUILD OK


Clean build. Now the app wiring.


Updated task #3 status


Edit

	"televibe/ai"
	"televibe/jira"
	"televibe/risk"
)

type Config struct {
	Teleport TeleportConfig `toml:"teleport"`
	AI       ai.Config      `toml:"ai"`
	Jira     jira.Config    `toml:"jira"`
	Risk     risk.Config    `toml:"risk"`
	Log      LogConfig      `toml:"log"`
}

	"televibe/ai"
	"televibe/jira"
	"televibe/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type Config struct {
	Teleport   TeleportConfig    `toml:"teleport"`
	AI         ai.Config         `toml:"ai"`
	Jira       jira.Config       `toml:"jira"`
	Risk       risk.Config       `toml:"risk"`
	Reputation reputation.Config `toml:"reputation"`
	Telemetry  telemetry.Config  `toml:"telemetry"`
	Log        LogConfig         `toml:"log"`
}