<memvid-mind-context>
# 🧠 Claude Mind Ready

📁 Project: **ruth**
💾 Memory will be created at: `.claude/mind.mv2`

_Your observations will be automatically captured._
</memvid-mind-context>


<EXTREMELY_IMPORTANT>
You have superpowers.

**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**

---
name: using-superpowers
description: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
---

<SUBAGENT-STOP>
If you were dispatched as a subagent to execute a specific task, skip this skill.
</SUBAGENT-STOP>

<EXTREMELY-IMPORTANT>
If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill.

IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.

This is not negotiable. This is not optional. You cannot rationalize your way out of this.
</EXTREMELY-IMPORTANT>

## Instruction Priority

Superpowers skills override default system prompt behavior, but **user instructions always take precedence**:

1. **User's explicit instructions** (CLAUDE.md, GEMINI.md, AGENTS.md, direct requests) — highest priority
2. **Superpowers skills** — override default system behavior where they conflict
3. **Default system prompt** — lowest priority

If CLAUDE.md, GEMINI.md, or AGENTS.md says "don't use TDD" and a skill says "always use TDD," follow the user's instructions. The user is in control.

## How to Access Skills

**In Claude Code:** Use the `Skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly. Never use the Read tool on skill files.

**In Copilot CLI:** Use the `skill` tool. Skills are auto-discovered from installed plugins. The `skill` tool works the same as Claude Code's `Skill` tool.

**In Gemini CLI:** Skills activate via the `activate_skill` tool. Gemini loads skill metadata at session start and activates the full content on demand.

**In other environments:** Check your platform's documentation for how skills are loaded.

## Platform Adaptation

Skills use Claude Code tool names. Non-CC platforms: see `references/copilot-tools.md` (Copilot CLI), `references/codex-tools.md` (Codex) for tool equivalents. Gemini CLI users get the tool mapping loaded automatically via GEMINI.md.

# Using Skills

## The Rule

**Invoke relevant or requested skills BEFORE any response or action.** Even a 1% chance a skill might apply means that you should invoke the skill to check. If an invoked skill turns out to be wrong for the situation, you don't need to use it.

```dot
digraph skill_flow {
    "User message received" [shape=doublecircle];
    "About to EnterPlanMode?" [shape=doublecircle];
    "Already brainstormed?" [shape=diamond];
    "Invoke brainstorming skill" [shape=box];
    "Might any skill apply?" [shape=diamond];
    "Invoke Skill tool" [shape=box];
    "Announce: 'Using [skill] to [purpose]'" [shape=box];
    "Has checklist?" [shape=diamond];
    "Create TodoWrite todo per item" [shape=box];
    "Follow skill exactly" [shape=box];
    "Respond (including clarifications)" [shape=doublecircle];

    "About to EnterPlanMode?" -> "Already brainstormed?";
    "Already brainstormed?" -> "Invoke brainstorming skill" [label="no"];
    "Already brainstormed?" -> "Might any skill apply?" [label="yes"];
    "Invoke brainstorming skill" -> "Might any skill apply?";

    "User message received" -> "Might any skill apply?";
    "Might any skill apply?" -> "Invoke Skill tool" [label="yes, even 1%"];
    "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"];
    "Invoke Skill tool" -> "Announce: 'Using [skill] to [purpose]'";
    "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?";
    "Has checklist?" -> "Create TodoWrite todo per item" [label="yes"];
    "Has checklist?" -> "Follow skill exactly" [label="no"];
    "Create TodoWrite todo per item" -> "Follow skill exactly";
}
```

## Red Flags

These thoughts mean STOP—you're rationalizing:

| Thought | Reality |
|---------|---------|
| "This is just a simple question" | Questions are tasks. Check for skills. |
| "I need more context first" | Skill check comes BEFORE clarifying questions. |
| "Let me explore the codebase first" | Skills tell you HOW to explore. Check first. |
| "I can check git/files quickly" | Files lack conversation context. Check for skills. |
| "Let me gather information first" | Skills tell you HOW to gather information. |
| "This doesn't need a formal skill" | If a skill exists, use it. |
| "I remember this skill" | Skills evolve. Read current version. |
| "This doesn't count as a task" | Action = task. Check for skills. |
| "The skill is overkill" | Simple things become complex. Use it. |
| "I'll just do this one thing first" | Check BEFORE doing anything. |
| "This feels productive" | Undisciplined action wastes time. Skills prevent this. |
| "I know what that means" | Knowing the concept ≠ using the skill. Invoke it. |

## Skill Priority

When multiple skills could apply, use this order:

1. **Process skills first** (brainstorming, debugging) - these determine HOW to approach the task
2. **Implementation skills second** (frontend-design, mcp-builder) - these guide execution

"Let's build X" → brainstorming first, then implementation skills.
"Fix this bug" → debugging first, then domain-specific skills.

## Skill Types

**Rigid** (TDD, debugging): Follow exactly. Don't adapt away discipline.

**Flexible** (patterns): Adapt principles to context.

The skill itself tells you which.

## User Instructions

Instructions say WHAT, not HOW. "Add X" or "Fix Y" doesn't mean skip workflows.

</EXTREMELY_IMPORTANT>


<memvid-mind-context>
# 🧠 Claude Mind Ready

📁 Project: **ruth**
💾 Memory will be created at: `.claude/mind.mv2`

_Your observations will be automatically captured._
</memvid-mind-context>

<EXTREMELY_IMPORTANT>
You have superpowers.

**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**

---
name: using-superpowers
description: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
---

<SUBAGENT-STOP>
If you were dispatched as a subagent to execute a specific task, skip this skill.
</SUBAGENT-STOP>

<EXTREMELY-IMPORTANT>
If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill.

IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.

This is not negotiable. This is not optional. You cannot rationalize your way out of this.
</EXTREMELY-IMPORTANT>

## Instruction Priority

Superpowers skills override default system prompt behavior, but **user instructions always take precedence**:

1. **User's explicit instructions** (CLAUDE.md, GEMINI.md, AGENTS.md, direct requests) — highest priority
2. **Superpowers skills** — override default system behavior where they conflict
3. **Default system prompt** — lowest priority

If CLAUDE.md, GEMINI.md, or AGENTS.md says "don't use TDD" and a skill says "always use TDD," follow the user's instructions. The user is in control.

## How to Access Skills

**In Claude Code:** Use the `Skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly. Never use the Read tool on skill files.

**In Copilot CLI:** Use the `skill` tool. Skills are auto-discovered from installed plugins. The `skill` tool works the same as Claude Code's `Skill` tool.

**In Gemini CLI:** Skills activate via the `activate_skill` tool. Gemini loads skill metadata at session start and activates the full content on demand.

**In other environments:** Check your platform's documentation for how skills are loaded.

## Platform Adaptation

Skills use Claude Code tool names. Non-CC platforms: see `references/copilot-tools.md` (Copilot CLI), `references/codex-tools.md` (Codex) for tool equivalents. Gemini CLI users get the tool mapping loaded automatically via GEMINI.md.

# Using Skills

## The Rule

**Invoke relevant or requested skills BEFORE any response or action.** Even a 1% chance a skill might apply means that you should invoke the skill to check. If an invoked skill turns out to be wrong for the situation, you don't need to use it.

```dot
digraph skill_flow {
    "User message received" [shape=doublecircle];
    "About to EnterPlanMode?" [shape=doublecircle];
    "Already brainstormed?" [shape=diamond];
    "Invoke brainstorming skill" [shape=box];
    "Might any skill apply?" [shape=diamond];
    "Invoke Skill tool" [shape=box];
    "Announce: 'Using [skill] to [purpose]'" [shape=box];
    "Has checklist?" [shape=diamond];
    "Create TodoWrite todo per item" [shape=box];
    "Follow skill exactly" [shape=box];
    "Respond (including clarifications)" [shape=doublecircle];

    "About to EnterPlanMode?" -> "Already brainstormed?";
    "Already brainstormed?" -> "Invoke brainstorming skill" [label="no"];
    "Already brainstormed?" -> "Might any skill apply?" [label="yes"];
    "Invoke brainstorming skill" -> "Might any skill apply?";

    "User message received" -> "Might any skill apply?";
    "Might any skill apply?" -> "Invoke Skill tool" [label="yes, even 1%"];
    "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"];
    "Invoke Skill tool" -> "Announce: 'Using [skill] to [purpose]'";
    "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?";
    "Has checklist?" -> "Create TodoWrite todo per item" [label="yes"];
    "Has checklist?" -> "Follow skill exactly" [label="no"];
    "Create TodoWrite todo per item" -> "Follow skill exactly";
}
```

## Red Flags

These thoughts mean STOP—you're rationalizing:

| Thought | Reality |
|---------|---------|
| "This is just a simple question" | Questions are tasks. Check for skills. |
| "I need more context first" | Skill check comes BEFORE clarifying questions. |
| "Let me explore the codebase first" | Skills tell you HOW to explore. Check first. |
| "I can check git/files quickly" | Files lack conversation context. Check for skills. |
| "Let me gather information first" | Skills tell you HOW to gather information. |
| "This doesn't need a formal skill" | If a skill exists, use it. |
| "I remember this skill" | Skills evolve. Read current version. |
| "This doesn't count as a task" | Action = task. Check for skills. |
| "The skill is overkill" | Simple things become complex. Use it. |
| "I'll just do this one thing first" | Check BEFORE doing anything. |
| "This feels productive" | Undisciplined action wastes time. Skills prevent this. |
| "I know what that means" | Knowing the concept ≠ using the skill. Invoke it. |

## Skill Priority

When multiple skills could apply, use this order:

1. **Process skills first** (brainstorming, debugging) - these determine HOW to approach the task
2. **Implementation skills second** (frontend-design, mcp-builder) - these guide execution

"Let's build X" → brainstorming first, then implementation skills.
"Fix this bug" → debugging first, then domain-specific skills.

## Skill Types

**Rigid** (TDD, debugging): Follow exactly. Don't adapt away discipline.

**Flexible** (patterns): Adapt principles to context.

The skill itself tells you which.

## User Instructions

Instructions say WHAT, not HOW. "Add X" or "Fix Y" doesn't mean skip workflows.

</EXTREMELY_IMPORTANT>


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


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


<local-command-stdout>Using custom theme "Challenger Deep"</local-command-stdout>


brainstorm some features/improvements we could make to RUTH


## claude.ai Context7
Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs.

Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.


- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. 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.
- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- superpowers:execute-plan: Deprecated - use the superpowers:executing-plans skill instead
- superpowers:write-plan: Deprecated - use the superpowers:writing-plans skill instead
- superpowers:brainstorm: Deprecated - use the superpowers:brainstorming skill instead
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - creates isolated git worktrees with smart directory selection and safety verification
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- 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


Brainstorm features and improvements for RUTH


Launching skill: superpowers:brainstorming


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming

# Brainstorming Ideas Into Designs

Help turn ideas into fully formed designs and specs through natural collaborative dialogue.

Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval.

<HARD-GATE>
Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity.
</HARD-GATE>

## Anti-Pattern: "This Is Too Simple To Need A Design"

Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval.

## Checklist

You MUST create a task for each of these items and complete them in order:

1. **Explore project context** — check files, docs, recent commits
2. **Offer visual companion** (if topic will involve visual questions) — this is its own message, not combined with a clarifying question. See the Visual Companion section below.
3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria
4. **Propose 2-3 approaches** — with trade-offs and your recommendation
5. **Present design** — in sections scaled to their complexity, get user approval after each section
6. **Write design doc** — save to `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` and commit
7. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope (see below)
8. **User reviews written spec** — ask user to review the spec file before proceeding
9. **Transition to implementation** — invoke writing-plans skill to create implementation plan

## Process Flow

```dot
digraph brainstorming {
    "Explore project context" [shape=box];
    "Visual questions ahead?" [shape=diamond];
    "Offer Visual Companion\n(own message, no other content)" [shape=box];
    "Ask clarifying questions" [shape=box];
    "Propose 2-3 approaches" [shape=box];
    "Present design sections" [shape=box];
    "User approves design?" [shape=diamond];
    "Write design doc" [shape=box];
    "Spec self-review\n(fix inline)" [shape=box];
    "User reviews spec?" [shape=diamond];
    "Invoke writing-plans skill" [shape=doublecircle];

    "Explore project context" -> "Visual questions ahead?";
    "Visual questions ahead?" -> "Offer Visual Companion\n(own message, no other content)" [label="yes"];
    "Visual questions ahead?" -> "Ask clarifying questions" [label="no"];
    "Offer Visual Companion\n(own message, no other content)" -> "Ask clarifying questions";
    "Ask clarifying questions" -> "Propose 2-3 approaches";
    "Propose 2-3 approaches" -> "Present design sections";
    "Present design sections" -> "User approves design?";
    "User approves design?" -> "Present design sections" [label="no, revise"];
    "User approves design?" -> "Write design doc" [label="yes"];
    "Write design doc" -> "Spec self-review\n(fix inline)";
    "Spec self-review\n(fix inline)" -> "User reviews spec?";
    "User reviews spec?" -> "Write design doc" [label="changes requested"];
    "User reviews spec?" -> "Invoke writing-plans skill" [label="approved"];
}
```

**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans.

## The Process

**Understanding the idea:**

- Check out the current project state first (files, docs, recent commits)
- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first.
- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle.
- For appropriately-scoped projects, ask questions one at a time to refine the idea
- Prefer multiple choice questions when possible, but open-ended is fine too
- Only one question per message - if a topic needs more exploration, break it into multiple questions
- Focus on understanding: purpose, constraints, success criteria

**Exploring approaches:**

- Propose 2-3 different approaches with trade-offs
- Present options conversationally with your recommendation and reasoning
- Lead with your recommended option and explain why

**Presenting the design:**

- Once you believe you understand what you're building, present the design
- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced
- Ask after each section whether it looks right so far
- Cover: architecture, components, data flow, error handling, testing
- Be ready to go back and clarify if something doesn't make sense

**Design for isolation and clarity:**

- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently
- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on?
- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work.
- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much.

**Working in existing codebases:**

- Explore the current structure before proposing changes. Follow existing patterns.
- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in.
- Don't propose unrelated refactoring. Stay focused on what serves the current goal.

## After the Design

**Documentation:**

- Write the validated design (spec) to `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`
  - (User preferences for spec location override this default)
- Use elements-of-style:writing-clearly-and-concisely skill if available
- Commit the design document to git

**Spec Self-Review:**
After writing the spec document, look at it with fresh eyes:

1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections, or vague requirements? Fix them.
2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions?
3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition?
4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit.

Fix any issues inline. No need to re-review — just fix and move on.

**User Review Gate:**
After the spec review loop passes, ask the user to review the written spec before proceeding:

> "Spec written and committed to `<path>`. Please review it and let me know if you want to make any changes before we start writing out the implementation plan."

Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves.

**Implementation:**

- Invoke the writing-plans skill to create a detailed implementation plan
- Do NOT invoke any other skill. writing-plans is the next step.

## Key Principles

- **One question at a time** - Don't overwhelm with multiple questions
- **Multiple choice preferred** - Easier to answer than open-ended when possible
- **YAGNI ruthlessly** - Remove unnecessary features from all designs
- **Explore alternatives** - Always propose 2-3 approaches before settling
- **Incremental validation** - Present design, get approval before moving on
- **Be flexible** - Go back and clarify when something doesn't make sense

## Visual Companion

A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser.

**Offering the companion:** When you anticipate that upcoming questions will involve visual content (mockups, layouts, diagrams), offer it once for consent:
> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and can be token-intensive. Want to try it? (Requires opening a local URL)"

**This offer MUST be its own message.** Do not combine it with clarifying questions, context summaries, or any other content. The message should contain ONLY the offer above and nothing else. Wait for the user's response before continuing. If they decline, proceed with text-only brainstorming.

**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?**

- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs
- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions

A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser.

If they agree to the companion, read the detailed guide before proceeding:
`skills/brainstorming/visual-companion.md`


ToolSearch

Question: select:TaskCreate


Question: select:TaskCreate


Bash

ls /Users/steve.briggs/projects/operations/apps/ruth/ && echo "---" && git -C /Users/steve.briggs/projects/operations log --oneline -10 -- apps/ruth/

List ruth dir and recent ruth commits


.rw-r--r-- steve.briggs staff 1.8 KB Tue Apr 28 15:21:20 2026 ruth.toml
drwxr-xr-x steve.briggs staff 224 B  Tue Apr 28 15:21:20 2026 .idea
.rw-r--r-- steve.briggs staff  16 KB Tue Apr 28 15:21:20 2026 .app.go.swp
drwxr-xr-x steve.briggs staff 320 B  Tue Apr 28 19:53:17 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Wed Apr 29 17:38:25 2026 .claude
.rw-r--r-- steve.briggs staff  47 B  Mon May  4 17:12:13 2026 .gitignore
.rw-r--r-- steve.briggs staff 7.6 KB Mon May  4 17:12:13 2026 CLAUDE.md
.rw-r--r-- steve.briggs staff 9.4 KB Mon May  4 17:12:13 2026 README.md
drwxr-xr-x steve.briggs staff 160 B  Mon May  4 17:12:13 2026 ai
.rw-r--r-- steve.briggs staff 9.1 KB Mon May  4 17:12:13 2026 app.go
.rw-r--r-- steve.briggs staff 2.1 KB Mon May  4 17:12:13 2026 app_test.go
drwxr-xr-x steve.briggs staff 128 B  Mon May  4 17:12:13 2026 cmd
.rw-r--r-- steve.briggs staff 2.3 KB Mon May  4 17:12:13 2026 config.go
.rw-r--r-- steve.briggs staff 3.6 KB Mon May  4 17:12:13 2026 config_test.go
.rw-r--r-- steve.briggs staff 2.6 KB Mon May  4 17:12:13 2026 env.go
.rw-r--r-- steve.briggs staff 3.5 KB Mon May  4 17:12:13 2026 env_test.go
.rw-r--r-- steve.briggs staff 3.6 KB Mon May  4 17:12:13 2026 go.mod
.rw-r--r-- steve.briggs staff  18 KB Mon May  4 17:12:13 2026 go.sum
drwxr-xr-x steve.briggs staff 128 B  Mon May  4 17:12:13 2026 jira
drwxr-xr-x steve.briggs staff 416 B  Mon May  4 17:12:13 2026 local
.rw-r--r-- steve.briggs staff 1.5 MB Mon May  4 17:12:13 2026 logo.png
drwxr-xr-x steve.briggs staff 128 B  Mon May  4 17:12:13 2026 reputation
drwxr-xr-x steve.briggs staff 160 B  Mon May  4 17:12:13 2026 risk
.rw-r--r-- steve.briggs staff 3.5 KB Mon May  4 17:12:13 2026 ruth.toml.example
drwxr-xr-x steve.briggs staff 832 B  Mon May  4 17:12:13 2026 .
drwxr-xr-x steve.briggs staff  96 B  Mon May  4 17:12:13 2026 telemetry
---
f888538 feat(RUTH): ruth-plugin (#256)


TaskCreate

Explore project context

Skim CLAUDE.md, directory layout, and recent commits to understand RUTH's current state


Task #1 created successfully: Explore project context

Explore project context


Clarify brainstorm scope/goal

Ask whether this is a wide ideation pass or a single feature to ship next, and what categories matter


Task #2 created successfully: Clarify brainstorm scope/goal

Clarify brainstorm scope/goal


Propose 2-3 approaches/groupings

Once scope is clear, present categories of improvements with trade-offs


Task #3 created successfully: Propose 2-3 approaches/groupings

Propose 2-3 approaches/groupings


Present design for chosen feature

Once narrowed, present design and gather approval section by section


Task #4 created successfully: Present design for chosen feature

Present design for chosen feature


Write design doc to docs/superpowers/specs

Save validated design to docs/superpowers/specs/YYYY-MM-DD-topic-design.md and commit


Task #5 created successfully: Write design doc to docs/superpowers/specs


Spec self-review + user review

Inline check for placeholders/contradictions/scope, then ask user to review the spec file


Task #6 created successfully: Spec self-review + user review

Spec self-review + user review


Hand off to writing-plans skill

Invoke writing-plans to produce implementation plan once spec is approved


Task #7 created successfully: Hand off to writing-plans skill

Hand off to writing-plans skill


Sir Charles of St Cheese — context skimmed. CLAUDE.md is rather thorough (almost suspiciously so), so I'll spare us the tour and get to the question.

A "brainstorm" can mean two very different things, and the skill I'm running insists I drag this through to a design doc + implementation plan. So before I start spitballing — what's the actual destination?

**Pick one:**

**A.** *Wide ideation pass.* You want a long list of candidate features/improvements across categories (security, ops/UX, observability, AI/risk, integrations, hardening). We don't pick one today — output is the menu, not the meal.

**B.** *Pick-one-to-ship.* You have a vague itch and want me to surface candidates so you can pick the one we then design + plan + build next.

**C.** *Specific area, go deep.* You already know roughly where you want to improve (e.g. "the AI scoring is too noisy" or "reputation needs more dimensions") and want me to dig into that area only.

If it's A, I can do that and we'll stop short of the design-doc machinery — just say so and I'll skip the rest of the checklist. If it's B or C, we proceed to design and plan.


afplay ~/.claude/sounds/mario/stop/stomp.wav 2>/dev/null || true


A


Roger that, Sir Charles — menu mode. No design doc, no plan, no implementation. I'll group by category, keep each idea to a line or two, and put a star next to the ones I'd actually fight for.

Quick caveat: I'm working off CLAUDE.md plus a directory listing — I haven't read the source. So if I propose something that already exists, blame the documentation, not me.

---

## Risk / AI scoring

- ★ **Shadow mode for risk policy.** Run a candidate scoring config (new prompt, new weights, new tier table) alongside production without acting on it. Log the delta. Lets you iterate on the prompt without praying.
- ★ **Decision feedback loop.** When an SRE resolves a Jira ticket (approve/deny), record that as a label paired with the score we generated. You don't need a model — even a weekly "calibration report" (false-positives, false-negatives, drift) is gold. Right now you're flying blind on whether the AI is any good.
- **AI confidence + double-shot.** Run the AI twice for high-stakes requests; if the two assessments disagree materially, treat that disagreement as an anomaly signal. Cheap insurance against a flaky model.
- **Risk model versioning.** Stamp every decision with `risk_policy_version` so you can correlate behavior changes to config changes after the fact.
- **Per-user / per-role baseline anomaly.** Beyond reputation, learn each user's typical pattern (roles, resources, time-of-day). Score deviations from *their* baseline, not just global thresholds.
- **Off-hours by user timezone.** Globally-defined off-hours penalize Tokyo for working at 9am.
- **Few-shot calibration corpus, refreshed from real tickets.** Periodically rebuild the prompt's calibration examples from recent resolved tickets so the model stays grounded.
- **Pluggable second-opinion provider.** Multi-provider consensus (without re-adding Anthropic, per CLAUDE.md). E.g. Bedrock + a smaller local model for cross-checking.
- **Gradient outcomes.** Today: auto-approve OR ticket. Add intermediate options — auto-approve-with-shorter-TTL, auto-approve-with-notify, auto-approve-with-step-up-mfa.
- **Prompt injection red-team corpus.** Frozen test suite of attack reasons that must never lower the score. Run in CI.

## Reputation

- ★ **Reputation can heal.** Right now it's monotonic-down, which means new joiners and reformed offenders are forever cooked. Add slow regeneration with caps and cooldowns. (Or a clear policy reason for why it should never go up — but that should be explicit, not incidental.)
- **Reputation decay.** Old infractions matter less; weight events by recency.
- **Manager override with audit.** Let SREs adjust a user's reputation with a required reason; persist the override.
- **`ruth whoami` / `ruth reputation <user>` CLI.** Cheap transparency. Today it's an opaque OTLP gauge.
- **Per-team / per-role aggregates.** Surface "this team is trending down" before any single user is.
- **Storage upgrade.** The atomic-rename JSON file is fine until it isn't. SQLite is a one-line dependency, gives you queries, removes the "hold mutex through fsync" footgun.
- **Reputation tier visible in AI prompt.** Let the AI know the user's tier; calibrate accordingly.

## Observability

- ★ **Decision audit log (durable, structured).** A separate, append-only record of every inbound request, the components of its score, the decision, and the outcome. Slog-to-stderr is fine for ops; this is for forensics + the feedback loop above. Could be JSONL on disk, S3, or a Postgres table.
- **OTel traces end-to-end.** Watcher → scorer → AI → Jira → poller as connected spans. You'll find latency you didn't know you had.
- **More metrics.** Request counter, decision counter by outcome, AI latency, Jira API latency, score histogram, reputation delta histogram, ticket-resolution latency.
- **Health endpoint with dependency checks.** Teleport reachable? Bedrock reachable? Jira reachable? Today the answer is "guess from logs."
- **pprof in production (gated).** When something hangs, you'll want it.
- **Datadog dashboard + monitor JSON in repo.** Source-of-truth dashboards instead of click-ops.
- **SLOs.** "p95 watcher → decision under N seconds, 99.5% of requests." Concrete budget for AI/Jira flakiness.

## Integrations / extensibility

- ★ **Jira webhook receiver instead of polling.** Push-based. Removes polling cost, removes wiki-bold-regex re-hydration drama (kind of), reduces latency on resolution.
- **Slack notifier.** Post the decision + ticket link to a channel. Approve/deny buttons in a follow-up if you're feeling brave.
- **Pluggable ITSM backend.** Interface-ify Jira so a future ServiceNow / Linear / GitHub-Issues team can swap. CLAUDE.md says "small interfaces" — this is a textbook one.
- **Pluggable approval channel.** Same shape — Jira today, Slack interactive / Teams / pager-rotation tomorrow.
- **Decisions export.** S3/Snowflake/BigQuery sink for the audit log so analytics can chew on it.
- **Multi-cluster Teleport.** One RUTH, many Teleport clusters. Probably a 2x complexity bump in the watcher; flag it before implementing.

## Operability / UX

- **Hot-reload config on SIGHUP.** Don't restart to bump a threshold.
- **Bulk replay tool.** Feed a JSONL of historical access requests through the scorer offline to test prompt/weight changes. Pairs with shadow mode.
- **`ruth diff-config` / `ruth explain <request-id>`.** Ops ergonomics — show what config is loaded; show why a specific request got the score it got.
- **Soak test harness.** Synthesize realistic request patterns at scale; surface goroutine/leak/lock issues.
- **Better local stack.** Seed users with reputation, prepopulate canned scenarios, ship `submit` with a YAML library of attack/benign/edge cases.
- **Dry-run sink.** `RUTH_DRY_RUN=true` already exists; add a sink that writes "would have done X" decisions to a file for offline review.

## Hardening

- **Input size limits on request reasons.** Cap the reason text and reject (or truncate + flag) above the limit. Cheap DoS / cost-control / prompt-injection mitigation.
- **Reason canonicalization.** Strip control characters, normalize unicode, kill zero-width/RTL tricks before the reason hits the AI.
- **Idempotency keys on Jira ticket creation.** "Created two tickets for one request" is a class of bug worth designing out.
- **Rate limit / circuit break Teleport, Jira, Bedrock.** When one is sad, don't make it sadder.
- **PII scrubber on reasons before AI.** Email/IP/SSN regex strip with replacement tokens. Useful for compliance more than security.
- **Per-role policy overrides.** Some roles must always ticket regardless of score; others (read-only, very-low-blast-radius) auto-approve up to a higher threshold.
- **Property-based tests on scoring.** Invariants like "adding an escalation never lowers the score."
- **Fuzz the AI JSON parser.** It's parsing untrusted-ish output.
- **Goroutine-leak / chaos tests.** Kill Teleport mid-flight, kill Jira mid-poll, verify recovery — given the errgroup + telemetry-grace dance, this is exactly the area where a regression hides.

## Compliance / governance

- **Per-tenant data residency.** Don't send EU request reasons to a US Bedrock region. Today's config probably assumes one region — make it explicit.
- **Configurable retention.** Reputation history, decision audit, AI request/response — each gets its own retention policy.
- **"Why didn't a ticket get created" audit.** For every auto-approve, persist the score breakdown so an auditor can reconstruct the call.

---

### My picks if you only do four

1. **Decision audit log** — unblocks every other improvement on this list.
2. **Shadow mode + decision feedback loop** — the only way to know the AI is doing anything useful.
3. **Reputation can heal (with a policy)** — the current "down only" rule has an obvious operational halflife.
4. **Jira webhooks instead of polling** — concrete, scoped, removes a known fragile area (the wiki-bold regex).

Anything jump out, or do you want me to expand a category?


We're doing a wide brainstorm of RUTH features and improvements; I delivered a categorized menu with starred picks. Next: you tell me which (if any) area to expand or move forward on. (disable recaps in /config)


lets add more metrics, specifically around request outcomes


Roger — that's a pivot from menu-mode to design-mode, so we're now on the design-doc path I described earlier. I'll ask questions one at a time and we'll end with a spec + implementation plan. Speak up if you want to bail out into "just go do it" — that's not how the skill is wired, but I'll respect it.

**First question — what are these metrics primarily *for*?** This determines the shape (which instruments, which labels, how much cardinality is acceptable):

**A. Operational health.** Is RUTH up and processing? Are we backed up? Are dependencies failing? Audience: oncall on a Datadog dashboard. → Mostly counters + latencies + error rates.

**B. Decision quality.** Is the risk model calibrated? Are we approving too liberally / too conservatively? How are scores distributed by outcome? Audience: whoever owns the risk policy, looking at trends weekly. → Mostly histograms of scores, broken down by outcome.

**C. Volume / governance reporting.** How many access requests, what kinds, what gets ticketed vs auto-approved, by team/cluster. Audience: management / audit / quarterly reviews. → Counters with carefully-chosen low-cardinality labels.

**D. All three.** I'll design the union and we'll prune.

Which one (or combination) are you optimizing for?


B


Decision quality — got it. Before I ask the next clarifying question, let me look at the actual outcome paths in the code so I'm not asking you things I can read.


1	package ruth
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		"ruth/ai"
15		"ruth/jira"
16		"ruth/reputation"
17		"ruth/risk"
18		"ruth/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("ruth starting",
93			"provider", a.cfg.AI.Provider,
94			"threshold", a.cfg.Risk.TicketThreshold,
95			"itsm_project", a.cfg.Jira.Project,
96			"dry_run", a.cfg.DryRun)
97		if a.cfg.DryRun {
98			slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")
99		}
100
101		if err := a.rehydrate(ctx); err != nil {
102			return fmt.Errorf("rehydrate open ITSM tickets: %w", err)
103		}
104
105		// Run watcher/poller under their own cancelable context rather than
106		// bolting them directly to the signal context. A coordinator
107		// goroutine waits for the signal, then sleeps for
108		// telemetry.shutdown_grace before cancelling the errgroup so that
109		// the OTel periodic reader can finish an in-flight export cleanly.
110		runCtx, cancelRun := context.WithCancel(context.Background())
111		defer cancelRun()
112		g, gctx := errgroup.WithContext(runCtx)
113		g.Go(func() error { return a.watchRequests(gctx) })
114		g.Go(func() error { return a.pollITSM(gctx) })
115		g.Go(func() error {
116			select {
117			case <-ctx.Done():
118				grace := time.Duration(0)
119				if a.cfg.Telemetry.Enabled {
120					grace = a.cfg.Telemetry.ShutdownGrace
121				}
122				if grace > 0 {
123					slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
124					select {
125					case <-time.After(grace):
126					case <-gctx.Done():
127						// errgroup collapsed for some other reason; stop waiting.
128					}
129				}
130				cancelRun()
131			case <-gctx.Done():
132			}
133			return nil
134		})
135
136		return g.Wait()
137	}
138
139	func (a *App) rehydrate(ctx context.Context) error {
140		open, err := a.jira.ListOpenTeleportTickets(ctx)
141		if err != nil {
142			return err
143		}
144		a.pendingMu.Lock()
145		for reqID, ticket := range open {
146			a.pending[reqID] = ticket
147		}
148		a.pendingMu.Unlock()
149		slog.Info("rehydrated pending tickets", "count", len(open))
150		return nil
151	}
152
153	func (a *App) watchRequests(ctx context.Context) error {
154		watcher, err := a.tele.NewWatcher(ctx, types.Watch{
155			Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}},
156		})
157		if err != nil {
158			return fmt.Errorf("start watcher: %w", err)
159		}
160		defer watcher.Close()
161
162		for {
163			select {
164			case <-ctx.Done():
165				return ctx.Err()
166			case <-watcher.Done():
167				return watcher.Error()
168			case ev := <-watcher.Events():
169				if ev.Type != types.OpPut {
170					continue
171				}
172				req, ok := ev.Resource.(types.AccessRequest)
173				if !ok {
174					continue
175				}
176				if req.GetState() != types.RequestState_PENDING {
177					continue
178				}
179				if !a.reserveOrSkip(req.GetName()) {
180					continue
181				}
182				go a.handleRequest(ctx, req)
183			}
184		}
185	}
186
187	// reserveOrSkip atomically claims a slot for request id. Returns true if
188	// this caller won the race and should process the request; false if the
189	// id is already in flight or already has a ticket. Using the same map
190	// for both reservations and ticket mappings keeps dedup decisions on a
191	// single source of truth.
192	func (a *App) reserveOrSkip(id string) bool {
193		a.pendingMu.Lock()
194		defer a.pendingMu.Unlock()
195		if _, held := a.pending[id]; held {
196			return false
197		}
198		a.pending[id] = ""
199		return true
200	}
201
202	func (a *App) releasePending(id string) {
203		a.pendingMu.Lock()
204		defer a.pendingMu.Unlock()
205		delete(a.pending, id)
206	}
207
208	func (a *App) recordTicket(id, ticket string) {
209		a.pendingMu.Lock()
210		defer a.pendingMu.Unlock()
211		a.pending[id] = ticket
212	}
213
214	func (a *App) handleRequest(ctx context.Context, req types.AccessRequest) {
215		id := req.GetName()
216		user := req.GetUser()
217		log := slog.With("request_id", id, "user", user)
218
219		// Release the reservation unless we successfully record a ticket;
220		// that way score/Jira failures are re-tryable on the next OpPut and
221		// auto-approved requests don't leave stale entries behind.
222		handled := false
223		defer func() {
224			if !handled {
225				a.releasePending(id)
226			}
227		}()
228
229		log.Info("scoring access request")
230		result, err := a.scorer.Score(ctx, a.ai, req)
231		if err != nil {
232			log.Error("scoring failed", "err", err)
233			return
234		}
235		log.Info("risk scored",
236			"score", result.Score,
237			"tier", result.Tier.Name,
238			"components", result.Components)
239
240		assessment, err := a.rep.Assess(ctx, user, result.Components)
241		if err != nil {
242			log.Warn("reputation update failed", "err", err)
243		}
244		if len(assessment.Penalties) > 0 {
245			reasons := make([]string, 0, len(assessment.Penalties))
246			for _, p := range assessment.Penalties {
247				reasons = append(reasons, p.Reason)
248			}
249			log.Info("reputation docked",
250				"previous", assessment.Previous,
251				"current", assessment.Score,
252				"reasons", reasons)
253		}
254
255		threshold := a.cfg.Risk.TicketThreshold
256		if result.Escalation {
257			threshold = a.cfg.Risk.EscalationThresholdFor(req.GetRoles())
258			log.Info("session escalation detected", "threshold", threshold)
259		}
260
261		if result.Score < threshold {
262			reason := fmt.Sprintf("ruth: %s (score %d) — auto-approved",
263				result.Tier.Name, result.Score)
264			if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
265				log.Error("auto-approve failed", "err", err)
266				return
267			}
268			log.Info("access request auto-approved",
269				"score", result.Score,
270				"tier", result.Tier.Name,
271				"threshold", threshold)
272			return
273		}
274
275		if a.cfg.DryRun {
276			log.Info("dry-run: would create ITSM ticket and hold request pending",
277				"score", result.Score,
278				"tier", result.Tier.Name,
279				"threshold", threshold)
280			return
281		}
282
283		ticket, err := a.jira.CreateITSMTicket(ctx, jira.TicketInput{
284			RequestID:   id,
285			User:        req.GetUser(),
286			Roles:       req.GetRoles(),
287			Reason:      req.GetRequestReason(),
288			Score:       result.Score,
289			Tier:        result.Tier.Name,
290			Components:  result.Components,
291			AIReasoning: result.Reasoning,
292			Escalation:  result.Escalation,
293		})
294		if err != nil {
295			log.Error("create ITSM ticket failed", "err", err)
296			return
297		}
298
299		a.recordTicket(id, ticket)
300		handled = true
301		log.Info("ITSM ticket created, request held for SRE review",
302			"ticket", ticket, "tier", result.Tier.Name)
303	}
304
305	func (a *App) pollITSM(ctx context.Context) error {
306		t := time.NewTicker(a.cfg.Jira.PollInterval)
307		defer t.Stop()
308		for {
309			select {
310			case <-ctx.Done():
311				return ctx.Err()
312			case <-t.C:
313				a.reconcileITSM(ctx)
314			}
315		}
316	}
317
318	func (a *App) reconcileITSM(ctx context.Context) {
319		a.pendingMu.Lock()
320		snap := make(map[string]string, len(a.pending))
321		for k, v := range a.pending {
322			snap[k] = v
323		}
324		a.pendingMu.Unlock()
325
326		for requestID, ticket := range snap {
327			if ticket == "" {
328				// Reservation for an in-flight handleRequest; nothing to poll yet.
329				continue
330			}
331			status, err := a.jira.TicketStatus(ctx, ticket)
332			if err != nil {
333				slog.Warn("ITSM status check failed", "ticket", ticket, "err", err)
334				continue
335			}
336			var next types.RequestState
337			switch status {
338			case jira.StatusApproved:
339				next = types.RequestState_APPROVED
340			case jira.StatusRejected:
341				next = types.RequestState_DENIED
342			default:
343				continue
344			}
345			reason := fmt.Sprintf("ruth: ITSM %s resolved (%s)", ticket, status)
346			if err := a.submitReview(ctx, requestID, next, reason); err != nil {
347				slog.Error("submit review failed", "request_id", requestID, "err", err)
348				continue
349			}
350			a.pendingMu.Lock()
351			delete(a.pending, requestID)
352			a.pendingMu.Unlock()
353		}
354	}
355
356	func (a *App) submitReview(ctx context.Context, requestID string, state types.RequestState, reason string) error {
357		if a.cfg.DryRun {
358			slog.Info("dry-run: skipping AccessReview submission",
359				"request_id", requestID,
360				"state", state.String(),
361				"reason", reason)
362			return nil
363		}
364		_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
365			RequestID: requestID,
366			Review: types.AccessReview{
367				Author:        "ruth",
368				ProposedState: state,
369				Reason:        reason,
370			},
371		})
372		return err
373	}
374

package ruth

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

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

	"ruth/ai"
	"ruth/jira"
	"ruth/reputation"
	"ruth/risk"
	"ruth/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("ruth starting",
		"provider", a.cfg.AI.Provider,
		"threshold", a.cfg.Risk.TicketThreshold,
		"itsm_project", a.cfg.Jira.Project,
		"dry_run", a.cfg.DryRun)
	if a.cfg.DryRun {
		slog.Warn("dry-run enabled: no Teleport reviews or Jira tickets will be submitted")
	}

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

	// Run watcher/poller under their own cancelable context rather than
	// bolting them directly to the signal context. A coordinator
	// goroutine waits for the signal, then sleeps for
	// telemetry.shutdown_grace before cancelling the errgroup so that
	// the OTel periodic reader can finish an in-flight export cleanly.
	runCtx, cancelRun := context.WithCancel(context.Background())
	defer cancelRun()
	g, gctx := errgroup.WithContext(runCtx)
	g.Go(func() error { return a.watchRequests(gctx) })
	g.Go(func() error { return a.pollITSM(gctx) })
	g.Go(func() error {
		select {
		case <-ctx.Done():
			grace := time.Duration(0)
			if a.cfg.Telemetry.Enabled {
				grace = a.cfg.Telemetry.ShutdownGrace
			}
			if grace > 0 {
				slog.Info("shutdown signal received; waiting for telemetry flush", "grace", grace)
				select {
				case <-time.After(grace):
				case <-gctx.Done():
					// errgroup collapsed for some other reason; stop waiting.
				}
			}
			cancelRun()
		case <-gctx.Done():
		}
		return nil
	})

	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
			}
			if !a.reserveOrSkip(req.GetName()) {
				continue
			}
			go a.handleRequest(ctx, req)
		}
	}
}

// reserveOrSkip atomically claims a slot for request id. Returns true if
// this caller won the race and should process the request; false if the
// id is already in flight or already has a ticket. Using the same map
// for both reservations and ticket mappings keeps dedup decisions on a
// single source of truth.
func (a *App) reserveOrSkip(id string) bool {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	if _, held := a.pending[id]; held {
		return false
	}
	a.pending[id] = ""
	return true
}

func (a *App) releasePending(id string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	delete(a.pending, id)
}

func (a *App) recordTicket(id, ticket string) {
	a.pendingMu.Lock()
	defer a.pendingMu.Unlock()
	a.pending[id] = ticket
}

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

	// Release the reservation unless we successfully record a ticket;
	// that way score/Jira failures are re-tryable on the next OpPut and
	// auto-approved requests don't leave stale entries behind.
	handled := false
	defer func() {
		if !handled {
			a.releasePending(id)
		}
	}()

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

	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("ruth: %s (score %d) — auto-approved",
			result.Tier.Name, result.Score)
		if err := a.submitReview(ctx, id, types.RequestState_APPROVED, reason); err != nil {
			log.Error("auto-approve failed", "err", err)
			return
		}
		log.Info("access request auto-approved",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		return
	}

	if a.cfg.DryRun {
		log.Info("dry-run: would create ITSM ticket and hold request pending",
			"score", result.Score,
			"tier", result.Tier.Name,
			"threshold", threshold)
		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,
		Components:  result.Components,
		AIReasoning: result.Reasoning,
		Escalation:  result.Escalation,
	})
	if err != nil {
		log.Error("create ITSM ticket failed", "err", err)
		return
	}

	a.recordTicket(id, ticket)
	handled = true
	log.Info("ITSM ticket created, request held for SRE review",
		"ticket", ticket, "tier", result.Tier.Name)
}

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 {
		if ticket == "" {
			// Reservation for an in-flight handleRequest; nothing to poll yet.
			continue
		}
		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("ruth: 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 {
	if a.cfg.DryRun {
		slog.Info("dry-run: skipping AccessReview submission",
			"request_id", requestID,
			"state", state.String(),
			"reason", reason)
		return nil
	}
	_, err := a.tele.SubmitAccessReview(ctx, types.AccessReviewSubmission{
		RequestID: requestID,
		Review: types.AccessReview{
			Author:        "ruth",
			ProposedState: state,
			Reason:        reason,
		},
	})
	return err
}


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

package risk

import (
	"context"
	"log/slog"
	"regexp"
	"time"

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

	"ruth/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,
	})
	if err != nil {
		return nil, err
	}
	slog.Debug("ai assessment received",
		"user", req.GetUser(),
		"description_quality", assessment.DescriptionQuality,
		"privilege_concern", assessment.PrivilegeConcern,
		"anomalies", assessment.Anomalies,
		"reasoning", assessment.Reasoning)

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

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
}


Error: EISDIR: illegal operation on a directory, read '/Users/steve.briggs/projects/operations/apps/ruth/telemetry'


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		// EscalationAnnotation is the Teleport system annotation key that marks
11		// an access request as a session escalation. A request is treated as an
12		// escalation when this key is present with any non-empty value.
13		EscalationAnnotation string `toml:"escalation_annotation"`
14		// EscalationThreshold is the fallback ticket threshold applied to
15		// escalation-flavored requests when no role-specific override matches.
16		EscalationThreshold int `toml:"escalation_threshold"`
17		// EscalationThresholds overrides EscalationThreshold for specific
18		// requested roles. The strictest (lowest) matching value wins when a
19		// request names multiple roles.
20		EscalationThresholds map[string]int `toml:"escalation_thresholds"`
21	}
22
23	func (c *Config) ApplyDefaults() {
24		if c.TicketThreshold == 0 {
25			c.TicketThreshold = 25
26		}
27		if c.CUSTXPattern == "" {
28			c.CUSTXPattern = `CUSTX-\d+`
29		}
30		if c.Timezone == "" {
31			c.Timezone = "UTC"
32		}
33		if c.EscalationAnnotation == "" {
34			c.EscalationAnnotation = "ruth.escalation"
35		}
36		if c.EscalationThreshold == 0 {
37			c.EscalationThreshold = 15
38		}
39	}
40
41	// EscalationThresholdFor returns the strictest ticket threshold among the
42	// requested roles. A role without an explicit override uses the global
43	// EscalationThreshold. An empty role list also yields the global default.
44	func (c Config) EscalationThresholdFor(roles []string) int {
45		if len(roles) == 0 {
46			return c.EscalationThreshold
47		}
48		chosen := -1
49		for _, role := range roles {
50			t, ok := c.EscalationThresholds[role]
51			if !ok {
52				t = c.EscalationThreshold
53			}
54			if chosen == -1 || t < chosen {
55				chosen = t
56			}
57		}
58		return chosen
59	}
60
61	type Tier struct {
62		Name     string
63		MinScore int
64	}
65
66	var Tiers = []Tier{
67		{Name: "low", MinScore: 0},
68		{Name: "medium", MinScore: 25},
69		{Name: "high", MinScore: 50},
70		{Name: "critical", MinScore: 75},
71	}
72
73	func TierForScore(score int) Tier {
74		chosen := Tiers[0]
75		for _, t := range Tiers {
76			if score >= t.MinScore {
77				chosen = t
78			}
79		}
80		return chosen
81	}
82

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

	// 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 = "ruth.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
}

type Tier struct {
	Name     string
	MinScore int
}

var Tiers = []Tier{
	{Name: "low", MinScore: 0},
	{Name: "medium", MinScore: 25},
	{Name: "high", MinScore: 50},
	{Name: "critical", MinScore: 75},
}

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


Bash

List telemetry package files


drwxr-xr-x steve.briggs staff 832 B  Mon May  4 17:12:13 2026 ..
drwxr-xr-x steve.briggs staff  96 B  Mon May  4 17:12:13 2026 .
.rw-r--r-- steve.briggs staff 3.5 KB Mon May  4 17:12:13 2026 telemetry.go


1	// Package telemetry wires an OpenTelemetry MeterProvider to an OTLP/gRPC
2	// collector and publishes per-user reputation as an observable gauge. The
3	// collector is expected to fan out to Datadog (or any other backend) via
4	// its own exporter pipeline.
5	package telemetry
6
7	import (
8		"context"
9		"fmt"
10		"time"
11
12		"go.opentelemetry.io/otel"
13		"go.opentelemetry.io/otel/attribute"
14		"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
15		"go.opentelemetry.io/otel/metric"
16		sdkmetric "go.opentelemetry.io/otel/sdk/metric"
17		"go.opentelemetry.io/otel/sdk/resource"
18		semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
19	)
20
21	type Config struct {
22		Enabled      bool          `toml:"enabled"`
23		Endpoint     string        `toml:"endpoint"`
24		Insecure     bool          `toml:"insecure"`
25		ServiceName  string        `toml:"service_name"`
26		ExportPeriod time.Duration `toml:"export_period"`
27
28		// ShutdownGrace is the delay between receiving SIGINT/SIGTERM and
29		// cancelling the watcher/poller context. It gives the OTel periodic
30		// reader time to finish an in-flight export before the app tears
31		// down; without it shutdowns emit a spurious "context canceled"
32		// error from the exporter. Set to 0 to disable.
33		ShutdownGrace time.Duration `toml:"shutdown_grace"`
34	}
35
36	func (c *Config) ApplyDefaults() {
37		if c.ServiceName == "" {
38			c.ServiceName = "ruth"
39		}
40		if c.ExportPeriod == 0 {
41			c.ExportPeriod = 15 * time.Second
42		}
43		if c.ShutdownGrace == 0 {
44			c.ShutdownGrace = 2 * time.Second
45		}
46	}
47
48	// ReputationSource is the minimum surface telemetry needs from the
49	// reputation store: iterate the users it currently knows about.
50	type ReputationSource interface {
51		Range(fn func(user string, score int))
52	}
53
54	type Meter struct {
55		provider *sdkmetric.MeterProvider
56		reg      metric.Registration
57	}
58
59	func New(ctx context.Context, cfg Config, src ReputationSource) (*Meter, error) {
60		opts := []otlpmetricgrpc.Option{}
61		if cfg.Endpoint != "" {
62			opts = append(opts, otlpmetricgrpc.WithEndpoint(cfg.Endpoint))
63		}
64		if cfg.Insecure {
65			opts = append(opts, otlpmetricgrpc.WithInsecure())
66		}
67		exp, err := otlpmetricgrpc.New(ctx, opts...)
68		if err != nil {
69			return nil, fmt.Errorf("otlp exporter: %w", err)
70		}
71
72		res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(cfg.ServiceName)))
73		if err != nil {
74			return nil, fmt.Errorf("resource: %w", err)
75		}
76
77		provider := sdkmetric.NewMeterProvider(
78			sdkmetric.WithResource(res),
79			sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp, sdkmetric.WithInterval(cfg.ExportPeriod))),
80		)
81		otel.SetMeterProvider(provider)
82
83		meter := provider.Meter("ruth/reputation")
84		gauge, err := meter.Int64ObservableGauge(
85			"ruth.user.reputation",
86			metric.WithDescription("Per-user reputation score (lower = worse)"),
87		)
88		if err != nil {
89			_ = provider.Shutdown(ctx)
90			return nil, fmt.Errorf("register gauge: %w", err)
91		}
92
93		reg, err := meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
94			src.Range(func(user string, score int) {
95				o.ObserveInt64(gauge, int64(score), metric.WithAttributes(
96					attribute.String("user", user),
97				))
98			})
99			return nil
100		}, gauge)
101		if err != nil {
102			_ = provider.Shutdown(ctx)
103			return nil, fmt.Errorf("register callback: %w", err)
104		}
105
106		return &Meter{provider: provider, reg: reg}, nil
107	}
108
109	// Shutdown unregisters the callback and flushes pending metrics to the
110	// collector. Safe to call on a nil receiver.
111	func (m *Meter) Shutdown(ctx context.Context) error {
112		if m == nil || m.provider == nil {
113			return nil
114		}
115		_ = m.reg.Unregister()
116		return m.provider.Shutdown(ctx)
117	}
118

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

import (
	"context"
	"fmt"
	"time"

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

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

	// ShutdownGrace is the delay between receiving SIGINT/SIGTERM and
	// cancelling the watcher/poller context. It gives the OTel periodic
	// reader time to finish an in-flight export before the app tears
	// down; without it shutdowns emit a spurious "context canceled"
	// error from the exporter. Set to 0 to disable.
	ShutdownGrace time.Duration `toml:"shutdown_grace"`
}

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

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

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

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

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

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

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

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

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

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


Explore project context

Skim CLAUDE.md, directory layout, and recent commits to understand RUTH's current state

Clarify brainstorm scope/goal

Ask whether this is a wide ideation pass or a single feature to ship next, and what categories matter

Propose 2-3 approaches/groupings

Once scope is clear, present categories of improvements with trade-offs

Present design for chosen feature

Once narrowed, present design and gather approval section by section

Save validated design to docs/superpowers/specs/YYYY-MM-DD-topic-design.md and commit

Spec self-review + user review

Inline check for placeholders/contradictions/scope, then ask user to review the spec file

Hand off to writing-plans skill

Invoke writing-plans to produce implementation plan once spec is approved


ToolSearch

Question: select:TaskUpdate


Question: select:TaskUpdate


Updated task #1 status


Updated task #2 status


Updated task #3 status


TaskUpdate

Design request-outcome metrics

Clarify cardinality, then propose 2-3 metric scopes, then present design for chosen feature


Updated task #4 subject, description, activeForm, status


Code read. Useful facts I extracted, so we're working from the same map:

- Score is `int` 0–100, capped. Components are a `map[string]int` with known keys: `missing_custx_ticket` (30), `thin_description` (15), `description_quality` (0–25), `privilege_concern` (0–25), `ai_anomalies` (0–10), `off_hours` (10).
- Tiers: `low` <25, `medium` 25–49, `high` 50–74, `critical` 75+.
- Distinct outcomes I can see: `score_failed`, `auto_approved`, `auto_approve_submit_failed`, `dry_run_would_ticket`, `ticket_created`, `ticket_create_failed`, `sre_approved` (via poller), `sre_denied` (via poller), `submit_review_failed` (during reconcile), and `escalation` as a flag (not an outcome).
- Existing telemetry is OTLP/gRPC with one observable gauge (`ruth.user.reputation`). The `Meter` struct is the natural seam to extend.

---

**Next question — cardinality / labeling philosophy.** This is the biggest design fork:

**A. Aggregates only, low cardinality.** Labels limited to small enums: `outcome`, `tier`, `escalation` (bool), `provider`. No `user`, no `role`, no `cluster`. Pure metrics path; cheapest in Datadog; you answer questions like "what % of tickets does SRE approve?" but not "is calibration different for admin roles vs read-only roles?"

**B. Aggregates + a `role_class` label.** Same as A, but add a configurable `role_class` mapping (e.g. `admin`, `db-reader`, `support`) so you can slice the score histogram by role-bucket. The class is a small enum you define in TOML; the *raw* role name never becomes a metric label. Roughly 5–10x the cardinality of A. Lets you answer "is the model under-ticketing for db roles?"

**C. Metrics aggregates AND a structured per-decision event log.** Keep metrics low-cardinality (option A). Separately emit a JSONL/structured event for each decision (request_id, user, roles, score, components, outcome, ticket, resolution, durations) to a sink — disk file, S3, or Datadog logs. That's where the high-cardinality analysis lives, off the metrics path. Better for long-tail forensic queries; about 1.5–2x the implementation work.

My recommendation is **C**. Reasons:
- "Decision quality" questions tend to drift toward "tell me about *this kind* of request" — metrics will frustrate you within a quarter.
- Datadog metric-cardinality bills will eat anyone who labels by role/user. The event log is logs/object-storage cost, which is much friendlier.
- It also lays the groundwork for the *decision feedback loop* item from the menu without committing to it now.
- The downside is a second sink to operate. If you don't have a Datadog Logs / S3 path already, it's a step up in scope.

Pick A, B, or C — or push back if the recommendation doesn't fit your reality.