task-profile
Mine the user's Claude Code + Cowork session history into a structured task profile, what they do with AI, how often, how successfully where friction lives, then propose atomic skills that would reduce iteration. Use when the user asks to "analyse my Claude use", "build a task pr
Install
npx skills add https://github.com/techwolf-ai/ai-first-toolkit/tree/main/plugins/ai-adoption/skills/task-profile
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install techwolf-ai-ai-first-toolkit@llmmart
git clone https://github.com/techwolf-ai/ai-first-toolkit.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole techwolf-ai/ai-first-toolkit collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
task-profile
Platforms: Claude Code / Cowork and Codex.
scripts/inventory.pydetects the host (via theplatformstampinstall.shwrites, orAI_FIRST_PLATFORM) and routes: Claude Code (~/.claude/projects) + Cowork transcripts, or Codex rollouts (~/.codex/sessions), building the same session condensate + token aggregates either way. Antigravity is unsupported: its IDE store is AEAD-encrypted at rest and its CLI store has no parseable turn content, so the skill prints a clear "not available" message and exits.
End-to-end skill: session inventory → LLM clustering → parallel Haiku analysis → aggregation → branded explorer HTML + shareable CSV + atomic skill proposals.
When to run
When the user asks to understand their own Claude usage patterns: what tasks they repeat, how much friction those tasks generate where tokens go which principles they already follow vs. where they slip, and which new skills would compound across many tasks.
Prerequisites
- Session history on this machine:
- Claude Code:
~/.claude/projects/*/\*.jsonl, plus each session's sub-agent transcripts at<project>/<sid>/subagents/**(workflow agents one level deeper) - Claude Cowork:
~/Library/Application Support/Claude/local-agent-mode-sessions/*/*/local_*/audit.jsonl
- Claude Code:
- The
session-searchskill is already installed at~/.claude/skills/session-search/(optional but recommended; this skill does its own inventory pass). - None beyond Python 3, the HTML generator ships with its own light theme baked in. No external design or logo skill required.
Workflow
Run from any working directory, outputs land under ./out/ in that directory.
Phase A, Inventory (deterministic script)
~/.claude/skills/task-profile/scripts/inventory.py --out out/inventory.json
Flags: --since YYYY-MM-DD, --until YYYY-MM-DD, --all (default window: last 6 months).
Writes per-session rows with: summary, token totals (per model, from message.usage), automation flag + reason, and a structured condensate (intent turns + correction turns + tool-flail episodes + outcome turns). Automated sessions (paperclip, scheduled-task, sdk-cli, ditto-routine) are flagged and excluded from downstream analysis but kept for transparency.
Two things about the token totals, because they are measured over different scopes.
- A session's
tokensinclude its sub-agents. Sub-agent and workflow-agent transcripts are separate files but the same unit of work, so they roll into the session that spawned them.turnsstays main-session-only, so a session can show few turns and a very large token total. That is fan-out, not a contradiction; say so rather than letting the reader trip over it.subagent_filesandsubagent_turnsgive the size of the fan-out, andtokens.main_by_model/tokens.subagent_by_modelsplit the model mix so a model the user chose for the conversation is never confused with one a sub-agent ran. - One turn = one assistant message. Claude Code writes one JSONL line per content block (thinking, text, each tool_use) and repeats the same
usageobject on every line, so counting lines would inflate both turns and tokens by roughly 2-3x. The inventory dedupes bymessage.id.
Phase B, Cluster (main agent reads + judges)
You (the main agent) read the non-automation rows and group them into ~40–80 clusters by judgment, no scripted heuristics past cwd. Write out/clusters.json. Merge sessions with the same cwd, similar Cowork titles, or clearly similar topics. Show the cluster list to the user before the Haiku fan-out so they can adjust.
Phase C, Per-cluster payloads + Haiku fan-out (parallel)
Run out/build_payloads.py (generated per-run, sample below) to produce one payload per cluster. Sampling: ≤ 10 sessions → all included; > 10 → include 10 biased to outliers (3 longest by turns, 3 most corrections, oldest, newest, even-spaced fill).
Dispatch one Agent(subagent_type="general-purpose", model="haiku", run_in_background=true) per cluster in parallel. Each subagent reads:
~/.claude/skills/task-profile/references/task-style.md~/.claude/skills/task-profile/references/success-rubric.md~/.claude/skills/task-profile/references/friction-signals.md- Its cluster payload at
out/payloads/<cluster_id>.json
And emits strict JSON to out/analyses/<cluster_id>.json with a 1–3 task list per cluster.
Phase D, Aggregate (main agent + script)
You (the main agent) read out/analyses/*.json, decide cross-cluster merges, and write out/canonical-merges.json with entries of the form:
{"canonical": "<sentence>", "category": "<cat>", "source_tasks": [{"cluster": "...", "match": "<substring>"}]}
Then run:
~/.claude/skills/task-profile/scripts/write_profile.py
The script normalises success/category enums, applies redaction one more time, sums tokens per task from the inventory (no estimation, real message.usage values), and writes:
out/profile.csv, shareable, one row per canonical task, withtokens_by_modelas a compact string.out/profile.json, richer, includes per-task friction points and session list (for the explorer).
Phase E, Coaching panel + skill proposals (main agent, MANDATORY)
Do not skip this phase. The explorer is half-empty without it. build_explorer.py will refuse to run unless both out/coaching-panel.json and out/skill-proposals.json exist; override with --allow-empty is only for debugging.
E.1, Coaching panel
Read out/profile.json and ~/.claude/skills/task-profile/references/ai-first-principles.md. Pick 3–5 principles where the user has a clear, evidenced gap. For each, cite ≥ 1 good-example session path and ≥ 1 friction-example session path. Write out/coaching-panel.json.
Schema:
{
"cards": [
{
"principle": "<short name of the habit>",
"pattern": "<one-line description of the observed pattern>",
"good_example": {"description": "<what worked here>", "session_path": "<path>"},
"friction_example": {"description": "<what slipped>", "session_path": "<path>"},
"suggested_adjustment": "<concrete habit to try next time>"
}
]
}
E.2, Skill proposals
Step 1, MANDATORY: enumerate what's already installed. Before you write a single proposal, list every skill the user already has access to:
# User-level skills
ls ~/.claude/skills/ 2>/dev/null
# Project-level skills (if present)
ls .claude/skills/ 2>/dev/null
# Plugin-namespaced skills (read SKILL.md frontmatter to capture `description`)
for f in ~/.claude/plugins/cache/*/*/skills/*/SKILL.md ~/.claude/plugins/*/skills/*/SKILL.md; do
[ -f "$f" ] && echo "=== $f ===" && head -5 "$f"
done 2>/dev/null
Also scan the transcripts: any mcp__... tool call, any /<namespace>:<name> slash command the user has typed, and anything the coaching-panel.json cites as "you do this well already", all of those are skills already in play. Collect the full list into a working set before proposing anything.
Step 2, de-duplicate against reality. For every task cluster you might propose a skill for, ask:
- Is there already an installed skill whose
descriptioncovers this territory? If yes, DO NOT propose a parallel skill. Either skip the proposal or reframe it as "enhance<existing-skill>with X", scoped narrowly to the gap. - Is the gap just that the user doesn't know the skill exists, or that the trigger description is weak? If yes, the proposal is "update trigger for
<existing-skill>", not a new skill. - Does this overlap with a plugin skill (e.g. a memo template, a design system, a people-management namespace)? Plugins already ship the canonical implementation; re-inventing them is noise.
A proposal that duplicates an installed skill is a worse recommendation than no proposal at all. Five sharp proposals are better than five padded ones, and two sharp proposals beat five mediocre ones. Do not pad the list to reach 5.
Step 3, propose. Up to 5 atomic skills, each impacting ≥ 2 top tasks (breadth) and following the task-centric shape: prescriptive mandatory_steps, bundled sources-of-truth (guidelines, prior-art scripts, templates), fixed output_shape, invocation-as-slash-command. Avoid abstract workflow shapers ("opener-template", "staged-drafts", "checkpoint"), these sit outside a task and so don't get invoked in context.
For each proposal emit to out/skill-proposals.json:
name, slug for the skilltrigger_description, SKILL.md frontmatter descriptionmodelled_after, the existing installed skill it takes inspiration from, one line (REQUIRED, non-empty, references a real skill from Step 1)overlaps_considered, list of installed skills that cover adjacent territory + one-line why this proposal is still distinct (REQUIRED; empty list is only valid if the domain is genuinely uncovered)mandatory_steps, ordered list the skill runs every time (MANDATORY reads of guidelines/prior-art/references)output_shape, fixed filename convention + required sectionstasks_impacted, ≥ 2 entries withtask_id+why_relevantexpected_savings, small/medium/large + whyinvocation_hint,/skill-creator <name>
Add a top-level _installed_skills_checked array to skill-proposals.json listing every skill enumerated in Step 1, so the user can verify the pre-check actually ran.
Phase G, Persona card (main agent, MANDATORY)
Do not skip. build_explorer.py refuses to run without out/persona.json.
- Run the deterministic feature helper:
Produces~/.claude/skills/task-profile/scripts/persona_features.pyout/persona-features.jsonwith the numbers only. - Read
~/.claude/skills/task-profile/references/personas.md(the 20-persona catalogue + fallback Explorer). - Read
out/persona-features.json,out/profile.json,out/coaching-panel.json,out/skill-proposals.json. - Pick one primary persona whose triggers fire most clearly in the feature sheet. Break ties by coherence with the coaching cards. If fewer than ~10 interactive sessions, pick The Explorer.
- Optionally pick one secondary modifier. Leave
modifier: nullwhen none fits cleanly. - Write a 40–60-word tailored blurb, in second person, opening with a concrete behaviour and including one surprising number from the feature sheet. No em-dashes, hype words, brand names. Voice: observant friend, not marketing coach.
- Write
out/persona.json:
{
"id": "<persona-slug>",
"name": "<The Xxxx>",
"tagline": "<catalogue tagline>",
"modifier": "<slug or null>",
"confidence_note": "<why this persona beats the others, one sentence>",
"blurb": "<your rewritten 40–60-word blurb>",
"highlight_stat": {"label": "<short>", "value": <number>},
"top3_task_names": ["<short>", "<short>", "<short>"],
"features_used": { ... relevant numbers cited in the blurb ... }
}
Phase F, Explorer HTML
~/.claude/skills/task-profile/scripts/build_explorer.py
Fixed light theme baked into the generator: off-white background, aquamarine accents, subtle dot-grid atmosphere, Geist sans-serif via Google Fonts, glassmorphism adapted for light. Single-file, no network at runtime (fonts via CDN). Data embedded as a JSON blob. Uses progressive disclosure, categories open to reveal tasks; tasks open to reveal friction and tokens; coaching and proposals open to reveal detail. Includes:
- Token-spend chart (horizontal stacked bars per task, clickable to jump to task detail)
- Sortable/filterable task table with search, category, min-frequency, since-date
- Row-click expands per-task detail: friction points with what-would-prevent guidance, per-model token table, session list
- Personal coaching panel (AI-first principle comparison)
- Skill proposals cards
- Automation-filter transparency footer
Open with open out/explorer.html.
Final manual review
Before considering the run done, scan out/profile.csv and the explorer for the top-100 highest-entropy tokens (any random-looking string of mixed case + digits ≥ 16 chars). These are the most likely way a secret slipped past automated redaction. Ask the user to confirm the scan is clean.
Outputs at a glance
| File | Audience | Shape |
|---|---|---|
out/inventory.json |
Internal | Full per-session rows with condensates, subagent_files / subagent_turns, and tokens.main_by_model / tokens.subagent_by_model |
out/clusters.json |
Internal | [{cluster_id, label, session_paths}] |
out/payloads/*.json |
Haiku subagents | Sampled condensates per cluster |
out/analyses/*.json |
Internal | Haiku output, 1–3 tasks per cluster |
out/canonical-merges.json |
Internal | Main-agent cross-cluster merge decisions |
out/profile.csv |
Shareable with company | One row per canonical task |
out/profile.json |
Feeds the explorer | Rich task rows + session detail |
out/coaching-panel.json |
Feeds the explorer | Personal AI-first coaching cards |
out/skill-proposals.json |
Feeds the explorer + user action | Up to 5 cross-cutting skill proposals |
out/explorer.html |
Personal | Single-file UI with progressive disclosure |
References
references/task-style.md, CSV-style task sentence rules, good/bad examplesreferences/success-rubric.md, 4-level success taxonomy with signalsreferences/friction-signals.md, correction phrases + behavioural markersreferences/automation-filters.md, rules for flagging non-interactive sessionsreferences/redaction-rules.md, regex + heuristic rules for stripping secretsreferences/ai-first-principles.md, bootcamp + prompting principles used for coaching
Files (ai-first-toolkit)
-
assets
-
logo.svg 1.9 KB · in bundle
-
-
references
-
ai-first-principles.md 5.8 KB
# AI-first principles (for coaching panel) Used by the main agent during Phase D to populate the personal coaching panel in the explorer. Pick 3–5 principles where the user has a clear, evidenced gap. Cite specific session paths. Tone: meeting you where you are, never scolding. ## Source Two families of principles, bundled together here so this file is self-sufficient: - **Project-level**, how to structure a repo / workspace so an AI assistant can work it effectively. - **Prompting-level**, habits around how the user phrases requests that correlate with lower iteration counts. ## Principles ### 1. Give enough context up-front **Definition:** The opening turn should contain goal, constraints, paths/files in scope, and desired output shape. "Fix the scraper" beats nothing, but "fix the scraper at src/scrape.py, it fails on sites with JS rendering, want a headless-fallback, keep the public signature" is what unlocks a one-shot outcome. **Detection signals:** - Opening user turn < 200 chars AND ≥ 3 clarifying follow-ups in the first 5 assistant turns. - Early assistant turn asks a question like "could you share the file" / "what's the expected format" / "which library are you using". **Reframe template:** "Your openings tend to be ~X chars and need ~Y follow-ups. On sessions where the opener gave goal + paths + shape, you got there in one pass. Keeping a 4-line opener template would save those follow-ups." **Before/after:** - Before: `help me fix the api` - After: `fix the retry loop in src/api/client.py, it retries on 4xx which is wrong. keep the function signature. add a test. output: diff only` ### 2. One atomic ask per prompt **Definition:** Prompts asking for three different things get a muddled answer that half-addresses each. **Detection signals:** - Opener contains ≥ 3 coordinated asks (e.g. `build X and also add Y and then write Z`). - Session has multiple separate acceptance phrases for different pieces, hinting the work was fragmented. **Reframe template:** "Bundled asks run ~N iterations vs ~M for single-ask openers. Splitting into sequential single asks is usually faster end-to-end." ### 3. Point at paths, don't paste content **Definition:** Claude Code and Cowork both read files on demand. Pasting a 500-line file into the prompt burns input tokens and drops structure; pointing at the path + using `Read` is faster and cheaper. **Detection signals:** - Opening user turn contains large code blocks (> 50 lines) for files that exist in the session's `cwd`. - Token spend anomaly: `input_tokens` on the first assistant turn is ≫ median for the user. **Reframe template:** "~X% of your openers paste code; on those you spend ~Y× more input tokens and get a similar outcome." ### 4. Use skills for repeated workflows **Definition:** If you've written the same kind of prompt 3+ times, extract it as a skill. Skills give Claude the prescriptive recipe directly; the user stops re-explaining. **Detection signals:** - Multiple sessions across different cwds with near-identical opening turns. - User frequently walks Claude through the same process by hand. **Reframe template:** "You've issued variants of '<pattern>' in N sessions. A `<proposed-skill>` skill would compress all of them to a one-line invocation, see the skill proposals panel." ### 5. Plan, then act (for anything non-trivial) **Definition:** For risky or multi-step work, have Claude propose a plan first and iterate on the plan, cheaper than iterating on half-done code. **Detection signals:** - Sessions where a large assistant implementation turn is followed by a user correction that redirects the whole approach ("actually let's do it differently"). - Repeated rewrites of the same file within one session. **Reframe template:** "On sessions where you accepted a plan before implementation, avg iterations = X. On sessions that skipped planning, avg = Y. Plan-mode (`shift-tab` in Claude Code) for the riskier stuff saves the redo." ### 6. Terminal-first, de-UI **Definition:** Don't ask for a web dashboard when a table in the terminal or an HTML file on disk does the same job with a tenth of the code. **Detection signals:** - Project-level sessions building dashboards / Streamlit / React UIs for a workflow the user runs once or twice a week. - High friction on those sessions (styling, auth, deployment questions that aren't core to the task). **Reframe template:** "Your UI-heavy sessions run ~X iterations vs ~Y for equivalent CLI/HTML-file outcomes. When the audience is just you, a static HTML or a `rich`-formatted CLI is usually enough." ### 7. Skills-first over embedded agents **Definition:** When the workflow can be captured as a skill invoked from Claude Code / Cowork, don't build a deployed chatbot / embedded agent. Skills run in the user's own session with full tool access and no deployment overhead. **Detection signals:** - Sessions discussing deployment of a user-facing LLM app (Streamlit chat, Discord bot, web form) for internal use. - "Claude can just do this natively with a skill" is the higher-leverage path that was skipped. **Reframe template:** "You've scoped deployed-agent projects N times. Each one ended with a skill-shaped outcome anyway. Starting with `skill-creator` next time skips the deploy/iterate cycle." ## Coaching card shape in the explorer ``` Principle: <name> Your pattern: <one-line summary with a number> Good example: <session path>, <why it worked> Where it slipped: <session path>, <what was missing> (what would've helped: <concrete advice>) Suggested adjustment: <one practical habit change> ``` Always ≥ 1 good-example session cited (so the user sees this is achievable for them) and ≥ 1 friction-example session cited (so the advice is grounded). If a principle has no good example yet, say so honestly: `No session yet where you applied this, try it on the next one.` -
automation-filters.md 3.2 KB
# Automation filters Sessions matching any of these get `is_automation: true` in the inventory and are excluded from Phase B–D. They stay visible in the explorer's "filtered automations" footer for transparency. ## Strong signals (any one triggers the flag) 1. **Automation entrypoint.** A session whose `entrypoint` is in the deny-list `AUTOMATION_ENTRYPOINTS` (`sdk-cli`, `sdk`): background dispatch rather than a person at a keyboard. This is a **deny-list, not an allow-list**. `cli`, `claude-desktop`, and any entrypoint not named above count as interactive spend. The earlier rule was `entrypoint != "cli"`, which silently dropped every desktop-app session and made the skill see about a sixth of real activity. An unknown future entrypoint is treated as interactive on purpose: for a cost and behaviour tool, dropping real work is the worse failure. The trade-off is explicit, and it means an `api` or `cron` entrypoint (if either ever appears) would count as interactive until added to the deny-list. 2. **Cowork scheduled routine path.** The session `path` contains `/agent/local_ditto_` or `/agent/local_routine_`. 3. **Paperclip cloud sandbox path.** The session `path` contains `--paperclip-instances-`. (These are already excluded by `session-search`; belt-and-braces check here.) 4. **`<scheduled-task>` opener.** First user turn contains `<scheduled-task name="…">`, Cowork scheduled runs announce themselves this way. Reason tag: `scheduled-task:<name>`. 5. **Slash-command-only opener.** First user turn is composed entirely of `<command-name>…</command-name>` / `<command-message>…</command-message>` / `<command-args>…</command-args>` blocks (possibly with surrounding whitespace), and the invoked command is in this list: ``` /loop /schedule /babysit-prs /ultrareview /autonomous-loop /productivity:update /productivity:start ``` Slash commands that are interactive aids (e.g. `/permissions`, `/compact`, `/fast`) do NOT trigger this flag. ## Composite signal (all three required) 1. **No freeform user prose anywhere in the transcript.** Every user turn is either a `<command-*>` wrapper, a `<task-notification>` block, a `<local-command-caveat>` wrapper, or a raw paste of structured data (JSON, code, tool output) with no natural-language framing. 2. **Short + few turns.** Duration < 5 minutes AND user+assistant text turn count < 6 (after `<task-notification>` stripping). 3. **Recurring title.** An exact-match session `summary` (Cowork `title` or Code first-line) appears ≥ 2 times on the same cwd within 7 days, suggesting a schedule. ## What this does NOT flag - Human-run `/loop` or `/schedule` invocations where the user added a natural-language prompt after the slash, the first user turn contains freeform prose alongside the command wrapper. Keep these. - Sessions where the user spoke to an automation later in the conversation. Keep these, they became interactive. - Short interactive sessions. The composite signal requires all three conditions, not any one. ## `automation_reason` field Set to a short tag: `sdk-cli`, `ditto-routine`, `paperclip`, `slash-command-opener:<cmd>`, or `composite:short-recurring`. Used for the footer breakdown. -
friction-signals.md 2.3 KB
# Friction signals Detector inputs used during condensate extraction (`inventory.py`) and referenced by Haiku during per-cluster analysis. These are hints; Haiku makes the final call using them plus context. ## User correction phrases (case-insensitive, word-boundary) ``` no not quite that's wrong that is wrong actually wait stop don't no I meant let me rephrase that's not what I redo try again different approach simpler shorter longer also and also one more thing you forgot you missed missing you didn't this isn't that's not right hold on nevermind scrap ``` When any of these appears as a standalone word (not embedded in a larger sentence that negates the correction meaning), increment `user_correction_count` for that session and include the containing turn in the condensate. ## Positive / acceptance phrases ``` perfect great thanks thank you ship it looks good exactly nice love it done good job nailed it ✅ 👍 ``` Used to detect session success. ## Behavioural markers (no explicit phrase needed) - **Tool flailing:** same tool name called ≥3× within a window of 10 consecutive assistant turns, with different args. Count as one friction event; include the last two such calls in the condensate. - **Error paste:** a user turn containing triple-backticks AND any of `Traceback`, `Error:`, `Exception`, `stderr`, `FAIL`, `panic:` → friction event. Include the user turn in the condensate. - **Restart burst:** user issues ≥2 restart-flavoured phrases (`let's start over`, `start from scratch`, `reset`) → friction event. - **Long silence then pivot:** timestamp gap ≥ 30 min between consecutive turns, followed by a user turn that does not reference the previous assistant output → plausible context loss; flag. ## What NOT to count as friction - Multi-turn exploration where the user is learning and asking clarifying questions about a topic (not corrections of output). Signals: user turns are questions, not corrections; no correction phrases present. - Pair-programming style back-and-forth where short user turns contain requirements additions, not corrections. Signals: short turns framed as new requirements (`also add`, `now let's`, `next`) without negation. The point is to measure when the assistant failed to match intent, not when the user evolved the intent. -
personas.md 11.2 KB
# AI Adoption Personas Twenty archetypes the main agent picks from when populating the persona card. Pick **one primary** and optionally **one secondary modifier**. Rewrite the baseline into a tailored blurb per the rules in `SKILL.md` Phase G. Rules: - Prefer the persona whose trigger signals fire most clearly in `out/persona-features.json`. - Break ties by coherence with the coaching cards already written, the persona should complement the coaching, not contradict it. - Do not force a modifier. Leave `modifier: null` when none fits cleanly. A clean single persona reads stronger than a bolted-on tag. - Minimum sample size: below 10 sessions, fall back to **The Explorer** regardless of signals. --- ## Speed & style ### one-shot-wonder, **The One-Shot Wonder** - Tagline: *You get it right the first time, most times.* - Triggers: `avg_clean_pct_top10 ≥ 55`, `avg_iter_top10 ≤ 1.5`, total sessions ≥ 30. - Modifier compat: `code-native`, `cowork-native`, `focused-craftsman-lite`. - Baseline: *Opens with goal, constraints, paths, desired shape. Rarely goes back for a second pass when that happens, it's the prompt that changed, not the output.* - Emblem: single arrow striking the bull's-eye of three concentric rings. ### iterator, **The Iterator** - Tagline: *Conversations as craft, each turn sharpens the last.* - Triggers: `avg_iter_top10 ≥ 4`, success still reached (`avg_clean_pct_top10 + avg_friction_pct ≥ 80`), high per-task session count. - Modifier compat: `wordsmith-lean`, `opus-loyalist`. - Baseline: *Doesn't ask for the final draft, works toward it. Accepts a few detours in exchange for a noticeably better last turn.* - Emblem: tightening spiral, three loops. ### architect, **The Architect** - Tagline: *The prompt is the design, the rest is just rendering.* - Triggers: long first-user-msg median (≥ 400 chars), few corrections, plan-mode markers, many file paths in opener. - Modifier compat: `code-native`, `opus-loyalist`, `polymath`. - Baseline: *Sketches the whole building before the first brick. Openers read like a small spec, goal, constraints, shape of the output, acceptance criteria.* - Emblem: nested arches in a blueprint rectangle. ### sprinter, **The Sprinter** - Tagline: *Short prompts, quick wins, many of them.* - Triggers: median turns ≤ 6, ≥ 80 sessions, `avg_iter_top10 ≤ 2`. - Modifier compat: `cowork-native`, `morning-routine`. - Baseline: *Doesn't linger, in, done, next. Prefers small tasks that fit into a coffee break.* - Emblem: three parallel diagonal motion marks. ### marathoner, **The Marathoner** - Tagline: *You and one session, all the way to the finish.* - Triggers: ≥ 3 sessions over 100 turns, very high cache_read per session, high max_turns. - Modifier compat: `code-native`, `opus-loyalist`. - Baseline: *Goes long. Picks a hard problem, settles in, and doesn't resurface until it's done.* - Emblem: single rising peak line, summit marker on top. --- ## Work-type ### wordsmith, **The Wordsmith** - Tagline: *Writing is a two-player game, and you know your partner.* - Triggers: writing share of tokens ≥ 25%, ≥ 2 writing-category tasks in top-10. - Modifier compat: `iterator-lean`, `polymath`. - Baseline: *Writes with a voice, drafts, nudges, edits against a real standard. Catches AI tics before they ship.* - Emblem: flowing ligature stroke tapering into a single dot. ### engineer, **The Engineer** - Tagline: *You build, and the AI picks up the sander.* - Triggers: engineering share ≥ 30%, distinct_cwds ≥ 5 in engineering, code_token_share ≥ 0.6. - Modifier compat: `code-native`, `focused-craftsman-lite`, `opus-loyalist`. - Baseline: *Writes real code, ships real things. Uses AI where compilation would be faster with a second pair of eyes.* - Emblem: isometric cube with one face detached (exploded module). ### researcher, **The Researcher** - Tagline: *Every question opens three others, and you chase them all.* - Triggers: research + analysis ≥ 35% combined, multi-source fetch patterns in friction_points. - Modifier compat: `cowork-native`, `polymath`, `connector-lean`. - Baseline: *Pulls threads. One question becomes four sub-queries before you're done, each answered with receipts.* - Emblem: compass rose, four cardinal arms, aquamarine centre. ### diplomat, **The Diplomat** - Tagline: *You show up prepared, AI handles the pre-reading.* - Triggers: planning + communication ≥ 30%, meeting-prep tasks in top-5, heavy calendar/mail/chat signals. - Modifier compat: `cowork-native`, `morning-routine`. - Baseline: *Walks in having read everything, calendar, inbox, last exchange, open threads. The meeting is the easy part.* - Emblem: two arcs intersecting at a single aquamarine node. ### data-whisperer, **The Data Whisperer** - Tagline: *Rows in, decks out. Repeat.* - Triggers: ops + analysis ≥ 30%, excel/pptx-conversion or data-to-deck patterns in top-10. - Modifier compat: `code-native`, `focused-craftsman-lite`. - Baseline: *Takes a spreadsheet and gets back a deck. The plumbing between raw data and a shareable artifact is muscle memory.* - Emblem: three ascending bars rising out of a baseline, aquamarine dot atop the tallest. ### strategist, **The Strategist** - Tagline: *You think in memos, not messages.* - Triggers: planning ≥ 20% AND research ≥ 15%, ≥ 3 long-form memo-shape tasks. - Modifier compat: `polymath`, `opus-loyalist`. - Baseline: *Frames the problem before reaching for a tool. The AI work is mostly pressure-testing a position you already have.* - Emblem: concentric circles with a single outbound vector. --- ## Tooling & behaviour ### connector, **The Connector** - Tagline: *Your AI has read your whole stack, you wired it that way.* - Triggers: `distinct_mcps ≥ 6`, cross-source research patterns, ≥ 4 distinct tool families. - Modifier compat: `cowork-native`, `polymath`, `opus-loyalist`. - Baseline: *Connected the AI to the rest of your life, calendar, mail, chat, docs, data, and it earns its keep by spanning them.* - Emblem: central hub with six edges to satellite nodes; hub is the aquamarine dot. ### automator, **The Automator** - Tagline: *Most of your AI hours run while you sleep.* - Triggers: scheduled_run_count ≥ 20, recurring-title ratio high, morning-consistent timestamps. - Modifier compat: `cowork-native`, `morning-routine`. - Baseline: *Scheduled the repeating work away. Daily triage, weekly prep, periodic digests, all running while your kettle boils.* - Emblem: infinity loop, single aquamarine dot at the crossing point. ### skill-crafter, **The Skill Crafter** - Tagline: *Build once, invoke forever.* - Triggers: ≥ 3 skill-plugin-development sessions, recurring invocations of those skills later. - Modifier compat: `code-native`, `engineer-lean`. - Baseline: *Notices the third time you explain the same thing to the AI and turns it into a skill. Your scaffolding compounds.* - Emblem: cut-gem with four facets, aquamarine glint on one. ### conductor, **The Conductor** - Tagline: *You don't just use AI, you orchestrate it.* - Triggers: sub-agent dispatches ≥ 100, ≥ 4 active MCPs, ≥ 2 distinct scheduled routines, cross-category spread. - Modifier compat: `cowork-native`, `polymath`, `opus-loyalist`. - Baseline: *Runs AI like a full ensemble, scheduled routines handling triage, sub-agents working in parallel, a sure feel for which instrument belongs in which player's hands.* - Emblem: four arrows merging into a single aquamarine dot. ### bench-builder, **The Bench-Builder** - Tagline: *You don't polish the output, you polish the workshop.* - Triggers: heavy skill development + long-lived reuse, ratio of meta-work to output-work is unusually high. - Modifier compat: `code-native`, `focused-craftsman-lite`. - Baseline: *Spends disproportionate time on the tools themselves. The payoff lands weeks later when everyone else on the team suddenly ships faster.* - Emblem: four stacked bricks on a single baseline. --- ## Volume & efficiency ### token-titan, **The Token Titan** - Tagline: *Your monthly AI spend has its own line item.* - Triggers: total tokens in top decile (baseline or ≥ 500M), large Opus share, heavy cache usage. - Modifier compat: `opus-loyalist`, `polymath`. - Baseline: *Volume is the strategy. Where others pick carefully, you batch the thing and sort afterwards.* - Emblem: three stacked chips with a single aquamarine one on top. ### cache-whisperer, **The Cache Whisperer** - Tagline: *Same context, tenth the cost. You've seen this before.* - Triggers: `cache_ratio ≥ 0.80`, long session chains reusing scope. - Modifier compat: `code-native`, `focused-craftsman-lite`, `architect-lean`. - Baseline: *Reuses context instead of rebuilding it. Most of your token bill is cached reads which means most of your time isn't spent re-briefing the AI.* - Emblem: three concentric shells, thickest at the outside, aquamarine core. ### model-polyglot, **The Model Polyglot** - Tagline: *Right tool, right turn, right size.* - Triggers: ≥ 4 distinct models with ≥ 5% token share each. - Modifier compat: `polymath`, `connector-lean`. - Baseline: *Picks the model like picking the knife, Haiku for the chop, Opus for the reduce, Sonnet for the rest. Rarely wastes a big model on a small job.* - Emblem: four dots in a quadrant, each a different aquamarine tint. ### focused-craftsman, **The Focused Craftsman** - Tagline: *Few projects, deep water.* - Triggers: distinct_cwds ≤ 5, high session count per cwd, low topic spread. - Modifier compat: `code-native`, `cache-whisperer-lean`. - Baseline: *Doesn't spread thin. Two or three things at a time, and each of them gets the full treatment.* - Emblem: single deep engraved vertical mark, aquamarine base. --- ## Fallback ### explorer, **The Explorer** - Tagline: *Still finding the shape of it, and that's where the good patterns start.* - Triggers: ≤ 10 interactive sessions, or no persona scores convincingly. - Modifier compat: any, but usually left null. - Baseline: *Early days. The patterns aren't settled yet, and that's a feature, you're still trying things. Come back after another month of use and a persona will have formed around your fingerprints.* - Emblem: a compass with a single dashed path unspooling from it. --- ## Secondary modifiers Optional single-token tag that layers onto the primary persona. Pick one if it clearly fits, otherwise leave null. | Modifier | Fires when | Reads as | |---|---|---| | `code-native` | code_token_share ≥ 0.70 | "lives in the CLI" | | `cowork-native` | cowork_token_share ≥ 0.70 | "runs it from Cowork" | | `morning-routine` | ≥ 15 recurring scheduled runs with consistent hour-of-day | "greets the day with a cron" | | `opus-loyalist` | ≥ 70% tokens on Opus-class models | "always reaches for the big one" | | `polymath` | active in ≥ 5 categories each above 8% token share | "jumps domains fluently" | | `fresh-off-the-boat` | `last_session_date - first_session_date ≤ 30 days` | "just arrived, watch this space" | --- ## Blurb rules (short version; full rules in SKILL.md Phase G) - Second person, 40–60 words, 2–3 sentences. - Open with a concrete user behaviour, not the persona name. - Include one surprising number lifted from `persona-features.json`. - Land on a crisp style observation. - No em-dashes. No hype words. No brand names. No "leverage", "game-changer", "next-level". - Voice: observant friend, writing an affectionate roast. -
redaction-rules.md 2.8 KB
# Redaction rules Applied by a single `redact(text)` function in the scripts. Runs on every piece of text that will: - Be dispatched to a Haiku subagent. - Be written to `out/profile.csv`, `out/explorer.html`, or `out/skill-proposals.md`. Redaction is text-only. Paths, cwd names, session titles pass through untouched, they're often the user's project names which are not sensitive and removing them breaks usefulness. ## Replacement token format `[REDACTED:<type>]` Types: `api_key`, `token`, `jwt`, `private_key`, `email`, `phone`, `card`, `iban`. ## Patterns ### Credentials / API keys - `sk-[A-Za-z0-9\-_]{20,}` → `[REDACTED:api_key]` - `ghp_[A-Za-z0-9]{30,}` → `[REDACTED:token]` - `ghs_[A-Za-z0-9]{30,}` → `[REDACTED:token]` - `github_pat_[A-Za-z0-9_]{20,}` → `[REDACTED:token]` - `xox[baprs]-[A-Za-z0-9-]{10,}` → `[REDACTED:token]` (Slack) - `AIza[A-Za-z0-9\-_]{30,}` → `[REDACTED:api_key]` (Google) - `AKIA[A-Z0-9]{16}` → `[REDACTED:api_key]` (AWS) - `eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+` → `[REDACTED:jwt]` - `(?i)(password|passwd|pwd|secret|api[_-]?key|bearer)\s*[:=]\s*['"]?([^\s'"]+)['"]?` → keep the key name, replace the value: `$1=[REDACTED:token]` ### Private keys - `-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----` → `[REDACTED:private_key]` ### Emails Every email matching `[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}` gets its local part replaced: - Input: `first.last@example.com` → `[REDACTED:email]@example.com` - Keep the domain so the context survives (`"an @example.com user asked"`). ### Phone numbers Only when clearly a phone (low false-positive strategy): - Match: within 20 characters preceding the number, one of `phone`, `tel`, `call`, `mobile`, `sms`, `whatsapp` appears. - Number shape: `\+?\d[\d\s\-().]{7,}\d` - Replace the number with `[REDACTED:phone]`. ### Credit cards - Any 13–19 digit run (optionally dash- or space-separated in groups of 4) that passes the Luhn checksum → `[REDACTED:card]`. ### IBAN - `[A-Z]{2}\d{2}[A-Z0-9]{10,30}` → `[REDACTED:iban]`. ## Conservative bias False positives are cheap here, a person reading the output sees `[REDACTED:…]` and moves on. False negatives (a secret that leaks) are not. When a regex is close but uncertain, keep it in the rule set. ## Final manual-review prompt After all output files are written, the skill lists the **top-100 highest-entropy tokens** in `profile.csv` and `explorer.html` for the user to scan. A high-entropy random-looking string that didn't match any pattern is the most likely way something sensitive slipped through. The skill does not mark itself complete until the user confirms the scan. Entropy here means Shannon entropy of the byte distribution of each whitespace-separated token of length ≥ 16, filtered to tokens with mixed case + digits (typical key shapes). -
success-rubric.md 2 KB
# Success rubric Four levels. Haiku assigns one per task based on the condensate, using these definitions and signals. The signals are hints, not hard rules, if context clearly contradicts a signal, override it. ## Levels ### delivered_clean User accepted output on first or near-first pass. Signals: - Final user turn is an acceptance phrase (`perfect`, `great`, `ship it`, `looks good`, `exactly`, `nice`, `love it`, `thanks`, `done`). - ≤ 1 correction anywhere in the transcript. - Assistant delivers; user moves on without revision demands. ### delivered_with_friction Completed, but only after 2+ user corrections. Signals: - 2–5 correction phrases (`no`, `actually`, `not quite`, `wait`, `redo`, `try again`, `you forgot`, `you missed`, `simpler`, `different approach`). - Tool-flail pattern: same tool called ≥3× with varying args. - User pastes an error and asks for a fix. - Session ends in acceptance despite the friction. ### partial Usable fragment, but user stopped before full completion. Signals: - Final assistant turn is a plan/outline/half-answer, not a delivered artifact. - User's last turn is a continuation question with no acknowledgement, then the session trails off. - No explicit acceptance and no clear abandonment. ### abandoned User changed direction, gave up, or silently dropped the thread. Signals: - Explicit: `nevermind`, `let's drop this`, `actually let's not`, `scrap this`, `move on`. - Silent drop: no acceptance, short duration, and (if cross-session evidence is available) a later same-cwd session restarts the same request. Treat cross-session re-opens as `delivered_with_friction` rather than `abandoned` when the second session succeeds, friction surfaced between sessions is still friction. ## Tie-breaking When torn between two levels, prefer the lower-success level. A conservative call is more useful for the coaching pass than an inflated one. ## Evidence field Always quote or paraphrase the single strongest signal into `success_evidence`. ≤ 120 chars. This is what the user sees in the drill-down, make it concrete. -
task-style.md 4.3 KB
# Task description style Tasks must read like rows in a standard job-to-task CSV: a concise, role-level sentence that describes a reusable activity, not a specific instance. Haiku subagents and the main agent both use this file as the authority on how to phrase a task. ## Shape ``` <verb> <object> [using/with <tech or context>] to <purpose> ``` - **Verb-first**, imperative, present tense. Never first-person. - **One clause**, ~10–15 words. Never two coordinated verbs, split "design and implement" into two tasks. - **Outcome** included when observable from the transcript. Skip when unclear (don't invent). - **Technology** only when load-bearing: the task would mean something different without it. ## Generalise from the instance The point of a task name is that it describes a *reusable activity*, not a single session. Strip anything that ties it to one specific instance: - **People names, company names, project names, dates, specific counts**, always remove. They belong to the instance, not the task. `Prepare for meeting with Jane` → `Prepare for customer meeting`. `Top 47 hires of 2024` → `Rank candidate pool for hiring decisions`. - **Internal or proprietary product names** (your company's own tools, datasets, models, plugins), replace with a generic descriptor. A proprietary embedding model → `embedding model`. An internal market-intelligence CLI → `internal analytics CLI` or `workforce-data CLI`, depending on what's load-bearing. A custom team plugin → `team-management plugin`. - **Third-party product names when incidental**, if the task isn't specifically about that product, use a category. A task about pulling customer data from two CRM vendors → `Pull data from CRM systems`. A task about searching across a chat tool and a mail tool → `Search chat and mail sources`. - **Third-party product/technology names when they are the subject**, keep. `Write a Python web scraper using Playwright` is fine because the task is specifically about Python and Playwright. `Tune a PyTorch training loop for distributed GPUs` keeps PyTorch. The test: could a different company or person perform this task with different tools? If yes, generalise. If the tool is the task, keep it. ## Good examples (copy this shape) ``` Design microservices architecture using Python and TypeScript to support scalable operations Implement RESTful and GraphQL APIs to enable platform functionality Write production-quality code for deployment on Kubernetes clusters Resolve production incidents within established SLA targets Debug timezone-related issues in workflow scheduling systems to ensure accuracy Review pull requests to maintain code quality standards Prepare briefing documents for executive meetings using calendar and email context Draft social media posts expressing thought leadership on industry trends Analyze candidate profiles from applicant-tracking data to inform hiring decisions Build internal automation tools to accelerate team productivity Finetune an embedding model through iterative training experiments Write Python data pipelines to extract and transform analytics events ``` ## Bad examples (and why) | Bad | Why | |---|---| | `Worked on the Acme integration` | Past tense, project-named, no verb-object-purpose shape | | `Help me prepare for the meeting with Alex` | First-person, named, not reusable | | `Build a Chrome extension that classifies tasks and stores them in SQLite and shows a dashboard and syncs with the cloud` | Multiple coordinated asks, split into 3 tasks | | `Fix bug` | No object, no purpose, too generic to cluster | | `Use WidgetPro to embed the vacancy strings from the 2024-Q3 dataset` | Date-named, proprietary product, not reusable; also a technique description, not a task | | `Stuff with AI assistant` | Meaningless | | `Build the Foo relationship tracker for our top 100 partners` | Proprietary name + specific count | ## Splitting rule If a session contains more than one genuinely different task, emit up to 3 task entries with separate `session_refs` pointing at the same session. The splitting threshold is *reusability*: would an outside reader see these as distinct role-level activities? Then split. ## Category field Pick exactly one from: `engineering`, `research`, `writing`, `ops`, `analysis`, `planning`, `communication`. If none fit well, pick the closest and accept the imprecision, do not invent new categories.
-
-
scripts
-
build_explorer.py 49 KB
#!/usr/bin/env python3 """Build out/explorer.html from profile.json, coaching-panel.json, skill-proposals.json. Self-contained light theme (bg #FAFAFA, dark #090D1F, aquamarine accent #62FFD8, purple-link accent #8097F3). Work Sans for headings, Geist for body, JetBrains Mono for numerics. Progressive disclosure, categories open to tasks, tasks open to detail, coaching and proposals collapse. """ from __future__ import annotations import json from pathlib import Path PROFILE = Path("out/profile.json") COACHING = Path("out/coaching-panel.json") SKILL_PROPOSALS = Path("out/skill-proposals.json") PERSONA = Path("out/persona.json") OUT = Path("out/explorer.html") # Generic fallback glyph when no branded logo is available. GENERIC_GLYPH = ( '<svg viewBox="0 0 40 40" width="32" height="32" role="img" aria-label="Task profile">' '<rect x="1" y="1" width="38" height="38" rx="8" fill="#090D1F"/>' '<path d="M12 20 L18 26 L29 14" fill="none" stroke="#62FFD8" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>' '</svg>' ) # Logo lives alongside the script inside the skill bundle (./assets/logo.svg relative # to this file). Portable: copies travel with the skill, no external dependency. SKILL_DIR = Path(__file__).resolve().parent.parent BUNDLED_LOGO = SKILL_DIR / "assets" / "logo.svg" def load_logo() -> str: if BUNDLED_LOGO.is_file(): svg = BUNDLED_LOGO.read_text() return svg.replace("<svg", '<svg style="height:16px;width:auto;" ', 1) return GENERIC_GLYPH def main() -> int: import argparse p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument( "--allow-empty", action="store_true", help="Render explorer.html even when coaching-panel.json or skill-proposals.json are missing. " "By default the script refuses to build an explorer with blank coaching/proposal sections, " "those are main-agent judgment outputs that must not be silently skipped.", ) args = p.parse_args() if not PROFILE.exists(): print(f"error: {PROFILE} missing, run inventory.py + write_profile.py first.", flush=True) return 1 missing = [f for f in (COACHING, SKILL_PROPOSALS, PERSONA) if not f.exists()] if missing and not args.allow_empty: print( "error: the following main-agent outputs are missing:\n" + "\n".join(f" - {m}" for m in missing) + "\n\nThese are mandatory parts of the explorer output, main-agent judgment work " "(Phases E + G of SKILL.md), not optional. Produce them before running build_explorer.py.\n" " · coaching-panel.json, 3–5 AI-first habit cards with session-path evidence. " "See references/ai-first-principles.md for the card shape.\n" " · skill-proposals.json, up to 5 task-centric skills each impacting ≥ 2 top tasks. " "Read the existing skill inventory first to avoid overlap.\n" " · persona.json, one persona from references/personas.md with tailored blurb. " "Run scripts/persona_features.py first to get the feature sheet.\n\n" "Override with --allow-empty only if you explicitly want to ship an explorer with blank panels.", flush=True, ) return 2 profile = json.loads(PROFILE.read_text()) coaching = json.loads(COACHING.read_text()) if COACHING.exists() else {"cards": []} skill_proposals = json.loads(SKILL_PROPOSALS.read_text()) if SKILL_PROPOSALS.exists() else {"proposals": []} persona = json.loads(PERSONA.read_text()) if PERSONA.exists() else None if not (coaching.get("cards") or []): print("warning: coaching-panel.json has no cards, explorer will render an empty coaching section.", flush=True) if not (skill_proposals.get("proposals") or []): print("warning: skill-proposals.json has no proposals, explorer will render an empty proposals section.", flush=True) # Gate: skill proposals must have done the installed-skill pre-check (SKILL.md Phase E.2). # Without this check, proposals drift toward generic "build a memo / prep a meeting" shapes # that duplicate skills the user already has installed. proposals_list = skill_proposals.get("proposals") or [] if proposals_list and not args.allow_empty: missing_precheck = "_installed_skills_checked" not in skill_proposals missing_fields = [] for i, p in enumerate(proposals_list): for required in ("modelled_after", "overlaps_considered"): if required not in p: missing_fields.append(f"proposals[{i}].{required}") if missing_precheck or missing_fields: print( "error: skill-proposals.json did not run the installed-skill pre-check required by " "Phase E.2 of SKILL.md. Without it, proposals duplicate skills the user already has.\n" + (f" - missing top-level `_installed_skills_checked`\n" if missing_precheck else "") + ("".join(f" - missing `{f}`\n" for f in missing_fields) if missing_fields else "") + "\nRe-run Phase E.2: enumerate installed skills first, then propose only skills that " "are genuinely distinct. Populate `modelled_after` (which installed skill inspired this) " "and `overlaps_considered` (which installed skills cover adjacent territory + why the " "proposal is still distinct) for every proposal, and add `_installed_skills_checked` at " "the top level listing the skills you enumerated.\n" "Override with --allow-empty only for debugging.", flush=True, ) return 3 if persona is None: print("warning: persona.json missing, explorer will omit the persona card.", flush=True) # Bundle the persona emblem SVG alongside the persona data. emblem_svg = "" if persona is not None: try: from persona_emblems import get as get_emblem except ImportError: import sys as _sys _sys.path.insert(0, str(Path(__file__).resolve().parent)) from persona_emblems import get as get_emblem emblem_svg = get_emblem(persona.get("id", "explorer")) data = { "profile": profile, "coaching": coaching, "skill_proposals": skill_proposals, "persona": persona, "emblem_svg": emblem_svg, } html = TEMPLATE.replace("__DATA_JSON__", json.dumps(data)).replace("__LOGO_SVG__", load_logo()) OUT.write_text(html) print(f"wrote {OUT} ({OUT.stat().st_size:,} bytes)") return 0 TEMPLATE = r"""<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Task profile</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Work+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Geist:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> <style> /* ─── palette ─── */ :root { --bg: #FAFAFA; --bg-secondary: #F3F3F2; --bg-card: #FFFFFF; --dark: #090D1F; --dark-secondary: #15192A; --grey: #5B607B; --grey-alt: #AFB4CB; --grey-10: rgba(132,135,150,.10); --grey-30: rgba(132,135,150,.30); --border: rgba(9,13,31,.10); --border-strong: rgba(9,13,31,.18); --green: #62FFD8; --green-light: #8DFFE3; --purple: #90A0E0; --purple-link: #8097F3; /* Accent text on light bg uses purple-link. --green-text is aliased so existing rules continue to work. */ --green-text: var(--purple-link); --lila: #E3E6F5; --lila-50: rgba(227,230,245,.5); --yellow: #FCC264; --radius: .5rem; /* 8px */ --radius-sm: .25rem; /* 4px */ --radius-pill: 999px; --font-title: "Work Sans", -apple-system, BlinkMacSystemFont, sans-serif; --font-body: "Geist", -apple-system, BlinkMacSystemFont, sans-serif; --font-mono: "JetBrains Mono", ui-monospace, Menlo, monospace; --shadow-soft: 0 1px 2px rgba(9,13,31,.04); --shadow-card: 0 2px 4px rgba(9,13,31,.03), 0 8px 24px rgba(9,13,31,.05); } * { box-sizing: border-box; } html, body { margin: 0; padding: 0; background: var(--bg); color: var(--dark); font-family: var(--font-body); font-weight: 400; font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; } /* ─── page shell ─── */ .page { max-width: 1180px; margin: 0 auto; padding: 48px 48px 140px; } /* ─── header ─── */ .header { display: flex; align-items: center; justify-content: space-between; gap: 32px; padding-bottom: 16px; border-bottom: 1px solid var(--border); } .header .mark { display: flex; align-items: center; gap: 18px; } .header .mark svg { display: block; } .header .mark .sep { width: 1px; height: 18px; background: var(--border-strong); } .header .mark .title { font-family: var(--font-title); font-weight: 500; color: var(--grey); font-size: 14px; letter-spacing: 0; } .header .meta { color: var(--grey); font-size: 13px; font-family: var(--font-mono); } /* ─── hero ─── */ .hero { padding: 56px 0 40px; max-width: 880px; } .hero h1 { font-family: var(--font-title); font-weight: 600; font-size: 56px; line-height: 1.05; letter-spacing: -0.03em; color: var(--dark); margin: 0 0 20px; } .hero h1 em { font-style: normal; color: var(--purple-link); } .hero .lead { color: var(--grey); font-size: 19px; line-height: 1.5; max-width: 680px; } /* ─── stats strip ─── */ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); margin: 32px 0 0; overflow: hidden; } .stat { background: var(--bg-card); padding: 22px 26px; } .stat .v { font-family: var(--font-title); font-weight: 600; font-size: 36px; line-height: 1; letter-spacing: -0.02em; color: var(--dark); font-feature-settings: "tnum"; } .stat .l { color: var(--grey); font-size: 13px; margin-top: 8px; font-family: var(--font-body); } /* ─── section ─── */ .section { margin-top: 64px; } .section-head { margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; justify-content: space-between; gap: 24px; } .section-head h2 { font-family: var(--font-title); font-size: 26px; font-weight: 600; color: var(--dark); margin: 0; letter-spacing: -0.02em; } .section-head .tagline { color: var(--grey); font-size: 14px; max-width: 440px; text-align: right; line-height: 1.5; } /* ─── card ─── */ .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-soft); } .card.pad { padding: 24px 28px; } h3 { font-family: var(--font-title); font-size: 17px; font-weight: 600; color: var(--dark); margin: 0 0 8px; letter-spacing: -0.01em; } h4 { font-family: var(--font-title); font-size: 12px; font-weight: 600; color: var(--grey); margin: 18px 0 8px; text-transform: uppercase; letter-spacing: 0.08em; } h4:first-child { margin-top: 0; } .muted { color: var(--grey); } /* ─── token chart (by category) ─── */ .chart-card { padding: 24px 28px; } .chart-legend { display: flex; gap: 18px; font-size: 12px; color: var(--grey); margin: 4px 0 18px; } .chart-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: 6px; vertical-align: 1px; } .chart-legend .code { background: var(--green); border: 1px solid var(--purple-link); } .chart-legend .cowork { background: var(--purple); } .chart-row { display: grid; grid-template-columns: 140px 1fr 96px; gap: 18px; align-items: center; padding: 7px 0; font-size: 14px; } .chart-row .cat-lbl { color: var(--dark); text-transform: capitalize; font-weight: 500; } .chart-row .bar { height: 12px; display: flex; border-radius: 3px; overflow: hidden; background: var(--bg-secondary); } .chart-row .seg { height: 100%; } .chart-row .seg.code { background: var(--green); } .chart-row .seg.cowork { background: var(--purple); } .chart-row .total { font-family: var(--font-mono); font-size: 12px; color: var(--grey); text-align: right; font-feature-settings: "tnum"; } /* ─── category accordion ─── */ .cats { display: flex; flex-direction: column; gap: 10px; } .cat { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-soft); overflow: hidden; transition: border-color .15s; } .cat[open] { border-color: var(--border-strong); box-shadow: var(--shadow-card); } .cat > summary { list-style: none; cursor: pointer; padding: 20px 28px; display: grid; grid-template-columns: 16px 12px minmax(0, 1fr) 90px 110px 110px; gap: 20px; align-items: center; } .cat > summary::-webkit-details-marker { display: none; } .cat > summary .chev { width: 10px; height: 10px; border-right: 2px solid var(--dark); border-bottom: 2px solid var(--dark); transform: rotate(-45deg); transition: transform .2s ease; justify-self: center; } .cat[open] > summary .chev { transform: rotate(45deg); } .cat > summary .dot { width: 10px; height: 10px; border-radius: 50%; justify-self: center; } .cat > summary .name { font-family: var(--font-title); font-weight: 600; font-size: 17px; color: var(--dark); text-transform: capitalize; letter-spacing: -0.01em; } .cat > summary .metric { font-family: var(--font-mono); font-size: 12px; color: var(--grey); text-align: right; font-feature-settings: "tnum"; } .cat > summary .metric b { color: var(--dark); font-weight: 500; } .cat > .cat-body { border-top: 1px solid var(--border); background: var(--bg-secondary); } /* ─── tasks list inside a category ─── */ .tasks { display: flex; flex-direction: column; } .task { border-bottom: 1px solid var(--border); transition: background .15s; } .task:last-child { border-bottom: 0; } .task > summary { list-style: none; cursor: pointer; padding: 14px 28px 14px 56px; display: grid; grid-template-columns: 14px minmax(0, 1fr) 70px 70px 80px 90px; gap: 20px; align-items: center; } .task > summary::-webkit-details-marker { display: none; } .task:hover > summary { background: var(--bg-card); } .task[open] > summary { background: var(--bg-card); } .task > summary .chev { width: 8px; height: 8px; border-right: 1.5px solid var(--grey); border-bottom: 1.5px solid var(--grey); transform: rotate(-45deg); justify-self: center; transition: transform .2s ease; } .task[open] > summary .chev { transform: rotate(45deg); } .task > summary .title { font-size: 15px; color: var(--dark); font-weight: 400; line-height: 1.45; overflow: hidden; } .task > summary .m { font-family: var(--font-mono); font-size: 12px; color: var(--grey); text-align: right; font-feature-settings: "tnum"; } .task > summary .m b { color: var(--dark); font-weight: 500; } .task > summary .m.clean-good b { color: var(--green-text); } .task > summary .m.clean-bad b { color: #B54E20; } .task > .detail { padding: 20px 28px 26px 56px; background: var(--bg-card); border-top: 1px solid var(--border); } .task > .detail h5 { font-family: var(--font-title); font-size: 11px; font-weight: 600; color: var(--grey); text-transform: uppercase; letter-spacing: 0.1em; margin: 18px 0 8px; } .task > .detail h5:first-child { margin-top: 0; } .task > .detail .fps { display: flex; flex-direction: column; gap: 10px; } .task > .detail .fp { padding: 12px 16px; background: var(--bg-secondary); border-radius: var(--radius-sm); border-left: 3px solid var(--green-text); font-size: 13px; } .task > .detail .fp .t { color: var(--dark); font-weight: 600; font-family: var(--font-title); font-size: 13px; } .task > .detail .fp .e { color: var(--grey); font-style: italic; margin-top: 4px; } .task > .detail .fp .p { color: var(--green-text); margin-top: 6px; font-weight: 500; } .task > .detail .mdl-tbl { width: 100%; border-collapse: collapse; font-size: 12px; } .task > .detail .mdl-tbl th { text-align: right; padding: 6px 12px 6px 0; color: var(--grey); font-weight: 500; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid var(--border); font-family: var(--font-title); } .task > .detail .mdl-tbl td { text-align: right; padding: 7px 12px 7px 0; color: var(--dark); font-family: var(--font-mono); font-feature-settings: "tnum"; border-bottom: 1px solid var(--border); } .task > .detail .mdl-tbl th:first-child, .task > .detail .mdl-tbl td:first-child { text-align: left; color: var(--grey); } .task > .detail .sessions { font-family: var(--font-mono); font-size: 11px; color: var(--grey); max-height: 220px; overflow-y: auto; padding: 12px 14px; background: var(--bg-secondary); border-radius: var(--radius-sm); } .task > .detail .sessions div { padding: 3px 0; border-bottom: 1px solid var(--border); } .task > .detail .sessions div:last-child { border: 0; } .task > .detail .sessions b { color: var(--dark); font-weight: 500; } .task > .detail .meta-strip { margin-top: 14px; font-size: 12px; color: var(--grey); display: flex; gap: 24px; flex-wrap: wrap; } .task > .detail .meta-strip b { color: var(--dark); font-weight: 500; } /* ─── coaching ─── */ .coach { display: flex; flex-direction: column; gap: 10px; } .coach-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-soft); overflow: hidden; transition: border-color .15s; } .coach-card[open] { border-color: var(--border-strong); box-shadow: var(--shadow-card); } .coach-card > summary { list-style: none; cursor: pointer; padding: 20px 28px; display: grid; grid-template-columns: 12px auto minmax(0, 1fr) auto; gap: 18px; align-items: center; } .coach-card > summary::-webkit-details-marker { display: none; } .coach-card > summary .chev { width: 9px; height: 9px; border-right: 2px solid var(--green-text); border-bottom: 2px solid var(--green-text); transform: rotate(-45deg); transition: transform .2s ease; justify-self: center; } .coach-card[open] > summary .chev { transform: rotate(45deg); } .coach-card > summary .badge { font-family: var(--font-title); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: var(--green-text); background: var(--green); background: rgba(98,255,216,.30); padding: 4px 10px; border-radius: var(--radius-pill); } .coach-card > summary .headline { font-family: var(--font-title); font-size: 17px; font-weight: 600; color: var(--dark); letter-spacing: -0.01em; line-height: 1.3; } .coach-card > summary .open { font-size: 12px; color: var(--grey); font-family: var(--font-mono); } .coach-card > .body { padding: 6px 28px 28px 28px; border-top: 1px solid var(--border); margin-top: 0; } .coach-card > .body .pattern { padding: 18px 0; font-size: 15px; color: var(--dark); line-height: 1.55; } .coach-card > .body .ev { padding: 12px 0; border-top: 1px solid var(--border); font-size: 14px; color: var(--grey); } .coach-card > .body .ev .tag { display: inline-block; font-family: var(--font-title); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; padding: 3px 9px; border-radius: var(--radius-pill); margin-right: 10px; vertical-align: 2px; } .coach-card > .body .ev .tag.good { background: rgba(98,255,216,.30); color: var(--green-text); } .coach-card > .body .ev .tag.bad { background: rgba(252,194,100,.30); color: #9A5A08; } .coach-card > .body .ev b { color: var(--dark); font-weight: 500; } .coach-card > .body .ev code { font-family: var(--font-mono); font-size: 11px; color: var(--grey); background: var(--bg-secondary); padding: 2px 7px; border-radius: var(--radius-sm); word-break: break-all; display: inline-block; margin-top: 6px; } .coach-card > .body .adjust { margin-top: 18px; padding: 16px 20px; background: var(--lila); background: rgba(227,230,245,.6); border-radius: var(--radius-sm); color: var(--dark); font-size: 14px; line-height: 1.5; } .coach-card > .body .adjust::before { content: "Try this →"; font-family: var(--font-title); font-weight: 600; color: var(--green-text); margin-right: 10px; } /* ─── skill proposals ─── */ .props { display: flex; flex-direction: column; gap: 10px; } .prop { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-soft); overflow: hidden; transition: border-color .15s; } .prop[open] { border-color: var(--border-strong); box-shadow: var(--shadow-card); } .prop > summary { list-style: none; cursor: pointer; padding: 20px 28px; display: grid; grid-template-columns: 12px auto minmax(0, 1fr) auto auto; gap: 18px; align-items: center; } .prop > summary::-webkit-details-marker { display: none; } .prop > summary .chev { width: 9px; height: 9px; border-right: 2px solid var(--green-text); border-bottom: 2px solid var(--green-text); transform: rotate(-45deg); transition: transform .2s ease; justify-self: center; } .prop[open] > summary .chev { transform: rotate(45deg); } .prop > summary .nm { font-family: var(--font-mono); font-weight: 500; font-size: 13px; color: var(--green-text); background: rgba(98,255,216,.22); padding: 6px 12px; border-radius: var(--radius-sm); } .prop > summary .head { font-family: var(--font-title); font-size: 16px; font-weight: 500; color: var(--dark); line-height: 1.4; } .prop > summary .pill { font-size: 11px; font-family: var(--font-title); font-weight: 500; padding: 4px 10px; background: var(--bg-secondary); color: var(--grey); border-radius: var(--radius-pill); } .prop > summary .saves { font-size: 12px; color: var(--grey); font-family: var(--font-body); } .prop > summary .saves b { color: var(--green-text); font-weight: 600; font-family: var(--font-title); text-transform: capitalize; } .prop > .body { padding: 6px 28px 28px; border-top: 1px solid var(--border); margin-top: 0; } .prop > .body .trigger { margin: 18px 0; padding: 14px 18px; background: var(--bg-secondary); border-left: 3px solid var(--green-text); border-radius: var(--radius-sm); font-size: 14px; color: var(--dark); line-height: 1.55; } .prop > .body h5 { font-family: var(--font-title); font-size: 11px; font-weight: 600; color: var(--grey); text-transform: uppercase; letter-spacing: 0.1em; margin: 16px 0 8px; } .prop > .body ol, .prop > .body ul { margin: 0; padding-left: 20px; color: var(--dark); font-size: 14px; line-height: 1.65; } .prop > .body ol li, .prop > .body ul li { margin: 4px 0; } .prop > .body ul li em { font-style: normal; color: var(--grey); font-size: 13px; } .prop > .body .cmd { font-family: var(--font-mono); font-size: 12px; background: var(--dark); color: var(--green); padding: 10px 14px; border-radius: var(--radius-sm); margin-top: 16px; word-break: break-all; } /* ─── footer ─── */ .footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid var(--border); font-size: 13px; color: var(--grey); } .footer details > summary { cursor: pointer; padding: 6px 0; } .footer details > summary:hover { color: var(--dark); } .footer .list { font-family: var(--font-mono); font-size: 11px; max-height: 260px; overflow-y: auto; margin-top: 10px; padding: 14px 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); } .footer .list div { padding: 3px 0; } /* ─── persona card (4:3, 820×615, LinkedIn-scale) ─── */ .persona-shell { margin: 48px 0 24px; display: flex; flex-direction: column; align-items: center; gap: 24px; } .persona-title-group { display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; } .persona-header { font-family: var(--font-title); font-size: 34px; font-weight: 600; color: var(--dark); letter-spacing: -0.02em; line-height: 1.1; margin: 0; } .persona-subheader { font-family: var(--font-body); font-size: 15px; font-weight: 400; color: var(--grey); margin: 0; } .persona-frame { /* Outer wrapper scales the card down to fit narrow viewports without touching the card's intrinsic pixel dimensions used for PNG export. */ width: 100%; max-width: 820px; display: flex; justify-content: center; } .persona-card { width: 820px; height: 615px; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-card); overflow: hidden; color: var(--dark); display: grid; grid-template-rows: 44px 205px 104px 52px 170px 40px; flex-shrink: 0; } .persona-card * { box-sizing: border-box; } /* Header strip */ .persona-card .pc-header { display: flex; align-items: center; justify-content: space-between; padding: 0 24px; border-bottom: 1px solid var(--border); } .persona-card .pc-header .pc-label { font-family: var(--font-title); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.18em; color: var(--grey); } .persona-card .pc-header .pc-label b { color: var(--dark); font-weight: 700; letter-spacing: 0.12em; } .persona-card .pc-header .pc-date { font-family: var(--font-mono); font-size: 11px; color: var(--grey); font-feature-settings: "tnum"; } /* Hero row: emblem (left) + identity (right) */ .persona-card .pc-hero { display: grid; grid-template-columns: 220px 1fr; border-bottom: 1px solid var(--border); } .persona-card .pc-emblem-panel { position: relative; background: var(--bg-secondary); display: flex; align-items: center; justify-content: center; border-right: 1px solid var(--border); } .persona-card .pc-emblem-panel::before { content: ""; position: absolute; width: 160px; height: 160px; border-radius: 50%; background: var(--lila); opacity: 0.55; } .persona-card .pc-emblem-panel svg { position: relative; z-index: 1; width: 120px; height: 120px; } .persona-card .pc-identity { padding: 32px 32px 28px; display: flex; flex-direction: column; justify-content: center; gap: 10px; overflow: hidden; } .persona-card .pc-name { font-family: var(--font-title); font-size: 44px; font-weight: 600; line-height: 1; letter-spacing: -0.03em; color: var(--dark); margin: 0; } .persona-card .pc-tagline { font-family: var(--font-title); font-size: 17px; font-weight: 400; font-style: italic; color: var(--purple-link); letter-spacing: -0.005em; line-height: 1.3; } .persona-card .pc-modifier { align-self: flex-start; font-family: var(--font-mono); font-size: 12px; color: var(--dark); background: rgba(98,255,216,.4); padding: 4px 10px; border-radius: var(--radius-pill); letter-spacing: 0.03em; margin-top: 2px; } /* Big stats strip */ .persona-card .pc-stats { display: flex; align-items: center; justify-content: space-around; padding: 0 24px; border-bottom: 1px solid var(--border); background: var(--bg-card); } .persona-card .pc-stat { display: flex; flex-direction: column; gap: 6px; text-align: center; flex: 1; } .persona-card .pc-stat .v { font-family: var(--font-title); font-size: 32px; font-weight: 600; color: var(--dark); line-height: 1; letter-spacing: -0.02em; font-feature-settings: "tnum"; } .persona-card .pc-stat .l { font-family: var(--font-mono); font-size: 11px; color: var(--grey); text-transform: uppercase; letter-spacing: 0.12em; } /* Code vs Cowork split bar */ .persona-card .pc-split { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 16px; padding: 0 24px; border-bottom: 1px solid var(--border); color: var(--grey); } .persona-card .pc-split-label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; } .persona-card .pc-split-bar { height: 10px; background: var(--bg-secondary); border-radius: 4px; overflow: hidden; display: flex; } .persona-card .pc-split-bar .seg-code { background: var(--green); } .persona-card .pc-split-bar .seg-cowork { background: var(--purple); } .persona-card .pc-split-pcts { display: flex; gap: 16px; font-family: var(--font-mono); font-size: 14px; font-feature-settings: "tnum"; } .persona-card .pc-split-pcts b { color: var(--dark); font-weight: 600; } /* Top 3 tasks triptych */ .persona-card .pc-tasks { padding: 18px 24px 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-card); border-bottom: 1px solid var(--border); } .persona-card .pc-tasks-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--grey); margin-bottom: 2px; } .persona-card .pc-tasks-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } .persona-card .pc-tasks-list li { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 14px; } .persona-card .pc-tasks-list .n { font-family: var(--font-title); font-size: 28px; font-weight: 600; color: var(--purple-link); line-height: 1; font-feature-settings: "tnum"; } .persona-card .pc-tasks-list .t { font-size: 18px; color: var(--dark); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .persona-card .pc-tasks-list .f { font-family: var(--font-mono); font-size: 14px; color: var(--grey); font-feature-settings: "tnum"; } .persona-card .pc-tasks-list .f b { color: var(--dark); font-weight: 600; } /* Footer */ .persona-card .pc-footer { display: flex; align-items: center; justify-content: space-between; padding: 0 24px; font-size: 11px; color: var(--grey); } .persona-card .pc-range { font-family: var(--font-mono); font-feature-settings: "tnum"; font-size: 11px; } .persona-card .pc-powered { display: inline-flex; align-items: center; gap: 8px; font-size: 11px; color: var(--grey); } .persona-card .pc-powered svg { height: 12px; width: auto; } /* Below the card: blurb caption + download button (not part of PNG) */ .persona-annex { width: 100%; max-width: 820px; display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 0 20px; } .persona-annex .pa-blurb { font-size: 15px; line-height: 1.6; color: var(--dark); max-width: 720px; text-align: center; font-style: italic; } .persona-annex .pa-download { display: inline-flex; align-items: center; gap: 8px; font-family: var(--font-mono); font-size: 13px; color: var(--dark); cursor: pointer; background: var(--green); padding: 10px 20px; border-radius: var(--radius-sm); border: none; transition: background .15s; } .persona-annex .pa-download:hover { background: var(--green-light); } .persona-annex .pa-download:disabled { opacity: 0.6; cursor: wait; } @media (max-width: 880px) { .persona-frame { /* Scale the 820×615 card down proportionally so it fits without overflowing. Using transform keeps the intrinsic pixel layout intact for html2canvas. */ transform-origin: top center; height: calc((100vw - 40px) * 615 / 820); max-height: 615px; overflow: visible; } .persona-card { transform: scale(calc((100vw - 40px) / 820)); transform-origin: top center; } } /* ─── scrollbars ─── */ ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--grey-30); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--grey); } /* ─── responsive ─── */ @media (max-width: 900px) { .page { padding: 28px 20px 80px; } .hero h1 { font-size: 38px; } .hero .lead { font-size: 16px; } .stats { grid-template-columns: repeat(2, 1fr); } .cat > summary { grid-template-columns: 16px 12px 1fr; } .cat > summary .metric { display: none; } .cat > summary .metric.primary { display: block; grid-column: 3; text-align: right; } .task > summary { grid-template-columns: 14px 1fr 70px; padding: 12px 20px 12px 40px; } .task > summary .m:not(.primary) { display: none; } .task > .detail { padding: 18px 20px 22px 40px; } .chart-row { grid-template-columns: 1fr; gap: 4px; } .prop > summary, .coach-card > summary { grid-template-columns: 12px 1fr auto; gap: 14px; } .prop > summary .head, .coach-card > summary .headline { grid-column: 1 / -1; } } </style> </head> <body> <div class="page"> <header class="header"> <div class="mark">__LOGO_SVG__<div class="sep"></div><div class="title">Task profile</div></div> <div class="meta" id="window-label"></div> </header> <section class="persona-shell" id="persona-shell" hidden> <div class="persona-title-group"> <h2 class="persona-header">Your AI adoption persona</h2> <p class="persona-subheader">Based on your Claude usage data</p> </div> <div class="persona-frame"> <article class="persona-card" id="persona-card"> <div class="pc-header"> <div class="pc-label"><b>AI adoption</b> · persona</div> <div class="pc-date" id="pc-date"></div> </div> <div class="pc-hero"> <div class="pc-emblem-panel" id="pc-emblem"></div> <div class="pc-identity"> <h2 class="pc-name" id="pc-name"></h2> <div class="pc-tagline" id="pc-tagline"></div> <div class="pc-modifier" id="pc-modifier" hidden></div> </div> </div> <div class="pc-stats" id="pc-stats"></div> <div class="pc-split"> <span class="pc-split-label">how you work</span> <div class="pc-split-bar"><div class="seg-code" id="pc-seg-code"></div><div class="seg-cowork" id="pc-seg-cowork"></div></div> <div class="pc-split-pcts"><span>Code <b id="pc-code-pct"></b>%</span><span>Cowork <b id="pc-cowork-pct"></b>%</span></div> </div> <div class="pc-tasks"> <div class="pc-tasks-label">Your top 3 tasks</div> <ol class="pc-tasks-list" id="pc-tasks-list"></ol> </div> <div class="pc-footer"> <div class="pc-range" id="pc-range"></div> <div class="pc-powered">powered by <span id="pc-tw-logo"></span></div> </div> </article> </div> <div class="persona-annex"> <p class="pa-blurb" id="pa-blurb"></p> <button class="pa-download" id="pa-download" type="button">↓ Download card as PNG</button> </div> </section> <section class="section"> <div class="section-head"> <h2>Tokens by category</h2> <div class="tagline">Stacked per category. Shows where spend concentrates across the kinds of work you do.</div> </div> <div class="card chart-card"> <div class="chart-legend"> <span><i class="code"></i>Claude Code</span> <span><i class="cowork"></i>Cowork</span> </div> <div id="chart"></div> </div> </section> <section class="section"> <div class="section-head"> <h2>Tasks by category</h2> <div class="tagline">Open a category for its tasks; open a task for friction, model spend, and sessions.</div> </div> <div class="cats" id="cats"></div> </section> <section class="section"> <div class="section-head"> <h2>AI-first coaching</h2> <div class="tagline">Four habits worth nudging, each with a session where it worked and one where it slipped.</div> </div> <div class="coach" id="coach"></div> </section> <section class="section"> <div class="section-head"> <h2>Skills worth building</h2> <div class="tagline">Five task-centric skills that would each cut iteration across multiple top tasks.</div> </div> <div class="props" id="props"></div> </section> <div class="footer"> <details> <summary id="auto-summary"></summary> <div class="list" id="auto-list"></div> </details> </div> </div> <script id="data" type="application/json">__DATA_JSON__</script> <script> const DATA = JSON.parse(document.getElementById('data').textContent); const profile = DATA.profile; const coaching = DATA.coaching; const proposals = DATA.skill_proposals; const persona = DATA.persona; const emblemSvg = DATA.emblem_svg || ''; function fmt(n) { n = n || 0; if (n >= 1e9) return (n/1e9).toFixed(2) + 'B'; if (n >= 1e6) return (n/1e6).toFixed(1) + 'M'; if (n >= 1e3) return (n/1e3).toFixed(0) + 'k'; return n.toLocaleString(); } function taskTotal(t) { return (t.tokens_input||0) + (t.tokens_output||0) + (t.tokens_cache_read||0) + (t.tokens_cache_creation||0); } // ─── header ─── document.getElementById('window-label').textContent = `${profile.window.since || 'all time'} → ${profile.window.until || 'now'} · generated ${profile.generated_at.slice(0,10)}`; // (The old stats strip lived in the hero; the persona card now carries the big numbers.) // ─── category grouping ─── const catMap = {}; profile.tasks.forEach(t => { const k = t.category || 'ops'; catMap[k] = catMap[k] || { tasks: [], tokens: 0, freq: 0 }; catMap[k].tasks.push(t); catMap[k].tokens += taskTotal(t); catMap[k].freq += t.frequency; }); const catList = Object.entries(catMap).map(([name, v]) => ({ name, ...v })); catList.sort((a,b) => b.freq - a.freq); const CAT_COLORS = { engineering: '#0E9373', research: '#8097F3', writing: '#C37410', analysis: '#0E9373', ops: '#5B607B', planning: '#90A0E0', communication: '#9A5A08', }; // ─── chart (tokens by kind: code vs cowork, per category) ─── function sessionTokens(s) { const t = s.tokens || {}; return (t.input||0) + (t.output||0) + (t.cache_read||0) + (t.cache_creation||0); } const chartEl = document.getElementById('chart'); const chartMax = Math.max(...catList.map(c => c.tokens), 1); catList.forEach(c => { const byKind = { code: 0, cowork: 0 }; c.tasks.forEach(t => { (t.sessions || []).forEach(s => { const k = s.kind === 'cowork' ? 'cowork' : 'code'; byKind[k] += sessionTokens(s); }); }); const totalKind = byKind.code + byKind.cowork; const bar = ['code','cowork'].map(k => `<div class="seg ${k}" style="width:${(100*byKind[k]/chartMax).toFixed(2)}%" title="${k}: ${byKind[k].toLocaleString()}"></div>`).join(''); const row = document.createElement('div'); row.className = 'chart-row'; row.innerHTML = `<div class="cat-lbl">${c.name}</div><div class="bar">${bar}</div><div class="total">${fmt(totalKind)}</div>`; chartEl.appendChild(row); }); // ─── category accordion ─── const catsEl = document.getElementById('cats'); catList.forEach(c => { const color = CAT_COLORS[c.name] || '#5B607B'; const tasks = c.tasks.slice().sort((a,b) => b.frequency - a.frequency); const el = document.createElement('details'); el.className = 'cat'; el.innerHTML = ` <summary> <span class="chev"></span> <span class="dot" style="background:${color}"></span> <span class="name">${c.name}</span> <span class="metric primary"><b>${tasks.length}</b> tasks</span> <span class="metric"><b>${c.freq}</b> sessions</span> <span class="metric">${fmt(c.tokens)} tok</span> </summary> <div class="cat-body"><div class="tasks">${tasks.map(renderTask).join('')}</div></div>`; catsEl.appendChild(el); }); function renderTask(t) { const fps = (t.friction_points||[]).slice(0, 5).map(fp => `<div class="fp"> <div class="t">${fp.type||'friction'}</div> ${fp.example ? `<div class="e">${fp.example}</div>` : ''} ${fp.what_would_prevent ? `<div class="p">↳ ${fp.what_would_prevent}</div>` : ''} </div>`).join(''); const models = Object.entries(t.by_model||{}) .sort((a,b) => (b[1].output+b[1].input) - (a[1].output+a[1].input)) .map(([m, v]) => `<tr><td>${m}</td><td>${fmt(v.input)}</td><td>${fmt(v.output)}</td><td>${fmt(v.cache_read)}</td><td>${fmt(v.cache_creation)}</td></tr>`) .join(''); const sess = (t.sessions||[]).slice(0, 40).map(s => `<div><b>${s.mtime}</b> · ${s.kind} · t=${s.turns} c=${s.corrections} · ${(s.summary||'').slice(0,70)}</div>`).join(''); const clean = t.success_clean_pct; const cleanClass = clean >= 60 ? 'clean-good' : (clean <= 10 ? 'clean-bad' : ''); return ` <details class="task"> <summary> <span class="chev"></span> <span class="title">${t.task}</span> <span class="m primary"><b>${t.frequency}</b>×</span> <span class="m ${cleanClass}"><b>${clean}%</b></span> <span class="m"><b>${t.avg_iterations}</b> iter</span> <span class="m"><b>${fmt(taskTotal(t))}</b></span> </summary> <div class="detail"> ${fps ? `<h5>Friction patterns</h5><div class="fps">${fps}</div>` : '<div class="muted">No friction patterns recorded.</div>'} <h5>Tokens by model</h5> <table class="mdl-tbl"> <thead><tr><th>Model</th><th>Input</th><th>Output</th><th>Cache read</th><th>Cache create</th></tr></thead> <tbody>${models}</tbody> </table> <h5>Sessions (${(t.sessions||[]).length})</h5> <div class="sessions">${sess}</div> <div class="meta-strip"> <div>Last seen: <b>${t.last_seen || ','}</b></div> <div>Top friction: <b>${t.top_friction || 'none'}</b></div> </div> </div> </details>`; } // ─── coaching ─── const coachEl = document.getElementById('coach'); (coaching.cards || []).forEach(c => { const good = c.good_example ? `<div class="ev"><span class="tag good">worked</span>${c.good_example.description}<br><code>${(c.good_example.session_path||'').replace(/^\/Users\/[^/]+/,'~')}</code></div>` : ''; const slip = c.friction_example ? `<div class="ev"><span class="tag bad">slipped</span>${c.friction_example.description}<br><code>${(c.friction_example.session_path||'').replace(/^\/Users\/[^/]+/,'~')}</code></div>` : ''; coachEl.innerHTML += `<details class="coach-card"> <summary> <span class="chev"></span> <span class="badge">habit</span> <span class="headline">${c.principle}</span> <span class="open">open</span> </summary> <div class="body"> <div class="pattern">${c.pattern}</div> ${good}${slip} <div class="adjust">${c.suggested_adjustment}</div> </div> </details>`; }); // ─── skill proposals ─── const propsEl = document.getElementById('props'); (proposals.proposals || []).forEach(p => { const steps = (p.mandatory_steps || []).map(s => `<li>${s}</li>`).join(''); const tasks = (p.tasks_impacted || []).map(t => `<li><b>${t.task_id||''}</b>, <em>${t.why_relevant}</em></li>`).join(''); const shape = p.output_shape ? `<h5>Output shape</h5><div style="font-size:14px;color:var(--dark);line-height:1.55;">${p.output_shape}</div>` : ''; const savings = (p.expected_savings || '').split(' ')[0]; propsEl.innerHTML += `<details class="prop"> <summary> <span class="chev"></span> <span class="nm">/${p.name}</span> <span class="head">${(p.trigger_description||'').split('.').slice(0,1)[0]}.</span> <span class="pill">${(p.tasks_impacted||[]).length} tasks</span> <span class="saves">savings <b>${savings}</b></span> </summary> <div class="body"> <div class="trigger">${p.trigger_description}</div> <h5>Mandatory steps</h5> <ol>${steps || '<li>,</li>'}</ol> ${shape} <h5>Tasks impacted</h5> <ul>${tasks}</ul> <div class="cmd">${p.invocation_hint||''}</div> </div> </details>`; }); // ─── footer ─── const breakdown = Object.entries(profile.automation_breakdown || {}) .sort((a,b) => b[1] - a[1]).map(([k,v]) => `${v} ${k}`).join(' · '); document.getElementById('auto-summary').textContent = `${profile.counts.automation} sessions excluded from analysis, ${breakdown}`; document.getElementById('auto-list').innerHTML = (profile.automation_examples || []).map(e => `<div>[${e.reason}] ${e.summary || ''}</div>`).join(''); // ─── persona card ─── (function renderPersona() { if (!persona) return; const shell = document.getElementById('persona-shell'); shell.hidden = false; document.getElementById('pc-date').textContent = (profile.generated_at || '').slice(0,10); document.getElementById('pc-emblem').innerHTML = emblemSvg; document.getElementById('pc-name').textContent = persona.name || ''; document.getElementById('pc-tagline').textContent = persona.tagline || ''; document.getElementById('pa-blurb').textContent = persona.blurb || ''; const mod = document.getElementById('pc-modifier'); if (persona.modifier) { mod.textContent = '◆ ' + persona.modifier; mod.hidden = false; } // Big numbers: sessions, tokens, one highlight stat picked by main agent const totalTok = profile.tasks.reduce((a,t) => a + taskTotal(t), 0); const nums = [ { v: String(profile.counts.interactive), l: 'sessions' }, { v: fmt(totalTok), l: 'tokens' }, ]; if (persona.highlight_stat && persona.highlight_stat.value !== undefined) { nums.push({ v: fmt(persona.highlight_stat.value), l: persona.highlight_stat.label || 'highlight' }); } document.getElementById('pc-stats').innerHTML = nums.map(n => `<div class="pc-stat"><div class="v">${n.v}</div><div class="l">${n.l}</div></div>`).join(''); // Code vs Cowork split let codeTok = 0, coworkTok = 0; profile.tasks.forEach(t => (t.sessions||[]).forEach(s => { const sT = (s.tokens||{}); const total = (sT.input||0)+(sT.output||0)+(sT.cache_read||0)+(sT.cache_creation||0); if (s.kind === 'cowork') coworkTok += total; else codeTok += total; })); const splitTotal = Math.max(1, codeTok + coworkTok); const codePct = Math.round(100*codeTok/splitTotal); const coworkPct = 100 - codePct; document.getElementById('pc-seg-code').style.width = codePct + '%'; document.getElementById('pc-seg-cowork').style.width = coworkPct + '%'; document.getElementById('pc-code-pct').textContent = codePct; document.getElementById('pc-cowork-pct').textContent = coworkPct; // Top 3 tasks list (use persona-provided short names when present) const topTasks = (persona.top3_task_names && persona.top3_task_names.length) ? persona.top3_task_names.map((name, i) => ({ name, freq: (profile.tasks[i] || {}).frequency })) : profile.tasks.slice(0, 3).map(t => ({ name: t.task, freq: t.frequency })); document.getElementById('pc-tasks-list').innerHTML = topTasks.slice(0, 3).map((t, i) => `<li><span class="n">${i+1}</span><span class="t">${t.name}</span><span class="f">${t.freq ? `<b>${t.freq}</b>×` : ''}</span></li>` ).join(''); // Range + TechWolf footer logo (clone the one in the header) document.getElementById('pc-range').textContent = `${profile.window.since || 'all time'} → ${profile.window.until || 'now'}`; const twLogo = document.querySelector('.header .mark svg'); if (twLogo) { const clone = twLogo.cloneNode(true); clone.setAttribute('style', 'height:16px;width:auto;'); document.getElementById('pc-tw-logo').appendChild(clone); } // Download as PNG via html2canvas document.getElementById('pa-download').addEventListener('click', downloadPersonaCard); async function downloadPersonaCard() { const btn = document.getElementById('pa-download'); const card = document.getElementById('pc-card') || document.getElementById('persona-card'); if (!card || typeof html2canvas !== 'function') { console.warn('html2canvas not available'); return; } const originalText = btn.textContent; btn.disabled = true; btn.textContent = 'Rendering…'; // Make sure web fonts are loaded before we rasterise. try { if (document.fonts && document.fonts.ready) { await document.fonts.ready; } } catch (_) {} // The card may be transformed via CSS on narrow viewports. We want to // capture its intrinsic 1200×900 layout, so temporarily strip transforms // by rendering inside a fixed, off-screen wrapper at native size. const stage = document.createElement('div'); stage.style.cssText = 'position:fixed;left:-10000px;top:0;width:820px;height:615px;background:#FAFAFA;'; const clone = card.cloneNode(true); clone.style.transform = 'none'; clone.style.width = '820px'; clone.style.height = '615px'; stage.appendChild(clone); document.body.appendChild(stage); try { const canvas = await html2canvas(clone, { backgroundColor: '#FAFAFA', scale: 2.4, width: 820, height: 615, windowWidth: 820, windowHeight: 615, useCORS: true, allowTaint: false, logging: false, }); await new Promise((resolve) => { canvas.toBlob((blob) => { const slug = (persona.id || 'persona').toLowerCase(); const yyyymm = new Date().toISOString().slice(0,7).replace('-',''); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ai-adoption-${slug}-${yyyymm}.png`; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); resolve(); }, 'image/png'); }); } catch (err) { console.error('PNG export failed:', err); alert('PNG export failed: ' + err.message); } finally { stage.remove(); btn.disabled = false; btn.textContent = originalText; } } })(); </script> </body> </html> """ if __name__ == "__main__": raise SystemExit(main()) -
codex_sessions.py 8.8 KB
"""Codex session adapter. Codex (OpenAI) stores each session as a JSONL "rollout" at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl`. Lines are wrapper objects `{type, timestamp, payload}`. The types we use: session_meta -> payload.cwd, payload.timestamp, payload.model_provider turn_context -> payload.model (the concrete model id) event_msg/user_message -> payload.message|text (first one = session title) event_msg/agent_message -> payload.message|text response_item/message -> payload.role + payload.content ([{type,text}] or str) This adapter exposes the same shape session-search uses for Claude transcripts (cwd, title, plus a (role, text) iterator for grep), so routing is a drop-in. """ from __future__ import annotations import json from pathlib import Path from typing import Iterator CODEX_ROOT = Path.home() / ".codex" / "sessions" def _content_text(content) -> str: if isinstance(content, str): return content if isinstance(content, list): parts = [] for c in content: if isinstance(c, dict): t = c.get("text") or c.get("content") if isinstance(t, str) and t: parts.append(t) return "\n".join(parts) return "" def iter_turns(path: Path) -> Iterator[tuple[str, str]]: """Yield (role, text) for the conversational turns in a rollout. Codex records each turn twice: a clean `event_msg` (user_message / agent_message) and a lower-level `response_item/message` that also carries system/developer preamble. We use the clean channel and fall back to response_item only if a rollout has no event_msg turns at all. """ event_turns: list[tuple[str, str]] = [] item_turns: list[tuple[str, str]] = [] try: with path.open(encoding="utf-8", errors="replace") as f: for line in f: try: e = json.loads(line) except json.JSONDecodeError: continue p = e.get("payload") or {} if not isinstance(p, dict): continue typ, ptyp = e.get("type"), p.get("type") if typ == "event_msg" and ptyp == "user_message": txt = p.get("message") or p.get("text") or "" if isinstance(txt, str) and txt: event_turns.append(("user", txt)) elif typ == "event_msg" and ptyp == "agent_message": txt = p.get("message") or p.get("text") or "" if isinstance(txt, str) and txt: event_turns.append(("assistant", txt)) elif typ == "response_item" and ptyp == "message": txt = _content_text(p.get("content")) if txt and p.get("role") in ("user", "assistant"): item_turns.append((p.get("role"), txt)) except OSError: return yield from (event_turns or item_turns) def peek(path: Path) -> tuple[str, str]: """Return (cwd, first_user_line) for a rollout, reading only the head.""" cwd = "" first_user = "" try: with path.open(encoding="utf-8", errors="replace") as f: for i, line in enumerate(f): if i > 120 and cwd and first_user: break try: e = json.loads(line) except json.JSONDecodeError: continue p = e.get("payload") or {} if not isinstance(p, dict): continue if not cwd and e.get("type") == "session_meta": cwd = p.get("cwd", "") or "" if (not first_user and e.get("type") == "event_msg" and p.get("type") == "user_message"): txt = p.get("message") or p.get("text") or "" if isinstance(txt, str) and txt.strip(): first_user = txt.strip().splitlines()[0] except OSError: pass return cwd, first_user def iter_sessions() -> Iterator[dict]: """Yield session records shaped like session-search's Claude records.""" if not CODEX_ROOT.is_dir(): return for jsonl in CODEX_ROOT.glob("*/*/*/rollout-*.jsonl"): try: st = jsonl.stat() except OSError: continue cwd, title = peek(jsonl) yield { "kind": "codex", "mtime": st.st_mtime, "size": st.st_size, "title": title, "cwd": cwd, "path": str(jsonl), } def _usage_from_last(lt: dict) -> dict: """Map a Codex last_token_usage record to the Anthropic-style usage dict the token-doctor pipeline expects (uncached input + cache_read split; OpenAI has no separate cache-write metric). """ inp = int(lt.get("input_tokens") or 0) cached = int(lt.get("cached_input_tokens") or 0) out = int(lt.get("output_tokens") or 0) + int(lt.get("reasoning_output_tokens") or 0) return { "input": max(inp - cached, 0), "output": out, "cache_read": cached, "cache_creation": 0, } def session_meta(path: Path) -> dict: """Return {cwd, model, start_ts} for a rollout.""" cwd, model, start_ts = "", "", "" try: with path.open(encoding="utf-8", errors="replace") as f: for i, line in enumerate(f): if i > 200 and cwd and model: break try: e = json.loads(line) except json.JSONDecodeError: continue p = e.get("payload") or {} if not isinstance(p, dict): continue if e.get("type") == "session_meta": cwd = cwd or p.get("cwd", "") or "" start_ts = start_ts or p.get("timestamp", "") or e.get("timestamp", "") elif e.get("type") == "turn_context" and p.get("model"): model = model or p["model"] except OSError: pass return {"cwd": cwd, "model": model, "start_ts": start_ts} def codex_turns(path: Path, model_hint: str = "") -> list[dict]: """Parse a rollout into turn dicts matching the token-doctor/task-profile shape: {role, ts, text, tool_calls, [model, usage]}. Usage is attached from each `token_count` event's per-response `last_token_usage` to the assistant turn it belongs to. """ model = model_hint turns: list[dict] = [] pending_tools: list[str] = [] try: with path.open(encoding="utf-8", errors="replace") as f: for line in f: try: e = json.loads(line) except json.JSONDecodeError: continue p = e.get("payload") or {} if not isinstance(p, dict): continue typ, ptyp = e.get("type"), p.get("type") ts = e.get("timestamp") if typ == "turn_context" and p.get("model"): model = p["model"] elif typ == "event_msg" and ptyp == "user_message": txt = p.get("message") or p.get("text") or "" if isinstance(txt, str) and txt: turns.append({"role": "user", "ts": ts, "text": txt, "tool_calls": []}) elif typ == "event_msg" and ptyp == "agent_message": txt = p.get("message") or p.get("text") or "" turns.append({"role": "assistant", "ts": ts, "text": txt if isinstance(txt, str) else "", "tool_calls": pending_tools}) pending_tools = [] elif typ == "response_item" and ptyp == "function_call": name = p.get("name") or p.get("tool_name") if name: pending_tools.append(name) elif typ == "event_msg" and ptyp == "token_count": lt = (p.get("info") or {}).get("last_token_usage") or {} if not lt: continue target = next((t for t in reversed(turns) if t["role"] == "assistant" and "usage" not in t), None) if target is None: target = {"role": "assistant", "ts": ts, "text": "", "tool_calls": []} turns.append(target) if pending_tools: target["tool_calls"] = target.get("tool_calls", []) + pending_tools pending_tools = [] target["model"] = model or "gpt-5" target["usage"] = _usage_from_last(lt) except OSError: return [] return turns -
host_platform.py 3.8 KB
"""Host-platform detection for the ai-adoption skills. One mechanism, shared by every script that reads agent session history. An identical copy ships in each skill's scripts/ dir so it travels with the script under all install shapes (Claude Code native, Codex flat, Antigravity nested). Resolution order (first hit wins): 1. AI_FIRST_PLATFORM env var, if set to a known platform (explicit override). 2. The "platform" field stamped into .techwolf-plugin.json by install.sh. The installer knows the target IDE (--ide codex|antigravity), so this is deterministic for Codex/Antigravity installs. 3. Fallback: if ~/.claude exists, assume Claude Code. Claude Code uses the native plugin system and never runs install.sh, so it is never stamped. 4. Default: "claude". Per-platform session-data reality (see each skill's SKILL.md): - claude Claude Code (~/.claude/projects) + Cowork transcripts. Full. - codex ~/.codex/sessions/**/rollout-*.jsonl, plaintext JSONL with cwd, model, token usage, and turns. Parseable (session-search routes here). - antigravity IDE conversations are AEAD-encrypted at rest (~/.gemini/antigravity/conversations/*.pb); the unencrypted CLI store (~/.gemini/antigravity-cli/conversations/*.db) carries no parseable turn/token content. No honest analysis path -> degrade. """ from __future__ import annotations import json import os from pathlib import Path CLAUDE = "claude" CODEX = "codex" ANTIGRAVITY = "antigravity" _VALID = {CLAUDE, CODEX, ANTIGRAVITY} def _from_stamp() -> str | None: # scripts/ -> skill root holds .techwolf-plugin.json (written by install.sh). here = Path(__file__).resolve() for d in (here.parent, here.parent.parent, here.parent.parent.parent): stamp = d / ".techwolf-plugin.json" if not stamp.is_file(): continue try: platform = json.loads(stamp.read_text(encoding="utf-8")).get("platform") except (json.JSONDecodeError, OSError): return None if isinstance(platform, str) and platform.lower() in _VALID: return platform.lower() return None return None def detect_platform() -> str: env = os.environ.get("AI_FIRST_PLATFORM", "").strip().lower() if env in _VALID: return env stamped = _from_stamp() if stamped: return stamped return CLAUDE _DEGRADE = { CODEX: ( "this analysis is not available on Codex yet.\n" " Codex sessions (~/.codex/sessions) are parseable, but this skill does not\n" " read them yet. Run it under Claude Code. (session-search already supports\n" " Codex.)" ), ANTIGRAVITY: ( "session analysis is not available on Antigravity.\n" " Antigravity stores IDE conversations encrypted at rest\n" " (~/.gemini/antigravity/conversations/*.pb, AEAD), and its unencrypted CLI\n" " store carries no parseable turn/token content. There is no honest local\n" " data path to analyse. Run this skill under Claude Code." ), } def degrade(skill: str, platform: str | None = None) -> None: """Print a clear, platform-specific 'not available' message and exit 0. Degrading is not an error: the skill simply isn't available on this host. """ platform = platform or detect_platform() print(f"{skill}: {_DEGRADE.get(platform, f'not available on {platform}.')}") raise SystemExit(0) def require_claude(skill: str) -> str: """Return the platform if Claude; otherwise degrade. For skills that only support Claude transcripts today (token-doctor, task-profile).""" platform = detect_platform() if platform == CLAUDE: return platform degrade(skill, platform) -
inventory.py 29.6 KB
#!/usr/bin/env python3 """Walk Claude Code + Cowork sessions into out/inventory.json. Per-session row carries summary fields, per-model token totals, automation flag, and a structured condensate (for Haiku subagents). Redaction applied to every piece of text that leaves the transcript. Default window: last 6 months. Override with --since / --until / --all. """ from __future__ import annotations import argparse import hashlib import json import re import sys import time from datetime import datetime, timedelta from pathlib import Path from typing import Iterable, Iterator sys.path.insert(0, str(Path(__file__).parent)) import codex_sessions # noqa: E402 from host_platform import ANTIGRAVITY, CODEX, degrade, detect_platform # noqa: E402 HOME = Path.home() CODE_ROOT = HOME / ".claude" / "projects" # Cowork desktop app stores sessions in the OS-native app-data dir. # macOS: ~/Library/Application Support/Claude/local-agent-mode-sessions # Windows: %APPDATA%\Claude\local-agent-mode-sessions # Linux: not applicable, Cowork is desktop-only (Mac/Windows). def _cowork_root() -> Path: if sys.platform == "darwin": return HOME / "Library" / "Application Support" / "Claude" / "local-agent-mode-sessions" if sys.platform.startswith("win"): import os return Path(os.environ.get("APPDATA", HOME)) / "Claude" / "local-agent-mode-sessions" return HOME / ".config" / "Claude" / "local-agent-mode-sessions" # fallback, unused on Linux COWORK_ROOT = _cowork_root() TASK_NOTIF_RE = re.compile(r"<task-notification>.*?</task-notification>", re.DOTALL) COMMAND_WRAPPER_RE = re.compile(r"<command-(?:name|message|args)>.*?</command-\w+>", re.DOTALL) LOCAL_COMMAND_CAVEAT_RE = re.compile(r"<local-command-caveat>.*?</local-command-caveat>", re.DOTALL) # Entrypoints that are background dispatch rather than a person at a keyboard. # Deny-list on purpose: `cli`, `claude-desktop` and any future interactive surface # count as real work. An unknown entrypoint is treated as interactive, because # silently dropping a person's sessions is the worse failure here. AUTOMATION_ENTRYPOINTS = {"sdk-cli", "sdk"} AUTOMATION_SLASH_CMDS = { "/loop", "/schedule", "/babysit-prs", "/ultrareview", "/autonomous-loop", "/productivity:update", "/productivity:start", } CORRECTION_PHRASES = [ r"\bno\b", r"\bnot quite\b", r"\bthat'?s wrong\b", r"\bthat is wrong\b", r"\bactually\b", r"\bwait\b", r"\bstop\b", r"\bdon'?t\b", r"\bno I meant\b", r"\blet me rephrase\b", r"\bthat'?s not what I\b", r"\bredo\b", r"\btry again\b", r"\bdifferent approach\b", r"\bsimpler\b", r"\bshorter\b", r"\blonger\b", r"\balso\b", r"\band also\b", r"\bone more thing\b", r"\byou forgot\b", r"\byou missed\b", r"\bmissing\b", r"\byou didn'?t\b", r"\bthis isn'?t\b", r"\bthat'?s not right\b", r"\bhold on\b", r"\bnevermind\b", r"\bscrap\b", ] CORRECTION_RE = re.compile("|".join(CORRECTION_PHRASES), re.IGNORECASE) # ---------- redaction ---------- REDACTIONS: list[tuple[re.Pattern, str]] = [ (re.compile(r"-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----"), "[REDACTED:private_key]"), (re.compile(r"eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), "[REDACTED:jwt]"), (re.compile(r"sk-[A-Za-z0-9\-_]{20,}"), "[REDACTED:api_key]"), (re.compile(r"ghp_[A-Za-z0-9]{30,}"), "[REDACTED:token]"), (re.compile(r"ghs_[A-Za-z0-9]{30,}"), "[REDACTED:token]"), (re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "[REDACTED:token]"), (re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "[REDACTED:token]"), (re.compile(r"AIza[A-Za-z0-9\-_]{30,}"), "[REDACTED:api_key]"), (re.compile(r"AKIA[A-Z0-9]{16}"), "[REDACTED:api_key]"), (re.compile(r"\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b"), "[REDACTED:iban]"), ] KEY_VALUE_RE = re.compile( r"(?i)(password|passwd|pwd|secret|api[_-]?key|bearer)\s*[:=]\s*['\"]?([^\s'\"]{6,})['\"]?" ) EMAIL_RE = re.compile(r"([A-Za-z0-9._%+\-]+)@([A-Za-z0-9.\-]+\.[A-Za-z]{2,})") PHONE_CONTEXT_RE = re.compile( r"(?i)(phone|tel|call|mobile|sms|whatsapp)[^\n]{0,20}?(\+?\d[\d\s\-().]{7,}\d)" ) def _luhn_ok(s: str) -> bool: digits = [int(c) for c in s if c.isdigit()] if not 13 <= len(digits) <= 19: return False total = 0 for i, d in enumerate(reversed(digits)): if i % 2 == 1: d *= 2 if d > 9: d -= 9 total += d return total % 10 == 0 CARD_RE = re.compile(r"\b(?:\d[ -]?){13,19}\b") def redact(text: str) -> str: if not text: return text for pat, repl in REDACTIONS: text = pat.sub(repl, text) text = KEY_VALUE_RE.sub(lambda m: f"{m.group(1)}=[REDACTED:token]", text) text = EMAIL_RE.sub(lambda m: f"[REDACTED:email]@{m.group(2)}", text) text = PHONE_CONTEXT_RE.sub(lambda m: f"{m.group(1)} [REDACTED:phone]", text) text = CARD_RE.sub(lambda m: "[REDACTED:card]" if _luhn_ok(m.group(0)) else m.group(0), text) return text # ---------- transcript parsing ---------- def _text_of(entry: dict) -> str: msg = entry.get("message") or {} content = msg.get("content") if isinstance(content, str): return content if isinstance(content, list): parts = [] for c in content: if isinstance(c, dict) and c.get("type") == "text": txt = c.get("text") or "" if txt: parts.append(txt) return "\n".join(parts) return "" def _tool_calls(entry: dict) -> list[str]: msg = entry.get("message") or {} content = msg.get("content") names: list[str] = [] if isinstance(content, list): for c in content: if isinstance(c, dict) and c.get("type") == "tool_use": n = c.get("name") if n: names.append(n) return names def _usage_of(entry: dict) -> tuple[str, dict] | None: msg = entry.get("message") or {} usage = msg.get("usage") if not isinstance(usage, dict): return None model = msg.get("model") or "unknown" return model, { "input": int(usage.get("input_tokens") or 0), "output": int(usage.get("output_tokens") or 0), "cache_read": int(usage.get("cache_read_input_tokens") or 0), "cache_creation": int(usage.get("cache_creation_input_tokens") or 0), } def _strip_system_noise(text: str) -> str: # Cowork-specific: remove task-notification blocks entirely (user §7.1). text = TASK_NOTIF_RE.sub("", text) return text.strip() def _is_only_wrappers(text: str) -> bool: """True if text is composed entirely of command-* / caveat wrappers plus whitespace.""" stripped = COMMAND_WRAPPER_RE.sub("", text) stripped = LOCAL_COMMAND_CAVEAT_RE.sub("", stripped) return not stripped.strip() def _extract_slash_cmd(text: str) -> str | None: m = re.search(r"<command-name>\s*(/\S+)\s*</command-name>", text) return m.group(1).strip() if m else None def _load_turns(path: Path) -> list[dict]: """Return ordered list of text turns + tool calls + usage records. Claude Code writes ONE JSONL LINE PER CONTENT BLOCK of an assistant message (thinking, text, and each tool_use), and every one of those lines repeats the SAME message.usage object. Lines sharing a message id are merged into one turn, so token totals are per-message rather than per-block. Without this, the same tokens are counted once per content block: measured ~2.4x on turn counts and up to ~3x on token totals. """ turns: list[dict] = [] by_msg_id: dict[str, dict] = {} try: with path.open(encoding="utf-8", errors="replace") as f: for line in f: try: e = json.loads(line) except json.JSONDecodeError: continue t = e.get("type") ts = e.get("timestamp") or "" if t not in ("user", "assistant"): continue raw_text = _text_of(e) stripped = _strip_system_noise(raw_text) tools = _tool_calls(e) usage = _usage_of(e) mid = (e.get("message") or {}).get("id") if t == "assistant" else None prev = by_msg_id.get(mid) if mid else None if prev is not None: # Another content block of a message already seen: fold it in, # do not re-count its usage. if stripped: prev["text"] = (f"{prev['text']}\n{stripped}".strip() if prev["text"] else stripped) if raw_text: prev["raw_has_content"] = True if not _is_only_wrappers(raw_text): prev["is_only_wrappers"] = False if tools: prev["tool_calls"].extend(tools) if usage and "usage" not in prev: prev["model"], prev["usage"] = usage continue entry: dict = { "role": t, "ts": ts, "text": stripped, "raw_has_content": bool(raw_text), "is_only_wrappers": _is_only_wrappers(raw_text) if raw_text else True, "tool_calls": tools, } if usage: entry["model"] = usage[0] entry["usage"] = usage[1] turns.append(entry) if mid: by_msg_id[mid] = entry except OSError: pass return turns # ---------- automation detection ---------- def detect_automation(path: Path, first_entry_meta: dict, turns: list[dict], summary: str) -> tuple[bool, str]: p = str(path) entrypoint = first_entry_meta.get("entrypoint") or "" if entrypoint in AUTOMATION_ENTRYPOINTS: return True, f"entrypoint:{entrypoint}" if "/agent/local_ditto_" in p or "/agent/local_routine_" in p: return True, "ditto-routine" if "--paperclip-instances-" in p: return True, "paperclip" # <scheduled-task> opener, Cowork schedule runs announce themselves this way. if turns: first_user = next((t for t in turns if t["role"] == "user"), None) if first_user and "<scheduled-task" in (first_user.get("text") or ""): m = re.search(r'name="([^"]+)"', first_user["text"]) tag = m.group(1) if m else "unknown" return True, f"scheduled-task:{tag}" # Slash-command-only opener if turns: first_user = next((t for t in turns if t["role"] == "user"), None) if first_user and first_user["is_only_wrappers"]: cmd = _extract_slash_cmd(first_user.get("_raw_text") or "") or _extract_slash_cmd( first_user["text"] ) if cmd and cmd in AUTOMATION_SLASH_CMDS: return True, f"slash-command-opener:{cmd}" # Composite: no freeform + short + recurring user_turns = [t for t in turns if t["role"] == "user"] assistant_turns = [t for t in turns if t["role"] == "assistant"] total_text_turns = len(user_turns) + len(assistant_turns) no_freeform = bool(user_turns) and all(t["is_only_wrappers"] or not t["text"] for t in user_turns) if no_freeform and total_text_turns < 6: duration_s = _duration(turns) if duration_s is not None and duration_s < 300: # Recurrence is checked at caller-level; leave caller to decide. For now: return True, "composite:short-no-freeform" return False, "" def _duration(turns: list[dict]) -> float | None: stamps: list[float] = [] for t in turns: ts = t.get("ts") if not ts: continue try: stamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()) except ValueError: continue if len(stamps) < 2: return None return max(stamps) - min(stamps) # ---------- condensate ---------- def _tool_flail_events(turns: list[dict]) -> list[tuple[int, list[str]]]: """Return up to one entry per tool that genuinely flails. Bar: ≥5 calls to the same tool within any 5-assistant-turn window, AND that tool represents ≥60% of tool calls in the window. This separates "working hard on files" (normal) from "stuck in a loop" (actual friction). Deduped by tool name so a persistent offender appears once. """ assistant_idxs = [i for i, t in enumerate(turns) if t["role"] == "assistant"] first_seen: dict[str, int] = {} for i, idx in enumerate(assistant_idxs): window = assistant_idxs[max(0, i - 4) : i + 1] counts: dict[str, int] = {} for j in window: for name in turns[j]["tool_calls"]: counts[name] = counts.get(name, 0) + 1 total_calls = sum(counts.values()) if total_calls < 5: continue for name, c in counts.items(): if c >= 5 and c / total_calls >= 0.6 and name not in first_seen: first_seen[name] = window[0] return sorted(((idx, [name]) for name, idx in first_seen.items()), key=lambda p: p[0]) def build_condensate(turns: list[dict]) -> dict: user_turns = [(i, t) for i, t in enumerate(turns) if t["role"] == "user" and t["text"]] first_3 = user_turns[:3] last_3 = user_turns[-3:] corrections = [(i, t) for i, t in user_turns if CORRECTION_RE.search(t["text"])] flail_events = _tool_flail_events(turns) final_assistant = None for i in range(len(turns) - 1, -1, -1): if turns[i]["role"] == "assistant" and turns[i]["text"]: final_assistant = (i, turns[i]) break seen: set[int] = set() seen_text: set[str] = set() picks: list[dict] = [] def add_text(i: int, t: dict, tag: str) -> None: if i in seen or not t["text"]: return # dedupe by leading 160 chars, Cowork sometimes re-posts the initial message key = t["text"][:160] if key in seen_text: return seen.add(i) seen_text.add(key) txt = t["text"] if len(txt) > 1200: txt = txt[:1200] + " …[trunc]" picks.append({"i": i, "role": t["role"], "ts": t.get("ts", ""), "tag": tag, "text": redact(txt)}) def add_flail(i: int, tools: list[str]) -> None: picks.append( { "i": i, "role": "meta", "ts": turns[i].get("ts", "") if i < len(turns) else "", "tag": "tool-flail", "text": f"[tool-flail episode: {', '.join(sorted(tools))} called ≥3× within 10 assistant turns]", } ) for i, t in first_3: add_text(i, t, "intent") # cap corrections at 10 to keep condensate lean for i, t in corrections[:10]: add_text(i, t, "correction") # cap flail events at 2, a strong signal rarely needs more for i, tools in flail_events[:2]: add_flail(i, tools) for i, t in last_3: add_text(i, t, "outcome") if final_assistant is not None: add_text(final_assistant[0], final_assistant[1], "final-assistant") picks.sort(key=lambda p: p["i"]) return { "picks": picks, "correction_count": len(corrections), "tool_flail_events": len(flail_events), } # ---------- token aggregation ---------- def aggregate_tokens(turns: list[dict]) -> dict: total = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0} by_model: dict[str, dict] = {} for t in turns: usage = t.get("usage") if not usage: continue model = t.get("model") or "unknown" bm = by_model.setdefault(model, {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}) for k, v in usage.items(): total[k] += v bm[k] += v return {**total, "by_model": by_model} def _subagent_tokens(paths: list[Path]) -> tuple[dict, int, int]: """Token totals across one session's sub-agent transcripts. Usage only, no text. One turn = one assistant message, deduped by message id, so a sub-agent turn is the same unit as a main-session turn. """ total = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0} by_model: dict[str, dict] = {} turns = files = 0 for p in paths: seen = False seen_msgs: set[str] = set() try: fh = p.open(encoding="utf-8", errors="replace") except OSError: continue with fh: for line in fh: try: e = json.loads(line) except json.JSONDecodeError: continue if e.get("type") != "assistant": continue # Same per-content-block duplication as the main transcript. mid = (e.get("message") or {}).get("id") if mid: if mid in seen_msgs: continue seen_msgs.add(mid) u = _usage_of(e) if not u: continue model, usage = u bm = by_model.setdefault(model, {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}) for k, v in usage.items(): total[k] += v bm[k] += v turns += 1 seen = True if seen: files += 1 return {**total, "by_model": by_model}, turns, files # ---------- session walking ---------- def _first_meta(path: Path) -> dict: """Scan early entries for entrypoint + cwd. First line may be metadata-only.""" out = {"entrypoint": None, "cwd": None} try: with path.open(encoding="utf-8", errors="replace") as f: for i, line in enumerate(f): if i > 30 and out["cwd"] and out["entrypoint"]: break try: e = json.loads(line) except json.JSONDecodeError: continue if not out["entrypoint"] and isinstance(e.get("entrypoint"), str): out["entrypoint"] = e["entrypoint"] if not out["cwd"] and isinstance(e.get("cwd"), str): out["cwd"] = e["cwd"] if out["entrypoint"] and out["cwd"]: break except OSError: pass return out def _cowork_title(audit: Path) -> str: session_dir = audit.parent meta_file = session_dir.parent / f"{session_dir.name}.json" if not meta_file.is_file(): return "" try: meta = json.loads(meta_file.read_text(encoding="utf-8", errors="replace")) except (json.JSONDecodeError, OSError): return "" return (meta.get("title") or meta.get("name") or (meta.get("initialMessage") or "")[:120] or "").strip() def _split_project(project_dir: Path) -> tuple[list[Path], dict[str, list[Path]]]: """Split a project dir into main session files and sub-agent files by parent sid. Main sessions are <project>/<sid>.jsonl. Sub-agents live at <project>/<sid>/subagents/agent-*.jsonl, and workflow agents one level deeper at <project>/<sid>/subagents/workflows/<wf>/agent-*.jsonl. Both shapes carry the parent session id as the first path component, so one rule covers them. """ mains: list[Path] = [] subs: dict[str, list[Path]] = {} for p in sorted(project_dir.rglob("*.jsonl")): parts = p.relative_to(project_dir).parts if len(parts) == 1: mains.append(p) elif "subagents" in parts: subs.setdefault(parts[0], []).append(p) return mains, subs def _candidates(since_ts: float | None, until_ts: float | None) -> Iterator[tuple[Path, str, list[Path]]]: # Sub-agent transcripts ride along with their parent session rather than # becoming sessions of their own: they are the same unit of work. if CODE_ROOT.is_dir(): for project_dir in CODE_ROOT.iterdir(): if not project_dir.is_dir() or project_dir.name.startswith("_archive"): continue mains, subs = _split_project(project_dir) for jsonl in mains: try: mtime = jsonl.stat().st_mtime except OSError: continue if since_ts is not None and mtime < since_ts: continue if until_ts is not None and mtime > until_ts: continue yield jsonl, "code", subs.get(jsonl.stem) or [] if COWORK_ROOT.is_dir(): for audit in COWORK_ROOT.rglob("local_*/audit.jsonl"): if "skills-plugin" in audit.parts: continue try: mtime = audit.stat().st_mtime except OSError: continue if since_ts is not None and mtime < since_ts: continue if until_ts is not None and mtime > until_ts: continue yield audit, "cowork", [] def process(path: Path, kind: str, subagent_paths: list[Path] | None = None) -> dict | None: try: st = path.stat() except OSError: return None meta = _first_meta(path) turns = _load_turns(path) if not turns: return None # Extract first user message (raw text, ≤400 chars, redacted). first_user_msg = "" for t in turns: if t["role"] == "user" and t["text"]: first_user_msg = t["text"][:400] break first_user_msg = redact(first_user_msg) if kind == "cowork": summary = _cowork_title(path) or first_user_msg.splitlines()[0][:120] if first_user_msg else "" else: summary = first_user_msg.splitlines()[0][:120] if first_user_msg else "" is_auto, reason = detect_automation(path, meta, turns, summary) # Sub-agent tokens fold into the parent's totals. `turns` stays main-session-only, # so per-session turn counts keep meaning one conversation. tokens = aggregate_tokens(turns) sub_tokens, sub_turns, sub_files = _subagent_tokens(subagent_paths or []) main_by_model = {m: dict(v) for m, v in tokens["by_model"].items()} if sub_files: for k in ("input", "output", "cache_read", "cache_creation"): tokens[k] += sub_tokens[k] for model, bm in sub_tokens["by_model"].items(): tgt = tokens["by_model"].setdefault( model, {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}) for k, v in bm.items(): tgt[k] += v # `tokens.by_model` is main + sub-agent, so on its own it cannot distinguish a # model the user chose for this conversation from one a sub-agent ran. Downstream # features read model shares to pick a persona; keep both halves addressable. tokens["main_by_model"] = main_by_model tokens["subagent_by_model"] = sub_tokens["by_model"] if sub_files else {} cond = build_condensate(turns) if not is_auto else {"picks": [], "correction_count": 0, "tool_flail_events": 0} return { "path": str(path), "kind": kind, "mtime": st.st_mtime, "cwd": meta.get("cwd") or "", "size": st.st_size, "entrypoint": meta.get("entrypoint") or "", "first_user_msg": first_user_msg, "summary": summary, "turns": sum(1 for t in turns if t["text"]), "user_correction_count": cond["correction_count"], "duration_s": _duration(turns), "is_automation": is_auto, "automation_reason": reason, "tokens": tokens, "subagent_files": sub_files, "subagent_turns": sub_turns, "condensate": cond, } def process_codex(path: Path) -> dict | None: """Build an inventory row from a Codex rollout (~/.codex/sessions).""" try: st = path.stat() except OSError: return None meta = codex_sessions.session_meta(path) turns = codex_sessions.codex_turns(path, model_hint=meta.get("model", "")) if not turns: return None # Codex user_message turns are raw freeform prompts (no command wrappers). for t in turns: t.setdefault("raw_has_content", bool(t.get("text"))) t.setdefault("is_only_wrappers", False) first_user_msg = "" for t in turns: if t["role"] == "user" and t["text"]: first_user_msg = redact(t["text"][:400]) break summary = first_user_msg.splitlines()[0][:120] if first_user_msg else "" cond = build_condensate(turns) # Codex has no sub-agent transcripts, but the row shape must match the Claude # Code one so consumers can read the model split unconditionally. codex_tokens = aggregate_tokens(turns) codex_tokens["main_by_model"] = {m: dict(v) for m, v in codex_tokens["by_model"].items()} codex_tokens["subagent_by_model"] = {} return { "path": str(path), "kind": "codex", "mtime": st.st_mtime, "cwd": meta.get("cwd") or "", "size": st.st_size, "entrypoint": "", "first_user_msg": first_user_msg, "summary": summary, "turns": sum(1 for t in turns if t["text"]), "user_correction_count": cond["correction_count"], "duration_s": _duration(turns), "is_automation": False, "automation_reason": "", "tokens": codex_tokens, "subagent_files": 0, "subagent_turns": 0, "condensate": cond, } def apply_recurrence_automation(rows: list[dict]) -> None: """Post-pass: any row with composite short-no-freeform whose summary repeats ≥2× on same cwd within 7 days graduates to composite:short-recurring.""" by_key: dict[tuple[str, str], list[dict]] = {} for r in rows: if r.get("automation_reason") == "composite:short-no-freeform": key = (r["cwd"], r["summary"]) by_key.setdefault(key, []).append(r) for key, group in by_key.items(): if len(group) < 2: # Revert solitary ones, one-off short wrapper-only sessions shouldn't be flagged. for r in group: r["is_automation"] = False r["automation_reason"] = "" continue group.sort(key=lambda r: r["mtime"]) # If at least two within 7 days of each other, keep as recurring; else revert. window = 7 * 86400 any_pair = False for i in range(len(group)): for j in range(i + 1, len(group)): if group[j]["mtime"] - group[i]["mtime"] <= window: any_pair = True break if any_pair: break if any_pair: for r in group: r["automation_reason"] = "composite:short-recurring" else: for r in group: r["is_automation"] = False r["automation_reason"] = "" def main() -> int: p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--out", default="out/inventory.json") p.add_argument("--since", help="YYYY-MM-DD inclusive") p.add_argument("--until", help="YYYY-MM-DD inclusive") p.add_argument("--all", action="store_true", help="disable default 6-month window") args = p.parse_args() platform = detect_platform() if platform == ANTIGRAVITY: degrade("task-profile", platform) now = time.time() since_ts: float | None until_ts: float | None if args.all: since_ts = None until_ts = None else: since_ts = ( datetime.strptime(args.since, "%Y-%m-%d").timestamp() if args.since else now - 180 * 86400 ) until_ts = ( datetime.strptime(args.until, "%Y-%m-%d").timestamp() + 86400 if args.until else None ) out_path = Path(args.out) out_path.parent.mkdir(parents=True, exist_ok=True) rows: list[dict] = [] scanned = 0 if platform == CODEX: for path in sorted(codex_sessions.CODEX_ROOT.glob("*/*/*/rollout-*.jsonl")): try: mtime = path.stat().st_mtime except OSError: continue if since_ts is not None and mtime < since_ts: continue if until_ts is not None and mtime > until_ts: continue scanned += 1 row = process_codex(path) if row is not None: rows.append(row) else: for path, kind, sub_paths in _candidates(since_ts, until_ts): scanned += 1 row = process(path, kind, sub_paths) if row is not None: rows.append(row) apply_recurrence_automation(rows) rows.sort(key=lambda r: r["mtime"], reverse=True) summary = { "generated_at": datetime.utcnow().isoformat() + "Z", "window": { "since": datetime.fromtimestamp(since_ts).strftime("%Y-%m-%d") if since_ts else None, "until": datetime.fromtimestamp(until_ts).strftime("%Y-%m-%d") if until_ts else None, }, "counts": { "scanned": scanned, "kept": len(rows), "automation": sum(1 for r in rows if r["is_automation"]), "interactive": sum(1 for r in rows if not r["is_automation"]), "code": sum(1 for r in rows if r["kind"] == "code"), "cowork": sum(1 for r in rows if r["kind"] == "cowork"), "codex": sum(1 for r in rows if r["kind"] == "codex"), "subagent_files": sum(r.get("subagent_files", 0) for r in rows), }, "sessions": rows, } out_path.write_text(json.dumps(summary, indent=2, default=str)) print( f"wrote {out_path} | scanned={scanned} kept={len(rows)} " f"interactive={summary['counts']['interactive']} automation={summary['counts']['automation']}" ) return 0 if __name__ == "__main__": raise SystemExit(main()) -
persona_emblems.py 12.6 KB
"""Hand-crafted SVG emblems for the 21 AI-adoption personas. Shared design language: * 160×160 viewBox, centred composition with ~16px margin * Primary structural strokes: 2.5px, dark #090D1F, round caps + joins * Secondary strokes: 1.8px, purple-link #8097F3 (for depth / ornament) * Signature accent: a single aquamarine #62FFD8 fill (dot / shape fill) * No gradients, no shadows, no textures * Each emblem has ONE aquamarine dot, the brand echo """ DARK = "#090D1F" PURPLE = "#8097F3" AQUA = "#62FFD8" LILA = "#E3E6F5" _BASE = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" width="160" height="160"' _S = f'stroke="{DARK}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"' _S2 = f'stroke="{PURPLE}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"' EMBLEMS: dict[str, str] = { # ── speed & style ────────────────────────────────────────────── "one-shot-wonder": f""" <svg {_BASE}> <circle cx="80" cy="80" r="62" {_S}/> <circle cx="80" cy="80" r="42" {_S}/> <circle cx="80" cy="80" r="22" {_S2}/> <line x1="22" y1="138" x2="74" y2="86" {_S}/> <path d="M22 138 L30 132 M22 138 L28 146" {_S}/> <circle cx="80" cy="80" r="6.5" fill="{AQUA}"/> </svg>""", "iterator": f""" <svg {_BASE}> <!-- 2.25-turn Archimedean spiral tightening to centre --> <path d=" M 140 80 A 60 60 0 1 1 20 80 A 58 58 0 1 1 138 80 A 46 46 0 1 0 34 80 A 44 44 0 1 0 126 80 A 32 32 0 1 1 48 80 A 30 30 0 1 1 114 80 A 18 18 0 1 0 62 80 A 16 16 0 1 0 98 80 " {_S}/> <circle cx="80" cy="80" r="5.5" fill="{AQUA}"/> </svg>""", "architect": f""" <svg {_BASE}> <!-- blueprint: nested brackets forming a spine --> <path d="M24 30 L24 130 M24 130 L38 130" {_S}/> <path d="M136 30 L136 130 M136 130 L122 130" {_S}/> <path d="M44 52 L44 130" {_S2}/> <path d="M116 52 L116 130" {_S2}/> <path d="M44 52 L116 52" {_S}/> <path d="M60 78 L100 78" {_S2}/> <path d="M60 104 L100 104" {_S2}/> <!-- vertical spine --> <line x1="80" y1="20" x2="80" y2="140" {_S}/> <circle cx="80" cy="52" r="6" fill="{AQUA}"/> </svg>""", "sprinter": f""" <svg {_BASE}> <!-- three motion dashes, right-to-left, getting longer (leading edge ahead) --> <line x1="30" y1="110" x2="62" y2="78" {_S}/> <line x1="54" y1="118" x2="92" y2="80" {_S}/> <line x1="76" y1="124" x2="130" y2="70" {_S}/> <!-- leading aquamarine streak head --> <circle cx="130" cy="70" r="6" fill="{AQUA}"/> </svg>""", "marathoner": f""" <svg {_BASE}> <!-- baseline + long rising path to a peak --> <line x1="18" y1="126" x2="142" y2="126" {_S2}/> <path d="M22 120 C 52 118, 72 96, 92 72 S 118 34, 132 38" {_S}/> <!-- summit flag --> <line x1="132" y1="38" x2="132" y2="22" {_S}/> <path d="M132 22 L 146 28 L 132 34" {_S} fill="{AQUA}"/> <circle cx="132" cy="38" r="5" fill="{AQUA}"/> </svg>""", # ── work-type ────────────────────────────────────────────────── "wordsmith": f""" <svg {_BASE}> <!-- flowing ligature, like a written 'e' loop opening outward --> <path d="M 30 112 C 22 92, 44 62, 74 58 C 108 54, 120 84, 102 102 C 86 118, 58 120, 46 104 C 38 92, 48 82, 66 84 C 82 86, 94 96, 108 96" {_S}/> <!-- ink drop at the tail --> <circle cx="122" cy="96" r="5.5" fill="{AQUA}"/> </svg>""", "engineer": f""" <svg {_BASE}> <!-- isometric cube with one face displaced --> <!-- back three faces --> <polygon points="50,50 88,32 126,50 88,68" {_S}/> <polygon points="50,50 50,106 88,124 88,68" {_S}/> <polygon points="126,50 126,106 88,124 88,68" {_S}/> <!-- detached top face, floated up-right --> <polygon points="70,24 108,6 146,24 108,42" {_S}/> <!-- connecting dashed link suggesting displacement --> <line x1="88" y1="32" x2="108" y2="24" {_S2} stroke-dasharray="3 4"/> <circle cx="108" cy="24" r="5.5" fill="{AQUA}"/> </svg>""", "researcher": f""" <svg {_BASE}> <!-- compass rose --> <circle cx="80" cy="80" r="56" {_S2}/> <!-- four cardinal diamonds, N long --> <polygon points="80,20 86,68 80,80 74,68" {_S} fill="{DARK}"/> <polygon points="80,140 86,92 80,80 74,92" {_S2} fill="{PURPLE}" fill-opacity=".15"/> <polygon points="20,80 68,74 80,80 68,86" {_S2} fill="none"/> <polygon points="140,80 92,74 80,80 92,86" {_S2} fill="none"/> <!-- centre --> <circle cx="80" cy="80" r="7" fill="{AQUA}"/> </svg>""", "diplomat": f""" <svg {_BASE}> <!-- two meeting arcs (bridges) --> <path d="M 14 100 Q 50 50, 80 80" {_S}/> <path d="M 146 100 Q 110 50, 80 80" {_S}/> <!-- small notches on the outer ends --> <line x1="14" y1="100" x2="14" y2="112" {_S}/> <line x1="146" y1="100" x2="146" y2="112" {_S}/> <!-- baseline --> <line x1="14" y1="120" x2="146" y2="120" {_S2}/> <!-- shared node --> <circle cx="80" cy="80" r="7" fill="{AQUA}"/> </svg>""", "data-whisperer": f""" <svg {_BASE}> <!-- three rising bars on a baseline --> <line x1="22" y1="132" x2="138" y2="132" {_S}/> <rect x="32" y="96" width="22" height="36" {_S} rx="2"/> <rect x="68" y="72" width="22" height="60" {_S} rx="2"/> <rect x="104" y="40" width="22" height="92" {_S} rx="2" fill="{LILA}"/> <!-- aquamarine crown dot --> <circle cx="115" cy="34" r="6" fill="{AQUA}"/> </svg>""", "strategist": f""" <svg {_BASE}> <!-- concentric circles + an outbound arrow --> <circle cx="72" cy="88" r="52" {_S2}/> <circle cx="72" cy="88" r="34" {_S2}/> <circle cx="72" cy="88" r="18" {_S}/> <!-- vector pointing out through NE --> <line x1="72" y1="88" x2="142" y2="24" {_S}/> <path d="M142 24 L130 26 M142 24 L140 36" {_S}/> <!-- aquamarine target --> <circle cx="142" cy="24" r="6" fill="{AQUA}"/> </svg>""", # ── tooling & behaviour ────────────────────────────────────────── "connector": f""" <svg {_BASE}> <!-- hub --> <circle cx="80" cy="80" r="10" fill="{AQUA}"/> <!-- six satellites on a circle of radius 54, then connect --> <!-- angles: 0, 60, 120, 180, 240, 300 --> <line x1="80" y1="80" x2="134" y2="80" {_S}/> <line x1="80" y1="80" x2="107" y2="33" {_S}/> <line x1="80" y1="80" x2="53" y2="33" {_S}/> <line x1="80" y1="80" x2="26" y2="80" {_S}/> <line x1="80" y1="80" x2="53" y2="127" {_S}/> <line x1="80" y1="80" x2="107" y2="127" {_S}/> <circle cx="134" cy="80" r="7" {_S} fill="{LILA}"/> <circle cx="107" cy="33" r="7" {_S} fill="{LILA}"/> <circle cx="53" cy="33" r="7" {_S} fill="{LILA}"/> <circle cx="26" cy="80" r="7" {_S} fill="{LILA}"/> <circle cx="53" cy="127" r="7" {_S} fill="{LILA}"/> <circle cx="107" cy="127" r="7" {_S} fill="{LILA}"/> </svg>""", "automator": f""" <svg {_BASE}> <!-- lemniscate / infinity --> <path d="M 80 80 C 80 50, 40 50, 28 80 C 40 110, 80 110, 80 80 C 80 50, 120 50, 132 80 C 120 110, 80 110, 80 80 Z" {_S}/> <!-- arrowheads at top of each lobe, suggesting motion --> <path d="M 44 64 L 50 58 M 44 64 L 50 68" {_S2}/> <path d="M 116 96 L 110 102 M 116 96 L 110 92" {_S2}/> <!-- crossing node --> <circle cx="80" cy="80" r="7" fill="{AQUA}"/> </svg>""", "skill-crafter": f""" <svg {_BASE}> <!-- cut gem / kite --> <polygon points="80,22 138,80 80,138 22,80" {_S}/> <!-- inner facets --> <line x1="80" y1="22" x2="80" y2="138" {_S2}/> <line x1="22" y1="80" x2="138" y2="80" {_S2}/> <!-- top-left facet filled lila to read as a facet --> <polygon points="80,22 80,80 22,80" fill="{LILA}"/> <!-- aquamarine glint on the right facet --> <polygon points="80,22 138,80 80,80" fill="{AQUA}" fill-opacity=".55"/> <polygon points="80,22 138,80 80,138 22,80" {_S}/> <!-- small sparkle dot --> <circle cx="116" cy="58" r="4" fill="{AQUA}"/> </svg>""", "conductor": f""" <svg {_BASE}> <!-- four arrows from corners converging on centre --> <line x1="22" y1="22" x2="68" y2="68" {_S}/> <line x1="138" y1="22" x2="92" y2="68" {_S}/> <line x1="22" y1="138" x2="68" y2="92" {_S}/> <line x1="138" y1="138" x2="92" y2="92" {_S}/> <!-- arrowheads --> <path d="M 68 68 L 58 66 M 68 68 L 66 58" {_S}/> <path d="M 92 68 L 102 66 M 92 68 L 94 58" {_S}/> <path d="M 68 92 L 58 94 M 68 92 L 66 102" {_S}/> <path d="M 92 92 L 102 94 M 92 92 L 94 102" {_S}/> <!-- central merged dot --> <circle cx="80" cy="80" r="9" fill="{AQUA}"/> </svg>""", "bench-builder": f""" <svg {_BASE}> <!-- baseline --> <line x1="18" y1="134" x2="142" y2="134" {_S2}/> <!-- three course of bricks, offset pattern --> <rect x="24" y="106" width="48" height="22" {_S} rx="2"/> <rect x="76" y="106" width="60" height="22" {_S} rx="2"/> <rect x="30" y="80" width="60" height="22" {_S} rx="2"/> <rect x="94" y="80" width="42" height="22" {_S} rx="2" fill="{LILA}"/> <rect x="42" y="54" width="60" height="22" {_S} rx="2"/> <rect x="106" y="54" width="24" height="22" {_S} rx="2"/> <!-- keystone brick --> <rect x="62" y="28" width="40" height="22" {_S} rx="2" fill="{AQUA}"/> </svg>""", # ── volume & efficiency ────────────────────────────────────────── "token-titan": f""" <svg {_BASE}> <!-- three stacked chips, perspective via ellipses --> <!-- bottom chip --> <ellipse cx="80" cy="118" rx="52" ry="12" {_S}/> <path d="M 28 118 L 28 104" {_S}/> <path d="M 132 118 L 132 104" {_S}/> <ellipse cx="80" cy="104" rx="52" ry="12" fill="{LILA}" {_S}/> <!-- middle chip --> <path d="M 34 96 L 34 82" {_S}/> <path d="M 126 96 L 126 82" {_S}/> <ellipse cx="80" cy="96" rx="46" ry="11" {_S}/> <ellipse cx="80" cy="82" rx="46" ry="11" fill="white" {_S}/> <!-- top chip, aquamarine --> <path d="M 42 72 L 42 58" {_S}/> <path d="M 118 72 L 118 58" {_S}/> <ellipse cx="80" cy="72" rx="38" ry="10" fill="{AQUA}" {_S}/> <ellipse cx="80" cy="58" rx="38" ry="10" fill="{AQUA}" {_S}/> <!-- small highlight --> <circle cx="62" cy="55" r="3" fill="white"/> </svg>""", "cache-whisperer": f""" <svg {_BASE}> <!-- three concentric shells, each a 270° open arc --> <path d="M 80 18 A 62 62 0 1 1 18 80" {_S}/> <path d="M 126 80 A 46 46 0 1 1 80 34" {_S}/> <path d="M 80 108 A 28 28 0 1 1 108 80" {_S2}/> <!-- core --> <circle cx="80" cy="80" r="10" fill="{AQUA}"/> </svg>""", "model-polyglot": f""" <svg {_BASE}> <!-- 2x2 grid of dots, differing sizes, one aquamarine --> <circle cx="52" cy="52" r="14" fill="{DARK}"/> <circle cx="108" cy="52" r="10" fill="{PURPLE}"/> <circle cx="52" cy="108" r="8" fill="{LILA}" {_S}/> <circle cx="108" cy="108" r="18" fill="{AQUA}"/> <!-- subtle grid cross --> <line x1="80" y1="28" x2="80" y2="132" {_S2}/> <line x1="28" y1="80" x2="132" y2="80" {_S2}/> </svg>""", "focused-craftsman": f""" <svg {_BASE}> <!-- single deep vertical mark, wedge/chisel shape --> <path d="M 80 20 L 88 22 L 92 120 L 80 138 L 68 120 L 72 22 Z" {_S} fill="{LILA}"/> <line x1="80" y1="20" x2="80" y2="100" {_S}/> <!-- aquamarine base --> <circle cx="80" cy="138" r="8" fill="{AQUA}"/> </svg>""", # ── fallback ────────────────────────────────────────────────── "explorer": f""" <svg {_BASE}> <!-- compass triangle --> <polygon points="80,30 94,64 80,56 66,64" fill="{DARK}"/> <circle cx="80" cy="80" r="34" {_S}/> <!-- dashed unspooling path --> <path d="M 80 80 Q 110 70, 128 90 T 132 128" {_S2} stroke-dasharray="3 5"/> <!-- starting point --> <circle cx="80" cy="80" r="6" fill="{AQUA}"/> </svg>""", } def get(persona_id: str) -> str: """Return the SVG string for a persona id, falling back to the explorer emblem.""" return EMBLEMS.get(persona_id, EMBLEMS["explorer"]).strip() if __name__ == "__main__": # Quick self-test: make sure every persona in the catalogue has an emblem. from pathlib import Path cat = Path(__file__).resolve().parent.parent / "references" / "personas.md" if cat.is_file(): import re ids = set(re.findall(r"^### ([a-z0-9-]+)", cat.read_text(), flags=re.M)) missing = ids - set(EMBLEMS) extra = set(EMBLEMS) - ids if missing: print("MISSING emblems:", sorted(missing)) if extra: print("EXTRA emblems :", sorted(extra)) if not missing and not extra: print(f"OK: {len(EMBLEMS)} emblems, all aligned with catalogue.") -
persona_features.py 9.7 KB
#!/usr/bin/env python3 """Build out/persona-features.json from profile.json + inventory.json. Stdlib-only, pure calculation. The main agent reads this alongside the other outputs and picks a persona + modifier; this script never does. """ from __future__ import annotations import json import re import statistics from collections import Counter from datetime import datetime from pathlib import Path PROFILE = Path("out/profile.json") INVENTORY = Path("out/inventory.json") OUT = Path("out/persona-features.json") MCP_RE = re.compile(r"mcp__([A-Za-z0-9_]+?)__") CATEGORIES = ("engineering", "research", "writing", "ops", "analysis", "planning", "communication") def _safe_pct(num: float, denom: float) -> float: return round(100 * num / denom, 1) if denom else 0.0 def _tokens_all(toks: dict) -> int: return sum((toks.get(k) or 0) for k in ("input", "output", "cache_read", "cache_creation")) def main() -> int: if not PROFILE.exists(): print(f"error: {PROFILE} missing. Run write_profile.py first.") return 1 profile = json.loads(PROFILE.read_text()) inv = json.loads(INVENTORY.read_text()) if INVENTORY.exists() else None tasks = profile.get("tasks") or [] counts = profile.get("counts") or {} window = profile.get("window") or {} # ── per-session aggregation across all tasks ───────────────── session_kind: dict[str, str] = {} session_turns: list[int] = [] session_tokens: dict[str, int] = {} session_mtime: dict[str, float] = {} for t in tasks: for s in t.get("sessions") or []: p = s["path"] if p in session_kind: continue session_kind[p] = s.get("kind", "code") session_turns.append(int(s.get("turns") or 0)) session_tokens[p] = _tokens_all(s.get("tokens") or {}) mt = s.get("mtime") if isinstance(mt, str): try: session_mtime[p] = datetime.strptime(mt, "%Y-%m-%d %H:%M").timestamp() except ValueError: pass total_sessions = counts.get("interactive") or len(session_kind) code_sessions = sum(1 for k in session_kind.values() if k == "code") cowork_sessions = sum(1 for k in session_kind.values() if k == "cowork") code_tokens = sum(v for p, v in session_tokens.items() if session_kind.get(p) == "code") cowork_tokens = sum(v for p, v in session_tokens.items() if session_kind.get(p) == "cowork") total_tokens = code_tokens + cowork_tokens or 1 # ── category token distribution ────────────────────────────── cat_tokens: dict[str, int] = {c: 0 for c in CATEGORIES} for t in tasks: c = t.get("category") or "ops" cat_tokens[c] = cat_tokens.get(c, 0) + sum( (t.get(k) or 0) for k in ("tokens_input", "tokens_output", "tokens_cache_read", "tokens_cache_creation") ) cat_share = {c: round(v / total_tokens, 3) for c, v in cat_tokens.items()} active_categories = sum(1 for v in cat_share.values() if v >= 0.08) # ── model mix ──────────────────────────────────────────────── model_tokens: dict[str, int] = {} for t in tasks: for m, v in (t.get("by_model") or {}).items(): model_tokens[m] = model_tokens.get(m, 0) + _tokens_all(v) model_tokens = dict(sorted(model_tokens.items(), key=lambda kv: -kv[1])) distinct_models_gt5pct = sum(1 for v in model_tokens.values() if v / total_tokens >= 0.05) opus_tokens = sum(v for m, v in model_tokens.items() if "opus" in m.lower()) opus_share = round(opus_tokens / total_tokens, 3) # ── success + iteration signal across the top-10 tasks ─────── top10 = sorted(tasks, key=lambda t: -(t.get("frequency") or 0))[:10] avg_clean = round(statistics.fmean(t.get("success_clean_pct") or 0 for t in top10), 1) if top10 else 0.0 avg_friction = round(statistics.fmean(t.get("success_friction_pct") or 0 for t in top10), 1) if top10 else 0.0 avg_iter = round(statistics.fmean(t.get("avg_iterations") or 0 for t in top10), 1) if top10 else 0.0 median_turns = statistics.median(session_turns) if session_turns else 0 max_turns = max(session_turns) if session_turns else 0 long_sessions = sum(1 for t in session_turns if t >= 100) # ── cwd diversity (code sessions only; cowork cwds are generic) ── code_cwds: set[str] = set() for t in tasks: for s in t.get("sessions") or []: if s.get("kind") == "code": # recover cwd by walking parent path of jsonl; profile already has cwd via inventory if we joined pass # Fall back to reading the inventory for cwd data, easier and accurate distinct_cwds = 0 if inv: code_cwd_set = { s.get("cwd") for s in (inv.get("sessions") or []) if s.get("kind") == "code" and s.get("cwd") and not s.get("is_automation") } distinct_cwds = len(code_cwd_set) # ── MCP discovery: scan condensate texts and first-user-msg for mcp__NAME__ prefixes ── mcp_counter: Counter[str] = Counter() if inv: for s in inv.get("sessions") or []: if s.get("is_automation"): continue haystack = [s.get("first_user_msg") or ""] for pick in (s.get("condensate") or {}).get("picks") or []: haystack.append(pick.get("text") or "") for m in MCP_RE.finditer(" ".join(haystack)): mcp_counter[m.group(1)] += 1 distinct_mcps = len(mcp_counter) top_mcps = [{"name": k, "mentions": v} for k, v in mcp_counter.most_common(10)] # ── automation breakdown (visible in profile.json) ─────────── auto_break = profile.get("automation_breakdown") or {} scheduled_run_count = sum(v for k, v in auto_break.items() if k.startswith("scheduled-task")) sdk_cli_count = auto_break.get("entrypoint:sdk-cli", 0) ditto_count = auto_break.get("ditto-routine", 0) # ── time window ────────────────────────────────────────────── mtimes = sorted(session_mtime.values()) first_date = datetime.fromtimestamp(mtimes[0]).strftime("%Y-%m-%d") if mtimes else None last_date = datetime.fromtimestamp(mtimes[-1]).strftime("%Y-%m-%d") if mtimes else None # ── cache ratio ────────────────────────────────────────────── total_cache = sum(t.get("tokens_cache_read") or 0 for t in tasks) cache_ratio = round(total_cache / total_tokens, 3) if total_tokens else 0.0 # ── opener signals (long first user messages = Architect hint) ── opener_chars: list[int] = [] if inv: for s in inv.get("sessions") or []: if s.get("is_automation"): continue opener_chars.append(len(s.get("first_user_msg") or "")) median_opener_chars = int(statistics.median(opener_chars)) if opener_chars else 0 # ── top 3 tasks ────────────────────────────────────────────── top3 = [{"task": t["task"], "frequency": t["frequency"]} for t in tasks[:3]] # ── output ─────────────────────────────────────────────────── out = { "_note": "Deterministic feature sheet. Main agent reads this + personas.md to pick a persona and write a blurb. See SKILL.md Phase G.", "window": window, "total_sessions": total_sessions, "code_sessions": code_sessions, "cowork_sessions": cowork_sessions, "total_tokens": total_tokens, "code_token_share": round(code_tokens / total_tokens, 3), "cowork_token_share": round(cowork_tokens / total_tokens, 3), "tokens_by_category": cat_share, "active_categories_8pct": active_categories, "model_tokens": {m: v for m, v in model_tokens.items()}, "distinct_models_gt5pct": distinct_models_gt5pct, "opus_share": opus_share, "avg_clean_pct_top10": avg_clean, "avg_friction_pct_top10": avg_friction, "avg_iter_top10": avg_iter, "median_turns": median_turns, "max_turns": max_turns, "long_sessions_ge100_turns": long_sessions, "distinct_cwds_code": distinct_cwds, "distinct_mcps": distinct_mcps, "top_mcps": top_mcps, "scheduled_run_count": scheduled_run_count, "sdk_cli_count": sdk_cli_count, "ditto_routine_count": ditto_count, "cache_ratio": cache_ratio, "median_opener_chars": median_opener_chars, "first_session_date": first_date, "last_session_date": last_date, "top3_tasks": top3, } OUT.write_text(json.dumps(out, indent=2)) print(f"wrote {OUT}") # Brief highlights for the operator print(f" sessions: {total_sessions} ({code_sessions} code, {cowork_sessions} cowork)") print(f" tokens: {total_tokens:,} | code {code_tokens/total_tokens*100:.0f}% / cowork {cowork_tokens/total_tokens*100:.0f}%") print(f" top cats: {', '.join(f'{c} {int(v*100)}%' for c,v in sorted(cat_share.items(), key=lambda kv:-kv[1])[:3])}") print(f" MCPs: {distinct_mcps} distinct") print(f" models: {distinct_models_gt5pct} above 5% share | Opus {opus_share*100:.0f}%") print(f" automation runs: {scheduled_run_count} scheduled, {sdk_cli_count} sub-agent, {ditto_count} ditto") return 0 if __name__ == "__main__": raise SystemExit(main()) -
write_profile.py 13.2 KB
#!/usr/bin/env python3 """Aggregate per-cluster analyses into canonical profile.csv + profile.json. Consumes: - out/analyses/*.json (Haiku outputs, one per cluster) - out/inventory.json (session metadata + per-session tokens) - out/canonical-merges.json (optional, main-agent judgment calls for cross-task merges) Produces: - out/profile.csv (company-shareable, one row per canonical task) - out/profile.json (richer structure for explorer.html with per-task friction + sessions) Scripted work here is strictly deterministic: read JSON, sum tokens, format CSV, apply redaction rules once more on any text that made it through. All naming/merging/rewriting decisions live in canonical-merges.json which the main agent produces by judgment. """ from __future__ import annotations import csv import glob import hashlib import json import re from pathlib import Path INV = Path("out/inventory.json") ANALYSES = Path("out/analyses") MERGES = Path("out/canonical-merges.json") OUT_CSV = Path("out/profile.csv") OUT_JSON = Path("out/profile.json") ALLOWED_SUCCESS = {"delivered_clean", "delivered_with_friction", "partial", "abandoned"} ALLOWED_CATS = {"engineering", "research", "writing", "ops", "analysis", "planning", "communication"} # --- redaction (same rules as inventory.py, applied defensively on final outputs) --- REDACTIONS = [ (re.compile(r"-----BEGIN [A-Z ]+ PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+ PRIVATE KEY-----"), "[REDACTED:private_key]"), (re.compile(r"eyJ[A-Za-z0-9_\-]+\.eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+"), "[REDACTED:jwt]"), (re.compile(r"sk-[A-Za-z0-9\-_]{20,}"), "[REDACTED:api_key]"), (re.compile(r"ghp_[A-Za-z0-9]{30,}"), "[REDACTED:token]"), (re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "[REDACTED:token]"), (re.compile(r"AIza[A-Za-z0-9\-_]{30,}"), "[REDACTED:api_key]"), (re.compile(r"AKIA[A-Z0-9]{16}"), "[REDACTED:api_key]"), ] KV = re.compile(r"(?i)(password|passwd|pwd|secret|api[_-]?key|bearer)\s*[:=]\s*['\"]?([^\s'\"]{6,})['\"]?") def redact(s: str) -> str: if not s: return s for p, r in REDACTIONS: s = p.sub(r, s) s = KV.sub(lambda m: f"{m.group(1)}=[REDACTED:token]", s) return s def norm_success(v, iterations): if isinstance(v, str) and v in ALLOWED_SUCCESS: return v if isinstance(iterations, (int, float)): if iterations <= 1: return "delivered_clean" if iterations <= 5: return "delivered_with_friction" return "partial" def norm_cat(v): if v in ALLOWED_CATS: return v v_lc = (v or "").lower() for c in ALLOWED_CATS: if c in v_lc: return c return "ops" def task_id(canonical_task: str) -> str: return hashlib.sha1(canonical_task.lower().encode()).hexdigest()[:10] def compact_token_str(by_model: dict) -> str: parts = [] for m, t in sorted(by_model.items(), key=lambda x: -(x[1]["input"] + x[1]["output"])): short = m.replace("claude-", "").replace("-20251101", "").replace("-20251001", "").replace("-20250929", "") parts.append(f"{short}:in={t['input']//1000}k/out={t['output']//1000}k/cread={t['cache_read']//1000}k") return "; ".join(parts) def main() -> int: inv = json.loads(INV.read_text()) by_path = {s["path"]: s for s in inv["sessions"]} # Load all analyses entries = [] for f in sorted(ANALYSES.glob("*.json")): try: d = json.loads(f.read_text()) except json.JSONDecodeError: print(f"skip malformed: {f}") continue cid = d.get("cluster_id") or f.stem for t in d.get("tasks", []) or []: iters = int(t.get("iterations") or 0) entries.append( { "_cluster": cid, "task": redact((t.get("task") or "").strip()), "category": norm_cat(t.get("category")), "success": norm_success(t.get("success"), iters), "success_evidence": redact(str(t.get("success_evidence") or "")[:240]), "iterations": iters, "friction_points": [ { "type": str(fp.get("type") or "")[:40], "example": redact(str(fp.get("example") or "")[:200]), "what_would_prevent": redact(str(fp.get("what_would_prevent") or "")[:240]), } for fp in (t.get("friction_points") or []) if isinstance(fp, dict) ], "frequency_signal": int(t.get("frequency_signal") or 0), "session_refs": list(t.get("session_refs") or []), } ) # Apply main-agent-supplied canonical merges if present merges = [] drop_thin_singletons = False if MERGES.exists(): _m = json.loads(MERGES.read_text()) merges = _m.get("merges", []) drop_thin_singletons = bool(_m.get("drop_low_context_singletons")) # Build merge map: {(cluster_id, task_index) or raw task text → canonical_name} # Each merge entry: {"canonical": "<new sentence>", "category": "<cat>", "source_tasks": [{"cluster": cid, "match": "<substring>"}]} merged_index: dict[int, dict] = {} for m in merges: m_can = { "canonical": redact(m["canonical"].strip()), "category": norm_cat(m.get("category", "ops")), "source_idxs": [], } for s in m.get("source_tasks", []): cid = s.get("cluster") match = (s.get("match") or "").lower() for i, e in enumerate(entries): if e["_cluster"] == cid and (not match or match in e["task"].lower()): merged_index[i] = m_can merges_applied = sum(1 for v in merged_index.values() if v is m_can) m_can["applied_count"] = merges_applied # Canonicalize each entry canonical: dict[str, dict] = {} for i, e in enumerate(entries): target = merged_index.get(i) if target: key = target["canonical"] cat = target["category"] else: key = e["task"] cat = e["category"] tid = task_id(key) c = canonical.setdefault( tid, { "task_id": tid, "task": key, "category": cat, "contributing_entries": [], "sessions": set(), "successes": [], "iterations_list": [], "friction_points": [], }, ) c["contributing_entries"].append(e) for s in e["session_refs"]: c["sessions"].add(s) c["successes"].append(e["success"]) c["iterations_list"].append(e["iterations"]) c["friction_points"].extend(e["friction_points"]) # Compute rollups + token totals rows_csv = [] rows_json = [] for tid, c in canonical.items(): sessions = list(c["sessions"]) total = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0} by_model: dict[str, dict] = {} last_mtime = 0 for sp in sessions: s = by_path.get(sp) if not s: continue last_mtime = max(last_mtime, s["mtime"]) tok = s["tokens"] for k in total: total[k] += tok[k] for m, bm in tok["by_model"].items(): agg = by_model.setdefault(m, {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}) for k, v in bm.items(): agg[k] += v success_counts = {k: 0 for k in ALLOWED_SUCCESS} for s in c["successes"]: success_counts[s] += 1 n = max(1, len(c["successes"])) avg_iters = round(sum(c["iterations_list"]) / n, 1) if n else 0 # Top friction types fp_type_counts: dict[str, int] = {} for fp in c["friction_points"]: t = fp["type"].strip() if t: fp_type_counts[t] = fp_type_counts.get(t, 0) + 1 top_friction = ", ".join(t for t, _ in sorted(fp_type_counts.items(), key=lambda x: -x[1])[:2]) freq = len(sessions) if sessions else sum(max(1, e["frequency_signal"]) for e in c["contributing_entries"]) row_csv = { "task_id": tid, "task": c["task"], "category": c["category"], "frequency": freq, "last_seen": __import__("datetime").datetime.fromtimestamp(last_mtime).strftime("%Y-%m-%d") if last_mtime else "", "success_clean_pct": round(100 * success_counts["delivered_clean"] / n, 0), "success_friction_pct": round(100 * success_counts["delivered_with_friction"] / n, 0), "success_partial_pct": round(100 * success_counts["partial"] / n, 0), "success_abandoned_pct": round(100 * success_counts["abandoned"] / n, 0), "avg_iterations": avg_iters, "top_friction": top_friction, "tokens_input": total["input"], "tokens_output": total["output"], "tokens_cache_read": total["cache_read"], "tokens_cache_creation": total["cache_creation"], "tokens_by_model": compact_token_str(by_model), "sample_session": sessions[0] if sessions else "", } rows_csv.append(row_csv) # Per-session detail for the explorer session_detail = [] for sp in sorted(sessions, key=lambda p: by_path[p]["mtime"] if p in by_path else 0, reverse=True): s = by_path.get(sp) if not s: continue session_detail.append( { "path": sp, "kind": s["kind"], "mtime": __import__("datetime").datetime.fromtimestamp(s["mtime"]).strftime("%Y-%m-%d %H:%M"), "summary": s["summary"], "turns": s["turns"], "corrections": s["user_correction_count"], "tokens": s["tokens"], } ) row_json = dict(row_csv) row_json["contributing_clusters"] = sorted({e["_cluster"] for e in c["contributing_entries"]}) row_json["friction_points"] = c["friction_points"][:10] row_json["sessions"] = session_detail row_json["by_model"] = by_model rows_json.append(row_json) # Drop thin-context singletons: canonical tasks whose contributing entries are all # frequency_signal=1 AND whose underlying sessions have a first_user_msg shorter than # 40 chars with no file-path / URL / structured marker. These are noise that the Haiku # could not generalise from. if drop_thin_singletons: kept_csv, kept_json = [], [] dropped = 0 for rc, rj in zip(rows_csv, rows_json): if rc["frequency"] > 1: kept_csv.append(rc); kept_json.append(rj); continue sess_paths = [s["path"] for s in rj.get("sessions", [])] thin = True for sp in sess_paths: s = by_path.get(sp) if not s: continue msg = (s.get("first_user_msg") or "").strip() has_path = any(marker in msg for marker in ("/", "://", "```", "<", "{")) if len(msg) >= 40 or has_path: thin = False break if thin: dropped += 1 continue kept_csv.append(rc); kept_json.append(rj) rows_csv, rows_json = kept_csv, kept_json if dropped: print(f"dropped {dropped} thin-context singleton tasks") rows_csv.sort(key=lambda r: -r["frequency"]) rows_json.sort(key=lambda r: -r["frequency"]) # Write CSV with OUT_CSV.open("w", newline="") as f: w = csv.DictWriter(f, fieldnames=list(rows_csv[0].keys())) w.writeheader() for r in rows_csv: w.writerow(r) # Write JSON (for explorer) counts = inv["counts"] automation_breakdown: dict[str, int] = {} automation_examples: list[dict] = [] for s in inv["sessions"]: if s["is_automation"]: reason = s["automation_reason"] automation_breakdown[reason] = automation_breakdown.get(reason, 0) + 1 if len(automation_examples) < 20: automation_examples.append({"path": s["path"], "reason": reason, "summary": s["summary"]}) profile = { "generated_at": inv["generated_at"], "window": inv["window"], "counts": counts, "automation_breakdown": automation_breakdown, "automation_examples": automation_examples, "tasks": rows_json, } OUT_JSON.write_text(json.dumps(profile, indent=2, default=str)) print(f"wrote {OUT_CSV} ({len(rows_csv)} tasks) and {OUT_JSON}") total_tokens = sum(r["tokens_input"] + r["tokens_output"] + r["tokens_cache_read"] + r["tokens_cache_creation"] for r in rows_csv) print(f"total tokens across all tasks: {total_tokens:,}") print() print("top 10 tasks:") for r in rows_csv[:10]: print(f" f={r['frequency']:>3} clean={r['success_clean_pct']:>3.0f}% friction={r['success_friction_pct']:>3.0f}% avg_i={r['avg_iterations']:>4.1f} tok={(r['tokens_input']+r['tokens_output']+r['tokens_cache_read'])//1000:>6}k | {r['task'][:80]}") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
SKILL.md 14.1 KB
--- name: task-profile description: Mine the user's Claude Code + Cowork session history into a structured task profile, what they do with AI, how often, how successfully where friction lives, then propose atomic skills that would reduce iteration. Use when the user asks to "analyse my Claude use", "build a task profile", "what tasks do I do with Claude", "where am I spending tokens", "what skills would help me", or mentions reviewing past sessions for patterns. Produces profile.csv (shareable), explorer.html (personal coaching view with AI-first principle comparison + token-spend chart), and skill-proposals.md. --- # task-profile > **Platforms: Claude Code / Cowork and Codex.** `scripts/inventory.py` detects the host (via the `platform` stamp `install.sh` writes, or `AI_FIRST_PLATFORM`) and routes: Claude Code (`~/.claude/projects`) + Cowork transcripts, or Codex rollouts (`~/.codex/sessions`), building the same session condensate + token aggregates either way. **Antigravity** is unsupported: its IDE store is AEAD-encrypted at rest and its CLI store has no parseable turn content, so the skill prints a clear "not available" message and exits. End-to-end skill: session inventory → LLM clustering → parallel Haiku analysis → aggregation → branded explorer HTML + shareable CSV + atomic skill proposals. ## When to run When the user asks to understand their own Claude usage patterns: what tasks they repeat, how much friction those tasks generate where tokens go which principles they already follow vs. where they slip, and which new skills would compound across many tasks. ## Prerequisites - Session history on this machine: - Claude Code: `~/.claude/projects/*/\*.jsonl`, plus each session's sub-agent transcripts at `<project>/<sid>/subagents/**` (workflow agents one level deeper) - Claude Cowork: `~/Library/Application Support/Claude/local-agent-mode-sessions/*/*/local_*/audit.jsonl` - The `session-search` skill is already installed at `~/.claude/skills/session-search/` (optional but recommended; this skill does its own inventory pass). - None beyond Python 3, the HTML generator ships with its own light theme baked in. No external design or logo skill required. ## Workflow Run from any working directory, outputs land under `./out/` in that directory. ### Phase A, Inventory (deterministic script) ```bash ~/.claude/skills/task-profile/scripts/inventory.py --out out/inventory.json ``` Flags: `--since YYYY-MM-DD`, `--until YYYY-MM-DD`, `--all` (default window: last 6 months). Writes per-session rows with: summary, token totals (per model, from `message.usage`), automation flag + reason, and a structured condensate (intent turns + correction turns + tool-flail episodes + outcome turns). Automated sessions (paperclip, scheduled-task, sdk-cli, ditto-routine) are flagged and excluded from downstream analysis but kept for transparency. **Two things about the token totals, because they are measured over different scopes.** 1. A session's `tokens` include its sub-agents. Sub-agent and workflow-agent transcripts are separate files but the same unit of work, so they roll into the session that spawned them. `turns` stays main-session-only, so a session can show few turns and a very large token total. That is fan-out, not a contradiction; say so rather than letting the reader trip over it. `subagent_files` and `subagent_turns` give the size of the fan-out, and `tokens.main_by_model` / `tokens.subagent_by_model` split the model mix so a model the user chose for the conversation is never confused with one a sub-agent ran. 2. One turn = one assistant **message**. Claude Code writes one JSONL line per content block (thinking, text, each tool_use) and repeats the same `usage` object on every line, so counting lines would inflate both turns and tokens by roughly 2-3x. The inventory dedupes by `message.id`. ### Phase B, Cluster (main agent reads + judges) You (the main agent) read the non-automation rows and group them into ~40–80 clusters by judgment, no scripted heuristics past cwd. Write `out/clusters.json`. Merge sessions with the same cwd, similar Cowork titles, or clearly similar topics. Show the cluster list to the user before the Haiku fan-out so they can adjust. ### Phase C, Per-cluster payloads + Haiku fan-out (parallel) Run `out/build_payloads.py` (generated per-run, sample below) to produce one payload per cluster. Sampling: ≤ 10 sessions → all included; > 10 → include 10 biased to outliers (3 longest by turns, 3 most corrections, oldest, newest, even-spaced fill). Dispatch one `Agent(subagent_type="general-purpose", model="haiku", run_in_background=true)` per cluster **in parallel**. Each subagent reads: 1. `~/.claude/skills/task-profile/references/task-style.md` 2. `~/.claude/skills/task-profile/references/success-rubric.md` 3. `~/.claude/skills/task-profile/references/friction-signals.md` 4. Its cluster payload at `out/payloads/<cluster_id>.json` And emits strict JSON to `out/analyses/<cluster_id>.json` with a 1–3 task list per cluster. ### Phase D, Aggregate (main agent + script) You (the main agent) read `out/analyses/*.json`, decide cross-cluster merges, and write `out/canonical-merges.json` with entries of the form: ```json {"canonical": "<sentence>", "category": "<cat>", "source_tasks": [{"cluster": "...", "match": "<substring>"}]} ``` Then run: ```bash ~/.claude/skills/task-profile/scripts/write_profile.py ``` The script normalises success/category enums, applies redaction one more time, sums tokens per task from the inventory (no estimation, real `message.usage` values), and writes: - `out/profile.csv`, shareable, one row per canonical task, with `tokens_by_model` as a compact string. - `out/profile.json`, richer, includes per-task friction points and session list (for the explorer). ### Phase E, Coaching panel + skill proposals (main agent, MANDATORY) **Do not skip this phase.** The explorer is half-empty without it. `build_explorer.py` will refuse to run unless both `out/coaching-panel.json` and `out/skill-proposals.json` exist; override with `--allow-empty` is only for debugging. #### E.1, Coaching panel Read `out/profile.json` and `~/.claude/skills/task-profile/references/ai-first-principles.md`. Pick 3–5 principles where the user has a clear, evidenced gap. For each, cite ≥ 1 good-example session path and ≥ 1 friction-example session path. Write `out/coaching-panel.json`. Schema: ```json { "cards": [ { "principle": "<short name of the habit>", "pattern": "<one-line description of the observed pattern>", "good_example": {"description": "<what worked here>", "session_path": "<path>"}, "friction_example": {"description": "<what slipped>", "session_path": "<path>"}, "suggested_adjustment": "<concrete habit to try next time>" } ] } ``` #### E.2, Skill proposals **Step 1, MANDATORY: enumerate what's already installed.** Before you write a single proposal, list every skill the user already has access to: ```bash # User-level skills ls ~/.claude/skills/ 2>/dev/null # Project-level skills (if present) ls .claude/skills/ 2>/dev/null # Plugin-namespaced skills (read SKILL.md frontmatter to capture `description`) for f in ~/.claude/plugins/cache/*/*/skills/*/SKILL.md ~/.claude/plugins/*/skills/*/SKILL.md; do [ -f "$f" ] && echo "=== $f ===" && head -5 "$f" done 2>/dev/null ``` Also scan the transcripts: any `mcp__...` tool call, any `/<namespace>:<name>` slash command the user has typed, and anything the `coaching-panel.json` cites as "you do this well already", all of those are skills already in play. Collect the full list into a working set before proposing anything. **Step 2, de-duplicate against reality.** For every task cluster you might propose a skill for, ask: - Is there already an installed skill whose `description` covers this territory? If yes, DO NOT propose a parallel skill. Either skip the proposal or reframe it as "enhance `<existing-skill>` with X", scoped narrowly to the gap. - Is the gap just that the user doesn't know the skill exists, or that the trigger description is weak? If yes, the proposal is "update trigger for `<existing-skill>`", not a new skill. - Does this overlap with a plugin skill (e.g. a memo template, a design system, a people-management namespace)? Plugins already ship the canonical implementation; re-inventing them is noise. A proposal that duplicates an installed skill is a worse recommendation than no proposal at all. Five sharp proposals are better than five padded ones, and two sharp proposals beat five mediocre ones. **Do not pad the list to reach 5.** **Step 3, propose.** Up to 5 atomic skills, each impacting ≥ 2 top tasks (breadth) and following the **task-centric** shape: prescriptive `mandatory_steps`, bundled sources-of-truth (guidelines, prior-art scripts, templates), fixed `output_shape`, invocation-as-slash-command. Avoid abstract workflow shapers ("opener-template", "staged-drafts", "checkpoint"), these sit outside a task and so don't get invoked in context. For each proposal emit to `out/skill-proposals.json`: - `name`, slug for the skill - `trigger_description`, SKILL.md frontmatter description - `modelled_after`, the existing installed skill it takes inspiration from, one line (REQUIRED, non-empty, references a real skill from Step 1) - `overlaps_considered`, list of installed skills that cover adjacent territory + one-line why this proposal is still distinct (REQUIRED; empty list is only valid if the domain is genuinely uncovered) - `mandatory_steps`, ordered list the skill runs every time (MANDATORY reads of guidelines/prior-art/references) - `output_shape`, fixed filename convention + required sections - `tasks_impacted`, ≥ 2 entries with `task_id` + `why_relevant` - `expected_savings`, small/medium/large + why - `invocation_hint`, `/skill-creator <name>` Add a top-level `_installed_skills_checked` array to `skill-proposals.json` listing every skill enumerated in Step 1, so the user can verify the pre-check actually ran. ### Phase G, Persona card (main agent, MANDATORY) **Do not skip.** `build_explorer.py` refuses to run without `out/persona.json`. 1. Run the deterministic feature helper: ```bash ~/.claude/skills/task-profile/scripts/persona_features.py ``` Produces `out/persona-features.json` with the numbers only. 2. Read `~/.claude/skills/task-profile/references/personas.md` (the 20-persona catalogue + fallback Explorer). 3. Read `out/persona-features.json`, `out/profile.json`, `out/coaching-panel.json`, `out/skill-proposals.json`. 4. Pick **one primary persona** whose triggers fire most clearly in the feature sheet. Break ties by coherence with the coaching cards. If fewer than ~10 interactive sessions, pick **The Explorer**. 5. Optionally pick **one secondary modifier**. Leave `modifier: null` when none fits cleanly. 6. Write a 40–60-word tailored blurb, in second person, opening with a concrete behaviour and including one surprising number from the feature sheet. No em-dashes, hype words, brand names. Voice: observant friend, not marketing coach. 7. Write `out/persona.json`: ```json { "id": "<persona-slug>", "name": "<The Xxxx>", "tagline": "<catalogue tagline>", "modifier": "<slug or null>", "confidence_note": "<why this persona beats the others, one sentence>", "blurb": "<your rewritten 40–60-word blurb>", "highlight_stat": {"label": "<short>", "value": <number>}, "top3_task_names": ["<short>", "<short>", "<short>"], "features_used": { ... relevant numbers cited in the blurb ... } } ``` ### Phase F, Explorer HTML ```bash ~/.claude/skills/task-profile/scripts/build_explorer.py ``` Fixed light theme baked into the generator: off-white background, aquamarine accents, subtle dot-grid atmosphere, Geist sans-serif via Google Fonts, glassmorphism adapted for light. Single-file, no network at runtime (fonts via CDN). Data embedded as a JSON blob. Uses **progressive disclosure**, categories open to reveal tasks; tasks open to reveal friction and tokens; coaching and proposals open to reveal detail. Includes: - Token-spend chart (horizontal stacked bars per task, clickable to jump to task detail) - Sortable/filterable task table with search, category, min-frequency, since-date - Row-click expands per-task detail: friction points with what-would-prevent guidance, per-model token table, session list - Personal coaching panel (AI-first principle comparison) - Skill proposals cards - Automation-filter transparency footer Open with `open out/explorer.html`. ## Final manual review Before considering the run done, scan `out/profile.csv` and the explorer for the top-100 highest-entropy tokens (any random-looking string of mixed case + digits ≥ 16 chars). These are the most likely way a secret slipped past automated redaction. Ask the user to confirm the scan is clean. ## Outputs at a glance | File | Audience | Shape | |---|---|---| | `out/inventory.json` | Internal | Full per-session rows with condensates, `subagent_files` / `subagent_turns`, and `tokens.main_by_model` / `tokens.subagent_by_model` | | `out/clusters.json` | Internal | `[{cluster_id, label, session_paths}]` | | `out/payloads/*.json` | Haiku subagents | Sampled condensates per cluster | | `out/analyses/*.json` | Internal | Haiku output, 1–3 tasks per cluster | | `out/canonical-merges.json` | Internal | Main-agent cross-cluster merge decisions | | `out/profile.csv` | Shareable with company | One row per canonical task | | `out/profile.json` | Feeds the explorer | Rich task rows + session detail | | `out/coaching-panel.json` | Feeds the explorer | Personal AI-first coaching cards | | `out/skill-proposals.json` | Feeds the explorer + user action | Up to 5 cross-cutting skill proposals | | `out/explorer.html` | Personal | Single-file UI with progressive disclosure | ## References - `references/task-style.md`, CSV-style task sentence rules, good/bad examples - `references/success-rubric.md`, 4-level success taxonomy with signals - `references/friction-signals.md`, correction phrases + behavioural markers - `references/automation-filters.md`, rules for flagging non-interactive sessions - `references/redaction-rules.md`, regex + heuristic rules for stripping secrets - `references/ai-first-principles.md`, bootcamp + prompting principles used for coaching
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.