Add sandbox template image with Claude configuration and plugins
build / build (push) Canceled after 0s
build / build (push) Canceled after 0s
Carries CLAUDE.md, AGENTS.md, hooks and skills verbatim from the host, plus a manifest of the 10 marketplaces and 17 plugins to reinstall at build time. The plugin directories themselves are not committed: ~/.claude/plugins is 831 MB and sits alongside credentials and transcripts, so the image is reproduced from the manifest instead and the build needs no access to the host. The Gitea registry is behind Cloudflare, which rejects request bodies over 100 MB against a base image with a 325 MB layer, so the workflow pushes chunked through regctl rather than docker push. sbx v0.37.0 and v0.37.1 cannot consume the result: layers stacked on the base are silently dropped (docker/sbx-releases#366). The image builds and pushes correctly and is a no-op at runtime until that is fixed, so README points at 'ai:sbx setup' as the mechanism that works today.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<claude-mem-context>
|
||||
# Memory Context
|
||||
|
||||
# [.claude] recent context, 2026-05-13 1:20pm CDT
|
||||
|
||||
Legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖️decision 🚨security_alert 🔐security_note
|
||||
Format: ID TIME TYPE TITLE
|
||||
Fetch details: get_observations([IDs]) | Search: mem-search skill
|
||||
|
||||
Stats: 50 obs (15,070t read) | 202,478t work | 93% savings
|
||||
|
||||
### May 13, 2026
|
||||
S379 Design and implement Trivy integration into terraform-review security scanning; update implementation plan to pivot from LLM-first to Trivy-first architecture based on token-cost optimization goal (May 13, 10:17 AM)
|
||||
S380 Review and finalize code-review skill Phase 1: verify test suite passes, identify and fix blocking issues, commit final changes (May 13, 10:21 AM)
|
||||
1677 10:32a 🟣 Manifest slicing module implemented for specialized review routing
|
||||
1678 " 🟣 Manifest slicing tests all passing
|
||||
1679 " ✅ Per-agent manifest slicing committed to codebase
|
||||
1680 " 🟣 CR-T18 task completed: per-agent manifest slicing fully implemented
|
||||
1681 10:33a 🔵 Code-review skill infrastructure expanded with package diffing and manifest slicing
|
||||
1682 " 🟣 CLI integration test suite created
|
||||
1683 10:34a 🟣 CLI entry point implemented for end-to-end code review manifest generation
|
||||
1684 " 🟣 CLI integration tests all passing
|
||||
1685 " 🔵 Code-review skill full test suite passing: 84 tests
|
||||
1686 " ✅ CLI entry point and integration tests committed
|
||||
1687 10:35a 🟣 CR-T19 task completed: code-review skill fully implemented and tested
|
||||
1688 10:44a 🔵 Code-review skill test suite fully passing
|
||||
1689 " 🔵 Missing subprocess import in collect-findings.py
|
||||
1690 " 🔴 Added missing subprocess import to collect-findings.py
|
||||
1691 10:45a 🔵 Package diff collection and slicing now working end-to-end
|
||||
1692 " ✅ Committed package_diffs and diff filter path-normalization fixes
|
||||
S383 Implement alerting for unsupported source languages detected in code review diffs — distinguish between unreviewed source code (Go, Ruby, Java, etc.) and silently-skipped config/doc files, surfacing language-specific warnings in the manifest errors. (May 13, 10:45 AM)
|
||||
1698 12:47p 🔵 terraform-review skill test suite passes all 12 tests
|
||||
1699 " 🟣 Module-to-source attribution fix in terraform-review catalog
|
||||
1700 12:48p 🔵 Current language support detection mechanism identified
|
||||
1701 " 🟣 Added detection and alerting for known but unsupported programming languages
|
||||
1702 " 🔵 Integration point identified for unsupported language alerts in file processing pipeline
|
||||
1703 12:49p ✅ Imported known_unsupported_language function into collect-findings module
|
||||
1704 " 🟣 Implemented unsupported language detection and alerting in file categorization
|
||||
1705 " 🔵 Code review of terraform-review module attribution fix approved for merge
|
||||
1706 " ✅ Added test coverage for known_unsupported_language function
|
||||
1707 " ✅ terraform-review multi-task implementation plan updated to reflect Task 1 completion
|
||||
1708 " ✅ Added integration tests for unsupported language detection and alerting
|
||||
1709 12:50p 🔵 All unit tests pass with unsupported language detection feature integrated
|
||||
1710 " ✅ Updated SKILL.md documentation with unsupported language alert handling guidance
|
||||
1711 " 🔵 End-to-end validation: unsupported language detection works correctly in real scenario
|
||||
1712 " ✅ Feature committed: unsupported language detection and alerting
|
||||
S384 Assess tool bundling and installation strategy for code-review skill — determine which of 10+ SAST/linting tools can be reasonably bundled, and design pragmatic setup flow for first-time users. (May 13, 12:50 PM)
|
||||
S386 Implement tool bundling and isolation strategy for code-review skill — enable reproducible Python tool versions in skill-local venv without polluting user's system Python, provide platform-aware installation script. (May 13, 12:51 PM)
|
||||
1713 12:52p ✅ Created pyproject.toml with tool dependencies in dependency-groups
|
||||
1714 12:53p 🟣 Implemented tool resolution to prefer skill-local venv over system PATH
|
||||
1715 " ✅ Added test coverage for skill-venv tool resolution logic
|
||||
1716 12:54p 🟣 Created install-tools.sh script for automated tool setup
|
||||
1717 " 🔵 Successfully installed Python tools to skill-local venv via uv sync
|
||||
1719 " 🔵 Examined root cause of test failures: venv tools found despite mock patches
|
||||
1720 12:55p 🔴 Fixed test mocks to account for venv-first tool resolution
|
||||
1722 " 🔵 Test failure root cause: resolved tool paths don't match mock subprocess checks
|
||||
1723 " 🔴 Fixed mock subprocess to handle both bare binary names and resolved absolute paths
|
||||
1724 " 🔵 All unit tests passing: tool bundling and venv integration complete
|
||||
1725 " 🔵 End-to-end validation: bundled tools execute and detect real security issues
|
||||
1726 12:56p ✅ Committed tool bundling feature: pyproject.toml, install-tools.sh, venv-aware runner
|
||||
S388 Analyze review-terraform skill for improvements to issue detection and token usage, starting with Task 2 verification (May 13, 12:56 PM)
|
||||
1727 12:57p 🔵 Terragrunt context lost in manifest slicing pipeline
|
||||
1728 12:58p 🔴 Terragrunt context propagated through manifest slicing pipeline
|
||||
S389 Complete review-terraform skill improvements efficiently; decision to skip spawned review agents and proceed with local work (May 13, 12:59 PM)
|
||||
S390 Complete review-terraform skill analysis and improvements; optimize workflow by skipping slow/failed spawned agents (May 13, 1:01 PM)
|
||||
S392 Continue implementing terraform-review skill optimization: complete Task 6 (bounded effective-config context) and begin Task 7 (precompute consistency norms) (May 13, 1:02 PM)
|
||||
1729 1:02p 🟣 Trivy findings integrated into terraform-review manifest schema
|
||||
1730 1:03p 🟣 Trivy config scanning integrated into terraform-review collection pipeline
|
||||
1731 1:04p 🟣 Test coverage added for Trivy integration in terraform-review
|
||||
1732 " 🟣 Unit tests added for TrivyFinding serialization and manifest integration
|
||||
1733 " ✅ SKILL.md documentation updated to reflect Trivy integration
|
||||
S393 Design Task 8: Reduce prompt duplication and tighten discovery text across terraform-review documentation and skill definitions (May 13, 1:13 PM)
|
||||
**Investigated**: Explored the terraform-review skill architecture including SKILL.md (operational guidance), DESIGN.md (architectural reference), agent prompt files (task-specific instructions), and the manifest contract to understand where documentation redundancy existed and how legacy FSBP/CIS references were spreading across files.
|
||||
|
||||
**Learned**: The skill suffered from role confusion where architectural explanations (belonging in DESIGN.md) were mixed into operational guidance (SKILL.md), and agent-agnostic context was duplicated across multiple task-specific prompts. Language had drifted from the implemented Trivy-first design into legacy FSBP/CIS terminology. BlockLocation helper required a text field for test compatibility while keeping the manifest contract unchanged.
|
||||
|
||||
**Completed**: Implemented full refactoring: SKILL.md tightened to pure operational trigger conditions; DESIGN.md clarified to contain only architecture and manifest contract details; agent prompts reduced to task-specific instructions only. All references updated from FSBP/CIS/benchmark language to Trivy-first/AWS-BP framework. BlockLocation.text field restored for test compatibility. Two new commits shipped: ed52e6b (consistency norms precomputation) and ba7eb7c (documentation alignment). Full test suite passing: 78 tests in 11.90s.
|
||||
|
||||
**Next Steps**: Session is complete - all 9 refactoring tasks in terraform-review initiative are finished. Code is production-ready with Trivy-first design, compact manifest payloads, bounded effective context, and precomputed consistency norms. No further action required unless user provides new requests.
|
||||
|
||||
|
||||
Access 202k tokens of past work via get_observations([IDs]) or mem-search skill.
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,67 @@
|
||||
# Personal Engineering Rules
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
Most of these are enforced mechanically by `~/.claude/hooks/bash-guard.mjs` (PreToolUse:Bash).
|
||||
Where marked *hook-enforced*, the hook is the source of truth and hands back the correct
|
||||
command when it blocks — the text here is a summary, not the mechanism.
|
||||
|
||||
- **Version control: jj only.** Never run mutating git commands. `git status` → `jj st`; `git add/rm` → just edit (jj auto-tracks); `git commit` → `jj describe` / `jj new`; `git log` → `jj log`; `git diff` → `jj diff`. New bookmarks: `malcolm/change-being-made`. *(hook-enforced; read-only git is allowed)*
|
||||
- **Isolation: jj workspaces, not git worktrees.** When work needs an isolated checkout (parallel agents, long build while editing, executing a plan), use `jj workspace add ../<name>`; clean up with `jj workspace forget <name>` *then* delete the directory. Never `git worktree add`, never the `EnterWorktree`/`ExitWorktree` tools, never `Agent(isolation: "worktree")` — those create git worktrees, which jj does not track. Details in the `jujutsu` skill (## Workspaces). *(EnterWorktree/ExitWorktree denied in settings.json)*
|
||||
- **Build and test before every push or tag.** Zero errors, zero warnings, all tests pass. No exceptions — not even for "trivial" changes. *(hook-enforced for dotnet/node/go/rust/python)*
|
||||
- **Warnings are errors.** Fix every warning produced by code you write.
|
||||
- **No Claude attribution anywhere.** Never include the "Generated with Claude Code" / "Created by Claude" tag, the `Co-Authored-By: Claude` trailer, or any equivalent attribution line in pull request descriptions OR commit messages. *(hook-enforced for inline messages only — heredocs and `-F` files are not inspected)*
|
||||
- **Shell: the Bash tool runs bash.** The environment block reports `/bin/fish`; that is the login shell, not the interpreter. The tool follows `$SHELL`, pinned to `/bin/bash` in settings.json. Write ordinary bash — heredocs, `export VAR=x`, `[[ ]]`, `{1..10}`, and `${VAR:-default}` all work. Never write fish syntax.
|
||||
|
||||
## Engineering Philosophy
|
||||
|
||||
- **Correctness is mandatory.** Unvalidated code is broken by definition.
|
||||
- **Evidence over confidence.** "Should work" is not justification — prove it with a test or direct observation.
|
||||
- **Simplicity is required.** The simplest implementation that meets the requirements is the correct one. Abstractions must earn their existence.
|
||||
- **Less code is better.** Prefer deletion. Prefer clarity over cleverness. If something is hard to explain, it's probably wrong.
|
||||
- **No premature abstraction.** Generalize only when duplication or complexity is already causing harm.
|
||||
|
||||
## Validation & Testing
|
||||
|
||||
- Every change validated by a test OR direct observation of program behavior. No exceptions.
|
||||
- Every bug fix ships with a test that fails before the fix and passes after. Bugs without tests are unresolved.
|
||||
- Prefer integration tests over unit tests. Mock only true external systems (network, third-party APIs). Never mock internal logic, domain rules, or data transformations.
|
||||
- Code without tests is incomplete. Refactors that reduce coverage are regressions.
|
||||
|
||||
## Errors
|
||||
|
||||
- Fail fast at boundaries — validate inputs immediately, reject invalid state early.
|
||||
- Error messages must explain: what went wrong, why it matters, what the caller can do next.
|
||||
- No silent failures. No vague errors.
|
||||
|
||||
## Comments
|
||||
|
||||
- Comments explain WHY, never WHAT. Allowed only for non-obvious constraints, trade-offs, or invariants.
|
||||
- If code needs commentary to be understood, rewrite the code instead.
|
||||
|
||||
## Security
|
||||
|
||||
- Assume every repo is public. Never commit secrets, credentials, tokens, private keys, or `.env` files.
|
||||
- Treat all external input as untrusted. Validate and sanitize at trust boundaries.
|
||||
|
||||
## Change Discipline
|
||||
|
||||
- Every change must be explainable: why it exists, why this approach, how correctness was validated.
|
||||
- If unsure, **stop**. Don't guess. Ask, test, or observe before proceeding.
|
||||
- If you discover a bug, write a test. If behavior is unclear, make it observable.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- **Fill PR templates lazily.** When a repo has a PR template, complete it with minimum words: check the appropriate boxes, give one-line (or terse/"N/A") answers for prose sections, no essays and no invented detail. Never leave required sections blank; never write paragraphs.
|
||||
- Brevity applies to prose, not honesty — keep security/privacy/impact box selections accurate even while terse.
|
||||
|
||||
## Verification Before Completion
|
||||
|
||||
Before claiming "done", "fixed", or "complete":
|
||||
|
||||
1. IDENTIFY: which command proves this claim?
|
||||
2. RUN: execute it.
|
||||
3. READ: did the output pass?
|
||||
4. CLAIM: only then, with evidence attached.
|
||||
|
||||
Red flags that mean STOP and verify: "should", "probably", "seems to", expressing satisfaction before running anything.
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env node
|
||||
// caveman — Claude Code SessionStart activation hook
|
||||
//
|
||||
// Runs on every session start:
|
||||
// 1. Writes flag file at $CLAUDE_CONFIG_DIR/.caveman-active (statusline reads this)
|
||||
// 2. Emits caveman ruleset as hidden SessionStart context
|
||||
// 3. Detects missing statusline config and emits setup nudge
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { getDefaultMode, safeWriteFlag } = require('./caveman-config');
|
||||
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
const mode = getDefaultMode();
|
||||
|
||||
// "off" mode — skip activation entirely, don't write flag or emit rules
|
||||
if (mode === 'off') {
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
process.stdout.write('OK');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 1. Write flag file (symlink-safe)
|
||||
safeWriteFlag(flagPath, mode);
|
||||
|
||||
// 2. Emit full caveman ruleset, filtered to the active intensity level.
|
||||
// The old 2-sentence summary was too weak — models drifted back to verbose
|
||||
// mid-conversation, especially after context compression pruned it away.
|
||||
// Full rules with examples anchor behavior much more reliably.
|
||||
//
|
||||
// Reads SKILL.md at runtime so edits to the source of truth propagate
|
||||
// automatically — no hardcoded duplication to go stale.
|
||||
|
||||
// Modes that have their own independent skill files — not caveman intensity levels.
|
||||
// For these, emit a short activation line; the skill itself handles behavior.
|
||||
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
|
||||
|
||||
if (INDEPENDENT_MODES.has(mode)) {
|
||||
process.stdout.write('CAVEMAN MODE ACTIVE — level: ' + mode + '. Behavior defined by /caveman-' + mode + ' skill.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Resolve the canonical label for wenyan alias
|
||||
const modeLabel = mode === 'wenyan' ? 'wenyan-full' : mode;
|
||||
|
||||
// Read SKILL.md — the single source of truth for caveman behavior.
|
||||
// Plugin installs: __dirname = <plugin_root>/hooks/, SKILL.md at <plugin_root>/skills/caveman/SKILL.md
|
||||
// Standalone installs: __dirname = $CLAUDE_CONFIG_DIR/hooks/, SKILL.md won't exist — falls back to hardcoded rules.
|
||||
let skillContent = '';
|
||||
try {
|
||||
skillContent = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'skills', 'caveman', 'SKILL.md'), 'utf8'
|
||||
);
|
||||
} catch (e) { /* standalone install — will use fallback below */ }
|
||||
|
||||
let output;
|
||||
|
||||
if (skillContent) {
|
||||
// Strip YAML frontmatter
|
||||
const body = skillContent.replace(/^---[\s\S]*?---\s*/, '');
|
||||
|
||||
// Filter intensity table: keep header rows + only the active level's row
|
||||
const filtered = body.split('\n').reduce((acc, line) => {
|
||||
// Intensity table rows start with | **level** |
|
||||
const tableRowMatch = line.match(/^\|\s*\*\*(\S+?)\*\*\s*\|/);
|
||||
if (tableRowMatch) {
|
||||
// Keep only the active level's row (and always keep header/separator)
|
||||
if (tableRowMatch[1] === modeLabel) {
|
||||
acc.push(line);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Example lines start with "- level:" — keep only lines matching active level
|
||||
const exampleMatch = line.match(/^- (\S+?):\s/);
|
||||
if (exampleMatch) {
|
||||
if (exampleMatch[1] === modeLabel) {
|
||||
acc.push(line);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc.push(line);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
output = 'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' + filtered.join('\n');
|
||||
} else {
|
||||
// Fallback when SKILL.md is not found (standalone hook install without skills dir).
|
||||
// This is the minimum viable ruleset — better than nothing.
|
||||
output =
|
||||
'CAVEMAN MODE ACTIVE — level: ' + modeLabel + '\n\n' +
|
||||
'Respond terse like smart caveman. All technical substance stay. Only fluff die.\n\n' +
|
||||
'## Persistence\n\n' +
|
||||
'ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode".\n\n' +
|
||||
'Current level: **' + modeLabel + '**. Switch: `/caveman lite|full|ultra`.\n\n' +
|
||||
'## Rules\n\n' +
|
||||
'Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. ' +
|
||||
'Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.\n\n' +
|
||||
'Pattern: `[thing] [action] [reason]. [next step].`\n\n' +
|
||||
'Not: "Sure! I\'d be happy to help you with that. The issue you\'re experiencing is likely caused by..."\n' +
|
||||
'Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"\n\n' +
|
||||
'## Auto-Clarity\n\n' +
|
||||
'Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.\n\n' +
|
||||
'## Boundaries\n\n' +
|
||||
'Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.';
|
||||
}
|
||||
|
||||
// 3. Detect missing statusline config — nudge Claude to help set it up
|
||||
try {
|
||||
let hasStatusline = false;
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
if (settings.statusLine) {
|
||||
hasStatusline = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasStatusline) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const scriptName = isWindows ? 'caveman-statusline.ps1' : 'caveman-statusline.sh';
|
||||
const scriptPath = path.join(__dirname, scriptName);
|
||||
const command = isWindows
|
||||
? `powershell -ExecutionPolicy Bypass -File "${scriptPath}"`
|
||||
: `bash "${scriptPath}"`;
|
||||
const statusLineSnippet =
|
||||
'"statusLine": { "type": "command", "command": ' + JSON.stringify(command) + ' }';
|
||||
output += "\n\n" +
|
||||
"STATUSLINE SETUP NEEDED: The caveman plugin includes a statusline badge showing active mode " +
|
||||
"(e.g. [CAVEMAN], [CAVEMAN:ULTRA]). It is not configured yet. " +
|
||||
"To enable, add this to " + path.join(claudeDir, 'settings.json') + ": " +
|
||||
statusLineSnippet + " " +
|
||||
"Proactively offer to set this up for the user on first interaction.";
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail — don't block session start over statusline detection
|
||||
}
|
||||
|
||||
process.stdout.write(output);
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env node
|
||||
// caveman — shared configuration resolver
|
||||
//
|
||||
// Resolution order for default mode:
|
||||
// 1. CAVEMAN_DEFAULT_MODE environment variable
|
||||
// 2. Config file defaultMode field:
|
||||
// - $XDG_CONFIG_HOME/caveman/config.json (any platform, if set)
|
||||
// - ~/.config/caveman/config.json (macOS / Linux fallback)
|
||||
// - %APPDATA%\caveman\config.json (Windows fallback)
|
||||
// 3. 'full'
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const VALID_MODES = [
|
||||
'off', 'lite', 'full', 'ultra',
|
||||
'wenyan-lite', 'wenyan', 'wenyan-full', 'wenyan-ultra',
|
||||
'commit', 'review', 'compress'
|
||||
];
|
||||
|
||||
function getConfigDir() {
|
||||
if (process.env.XDG_CONFIG_HOME) {
|
||||
return path.join(process.env.XDG_CONFIG_HOME, 'caveman');
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
'caveman'
|
||||
);
|
||||
}
|
||||
return path.join(os.homedir(), '.config', 'caveman');
|
||||
}
|
||||
|
||||
function getConfigPath() {
|
||||
return path.join(getConfigDir(), 'config.json');
|
||||
}
|
||||
|
||||
function getDefaultMode() {
|
||||
// 1. Environment variable (highest priority)
|
||||
const envMode = process.env.CAVEMAN_DEFAULT_MODE;
|
||||
if (envMode && VALID_MODES.includes(envMode.toLowerCase())) {
|
||||
return envMode.toLowerCase();
|
||||
}
|
||||
|
||||
// 2. Config file
|
||||
try {
|
||||
const configPath = getConfigPath();
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
if (config.defaultMode && VALID_MODES.includes(config.defaultMode.toLowerCase())) {
|
||||
return config.defaultMode.toLowerCase();
|
||||
}
|
||||
} catch (e) {
|
||||
// Config file doesn't exist or is invalid — fall through
|
||||
}
|
||||
|
||||
// 3. Default
|
||||
return 'full';
|
||||
}
|
||||
|
||||
// Symlink-safe flag file write.
|
||||
// Uses O_NOFOLLOW where available, writes atomically via temp + rename with
|
||||
// 0600 permissions. Protects against local attackers replacing the predictable
|
||||
// flag path (~/.claude/.caveman-active) with a symlink to clobber other files.
|
||||
//
|
||||
// When the parent directory is itself a symlink (legitimate pattern: ~/.claude
|
||||
// symlinked to another drive or shared config dir), resolves through to the
|
||||
// real path and verifies ownership on Unix (uid match). This allows e.g.
|
||||
// ln -s /opt/shared-claude-config ~/.claude
|
||||
// while still refusing attacker-planted symlinks pointing to dirs owned by
|
||||
// another user.
|
||||
//
|
||||
// On Windows, uid checks are unavailable — falls back to verifying the resolved
|
||||
// path lives under the user's home directory.
|
||||
//
|
||||
// The flag file itself must never be a symlink (that's the actual clobber vector).
|
||||
//
|
||||
// Set CAVEMAN_DEBUG=1 to emit stderr diagnostics when flag writes are refused.
|
||||
//
|
||||
// Silent-fails on any filesystem error — the flag is best-effort.
|
||||
function safeWriteFlag(flagPath, content) {
|
||||
const debug = process.env.CAVEMAN_DEBUG === '1';
|
||||
try {
|
||||
const flagDir = path.dirname(flagPath);
|
||||
fs.mkdirSync(flagDir, { recursive: true });
|
||||
|
||||
// When the parent directory is a symlink, resolve it and verify ownership.
|
||||
// This allows legitimate symlinked ~/.claude dirs while still refusing
|
||||
// attacker-planted symlinks pointing at dirs owned by another user.
|
||||
let realFlagDir;
|
||||
try {
|
||||
const lstat = fs.lstatSync(flagDir);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
realFlagDir = fs.realpathSync(flagDir);
|
||||
const realStat = fs.statSync(realFlagDir);
|
||||
if (!realStat.isDirectory()) {
|
||||
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} is not a directory\n`);
|
||||
return;
|
||||
}
|
||||
if (typeof process.getuid === 'function') {
|
||||
if (realStat.uid !== process.getuid()) {
|
||||
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${realFlagDir} owned by uid ${realStat.uid}, not current user ${process.getuid()}\n`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const home = os.homedir();
|
||||
const normalizedReal = path.resolve(realFlagDir);
|
||||
const normalizedHome = path.resolve(home);
|
||||
if (!normalizedReal.toLowerCase().startsWith(normalizedHome.toLowerCase() + path.sep) &&
|
||||
normalizedReal.toLowerCase() !== normalizedHome.toLowerCase()) {
|
||||
if (debug) process.stderr.write(`[caveman] safeWriteFlag: symlink target ${normalizedReal} is outside home directory ${normalizedHome}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
realFlagDir = flagDir;
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The flag file itself must never be a symlink (that's the actual clobber vector).
|
||||
const realFlagPath = path.join(realFlagDir, path.basename(flagPath));
|
||||
try {
|
||||
if (fs.lstatSync(realFlagPath).isSymbolicLink()) return;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') return;
|
||||
}
|
||||
|
||||
const tempPath = path.join(realFlagDir, `.caveman-active.${process.pid}.${Date.now()}`);
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(tempPath, flags, 0o600);
|
||||
fs.writeSync(fd, String(content));
|
||||
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tempPath, realFlagPath);
|
||||
} catch (e) {
|
||||
// Silent fail — flag is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Symlink-safe, size-capped, whitelist-validated flag file read.
|
||||
// Symmetric with safeWriteFlag: refuses symlinks at the target, caps the read,
|
||||
// and rejects anything that isn't a known mode. Returns null on any anomaly.
|
||||
//
|
||||
// Without this, a local attacker with write access to ~/.claude/ could replace
|
||||
// the flag with a symlink to ~/.ssh/id_rsa (or any user-readable secret). Every
|
||||
// reader — statusline, per-turn reinforcement — would slurp that content and
|
||||
// either echo it to the terminal or inject it into model context.
|
||||
//
|
||||
// MAX_FLAG_BYTES is a hard cap. The longest legitimate value is "wenyan-ultra"
|
||||
// (12 bytes); 64 leaves slack without enabling exfil.
|
||||
const MAX_FLAG_BYTES = 64;
|
||||
|
||||
function readFlag(flagPath) {
|
||||
try {
|
||||
let st;
|
||||
try {
|
||||
st = fs.lstatSync(flagPath);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (st.isSymbolicLink() || !st.isFile()) return null;
|
||||
if (st.size > MAX_FLAG_BYTES) return null;
|
||||
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
|
||||
let fd;
|
||||
let out;
|
||||
try {
|
||||
fd = fs.openSync(flagPath, flags);
|
||||
const buf = Buffer.alloc(MAX_FLAG_BYTES);
|
||||
const n = fs.readSync(fd, buf, 0, MAX_FLAG_BYTES, 0);
|
||||
out = buf.slice(0, n).toString('utf8');
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
|
||||
const raw = out.trim().toLowerCase();
|
||||
if (!VALID_MODES.includes(raw)) return null;
|
||||
return raw;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Symlink-safe append. Same parent-dir + symlink-target rules as safeWriteFlag,
|
||||
// but opens with O_APPEND so concurrent writers from different sessions don't
|
||||
// clobber each other. Used for the lifetime stats log
|
||||
// ($CLAUDE_CONFIG_DIR/.caveman-history.jsonl).
|
||||
//
|
||||
// Silent-fails on any filesystem error.
|
||||
function appendFlag(filePath, line) {
|
||||
const debug = process.env.CAVEMAN_DEBUG === '1';
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
let realDir;
|
||||
try {
|
||||
const lstat = fs.lstatSync(dir);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
realDir = fs.realpathSync(dir);
|
||||
const realStat = fs.statSync(realDir);
|
||||
if (!realStat.isDirectory()) return;
|
||||
if (typeof process.getuid === 'function') {
|
||||
if (realStat.uid !== process.getuid()) {
|
||||
if (debug) process.stderr.write(`[caveman] appendFlag: symlink target ${realDir} owned by uid ${realStat.uid}\n`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const home = os.homedir();
|
||||
const normalized = path.resolve(realDir).toLowerCase();
|
||||
const normalizedHome = path.resolve(home).toLowerCase();
|
||||
if (!normalized.startsWith(normalizedHome + path.sep) && normalized !== normalizedHome) return;
|
||||
}
|
||||
} else {
|
||||
realDir = dir;
|
||||
}
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const realPath = path.join(realDir, path.basename(filePath));
|
||||
try {
|
||||
if (fs.lstatSync(realPath).isSymbolicLink()) return;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') return;
|
||||
}
|
||||
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | O_NOFOLLOW;
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(realPath, flags, 0o600);
|
||||
fs.writeSync(fd, String(line).replace(/\n$/, '') + '\n');
|
||||
try { fs.fchmodSync(fd, 0o600); } catch (e) { /* best-effort on Windows */ }
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail — history is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Symlink-safe history read. Returns lines (untrimmed) or empty array on any
|
||||
// anomaly. Caller is responsible for parsing JSON. Does NOT enforce a size cap
|
||||
// the way readFlag does — history is expected to grow with use.
|
||||
function readHistory(filePath) {
|
||||
try {
|
||||
const st = fs.lstatSync(filePath);
|
||||
if (st.isSymbolicLink() || !st.isFile()) return [];
|
||||
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
||||
const flags = fs.constants.O_RDONLY | O_NOFOLLOW;
|
||||
let fd;
|
||||
let raw;
|
||||
try {
|
||||
fd = fs.openSync(filePath, flags);
|
||||
raw = fs.readFileSync(fd, 'utf8');
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
return raw.split('\n').filter(line => line.trim());
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDefaultMode, getConfigDir, getConfigPath, VALID_MODES, safeWriteFlag, readFlag, appendFlag, readHistory };
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// caveman — UserPromptSubmit hook to track which caveman mode is active
|
||||
// Inspects user input for /caveman commands and writes mode to flag file
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = require('./caveman-config');
|
||||
|
||||
// Modes handled by their own slash commands (/caveman-commit, etc.) — not
|
||||
// selectable via /caveman <arg>.
|
||||
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
|
||||
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const flagPath = path.join(claudeDir, '.caveman-active');
|
||||
|
||||
let input = '';
|
||||
process.stdin.on('data', chunk => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
const prompt = (data.prompt || '').trim().toLowerCase();
|
||||
|
||||
// Natural language activation (e.g. "activate caveman", "turn on caveman mode",
|
||||
// "talk like caveman"). README tells users they can say these, but the hook
|
||||
// only matched /caveman commands — flag file and statusline stayed out of sync.
|
||||
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
|
||||
if (!/\b(stop|disable|turn off|deactivate)\b/i.test(prompt)) {
|
||||
const mode = getDefaultMode();
|
||||
if (mode !== 'off') {
|
||||
safeWriteFlag(flagPath, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /caveman-stats [--share] — block the prompt and inject stats output as
|
||||
// the hook's reason. The script reads the active session log, so we pass
|
||||
// transcript_path through when Claude Code provides it.
|
||||
const statsMatch = /^\/caveman(?::caveman)?-stats(?:\s+(.*))?$/.exec(prompt);
|
||||
if (statsMatch) {
|
||||
const tailArgs = (statsMatch[1] || '').trim().split(/\s+/).filter(Boolean);
|
||||
try {
|
||||
const statsPath = path.join(__dirname, 'caveman-stats.js');
|
||||
const argv = [statsPath];
|
||||
if (data.transcript_path) argv.push('--session-file', data.transcript_path);
|
||||
if (tailArgs.includes('--share')) argv.push('--share');
|
||||
if (tailArgs.includes('--all')) argv.push('--all');
|
||||
const sinceIdx = tailArgs.indexOf('--since');
|
||||
if (sinceIdx !== -1 && tailArgs[sinceIdx + 1]) {
|
||||
argv.push('--since', tailArgs[sinceIdx + 1]);
|
||||
}
|
||||
const out = execFileSync(process.execPath, argv, { encoding: 'utf8', timeout: 5000 });
|
||||
process.stdout.write(JSON.stringify({ decision: 'block', reason: out.trim() }));
|
||||
} catch (e) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
decision: 'block',
|
||||
reason: 'caveman-stats: could not run stats script.\nTry manually: node hooks/caveman-stats.js'
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match /caveman commands
|
||||
if (prompt.startsWith('/caveman')) {
|
||||
const parts = prompt.split(/\s+/);
|
||||
const cmd = parts[0]; // /caveman, /caveman-commit, /caveman-review, etc.
|
||||
const arg = parts[1] || '';
|
||||
|
||||
let mode = null;
|
||||
|
||||
if (cmd === '/caveman-commit') {
|
||||
mode = 'commit';
|
||||
} else if (cmd === '/caveman-review') {
|
||||
mode = 'review';
|
||||
} else if (cmd === '/caveman-compress' || cmd === '/caveman:caveman-compress') {
|
||||
mode = 'compress';
|
||||
} else if (cmd === '/caveman' || cmd === '/caveman:caveman') {
|
||||
// Bare /caveman → activate at configured default
|
||||
if (!arg) {
|
||||
mode = getDefaultMode();
|
||||
} else if (arg === 'off' || arg === 'stop' || arg === 'disable') {
|
||||
mode = 'off';
|
||||
} else if (arg === 'wenyan-full') {
|
||||
// Canonical alias — config stores as 'wenyan'
|
||||
mode = 'wenyan';
|
||||
} else if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) {
|
||||
mode = arg;
|
||||
}
|
||||
// Unknown arg → mode stays null, flag untouched (no silent overwrite)
|
||||
}
|
||||
|
||||
if (mode && mode !== 'off') {
|
||||
safeWriteFlag(flagPath, mode);
|
||||
} else if (mode === 'off') {
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect deactivation — natural language and slash commands
|
||||
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
|
||||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
|
||||
/\bnormal mode\b/i.test(prompt)) {
|
||||
try { fs.unlinkSync(flagPath); } catch (e) {}
|
||||
}
|
||||
|
||||
// Per-turn reinforcement: emit a structured reminder when caveman is active.
|
||||
// The SessionStart hook injects the full ruleset once, but models lose it
|
||||
// when other plugins inject competing style instructions every turn.
|
||||
// This keeps caveman visible in the model's attention on every user message.
|
||||
//
|
||||
// Skip independent modes (commit, review, compress) — they have their own
|
||||
// skill behavior and the base caveman rules would conflict.
|
||||
// readFlag enforces symlink-safe read + size cap + VALID_MODES whitelist.
|
||||
// If the flag is missing, corrupted, oversized, or a symlink pointing at
|
||||
// something like ~/.ssh/id_rsa, readFlag returns null and we emit nothing
|
||||
// — never inject untrusted bytes into model context.
|
||||
const activeMode = readFlag(flagPath);
|
||||
if (activeMode && !INDEPENDENT_MODES.has(activeMode)) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "UserPromptSubmit",
|
||||
additionalContext: "CAVEMAN MODE ACTIVE (" + activeMode + "). " +
|
||||
"Drop articles/filler/pleasantries/hedging. Fragments OK. " +
|
||||
"Code/commits/security: write normal."
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fail
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
// caveman-stats — read the active Claude Code session log, print real token
|
||||
// usage plus an estimated savings figure from the benchmark in benchmarks/.
|
||||
//
|
||||
// Run directly: node hooks/caveman-stats.js
|
||||
// Inside Claude: /caveman-stats triggers this via the UserPromptSubmit hook.
|
||||
// Hook integration passes --session-file <transcript_path> so we always read
|
||||
// the active session, not whichever JSONL was modified most recently.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { readFlag, appendFlag, readHistory, safeWriteFlag } = require('./caveman-config');
|
||||
|
||||
// Mean per-task savings from benchmarks/results/*.json (avg_savings: 65 across
|
||||
// 10 tasks, sonnet-4-20250514). Only 'full' has measured data; lite / ultra /
|
||||
// wenyan modes show no estimate until benchmarked. Add an entry here when a new
|
||||
// run is committed.
|
||||
const COMPRESSION = { 'full': 0.65 };
|
||||
|
||||
// Approximate Anthropic public output-token pricing, USD per million.
|
||||
// Match by model id prefix so this stays correct across point releases
|
||||
// (e.g. claude-sonnet-4-20250514, claude-sonnet-4-7). Update from
|
||||
// https://www.anthropic.com/pricing if a release changes the tier.
|
||||
const MODEL_OUTPUT_PRICE_PER_M = [
|
||||
['claude-opus-4', 75.00],
|
||||
['claude-sonnet-4', 15.00],
|
||||
['claude-haiku-4', 4.00],
|
||||
['claude-3-5-sonnet', 15.00],
|
||||
['claude-3-5-haiku', 4.00],
|
||||
['claude-3-opus', 75.00],
|
||||
];
|
||||
|
||||
function priceForModel(model) {
|
||||
if (!model) return null;
|
||||
for (const [prefix, price] of MODEL_OUTPUT_PRICE_PER_M) {
|
||||
if (model.startsWith(prefix)) return price;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatUsd(amount) {
|
||||
if (amount >= 1) return `$${amount.toFixed(2)}`;
|
||||
if (amount >= 0.01) return `$${amount.toFixed(3)}`;
|
||||
return `$${amount.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function findRecentSession(claudeDir) {
|
||||
const projectsDir = path.join(claudeDir, 'projects');
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(projectsDir, { withFileTypes: true }); }
|
||||
catch { return null; }
|
||||
|
||||
let best = null;
|
||||
const stack = entries.map(e => path.join(projectsDir, e.name));
|
||||
while (stack.length) {
|
||||
const p = stack.pop();
|
||||
let st;
|
||||
try { st = fs.statSync(p); } catch { continue; }
|
||||
if (st.isDirectory()) {
|
||||
try {
|
||||
for (const child of fs.readdirSync(p)) stack.push(path.join(p, child));
|
||||
} catch {}
|
||||
} else if (p.endsWith('.jsonl') && (!best || st.mtimeMs > best.mtime)) {
|
||||
best = { file: p, mtime: st.mtimeMs };
|
||||
}
|
||||
}
|
||||
return best ? best.file : null;
|
||||
}
|
||||
|
||||
function parseSession(filePath) {
|
||||
let raw;
|
||||
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
||||
catch { return { outputTokens: 0, cacheReadTokens: 0, turns: 0, model: null }; }
|
||||
|
||||
let outputTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
let turns = 0;
|
||||
let model = null;
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (entry.type !== 'assistant' || !entry.message) continue;
|
||||
const usage = entry.message.usage;
|
||||
if (!usage) continue;
|
||||
outputTokens += usage.output_tokens || 0;
|
||||
cacheReadTokens += usage.cache_read_input_tokens || 0;
|
||||
turns++;
|
||||
if (!model && entry.message.model) model = entry.message.model;
|
||||
}
|
||||
return { outputTokens, cacheReadTokens, turns, model };
|
||||
}
|
||||
|
||||
// Detect *.original.md / *.md pairs left behind by caveman-compress. The
|
||||
// presence of a *.original.md backup means the *.md sibling is a compressed
|
||||
// memory file — every session start reads the compressed version, so the
|
||||
// delta is per-session input-token savings (passive). Returns a summary or
|
||||
// null if nothing was found in the given dirs.
|
||||
function findCompressedPairs(dirs) {
|
||||
const pairs = [];
|
||||
for (const dir of dirs) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
||||
catch { continue; }
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.original.md')) continue;
|
||||
const base = entry.name.slice(0, -'.original.md'.length);
|
||||
const originalPath = path.join(dir, entry.name);
|
||||
const compressedPath = path.join(dir, `${base}.md`);
|
||||
let oSize, cSize;
|
||||
try {
|
||||
oSize = fs.statSync(originalPath).size;
|
||||
cSize = fs.statSync(compressedPath).size;
|
||||
} catch { continue; }
|
||||
if (oSize <= cSize) continue;
|
||||
pairs.push({ name: base, dir, originalSize: oSize, compressedSize: cSize });
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function summarizeCompressed(pairs) {
|
||||
if (!pairs || pairs.length === 0) return null;
|
||||
const totalOriginal = pairs.reduce((s, p) => s + p.originalSize, 0);
|
||||
const totalCompressed = pairs.reduce((s, p) => s + p.compressedSize, 0);
|
||||
const bytesSaved = totalOriginal - totalCompressed;
|
||||
// English prose runs ~4 chars per token. Label result as approximate so we
|
||||
// don't make claims tighter than the method warrants.
|
||||
const tokensSaved = Math.round(bytesSaved / 4);
|
||||
return { count: pairs.length, bytesSaved, tokensSaved };
|
||||
}
|
||||
|
||||
// Compute the savings figures we want to log/share for one session snapshot.
|
||||
function deriveSavings({ outputTokens, mode, model }) {
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
const price = priceForModel(model);
|
||||
if (ratio === null) return { estSavedTokens: 0, estSavedUsd: 0 };
|
||||
const estNormal = Math.round(outputTokens / (1 - ratio));
|
||||
const estSavedTokens = estNormal - outputTokens;
|
||||
const estSavedUsd = price !== null ? (estSavedTokens / 1_000_000) * price : 0;
|
||||
return { estSavedTokens, estSavedUsd };
|
||||
}
|
||||
|
||||
// Parse "7d", "12h" etc. to milliseconds. Returns null on invalid input.
|
||||
function parseDuration(spec) {
|
||||
if (!spec) return null;
|
||||
const m = /^(\d+)([dh])$/.exec(spec.trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 10);
|
||||
return m[2] === 'd' ? n * 86_400_000 : n * 3_600_000;
|
||||
}
|
||||
|
||||
// Aggregate history into latest-per-session totals, optionally filtered to a
|
||||
// time window. Returns { sessions, outputTokens, estSavedTokens, estSavedUsd }.
|
||||
function aggregateHistory(historyPath, sinceMs) {
|
||||
const lines = readHistory(historyPath);
|
||||
const cutoff = sinceMs ? Date.now() - sinceMs : null;
|
||||
const latestPerSession = new Map();
|
||||
for (const line of lines) {
|
||||
let entry;
|
||||
try { entry = JSON.parse(line); } catch { continue; }
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
if (cutoff !== null && (entry.ts || 0) < cutoff) continue;
|
||||
const id = entry.session_id || '_';
|
||||
const prev = latestPerSession.get(id);
|
||||
if (!prev || (entry.ts || 0) >= (prev.ts || 0)) latestPerSession.set(id, entry);
|
||||
}
|
||||
let outputTokens = 0, estSavedTokens = 0, estSavedUsd = 0;
|
||||
for (const e of latestPerSession.values()) {
|
||||
outputTokens += e.output_tokens || 0;
|
||||
estSavedTokens += e.est_saved_tokens || 0;
|
||||
estSavedUsd += e.est_saved_usd || 0;
|
||||
}
|
||||
return { sessions: latestPerSession.size, outputTokens, estSavedTokens, estSavedUsd };
|
||||
}
|
||||
|
||||
function humanizeTokens(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function formatHistory({ sessions, outputTokens, estSavedTokens, estSavedUsd, since }) {
|
||||
const sep = '──────────────────────────────────';
|
||||
const window = since ? ` (last ${since})` : '';
|
||||
if (sessions === 0) {
|
||||
return `\nCaveman Stats — Lifetime${window}\n${sep}\nNo sessions logged yet — run /caveman-stats inside any session to start tracking.\n${sep}\n`;
|
||||
}
|
||||
const usdLine = estSavedUsd > 0 ? `Est. saved (USD): ~${formatUsd(estSavedUsd)}\n` : '';
|
||||
return `\nCaveman Stats — Lifetime${window}\n${sep}\n` +
|
||||
`Sessions: ${sessions.toLocaleString()}\n${sep}\n` +
|
||||
`Output tokens: ${outputTokens.toLocaleString()}\n` +
|
||||
`Est. tokens saved: ${estSavedTokens.toLocaleString()}\n` +
|
||||
usdLine + sep + '\n';
|
||||
}
|
||||
|
||||
// Single-line tweetable summary. Stays human-friendly when no ratio is known.
|
||||
function formatShare({ outputTokens, turns, mode, model }) {
|
||||
if (turns === 0) {
|
||||
return '🪨 caveman armed but no turns yet — caveman.sh';
|
||||
}
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
const price = priceForModel(model);
|
||||
|
||||
if (ratio !== null) {
|
||||
const estSaved = Math.round(outputTokens / (1 - ratio)) - outputTokens;
|
||||
let usd = '';
|
||||
if (price !== null) {
|
||||
const amt = (estSaved / 1_000_000) * price;
|
||||
usd = ` (~${formatUsd(amt)})`;
|
||||
}
|
||||
return `🪨 Saved ${estSaved.toLocaleString()} output tokens${usd} across ${turns} turns this session — caveman.sh`;
|
||||
}
|
||||
return `🪨 ${turns} turns, ${outputTokens.toLocaleString()} output tokens this session — caveman.sh`;
|
||||
}
|
||||
|
||||
// Pure formatter — separated from main() so tests can pass synthetic inputs.
|
||||
function formatStats({ outputTokens, cacheReadTokens, turns, mode, model, sessionPath, compressed }) {
|
||||
const sep = '──────────────────────────────────';
|
||||
const shortPath = sessionPath && sessionPath.length > 45
|
||||
? '...' + sessionPath.slice(-45)
|
||||
: (sessionPath || '');
|
||||
|
||||
if (turns === 0) {
|
||||
return `\nCaveman Stats\n${sep}\nNo conversation yet — stats available after first response.\n${sep}\n`;
|
||||
}
|
||||
|
||||
const ratio = COMPRESSION[mode] != null ? COMPRESSION[mode] : null;
|
||||
const price = priceForModel(model);
|
||||
|
||||
let savings;
|
||||
let footer = '';
|
||||
if (ratio !== null) {
|
||||
const estNormal = Math.round(outputTokens / (1 - ratio));
|
||||
const estSaved = estNormal - outputTokens;
|
||||
let usdLine = '';
|
||||
if (price !== null) {
|
||||
const usd = (estSaved / 1_000_000) * price;
|
||||
usdLine = `Est. saved (USD): ~${formatUsd(usd)}\n`;
|
||||
footer = `Savings est. from benchmarks/ (mean per-task). Pricing for ${model}. Actual varies by task.`;
|
||||
} else {
|
||||
footer = 'Savings est. from benchmarks/ (mean per-task). Actual varies by task.';
|
||||
}
|
||||
savings = `Est. without caveman: ${estNormal.toLocaleString()}\n` +
|
||||
`Est. tokens saved: ${estSaved.toLocaleString()} (~${Math.round(ratio * 100)}%)\n` +
|
||||
usdLine.replace(/\n$/, '');
|
||||
} else if (mode && mode !== 'off') {
|
||||
savings = `No savings estimate for '${mode}' mode — only 'full' has benchmark data.`;
|
||||
} else {
|
||||
savings = 'Caveman not active this session.';
|
||||
}
|
||||
|
||||
let memoryLine = '';
|
||||
if (compressed && compressed.count > 0) {
|
||||
const tokensApprox = compressed.tokensSaved.toLocaleString();
|
||||
memoryLine = `${sep}\nMemory compressed: ${compressed.count} file${compressed.count === 1 ? '' : 's'}, ` +
|
||||
`~${tokensApprox} tokens saved per session start (approx)\n`;
|
||||
}
|
||||
|
||||
return `\nCaveman Stats\n${sep}\n` +
|
||||
(shortPath ? `Session: ${shortPath}\n` : '') +
|
||||
`Turns: ${turns}\n${sep}\n` +
|
||||
`Output tokens: ${outputTokens.toLocaleString()}\n` +
|
||||
`Cache-read tokens: ${cacheReadTokens.toLocaleString()}\n${sep}\n` +
|
||||
`${savings}\n` +
|
||||
memoryLine +
|
||||
(footer ? footer + '\n' : '');
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const i = args.indexOf('--session-file');
|
||||
const sessionFileArg = i !== -1 ? args[i + 1] : null;
|
||||
const share = args.includes('--share');
|
||||
const all = args.includes('--all');
|
||||
const sinceIdx = args.indexOf('--since');
|
||||
const sinceArg = sinceIdx !== -1 ? args[sinceIdx + 1] : null;
|
||||
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const historyPath = path.join(claudeDir, '.caveman-history.jsonl');
|
||||
|
||||
// Lifetime aggregation paths short-circuit before we need a live session.
|
||||
if (all || sinceArg) {
|
||||
const sinceMs = parseDuration(sinceArg);
|
||||
if (sinceArg && sinceMs === null) {
|
||||
process.stderr.write(`caveman-stats: --since takes Nh or Nd (e.g. 7d, 24h), got: ${sinceArg}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
const agg = aggregateHistory(historyPath, sinceMs);
|
||||
process.stdout.write(formatHistory({ ...agg, since: sinceArg || null }));
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionFile = sessionFileArg || findRecentSession(claudeDir);
|
||||
|
||||
if (!sessionFile) {
|
||||
process.stderr.write('caveman-stats: no Claude Code session found.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const parsed = parseSession(sessionFile);
|
||||
const mode = readFlag(path.join(claudeDir, '.caveman-active'));
|
||||
|
||||
// Append a snapshot of this session's totals to the lifetime log. Multiple
|
||||
// /caveman-stats calls in one session emit multiple lines for the same
|
||||
// session_id; aggregateHistory keeps only the latest per session_id.
|
||||
if (parsed.turns > 0) {
|
||||
const { estSavedTokens, estSavedUsd } = deriveSavings({ ...parsed, mode });
|
||||
const sessionId = path.basename(sessionFile, '.jsonl');
|
||||
appendFlag(historyPath, JSON.stringify({
|
||||
ts: Date.now(),
|
||||
session_id: sessionId,
|
||||
mode: mode || null,
|
||||
model: parsed.model || null,
|
||||
output_tokens: parsed.outputTokens,
|
||||
est_saved_tokens: estSavedTokens,
|
||||
est_saved_usd: estSavedUsd,
|
||||
}));
|
||||
|
||||
// Statusline suffix: tiny pre-rendered string the shell statusline can
|
||||
// cat without parsing JSONL. Updated on every /caveman-stats run.
|
||||
// Routed through safeWriteFlag — the suffix path is predictable and
|
||||
// user-owned, same symlink-clobber surface as the .caveman-active flag.
|
||||
const agg = aggregateHistory(historyPath, null);
|
||||
const suffix = agg.estSavedTokens > 0 ? `⛏ ${humanizeTokens(agg.estSavedTokens)}` : '';
|
||||
safeWriteFlag(path.join(claudeDir, '.caveman-statusline-suffix'), suffix);
|
||||
}
|
||||
|
||||
if (share) {
|
||||
process.stdout.write(formatShare({ ...parsed, mode }) + '\n');
|
||||
} else {
|
||||
const scanDirs = [claudeDir, process.cwd()].filter((d, i, a) => a.indexOf(d) === i);
|
||||
const compressed = summarizeCompressed(findCompressedPairs(scanDirs));
|
||||
process.stdout.write(formatStats({ ...parsed, mode, sessionPath: sessionFile, compressed }));
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = {
|
||||
formatStats, formatShare, formatHistory, aggregateHistory, parseDuration, deriveSavings,
|
||||
parseSession, priceForModel, formatUsd, COMPRESSION, MODEL_OUTPUT_PRICE_PER_M,
|
||||
findCompressedPairs, summarizeCompressed, humanizeTokens,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
$ClaudeDir = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $HOME ".claude" }
|
||||
$Flag = Join-Path $ClaudeDir ".caveman-active"
|
||||
if (-not (Test-Path $Flag)) { exit 0 }
|
||||
|
||||
# Refuse reparse points (symlinks / junctions) and oversized files. Without
|
||||
# this, a local attacker could point the flag at a secret file and have the
|
||||
# statusline render its bytes (including ANSI escape sequences) to the terminal
|
||||
# every keystroke.
|
||||
try {
|
||||
$Item = Get-Item -LiteralPath $Flag -Force -ErrorAction Stop
|
||||
if ($Item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { exit 0 }
|
||||
if ($Item.Length -gt 64) { exit 0 }
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
|
||||
$Mode = ""
|
||||
try {
|
||||
$Raw = Get-Content -LiteralPath $Flag -TotalCount 1 -ErrorAction Stop
|
||||
if ($null -ne $Raw) { $Mode = ([string]$Raw).Trim() }
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Strip anything outside [a-z0-9-] — blocks terminal-escape and OSC hyperlink
|
||||
# injection via the flag contents. Then whitelist-validate.
|
||||
$Mode = $Mode.ToLowerInvariant()
|
||||
$Mode = ($Mode -replace '[^a-z0-9-]', '')
|
||||
|
||||
$Valid = @('off','lite','full','ultra','wenyan-lite','wenyan','wenyan-full','wenyan-ultra','commit','review','compress')
|
||||
if (-not ($Valid -contains $Mode)) { exit 0 }
|
||||
|
||||
$Esc = [char]27
|
||||
if ([string]::IsNullOrEmpty($Mode) -or $Mode -eq "full") {
|
||||
[Console]::Write("${Esc}[38;5;172m[CAVEMAN]${Esc}[0m")
|
||||
} else {
|
||||
$Suffix = $Mode.ToUpperInvariant()
|
||||
[Console]::Write("${Esc}[38;5;172m[CAVEMAN:$Suffix]${Esc}[0m")
|
||||
}
|
||||
|
||||
# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0.
|
||||
# Reads a pre-rendered string written by caveman-stats.js. Refuses reparse
|
||||
# points and strips control bytes (matches statusline.sh hardening). Until
|
||||
# /caveman-stats has run at least once, the suffix file is absent and nothing
|
||||
# is rendered — safe default for fresh installs.
|
||||
if ($env:CAVEMAN_STATUSLINE_SAVINGS -ne "0") {
|
||||
$SavingsFile = Join-Path $ClaudeDir ".caveman-statusline-suffix"
|
||||
if (Test-Path $SavingsFile) {
|
||||
try {
|
||||
$SavingsItem = Get-Item -LiteralPath $SavingsFile -Force -ErrorAction Stop
|
||||
if (-not ($SavingsItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and
|
||||
$SavingsItem.Length -le 64) {
|
||||
$Savings = (Get-Content -LiteralPath $SavingsFile -Raw -ErrorAction Stop).TrimEnd()
|
||||
$Savings = ($Savings -replace '[\x00-\x1F]', '')
|
||||
if ($Savings.Length -gt 0) {
|
||||
[Console]::Write(" ${Esc}[38;5;172m$Savings${Esc}[0m")
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# caveman — statusline badge script for Claude Code
|
||||
# Reads the caveman mode flag file and outputs a colored badge.
|
||||
#
|
||||
# Usage in ~/.claude/settings.json:
|
||||
# "statusLine": { "type": "command", "command": "bash /path/to/caveman-statusline.sh" }
|
||||
#
|
||||
# Plugin users: Claude will offer to set this up on first session.
|
||||
# Standalone users: install.sh wires this automatically.
|
||||
|
||||
FLAG="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-active"
|
||||
|
||||
# Refuse symlinks — a local attacker could point the flag at ~/.ssh/id_rsa and
|
||||
# have the statusline render its bytes (including ANSI escape sequences) to
|
||||
# the terminal every keystroke.
|
||||
[ -L "$FLAG" ] && exit 0
|
||||
[ ! -f "$FLAG" ] && exit 0
|
||||
|
||||
# Hard-cap the read at 64 bytes and strip anything outside [a-z0-9-] — blocks
|
||||
# terminal-escape injection and OSC hyperlink spoofing via the flag contents.
|
||||
MODE=$(head -c 64 "$FLAG" 2>/dev/null | tr -d '\n\r' | tr '[:upper:]' '[:lower:]')
|
||||
MODE=$(printf '%s' "$MODE" | tr -cd 'a-z0-9-')
|
||||
|
||||
# Whitelist. Anything else → render nothing rather than echo attacker bytes.
|
||||
case "$MODE" in
|
||||
off|lite|full|ultra|wenyan-lite|wenyan|wenyan-full|wenyan-ultra|commit|review|compress) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ -z "$MODE" ] || [ "$MODE" = "full" ]; then
|
||||
printf '\033[38;5;172m[CAVEMAN]\033[0m'
|
||||
else
|
||||
SUFFIX=$(printf '%s' "$MODE" | tr '[:lower:]' '[:upper:]')
|
||||
printf '\033[38;5;172m[CAVEMAN:%s]\033[0m' "$SUFFIX"
|
||||
fi
|
||||
|
||||
# Savings suffix: on by default. Opt out via CAVEMAN_STATUSLINE_SAVINGS=0.
|
||||
# Reads a pre-rendered string written by caveman-stats.js so we don't shell out
|
||||
# to node on every keystroke. Refuses symlinks and strips control bytes —
|
||||
# same hardening as the flag file (a local attacker could plant a file with
|
||||
# ANSI escape codes otherwise). Until /caveman-stats has run at least once,
|
||||
# the suffix file is absent and nothing is rendered — so the default is safe
|
||||
# for fresh installs (no fake number, no crash).
|
||||
if [ "${CAVEMAN_STATUSLINE_SAVINGS:-1}" != "0" ]; then
|
||||
SAVINGS_FILE="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.caveman-statusline-suffix"
|
||||
if [ -f "$SAVINGS_FILE" ] && [ ! -L "$SAVINGS_FILE" ]; then
|
||||
SAVINGS=$(head -c 64 "$SAVINGS_FILE" 2>/dev/null | tr -d '\000-\037')
|
||||
[ -n "$SAVINGS" ] && printf ' \033[38;5;172m%s\033[0m' "$SAVINGS"
|
||||
fi
|
||||
fi
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// context-mode plugin cache self-heal (auto-deployed)
|
||||
// Fixes anthropics/claude-code#46915: auto-update breaks CLAUDE_PLUGIN_ROOT
|
||||
// Issue #727: also normalizes stale version paths in existing installPaths
|
||||
// Honors CLAUDE_CONFIG_DIR (#577) — checked at this script's runtime so users
|
||||
// who set CLAUDE_CONFIG_DIR after install still get healed correctly.
|
||||
// Pure Node.js — no bash/shell dependency.
|
||||
import{existsSync,readdirSync,statSync,symlinkSync,lstatSync,unlinkSync,readFileSync}from"node:fs";
|
||||
import{dirname,join,resolve,sep}from"node:path";
|
||||
import{homedir}from"node:os";
|
||||
function cfgDir(){const e=process.env.CLAUDE_CONFIG_DIR;if(e&&e.trim()!==""){return e.startsWith("~")?resolve(homedir(),e.replace(/^~[/\\]?/,"")):resolve(e)}return resolve(homedir(),".claude")}
|
||||
try{
|
||||
const f=resolve(cfgDir(),"plugins","installed_plugins.json");
|
||||
if(!existsSync(f))process.exit(0);
|
||||
const cacheRoot=resolve(cfgDir(),"plugins","cache");
|
||||
const ip=JSON.parse(readFileSync(f,"utf-8"));
|
||||
for(const[k,es]of Object.entries(ip.plugins||{})){
|
||||
if(k!=="context-mode@context-mode")continue;
|
||||
for(const e of es){
|
||||
const p=e.installPath;
|
||||
if(!p)continue;
|
||||
if(!resolve(p).startsWith(cacheRoot+sep))continue;
|
||||
if(existsSync(p)){
|
||||
// Issue #727: normalize stale version paths in existing installPaths.
|
||||
// CC's auto-update can carry forward hooks.json/plugin.json with paths
|
||||
// baked to a previous version dir. Import normalize-hooks from the
|
||||
// installPath itself and let it detect + rewrite stale segments.
|
||||
try{
|
||||
// #713: narrow helper only — installPath belongs to a different
|
||||
// version's cache dir; writing plugin.json there is the #711 vector.
|
||||
const nhPath=join(p,"hooks","normalize-hooks.mjs");
|
||||
if(existsSync(nhPath)){
|
||||
const mod=await import(nhPath);
|
||||
const fn=mod.normalizeHooksJsonOnly||mod.normalizeHooksOnStartup;
|
||||
if(fn)fn({pluginRoot:p,nodePath:process.execPath,platform:process.platform});
|
||||
}
|
||||
}catch{}
|
||||
continue;
|
||||
}
|
||||
const parent=dirname(p);
|
||||
if(!existsSync(parent))continue;
|
||||
try{if(lstatSync(p).isSymbolicLink())unlinkSync(p)}catch{}
|
||||
const dirs=readdirSync(parent).filter(d=>/^\d+\.\d+/.test(d)&&statSync(join(parent,d)).isDirectory());
|
||||
if(!dirs.length)continue;
|
||||
dirs.sort((a,b)=>{const pa=a.split(".").map(Number),pb=b.split(".").map(Number);for(let i=0;i<3;i++){if((pa[i]||0)!==(pb[i]||0))return(pa[i]||0)-(pb[i]||0)}return 0});
|
||||
try{symlinkSync(join(parent,dirs[dirs.length-1]),p,process.platform==="win32"?"junction":undefined)}catch{}
|
||||
}
|
||||
}
|
||||
}catch{}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: context7-mcp
|
||||
description: This skill should be used when the user asks about libraries, frameworks, API references, or needs code examples. Activates for setup questions, code generation involving libraries, or mentions of specific frameworks like React, Vue, Next.js, Prisma, Supabase, etc.
|
||||
---
|
||||
|
||||
When the user asks about libraries, frameworks, or needs code examples, use Context7 to fetch current documentation instead of relying on training data.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Activate this skill when the user:
|
||||
|
||||
- Asks setup or configuration questions ("How do I configure Next.js middleware?")
|
||||
- Requests code involving libraries ("Write a Prisma query for...")
|
||||
- Needs API references ("What are the Supabase auth methods?")
|
||||
- Mentions specific frameworks (React, Vue, Svelte, Express, Tailwind, etc.)
|
||||
|
||||
## How to Fetch Documentation
|
||||
|
||||
### Step 1: Resolve the Library ID
|
||||
|
||||
Call `resolve-library-id` with:
|
||||
|
||||
- `libraryName`: The library name extracted from the user's question
|
||||
- `query`: What to look up in the library's documentation (improves relevance ranking)
|
||||
|
||||
### Step 2: Select the Best Match
|
||||
|
||||
From the resolution results, choose based on:
|
||||
|
||||
- Exact or closest name match to what the user asked for
|
||||
- Higher benchmark scores indicate better documentation quality
|
||||
- If the user mentioned a version (e.g., "React 19"), prefer version-specific IDs
|
||||
|
||||
### Step 3: Fetch the Documentation
|
||||
|
||||
Call `query-docs` with:
|
||||
|
||||
- `libraryId`: The selected Context7 library ID (e.g., `/vercel/next.js`)
|
||||
- `query`: What to look up in the library's documentation, scoped to a single concept
|
||||
|
||||
If the user's question spans multiple distinct concepts (e.g. routing and auth and caching), make a separate `query-docs` call per concept with the same library ID, unless the question is about how the concepts interact — combined queries dilute ranking and return shallow results for each topic.
|
||||
|
||||
### Step 4: Use the Documentation
|
||||
|
||||
Incorporate the fetched documentation into your response:
|
||||
|
||||
- Answer the user's question using current, accurate information
|
||||
- Include relevant code examples from the docs
|
||||
- Cite the library version when relevant
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Be specific**: Describe what to look up in the library's documentation, but keep each query to a single concept
|
||||
- **One topic per query**: Split multi-topic questions into separate `query-docs` calls — resolve the library ID once, then query per concept, unless the question is about how the concepts interact
|
||||
- **Version awareness**: When users mention versions ("Next.js 15", "React 19"), use version-specific library IDs if available from the resolution step
|
||||
- **Prefer official sources**: When multiple matches exist, prefer official/primary packages over community forks
|
||||
@@ -0,0 +1,415 @@
|
||||
---
|
||||
name: jujutsu
|
||||
description: "**REQUIRED** - Always activate FIRST on any git/VCS operations (commit, status, branch, push, etc.), especially when HEAD is detached. If `.jj/` exists -> this is a Jujutsu (jj) repo - raw git commands can corrupt data. Essential git safety instructions inside. DO NOT IGNORE."
|
||||
allowed-tools: Bash(jj *)
|
||||
---
|
||||
|
||||
# Jujutsu (jj) Version Control System
|
||||
|
||||
This skill helps you work with Jujutsu, a Git-compatible VCS with mutable commits and automatic rebasing.
|
||||
|
||||
**Tested with jj v0.37.0** - Commands may differ in other versions.
|
||||
|
||||
## Important: Automated/Agent Environment
|
||||
|
||||
When running as an agent:
|
||||
|
||||
1. **Always use `--no-pager`** to prevent commands from opening an interactive pager (like `less`), which will hang the agent:
|
||||
|
||||
```bash
|
||||
# Always use --no-pager on commands that produce output
|
||||
jj --no-pager log # NOT: jj log
|
||||
jj --no-pager diff # NOT: jj diff
|
||||
jj --no-pager show <id> # NOT: jj show <id>
|
||||
```
|
||||
|
||||
2. **Always use `-m` flags** to provide messages inline rather than relying on editor prompts:
|
||||
|
||||
```bash
|
||||
# Always use -m to avoid editor prompts
|
||||
jj desc -m "message" # NOT: jj desc
|
||||
jj squash -m "message" # NOT: jj squash (which opens editor)
|
||||
```
|
||||
|
||||
Editor-based commands will fail in non-interactive environments.
|
||||
|
||||
3. **Verify operations with `jj st`** after mutations (`squash`, `abandon`, `rebase`, `restore`) to confirm the operation succeeded.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### The Working Copy is a Commit
|
||||
|
||||
In jj, your working directory is always a commit (referenced as `@`). Changes are automatically snapshotted when you run any jj command. There is no staging area.
|
||||
|
||||
There is no need to run `jj commit`.
|
||||
|
||||
### Commits Are Mutable
|
||||
|
||||
**CRITICAL**: Unlike git, jj commits can be freely modified after creation. You can update descriptions, squash changes, rebase, and absorb — all without creating new commits. See "Essential Workflow" below for the recommended working pattern.
|
||||
|
||||
### Change IDs vs Commit IDs
|
||||
|
||||
- **Change ID**: A stable identifier (like `tqpwlqmp`) that persists when a commit is rewritten — prefer these when referencing commits
|
||||
- **Commit ID**: A content hash (like `3ccf7581`) that changes when commit content changes
|
||||
|
||||
### Revsets
|
||||
|
||||
jj uses a revset language to select commits in commands. Common revsets:
|
||||
|
||||
- `@` — the working copy commit
|
||||
- `@-` — the parent of the working copy
|
||||
- `::@` — all ancestors of `@`
|
||||
- `@::` — all descendants of `@`
|
||||
- `trunk()..@` — commits between trunk and `@` (your branch)
|
||||
- `bookmarks()` — all commits with bookmarks
|
||||
|
||||
Use revsets with `-r` flags: `jj log -r 'trunk()..@'`
|
||||
|
||||
## Essential Workflow
|
||||
|
||||
### Starting Work: Describe First, Then Code
|
||||
|
||||
**Always create your commit message before writing code:**
|
||||
|
||||
Validate that you're on a blank revision with `jj st`. If you are not, you should type:
|
||||
|
||||
```bash
|
||||
jj new
|
||||
```
|
||||
|
||||
```bash
|
||||
# First, describe what you intend to do
|
||||
jj desc -m "Add user authentication to login endpoint"
|
||||
|
||||
# Then make your changes - they automatically become part of this commit
|
||||
# ... edit files ...
|
||||
|
||||
# Check status
|
||||
jj st
|
||||
```
|
||||
|
||||
### Creating Atomic Commits
|
||||
|
||||
Each commit should represent ONE logical change. Use this format for commit messages:
|
||||
|
||||
```
|
||||
Examples:
|
||||
- "Add validation to user input forms"
|
||||
- "Fix null pointer in payment processor"
|
||||
- "Remove deprecated API endpoints"
|
||||
- "Update dependencies to latest versions"
|
||||
```
|
||||
|
||||
### Viewing History
|
||||
|
||||
```bash
|
||||
# View recent commits
|
||||
jj --no-pager log
|
||||
|
||||
# View with patches
|
||||
jj --no-pager log -p
|
||||
|
||||
# View specific commit
|
||||
jj --no-pager show <change-id>
|
||||
|
||||
# View diff of working copy (use --git for familiar +/- format)
|
||||
jj --no-pager diff --git
|
||||
```
|
||||
|
||||
**IMPORTANT: `jj diff` output format**: The default `jj diff` output uses a side-by-side line number format (e.g. `26 26:`) that looks very different from git's `+`/`-` prefix format. This is **normal and correct** — it is NOT corrupted or showing stale content. However, to avoid confusion, **always use `jj diff --git`** to get standard unified diff format with `+`/`-` lines.
|
||||
|
||||
### Moving Between Commits
|
||||
|
||||
```bash
|
||||
# Create a new empty commit on top of current
|
||||
jj new
|
||||
|
||||
# Create new commit with message
|
||||
jj new -m "Commit message"
|
||||
|
||||
# Edit an existing commit (working copy becomes that commit)
|
||||
jj edit <change-id>
|
||||
|
||||
# Edit the previous commit
|
||||
jj prev -e
|
||||
|
||||
# Edit the next commit
|
||||
jj next -e
|
||||
```
|
||||
|
||||
## Refining Commits
|
||||
|
||||
### Squashing Changes
|
||||
|
||||
Move changes from current commit into its parent:
|
||||
|
||||
```bash
|
||||
# Squash all changes into parent
|
||||
jj squash
|
||||
```
|
||||
|
||||
**Note**: `jj squash -i` opens an interactive UI and will hang in agent environments. Avoid it.
|
||||
|
||||
### Splitting Commits
|
||||
|
||||
**Warning**: `jj split` is interactive and will hang in agent environments. To divide a commit, use `jj restore` to move changes out, then create separate commits manually.
|
||||
|
||||
### Absorbing Changes
|
||||
|
||||
Automatically distribute changes to the commits that last modified those lines:
|
||||
|
||||
```bash
|
||||
# Absorb working copy changes into appropriate ancestor commits
|
||||
jj absorb
|
||||
```
|
||||
|
||||
### Abandoning Commits
|
||||
|
||||
Remove a commit entirely (descendants are rebased to its parent):
|
||||
|
||||
```bash
|
||||
jj abandon <change-id>
|
||||
```
|
||||
|
||||
### Undoing Operations
|
||||
|
||||
Reverse the last jj operation:
|
||||
|
||||
```bash
|
||||
jj undo
|
||||
```
|
||||
|
||||
This reverts the repository to its state before the previous command. Useful for recovering from mistakes like accidental `abandon`, `squash`, or `rebase`.
|
||||
|
||||
### Rebasing Commits
|
||||
|
||||
Move commits to a different parent:
|
||||
|
||||
```bash
|
||||
# Rebase current branch onto a destination
|
||||
jj rebase -d <destination>
|
||||
|
||||
# Rebase a specific revision (without descendants) onto a destination
|
||||
jj rebase -r <change-id> -d <destination>
|
||||
|
||||
# Rebase a revision and all its descendants
|
||||
jj rebase -s <change-id> -d <destination>
|
||||
|
||||
# Rebase onto trunk (common: update your branch to latest main)
|
||||
jj rebase -d main
|
||||
```
|
||||
|
||||
### Restoring Files
|
||||
|
||||
Discard changes to specific files or restore files from another revision:
|
||||
|
||||
```bash
|
||||
# Discard all uncommitted changes in working copy (restore from parent)
|
||||
jj restore
|
||||
|
||||
# Discard changes to specific files
|
||||
jj restore path/to/file.txt
|
||||
|
||||
# Restore files from a specific revision
|
||||
jj restore --from <change-id> path/to/file.txt
|
||||
```
|
||||
|
||||
## Working with Bookmarks (Branches)
|
||||
|
||||
Bookmarks are jj's equivalent to git branches:
|
||||
|
||||
```bash
|
||||
# Create a bookmark at current commit
|
||||
jj bookmark create my-feature -r@
|
||||
|
||||
# Move bookmark to a different commit
|
||||
jj bookmark move my-feature --to <change-id>
|
||||
|
||||
# List bookmarks
|
||||
jj --no-pager bookmark list
|
||||
|
||||
# Delete a bookmark
|
||||
jj bookmark delete my-feature
|
||||
```
|
||||
|
||||
## Workspaces
|
||||
|
||||
A **workspace** is a working copy plus its associated repo. One repo can have multiple workspaces — each with its own working directory and working-copy commit (`@`) — all sharing the same commits, operations, and bookmarks. This is jj's equivalent of `git worktree`.
|
||||
|
||||
Useful for running a long build or test in one workspace while editing in another. Workspaces are a rarely-needed feature; consult the [official docs](https://docs.jj-vcs.dev/latest/working-copy/#workspaces) for anything beyond the basics below.
|
||||
|
||||
### Common commands
|
||||
|
||||
```bash
|
||||
# Create a new workspace (defaults: name = basename of path, parent = current @'s parent)
|
||||
jj workspace add ../my-tests
|
||||
jj workspace add --name tests -r <change-id> ../my-tests # explicit name and base
|
||||
|
||||
# Inspect
|
||||
jj --no-pager workspace list
|
||||
jj workspace root [--name <ws>]
|
||||
|
||||
# Remove (does NOT delete files on disk — rm the directory separately)
|
||||
jj workspace forget [<ws>]
|
||||
|
||||
# Rename current workspace
|
||||
jj workspace rename <new-name>
|
||||
```
|
||||
|
||||
In `jj log`, each workspace's `@` appears as `<workspace-name>@`.
|
||||
|
||||
### Key semantics
|
||||
|
||||
- **Isolation by default.** `jj workspace add` gives the new workspace its own fresh empty commit; workspaces don't start out sharing `@`, and on-disk files are never live-mirrored between them.
|
||||
- **Propagation at command boundaries.** Each jj command snapshots the current workspace's files and reads the op log, so it sees commits/bookmarks made by other workspaces. There is no filesystem watcher.
|
||||
- **Stale working copy.** If another workspace rewrites this workspace's `@` (e.g. via `jj squash`, `rebase`, `abandon`), jj refuses commands here until you run `jj workspace update-stale`. Same recovery path if a command was interrupted mid-update.
|
||||
- **Shared `@` is sharp-edged.** `jj edit <id>` lets two workspaces point at the same change without warning. When one mutates it, the other goes stale; if the stale one had un-snapshotted edits, `update-stale` preserves them as a **divergent commit** (same change ID, shown as `xyz??` in `jj log`) that you must resolve. Avoid sharing `@` unless both workspaces are read-only.
|
||||
|
||||
### Agent guidance
|
||||
|
||||
- Always pass `--no-pager` to `jj workspace list`.
|
||||
- Don't `jj edit` a change another workspace already has as its `@` — main cause of accidental divergence.
|
||||
- Don't `rm -rf` a workspace directory without also running `jj workspace forget <name>`.
|
||||
|
||||
## Git Integration
|
||||
|
||||
### Working with Existing Git Repos
|
||||
|
||||
```bash
|
||||
# Clone a git repository
|
||||
jj git clone <url>
|
||||
|
||||
# Initialize jj in an existing git repo
|
||||
jj git init --colocate
|
||||
```
|
||||
|
||||
### Fetching Remote Changes
|
||||
|
||||
```bash
|
||||
# Fetch all branches from the default remote
|
||||
jj git fetch
|
||||
|
||||
# Fetch from a specific remote
|
||||
jj git fetch --remote <remote-name>
|
||||
|
||||
# Fetch specific branches
|
||||
jj git fetch -b <branch-name>
|
||||
```
|
||||
|
||||
After fetching, rebase your work onto the updated trunk: `jj rebase -d main`
|
||||
|
||||
### Switching Between jj and git (Colocated Repos Only)
|
||||
|
||||
**This section only applies to colocated repos** (where both `.jj/` and `.git/` exist). In non-colocated repos, do not use git commands — they will corrupt jj state.
|
||||
|
||||
In a colocated repository, you can use both jj and git commands with care:
|
||||
|
||||
**Switching to git mode** (e.g., for merge workflows):
|
||||
```bash
|
||||
# First, ensure your jj working copy is clean
|
||||
jj st
|
||||
|
||||
# Then checkout a branch with git
|
||||
git checkout <branch-name>
|
||||
```
|
||||
|
||||
**Switching back to jj mode**:
|
||||
```bash
|
||||
# Use jj edit to resume working with jj
|
||||
jj edit <change-id>
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
- Git may complain about uncommitted changes if jj's working copy differs from the git HEAD
|
||||
- ALWAYS ensure your work is committed in jj before switching to git
|
||||
- After git operations, jj will detect and incorporate the changes on next command
|
||||
|
||||
### Pushing Changes
|
||||
|
||||
When the user asks you to push changes:
|
||||
|
||||
```bash
|
||||
# Push a specific bookmark to the remote
|
||||
jj git push -b <bookmark-name>
|
||||
|
||||
# Example: push the main bookmark
|
||||
jj git push -b main
|
||||
```
|
||||
|
||||
**Before pushing, ensure:**
|
||||
1. Your bookmark points to the correct commit (bookmarks don't auto-advance like git branches)
|
||||
2. The commits are refined and atomic
|
||||
3. The user has explicitly requested the push
|
||||
|
||||
**IMPORTANT**: Unlike git branches, jj bookmarks do not automatically move when you create new commits. You must manually update them before pushing:
|
||||
|
||||
```bash
|
||||
# Move an existing bookmark to the current commit
|
||||
jj bookmark move my-feature --to @
|
||||
|
||||
# Then push it
|
||||
jj git push -b my-feature
|
||||
```
|
||||
|
||||
If no bookmark exists for your changes, create one first:
|
||||
|
||||
```bash
|
||||
# Create a bookmark at the current commit
|
||||
jj bookmark create my-feature
|
||||
|
||||
# Then push it
|
||||
jj git push -b my-feature
|
||||
```
|
||||
|
||||
## Handling Conflicts
|
||||
|
||||
jj allows committing conflicts — you can resolve them later:
|
||||
|
||||
```bash
|
||||
# View conflicts
|
||||
jj st
|
||||
```
|
||||
|
||||
**Agent conflict resolution**: Do not use `jj resolve` (interactive). Instead, edit the conflicted files directly to remove conflict markers, then run `jj st` to verify resolution.
|
||||
|
||||
## Preserving Commit Quality
|
||||
|
||||
**IMPORTANT**: Because commits are mutable, always refine them before considering work done:
|
||||
|
||||
1. **Review your commit**: `jj --no-pager show @` or `jj --no-pager diff --git`
|
||||
2. **Is it atomic?** One logical change per commit
|
||||
3. **Is the message clear?** Use imperative verb phrase in sentence case format with no full stop: e.g. "Add login endpoint", "Fix null pointer in payment processor", "Remove deprecated API endpoints"
|
||||
4. **Are there unrelated changes?** Use `jj restore` to move changes out, then create separate commits
|
||||
5. **Should changes be elsewhere?** Use `jj squash` or `jj absorb`
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Action | Command |
|
||||
|--------|---------|
|
||||
| Describe commit | `jj desc -m "message"` |
|
||||
| View status | `jj st` |
|
||||
| View log | `jj --no-pager log` |
|
||||
| View diff | `jj --no-pager diff --git` |
|
||||
| New commit | `jj new -m "message"` (use `jj st` first; skip if `@` is empty) |
|
||||
| Edit commit | `jj edit <id>` |
|
||||
| Squash to parent | `jj squash` |
|
||||
| Auto-distribute | `jj absorb` |
|
||||
| Rebase | `jj rebase -d <destination>` |
|
||||
| Abandon commit | `jj abandon <id>` |
|
||||
| Undo last operation | `jj undo` |
|
||||
| Restore files | `jj restore [paths]` |
|
||||
| Create bookmark | `jj bookmark create <name>` |
|
||||
| Fetch remote | `jj git fetch` |
|
||||
| Push bookmark | `jj git push -b <name>` |
|
||||
| Add workspace | `jj workspace add <path>` |
|
||||
| List workspaces | `jj --no-pager workspace list` |
|
||||
| Forget workspace | `jj workspace forget [name]` |
|
||||
| Fix stale working copy | `jj workspace update-stale` |
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
1. **Describe first**: Set the commit message before coding
|
||||
2. **One change per commit**: Keep commits atomic and focused
|
||||
3. **Use change IDs**: They're stable across rewrites
|
||||
4. **Refine commits**: Leverage mutability for clean history
|
||||
5. **Embrace the workflow**: No staging area, no stashing - just commits
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: vikunja-backlog
|
||||
description: Use when you have a brainstorming, design, discussion, meeting-notes, or specification document (any format — md, txt, pdf, docx, odt) and want it turned into Vikunja backlog tasks under the "sliders, not checkboxes" feature-category planning model at tasks.mroberts.dev.
|
||||
---
|
||||
|
||||
# Vikunja Backlog From a Document
|
||||
|
||||
## Overview
|
||||
|
||||
Turn a discussion/design/brainstorming document into Vikunja tasks using the
|
||||
**"Sliders, Not Checkboxes"** framework: work is organized by **feature
|
||||
category** (the project), each item is restated as the **need** it addresses
|
||||
(the task title), the proposed solution lives in the description, and value is
|
||||
expressed as **priority** (rank within the category).
|
||||
|
||||
The mapping and the 8 categories are the contract. Read
|
||||
[reference/sliders-framework.md](reference/sliders-framework.md) before
|
||||
classifying — it defines each category, the need-vs-solution discipline, and
|
||||
the priority/label rules.
|
||||
|
||||
**Core principle:** the title is the *need*, never the solution. "Limit a
|
||||
compromised container's blast radius", not "add read-only rootfs".
|
||||
|
||||
## When to Use
|
||||
|
||||
- You have a doc (any format) describing work, decisions, or a backlog and want
|
||||
it in Vikunja.
|
||||
- Symptoms: a design spec, meeting notes, a brainstorm, a PR/backlog writeup, a
|
||||
Slack/email dump pasted into a file.
|
||||
|
||||
Not for: editing tasks one-off (use the API directly), or non-planning docs.
|
||||
|
||||
## Object Mapping (the contract)
|
||||
|
||||
| Framework concept | Vikunja object |
|
||||
|---|---|
|
||||
| Feature category | **Project** (child of the root `CareEvolution` project) |
|
||||
| Candidate item | **Task** |
|
||||
| Need | Task **title** (phrased as the goal) |
|
||||
| Solution(s) + source | Task **description** |
|
||||
| Value rank within category | Task **priority** (1–4) |
|
||||
| Urgency / returns | Label `acute` / `diminishing` |
|
||||
| Platform | Label (e.g. `Orchestrate`) |
|
||||
| Deployment / repo | Label (e.g. `Rosetta`) |
|
||||
|
||||
The same task viewed by **project** = cross-deployment value lens; filtered by
|
||||
**deployment label** = per-repo execution lens (one dataset, many views).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Extract** the doc text:
|
||||
`scripts/extract.py <path>` (handles md/txt/pdf/docx/odt → stdout).
|
||||
2. **Analyze** the text. Pull out candidate items. For each, restate the
|
||||
**need** (what the user/admin/dev is trying to accomplish), and capture the
|
||||
proposed **solution(s)** separately. Discard pure discussion that isn't a
|
||||
candidate. See the framework reference for the need-restatement discipline.
|
||||
3. **Classify** each need into exactly one of the 8 categories. Borderline
|
||||
items: pick the category of the *primary value*, note the alternative in the
|
||||
description.
|
||||
4. **Rank** — assign a priority (4 = most acute/frequent/broad … 1 = small/late
|
||||
on the curve) reflecting value *within its category*, not build effort. Tag
|
||||
`acute` / `diminishing` per the reference rules.
|
||||
5. **Tag platform + deployment.** Infer the platform (tag) and deployment(s)
|
||||
(label) from the doc, then **confirm/override with the user** before
|
||||
creating. If a doc spans several deployments and granularity is "per
|
||||
deployment", emit one task per (need × deployment).
|
||||
6. **Propose, then create.** Present the full breakdown as a table
|
||||
(need | category | priority | labels | done?). Get the user's approval/edits.
|
||||
**Do not create anything before approval.**
|
||||
7. On approval, write the approved tasks to a JSON file and run
|
||||
`scripts/vikunja.py create --file <tasks.json>`. It idempotently ensures the
|
||||
8 category projects + labels exist, creates missing labels, skips duplicates,
|
||||
and handles the done-priority gotcha.
|
||||
8. **Report** created task IDs and the view hints above.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/extract.py <path>` — extract plain text from md/txt/pdf/docx/odt.
|
||||
- `scripts/vikunja.py ensure` — idempotently create the 8 category projects +
|
||||
`acute`/`diminishing` labels under `CareEvolution`. Prints name→id maps.
|
||||
- `scripts/vikunja.py projects` / `labels` — list current ids.
|
||||
- `scripts/vikunja.py create --file <tasks.json>` — create tasks. Run `--help`
|
||||
for the JSON schema and all flags.
|
||||
|
||||
Auth: reads `VIKUNJA_API_KEY` from the environment. Base URL defaults to
|
||||
`https://tasks.mroberts.dev/api/v1` (`VIKUNJA_BASE_URL` overrides); root project
|
||||
defaults to `CareEvolution` (`VIKUNJA_ROOT_PROJECT` overrides).
|
||||
|
||||
## Tasks JSON schema
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"category": "Security, privacy & compliance",
|
||||
"title": "Limit a compromised container's runtime blast radius",
|
||||
"description": "**Need:** ...\n\n**Solution:** ...\n\n**Source:** doc.md",
|
||||
"priority": 4,
|
||||
"labels": ["Orchestrate", "Rosetta", "acute"],
|
||||
"done": false,
|
||||
"percent": 0
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`category` must be one of the 8 exact names (see reference). `labels` are
|
||||
created if missing. `done`/`percent` optional.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- **Title is the solution, not the need.** "Add Dependabot auto-merge" → wrong.
|
||||
"Stay patched with less manual toil" → right; Dependabot goes in the body.
|
||||
- **Priority = build effort.** No. Priority = value of the need within its
|
||||
category. A cheap rank-1 outranks an expensive rank-6.
|
||||
- **Creating before approval.** The framework's whole point is the judgment
|
||||
call on need + category + rank. Always propose first.
|
||||
- **Re-running creates duplicates.** `create` dedupes on (title + category +
|
||||
deployment labels); don't bypass it with ad-hoc API calls.
|
||||
- **Mark-done zeros priority.** The Vikunja update replaces the task; `create`
|
||||
re-sends priority on the done update. Don't hand-roll the done call.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Sliders, Not Checkboxes — classification reference
|
||||
|
||||
The framework models work as a small set of **feature categories**. A backlog
|
||||
item is restated as the **need** it addresses (not the proposed solution), and
|
||||
needs are value-ranked within each category. Source: the "Sliders, Not
|
||||
Checkboxes" paper (CareEvolution).
|
||||
|
||||
## The need-restatement discipline
|
||||
|
||||
Before an item enters a category, restate it as the user/admin/developer
|
||||
**need** — what they are trying to accomplish — and treat the proposed solution
|
||||
as *one way* to address it.
|
||||
|
||||
- A doc says "build a self-serve provisioning UI." The **need** is "add/remove a
|
||||
user without filing a support ticket." The UI is one solution; SCIM or a
|
||||
declarative API are others. Title = the need; list the candidate solutions in
|
||||
the description.
|
||||
- Ranking reflects what addressing the need means for the people who hold it:
|
||||
**how often** it's hit, **how acute** the pain, **how broadly** held. Not what
|
||||
is easiest to build or most recently requested.
|
||||
- Discussion, debate, and context that isn't a candidate item → do not create a
|
||||
task. Capture only genuine candidate needs.
|
||||
|
||||
## The 8 feature categories
|
||||
|
||||
Classify each need into exactly one. Pick the category of the **primary value**;
|
||||
note a close alternative in the description.
|
||||
|
||||
| Category (use this exact name) | What it covers | Whose need |
|
||||
|---|---|---|
|
||||
| **End-user experience** | User-facing product: features, flows, learnability, polish | People using the front-end to do their own tasks |
|
||||
| **Admin & operator experience** | Configuration, governance, monitoring, operational tooling | Customer-side admins / IT staff deploying & overseeing the product |
|
||||
| **Developer experience & adoption** | API design, SDKs, docs, sandbox, time-to-first-call; community, mindshare | Developers integrating against or building on the product |
|
||||
| **Data, integrations & ecosystem** | Data quality, lineage, connectors, standards conformance (FHIR, HL7, OAuth, OpenAPI), marketplace, partners | Integrating systems and partner platforms |
|
||||
| **Performance & scale** | Latency, throughput, behavior under load | Anyone depending on the product being fast and handling their volume |
|
||||
| **Reliability & observability** | Uptime, fault tolerance, graceful degradation, recovery; logs, metrics, traces, alerting, runbooks | Customers needing it up; operators keeping it up |
|
||||
| **Security, privacy & compliance** | Authn/authz, encryption, audit logging, certifications, regulatory posture, attack-surface reduction, vulnerability/CVE management, supply-chain integrity, runtime threat detection | Security & compliance teams; regulators; users trusting the platform |
|
||||
| **Cost efficiency** | Cost per unit work — per record, per API call, per active user; compute/arch cost (e.g. ARM64/Graviton) | Internal P&L; customers indirectly via pricing |
|
||||
|
||||
### Classification hints / common overlaps
|
||||
|
||||
- **Hardening** (read-only rootfs, drop caps, minimal/chiseled base images, image
|
||||
CVE scanning, SHA-pinning CI actions, dependency patch currency, supply-chain
|
||||
inventory, runtime threat detection) → **Security, privacy & compliance**.
|
||||
- **ARM64/Graviton migration, smaller images for cost** → **Cost efficiency**
|
||||
(its perf side can be **Performance & scale** — pick the primary driver named
|
||||
in the doc).
|
||||
- **Perf vs cost** are often two sides of one coin — categorize by the value the
|
||||
doc emphasizes (latency/throughput → Performance; $/unit → Cost).
|
||||
- **A correctness/data-race bug surfaced by infra work** → **Reliability &
|
||||
observability** (it's about the service being correct/up), not Security.
|
||||
- **CI/CD integrity** (pinning actions, blocking tampering) → Security; **CI
|
||||
ergonomics/speed for devs** → Developer experience. Pick by the value.
|
||||
- **Runtime anomaly detection** (GuardDuty etc.) → Security (threat detection)
|
||||
even though it touches observability.
|
||||
|
||||
## Value rank → priority
|
||||
|
||||
Vikunja priority is 1–5. Use **1–4** for value tiers (reserve 5 for true
|
||||
DO-NOW). Rank within the category, by value of the need:
|
||||
|
||||
| Priority | Meaning |
|
||||
|---|---|
|
||||
| **4** | Rank-1 tier: most acute / frequent / broadly held; foundational. Everything else in the category is downstream of it. |
|
||||
| **3** | High value, clearly worth doing, but not the keystone. |
|
||||
| **2** | Medium: real need, smaller delta over the status quo. |
|
||||
| **1** | Small / late-on-the-curve: refinement, niche, or largely satisfied by another item. |
|
||||
|
||||
Priority reflects **value, not build effort**. A cheap rank-1 outranks an
|
||||
expensive rank-6.
|
||||
|
||||
## Urgency / returns labels
|
||||
|
||||
- **`acute`** — the need is frequent, painful, and/or broadly held *right now*.
|
||||
Apply to the rank-1/keystone needs and anything externally forced (regulatory
|
||||
deadline, audit finding).
|
||||
- **`diminishing`** — the next investment in this area has tipped into
|
||||
diminishing returns, or the need is largely satisfied by another item already
|
||||
in the plan. Apply sparingly; it's a signal to deprioritize.
|
||||
|
||||
A task can have neither. Most have neither.
|
||||
|
||||
## Platform & deployment labels
|
||||
|
||||
- **Platform** = the product/platform the work belongs to (e.g. `Orchestrate`,
|
||||
`HBNG`). One label.
|
||||
- **Deployment / repo** = the specific service or repo (e.g. `Rosetta`,
|
||||
`Hendrix`, `Insight`, `Kong`). One per task when granularity is per-deployment.
|
||||
|
||||
Categories are consistent **across** platforms and deployments (that's the point
|
||||
— cross-deployment comparability). Platform/deployment are tags, never top-level
|
||||
projects.
|
||||
|
||||
## Done / in-progress
|
||||
|
||||
- If the doc says an item is already shipped for a deployment, set `done: true`.
|
||||
- Partial progress: leave `done: false` and set `percent` (e.g. 50) — note what
|
||||
remains in the description.
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract plain text from a document for backlog analysis.
|
||||
|
||||
Supports: .md .markdown .rst .txt (read), .odt / .docx (zip + XML strip),
|
||||
.pdf (pdftotext if available, else pypdf/pdfplumber). Writes text to stdout.
|
||||
|
||||
Usage: extract.py <path>
|
||||
"""
|
||||
import sys, os, re, html, zipfile, shutil, subprocess
|
||||
|
||||
|
||||
def _strip_xml(xml: str, para_close=("</text:p>", "</text:h>", "</w:p>")) -> str:
|
||||
for tag in para_close:
|
||||
xml = xml.replace(tag, "\n")
|
||||
xml = re.sub(r"<[^>]+>", "", xml)
|
||||
xml = html.unescape(xml)
|
||||
lines = [ln.strip() for ln in xml.split("\n")]
|
||||
return "\n".join(ln for ln in lines if ln)
|
||||
|
||||
|
||||
def from_zip_xml(path: str, inner: str) -> str:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
return _strip_xml(z.read(inner).decode("utf-8", "ignore"))
|
||||
|
||||
|
||||
def from_pdf(path: str) -> str:
|
||||
if shutil.which("pdftotext"):
|
||||
out = subprocess.run(["pdftotext", "-layout", path, "-"],
|
||||
capture_output=True, text=True)
|
||||
if out.returncode == 0 and out.stdout.strip():
|
||||
return out.stdout
|
||||
try:
|
||||
import pypdf
|
||||
return "\n".join(p.extract_text() or "" for p in pypdf.PdfReader(path).pages)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import pdfplumber
|
||||
with pdfplumber.open(path) as pdf:
|
||||
return "\n".join(pg.extract_text() or "" for pg in pdf.pages)
|
||||
except Exception:
|
||||
sys.exit("PDF extraction needs `pdftotext` (poppler) or `pip install pypdf`.")
|
||||
|
||||
|
||||
def extract(path: str) -> str:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".md", ".markdown", ".rst", ".txt", ".text", ""):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
if ext == ".odt":
|
||||
return from_zip_xml(path, "content.xml")
|
||||
if ext == ".docx":
|
||||
return from_zip_xml(path, "word/document.xml")
|
||||
if ext == ".pdf":
|
||||
return from_pdf(path)
|
||||
# last resort: try as text
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except UnicodeDecodeError:
|
||||
sys.exit(f"Unsupported binary format: {ext}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: extract.py <path>")
|
||||
if not os.path.isfile(sys.argv[1]):
|
||||
sys.exit(f"no such file: {sys.argv[1]}")
|
||||
sys.stdout.write(extract(sys.argv[1]))
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vikunja backlog helper for the sliders/feature-category model.
|
||||
|
||||
Idempotently ensures the 8 feature-category projects + framework labels exist
|
||||
under the root project, and creates tasks from an approved JSON breakdown
|
||||
(deduping, and working around the mark-done-zeros-priority quirk).
|
||||
|
||||
Auth/config (environment):
|
||||
VIKUNJA_API_KEY required — long-lived API token
|
||||
VIKUNJA_BASE_URL default https://tasks.mroberts.dev/api/v1
|
||||
VIKUNJA_ROOT_PROJECT default CareEvolution
|
||||
|
||||
Subcommands:
|
||||
ensure create missing category projects + acute/diminishing labels
|
||||
projects list category projects (name -> id)
|
||||
labels list labels (name -> id)
|
||||
create --file T.json create tasks from an approved breakdown (see --help)
|
||||
|
||||
Tasks JSON: a list of objects:
|
||||
{category, title, description, priority, labels:[...], done?, percent?}
|
||||
`category` must be one of the 8 exact names. Missing labels are created.
|
||||
Duplicates (same title + category + deployment labels) are skipped.
|
||||
"""
|
||||
import sys, os, json, argparse, urllib.request, urllib.error
|
||||
|
||||
BASE = os.environ.get("VIKUNJA_BASE_URL", "https://tasks.mroberts.dev/api/v1")
|
||||
ROOT_NAME = os.environ.get("VIKUNJA_ROOT_PROJECT", "CareEvolution")
|
||||
|
||||
CATEGORIES = [
|
||||
"End-user experience",
|
||||
"Admin & operator experience",
|
||||
"Developer experience & adoption",
|
||||
"Data, integrations & ecosystem",
|
||||
"Performance & scale",
|
||||
"Reliability & observability",
|
||||
"Security, privacy & compliance",
|
||||
"Cost efficiency",
|
||||
]
|
||||
FRAMEWORK_LABELS = ["acute", "diminishing"]
|
||||
|
||||
|
||||
def _key():
|
||||
k = os.environ.get("VIKUNJA_API_KEY")
|
||||
if not k:
|
||||
sys.exit("VIKUNJA_API_KEY not set")
|
||||
return k
|
||||
|
||||
|
||||
def call(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, method=method,
|
||||
headers={"Authorization": "Bearer " + _key(),
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return resp.status, json.loads(resp.read() or "null")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode()[:300]
|
||||
|
||||
|
||||
def get(path):
|
||||
return call("GET", path)[1]
|
||||
|
||||
|
||||
def root_id():
|
||||
for p in get("/projects") or []:
|
||||
if p["title"] == ROOT_NAME and p.get("parent_project_id") in (0, None):
|
||||
return p["id"]
|
||||
sys.exit(f"root project {ROOT_NAME!r} not found")
|
||||
|
||||
|
||||
def all_labels():
|
||||
return {l["title"]: l["id"] for l in (get("/labels") or [])}
|
||||
|
||||
|
||||
def category_projects():
|
||||
rid = root_id()
|
||||
return {p["title"]: p["id"] for p in (get("/projects") or [])
|
||||
if p.get("parent_project_id") == rid and p["title"] in CATEGORIES}
|
||||
|
||||
|
||||
def ensure_label(name, cache):
|
||||
if name in cache:
|
||||
return cache[name]
|
||||
s, l = call("PUT", "/labels", {"title": name})
|
||||
if not isinstance(l, dict):
|
||||
sys.exit(f"failed creating label {name!r}: {l}")
|
||||
cache[name] = l["id"]
|
||||
return l["id"]
|
||||
|
||||
|
||||
def cmd_ensure(_):
|
||||
rid = root_id()
|
||||
existing = category_projects()
|
||||
cats = dict(existing)
|
||||
for c in CATEGORIES:
|
||||
if c not in cats:
|
||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
||||
if not isinstance(p, dict):
|
||||
sys.exit(f"failed creating project {c!r}: {p}")
|
||||
cats[c] = p["id"]
|
||||
print(f"created project {c!r} -> {p['id']}", file=sys.stderr)
|
||||
labels = all_labels()
|
||||
for n in FRAMEWORK_LABELS:
|
||||
ensure_label(n, labels)
|
||||
print(json.dumps({"root": rid, "categories": cats, "labels": labels}, indent=2))
|
||||
|
||||
|
||||
def cmd_projects(_):
|
||||
print(json.dumps(category_projects(), indent=2))
|
||||
|
||||
|
||||
def cmd_labels(_):
|
||||
print(json.dumps(all_labels(), indent=2))
|
||||
|
||||
|
||||
def _deployment_labels(labels):
|
||||
return {l for l in labels if l not in FRAMEWORK_LABELS}
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
tasks = json.load(open(args.file))
|
||||
if not isinstance(tasks, list):
|
||||
sys.exit("tasks JSON must be a list")
|
||||
rid = root_id()
|
||||
cats = category_projects()
|
||||
# ensure all referenced categories exist
|
||||
for t in tasks:
|
||||
c = t["category"]
|
||||
if c not in CATEGORIES:
|
||||
sys.exit(f"unknown category {c!r} (must be one of the 8)")
|
||||
if c not in cats:
|
||||
s, p = call("PUT", "/projects", {"title": c, "parent_project_id": rid})
|
||||
cats[c] = p["id"]
|
||||
label_cache = all_labels()
|
||||
# cache existing tasks per category for dedup
|
||||
existing = {}
|
||||
for c, pid in cats.items():
|
||||
existing[c] = get(f"/projects/{pid}/tasks") or []
|
||||
|
||||
created, skipped = [], []
|
||||
for t in tasks:
|
||||
c = t["category"]
|
||||
pid = cats[c]
|
||||
title = t["title"]
|
||||
want_dep = _deployment_labels(t.get("labels", []))
|
||||
dup = False
|
||||
for ex in existing[c]:
|
||||
if ex["title"].strip().lower() != title.strip().lower():
|
||||
continue
|
||||
ex_labels = {l["title"] for l in (ex.get("labels") or [])}
|
||||
if want_dep.issubset(ex_labels):
|
||||
dup = True
|
||||
break
|
||||
if dup:
|
||||
skipped.append((title, c, sorted(want_dep)))
|
||||
continue
|
||||
prio = int(t.get("priority", 0))
|
||||
s, task = call("PUT", f"/projects/{pid}/tasks", {
|
||||
"title": title,
|
||||
"description": (t.get("description") or "").replace("\n", "<br>"),
|
||||
"priority": prio,
|
||||
})
|
||||
if not isinstance(task, dict):
|
||||
sys.exit(f"create failed for {title!r}: {task}")
|
||||
tid = task["id"]
|
||||
for ln in t.get("labels", []):
|
||||
call("PUT", f"/tasks/{tid}/labels", {"label_id": ensure_label(ln, label_cache)})
|
||||
done = bool(t.get("done"))
|
||||
pct = int(t.get("percent", 0))
|
||||
if done or pct:
|
||||
# re-send priority: the update replaces the task and would zero it
|
||||
call("POST", f"/tasks/{tid}", {
|
||||
"done": done, "percent_done": pct / 100, "priority": prio})
|
||||
existing[c].append({"title": title,
|
||||
"labels": [{"title": x} for x in t.get("labels", [])]})
|
||||
created.append((tid, title, c, prio, sorted(want_dep), "done" if done else (f"{pct}%" if pct else "")))
|
||||
|
||||
print(f"created {len(created)} tasks, skipped {len(skipped)} duplicate(s)", file=sys.stderr)
|
||||
for r in created:
|
||||
print(f" + #{r[0]} [{r[3]}] {r[2]}: {r[1]} {r[4]} {r[5]}", file=sys.stderr)
|
||||
for r in skipped:
|
||||
print(f" = skip {r[1]}: {r[0]} {r[2]}", file=sys.stderr)
|
||||
print(json.dumps({"created": [r[0] for r in created],
|
||||
"skipped": len(skipped)}))
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("ensure", help="create missing category projects + labels")
|
||||
sub.add_parser("projects", help="list category projects")
|
||||
sub.add_parser("labels", help="list labels")
|
||||
c = sub.add_parser("create", help="create tasks from approved JSON")
|
||||
c.add_argument("--file", required=True, help="path to tasks JSON (list)")
|
||||
args = ap.parse_args()
|
||||
{"ensure": cmd_ensure, "projects": cmd_projects,
|
||||
"labels": cmd_labels, "create": cmd_create}[args.cmd](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user