mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
c4f679d829
* feat: add /careful, /freeze, /guard, /unfreeze safety hook skills Four new on-demand skills using Claude Code's PreToolUse hooks: - /careful: warns before destructive commands (rm -rf, DROP TABLE, force-push, etc.) - /freeze: blocks file edits outside a specified directory - /guard: composes both into one command - /unfreeze: clears freeze boundary without ending session Pure bash hook scripts with Python fallback for JSON edge cases. Safe exceptions for build artifacts (node_modules, dist, .next, etc.). Hook fire telemetry logs pattern name only (never command content). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add skill usage telemetry to preamble TemplateContext system passes skill name through resolver pipeline so each generated SKILL.md gets its own name baked into the telemetry line. Appends to ~/.gstack/analytics/skill-usage.jsonl on every invocation. Covers 14 preamble-using skills + 4 hook skills (inline telemetry). JSONL format: {"skill":"ship","ts":"...","repo":"my-project"} Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add analytics CLI for skill usage stats bun run analytics reads ~/.gstack/analytics/skill-usage.jsonl and shows top skills, per-repo breakdown, hook fire stats, and daily timeline. Supports --period 7d/30d/all. Handles missing/empty/malformed data. 22 unit tests cover parsing, filtering, formatting, and edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add skills-used-this-week to /retro Retro Step 2 now reads skill-usage.jsonl and shows which gstack skills were used during the retro window. Follows the same pattern as the Greptile signal and Backlog Health metrics — read file, filter by date, aggregate, present. Skips silently if no analytics data exists. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add hook script and telemetry tests 32 unit tests for check-careful.sh covering all 8 destructive patterns, safe exceptions, Python fallback, and malformed input handling. 7 unit tests for check-freeze.sh covering boundary enforcement, trailing slash edge case, and missing state file. Telemetry tests verify per-skill name correctness in generated output. Adds careful/freeze/guard/unfreeze/document-release to ALL_SKILLS. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version to 0.6.5 + changelog + mark TODOs shipped Safety hook skills and skill usage telemetry shipped. Analytics CLI and /retro integration included. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: /debug auto-freezes edits to the module being debugged Add PreToolUse hooks (Edit/Write) to debug/SKILL.md.tmpl that reference the existing freeze/bin/check-freeze.sh. After Phase 1 investigation, /debug locks edits to the narrowest affected directory. Graceful degradation: if freeze script is unavailable, scope lock is skipped. Users can run /unfreeze to remove the restriction. Deferred 6 enhancements to TODOS.md, gated on telemetry showing the freeze hook actually fires in real debugging sessions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
191 lines
5.3 KiB
TypeScript
191 lines
5.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* analytics — CLI for viewing gstack skill usage statistics.
|
|
*
|
|
* Reads ~/.gstack/analytics/skill-usage.jsonl and displays:
|
|
* - Top skills by invocation count
|
|
* - Per-repo skill breakdown
|
|
* - Safety hook fire events
|
|
*
|
|
* Usage:
|
|
* bun run scripts/analytics.ts [--period 7d|30d|all]
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
export interface AnalyticsEvent {
|
|
skill: string;
|
|
ts: string;
|
|
repo: string;
|
|
event?: string;
|
|
pattern?: string;
|
|
}
|
|
|
|
const ANALYTICS_FILE = path.join(os.homedir(), '.gstack', 'analytics', 'skill-usage.jsonl');
|
|
|
|
/**
|
|
* Parse JSONL content into AnalyticsEvent[], skipping malformed lines.
|
|
*/
|
|
export function parseJSONL(content: string): AnalyticsEvent[] {
|
|
const events: AnalyticsEvent[] = [];
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const obj = JSON.parse(trimmed);
|
|
if (typeof obj === 'object' && obj !== null && typeof obj.ts === 'string') {
|
|
events.push(obj as AnalyticsEvent);
|
|
}
|
|
} catch {
|
|
// skip malformed lines
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
/**
|
|
* Filter events by period. Supports "7d", "30d", and "all".
|
|
*/
|
|
export function filterByPeriod(events: AnalyticsEvent[], period: string): AnalyticsEvent[] {
|
|
if (period === 'all') return events;
|
|
|
|
const match = period.match(/^(\d+)d$/);
|
|
if (!match) return events;
|
|
|
|
const days = parseInt(match[1], 10);
|
|
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
|
|
return events.filter(e => {
|
|
const d = new Date(e.ts);
|
|
return !isNaN(d.getTime()) && d >= cutoff;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Format a report string from a list of events.
|
|
*/
|
|
export function formatReport(events: AnalyticsEvent[], period: string = 'all'): string {
|
|
const skillEvents = events.filter(e => e.event !== 'hook_fire');
|
|
const hookEvents = events.filter(e => e.event === 'hook_fire');
|
|
|
|
const lines: string[] = [];
|
|
lines.push('gstack skill usage analytics');
|
|
lines.push('\u2550'.repeat(39));
|
|
lines.push('');
|
|
|
|
const periodLabel = period === 'all' ? 'all time' : `last ${period.replace('d', ' days')}`;
|
|
lines.push(`Period: ${periodLabel}`);
|
|
|
|
// Top Skills
|
|
const skillCounts = new Map<string, number>();
|
|
for (const e of skillEvents) {
|
|
skillCounts.set(e.skill, (skillCounts.get(e.skill) || 0) + 1);
|
|
}
|
|
|
|
if (skillCounts.size > 0) {
|
|
lines.push('');
|
|
lines.push('Top Skills');
|
|
|
|
const sorted = [...skillCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
const maxName = Math.max(...sorted.map(([name]) => name.length + 1)); // +1 for /
|
|
const maxCount = Math.max(...sorted.map(([, count]) => String(count).length));
|
|
|
|
for (const [name, count] of sorted) {
|
|
const label = `/${name}`;
|
|
const suffix = `${count} invocation${count === 1 ? '' : 's'}`;
|
|
const dotLen = Math.max(2, 25 - label.length - suffix.length);
|
|
const dots = ' ' + '.'.repeat(dotLen) + ' ';
|
|
lines.push(` ${label}${dots}${suffix}`);
|
|
}
|
|
}
|
|
|
|
// By Repo
|
|
const repoSkills = new Map<string, Map<string, number>>();
|
|
for (const e of skillEvents) {
|
|
if (!repoSkills.has(e.repo)) repoSkills.set(e.repo, new Map());
|
|
const m = repoSkills.get(e.repo)!;
|
|
m.set(e.skill, (m.get(e.skill) || 0) + 1);
|
|
}
|
|
|
|
if (repoSkills.size > 0) {
|
|
lines.push('');
|
|
lines.push('By Repo');
|
|
|
|
const sortedRepos = [...repoSkills.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
for (const [repo, skills] of sortedRepos) {
|
|
const parts = [...skills.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.map(([s, c]) => `${s}(${c})`);
|
|
lines.push(` ${repo}: ${parts.join(' ')}`);
|
|
}
|
|
}
|
|
|
|
// Safety Hook Events
|
|
const hookCounts = new Map<string, number>();
|
|
for (const e of hookEvents) {
|
|
if (e.pattern) {
|
|
hookCounts.set(e.pattern, (hookCounts.get(e.pattern) || 0) + 1);
|
|
}
|
|
}
|
|
|
|
if (hookCounts.size > 0) {
|
|
lines.push('');
|
|
lines.push('Safety Hook Events');
|
|
|
|
const sortedHooks = [...hookCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
for (const [pattern, count] of sortedHooks) {
|
|
const suffix = `${count} fire${count === 1 ? '' : 's'}`;
|
|
const dotLen = Math.max(2, 25 - pattern.length - suffix.length);
|
|
const dots = ' ' + '.'.repeat(dotLen) + ' ';
|
|
lines.push(` ${pattern}${dots}${suffix}`);
|
|
}
|
|
}
|
|
|
|
// Total
|
|
const totalSkills = skillEvents.length;
|
|
const totalHooks = hookEvents.length;
|
|
lines.push('');
|
|
lines.push(`Total: ${totalSkills} skill invocation${totalSkills === 1 ? '' : 's'}, ${totalHooks} hook fire${totalHooks === 1 ? '' : 's'}`);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function main() {
|
|
// Parse --period flag
|
|
let period = 'all';
|
|
const args = process.argv.slice(2);
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--period' && i + 1 < args.length) {
|
|
period = args[i + 1];
|
|
i++;
|
|
}
|
|
}
|
|
|
|
// Read file
|
|
if (!fs.existsSync(ANALYTICS_FILE)) {
|
|
console.log('No analytics data found.');
|
|
process.exit(0);
|
|
}
|
|
|
|
const content = fs.readFileSync(ANALYTICS_FILE, 'utf-8').trim();
|
|
if (!content) {
|
|
console.log('No analytics data found.');
|
|
process.exit(0);
|
|
}
|
|
|
|
const events = parseJSONL(content);
|
|
if (events.length === 0) {
|
|
console.log('No analytics data found.');
|
|
process.exit(0);
|
|
}
|
|
|
|
const filtered = filterByPeriod(events, period);
|
|
console.log(formatReport(filtered, period));
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
main();
|
|
}
|