
Skill Studio
- 145 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Scaffold, edit, validate, and publish Claude Code skills—SKILL.md structure, scripts, references, and progressive disclosure—for custom agent capabilities.
About
skill-studio is an in-repo workshop for building Claude Code skills—structuring SKILL.md files, bundling references and scripts, and iterating agent capabilities with consistent progressive-disclosure patterns.
- SKILL.md scaffolding
- Progressive disclosure
- Bundled scripts and refs
- Skill validation patterns
- Publish-ready packaging
Skill Studio by the numbers
- 145 all-time installs (skills.sh)
- Ranked #213 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill skill-studioAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 145 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Scaffold, edit, validate, and publish Claude Code skills—SKILL.md structure, scripts, references, and progressive disclosure—for custom agent capabilities.
Files
Skill Studio
Purpose
Conduct a structured JTBD interview that captures what to build, for whom, and why — then emit a one-page design.md + design.svg spec. Sits between "should I automate this?" (automation-advisor) and "how do I package this as a skill?" (skill-creator).
Architecture
This skill wraps an external CLI tool (skill-studio) installed via pip. The CLI handles session state, coverage tracking, and export. The skill orchestrates the CLI — it does not bundle scripts directly.
When to use
Trigger on any of: "help me design...", "build a skill for...", "design an automation for...", "I want a bot/agent/workflow that...", "scope a new shortcut". Also trigger when the user describes a recurring pain and asks how to automate it.
Also trigger for session analysis: "analyze this session", "what skills could I build from this", "propose skills from session", "what workflows did I use", "what did I do in this session", "extract patterns from my work", "turn this session into a skill", "what could be automated from this". If the user references a session ID or asks about subagent activity, this skill handles it.
Prerequisites
skill-studioCLI on PATH (pip install -e .inside the skill directory, orskill-studio initfor guided setup)- Python 3.11+
- Text mode needs no API key — the interview runs natively inside Claude Code
- Voice mode (
--voice) needsDAILY_API_KEY,GROQ_API_KEY,DEEPGRAM_API_KEY, and an LLM provider key (OPENROUTER_API_KEYby default). If any key is missing, suggest text mode instead.
To verify the CLI is available, run skill-studio --help. If the command is not found, install it from the skill's base directory: pip install -e <skill-studio-base-dir>.
Interview protocol (text mode)
Follow these steps in order.
Step 0 — (Optional) Seed from a prior session
If the user provides a prior session (Claude Code transcript, another skill-studio session, or arbitrary transcript path), seed the interview instead of starting blank:
# Analyze the current running session
skill-studio propose-from-session --current
# Analyze a specific session by ID (prefix match works)
skill-studio propose-from-session <session_id>
# Analyze a session from a specific project
skill-studio propose-from-session <session_id> --project <project-dir-name>
# Analyze an arbitrary transcript file
skill-studio propose-from-session --path <file>
# Inspect the raw extracted bundle without an LLM call
skill-studio propose-from-session --current --bundle-onlyThis runs in two stages: 1. Deterministic ingest (no LLM) — extracts models tried, cost events, prompt changes, pain snippets, subagent calls (Agent tool with descriptions, types, prompt snippets), skill invocations, tool sequences (ordered list of all tool calls), tool frequency, and workflow patterns (repeated multi-tool sequences). A 50k-token transcript compresses to a compact structured JSON bundle. 2. Single LLM call — over that compact bundle only, proposes a partial DesignJSON patch with a rationale map citing which signals justified each field, plus skill proposals — potential new skills derived from observed workflow patterns and agent orchestration.
The bundle includes these structured signals:
agents— subagent calls withdescription,subagent_type, andprompt_snippetskills— skill invocations observed during the sessiontool_sequence— ordered list of all tool calls with descriptionstool_frequency— how often each tool was usedworkflow_patterns— repeated tool sequences (e.g. "Read → Edit → Bash" appearing 3× suggests a test-fix cycle)
The proposal is NOT applied automatically. Present it to the user (with the rationale and any skill proposals) and ask for approval. Offer: approve as-is, edit inline, discard and start fresh, approve partial (keep some fields, re-interview others).
If the proposal includes skill_proposals, present them separately and ask if the user wants to proceed to /skill-creator with any of them.
propose-from-session does not create a session. After approval, run new-session (Step 1) to create one, then pipe the approved patch to apply-patch, and continue the interview loop from the next uncovered target.
Browsing Claude Code sessions
To help the user pick a session to analyze:
# List recent sessions (most recent first, all projects)
skill-studio list-sessions
# Filter to a specific project
skill-studio list-sessions --project <project-dir-name>
# Show more results
skill-studio list-sessions --limit 50Output shows session ID prefix, age, size, and title.
Step 1 — Start the session
Presets: ai-agent (default), life-automation, knowledge-work, custom. Depth: sprint (0.60, ~5–7 questions), standard (0.80, ~15–20 questions, default), deep (0.92, ~25–35 questions).
Styles (shape how questions are phrased):
scenario-first(default) — "Walk me through a specific time when..."socratic— "Why does that matter? What would happen if...?"metaphor-first— "If this automation were a [thing], what would it be?"form— One direct question per field, no preamble.
Run:
skill-studio new-session --preset <preset> --depth <depth> --style <style>Output:
session_id: <uuid>
opening: <question text>Store the session_id. Present the opening question to the user as a direct text message.
Step 2 — Interview loop
For every user answer:
a. Extract a JSON patch. Emit a JSON object containing only the DesignJSON fields the answer addresses. Use only fields from the schema below — never hallucinate fields or values. If nothing schema-relevant was said, emit {}.
Example patch:
{"jtbd.situation": "When I finish a coaching call and need to write up notes", "problem.what_hurts": "Manual note-taking takes 20 minutes and I lose details"}Example with list fields:
{"needs.functional": ["transcribe audio", "extract action items"], "guardrails": ["never send notes without review"]}Example with object-list field (scenarios):
{"scenarios": [{"title": "Post-coaching rush", "vignette": "Call ends at 14:00, next meeting at 14:15 — I scribble three bullet points and lose the rest by evening."}]}DesignJSON fields:
| Field | Type | Notes |
|---|---|---|
hook | str | One-sentence pitch of the automation |
problem.what_hurts | str | Specific pain |
problem.cost_today | str | What the pain costs right now |
needs.functional | list[str] | What it must do |
needs.emotional | list[str] | How the user wants to feel |
needs.social | list[str] | Relational / status needs |
jtbd.situation | str | When this happens |
jtbd.motivation | str | What the user wants |
jtbd.outcome | str | So they can... |
before_after.before_external | str | Visible state before |
before_after.before_internal | str | Felt state before |
before_after.after_external | str | Visible state after |
before_after.after_internal | str | Felt state after |
scenarios | list[{title, vignette}] | Concrete day-in-the-life stories |
trigger.type | manual / scheduled / event | |
trigger.detail | str | e.g. "7:45am weekdays" |
inputs | list[str] | Data / services consumed |
capabilities | list[str] | What it does |
outputs | list[str] | What it produces |
guardrails | list[str] | Safety rails; negative-space rules |
cta | str | Next action at end of design |
concept_imagery.metaphor | str | Visual / verbal handle |
b. Apply the patch.
echo '<patch_json>' | skill-studio apply-patch <session_id>Output:
coverage: 0.42
next_target: jtbd.situationc. Check stop conditions. End the loop if either:
coverage >= threshold(sprint=0.60, standard=0.80, deep=0.92)- User says "done", "wrap up", or "stop"
d. Ask the next question. Target the next_target field, in the active style. Never re-ask a field already past 0.5 coverage. Present the question as direct text to the user.
Step 3 — Export
skill-studio done <session_id>Prints the paths to design.md and design.svg. Present both paths to the user.
Voice mode
For voice interviews, skip the manual loop and delegate to the built-in pipeline:
skill-studio new --voice --preset <preset> --depth <depth>This spins up a Daily room (auto-opens in the browser), runs Groq Whisper STT -> interview loop -> Deepgram TTS, and auto-exports on session end.
If voice mode fails due to missing API keys, fall back to text mode and inform the user. To configure keys, run skill-studio init.
Other commands
skill-studio list— list all skill-studio interview sessionsskill-studio list-sessions— list Claude Code sessions (most recent first)skill-studio list-sessions --project <name>— filter by projectskill-studio export <id> md-svg— regeneratedesign.md+design.svgskill-studio coverage <id>— per-field confidence JSONskill-studio next-target <id>— ask-this-next hintskill-studio init— full first-run wizard (prereq checks + keys + paths)skill-studio setup— narrower key-rotation flow (sops-only)
Sessions
Each interview writes to $SKILL_STUDIO_HOME/sessions/<uuid>/ (default: ~/.skill-studio/sessions/<uuid>/):
design.json— canonical schema (single source of truth)transcript.md— full Q&A logdesign.md,design.svg— exported artifacts
Troubleshooting
- `skill-studio: command not found` — Run
pip install -e <skill-studio-base-dir>and retry. - `apply-patch` returns an error — Verify the JSON patch is valid (keys must match schema fields above). Run
skill-studio coverage <session_id>to inspect current state. - Session not found — Always run
new-sessionbefore the firstapply-patch. There is no implicit session creation. Runskill-studio listto check existing sessions. - Voice mode key errors — Run
skill-studio initto configure missing keys, or fall back to text mode.
Notes
- The interview loop runs entirely inside Claude Code for text mode. No Anthropic API key is required.
- Voice mode LLM provider is swappable via
LLM_PROVIDER=anthropic(default isopenrouter).
{
"name": "skill-studio",
"description": "Interview-driven automation design tool. This skill should be used when the user wants to design a new skill, agent, aut",
"author": {
"name": "Gleb Kalinin"
},
"repository": "https://github.com/glebis/claude-skills",
"license": "MIT"
}# skill-studio environment overrides. Copy to a private location outside
# the repo and point $SKILL_STUDIO_ENV_FILE at the encrypted version, or
# source this file directly for local development.
# --- Paths (all optional; defaults shown) ---
# SKILL_STUDIO_HOME=~/.skill-studio
# SKILL_STUDIO_ENV_FILE=~/.env.skill-studio
# SKILL_STUDIO_PIPECAT_ENV=~/.env.pipecat
# SKILL_STUDIO_IMPORT_ENV=
# SKILL_STUDIO_GROUNDWORK_ROOT=
# --- LLM provider ---
LLM_PROVIDER=openrouter
OPENROUTER_MODEL=anthropic/claude-opus-4
OPENROUTER_API_KEY=
# ANTHROPIC_API_KEY=
# --- Voice mode (optional; only needed for --voice) ---
DAILY_API_KEY=
GROQ_API_KEY=
DEEPGRAM_API_KEY=
DEEPGRAM_VOICE=aura-asteria-en
# --- Debug ---
# SKILL_STUDIO_QUIET=1
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg
*.egg-info/
dist/
build/
develop-eggs/
eggs/
.eggs/
sdist/
wheels/
share/python-wheels/
# Virtual environments
.venv/
venv/
env/
ENV/
# Test / coverage
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.tox/
.cache/
coverage.xml
*.cover
# Editors / OS
.idea/
.vscode/
*.swp
*.swo
.DS_Store
# Secrets — never commit plaintext or encrypted provider keys
.env
.env.*
!.env.example
.env.skill-studio
.env.pipecat
# User data (default location outside repo, but guard anyway)
.skill-studio/
sessions/
# Logs
*.log
# Enzyme local cache
.enzyme/
.enzyme-embeddings/
# {{ design.hook or "Untitled Design" }}
{% if design.problem.what_hurts -%}
## The Problem
{{ design.problem.what_hurts }}
{% if design.problem.cost_today %}*Cost today:* {{ design.problem.cost_today }}{% endif %}
{%- endif %}
## Job to be Done
**When** {{ design.jtbd.situation }}, **I want to** {{ design.jtbd.motivation }}, **so I can** {{ design.jtbd.outcome }}.
{% if design.meta.jtbd_frame == "forces" and design.jtbd_frame_extension -%}
### Forces of Progress
- **Push** (current pain): {{ design.jtbd_frame_extension.push or "—" }}
- **Pull** (new promise): {{ design.jtbd_frame_extension.pull or "—" }}
- **Anxiety**: {{ design.jtbd_frame_extension.anxiety or "—" }}
- **Habit**: {{ design.jtbd_frame_extension.habit or "—" }}
{%- endif %}
## Before / After
| | Before | After |
|--------|---------------------------------------------------|---------------------------------------------------|
| Seen | {{ design.before_after.before_external or "—" }} | {{ design.before_after.after_external or "—" }} |
| Felt | {{ design.before_after.before_internal or "—" }} | {{ design.before_after.after_internal or "—" }} |
{% if design.scenarios -%}
## Scenarios
{% for s in design.scenarios -%}
> **{{ s.title }}** — {{ s.vignette }}
{% endfor %}
{%- endif %}
## Under the hood
- **Trigger:** {{ design.trigger.type }} — {{ design.trigger.detail or "(tbd)" }}
- **Inputs:** {{ design.inputs | join(", ") or "—" }}
- **Capabilities:** {{ design.capabilities | join(", ") or "—" }}
- **Outputs:** {{ design.outputs | join(", ") or "—" }}
- **Guardrails:** {{ design.guardrails | join(", ") or "—" }}
{% if design.cta %}## Next step
{{ design.cta }}
{% endif %}
---
*session id:* `{{ design.meta.id }}` — *generated by skill-studio*
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 900" width="1200" height="900" font-family="system-ui, sans-serif">
<defs>
<filter id="organic" x="-10%" y="-10%" width="120%" height="120%">
<feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="4"/>
<feDisplacementMap in="SourceGraphic" scale="4"/>
</filter>
</defs>
<rect width="1200" height="900" fill="#fafaf7"/>
<g transform="translate(600,200)">
<ellipse cx="0" cy="0" rx="260" ry="90" fill="#111" filter="url(#organic)"/>
<text text-anchor="middle" fill="#fafaf7" font-size="22" font-weight="600" dy="6">
{{ design.hook[:60] or "Untitled" }}
</text>
</g>
<g transform="translate(600,340)">
<text text-anchor="middle" fill="#555" font-size="14">
When {{ design.jtbd.situation[:40] }}
</text>
<text text-anchor="middle" fill="#111" font-size="16" dy="20" font-weight="500">
I want to {{ design.jtbd.motivation[:50] }}
</text>
<text text-anchor="middle" fill="#555" font-size="14" dy="40">
so I can {{ design.jtbd.outcome[:40] }}
</text>
</g>
{% set grid_y = 450 %}
<g transform="translate(100,{{ grid_y }})">
<text x="0" y="-10" fill="#555" font-size="14" font-weight="600">BEFORE (seen)</text>
<text x="480" y="-10" fill="#555" font-size="14" font-weight="600">AFTER (seen)</text>
<text x="0" y="140" fill="#555" font-size="14" font-weight="600">BEFORE (felt)</text>
<text x="480" y="140" fill="#555" font-size="14" font-weight="600">AFTER (felt)</text>
<rect x="0" y="0" width="440" height="110" fill="none" stroke="#ccc" stroke-width="1" rx="14"/>
<rect x="480" y="0" width="440" height="110" fill="none" stroke="#ccc" stroke-width="1" rx="14"/>
<rect x="0" y="150" width="440" height="110" fill="none" stroke="#ccc" stroke-width="1" rx="14"/>
<rect x="480" y="150" width="440" height="110" fill="none" stroke="#ccc" stroke-width="1" rx="14"/>
<foreignObject x="12" y="12" width="416" height="90">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:#222;">
{{ design.before_after.before_external[:200] or "—" }}
</div>
</foreignObject>
<foreignObject x="492" y="12" width="416" height="90">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:#222;">
{{ design.before_after.after_external[:200] or "—" }}
</div>
</foreignObject>
<foreignObject x="12" y="162" width="416" height="90">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:#222;">
{{ design.before_after.before_internal[:200] or "—" }}
</div>
</foreignObject>
<foreignObject x="492" y="162" width="416" height="90">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:#222;">
{{ design.before_after.after_internal[:200] or "—" }}
</div>
</foreignObject>
</g>
<g transform="translate(1020,200)">
<text x="0" y="0" fill="#111" font-size="13" font-weight="600">TRIGGER</text>
<text x="0" y="18" fill="#555" font-size="12">{{ design.trigger.type }}: {{ design.trigger.detail[:30] or "—" }}</text>
<text x="0" y="60" fill="#111" font-size="13" font-weight="600">INPUTS</text>
{% for i in design.inputs[:4] %}<text x="0" y="{{ 78 + loop.index0 * 14 }}" fill="#555" font-size="12">· {{ i[:30] }}</text>{% endfor %}
<text x="0" y="180" fill="#111" font-size="13" font-weight="600">OUTPUTS</text>
{% for o in design.outputs[:4] %}<text x="0" y="{{ 198 + loop.index0 * 14 }}" fill="#555" font-size="12">· {{ o[:30] }}</text>{% endfor %}
</g>
{% if design.scenarios %}
<g transform="translate(100,760)">
<text x="0" y="0" fill="#111" font-size="13" font-weight="600">SCENARIO</text>
<text x="0" y="20" fill="#222" font-size="14" font-style="italic">{{ design.scenarios[0].title }}</text>
<foreignObject x="0" y="28" width="1000" height="90">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:14px; color:#333;">
{{ design.scenarios[0].vignette[:400] }}
</div>
</foreignObject>
</g>
{% endif %}
<text x="1180" y="880" text-anchor="end" fill="#999" font-size="10">skill-studio · {{ design.meta.id[:8] }}</text>
</svg>
Contributing to skill-studio
Thanks for considering a contribution! This project is intentionally small and opinionated. Here's how to move fast without stepping on toes.
Quick start
git clone https://github.com/<your-org>/skill-studio.git
cd skill-studio
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -qAll 142 tests should pass before you start touching code.
Development workflow
1. Open an issue first for anything larger than a bug fix or a one-line tweak. A quick "is this in scope?" thread saves everyone time. 2. Branch from `main` — short, descriptive names (fix/sops-cwd, feat/milkshake-framework). 3. Write a failing test first when adding behavior. The repo is test-heavy on purpose — the interview loop has too many moving parts to iterate blind. 4. Keep PRs focused. One logical change per PR. Refactors and feature work go in separate PRs. 5. Run `pytest -q` locally before pushing. CI will rerun it, but don't burn CI time.
What to contribute
Good first issues:
- New JTBD frameworks in
src/skill_studio/interview/frameworks/— drop a YAML file withquestions:for each phase. - New exporters in
src/skill_studio/exporters/— implement theExporterprotocol, register inregistry.py. - New presets in
src/skill_studio/presets/— YAML with weights across schema fields. - Better tests for the voice pipeline — current coverage is ~32% because Pipecat services are tricky to mock.
Things we'll usually push back on:
- Adding cloud/SaaS dependencies. Local-first is a core value.
- Swapping sops for another secrets tool. The sops integration is deliberate.
- Touching
schema.pywithout a plan —design.jsonis the single source of truth across text/voice/exporters and schema changes ripple everywhere.
Code style
- Python 3.11+ only. Use
from __future__ import annotations. - Prefer small, pure functions. Tests mock the LLM seam (
llm.ask), not deeper. - No unnecessary comments. Let names carry the meaning.
- Path resolution goes through
skill_studio.paths— never hard-code a user path. - User-visible strings stay in English; the interview itself auto-detects language.
Secrets and path safety
Never commit:
- Plaintext
.env*files (the.gitignoreguards against it, but double-check). - Absolute paths under
/Users/...or/home/...in code, tests, or docs. Useskill_studio.pathshelpers or document the env-var override. - Session fixtures containing real PII. Use synthetic data in tests.
Releasing
Maintainers: tag from main, push the tag, CI picks it up. No pre-release channel yet.
Code of Conduct
Be kind, be specific, assume good faith. No formal CoC document — if someone's behavior is making the project worse to work on, open an issue and we'll talk.
MIT License
Copyright (c) 2026 skill-studio contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
[project]
name = "skill-studio"
version = "0.1.0"
description = "Interview-driven automation design tool — conducts a JTBD interview (text or voice) and exports a one-page design spec plus an SVG map."
readme = "README.md"
license = { file = "LICENSE" }
authors = [
{ name = "skill-studio contributors" },
]
requires-python = ">=3.11"
keywords = ["jtbd", "interview", "design", "skill", "claude-code", "automation", "voice"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development",
"Topic :: Software Development :: Libraries :: Application Frameworks",
]
dependencies = [
"anthropic>=0.40",
"openai>=1.40",
"pydantic>=2.0",
"pyyaml>=6.0",
"jinja2>=3.1",
"loguru>=0.7",
"pipecat-ai>=1.0.0",
]
[project.optional-dependencies]
test = ["pytest>=8.0", "pytest-mock>=3.12", "coverage>=7.0"]
[project.urls]
Homepage = "https://github.com/glebis/skill-studio"
Issues = "https://github.com/glebis/skill-studio/issues"
Repository = "https://github.com/glebis/skill-studio.git"
[project.scripts]
skill-studio = "skill_studio.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
skill_studio = ["presets/*.yaml", "interview/frameworks/*.yaml", "../assets/*.j2"]
skill-studio
Interview-driven automation design tool. Captures what you want to build, for whom, and why via a coverage-driven JTBD interview (text or voice), then exports a one-page markdown spec and an SVG design map.
Fits between two sibling tools:
- `automation-advisor` — should I automate this?
- `skill-studio` — what should this automation actually do? ← you are here
- `skill-creator` — how do I package this as a skill?
Status
- v1: text + voice interview, md + SVG exporter, session ingest — shipping
- v1.5: carousel / presentation / multi-target exports — planned
Installation
git clone https://github.com/<your-org>/skill-studio.git
cd skill-studio
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest # 165 passingPrerequisites:
- Python 3.11+
- `sops` + a registered age key (optional — the
initwizard falls back to a plaintext 0600 dotenv if sops is unavailable) - Voice mode only: Daily, Groq, and Deepgram API keys
First-run setup
skill-studio initInteractive wizard: checks prerequisites, picks a data home + env-file path, chooses sops vs plaintext, collects LLM and (optional) voice keys, prints the shell export lines, and offers to run the test suite.
For narrow key-rotation (assumes sops is already configured), skip straight to skill-studio setup.
Configuration (env vars)
All paths are overridable — no hard-coded user directories.
| Variable | Default | Purpose |
|---|---|---|
SKILL_STUDIO_HOME | ~/.skill-studio | Data root; sessions live under $HOME/sessions/ |
SKILL_STUDIO_ENV_FILE | ~/.env.skill-studio | Sops-encrypted (or 0600 plaintext) provider keys |
SKILL_STUDIO_PIPECAT_ENV | ~/.env.pipecat | Voice-mode secrets (Daily / Groq / Deepgram) |
SKILL_STUDIO_IMPORT_ENV | unset | Optional dotenv to import OPENROUTER_API_KEY from during setup |
SKILL_STUDIO_GROUNDWORK_ROOT | unset | Optional groundwork root; feature disabled if unset |
LLM_PROVIDER | openrouter | openrouter \ |
OPENROUTER_MODEL | anthropic/claude-opus-4 | Any OpenRouter model slug |
DEEPGRAM_VOICE | aura-asteria-en | Voice-mode TTS voice |
SKILL_STUDIO_QUIET | unset | Set =1 to silence Pipecat debug logs |
Usage
Text mode (runs natively inside Claude Code — zero provider keys)
/skill-studio new --preset ai-agent --depth sprintClaude Code conducts the interview via AskUserQuestion. The Python CLI handles state ops only (new-session, apply-patch, next-target, done) — no LLM key needed for the interview loop.
Presets: ai-agent, life-automation, knowledge-work, custom Depth modes: sprint (~5–7 Q, 60% coverage), standard (~15–20 Q, 80%), deep (~25–35 Q, 92%) Styles: scenario-first (default), socratic, metaphor-first, form
Voice mode (Daily + Groq + Deepgram + OpenRouter)
skill-studio new --voice --preset ai-agent --depth sprintPipecat pipeline: Daily transport → Groq Whisper STT → Silero VAD → interview loop → Deepgram TTS. A Daily room is created and auto-opens in your browser. Every turn is persisted; the session auto-exports when you leave the room.
Seed an interview from a prior transcript
If you already talked through the problem in another session, skip re-asking the basics:
skill-studio propose-from-session <claude-code-session-id>
# or
skill-studio propose-from-session --path /path/to/transcript.jsonl
# or inspect the compact bundle without calling the LLM:
skill-studio propose-from-session <id> --bundle-onlyTwo stages: 1. Deterministic ingest — pure regex extracts models tried, cost events, prompt changes, and pain snippets. A 50k-token transcript compresses to ~30 lines of JSON. 2. Single LLM call — over that compact bundle, proposes a partial DesignJSON patch with a rationale map citing which signals justified each field.
The proposal is never applied automatically. Claude Code presents it to you for approval/edits, then pipes the approved subset through apply-patch — the interview then continues from the next uncovered target. Typical cost savings vs. feeding the raw transcript to an LLM: 100×.
Resume
skill-studio new (text or voice) auto-resumes the most recent session whose coverage is below its depth threshold. Explicit flags: --resume <id> or --fresh.
Other commands
skill-studio list— all sessionsskill-studio export <id> md-svg— regeneratedesign.md+design.svgskill-studio coverage <id>— per-field confidence JSONskill-studio next-target <id>— ask-this-next hintskill-studio done <id>— export and close outskill-studio init— full first-run wizardskill-studio setup— narrow key-rotation flow (sops-only)
Sessions
Each interview writes to $SKILL_STUDIO_HOME/sessions/<uuid>/:
design.json— canonical schema (single source of truth)transcript.md— full Q&A log, appended per turndesign.md— human-readable one-pagerdesign.svg— one-page visual design mapsummary.md— LLM-synthesized "what emerged" recap (voice mode only; written on session end)
Architecture
┌─────────────────────────────────────────┐
│ Claude Code (text interview) │
│ or Pipecat pipeline (voice interview) │
└──────────────┬──────────────────────────┘
│ JSON patches
▼
┌─────────────────────────────────────────┐
│ state-ops CLI (Python) │
│ new-session / apply-patch / │
│ next-target / coverage / done / │
│ propose-from-session │
└──────────────┬──────────────────────────┘
│ reads/writes
▼
┌─────────────────────────────────────────┐
│ design.json (Pydantic-validated) │
│ transcript.md │
└──────────────┬──────────────────────────┘
│ render
▼
┌─────────────────────────────────────────┐
│ md + SVG exporter (Jinja2, assets/) │
└─────────────────────────────────────────┘Key design choices:
design.jsonis the single source of truth. One schema across text/voice/ingest/exporters.- Coverage-driven loop: next field picked by
weight × (1 − confidence); stops when the depth-mode threshold is met. - Narrative-arc director: phases (Opening → Pain → Moment → Cost → After → Shape → Guardrails → Close) driven by a per-subject landing criterion rather than raw coverage.
- JTBD frame auto-detection: regex on transcript picks between Forces / FSE / Outcomes / Job Story; user can override.
- Pluggable exporters (Protocol-based): v1 ships
md-svg. Add a file insrc/skill_studio/exporters/, register inregistry.py. - Pluggable LLM provider (voice side):
OpenRouterProvider(default) orAnthropicProvider. Text mode uses Claude Code natively. - Deterministic ingest (
src/skill_studio/ingest/): regex compresses transcripts before any LLM touches them; the proposer then makes a single targeted call.
Groundwork integration (optional)
If SKILL_STUDIO_GROUNDWORK_ROOT is set and points at a directory containing a sessions/ subdirectory, each completed voice session automatically: 1. Renders design.md + design.svg to the session folder 2. Generates a "what emerged" synthesis 3. Writes summary.md alongside the transcript 4. Drops a session log into $SKILL_STUDIO_GROUNDWORK_ROOT/sessions/
When unset, this feed is silently skipped.
Development
pytest -q # 165 tests
coverage run -m pytest && coverage report --include="src/skill_studio/*"Current coverage: 82% overall (schema / interview / exporters / ingest ≥ 89%; voice pipeline ~31% due to live-service deps that aren't unit-testable).
Layout:
src/skill_studio/
cli.py — argparse entry point
ingest/ — deterministic transcript extractor + LLM proposer
interview/ — coverage, director, question picker, frameworks (YAML)
exporters/ — md-svg renderer (Protocol-based registry)
voice/ — Pipecat pipeline (Daily → Groq → Silero VAD → Deepgram)
paths.py — all env-var-overridable paths
init_wizard.py — first-run setup
setup.py — key-only rotation
assets/ — Jinja templates for the md-svg exporter
references/ — schema + presets/modes docs
tests/ — 165 testsSee CONTRIBUTING.md for contribution guidelines.
Troubleshooting
- `preflight: missing env files` — voice mode needs
$SKILL_STUDIO_PIPECAT_ENVto exist. Either create the file or point the env var at your existing one:export SKILL_STUDIO_PIPECAT_ENV=/path/to/.env.pipecat. - sops "config file not found" — encrypt/decrypt run with
cwd=path.parentso sops locates the nearest.sops.yaml. Verify one exists alongside (or above) your env file. - Voice: no transcription after speaking — check DEBUG logs (enabled by default; silence with
SKILL_STUDIO_QUIET=1). Silero VAD thresholds (min_volume=0.15,confidence=0.5) can be tuned invoice/pipecat_interview.pyfor quiet mics. - Voice: bot speaks to empty room — greeting is queued in
on_first_participant_joined. Check browser mic permission.
License
MIT — see LICENSE.
Presets, depth modes, and interview styles
The canonical options are inlined in SKILL.md (Step 1).
This file exists for the CLI's own reference. The authoritative list for Claude is in SKILL.md.
DesignJSON schema
The canonical field reference is inlined in SKILL.md (section "DesignJSON fields").
This file exists for the CLI's own reference and for propose-from-session prompts. The authoritative list for Claude is in SKILL.md.
# requirements.txt — pinned runtime deps
anthropic==0.40.0
openai>=1.40
pydantic==2.9.2
pyyaml==6.0.2
jinja2==3.1.4
pipecat-ai==0.0.50
# DEPRECATED: Use skill_studio.llm_provider.get_provider() instead.
# Kept for backwards compatibility only.
from __future__ import annotations
from typing import Any
import os
from anthropic import Anthropic
MODEL_DEFAULT = "claude-opus-4-7"
class AnthropicInterviewer:
def __init__(self, system_prompt: str, client: Any | None = None, model: str = MODEL_DEFAULT):
self.client = client or Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
self.model = model
self.system_prompt = system_prompt
def ask(self, history: list[dict], max_tokens: int = 600) -> str:
resp = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=[{
"type": "text",
"text": self.system_prompt,
"cache_control": {"type": "ephemeral"},
}],
messages=history,
)
for block in resp.content:
if getattr(block, "text", None):
return block.text
return ""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from skill_studio.storage import SessionStorage
from skill_studio.presets import load_preset, list_presets
from skill_studio.interview.loop import run_interview_turn
from skill_studio.interview.modes import QUESTION_BUDGET, COVERAGE_THRESHOLD
from skill_studio.interview.updater import _deep_merge
from skill_studio.interview.coverage import overall_coverage, next_uncovered_field, score_coverage
from skill_studio.exporters.registry import get_exporter
from skill_studio.llm_provider import get_provider
from skill_studio import paths
SESSION_ROOT = paths.session_root()
def find_resumable(storage: SessionStorage):
"""Return most recent session whose coverage is below its depth-mode threshold, or None."""
sessions = storage.list()
sessions.sort(key=lambda s: s.meta.created, reverse=True)
for s in sessions:
try:
preset = load_preset(s.meta.preset)
except ValueError:
continue
threshold = COVERAGE_THRESHOLD.get(s.meta.interview_mode.depth, 0.8)
if overall_coverage(s, preset) < threshold:
return s
return None
def resolve_session(args: argparse.Namespace, storage: SessionStorage):
"""Return (design, resumed_bool). Auto-resume unless --fresh or --resume given."""
if getattr(args, "resume", None):
return storage.load(args.resume), True
if not getattr(args, "fresh", False):
candidate = find_resumable(storage)
if candidate is not None:
return candidate, True
design = storage.new()
design.meta.preset = args.preset
design.meta.interview_mode.depth = args.depth
design.meta.interview_mode.style = args.style
storage.save(design)
return design, False
# ---------------------------------------------------------------------------
# Original stdin-loop fallback (now uses provider factory instead of hardcoded Anthropic)
# ---------------------------------------------------------------------------
def cmd_new(args: argparse.Namespace) -> int:
storage = SessionStorage(SESSION_ROOT)
design, resumed = resolve_session(args, storage)
preset = load_preset(design.meta.preset)
if resumed:
cov = overall_coverage(design, preset)
print(f"Resuming session {design.meta.id[:8]} ({cov:.0%} covered)")
provider = get_provider(system_prompt=preset.opening_question)
budget = QUESTION_BUDGET[args.depth]
question = run_interview_turn(design, preset, provider, user_input=None)
storage.append_transcript(design.meta.id, "assistant", question)
print(f"\n{question}\n")
asked = 1
while asked < budget:
try:
user_input = input("you> ").strip()
except EOFError:
break
if not user_input or user_input.lower() in {"done", "wrap up", "stop"}:
break
storage.append_transcript(design.meta.id, "user", user_input)
question = run_interview_turn(design, preset, provider, user_input=user_input)
storage.append_transcript(design.meta.id, "assistant", question)
print(f"\n{question}\n")
asked += 1
storage.save(design)
exporter = get_exporter("md-svg")
out = SESSION_ROOT / design.meta.id
exporter.render(design, out)
print(f"\nDone. Session id: {design.meta.id}")
print(f"Files: {out}")
return 0
# ---------------------------------------------------------------------------
# State-only CLI subcommands (used by Claude Code-native interview)
# ---------------------------------------------------------------------------
def cmd_new_session(args: argparse.Namespace) -> int:
"""Create a session, print session_id and opening question. No LLM calls."""
storage = SessionStorage(SESSION_ROOT)
preset = load_preset(args.preset)
design = storage.new()
design.meta.preset = args.preset
design.meta.interview_mode.depth = args.depth
design.meta.interview_mode.style = args.style
storage.save(design)
print(f"session_id: {design.meta.id}")
print(f"opening: {preset.opening_question}")
return 0
def cmd_apply_patch(args: argparse.Namespace) -> int:
"""Read JSON patch from stdin, apply to design, print updated coverage + next_target."""
storage = SessionStorage(SESSION_ROOT)
design = storage.load(args.id)
preset = load_preset(design.meta.preset)
raw = sys.stdin.read().strip()
if raw:
try:
patch = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"error: invalid JSON patch: {exc}", file=sys.stderr)
return 1
if isinstance(patch, dict):
_deep_merge(design, patch)
storage.save(design)
cov = overall_coverage(design, preset)
nxt = next_uncovered_field(design, preset) or "DONE"
print(f"coverage: {cov:.2f}")
print(f"next_target: {nxt}")
return 0
def cmd_next_target(args: argparse.Namespace) -> int:
"""Print the next uncovered field path, or DONE."""
storage = SessionStorage(SESSION_ROOT)
design = storage.load(args.id)
preset = load_preset(design.meta.preset)
nxt = next_uncovered_field(design, preset)
print(nxt if nxt else "DONE")
return 0
def cmd_coverage(args: argparse.Namespace) -> int:
"""Print overall coverage + per-field scores as JSON."""
storage = SessionStorage(SESSION_ROOT)
design = storage.load(args.id)
preset = load_preset(design.meta.preset)
scores = score_coverage(design)
cov = overall_coverage(design, preset)
print(json.dumps({"overall": round(cov, 4), "fields": {k: round(v, 4) for k, v in scores.items()}}, indent=2))
return 0
def cmd_done(args: argparse.Namespace) -> int:
"""Export design.md + design.svg for a session and print the paths."""
storage = SessionStorage(SESSION_ROOT)
design = storage.load(args.id)
exporter = get_exporter("md-svg")
out_dir = SESSION_ROOT / args.id
paths = exporter.render(design, out_dir)
for p in paths:
print(f"wrote {p}")
return 0
# ---------------------------------------------------------------------------
# Existing subcommands
# ---------------------------------------------------------------------------
def cmd_list(args: argparse.Namespace) -> int:
storage = SessionStorage(SESSION_ROOT)
for s in storage.list():
print(f"{s.meta.id[:8]} preset={s.meta.preset} hook={s.hook[:60]}")
return 0
def cmd_export(args: argparse.Namespace) -> int:
storage = SessionStorage(SESSION_ROOT)
design = storage.load(args.id)
exporter = get_exporter(args.target)
paths = exporter.render(design, SESSION_ROOT / args.id)
for p in paths:
print(f"wrote {p}")
return 0
def cmd_setup(args: argparse.Namespace) -> int:
from skill_studio.setup import run_setup
run_setup()
return 0
def cmd_init(args: argparse.Namespace) -> int:
from skill_studio.init_wizard import run_init_wizard
return run_init_wizard()
def cmd_propose_from_session(args: argparse.Namespace) -> int:
"""Ingest a prior session (deterministic) + propose a DesignJSON patch (one LLM call).
Does NOT apply the patch. Caller (Claude Code) shows the proposal to the
user, collects approval/edits, then pipes the approved patch to apply-patch.
"""
import sys
from skill_studio.ingest.transcript import (
extract, resolve_session, resolve_current_session,
)
from skill_studio.ingest.proposer import propose
from pathlib import Path as _P
project = getattr(args, "project", None)
if args.path:
path = _P(args.path).expanduser()
source = "path"
session_id = args.session_id or path.stem
elif getattr(args, "current", False):
path, session_id = resolve_current_session(project=project)
source = "claude-code"
else:
if not args.session_id:
print("error: provide a session_id, --current, or --path", file=sys.stderr)
return 1
path, source = resolve_session(args.session_id, project=project)
session_id = args.session_id
bundle = extract(path, session_id, source)
if args.bundle_only:
json.dump(bundle.to_dict(), sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
provider = get_provider(system_prompt="You are a JTBD interview seeder.")
patch, rationale = propose(bundle, provider)
out = {
"bundle_summary": bundle.to_dict().get("summary"),
"patch": patch,
"rationale": rationale,
}
json.dump(out, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
def cmd_list_sessions(args: argparse.Namespace) -> int:
"""List Claude Code sessions, optionally filtered by project."""
from skill_studio.ingest.transcript import list_claude_code_sessions
import time
project = getattr(args, "project", None)
limit = getattr(args, "limit", 20)
sessions = list_claude_code_sessions(project=project, limit=limit)
if not sessions:
print("No sessions found.")
return 0
for s in sessions:
age = time.time() - s["modified"]
if age < 3600:
age_str = f"{int(age / 60)}m ago"
elif age < 86400:
age_str = f"{int(age / 3600)}h ago"
else:
age_str = f"{int(age / 86400)}d ago"
title = s["title"][:50] if s["title"] else "(untitled)"
print(f"{s['session_id'][:8]} {age_str:>8} {s['size_kb']:>7.1f}kb {title}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="skill-studio")
sub = parser.add_subparsers(dest="cmd", required=True)
# --- new (stdin fallback) ---
new_p = sub.add_parser("new")
new_p.add_argument("--preset", choices=list_presets(), default="custom")
new_p.add_argument("--depth", choices=["sprint", "standard", "deep"], default="standard")
new_p.add_argument("--style", choices=["socratic", "scenario-first", "metaphor-first", "form", "conversational"], default="scenario-first")
new_p.add_argument("--voice", action="store_true")
new_p.add_argument("--resume", metavar="ID", help="Resume a specific session id")
new_p.add_argument("--fresh", action="store_true", help="Force new session even if a resumable one exists")
new_p.set_defaults(func=cmd_new)
# --- new-session (state-only) ---
ns_p = sub.add_parser("new-session")
ns_p.add_argument("--preset", choices=list_presets(), default="custom")
ns_p.add_argument("--depth", choices=["sprint", "standard", "deep"], default="standard")
ns_p.add_argument("--style", choices=["socratic", "scenario-first", "metaphor-first", "form", "conversational"], default="scenario-first")
ns_p.set_defaults(func=cmd_new_session)
# --- apply-patch ---
ap_p = sub.add_parser("apply-patch")
ap_p.add_argument("id")
ap_p.set_defaults(func=cmd_apply_patch)
# --- next-target ---
nt_p = sub.add_parser("next-target")
nt_p.add_argument("id")
nt_p.set_defaults(func=cmd_next_target)
# --- coverage ---
cov_p = sub.add_parser("coverage")
cov_p.add_argument("id")
cov_p.set_defaults(func=cmd_coverage)
# --- done ---
done_p = sub.add_parser("done")
done_p.add_argument("id")
done_p.set_defaults(func=cmd_done)
# --- list ---
list_p = sub.add_parser("list")
list_p.set_defaults(func=cmd_list)
# --- export ---
export_p = sub.add_parser("export")
export_p.add_argument("id")
export_p.add_argument("target")
export_p.set_defaults(func=cmd_export)
# --- setup (key-entry only, sops-required) ---
setup_p = sub.add_parser("setup")
setup_p.set_defaults(func=cmd_setup)
# --- init (full first-run wizard) ---
init_p = sub.add_parser("init", help="Interactive first-run setup wizard")
init_p.set_defaults(func=cmd_init)
# --- propose-from-session (ingest a prior transcript → JTBD patch proposal) ---
pfs_p = sub.add_parser(
"propose-from-session",
help="Ingest a prior session and propose a DesignJSON patch (user must approve before apply-patch)",
)
pfs_p.add_argument("session_id", nargs="?")
pfs_p.add_argument("--current", action="store_true",
help="Use the current running Claude Code session (via CLAUDE_CODE_SESSION_ID)")
pfs_p.add_argument("--project", help="Scope search to a specific project directory name")
pfs_p.add_argument("--path", help="Direct path to a transcript file")
pfs_p.add_argument(
"--bundle-only",
action="store_true",
help="Emit just the deterministic bundle (no LLM call)",
)
pfs_p.set_defaults(func=cmd_propose_from_session)
# --- list-sessions (show Claude Code sessions) ---
ls_p = sub.add_parser(
"list-sessions",
help="List Claude Code sessions (most recent first)",
)
ls_p.add_argument("--project", help="Filter by project directory name")
ls_p.add_argument("--limit", type=int, default=20, help="Max sessions to show (default: 20)")
ls_p.set_defaults(func=cmd_list_sessions)
args = parser.parse_args(argv)
if args.cmd == "new" and getattr(args, "voice", False):
from skill_studio.voice.pipecat_interview import run_voice_interview
return run_voice_interview(args)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from skill_studio.schema import DesignJSON
class Exporter(Protocol):
name: str
def render(self, design: DesignJSON, out_dir: Path) -> list[Path]: ...
from __future__ import annotations
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape
from skill_studio.schema import DesignJSON
TEMPLATE_DIR = Path(__file__).resolve().parents[2].parent / "assets"
class MdSvgExporter:
name = "md-svg"
def __init__(self, template_dir: Path = TEMPLATE_DIR):
self.env = Environment(
loader=FileSystemLoader(str(template_dir)),
autoescape=select_autoescape(["html", "svg"]),
keep_trailing_newline=True,
)
def render(self, design: DesignJSON, out_dir: Path) -> list[Path]:
out_dir.mkdir(parents=True, exist_ok=True)
md = self.env.get_template("design.md.j2").render(design=design)
svg = self.env.get_template("design.svg.j2").render(design=design)
md_path = out_dir / "design.md"
svg_path = out_dir / "design.svg"
md_path.write_text(md)
svg_path.write_text(svg)
return [md_path, svg_path]
from __future__ import annotations
from skill_studio.exporters.base import Exporter
from skill_studio.exporters.md_svg import MdSvgExporter
EXPORTERS: dict[str, Exporter] = {
"md-svg": MdSvgExporter(),
}
def get_exporter(name: str) -> Exporter:
if name not in EXPORTERS:
raise KeyError(f"Unknown exporter: {name}. Available: {sorted(EXPORTERS)}")
return EXPORTERS[name]
"""Turn an ingest Bundle into a proposed DesignJSON patch (single LLM call).
Flow: Bundle (deterministic) -> proposer (one LLM call) -> partial JSON patch.
The patch is NOT applied; the caller must present it to the user for approval.
"""
from __future__ import annotations
import json
from typing import Protocol
from .transcript import Bundle
class LLMProvider(Protocol):
def ask(self, history: list[dict], max_tokens: int = ...) -> str: ...
PROPOSE_PROMPT = """You are helping seed a JTBD interview from a compact bundle of signals \
extracted from a prior session. Propose initial values for the DesignJSON schema below, \
using ONLY what the signals support. When uncertain, leave the field empty — do not \
hallucinate. The user will review and approve/edit before anything is applied.
Schema (propose only fields the signals justify):
hook (str), problem.what_hurts (str), problem.cost_today (str),
jtbd.situation (str), jtbd.motivation (str), jtbd.outcome (str),
scenarios (list of {title, vignette}),
capabilities (list), inputs (list)
The bundle now includes structured session activity:
- agents: subagent calls with descriptions, types, and prompt snippets
- skills: skill invocations observed during the session
- tool_sequence: ordered list of all tool calls with descriptions
- tool_frequency: how often each tool was used
- workflow_patterns: repeated tool sequences (potential automatable workflows)
Use these signals to infer capabilities, inputs, and scenarios. For example:
- Agent calls reveal delegation patterns and multi-step orchestration
- Skill calls show existing automations the user relies on
- Workflow patterns (repeated tool sequences) suggest automatable workflows
- Tool frequency reveals the user's primary interaction patterns
Rules:
- Every proposed value must cite which signal it came from in the "rationale" map.
- If the bundle is empty/thin, propose {} — do not invent.
- cost_today: only fill if total_cost_usd > 0 or the transcript clearly complains about cost.
- scenarios: derive from agent calls, workflow patterns, or iterations — not a single turn.
- capabilities: derive from agent descriptions, skill calls, and tool sequences.
Output format (valid JSON only, no prose):
{
"patch": { <partial DesignJSON> },
"rationale": { "<field.path>": "<which signal(s) justified this>" },
"skill_proposals": [
{
"name": "suggested-skill-name",
"description": "what this skill would do",
"trigger": "when to invoke it",
"workflow": ["tool1", "tool2", "tool3"],
"source_signals": ["which bundle fields support this proposal"]
}
]
}
skill_proposals: If the session reveals repeatable multi-step workflows (from \
workflow_patterns, agent orchestration, or repeated skill+tool sequences), \
propose them as potential new skills. Only propose when the evidence is strong — \
at least 2 occurrences of a pattern or a clear agent orchestration flow. \
If no strong patterns exist, return an empty list.
"""
def _extract_json(raw: str) -> dict | None:
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
return None
try:
return json.loads(raw[start : end + 1])
except json.JSONDecodeError:
return None
def _compact_bundle(bundle: Bundle) -> dict:
"""Trim the bundle to fit in a single LLM prompt without losing signal."""
d = bundle.to_dict()
if len(d.get("tool_sequence", [])) > 30:
d["tool_sequence"] = d["tool_sequence"][:15] + [{"tool": "...", "description": f"({len(d['tool_sequence']) - 30} more)"}] + d["tool_sequence"][-15:]
for agent in d.get("agents", []):
if len(agent.get("prompt_snippet", "")) > 150:
agent["prompt_snippet"] = agent["prompt_snippet"][:150] + "..."
return d
def propose(bundle: Bundle, llm: LLMProvider, max_tokens: int = 1500) -> tuple[dict, dict]:
"""Run the LLM once over the compact bundle. Returns (patch, rationale).
On any failure (bad JSON, LLM error), returns ({}, {}) — caller falls back
to starting the interview without a seed.
"""
body = json.dumps(_compact_bundle(bundle), indent=2)
try:
raw = llm.ask(
history=[
{"role": "user", "content": f"{PROPOSE_PROMPT}\n\nBundle:\n{body}"},
],
max_tokens=max_tokens,
)
except Exception:
return {}, {}
parsed = _extract_json(raw)
if not isinstance(parsed, dict):
return {}, {}
patch = parsed.get("patch") or {}
rationale = parsed.get("rationale") or {}
if parsed.get("skill_proposals"):
rationale["_skill_proposals"] = parsed["skill_proposals"]
return patch, rationale
"""Deterministic transcript ingest — compresses a session transcript into a
small structured bundle before any LLM touches it.
Sources auto-detected:
- Claude Code session transcript (~/.claude/projects/*/<uuid>.jsonl)
- skill-studio session (~/.skill-studio/sessions/<uuid>/transcript.md)
- Arbitrary file via --from-path
- Current session via CLAUDE_CODE_SESSION_ID env var
Zero LLM calls. Pure regex + parse. The output bundle is meant to fit into
a single LLM prompt for the proposal step.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
from collections import Counter
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Iterable, Literal
Source = Literal["claude-code", "skill-studio", "path"]
MODEL_PATTERNS = [
r"\b(gpt-[0-9][a-z0-9\-\.]*)\b",
r"\b(claude-[0-9a-z\-\.]+)\b",
r"\b(mistral-[a-z0-9\-\.]+)\b",
r"\b(llama-?\d[a-z0-9\-\.]*)\b",
r"\b(gemini-[a-z0-9\-\.]+)\b",
r"\b(sonnet-[0-9a-z\-\.]+)\b",
r"\b(opus-[0-9a-z\-\.]+)\b",
r"\b(haiku-[0-9a-z\-\.]+)\b",
]
COST_PATTERNS = [
re.compile(r"\$(\d+\.\d{2,4})\s*(?:spent|cost|charged|USD)?", re.I),
re.compile(r"cost[:= ]+\$?(\d+\.\d{2,4})", re.I),
re.compile(r"usage[:= ]+\$?(\d+\.\d{2,4})", re.I),
]
PROMPT_CHANGE_MARKERS = [
re.compile(r"\b(retrieval|rag|system)\s+prompt[:= ]", re.I),
re.compile(r"(changed|updated|tweaked)\s+(the\s+)?prompt", re.I),
re.compile(r"new prompt", re.I),
]
PAIN_MARKERS = [
re.compile(r"\b(frustrat|annoy|wast|slow|stuck|blocked|hate|struggle|hurt)\w*", re.I),
re.compile(r"(too (many|much|long|slow)|keeps? (fail|breaking))", re.I),
]
@dataclass
class AgentCall:
description: str
subagent_type: str | None = None
prompt_snippet: str = ""
def to_dict(self) -> dict:
d = {"description": self.description}
if self.subagent_type:
d["subagent_type"] = self.subagent_type
if self.prompt_snippet:
d["prompt_snippet"] = self.prompt_snippet
return d
@dataclass
class SkillCall:
skill: str
args: str | None = None
def to_dict(self) -> dict:
d = {"skill": self.skill}
if self.args:
d["args"] = self.args
return d
@dataclass
class WorkflowStep:
tool: str
description: str = ""
def to_dict(self) -> dict:
d = {"tool": self.tool}
if self.description:
d["description"] = self.description
return d
@dataclass
class Bundle:
session_id: str
source: Source
models_tried: list[str] = field(default_factory=list)
prompt_hashes: list[str] = field(default_factory=list)
iterations: list[dict] = field(default_factory=list)
pain_snippets: list[str] = field(default_factory=list)
total_cost_usd: float = 0.0
turn_count: int = 0
agents: list[AgentCall] = field(default_factory=list)
skills: list[SkillCall] = field(default_factory=list)
tool_sequence: list[WorkflowStep] = field(default_factory=list)
tool_frequency: dict[str, int] = field(default_factory=dict)
workflow_patterns: list[dict] = field(default_factory=list)
def to_dict(self) -> dict:
d = asdict(self)
d["agents"] = [a.to_dict() for a in self.agents]
d["skills"] = [s.to_dict() for s in self.skills]
d["tool_sequence"] = [w.to_dict() for w in self.tool_sequence]
d["summary"] = {
"turns": self.turn_count,
"iterations": len(self.iterations),
"models_tried": len(self.models_tried),
"prompt_variants": len(self.prompt_hashes),
"total_cost_usd": round(self.total_cost_usd, 4),
"pain_signals": len(self.pain_snippets),
"agent_calls": len(self.agents),
"skill_calls": len(self.skills),
"unique_tools": len(self.tool_frequency),
"workflow_patterns": len(self.workflow_patterns),
}
return d
def get_current_session_id() -> str | None:
return os.environ.get("CLAUDE_CODE_SESSION_ID")
def find_claude_code_session(session_id: str, project: str | None = None) -> Path | None:
root = Path.home() / ".claude" / "projects"
if not root.exists():
return None
if project:
project_dir = root / project
if project_dir.exists():
matches = list(project_dir.glob(f"{session_id}*.jsonl"))
return matches[0] if matches else None
matches = list(root.rglob(f"{session_id}*.jsonl"))
return matches[0] if matches else None
def list_claude_code_sessions(project: str | None = None, limit: int = 20) -> list[dict]:
root = Path.home() / ".claude" / "projects"
if not root.exists():
return []
if project:
dirs = [root / project]
else:
dirs = [d for d in root.iterdir() if d.is_dir()]
results = []
for d in dirs:
for f in d.glob("*.jsonl"):
sid = f.stem
title = _read_session_title(f)
results.append({
"session_id": sid,
"project": d.name,
"modified": f.stat().st_mtime,
"size_kb": round(f.stat().st_size / 1024, 1),
"title": title,
})
results.sort(key=lambda r: r["modified"], reverse=True)
return results[:limit]
def _read_session_title(path: Path) -> str:
for line in path.open():
try:
obj = json.loads(line)
if obj.get("type") == "ai-title":
return obj.get("title", "")
except json.JSONDecodeError:
continue
return ""
def find_skill_studio_session(session_id: str) -> Path | None:
from skill_studio import paths
for d in paths.session_root().glob(f"{session_id}*"):
t = d / "transcript.md"
if t.exists():
return t
return None
def resolve_session(session_id: str, *, project: str | None = None) -> tuple[Path, Source]:
"""Find a session by id across known sources. Raises if not found."""
p = find_claude_code_session(session_id, project=project)
if p is not None:
return p, "claude-code"
p = find_skill_studio_session(session_id)
if p is not None:
return p, "skill-studio"
raise FileNotFoundError(f"session not found: {session_id}")
def resolve_current_session(*, project: str | None = None) -> tuple[Path, str]:
"""Find the current running session via env var. Returns (path, session_id)."""
sid = get_current_session_id()
if not sid:
raise RuntimeError(
"CLAUDE_CODE_SESSION_ID not set — run inside a Claude Code session "
"or pass a session_id explicitly"
)
p = find_claude_code_session(sid, project=project)
if p is None:
raise FileNotFoundError(f"current session file not found: {sid}")
return p, sid
def _iter_text(path: Path) -> Iterable[str]:
if path.suffix == ".jsonl":
for line in path.read_text().splitlines():
try:
obj = json.loads(line)
msg = obj.get("message") or obj
content = msg.get("content") if isinstance(msg, dict) else None
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
yield block.get("text", "")
elif isinstance(content, str):
yield content
except json.JSONDecodeError:
continue
else:
yield path.read_text()
def _iter_tool_uses(path: Path) -> Iterable[dict]:
"""Yield tool_use blocks from assistant messages in a JSONL session."""
if path.suffix != ".jsonl":
return
for line in path.read_text().splitlines():
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
if obj.get("type") != "assistant":
continue
msg = obj.get("message", {})
content = msg.get("content", [])
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") == "tool_use":
yield block
def _detect_workflow_patterns(steps: list[WorkflowStep], min_length: int = 2, min_count: int = 2) -> list[dict]:
"""Find repeated subsequences of tool calls that indicate a workflow.
Filters out homogeneous sequences (e.g. Bash→Bash) since those are noise.
"""
if len(steps) < min_length * min_count:
return []
tool_names = [s.tool for s in steps]
patterns: Counter[tuple[str, ...]] = Counter()
for length in range(min_length, min(6, len(tool_names) // min_count + 1)):
for i in range(len(tool_names) - length + 1):
seq = tuple(tool_names[i : i + length])
if len(set(seq)) < 2:
continue
patterns[seq] += 1
results = []
seen_supersets: set[tuple[str, ...]] = set()
for seq, count in patterns.most_common():
if count < min_count:
continue
is_subset = any(
len(sup) > len(seq) and _is_subsequence(seq, sup)
for sup in seen_supersets
)
if is_subset:
continue
seen_supersets.add(seq)
results.append({
"tools": list(seq),
"count": count,
"label": " → ".join(seq),
})
return results[:10]
def _is_subsequence(short: tuple[str, ...], long: tuple[str, ...]) -> bool:
it = iter(long)
return all(c in it for c in short)
def extract(path: Path, session_id: str, source: Source, *, max_pain_snippets: int = 6) -> Bundle:
b = Bundle(session_id=session_id, source=source)
seen_models: set[str] = set()
seen_prompts: set[str] = set()
tool_counter: Counter[str] = Counter()
for block in _iter_tool_uses(path):
name = block.get("name", "")
inp = block.get("input", {})
tool_counter[name] += 1
if name == "Agent":
desc = inp.get("description", "")
prompt = inp.get("prompt", "")
agent = AgentCall(
description=desc,
subagent_type=inp.get("subagent_type"),
prompt_snippet=prompt[:200] if prompt else "",
)
b.agents.append(agent)
b.tool_sequence.append(WorkflowStep(tool="Agent", description=desc))
elif name == "Skill":
skill = SkillCall(skill=inp.get("skill", ""), args=inp.get("args"))
b.skills.append(skill)
b.tool_sequence.append(WorkflowStep(tool="Skill", description=inp.get("skill", "")))
else:
desc = inp.get("description", "")
b.tool_sequence.append(WorkflowStep(tool=name, description=desc))
b.tool_frequency = dict(tool_counter.most_common())
b.workflow_patterns = _detect_workflow_patterns(b.tool_sequence)
for chunk in _iter_text(path):
b.turn_count += 1
for pattern in MODEL_PATTERNS:
for m in re.findall(pattern, chunk, re.I):
seen_models.add(m.lower())
cost_spans: dict[tuple[int, int], float] = {}
for rx in COST_PATTERNS:
for m in rx.finditer(chunk):
try:
amt = float(m.group(1))
except (ValueError, IndexError):
continue
if 0.001 <= amt <= 100:
span = m.span(1)
cost_spans.setdefault(span, amt)
for amt in cost_spans.values():
b.total_cost_usd += amt
b.iterations.append({"action": "cost_event", "amount_usd": amt})
for rx in PROMPT_CHANGE_MARKERS:
if rx.search(chunk):
h = hashlib.sha1(chunk.encode("utf-8", "ignore")).hexdigest()[:8]
if h not in seen_prompts:
seen_prompts.add(h)
b.iterations.append({"action": "prompt_change", "hash": h})
break
if len(b.pain_snippets) < max_pain_snippets:
for rx in PAIN_MARKERS:
m = rx.search(chunk)
if m:
start = max(0, m.start() - 60)
end = min(len(chunk), m.end() + 60)
snippet = chunk[start:end].strip().replace("\n", " ")
if snippet and snippet not in b.pain_snippets:
b.pain_snippets.append(snippet)
break
b.models_tried = sorted(seen_models)
b.prompt_hashes = sorted(seen_prompts)
return b
"""Interactive first-run wizard. Walks a new user through prereq checks,
data-home selection, encryption mode, LLM/voice provider setup, and prints
a shell-rc snippet with the env vars they need exported.
Wraps the narrower `setup.run_setup` (which only handles key entry via sops).
The wizard falls back to plaintext dotenv when sops is unavailable."""
from __future__ import annotations
import getpass
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Callable
from skill_studio import paths
# ---------------------------------------------------------------------------
# Prereqs
# ---------------------------------------------------------------------------
def check_python() -> tuple[bool, str]:
major, minor = sys.version_info[:2]
ok = (major, minor) >= (3, 11)
return ok, f"Python {major}.{minor} ({'ok' if ok else 'need 3.11+'})"
def check_sops() -> tuple[bool, str]:
path = shutil.which("sops")
if not path:
return False, "sops: not found on PATH"
return True, f"sops: {path}"
def check_age_key() -> tuple[bool, str]:
"""age key is needed for sops. Accept either SOPS_AGE_KEY_FILE or the
standard ~/.config/sops/age/keys.txt location."""
env = os.environ.get("SOPS_AGE_KEY_FILE")
if env and Path(env).expanduser().exists():
return True, f"age key: {env}"
default = Path.home() / ".config/sops/age/keys.txt"
if default.exists():
return True, f"age key: {default}"
return False, "age key: not found (SOPS_AGE_KEY_FILE unset; ~/.config/sops/age/keys.txt missing)"
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
def _prompt(label: str, default: str = "") -> str:
suffix = f" [{default}]" if default else ""
ans = input(f"{label}{suffix}: ").strip()
return ans or default
def _prompt_yn(label: str, default: bool = True) -> bool:
marker = "Y/n" if default else "y/N"
ans = input(f"{label} [{marker}]: ").strip().lower()
if not ans:
return default
return ans.startswith("y")
def _prompt_choice(label: str, choices: list[str], default: str) -> str:
shown = "/".join(c if c != default else c.upper() for c in choices)
while True:
ans = input(f"{label} ({shown}): ").strip().lower() or default
if ans in choices:
return ans
print(f" pick one of: {', '.join(choices)}")
# ---------------------------------------------------------------------------
# Env writing
# ---------------------------------------------------------------------------
def _write_plaintext_env(path: Path, entries: dict[str, str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
body = "\n".join(f"{k}={v}" for k, v in entries.items()) + "\n"
path.write_text(body)
path.chmod(0o600)
def _write_sops_env(path: Path, entries: dict[str, str]) -> None:
from skill_studio.sops_helper import encrypt_dotenv
_write_plaintext_env(path, entries)
encrypt_dotenv(path)
# ---------------------------------------------------------------------------
# Smoke test
# ---------------------------------------------------------------------------
def _smoke_test() -> bool:
"""Run `pytest -q` inside the project. Returns True on pass."""
root = Path(__file__).resolve().parents[2]
try:
r = subprocess.run(
[sys.executable, "-m", "pytest", "-q", "--no-header", "-x"],
cwd=root,
capture_output=True,
text=True,
timeout=120,
)
except Exception as e:
print(f" smoke test could not run: {e}")
return False
if r.returncode == 0:
last = r.stdout.strip().splitlines()[-1] if r.stdout else ""
print(f" ✓ {last}")
return True
print(r.stdout[-500:])
print(r.stderr[-500:])
return False
# ---------------------------------------------------------------------------
# Main flow
# ---------------------------------------------------------------------------
BANNER = """
╭─────────────────────────────────────────────╮
│ skill-studio — first-run setup wizard │
╰─────────────────────────────────────────────╯
"""
def run_init_wizard(
input_fn: Callable[[str], str] | None = None,
) -> int:
"""Return 0 on success, non-zero on abort."""
# input_fn injection is for tests; default module uses builtin input.
if input_fn is not None:
global input
input = input_fn # type: ignore[assignment]
print(BANNER)
# 1. Prereqs
print("Checking prerequisites…")
py_ok, py_msg = check_python()
sops_ok, sops_msg = check_sops()
age_ok, age_msg = check_age_key()
for ok, msg in [(py_ok, py_msg), (sops_ok, sops_msg), (age_ok, age_msg)]:
print(f" {'✓' if ok else '·'} {msg}")
if not py_ok:
print("\nPython 3.11+ is required. Aborting.")
return 1
# 2. Data home
print("\n— Data location —")
current_home = os.environ.get("SKILL_STUDIO_HOME") or str(paths.home())
data_home = Path(_prompt("Data home (sessions + cache)", current_home)).expanduser()
data_home.mkdir(parents=True, exist_ok=True)
(data_home / "sessions").mkdir(exist_ok=True)
print(f" ✓ {data_home}")
# 3. Encryption mode
print("\n— Secrets storage —")
sops_available = sops_ok and age_ok
if sops_available:
encrypt = _prompt_yn("Encrypt provider keys with sops?", default=True)
else:
print(" sops unavailable — secrets will be stored in plaintext (chmod 600).")
print(" Install sops + age to enable encryption later: https://github.com/getsops/sops")
encrypt = False
env_file = Path(_prompt(
"Env file path",
os.environ.get("SKILL_STUDIO_ENV_FILE") or str(paths.env_file()),
)).expanduser()
# 4. LLM provider
print("\n— LLM provider —")
provider = _prompt_choice(
"Provider",
["openrouter", "anthropic", "skip"],
default="openrouter",
)
entries: dict[str, str] = {}
if provider == "openrouter":
key = getpass.getpass(" OPENROUTER_API_KEY (hidden, Enter to skip): ").strip()
if key:
entries["OPENROUTER_API_KEY"] = key
entries["LLM_PROVIDER"] = "openrouter"
entries["OPENROUTER_MODEL"] = _prompt(
" OpenRouter model", "anthropic/claude-opus-4"
)
elif provider == "anthropic":
key = getpass.getpass(" ANTHROPIC_API_KEY (hidden, Enter to skip): ").strip()
if key:
entries["ANTHROPIC_API_KEY"] = key
entries["LLM_PROVIDER"] = "anthropic"
else:
print(" Skipped — text mode inside Claude Code still works without a provider key.")
# 5. Voice mode
print("\n— Voice mode —")
want_voice = _prompt_yn("Enable voice mode? (needs Daily / Groq / Deepgram)", default=False)
if want_voice:
for k, label in [
("DAILY_API_KEY", "Daily API key"),
("GROQ_API_KEY", "Groq API key"),
("DEEPGRAM_API_KEY", "Deepgram API key"),
]:
v = getpass.getpass(f" {label} (hidden): ").strip()
if v:
entries[k] = v
entries.setdefault("DEEPGRAM_VOICE", "aura-asteria-en")
# 6. Write env file
if entries:
print(f"\nWriting env file to {env_file} ({'sops-encrypted' if encrypt else 'plaintext'})…")
try:
if encrypt:
_write_sops_env(env_file, entries)
else:
_write_plaintext_env(env_file, entries)
print(" ✓")
except Exception as e:
print(f" ✗ {e}")
return 2
else:
print("\nNo keys captured — skipping env file write.")
# 7. Shell rc snippet
print("\n— Shell configuration —")
lines = [
f'export SKILL_STUDIO_HOME="{data_home}"',
f'export SKILL_STUDIO_ENV_FILE="{env_file}"',
]
print("Add these to your shell rc (~/.zshrc, ~/.bashrc, etc.):\n")
for line in lines:
print(f" {line}")
# 8. Smoke test
print("\n— Smoke test —")
if _prompt_yn("Run test suite to verify install?", default=True):
ok = _smoke_test()
if not ok:
print(" tests failed — check output above")
print("\nDone. Try: skill-studio new --preset ai-agent --depth sprint")
return 0
"""Finding 2: Fold extractor + director landing check into a single LLM call.
Each user turn previously made 2 sequential LLM calls:
1. extract_and_apply — extract schema fields from transcript tail
2. _subject_landed — decide if current subject has been addressed
This module collapses them into one call that returns both signals at once,
cutting per-turn LLM latency in half in voice mode.
"""
from __future__ import annotations
import json
from skill_studio.schema import DesignJSON
from skill_studio.interview.subjects import Subject
from skill_studio.interview.merge import deep_merge
COMBINED_PROMPT = """\
You are an AI interview assistant. Given recent interview exchanges and the \
current landing criterion, perform two tasks at once and return a single JSON \
object with exactly two keys:
1. "landed": true if the user's latest answer satisfies the landing criterion, \
false otherwise.
2. "patch": a partial DesignJSON object containing only fields the transcript \
actually fills — or an empty object {} if nothing concrete was said.
Respond with ONLY valid JSON. No commentary. No markdown fences. Example:
{"landed": true, "patch": {"hook": "Weekly review drafter"}}
"""
def analyze_turn(
design: DesignJSON,
subject: Subject | None,
transcript_tail: list[dict],
llm,
) -> tuple[bool, dict]:
"""Single LLM call: decide if subject landed AND extract schema patch.
Returns (landed: bool, patch: dict).
Falls back to (False, {}) on any parse or network error.
"""
landing_criterion = subject.landing_criterion if subject else "always true"
context = "\n".join(f"{t['role']}: {t['text']}" for t in transcript_tail[-6:])
user_msg = (
f"{COMBINED_PROMPT}\n\n"
f"Landing criterion: {landing_criterion}\n\n"
f"Recent exchanges:\n{context}\n\n"
f"JSON:"
)
try:
raw = llm.ask(history=[{"role": "user", "content": user_msg}], max_tokens=900)
except Exception:
return False, {}
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
return False, {}
try:
result = json.loads(raw[start:end + 1])
except json.JSONDecodeError:
return False, {}
landed = bool(result.get("landed", False))
patch = result.get("patch", {})
if not isinstance(patch, dict):
patch = {}
if patch:
deep_merge(design, patch)
return landed, patch
from __future__ import annotations
from typing import Any
from skill_studio.schema import DesignJSON
from skill_studio.presets import Preset
def _get(obj: Any, path: str) -> Any:
"""Walk dotted path on a pydantic model or dict."""
cur: Any = obj
for part in path.split("."):
if hasattr(cur, part):
cur = getattr(cur, part)
elif isinstance(cur, dict):
cur = cur.get(part)
else:
return None
return cur
def _flatten_weights(weights: dict[str, Any], prefix: str = "") -> dict[str, float]:
out: dict[str, float] = {}
for k, v in weights.items():
full = f"{prefix}.{k}" if prefix else k
if isinstance(v, dict):
out.update(_flatten_weights(v, full))
else:
out[full] = float(v)
return out
def score_coverage(design: DesignJSON) -> dict[str, float]:
"""Return per-field confidence 0..1. Simple v1 heuristic."""
scores: dict[str, float] = {}
checkable = [
"hook", "problem.what_hurts", "problem.cost_today",
"jtbd.situation", "jtbd.motivation", "jtbd.outcome",
"before_after.before_external", "before_after.before_internal",
"before_after.after_external", "before_after.after_internal",
"cta",
"trigger.detail",
]
for path in checkable:
val = _get(design, path)
scores[path] = 1.0 if (isinstance(val, str) and val.strip()) else 0.0
list_targets = {
"needs.functional": 2, "needs.emotional": 1, "needs.social": 1,
"inputs": 2, "capabilities": 2, "outputs": 1, "guardrails": 1,
"scenarios": 1,
}
for path, target in list_targets.items():
val = _get(design, path) or []
scores[path] = min(1.0, len(val) / target)
scores["concept_imagery.metaphor"] = 1.0 if (design.concept_imagery.metaphor or "").strip() else 0.0
# Aggregate top-level scores so presets that weight a whole submodel (e.g.
# `before_after: 0.7`) get credited once its nested fields are filled.
def _group_mean(prefix: str) -> float:
sub = [v for k, v in scores.items() if k.startswith(f"{prefix}.")]
return sum(sub) / len(sub) if sub else 0.0
for group in ("before_after", "problem", "jtbd", "trigger", "concept_imagery"):
scores.setdefault(group, _group_mean(group))
return scores
def overall_coverage(design: DesignJSON, preset: Preset) -> float:
scores = score_coverage(design)
weights = _flatten_weights(preset.field_weights)
total_weight = sum(weights.values())
if total_weight == 0:
return 0.0
weighted = sum(scores.get(k, 0.0) * w for k, w in weights.items())
return weighted / total_weight
def next_uncovered_field(design: DesignJSON, preset: Preset) -> str | None:
scores = score_coverage(design)
weights = _flatten_weights(preset.field_weights)
candidates = [(k, w * (1.0 - scores.get(k, 0.0))) for k, w in weights.items()]
candidates.sort(key=lambda kv: kv[1], reverse=True)
for k, gain in candidates:
if gain > 0.01:
return k
return None
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from skill_studio.interview.phases import Phase, next_phase, ARC
from skill_studio.interview.subjects import subjects_for, Subject, SUBJECTS
class MoveKind(str, Enum):
FOLLOW_UP = "follow_up"
NEW_SUBJECT = "new_subject"
ADVANCE_PHASE = "advance_phase"
CLOSE = "close"
@dataclass
class DirectorState:
phase: Phase = Phase.OPENING
current_subject: Subject | None = None
subjects_landed: set[str] = field(default_factory=set)
follow_ups_on_current: int = 0
max_follow_ups: int = 4 # conversational default; each subject gets more probing before moving on
asked_questions_per_phase: dict[str, list[str]] = field(default_factory=dict)
def to_schema_state(self):
"""Serialize to InterviewState for persistence in DesignJSON."""
from skill_studio.schema import InterviewState
return InterviewState(
phase=self.phase.value,
current_subject_key=self.current_subject.key if self.current_subject else None,
subjects_landed=list(self.subjects_landed),
follow_ups_on_current=self.follow_ups_on_current,
max_follow_ups=self.max_follow_ups,
asked_questions_per_phase={
k: list(v) for k, v in self.asked_questions_per_phase.items()
},
)
@classmethod
def from_schema_state(cls, state) -> "DirectorState":
"""Hydrate from a persisted InterviewState schema object."""
phase = Phase(state.phase)
# Resolve current subject from key
current_subject: Subject | None = None
if state.current_subject_key:
for subj_list in SUBJECTS.values():
for s in subj_list:
if s.key == state.current_subject_key:
current_subject = s
break
return cls(
phase=phase,
current_subject=current_subject,
subjects_landed=set(state.subjects_landed),
follow_ups_on_current=state.follow_ups_on_current,
max_follow_ups=state.max_follow_ups,
asked_questions_per_phase={
k: list(v) for k, v in state.asked_questions_per_phase.items()
},
)
@dataclass
class Move:
kind: MoveKind
phase: Phase
subject: Subject | None
rationale: str = ""
def next_move(state: DirectorState, last_user_text: str | None = None, llm=None, *, landed: bool | None = None) -> Move:
"""Decide the next move.
Two call modes:
- Legacy: next_move(state, last_user_text, llm) — calls _subject_landed internally (2 LLM calls per turn).
- Fast: next_move(state, landed=<bool>) — caller passes pre-computed landed signal
(used by combined.analyze_turn for 1 LLM call per turn).
"""
# Initial move: first subject of current phase
if state.current_subject is None:
subjects = subjects_for(state.phase)
if not subjects:
nxt = next_phase(state.phase)
return Move(
MoveKind.ADVANCE_PHASE if nxt else MoveKind.CLOSE,
nxt or Phase.CLOSE,
None,
"no subjects in phase",
)
return Move(MoveKind.NEW_SUBJECT, state.phase, subjects[0], "first subject in phase")
# Resolve landed signal
if landed is None:
# Legacy path: call LLM directly
landed = _subject_landed(state.current_subject, last_user_text or "", llm)
if not landed and state.follow_ups_on_current < state.max_follow_ups:
return Move(MoveKind.FOLLOW_UP, state.phase, state.current_subject, "not landed, follow up")
# Mark landed (or give up after max follow-ups) and find next subject in phase
remaining = [
s for s in subjects_for(state.phase)
if s.key not in state.subjects_landed and s.key != state.current_subject.key
]
if remaining:
return Move(MoveKind.NEW_SUBJECT, state.phase, remaining[0], "next subject in phase")
# Phase done — advance
nxt = next_phase(state.phase)
if nxt is None:
return Move(MoveKind.CLOSE, Phase.CLOSE, None, "arc complete")
return Move(MoveKind.ADVANCE_PHASE, nxt, None, f"advance {state.phase.value} -> {nxt.value}")
def _subject_landed(subject: Subject, last_user_text: str, llm) -> bool:
"""Ask the LLM whether the user's last answer satisfies the landing criterion.
Legacy helper — kept for callers that use the 2-LLM-call path.
The hot path now goes through combined.analyze_turn for 1 call per turn.
"""
if not last_user_text or len(last_user_text.strip()) < 5:
return False
prompt = (
f"Landing criterion: {subject.landing_criterion}\n"
f"User just said: {last_user_text!r}\n\n"
f"Does this answer satisfy the landing criterion? Reply with only 'YES' or 'NO'."
)
try:
resp = llm.ask(history=[{"role": "user", "content": prompt}], max_tokens=5)
return resp.strip().upper().startswith("YES")
except Exception:
return False # conservative
from __future__ import annotations
import json
from skill_studio.schema import DesignJSON
from skill_studio.interview.merge import deep_merge
EXTRACTION_PROMPT = """Given recent interview exchanges, extract any fields from the DesignJSON schema that they fill. Output ONLY valid JSON with fields the transcript actually addresses, no others. Don't hallucinate."""
def extract_and_apply(design: DesignJSON, transcript_tail: list[dict], llm) -> dict:
"""After each user turn, run extraction from transcript tail and merge into design.
Returns the patch applied (empty dict if extraction produced nothing).
"""
context = "\n".join(f"{t['role']}: {t['text']}" for t in transcript_tail[-6:])
user_msg = (
f"{EXTRACTION_PROMPT}\n\n"
f"Recent exchanges:\n{context}\n\n"
f"Partial JSON:"
)
try:
raw = llm.ask(history=[{"role": "user", "content": user_msg}], max_tokens=800)
except Exception:
return {}
start, end = raw.find("{"), raw.rfind("}")
if start == -1 or end == -1:
return {}
try:
patch = json.loads(raw[start:end + 1])
except json.JSONDecodeError:
return {}
deep_merge(design, patch)
return patch
# Backward-compatible alias — existing tests import _deep_merge from extractor.
_deep_merge = deep_merge
from __future__ import annotations
import re
from skill_studio.presets import load_preset
FORCES_SIGNALS = re.compile(r"\b(stuck|meant to|never do|avoid|keep meaning|procrastina\w+|dread|anxious|afraid)\b", re.I)
OUTCOMES_SIGNALS = re.compile(r"\b(\d+\s*(hour|min|%|percent)|reduce|cut|speed up|down to|from \d)\b", re.I)
FSE_SIGNALS = re.compile(r"\b(team|colleagues|boss|client|reputation|image|seen as|come across)\b", re.I)
def suggest_frame(transcript: str, preset: str) -> str:
text = transcript.lower()
if OUTCOMES_SIGNALS.search(text):
return "outcomes"
if FSE_SIGNALS.search(text):
return "fse"
if FORCES_SIGNALS.search(text):
return "forces"
return load_preset(preset).default_jtbd_frame
from __future__ import annotations
from pathlib import Path
import yaml
FRAMEWORKS_DIR = Path(__file__).parent
def load_framework(name: str) -> dict:
path = FRAMEWORKS_DIR / f"{name}.yaml"
if not path.exists():
raise ValueError(f"Unknown framework: {name}. Available: {list_frameworks()}")
return yaml.safe_load(path.read_text())
def list_frameworks() -> list[str]:
return sorted(p.stem for p in FRAMEWORKS_DIR.glob("*.yaml"))
name: forces-of-progress
author: Alan Klement (Jobs to be Done)
stance: |
You are running a Forces of Progress interview. Your stance is curious, grounded,
and specific. Prefer short questions. Probe for the Push of current situation
and the Pull of new promise. Anxieties and habits emerge naturally — don't force
them. Never ask about features. Ask about moments, frustrations, and hopes.
phases:
opening:
questions:
- "What are you trying to change about how you work?"
- "What made you reach for a new tool today?"
- "What's pulling you to build something?"
pain:
questions:
- "What's the current way of doing this that wears you down?"
- "What part of it gets you most?"
- "Why does that part bother you specifically?"
follow_ups:
- "Say more about that."
- "What makes *that* the sticking point?"
moment:
questions:
- "Walk me through the last time this bit. Where were you, what had just happened?"
- "Paint the scene — I want to see it."
- "When was the most recent time you felt this?"
follow_ups:
- "What were you thinking right then?"
- "What did you do next?"
cost:
questions:
- "What does this cost you when it happens?"
- "How much time or energy does this take on a typical week?"
- "What else does it affect — your mood, your relationships, your focus?"
after:
questions:
- "If this were handled tomorrow, what would feel different?"
- "Describe the version of this where you're happy with how it goes."
- "What would you do with the time or energy this gave back?"
shape:
questions:
- "When should this tool show up? What's the trigger?"
- "What's the smallest thing the tool needs to do to help?"
- "Where do you want to be in the loop, and where can it just handle it?"
guardrails:
questions:
- "What would the tool doing make you regret using it?"
- "Where does it need to stop and check in with you?"
close:
questions:
- "Here's what I heard. Does this feel like you?"
- "Anything I'm missing?"
from __future__ import annotations
from skill_studio.schema import DesignJSON, TranscriptTurn
from skill_studio.presets import Preset
from skill_studio.interview.director import DirectorState, Move, MoveKind, next_move
from skill_studio.interview.question_picker import pick_question
from skill_studio.interview.extractor import extract_and_apply
from skill_studio.interview.combined import analyze_turn
from skill_studio.interview.modes import STYLE_SYSTEM_PROMPTS
from skill_studio.interview.phases import Phase
from skill_studio.interview.subjects import subjects_for
def _preferred_framework(preset_name: str) -> str:
return {
"ai-agent": "forces",
"life-automation": "forces",
"knowledge-work": "forces",
"custom": "forces",
}.get(preset_name, "forces")
def _load_state(design: DesignJSON) -> DirectorState:
"""Hydrate DirectorState from design.interview_state (survives restarts)."""
return DirectorState.from_schema_state(design.interview_state)
def _save_state(design: DesignJSON, state: DirectorState) -> None:
"""Persist DirectorState back into design so storage can flush it."""
design.interview_state = state.to_schema_state()
def run_interview_turn(design: DesignJSON, preset: Preset, llm, user_input: str | None) -> str:
"""Run one turn of the narrative-arc interview.
Call with user_input=None on the first turn to receive the opening question.
On subsequent turns pass the user's text; the function appends both sides to
the transcript and returns the next interviewer question.
Signature is identical to the legacy loop — voice/ and cli.py are unaffected.
"""
state = _load_state(design)
framework = _preferred_framework(design.meta.preset)
style_prompt = STYLE_SYSTEM_PROMPTS.get(design.meta.interview_mode.style, "")
if user_input is not None:
design.transcript.append(TranscriptTurn(role="user", text=user_input))
# Single LLM call: extract schema fields AND decide if subject landed.
# Falls back to (False, {}) on any error, which is conservative but safe.
tail = [{"role": t.role, "text": t.text} for t in design.transcript[-8:]]
landed, _patch = analyze_turn(design, state.current_subject, tail, llm)
# Decide next move using the pre-computed landed signal (no extra LLM call)
move = next_move(state, landed=landed)
else:
# First turn — start at the opening of the arc
move = Move(MoveKind.NEW_SUBJECT, Phase.OPENING, None, "start")
# Apply state transitions from the move
if move.kind == MoveKind.FOLLOW_UP:
state.follow_ups_on_current += 1
elif move.kind == MoveKind.NEW_SUBJECT:
if state.current_subject is not None:
state.subjects_landed.add(state.current_subject.key)
state.current_subject = move.subject
state.follow_ups_on_current = 0
elif move.kind == MoveKind.ADVANCE_PHASE:
if state.current_subject is not None:
state.subjects_landed.add(state.current_subject.key)
state.phase = move.phase
state.current_subject = None
state.follow_ups_on_current = 0
# Lookahead: enter the first subject of the new phase immediately
first = subjects_for(state.phase)
if first:
state.current_subject = first[0]
move = Move(MoveKind.NEW_SUBJECT, state.phase, first[0], "phase entry")
elif move.kind == MoveKind.CLOSE:
state.phase = Phase.CLOSE
state.current_subject = None
# Pick and return a question
tail = [{"role": t.role, "text": t.text} for t in design.transcript[-6:]]
asked_in_phase = set(state.asked_questions_per_phase.get(state.phase.value, []))
question = pick_question(move, framework, tail, llm, style_prompt, asked_in_phase=asked_in_phase)
# Record the asked question for per-phase dedup (Finding 4)
phase_key = state.phase.value
if phase_key not in state.asked_questions_per_phase:
state.asked_questions_per_phase[phase_key] = []
if question not in state.asked_questions_per_phase[phase_key]:
state.asked_questions_per_phase[phase_key].append(question)
design.transcript.append(TranscriptTurn(role="assistant", text=question))
# Persist director state into design (survives restarts via storage)
_save_state(design, state)
return question
from __future__ import annotations
from skill_studio.schema import DesignJSON
def deep_merge(design: DesignJSON, patch: dict) -> None:
"""Merge a partial JSON patch into a DesignJSON model in-place.
Supports both nested dicts and dot-notation keys (e.g. "problem.what_hurts").
"""
expanded = _expand_dot_keys(patch)
_apply(design, expanded)
def _expand_dot_keys(patch: dict) -> dict:
"""Expand dot-notation keys into nested dicts."""
result: dict = {}
for k, v in patch.items():
if "." in k:
parts = k.split(".")
target = result
for part in parts[:-1]:
target = target.setdefault(part, {})
existing = target.get(parts[-1])
if isinstance(existing, dict) and isinstance(v, dict):
existing.update(v)
else:
target[parts[-1]] = v
else:
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k].update(v)
else:
result[k] = v
return result
def _apply(design: DesignJSON, patch: dict) -> None:
"""Apply an expanded (no dot-keys) patch to a DesignJSON model."""
for k, v in patch.items():
if not hasattr(design, k):
continue
current = getattr(design, k)
is_submodel = hasattr(current, "model_copy")
if isinstance(v, dict) and is_submodel:
for sub_k, sub_v in v.items():
if hasattr(current, sub_k):
setattr(current, sub_k, sub_v)
elif is_submodel and isinstance(v, str):
for candidate in ("detail", "what_hurts", "situation", "motivation"):
if hasattr(current, candidate):
setattr(current, candidate, v)
break
elif isinstance(v, list):
field = type(design).model_fields.get(k)
item_type = None
if field is not None:
ann = field.annotation
args = getattr(ann, "__args__", None)
if args and hasattr(args[0], "model_validate"):
item_type = args[0]
if item_type is not None:
coerced = []
for item in v:
if isinstance(item, dict):
try:
coerced.append(item_type.model_validate(item))
except Exception:
continue
else:
coerced.append(item)
setattr(design, k, coerced)
else:
setattr(design, k, v)
elif is_submodel:
continue
else:
setattr(design, k, v)
COVERAGE_THRESHOLD: dict[str, float] = {"sprint": 0.6, "standard": 0.8, "deep": 0.92}
QUESTION_BUDGET: dict[str, int] = {"sprint": 7, "standard": 20, "deep": 35}
STYLE_SYSTEM_PROMPTS: dict[str, str] = {
"socratic": "You are a socratic interviewer. Chain 'why' questions, stress-test assumptions, surface contradictions. Be respectful but probing.",
"scenario-first": "You are a warm interviewer. Open every line of questioning with a concrete scenario — 'walk me through a specific time...'. Prefer stories over abstractions.",
"metaphor-first": "You are a playful interviewer. Reach for metaphors, analogies, and personification. Ask what the automation would be if it were a character, a weather pattern, a piece of furniture.",
"form": "You are a terse form-filler. Ask one direct question per field with no preamble. Accept short answers. No smalltalk.",
"conversational": (
"You are a conversational partner, not a form-filler. "
"ALWAYS reflect back a specific phrase or concrete detail from the user's last answer before asking the next thing — quote them briefly, then probe. "
"Prefer going deeper on what they just said over moving to a new topic. "
"Only advance to a new subject after you've followed one thread to something specific. "
"Voice-friendly: short questions, natural tone, no bullet points or lists."
),
}
from __future__ import annotations
from enum import Enum
class Phase(str, Enum):
OPENING = "opening"
PAIN = "pain"
MOMENT = "moment"
COST = "cost"
AFTER = "after"
SHAPE = "shape"
GUARDRAILS = "guardrails"
CLOSE = "close"
ARC = [
Phase.OPENING,
Phase.PAIN,
Phase.MOMENT,
Phase.COST,
Phase.AFTER,
Phase.SHAPE,
Phase.GUARDRAILS,
Phase.CLOSE,
]
# Phases that can be skipped if depth-mode says so
OPTIONAL_PHASES = {Phase.GUARDRAILS}
def next_phase(current: Phase) -> Phase | None:
idx = ARC.index(current)
if idx + 1 >= len(ARC):
return None
return ARC[idx + 1]
from __future__ import annotations
from skill_studio.interview.frameworks import load_framework
from skill_studio.interview.subjects import Subject
from skill_studio.interview.director import Move, MoveKind
def pick_question(
move: Move,
framework_name: str,
transcript_tail: list[dict],
llm,
style_prompt: str = "",
asked_in_phase: set[str] | None = None,
) -> str:
"""Pick a question given the director's move.
Uses the YAML question bank first, falls back to LLM generation when
all curated questions for a phase have already been asked.
asked_in_phase: explicit set of questions already asked in this phase
(persisted across restarts via DirectorState). If None, falls back to
scanning the transcript tail.
"""
fw = load_framework(framework_name)
phase_key = move.phase.value
phase_data = fw.get("phases", {}).get(phase_key, {})
if move.kind == MoveKind.FOLLOW_UP:
pool = phase_data.get("follow_ups", []) or phase_data.get("questions", [])
elif move.kind in (MoveKind.NEW_SUBJECT, MoveKind.ADVANCE_PHASE):
pool = phase_data.get("questions", [])
elif move.kind == MoveKind.CLOSE:
pool = fw.get("phases", {}).get("close", {}).get("questions", [
"Here's what I heard. Does this feel like you?"
])
else:
pool = phase_data.get("questions", [])
# Combine persistent per-phase dedup set with tail-scan (belt + suspenders).
tail_asked = {t["text"] for t in transcript_tail if t.get("role") == "assistant"}
already_asked = (asked_in_phase or set()) | tail_asked
for q in pool:
if q not in already_asked:
return q
# Fallback: use LLM with the framework's stance + style prompt.
stance = fw.get("stance", "")
context = "\n".join(f"{t['role']}: {t['text']}" for t in transcript_tail[-6:])
user_msg = (
f"{stance}\n\n{style_prompt}\n\n"
f"Recent exchange:\n{context}\n\n"
f"Current phase: {move.phase.value}. "
f"Current subject: {move.subject.label if move.subject else 'close'}. "
f"Move: {move.kind.value}.\n\n"
f"Ask ONE short, human, natural question in keeping with the stance. "
f"No preamble. Don't number. Don't ask about features."
)
try:
return llm.ask(history=[{"role": "user", "content": user_msg}], max_tokens=100)
except Exception:
return pool[0] if pool else "Tell me more."
# ---------------------------------------------------------------------------
# Deprecated alias — kept so any code that still imports pick_next_question
# (e.g. old test snapshots or external callers) does not hard-crash.
# The old signature is incompatible with the new engine; this shim returns
# the opening question unconditionally and logs a deprecation warning.
# ---------------------------------------------------------------------------
def pick_next_question(design, preset, llm) -> str: # type: ignore[override]
"""Deprecated. Use pick_question() with a director Move instead."""
import warnings
warnings.warn(
"pick_next_question() is deprecated — use pick_question() with a Move.",
DeprecationWarning,
stacklevel=2,
)
if not design.transcript:
return preset.opening_question
# Minimal fallback: ask LLM with old-style context
from skill_studio.interview.coverage import next_uncovered_field
from skill_studio.interview.modes import STYLE_SYSTEM_PROMPTS
target = next_uncovered_field(design, preset)
if target is None:
return "I think we have enough — want to wrap up, or keep going?"
context = design.model_dump_json(indent=2)
style_prompt = STYLE_SYSTEM_PROMPTS[design.meta.interview_mode.style]
user_msg = (
f"{style_prompt}\n\n"
f"Current design state:\n```json\n{context}\n```\n\n"
f"TARGET FIELD: {target}\n\n"
f"Ask ONE question that most efficiently fills this field. "
f"Do not add preamble or summarize prior answers. Just the question."
)
return llm.ask(history=[{"role": "user", "content": user_msg}], max_tokens=200)
from __future__ import annotations
from dataclasses import dataclass
from skill_studio.interview.phases import Phase
@dataclass(frozen=True)
class Subject:
key: str
label: str
phase: Phase
landing_criterion: str # plain-English description for LLM to judge
SUBJECTS: dict[Phase, list[Subject]] = {
Phase.OPENING: [
Subject(
"aspiration",
"what you're trying to change",
Phase.OPENING,
"user has articulated a clear direction or goal in their own words",
),
],
Phase.PAIN: [
Subject(
"current_pain",
"what hurts about the current approach",
Phase.PAIN,
"user has named a specific, visceral dissatisfaction with how things are now",
),
Subject(
"push",
"what's driving them to look for something new",
Phase.PAIN,
"user has explained why now, or why they're seeking change",
),
],
Phase.MOMENT: [
Subject(
"vivid_scene",
"a specific recent instance of the problem",
Phase.MOMENT,
"user has described a concrete moment with time, place, and what happened",
),
],
Phase.COST: [
Subject(
"cost_today",
"what this costs them — time, money, energy, relationships",
Phase.COST,
"user has quantified or vividly described the cost",
),
],
Phase.AFTER: [
Subject(
"good_looks_like",
"what better would feel or look like",
Phase.AFTER,
"user has painted a picture of success, even roughly",
),
],
Phase.SHAPE: [
Subject(
"trigger",
"when the tool should kick in",
Phase.SHAPE,
"user has specified a clear trigger or invocation pattern",
),
Subject(
"capabilities",
"what the tool needs to do",
Phase.SHAPE,
"user has named the core actions the tool must perform",
),
],
Phase.GUARDRAILS: [
Subject(
"must_not",
"what the tool must never do",
Phase.GUARDRAILS,
"user has named at least one boundary or constraint",
),
],
Phase.CLOSE: [
Subject(
"synthesis",
"the user sees themselves in the design",
Phase.CLOSE,
"user confirms the synthesis resonates",
),
],
}
def subjects_for(phase: Phase) -> list[Subject]:
return SUBJECTS.get(phase, [])
from __future__ import annotations
import json
from skill_studio.schema import DesignJSON
from skill_studio.interview.merge import deep_merge
EXTRACTION_PROMPT = """Given the user's latest answer, extract any fields from the DesignJSON schema that it fills. Output ONLY valid JSON matching the schema's partial shape (only include fields the answer actually addresses, no others). Don't hallucinate."""
def apply_answer(design: DesignJSON, target: str, answer: str, llm) -> None:
user_msg = (
f"{EXTRACTION_PROMPT}\n\n"
f"Target field we were asking about: {target}\n"
f"User's answer: \"{answer}\"\n\n"
f"JSON (partial):"
)
raw = llm.ask(history=[{"role": "user", "content": user_msg}], max_tokens=800)
start = raw.find("{")
end = raw.rfind("}")
if start == -1 or end == -1:
return
try:
patch = json.loads(raw[start:end + 1])
except json.JSONDecodeError:
return
deep_merge(design, patch)
# Backward-compatible alias
_deep_merge = deep_merge
from __future__ import annotations
import os
from typing import Protocol, Any
class LLMProvider(Protocol):
system_prompt: str
model: str
def ask(self, history: list[dict], max_tokens: int = 600) -> str: ...
class AnthropicProvider:
def __init__(self, system_prompt: str, client: Any | None = None, model: str | None = None):
from anthropic import Anthropic
self.client = client or Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
self.model = model or os.environ.get("ANTHROPIC_MODEL", "claude-opus-4-7")
self.system_prompt = system_prompt
def ask(self, history: list[dict], max_tokens: int = 600) -> str:
resp = self.client.messages.create(
model=self.model,
max_tokens=max_tokens,
system=[{
"type": "text",
"text": self.system_prompt,
"cache_control": {"type": "ephemeral"},
}],
messages=history,
)
for block in resp.content:
if getattr(block, "text", None):
return block.text
return ""
class OpenRouterProvider:
def __init__(self, system_prompt: str, client: Any | None = None, model: str | None = None):
from openai import OpenAI
self.client = client or OpenAI(
api_key=os.environ["OPENROUTER_API_KEY"],
base_url="https://openrouter.ai/api/v1",
)
self.model = model or os.environ.get("OPENROUTER_MODEL", "anthropic/claude-opus-4")
self.system_prompt = system_prompt
def ask(self, history: list[dict], max_tokens: int = 600) -> str:
messages = [{"role": "system", "content": self.system_prompt}]
messages.extend(history)
resp = self.client.chat.completions.create(
model=self.model,
max_tokens=max_tokens,
messages=messages,
)
return resp.choices[0].message.content or ""
def get_provider(system_prompt: str) -> LLMProvider:
"""Factory — picks provider from LLM_PROVIDER env (default: auto-detect)."""
provider = os.environ.get("LLM_PROVIDER", "auto").lower()
if provider == "auto":
if os.environ.get("OPENROUTER_API_KEY"):
provider = "openrouter"
elif os.environ.get("ANTHROPIC_API_KEY"):
provider = "anthropic"
else:
raise ValueError(
"No LLM provider configured. Set OPENROUTER_API_KEY or ANTHROPIC_API_KEY."
)
if provider == "anthropic":
return AnthropicProvider(system_prompt=system_prompt)
elif provider == "openrouter":
return OpenRouterProvider(system_prompt=system_prompt)
else:
raise ValueError(f"Unknown LLM_PROVIDER: {provider!r}. Use 'anthropic' or 'openrouter'.")
"""Centralized path resolution with environment-variable overrides.
All user-facing paths are resolved here so the tool is portable and contains
no hard-coded, user-specific locations. Override any of these via env vars:
- SKILL_STUDIO_HOME — data root (default: ~/.skill-studio)
- SKILL_STUDIO_ENV_FILE — encrypted dotenv (default: $HOME/.env.skill-studio or $SKILL_STUDIO_HOME/.env)
- SKILL_STUDIO_PIPECAT_ENV — voice-mode secrets (default: $HOME/.env.pipecat)
- SKILL_STUDIO_IMPORT_ENV — optional dotenv to import OPENROUTER_API_KEY from during setup
- SKILL_STUDIO_GROUNDWORK_ROOT — optional groundwork integration; feature disabled if unset
"""
from __future__ import annotations
import os
from pathlib import Path
def _env_path(name: str, default: Path | None) -> Path | None:
val = os.environ.get(name)
if val:
return Path(val).expanduser()
return default
def home() -> Path:
"""Root directory for session data, cache, etc."""
default = Path.home() / ".skill-studio"
return _env_path("SKILL_STUDIO_HOME", default) # type: ignore[return-value]
def session_root() -> Path:
return home() / "sessions"
def env_file() -> Path:
"""Encrypted .env (sops) holding LLM + voice provider keys."""
default = Path.home() / ".env.skill-studio"
return _env_path("SKILL_STUDIO_ENV_FILE", default) # type: ignore[return-value]
def pipecat_env_file() -> Path:
default = Path.home() / ".env.pipecat"
return _env_path("SKILL_STUDIO_PIPECAT_ENV", default) # type: ignore[return-value]
def import_env_file() -> Path | None:
"""Optional dotenv to import OPENROUTER_API_KEY from during first-run setup."""
return _env_path("SKILL_STUDIO_IMPORT_ENV", None)
def groundwork_root() -> Path | None:
"""Optional groundwork integration root. If unset, groundwork feed is disabled."""
val = os.environ.get("SKILL_STUDIO_GROUNDWORK_ROOT")
if val:
return Path(val).expanduser()
return None
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel
PRESET_DIR = Path(__file__).parent
class Preset(BaseModel):
name: str
label: str
default_jtbd_frame: str
opening_question: str
field_weights: dict[str, Any] # nested dict or flat — both allowed
def load_preset(name: str) -> Preset:
path = PRESET_DIR / f"{name.replace('-', '_')}.yaml"
if not path.exists():
raise ValueError(f"Unknown preset: {name}")
data = yaml.safe_load(path.read_text())
return Preset.model_validate(data)
def list_presets() -> list[str]:
return [p.stem.replace("_", "-") for p in sorted(PRESET_DIR.glob("*.yaml"))]
name: ai-agent
label: AI agent / skill
default_jtbd_frame: forces
opening_question: "In one sentence — what do you want this agent to help you do?"
field_weights:
hook: 1.0
problem.what_hurts: 1.0
jtbd.situation: 0.9
jtbd.motivation: 0.9
trigger: 0.95
capabilities: 0.9
inputs: 0.8
outputs: 0.8
guardrails: 0.7
before_after: 0.7
scenarios: 0.7
needs.functional: 0.6
needs.emotional: 0.5
concept_imagery:
metaphor: 0.2
name: custom
label: Something else
default_jtbd_frame: job-story
opening_question: "What do you want to build or design today?"
field_weights:
hook: 1.0
problem.what_hurts: 0.9
jtbd.situation: 0.8
jtbd.motivation: 0.8
before_after: 0.7
scenarios: 0.7
needs.functional: 0.6
needs.emotional: 0.6
trigger: 0.6
capabilities: 0.5
outputs: 0.5
guardrails: 0.4
concept_imagery:
metaphor: 0.3
name: knowledge-work
label: Knowledge-worker workflow
default_jtbd_frame: fse
opening_question: "Which recurring task at work do you wish you could hand off?"
field_weights:
hook: 1.0
problem.what_hurts: 1.0
problem.cost_today: 1.0
jtbd.situation: 0.9
jtbd.motivation: 0.9
needs.functional: 0.9
needs.social: 0.7
needs.emotional: 0.7
before_after: 0.8
scenarios: 0.8
trigger: 0.7
capabilities: 0.6
outputs: 0.7
guardrails: 0.6
concept_imagery:
metaphor: 0.2
name: life-automation
label: Life / personal automation
default_jtbd_frame: job-story
opening_question: "Describe one moment last week when you wished a tool would just handle something for you."
field_weights:
hook: 1.0
problem.what_hurts: 1.0
problem.cost_today: 0.9
jtbd.situation: 0.9
jtbd.motivation: 0.9
before_after: 0.9
scenarios: 0.9
needs.emotional: 0.8
trigger: 0.6
capabilities: 0.4
outputs: 0.5
concept_imagery:
metaphor: 0.3
from __future__ import annotations
from datetime import datetime
from typing import Literal, Any
from pydantic import BaseModel, Field
from uuid import uuid4
Preset = Literal["ai-agent", "life-automation", "knowledge-work", "custom"]
JTBDFrame = Literal["forces", "fse", "outcomes", "job-story"]
DepthMode = Literal["sprint", "standard", "deep"]
StyleMode = Literal["socratic", "scenario-first", "metaphor-first", "form"]
Language = Literal["en", "ru"]
class InterviewMode(BaseModel):
depth: DepthMode = "standard"
style: StyleMode = "scenario-first"
class Meta(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
created: datetime = Field(default_factory=datetime.utcnow)
preset: Preset = "custom"
jtbd_frame: JTBDFrame = "job-story"
interview_mode: InterviewMode = Field(default_factory=InterviewMode)
language: Language = "en"
class Problem(BaseModel):
what_hurts: str = ""
cost_today: str = ""
class Needs(BaseModel):
functional: list[str] = Field(default_factory=list)
emotional: list[str] = Field(default_factory=list)
social: list[str] = Field(default_factory=list)
class JTBD(BaseModel):
situation: str = ""
motivation: str = ""
outcome: str = ""
class BeforeAfter(BaseModel):
before_external: str = ""
before_internal: str = ""
after_external: str = ""
after_internal: str = ""
class Scenario(BaseModel):
title: str
vignette: str
class Trigger(BaseModel):
type: Literal["manual", "scheduled", "event"] = "manual"
detail: str = ""
class ConceptImagery(BaseModel):
metaphor: str = ""
visual_style: str = ""
nano_banana_prompt: str = ""
class CoverageEntry(BaseModel):
confidence: float = 0.0
inferred: bool = False
turns_spent: int = 0
class TranscriptTurn(BaseModel):
role: Literal["assistant", "user"]
text: str
ts: datetime = Field(default_factory=datetime.utcnow)
class InterviewState(BaseModel):
phase: str = "opening" # Phase value
current_subject_key: str | None = None
subjects_landed: list[str] = Field(default_factory=list)
follow_ups_on_current: int = 0
max_follow_ups: int = 2
asked_questions_per_phase: dict[str, list[str]] = Field(default_factory=dict)
class DesignJSON(BaseModel):
meta: Meta
hook: str = ""
problem: Problem = Field(default_factory=Problem)
needs: Needs = Field(default_factory=Needs)
jtbd: JTBD = Field(default_factory=JTBD)
jtbd_frame_extension: dict[str, Any] = Field(default_factory=dict)
before_after: BeforeAfter = Field(default_factory=BeforeAfter)
scenarios: list[Scenario] = Field(default_factory=list)
cta: str = ""
trigger: Trigger = Field(default_factory=Trigger)
inputs: list[str] = Field(default_factory=list)
capabilities: list[str] = Field(default_factory=list)
outputs: list[str] = Field(default_factory=list)
guardrails: list[str] = Field(default_factory=list)
concept_imagery: ConceptImagery = Field(default_factory=ConceptImagery)
coverage: dict[str, CoverageEntry] = Field(default_factory=dict)
transcript: list[TranscriptTurn] = Field(default_factory=list)
interview_state: InterviewState = Field(default_factory=InterviewState)
from __future__ import annotations
import getpass
import os
from pathlib import Path
from typing import Callable
import urllib.request
from skill_studio.sops_helper import encrypt_dotenv
from skill_studio import paths
DEFAULT_ENV_PATH = paths.env_file()
DEFAULT_PIPECAT_ENV = paths.pipecat_env_file()
DEFAULT_AGENCY_RAG_ENV = paths.import_env_file()
def _validate_gemini(key: str) -> bool:
try:
req = urllib.request.Request(
f"https://generativelanguage.googleapis.com/v1beta/models?key={key}",
method="GET",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status == 200
except Exception:
return False
def run_setup(
env_path: Path = DEFAULT_ENV_PATH,
pipecat_env: Path = DEFAULT_PIPECAT_ENV,
agency_rag_env: Path | None = DEFAULT_AGENCY_RAG_ENV,
validate_gemini: Callable[[str], bool] = _validate_gemini,
sops_helper=None,
) -> None:
print("Welcome. I'll ask for a few keys and encrypt them locally.\n")
if sops_helper is None:
from skill_studio import sops_helper as _sh
sops_helper = _sh
entries: dict[str, str] = {}
print("1. Gemini API key (image gen, nano-banana)")
print(" Get one: https://aistudio.google.com/apikey")
gemini = getpass.getpass(" Paste (hidden): ").strip()
if not gemini:
print(" Skipped.")
else:
print(" testing… ", end="")
if validate_gemini(gemini):
print("✓")
entries["GEMINI_API_KEY"] = gemini
else:
print("✗ (bad key) — aborting")
return
print("\n2. OpenRouter API key (primary LLM provider — default for text + voice)")
print(" Get one: https://openrouter.ai/keys")
or_imported = False
if agency_rag_env and agency_rag_env.exists():
ans = input(f" Found {agency_rag_env} — try to import OPENROUTER_API_KEY? [Y/n] ").strip().lower() or "y"
if ans == "y":
try:
rag_env = sops_helper.decrypt_dotenv(agency_rag_env)
if "OPENROUTER_API_KEY" in rag_env:
entries["OPENROUTER_API_KEY"] = rag_env["OPENROUTER_API_KEY"]
entries.setdefault("LLM_PROVIDER", "openrouter")
entries.setdefault("OPENROUTER_MODEL", "anthropic/claude-opus-4")
print(f" ✓ imported from {agency_rag_env}")
or_imported = True
else:
print(f" OPENROUTER_API_KEY not found in {agency_rag_env}")
except Exception as exc:
print(f" Could not decrypt {agency_rag_env}: {exc}")
if not or_imported:
key = getpass.getpass(" Paste (hidden): ").strip()
if key:
entries["OPENROUTER_API_KEY"] = key
entries.setdefault("LLM_PROVIDER", "openrouter")
entries.setdefault("OPENROUTER_MODEL", "anthropic/claude-opus-4")
print("\n3. Anthropic API key (optional — only needed if you set LLM_PROVIDER=anthropic)")
existing = os.environ.get("ANTHROPIC_API_KEY")
if existing:
ans = input(f" Reuse from $ANTHROPIC_API_KEY? [y/N] ").strip().lower() or "n"
if ans == "y":
entries["ANTHROPIC_API_KEY"] = existing
print(" ✓")
if "ANTHROPIC_API_KEY" not in entries:
key = getpass.getpass(" Paste (hidden, or Enter to skip): ").strip()
if key:
entries["ANTHROPIC_API_KEY"] = key
print("\n4. Pipecat voice keys (Daily, Groq, Deepgram)")
if pipecat_env.exists():
ans = input(f" Found {pipecat_env} — reuse? [Y/n] ").strip().lower() or "y"
if ans == "y":
for line in pipecat_env.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, _, v = line.partition("=")
entries[k.strip()] = v.strip()
print(" ✓")
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("\n".join(f"{k}={v}" for k, v in entries.items()) + "\n")
print(f"\nEncrypting to {env_path} via sops…", end=" ")
try:
encrypt_dotenv(env_path)
print("✓")
except Exception as e:
env_path.unlink(missing_ok=True)
print(f"✗ {e}")
raise
print("Done. Run /skill-studio new to try it out.")
from __future__ import annotations
import subprocess
from pathlib import Path
def encrypt_dotenv(path: Path) -> None:
"""Encrypt a file in place using sops. Uses defaults (.sops.yaml creation rules).
Runs with cwd = path.parent so sops can find the nearest .sops.yaml by walking
up from there (rather than from wherever the skill was invoked).
"""
result = subprocess.run(
["sops", "--encrypt", "--in-place", path.name],
cwd=str(path.parent),
check=False, capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"sops encrypt failed (exit {result.returncode}):\n"
f"STDERR:\n{result.stderr}\n"
f"STDOUT:\n{result.stdout}"
)
def decrypt_dotenv(path: Path) -> dict[str, str]:
"""Decrypt a sops-encrypted file and parse as dotenv."""
result = subprocess.run(
["sops", "--decrypt", path.name],
cwd=str(path.parent),
check=False, capture_output=True, text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"sops decrypt failed (exit {result.returncode}):\n"
f"STDERR:\n{result.stderr}"
)
env: dict[str, str] = {}
for line in result.stdout.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
env[k.strip()] = v.strip().strip('"').strip("'")
return env
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from skill_studio.schema import DesignJSON, Meta
def _migrate_str_submodels(data: dict) -> dict:
"""Best-effort migration: coerce str values into their submodel shape.
Older sessions may have `trigger: "..."` or `problem: "..."` on disk because
the updater used to shove LLM string outputs directly into Pydantic submodel
fields. Current schema rejects that. Auto-wrap during load.
"""
str_to_sub = {
"trigger": "detail",
"problem": "what_hurts",
"jtbd": "situation",
"needs": None, # Needs is a dict of lists; can't wrap a bare string
"before_after": None,
"concept_imagery": "metaphor",
}
for key, text_field in str_to_sub.items():
if isinstance(data.get(key), str) and text_field:
data[key] = {text_field: data[key]}
return data
class SessionStorage:
def __init__(self, root: Path):
self.root = root
self.root.mkdir(parents=True, exist_ok=True)
def new(self) -> DesignJSON:
design = DesignJSON(meta=Meta())
self._session_dir(design.meta.id).mkdir()
(self._session_dir(design.meta.id) / "transcript.md").touch()
self.save(design)
return design
def load(self, session_id: str) -> DesignJSON:
path = self._session_dir(session_id) / "design.json"
data = json.loads(path.read_text())
data = _migrate_str_submodels(data)
return DesignJSON.model_validate(data)
def save(self, design: DesignJSON) -> None:
path = self._session_dir(design.meta.id) / "design.json"
path.write_text(design.model_dump_json(indent=2))
def list(self) -> list[DesignJSON]:
out: list[DesignJSON] = []
for child in sorted(self.root.iterdir()):
if (child / "design.json").exists():
out.append(self.load(child.name))
return out
def append_transcript(self, session_id: str, role: str, text: str) -> None:
path = self._session_dir(session_id) / "transcript.md"
ts = datetime.utcnow().isoformat(timespec="seconds")
with path.open("a") as f:
f.write(f"\n**{role}** `{ts}`\n\n{text}\n")
def _session_dir(self, session_id: str) -> Path:
return self.root / session_id
from unittest.mock import MagicMock
from skill_studio.anthropic_client import AnthropicInterviewer
def test_ask_returns_text(monkeypatch):
fake_message = MagicMock()
fake_message.content = [MagicMock(text="What's the core pain?")]
fake_client = MagicMock()
fake_client.messages.create.return_value = fake_message
interviewer = AnthropicInterviewer(client=fake_client, system_prompt="sys")
out = interviewer.ask(history=[{"role": "user", "content": "hi"}])
assert out == "What's the core pain?"
call_kwargs = fake_client.messages.create.call_args.kwargs
assert isinstance(call_kwargs["system"], list)
assert call_kwargs["system"][0]["cache_control"] == {"type": "ephemeral"}
from skill_studio.exporters.registry import EXPORTERS, get_exporter
def test_md_svg_registered():
assert "md-svg" in EXPORTERS
def test_get_exporter_returns_instance():
exp = get_exporter("md-svg")
assert exp.name == "md-svg"
def test_unknown_exporter_raises():
import pytest
with pytest.raises(KeyError):
get_exporter("nope")