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


<local-command-stdout>Kept model as Opus 4.7 (1M context)</local-command-stdout>


Is it possible to approve session escalations in teleport the same way as access requests using the televibe plugin that we are writing?


- 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) on a cron schedule or once at a specific time. - 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. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- 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

List televibe directory contents


drwxr-xr-x steve.briggs staff 288 B  Wed Apr 22 16:44:47 2026 ..
.rw-r--r-- steve.briggs staff 4.0 MB Wed Apr 22 16:44:47 2026 logo.png
drwxr-xr-x steve.briggs staff 192 B  Wed Apr 22 16:44:47 2026 ai
drwxr-xr-x steve.briggs staff 128 B  Wed Apr 22 16:44:47 2026 risk
drwxr-xr-x steve.briggs staff 128 B  Thu Apr 23 10:56:08 2026 reputation
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 10:57:30 2026 telemetry
.rw-r--r-- steve.briggs staff 1.7 KB Thu Apr 23 10:58:58 2026 config.go
.rw-r--r-- steve.briggs staff 1.8 KB Thu Apr 23 11:02:52 2026 televibe.toml.example
.rw-r--r-- steve.briggs staff 6.1 KB Thu Apr 23 11:03:30 2026 README.md
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 11:03:39 2026 .claude
.rw-r--r-- steve.briggs staff 1.8 KB Thu Apr 23 11:28:11 2026 televibe.toml
.rw-r--r-- steve.briggs staff  12 KB Thu Apr 23 11:28:17 2026 .televibe.toml.swp
drwxr-xr-x steve.briggs staff 128 B  Thu Apr 23 11:43:08 2026 cmd
.rwxr-xr-x steve.briggs staff  58 MB Thu Apr 23 11:43:19 2026 submit
.rw-r--r-- steve.briggs staff 3.6 KB Thu Apr 23 16:16:58 2026 go.mod
.rw-r--r-- steve.briggs staff  18 KB Thu Apr 23 16:16:58 2026 go.sum
.rw-r--r-- steve.briggs staff  20 B  Thu Apr 23 16:22:50 2026 .gitignore
.rw-r--r-- steve.briggs staff  16 KB Thu Apr 23 17:05:40 2026 .app.go.swp
.rw-r--r-- steve.briggs staff 6.5 KB Thu Apr 23 17:59:51 2026 app.go
drwxr-xr-x steve.briggs staff 736 B  Thu Apr 23 17:59:51 2026 .
drwxr-xr-x steve.briggs staff 224 B  Thu Apr 23 17:59:51 2026 .idea
drwxr-xr-x steve.briggs staff  96 B  Thu Apr 23 18:09:52 2026 jira
drwxr-xr-x steve.briggs staff 448 B  Thu Apr 23 18:15:49 2026 local


Agent

Survey televibe plugin and Teleport integration

Prompt:
I need to understand an existing Go project called "televibe" at /Users/steve.briggs/projects/operations/apps/televibe/. It appears to be a Teleport access-request plugin that uses AI to evaluate requests based on a requester's reputation, a Jira ITSM ticket, and risk scoring. I want to know whether the Teleport API supports approving "session escalations" the same way it supports access requests, and whether the televibe plugin currently handles session escalations.

Please investigate and report back:
1. What does televibe currently do? What events does it watch from Teleport and what types of requests does it approve/deny? List the main files and their roles (app.go, config.go, ai/, risk/, reputation/, telemetry/, jira/, local/, cmd/) briefly.
2. Which Teleport Go SDK / client library is being used (check go.mod)? What version?
3. Does the Teleport API SDK in use expose anything related to "session escalations", "moderated sessions", "session approvals", "participant requests", or similar? Check imports and look for types in the vendor or module cache if needed — e.g. look for `types.AccessRequest`, and search for any SessionTracker / session join / moderated-session-related types in the same API package.
4. Does televibe's code currently subscribe to or handle any session-related watch events, or only access requests?

Report in under 400 words. Include concrete file paths and relevant function names so I can point the user at them. Do not modify any files.


Perfect. Now let me create a comprehensive summary based on all findings:

## Report: televibe Architecture and Session Escalation Support

### 1. What televibe currently does

**televibe** is a Teleport access-request plugin that evaluates access requests through AI-driven risk assessment. Here's what it does:

**Main workflow:**
- Watches Teleport for pending `AccessRequest` events (via `watchRequests()` in `/Users/steve.briggs/projects/operations/apps/televibe/app.go:121-156`)
- Scores each request using:
  - **AI assessment** (Claude via Anthropic/Bedrock/webhook) on description quality, privilege concern, anomalies
  - **Structural scoring** (deterministic): missing CUSTX ticket ref, thin descriptions, off-hours requests
- **Auto-approves** low-risk requests (below configurable threshold) via `SubmitAccessReview()`
- **Files Jira ITSM tickets** for higher-risk requests and holds them in `PENDING` state
- **Polls Jira** every 30s to reflect SRE approval/rejection decisions back to Teleport
- Tracks **per-user reputation** that decays with bad requests, published as telemetry

**Key files and roles:**
- `/Users/steve.briggs/projects/operations/apps/televibe/app.go` — event watcher loop, ITSM poller, request handler
- `/Users/steve.briggs/projects/operations/apps/televibe/config.go` — TOML configuration & validation
- `/Users/steve.briggs/projects/operations/apps/televibe/risk/score.go` — risk scoring engine
- `/Users/steve.briggs/projects/operations/apps/televibe/jira/itsm.go` — Jira ticket creation & status polling
- `/Users/steve.briggs/projects/operations/apps/televibe/reputation/reputation.go` — user reputation tracking
- `/Users/steve.briggs/projects/operations/apps/televibe/ai/` — provider implementations (Anthropic, Bedrock, webhook)
- `/Users/steve.briggs/projects/operations/apps/televibe/telemetry/telemetry.go` — OpenTelemetry metric export

### 2. Teleport SDK version

**Teleport API version:** `github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea` (pseudo-version from April 23, 2026)

### 3. Session escalation support in Teleport SDK

**No session escalation support is exposed.** The Teleport SDK currently only contains:
- `types.KindAccessRequest` (watched and handled)
- `types.AccessRequest` and `types.AccessReview` types
- `types.RequestState_PENDING`, `types.RequestState_APPROVED`, `types.RequestState_DENIED` states
- Client methods: `NewWatcher()`, `SubmitAccessReview()`, `CreateAccessRequestV2()`

**No types or enums found for:**
- `SessionTracker`, `ModeratedSession`, `ParticipantRequest`, or `SessionEscalation`
- `KindSessionJoin`, `KindModerationRequest`, or similar watch kinds
- Any session-specific review submission methods

### 4. Session event handling in televibe

**televibe does NOT handle session-related events.** Evidence:
- `/Users/steve.briggs/projects/operations/apps/televibe/app.go:122-124` watches **only** `types.KindAccessRequest`
- No code references sessions, moderation, escalations, or participant requests
- The `handleRequest()` method signature accepts only `types.AccessRequest`, not any session type
- The `submitReview()` method calls only `SubmitAccessReview()` for access requests

**Conclusion:** The Teleport API v0.0.0-20260423131902 in this project does not yet expose session escalations as a first-class resource type like it does for access requests. To handle session escalations in televibe, you would need a newer Teleport API SDK version that adds `KindSessionEscalation` or similar to the watcher, new event types, and corresponding `SubmitSessionReview()` or equivalent client methods.

Prompt:
I need to understand an existing Go project called "televibe" at /Users/steve.briggs/projects/operations/apps/televibe/. It appears to be a Teleport access-request plugin that uses AI to evaluate requests based on a requester's reputation, a Jira ITSM ticket, and risk scoring. I want to know whether the Teleport API supports approving "session escalations" the same way it supports access requests, and whether the televibe plugin currently handles session escalations.

Please investigate and report back:
1. What does televibe currently do? What events does it watch from Teleport and what types of requests does it approve/deny? List the main files and their roles (app.go, config.go, ai/, risk/, reputation/, telemetry/, jira/, local/, cmd/) briefly.
2. Which Teleport Go SDK / client library is being used (check go.mod)? What version?
3. Does the Teleport API SDK in use expose anything related to "session escalations", "moderated sessions", "session approvals", "participant requests", or similar? Check imports and look for types in the vendor or module cache if needed — e.g. look for `types.AccessRequest`, and search for any SessionTracker / session join / moderated-session-related types in the same API package.
4. Does televibe's code currently subscribe to or handle any session-related watch events, or only access requests?

Report in under 400 words. Include concrete file paths and relevant function names so I can point the user at them. Do not modify any files.


Short answer, Sir Charles of St Cheese: *not really* — at least not with what's in front of you.

Televibe today watches `types.KindAccessRequest` only (`app.go:122-124`) and approves via `SubmitAccessReview()`. The Teleport API pinned in `go.mod` (`github.com/gravitational/teleport/api v0.0.0-20260423131902-40e0dc19cfea`) doesn't expose session escalations as an equivalent first-class resource — no `KindSessionEscalation`, no moderated-session review method, no participant-request watch kind. So there is no symmetric `SubmitSessionReview()` you can wire the same scoring pipeline into.

What you *can* do, in roughly ascending effort:

1. **Moderated sessions via roles.** Teleport models "escalation" as moderated sessions gated by `require_session_join` / `join_sessions` role rules — a human moderator joins/approves live, not an async reviewer. Televibe isn't in that path at all.
2. **SessionTracker watch.** Newer Teleport API versions expose `types.KindSessionTracker` and participant-mode plumbing. If you bump the SDK, you could watch tracker events and nudge a moderator (Jira ticket, Slack ping) — but you still can't "approve" on their behalf from outside the session.
3. **Wrap it as an access request.** If the real goal is "let televibe gate privilege bumps mid-session," the path of least resistance is to make the escalation *be* an access request (short TTL role) and let the existing pipeline do its thing. That keeps `app.go`, `risk/`, and `jira/itsm.go` unchanged.

My recommendation: option 3 unless you have a specific moderated-session use case. Before I go wire anything, which flavor of "session escalation" are you picturing — the moderated-session/participant-join kind, or a mid-session role bump? Those answers point at very different code.


Goal: figure out whether televibe can approve Teleport session escalations like it does access requests. Current finding: the pinned Teleport SDK exposes no session-escalation resource, so symmetric approval isn't possible today. Next: you decide moderated-session vs mid-session role-bump so I know which path to build. (disable recaps in /config)


lets go with option 3


Excellent choice, Sir Charles — mostly because as I pitched it, option 3 requires approximately zero lines of code in televibe. The "work" is making whatever creates escalations submit an `AccessRequest` for a short-TTL role instead, and the existing pipeline eats it like any other request.

So before I start fabricating work to look busy, which of these did you actually have in mind?

1. **Pure convention** — document/show a Teleport role config (short `max_duration`, maybe a `session_escalation:true` annotation) so folks submit escalations as access requests. No televibe changes.
2. **Televibe learns to recognize them** — detect an annotation/label (e.g. `escalation=true`) on incoming `AccessRequest` events and tweak behavior: tighter risk thresholds, different Jira template, shorter ITSM SLA. Small, surgical changes in `risk/score.go` and `jira/itsm.go`.
3. **A helper CLI** — a `televibe escalate` subcommand that creates the short-TTL access request on the user's behalf so they don't have to hand-craft `tsh request create` incantations.

My pick would be **2** — it's the only one that genuinely uses televibe, and the blast radius is tiny. Which do you want?


option 2


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

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/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	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)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

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

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}

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()
	user := req.GetUser()
	log := slog.With("request_id", id, "user", user)

	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)

	assessment, err := a.rep.Assess(ctx, user, result.Components)
	if err != nil {
		log.Warn("reputation update failed", "err", err)
	}
	if len(assessment.Penalties) > 0 {
		reasons := make([]string, 0, len(assessment.Penalties))
		for _, p := range assessment.Penalties {
			reasons = append(reasons, p.Reason)
		}
		log.Info("reputation docked",
			"previous", assessment.Previous,
			"current", assessment.Score,
			"reasons", reasons)
	}

	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/reputation"
12		"televibe/risk"
13		"televibe/telemetry"
14	)
15
16	type Config struct {
17		Teleport   TeleportConfig    `toml:"teleport"`
18		AI         ai.Config         `toml:"ai"`
19		Jira       jira.Config       `toml:"jira"`
20		Risk       risk.Config       `toml:"risk"`
21		Reputation reputation.Config `toml:"reputation"`
22		Telemetry  telemetry.Config  `toml:"telemetry"`
23		Log        LogConfig         `toml:"log"`
24	}
25
26	type TeleportConfig struct {
27		Addr         string `toml:"addr"`
28		IdentityFile string `toml:"identity_file"`
29	}
30
31	type LogConfig struct {
32		Level string `toml:"level"`
33	}
34
35	func LoadConfig(path string) (*Config, error) {
36		data, err := os.ReadFile(path)
37		if err != nil {
38			return nil, fmt.Errorf("read config: %w", err)
39		}
40		cfg := &Config{}
41		if err := toml.Unmarshal(data, cfg); err != nil {
42			return nil, fmt.Errorf("parse config: %w", err)
43		}
44		cfg.applyDefaults()
45		if err := cfg.validate(); err != nil {
46			return nil, err
47		}
48		return cfg, nil
49	}
50
51	func (c *Config) applyDefaults() {
52		c.Jira.ApplyDefaults()
53		c.Risk.ApplyDefaults()
54		c.Reputation.ApplyDefaults()
55		c.Telemetry.ApplyDefaults()
56		if c.Log.Level == "" {
57			c.Log.Level = "info"
58		}
59	}
60
61	func (c *Config) validate() error {
62		if c.Teleport.Addr == "" {
63			return fmt.Errorf("teleport.addr is required")
64		}
65		if c.Teleport.IdentityFile == "" {
66			return fmt.Errorf("teleport.identity_file is required")
67		}
68		if err := c.AI.Validate(); err != nil {
69			return fmt.Errorf("ai: %w", err)
70		}
71		if err := c.Jira.Validate(); err != nil {
72			return fmt.Errorf("jira: %w", err)
73		}
74		if err := c.Reputation.Validate(); err != nil {
75			return fmt.Errorf("reputation: %w", err)
76		}
77		return nil
78	}
79

package televibe

import (
	"fmt"
	"os"

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

	"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"`
}

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()
	c.Reputation.ApplyDefaults()
	c.Telemetry.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)
	}
	if err := c.Reputation.Validate(); err != nil {
		return fmt.Errorf("reputation: %w", err)
	}
	return nil
}


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

package jira

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"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},
			},
		}
		b, err := json.MarshalIndent(issue, "", "  ")
		if err != nil {
			return "", fmt.Errorf("marshal jira issue: %w", err)
		}
		fmt.Println("Jira request body:")
		fmt.Println(string(b))

		created, _, err := c.jira.Issue.Create(ctx, issue)
		if err != nil {
			return "", fmt.Errorf("create jira issue: %w", err)
		}
		if created == nil {
			return "", fmt.Errorf("jira returned nil issue without error")
		}

		return created.Key, nil
	}
*/
func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
	if c.cfg.Project == "" {
		return "", fmt.Errorf("jira project key is empty")
	}
	if c.cfg.IssueType == "" {
		return "", fmt.Errorf("jira issue type is empty")
	}
	if in.RequestID == "" {
		return "", fmt.Errorf("request id is empty")
	}

	labels := []string{"televibe", "teleport-access"}
	if in.Tier != "" {
		labels = append(labels, "tier-"+strings.ToLower(in.Tier))
	}

	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:      labels,
		},
	}

	reqBody, err := json.MarshalIndent(issue, "", "  ")
	if err != nil {
		return "", fmt.Errorf("marshal jira issue: %w", err)
	}
	fmt.Println("Jira request body:")
	fmt.Println(string(reqBody))

	created, resp, err := c.jira.Issue.Create(ctx, issue)
	if err != nil {
		if resp != nil && resp.Body != nil {
			body, _ := io.ReadAll(resp.Body)
			fmt.Println("Jira error response:")
			fmt.Println(string(body))
		}
		return "", fmt.Errorf("create jira issue: %w", err)
	}

	if created == nil {
		return "", fmt.Errorf("jira returned nil issue without error")
	}

	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()
}


Bash

ls /Users/steve.briggs/projects/operations/apps/televibe/risk/ && echo '---' && grep -n "types.AccessRequest" /Users/steve.briggs/projects/operations/apps/televibe/risk/*.go

List risk package files and check AccessRequest usage


.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
drwxr-xr-x steve.briggs staff 736 B  Thu Apr 23 17:59:51 2026 ..
---
/Users/steve.briggs/projects/operations/apps/televibe/risk/score.go:38:func (s *Scorer) Score(ctx context.Context, prov ai.Provider, req types.AccessRequest) (*Result, error) {


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	[reputation]
47	# Per-user reputation, persisted as JSON on local disk. Every user starts
48	# at starting_score; points are deducted for bad requests and clamped to
49	# [floor_score, ceiling_score]. Reputation only goes down — the point is
50	# to shame, not to forgive.
51	persist_path              = "/var/lib/televibe/reputation.json"
52	starting_score            = 100
53	floor_score               = 0
54	ceiling_score             = 100
55	penalty_missing_custx     = 10
56	penalty_thin_description  = 5
57
58	[telemetry]
59	# Emit per-user reputation as an OTLP gauge for the collector to ship to
60	# Datadog. Disable to keep reputation state local only.
61	enabled       = true
62	endpoint      = "otel-collector:4317"
63	insecure      = true
64	service_name  = "televibe"
65	export_period = "15s"
66
67	[log]
68	level = "info"
69

# 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"

[reputation]
# Per-user reputation, persisted as JSON on local disk. Every user starts
# at starting_score; points are deducted for bad requests and clamped to
# [floor_score, ceiling_score]. Reputation only goes down — the point is
# to shame, not to forgive.
persist_path              = "/var/lib/televibe/reputation.json"
starting_score            = 100
floor_score               = 0
ceiling_score             = 100
penalty_missing_custx     = 10
penalty_thin_description  = 5

[telemetry]
# Emit per-user reputation as an OTLP gauge for the collector to ship to
# Datadog. Disable to keep reputation state local only.
enabled       = true
endpoint      = "otel-collector:4317"
insecure      = true
service_name  = "televibe"
export_period = "15s"

[log]
level = "info"


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
}


grep -rn "GetSystemAnnotations\|SystemAnnotations\|GetStaticLabels" "$(go env GOMODCACHE)/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go" 2>/dev/null | head -20

Check AccessRequest annotation methods in vendored Teleport API


/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:81:	// GetSystemAnnotations gets the teleport-applied annotations.
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:82:	GetSystemAnnotations() map[string][]string
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:83:	// SetSystemAnnotations sets the teleport-applied annotations.
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:84:	SetSystemAnnotations(map[string][]string)
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:374:// GetSystemAnnotations gets SystemAnnotations
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:375:func (r *AccessRequestV3) GetSystemAnnotations() map[string][]string {
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:376:	return r.Spec.SystemAnnotations
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:379:// SetSystemAnnotations sets SystemAnnotations
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:380:func (r *AccessRequestV3) SetSystemAnnotations(annotations map[string][]string) {
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:381:	r.Spec.SystemAnnotations = annotations
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:725:// GetStaticLabels returns the access request static labels.
/Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go:726:func (r *AccessRequestV3) GetStaticLabels() map[string]string {


Good — `GetSystemAnnotations()` is on the interface, so I have a clean hook without bumping the SDK. Here's the concrete plan, Sir Charles, before I start poking at bytes:

**Detection**
- Escalation is signalled by a Teleport system annotation (set via role config: `allow.request.annotations`). Default key: `televibe.escalation` with value `true`. Both configurable in TOML.

**`risk/risk.go`**
- Add `EscalationAnnotation string` (default `televibe.escalation`) and `EscalationThreshold int` (default `15` — tighter than the standard `25`).

**`risk/score.go`**
- Add `IsEscalation(req, key) bool` helper that checks `req.GetSystemAnnotations()[key]`.
- `Result` gains an `Escalation bool`. Scoring math unchanged — the only behavioral lever is the threshold, which keeps the pipeline honest.
- Add a small unit test for the helper (risk/ has no tests today, so I'll add a minimal `score_test.go`).

**`app.go`**
- In `handleRequest`, choose threshold: `EscalationThreshold` if `result.Escalation`, else `TicketThreshold`.
- Thread `Escalation` into `jira.TicketInput` and the log line.

**`jira/itsm.go`**
- `TicketInput.Escalation bool`.
- Summary becomes `[televibe][escalation] Session escalation {id} - {tier}` when set; label `televibe-escalation` added; description gets a short "Session escalation — SRE please prioritise" note. Nothing else about the Jira flow changes.

**`televibe.toml.example`**
- Document the new `risk.escalation_threshold` and `risk.escalation_annotation` keys.

Out of scope (explicitly): enforcing short TTL (that's the Teleport role's job), a new watch kind, any SDK bump.

One knob to confirm: **default escalation threshold of 15** — that means escalations auto-approve only if they're *very* clean (CUSTX ticket present, thick description, not off-hours). Too tight? Too loose? If you'd rather every escalation goes to ITSM regardless, we set the default to `0`. Your call.


lets set the default escalation threshold of 15 but it should be configurable, and preferrably configurable for different requesting roles


Per-role overrides are easy — `map[string]int` in TOML, strictest threshold wins when a request names multiple roles, global `escalation_threshold` is the fallback for roles without an override. Let me verify one SDK detail and then write it.


grep -n "func NewAccessRequest" /Users/steve.briggs/go/pkg/mod/github.com/gravitational/teleport/api@v0.0.0-20260423131902-40e0dc19cfea/types/access_request.go | head -5

Check NewAccessRequest signature


159:func NewAccessRequest(name string, user string, roles ...string) (AccessRequest, error) {
165:func NewAccessRequestWithResources(name string, user string, roles []string, resources []ResourceAccessID) (AccessRequest, error) {
1101:func NewAccessRequestAllowedPromotions(promotions []*AccessRequestAllowedPromotion) *AccessRequestAllowedPromotions {


Writing it now.


Edit

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 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"`

	// EscalationAnnotation is the Teleport system annotation key that marks
	// an access request as a session escalation. A request is treated as an
	// escalation when this key is present with any non-empty value.
	EscalationAnnotation string `toml:"escalation_annotation"`
	// EscalationThreshold is the fallback ticket threshold applied to
	// escalation-flavored requests when no role-specific override matches.
	EscalationThreshold int `toml:"escalation_threshold"`
	// EscalationThresholds overrides EscalationThreshold for specific
	// requested roles. The strictest (lowest) matching value wins when a
	// request names multiple roles.
	EscalationThresholds map[string]int `toml:"escalation_thresholds"`
}

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

// EscalationThresholdFor returns the strictest ticket threshold among the
// requested roles. A role without an explicit override uses the global
// EscalationThreshold. An empty role list also yields the global default.
func (c Config) EscalationThresholdFor(roles []string) int {
	if len(roles) == 0 {
		return c.EscalationThreshold
	}
	chosen := -1
	for _, role := range roles {
		t, ok := c.EscalationThresholds[role]
		if !ok {
			t = c.EscalationThreshold
		}
		if chosen == -1 || t < chosen {
			chosen = t
		}
	}
	return chosen
}


The file /Users/steve.briggs/projects/operations/apps/televibe/risk/risk.go has been updated successfully.

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"`

	// EscalationAnnotation is the Teleport system annotation key that marks
	// an access request as a session escalation. A request is treated as an
	// escalation when this key is present with any non-empty value.
	EscalationAnnotation string `toml:"escalation_annotation"`
	// EscalationThreshold is the fallback ticket threshold applied to
	// escalation-flavored requests when no role-specific override matches.
	EscalationThreshold int `toml:"escalation_threshold"`
	// EscalationThresholds overrides EscalationThreshold for specific
	// requested roles. The strictest (lowest) matching value wins when a
	// request names multiple roles.
	EscalationThresholds map[string]int `toml:"escalation_thresholds"`
}

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

// EscalationThresholdFor returns the strictest ticket threshold among the
// requested roles. A role without an explicit override uses the global
// EscalationThreshold. An empty role list also yields the global default.
func (c Config) EscalationThresholdFor(roles []string) int {
	if len(roles) == 0 {
		return c.EscalationThreshold
	}
	chosen := -1
	for _, role := range roles {
		t, ok := c.EscalationThresholds[role]
		if !ok {
			t = c.EscalationThreshold
		}
		if chosen == -1 || t < chosen {
			chosen = t
		}
	}
	return chosen
}

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
}

OffHoursEnd     int    `toml:"off_hours_end"`

Timezone        string `toml:"timezone"`

+  // EscalationAnnotation is the Teleport system annotation key that marks

+  // an access request as a session escalation. A request is treated as an

+  // escalation when this key is present with any non-empty value.

+  EscalationAnnotation string `toml:"escalation_annotation"`

+  // EscalationThreshold is the fallback ticket threshold applied to

+  // escalation-flavored requests when no role-specific override matches.

+  EscalationThreshold int `toml:"escalation_threshold"`

+  // EscalationThresholds overrides EscalationThreshold for specific

+  // requested roles. The strictest (lowest) matching value wins when a

+  EscalationThresholds map[string]int `toml:"escalation_thresholds"`

+    c.EscalationAnnotation = "televibe.escalation"

+// EscalationThresholdFor returns the strictest ticket threshold among the

+// requested roles. A role without an explicit override uses the global

+// EscalationThreshold. An empty role list also yields the global default.

+func (c Config) EscalationThresholdFor(roles []string) int {

+    t, ok := c.EscalationThresholds[role]


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

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


The file /Users/steve.briggs/projects/operations/apps/televibe/risk/score.go has been updated successfully.

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

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
}

Tier       Tier
Components map[string]int
Reasoning  string
+  Escalation bool
}
type Scorer struct {


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

	return &Result{
		Score:      score,
		Tier:       TierForScore(score),
		Components: components,
		Reasoning:  assessment.Reasoning,
		Escalation: IsEscalation(req, s.cfg.EscalationAnnotation),
	}, nil
}

// IsEscalation reports whether the access request carries the configured
// escalation annotation with a non-empty value.
func IsEscalation(req types.AccessRequest, key string) bool {
	if key == "" {
		return false
	}
	for _, v := range req.GetSystemAnnotations()[key] {
		if v != "" {
			return true
		}
	}
	return false
}


	return &Result{
		Score:      score,
		Tier:       TierForScore(score),
		Components: components,
		Reasoning:  assessment.Reasoning,
		Escalation: IsEscalation(req, s.cfg.EscalationAnnotation),
	}, nil
}

// IsEscalation reports whether the access request carries the configured
// escalation annotation with a non-empty value.
func IsEscalation(req types.AccessRequest, key string) bool {
	if key == "" {
		return false
	}
	for _, v := range req.GetSystemAnnotations()[key] {
		if v != "" {
			return true
		}
	}
	return false
}

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
	Escalation bool
}

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
}

+    Escalation: IsEscalation(req, s.cfg.EscalationAnnotation),

+// IsEscalation reports whether the access request carries the configured

+// escalation annotation with a non-empty value.

+func IsEscalation(req types.AccessRequest, key string) bool {

+  for _, v := range req.GetSystemAnnotations()[key] {


Now the app wiring and Jira side.


	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,
	})

	threshold := a.cfg.Risk.TicketThreshold
	if result.Escalation {
		threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
		log.Info("session escalation detected", "threshold", threshold)
	}

	if result.Score < threshold {
		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,
		Escalation:  result.Escalation,
	})


The file /Users/steve.briggs/projects/operations/apps/televibe/app.go has been updated successfully.

	threshold := a.cfg.Risk.TicketThreshold
	if result.Escalation {
		threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
		log.Info("session escalation detected", "threshold", threshold)
	}

	if result.Score < threshold {
		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,
		Escalation:  result.Escalation,
	})

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/reputation"
	"televibe/risk"
	"televibe/telemetry"
)

type App struct {
	cfg    *Config
	tele   *client.Client
	ai     ai.Provider
	jira   *jira.Client
	scorer *risk.Scorer
	rep    *reputation.Store
	meter  *telemetry.Meter

	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)
	}

	store, err := reputation.NewStore(cfg.Reputation)
	if err != nil {
		return nil, fmt.Errorf("reputation store: %w", err)
	}

	var meter *telemetry.Meter
	if cfg.Telemetry.Enabled {
		meter, err = telemetry.New(ctx, cfg.Telemetry, store)
		if err != nil {
			return nil, fmt.Errorf("telemetry: %w", err)
		}
	}

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

// Close flushes outstanding telemetry. Safe to call even if telemetry
// is disabled.
func (a *App) Close(ctx context.Context) error {
	return a.meter.Shutdown(ctx)
}

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()
	user := req.GetUser()
	log := slog.With("request_id", id, "user", user)

	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)

	assessment, err := a.rep.Assess(ctx, user, result.Components)
	if err != nil {
		log.Warn("reputation update failed", "err", err)
	}
	if len(assessment.Penalties) > 0 {
		reasons := make([]string, 0, len(assessment.Penalties))
		for _, p := range assessment.Penalties {
			reasons = append(reasons, p.Reason)
		}
		log.Info("reputation docked",
			"previous", assessment.Previous,
			"current", assessment.Score,
			"reasons", reasons)
	}

	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
}

-  if result.Score < a.cfg.Risk.TicketThreshold {

+  threshold := a.cfg.Risk.TicketThreshold

+    threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())

+    log.Info("session escalation detected", "threshold", threshold)


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

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


The file /Users/steve.briggs/projects/operations/apps/televibe/jira/itsm.go has been updated successfully.

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

package jira

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"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},
			},
		}
		b, err := json.MarshalIndent(issue, "", "  ")
		if err != nil {
			return "", fmt.Errorf("marshal jira issue: %w", err)
		}
		fmt.Println("Jira request body:")
		fmt.Println(string(b))

		created, _, err := c.jira.Issue.Create(ctx, issue)
		if err != nil {
			return "", fmt.Errorf("create jira issue: %w", err)
		}
		if created == nil {
			return "", fmt.Errorf("jira returned nil issue without error")
		}

		return created.Key, nil
	}
*/
func (c *Client) CreateITSMTicket(ctx context.Context, in TicketInput) (string, error) {
	if c.cfg.Project == "" {
		return "", fmt.Errorf("jira project key is empty")
	}
	if c.cfg.IssueType == "" {
		return "", fmt.Errorf("jira issue type is empty")
	}
	if in.RequestID == "" {
		return "", fmt.Errorf("request id is empty")
	}

	labels := []string{"televibe", "teleport-access"}
	if in.Tier != "" {
		labels = append(labels, "tier-"+strings.ToLower(in.Tier))
	}

	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:      labels,
		},
	}

	reqBody, err := json.MarshalIndent(issue, "", "  ")
	if err != nil {
		return "", fmt.Errorf("marshal jira issue: %w", err)
	}
	fmt.Println("Jira request body:")
	fmt.Println(string(reqBody))

	created, resp, err := c.jira.Issue.Create(ctx, issue)
	if err != nil {
		if resp != nil && resp.Body != nil {
			body, _ := io.ReadAll(resp.Body)
			fmt.Println("Jira error response:")
			fmt.Println(string(body))
		}
		return "", fmt.Errorf("create jira issue: %w", err)
	}

	if created == nil {
		return "", fmt.Errorf("jira returned nil issue without error")
	}

	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()
}

Slang       string
Components  map[string]int
AIReasoning string
+  Escalation  bool
}
type Client struct {


	labels := []string{"televibe", "teleport-access"}
	if in.Tier != "" {
		labels = append(labels, "tier-"+strings.ToLower(in.Tier))
	}

	summary := fmt.Sprintf("[televibe] Access request %s - %s (%s)", in.RequestID, in.Tier, in.Slang)

	labels := []string{"televibe", "teleport-access"}
	if in.Tier != "" {
		labels = append(labels, "tier-"+strings.ToLower(in.Tier))
	}
	if in.Escalation {
		labels = append(labels, "televibe-escalation")
	}

	summary := fmt.Sprintf("[televibe] Access request %s - %s (%s)", in.RequestID, in.Tier, in.Slang)
	if in.Escalation {
		summary = fmt.Sprintf("[televibe][escalation] Session escalation %s - %s (%s)", in.RequestID, in.Tier, in.Slang)
	}