worclaude 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +72 -56
  3. package/package.json +1 -1
  4. package/src/commands/doc-lint.js +37 -0
  5. package/src/commands/doctor.js +77 -0
  6. package/src/commands/init.js +144 -44
  7. package/src/commands/observability.js +24 -0
  8. package/src/commands/regenerate-routing.js +70 -0
  9. package/src/commands/status.js +14 -0
  10. package/src/commands/upgrade.js +87 -1
  11. package/src/commands/worktrees.js +90 -0
  12. package/src/core/config.js +10 -1
  13. package/src/core/file-categorizer.js +16 -0
  14. package/src/core/merger.js +42 -20
  15. package/src/core/scaffolder.js +26 -0
  16. package/src/data/agents.js +14 -28
  17. package/src/data/optional-features.js +46 -0
  18. package/src/generators/agent-routing.js +189 -34
  19. package/src/index.js +37 -0
  20. package/src/prompts/agent-selection.js +11 -3
  21. package/src/utils/agent-frontmatter.js +109 -0
  22. package/src/utils/doc-lint.js +196 -0
  23. package/src/utils/observability.js +300 -0
  24. package/templates/agents/optional/backend/api-designer.md +7 -1
  25. package/templates/agents/optional/backend/auth-auditor.md +7 -1
  26. package/templates/agents/optional/backend/database-analyst.md +7 -1
  27. package/templates/agents/optional/data/data-pipeline-reviewer.md +7 -1
  28. package/templates/agents/optional/data/ml-experiment-tracker.md +7 -1
  29. package/templates/agents/optional/data/prompt-engineer.md +7 -1
  30. package/templates/agents/optional/devops/ci-fixer.md +7 -1
  31. package/templates/agents/optional/devops/dependency-manager.md +7 -1
  32. package/templates/agents/optional/devops/deploy-validator.md +7 -1
  33. package/templates/agents/optional/devops/docker-helper.md +7 -1
  34. package/templates/agents/optional/docs/changelog-generator.md +9 -1
  35. package/templates/agents/optional/docs/doc-writer.md +7 -1
  36. package/templates/agents/optional/frontend/style-enforcer.md +7 -1
  37. package/templates/agents/optional/frontend/ui-reviewer.md +7 -1
  38. package/templates/agents/optional/quality/bug-fixer.md +7 -1
  39. package/templates/agents/optional/quality/build-fixer.md +7 -1
  40. package/templates/agents/optional/quality/performance-auditor.md +8 -1
  41. package/templates/agents/optional/quality/refactorer.md +7 -1
  42. package/templates/agents/optional/quality/security-reviewer.md +7 -1
  43. package/templates/agents/universal/build-validator.md +7 -1
  44. package/templates/agents/universal/code-simplifier.md +8 -1
  45. package/templates/agents/universal/plan-reviewer.md +8 -1
  46. package/templates/agents/universal/test-writer.md +7 -1
  47. package/templates/agents/universal/upstream-watcher.md +8 -1
  48. package/templates/agents/universal/verify-app.md +33 -3
  49. package/templates/commands/build-fix.md +30 -11
  50. package/templates/commands/commit-push-pr.md +47 -24
  51. package/templates/commands/compact-safe.md +79 -7
  52. package/templates/commands/conflict-resolver.md +7 -3
  53. package/templates/commands/end.md +63 -17
  54. package/templates/commands/learn.md +72 -8
  55. package/templates/commands/observability.md +59 -0
  56. package/templates/commands/refactor-clean.md +44 -2
  57. package/templates/commands/review-changes.md +40 -11
  58. package/templates/commands/review-plan.md +83 -10
  59. package/templates/commands/start.md +61 -30
  60. package/templates/commands/sync.md +86 -6
  61. package/templates/commands/test-coverage.md +78 -12
  62. package/templates/commands/update-claude-md.md +96 -7
  63. package/templates/commands/verify.md +32 -8
  64. package/templates/core/claude-md.md +9 -0
  65. package/templates/hooks/correction-detect.cjs +1 -1
  66. package/templates/hooks/learn-capture.cjs +0 -2
  67. package/templates/hooks/obs-agent-events.cjs +55 -0
  68. package/templates/hooks/obs-command-invocations.cjs +53 -0
  69. package/templates/hooks/obs-skill-loads.cjs +54 -0
  70. package/templates/hooks/skill-hint.cjs +22 -2
  71. package/templates/scripts/start-drift.sh +29 -0
  72. package/templates/scripts/sync-release-scope.sh +17 -0
  73. package/templates/scripts/test-coverage-changed-files.sh +14 -0
  74. package/templates/settings/base.json +73 -0
  75. package/templates/skills/universal/claude-md-maintenance.md +50 -14
  76. package/templates/skills/universal/git-conventions.md +11 -1
  77. package/templates/skills/universal/memory-architecture.md +115 -0
  78. package/templates/skills/universal/subagent-usage.md +1 -1
  79. package/src/data/agent-registry.js +0 -365
  80. package/templates/agents/optional/quality/e2e-runner.md +0 -98
  81. package/templates/commands/status.md +0 -15
  82. package/templates/commands/techdebt.md +0 -18
  83. package/templates/commands/upstream-check.md +0 -85
@@ -63,8 +63,9 @@ export async function promptAgentSelection(projectTypes) {
63
63
  // Step 3: Offer unselected categories
64
64
  const unselectedCategories = categoryNames.filter((cat) => !selectedCategories.includes(cat));
65
65
 
66
+ let additionalCategories = [];
66
67
  if (unselectedCategories.length > 0) {
67
- const { additionalCategories } = await inquirer.prompt([
68
+ ({ additionalCategories } = await inquirer.prompt([
68
69
  {
69
70
  type: 'checkbox',
70
71
  name: 'additionalCategories',
@@ -74,7 +75,7 @@ export async function promptAgentSelection(projectTypes) {
74
75
  value: cat,
75
76
  })),
76
77
  },
77
- ]);
78
+ ]));
78
79
 
79
80
  for (const cat of additionalCategories) {
80
81
  const agentNames = AGENT_CATEGORIES[cat].agents;
@@ -95,5 +96,12 @@ export async function promptAgentSelection(projectTypes) {
95
96
  }
96
97
 
97
98
  // Deduplicate
98
- return [...new Set(selected)];
99
+ const dedupedSelectedAgents = [...new Set(selected)];
100
+
101
+ return {
102
+ selectedAgents: dedupedSelectedAgents,
103
+ selectedCategories,
104
+ additionalCategories,
105
+ preSelectedCategories: [...preSelectedCategories],
106
+ };
99
107
  }
@@ -0,0 +1,109 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import yaml from 'yaml';
4
+
5
+ const ROUTING_FIELDS = [
6
+ 'category',
7
+ 'triggerType',
8
+ 'triggerCommand',
9
+ 'whenToUse',
10
+ 'whatItDoes',
11
+ 'expectBack',
12
+ 'situationLabel',
13
+ 'status',
14
+ ];
15
+
16
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
17
+
18
+ export class AgentFrontmatterError extends Error {
19
+ constructor(message, { filePath } = {}) {
20
+ super(message);
21
+ this.name = 'AgentFrontmatterError';
22
+ this.filePath = filePath;
23
+ }
24
+ }
25
+
26
+ export function parseAgentFrontmatter(source, filePath) {
27
+ const match = source.match(FRONTMATTER_RE);
28
+ if (!match) {
29
+ throw new AgentFrontmatterError('No YAML frontmatter found', { filePath });
30
+ }
31
+ let parsed;
32
+ try {
33
+ parsed = yaml.parse(match[1]);
34
+ } catch (err) {
35
+ throw new AgentFrontmatterError(`Frontmatter is not valid YAML: ${err.message}`, {
36
+ filePath,
37
+ });
38
+ }
39
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
40
+ throw new AgentFrontmatterError('Frontmatter must be a YAML mapping', { filePath });
41
+ }
42
+ return parsed;
43
+ }
44
+
45
+ export function validateRoutingFields(frontmatter, { filePath } = {}) {
46
+ const missing = [];
47
+ for (const field of ['category', 'triggerType', 'whenToUse', 'whatItDoes', 'expectBack']) {
48
+ if (frontmatter[field] === undefined || frontmatter[field] === '') {
49
+ missing.push(field);
50
+ }
51
+ }
52
+ if (missing.length > 0) {
53
+ throw new AgentFrontmatterError(
54
+ `Agent frontmatter missing required routing fields: ${missing.join(', ')}`,
55
+ { filePath }
56
+ );
57
+ }
58
+ if (frontmatter.triggerType !== 'automatic' && frontmatter.triggerType !== 'manual') {
59
+ throw new AgentFrontmatterError(
60
+ `Agent frontmatter triggerType must be "automatic" or "manual", got "${frontmatter.triggerType}"`,
61
+ { filePath }
62
+ );
63
+ }
64
+ }
65
+
66
+ export async function readAgentFile(filePath) {
67
+ const source = await readFile(filePath, 'utf8');
68
+ const frontmatter = parseAgentFrontmatter(source, filePath);
69
+ return frontmatter;
70
+ }
71
+
72
+ export async function loadAgentsFromDir(dir, { recursive = true } = {}) {
73
+ const entries = [];
74
+ await walk(dir, recursive, entries);
75
+ const agents = [];
76
+ for (const filePath of entries) {
77
+ const frontmatter = await readAgentFile(filePath);
78
+ if (!frontmatter.name) {
79
+ throw new AgentFrontmatterError('Agent frontmatter missing required field: name', {
80
+ filePath,
81
+ });
82
+ }
83
+ agents.push({ name: frontmatter.name, filePath, ...frontmatter });
84
+ }
85
+ agents.sort((a, b) => a.name.localeCompare(b.name));
86
+ return agents;
87
+ }
88
+
89
+ async function walk(dir, recursive, out) {
90
+ let dirents;
91
+ try {
92
+ dirents = await readdir(dir, { withFileTypes: true });
93
+ } catch (err) {
94
+ if (err.code === 'ENOENT') return;
95
+ throw err;
96
+ }
97
+ for (const dirent of dirents) {
98
+ const full = path.join(dir, dirent.name);
99
+ if (dirent.isDirectory()) {
100
+ if (recursive) await walk(full, recursive, out);
101
+ continue;
102
+ }
103
+ if (dirent.isFile() && dirent.name.endsWith('.md')) {
104
+ out.push(full);
105
+ }
106
+ }
107
+ }
108
+
109
+ export { ROUTING_FIELDS };
@@ -0,0 +1,196 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { fileExists, readFile } from './file.js';
4
+
5
+ const TEST_COUNT_RE = /(\d+)\s+tests?\s*,\s*(\d+)\s+(?:test\s+)?files?/i;
6
+
7
+ export function parseClaudeMdTestCountClaims(content) {
8
+ if (typeof content !== 'string' || content.length === 0) return [];
9
+ const claims = [];
10
+ const lines = content.split(/\r?\n/);
11
+ for (let i = 0; i < lines.length; i++) {
12
+ const line = lines[i];
13
+ const m = line.match(TEST_COUNT_RE);
14
+ if (m) {
15
+ claims.push({
16
+ lineNumber: i + 1,
17
+ raw: line,
18
+ claimedTests: Number(m[1]),
19
+ claimedFiles: Number(m[2]),
20
+ });
21
+ }
22
+ }
23
+ return claims;
24
+ }
25
+
26
+ const TEST_FILE_RE = /\.test\.(?:js|mjs|cjs|ts|tsx)$/;
27
+ const TEST_CALL_RE = /^\s*(?:it|test)(?:\.each\s*\([^)]*\))?\s*\(/gm;
28
+
29
+ export function countTestCalls(content) {
30
+ if (typeof content !== 'string' || content.length === 0) return 0;
31
+ return (content.match(TEST_CALL_RE) || []).length;
32
+ }
33
+
34
+ async function collectTestFiles(dir) {
35
+ if (!(await fs.pathExists(dir))) return [];
36
+ const out = [];
37
+ const entries = await fs.readdir(dir, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
40
+ const full = path.join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ const nested = await collectTestFiles(full);
43
+ out.push(...nested);
44
+ } else if (entry.isFile() && TEST_FILE_RE.test(entry.name)) {
45
+ out.push(full);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export async function countActualTestSuite(projectRoot, options = {}) {
52
+ const testDir = options.testDir || path.join(projectRoot, 'tests');
53
+ const files = await collectTestFiles(testDir);
54
+ let tests = 0;
55
+ for (const file of files) {
56
+ const content = await readFile(file);
57
+ tests += countTestCalls(content);
58
+ }
59
+ return { tests, files: files.length };
60
+ }
61
+
62
+ const MARKER_RE = /<!--\s*references\s+(\S+?)\s*-->/i;
63
+ const SKIP_DIRS = new Set([
64
+ 'node_modules',
65
+ '.git',
66
+ 'dist',
67
+ 'coverage',
68
+ '.eslintcache',
69
+ '.vitest',
70
+ 'docs/.vitepress/dist',
71
+ 'docs/.vitepress/cache',
72
+ ]);
73
+
74
+ async function walkMarkdownFiles(rootDir, currentRel = '') {
75
+ const out = [];
76
+ const dir = path.join(rootDir, currentRel);
77
+ if (!(await fs.pathExists(dir))) return out;
78
+ const entries = await fs.readdir(dir, { withFileTypes: true });
79
+ for (const entry of entries) {
80
+ const rel = currentRel ? `${currentRel}/${entry.name}` : entry.name;
81
+ if (entry.isDirectory()) {
82
+ if (SKIP_DIRS.has(entry.name) || SKIP_DIRS.has(rel) || entry.name.startsWith('.')) continue;
83
+ const nested = await walkMarkdownFiles(rootDir, rel);
84
+ out.push(...nested);
85
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
86
+ out.push(rel);
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function extractSection(lines, markerLineIdx) {
93
+ const sectionLines = [];
94
+ for (let i = markerLineIdx + 1; i < lines.length; i++) {
95
+ if (/^##\s/.test(lines[i])) break;
96
+ sectionLines.push(lines[i]);
97
+ }
98
+ return sectionLines.join('\n');
99
+ }
100
+
101
+ export async function findReferencesMarkers(rootDir) {
102
+ const files = await walkMarkdownFiles(rootDir);
103
+ const markers = [];
104
+ for (const relFile of files) {
105
+ const absPath = path.join(rootDir, relFile);
106
+ const content = await readFile(absPath);
107
+ const lines = content.split(/\r?\n/);
108
+ for (let i = 0; i < lines.length; i++) {
109
+ const m = lines[i].match(MARKER_RE);
110
+ if (!m) continue;
111
+ const source = m[1];
112
+ const sectionContent = extractSection(lines, i);
113
+ markers.push({
114
+ file: relFile,
115
+ markerLine: i + 1,
116
+ source,
117
+ sectionStartLine: i + 2,
118
+ sectionContent,
119
+ });
120
+ }
121
+ }
122
+ return markers;
123
+ }
124
+
125
+ const RUN_SCRIPT_RE = /\bnpm\s+run\s+([a-zA-Z][a-zA-Z0-9:_-]*)/g;
126
+
127
+ export function findMissingNpmScripts(sectionContent, packageScripts) {
128
+ const seen = new Set();
129
+ const missing = [];
130
+ RUN_SCRIPT_RE.lastIndex = 0;
131
+ let match;
132
+ while ((match = RUN_SCRIPT_RE.exec(sectionContent)) !== null) {
133
+ const name = match[1];
134
+ if (seen.has(name)) continue;
135
+ seen.add(name);
136
+ if (!(name in (packageScripts || {}))) {
137
+ missing.push(name);
138
+ }
139
+ }
140
+ return missing;
141
+ }
142
+
143
+ export async function validatePackageJsonReferences(marker, projectRoot, packageJson) {
144
+ const issues = [];
145
+ const claims = parseClaudeMdTestCountClaims(marker.sectionContent);
146
+ if (claims.length > 0) {
147
+ const actual = await countActualTestSuite(projectRoot);
148
+ for (const claim of claims) {
149
+ if (claim.claimedFiles !== actual.files) {
150
+ issues.push({
151
+ kind: 'test-count-drift',
152
+ markerLine: marker.markerLine,
153
+ claimLine: marker.sectionStartLine + claim.lineNumber - 1,
154
+ claimed: { tests: claim.claimedTests, files: claim.claimedFiles },
155
+ actual,
156
+ raw: claim.raw.trim(),
157
+ });
158
+ }
159
+ }
160
+ }
161
+ const missing = findMissingNpmScripts(marker.sectionContent, packageJson.scripts);
162
+ for (const name of missing) {
163
+ issues.push({
164
+ kind: 'missing-npm-script',
165
+ markerLine: marker.markerLine,
166
+ scriptName: name,
167
+ });
168
+ }
169
+ return issues;
170
+ }
171
+
172
+ export async function lintRepo(projectRoot) {
173
+ const pkgPath = path.join(projectRoot, 'package.json');
174
+ let packageJson = { scripts: {} };
175
+ if (await fileExists(pkgPath)) {
176
+ try {
177
+ packageJson = JSON.parse(await readFile(pkgPath));
178
+ } catch {
179
+ packageJson = { scripts: {} };
180
+ }
181
+ }
182
+ const markers = await findReferencesMarkers(projectRoot);
183
+ const findings = [];
184
+ for (const marker of markers) {
185
+ if (marker.source !== 'package.json') continue;
186
+ const issues = await validatePackageJsonReferences(marker, projectRoot, packageJson);
187
+ for (const issue of issues) {
188
+ findings.push({ file: marker.file, ...issue });
189
+ }
190
+ }
191
+ return {
192
+ hasDrift: findings.length > 0,
193
+ markerCount: markers.length,
194
+ findings,
195
+ };
196
+ }
@@ -0,0 +1,300 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { readFile } from './file.js';
4
+
5
+ export async function readJsonl(filePath) {
6
+ if (!(await fs.pathExists(filePath))) return [];
7
+ const content = await readFile(filePath);
8
+ const out = [];
9
+ for (const line of content.split(/\r?\n/)) {
10
+ if (!line.trim()) continue;
11
+ try {
12
+ out.push(JSON.parse(line));
13
+ } catch {
14
+ // Skip malformed lines; never block the report
15
+ }
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function tally(items, keyFn) {
21
+ const counts = new Map();
22
+ for (const item of items) {
23
+ const key = keyFn(item);
24
+ if (!key) continue;
25
+ counts.set(key, (counts.get(key) || 0) + 1);
26
+ }
27
+ return [...counts.entries()]
28
+ .map(([key, count]) => ({ key, count }))
29
+ .sort((a, b) => b.count - a.count);
30
+ }
31
+
32
+ export function pairAgentEvents(events) {
33
+ const startsByKey = new Map();
34
+ const pairs = [];
35
+ for (const event of events) {
36
+ const session = event.session || 'no-session';
37
+ const agent = event.agent || 'unknown';
38
+ const key = `${session}::${agent}`;
39
+ if (event.event === 'start') {
40
+ startsByKey.set(key, event);
41
+ continue;
42
+ }
43
+ if (event.event !== 'stop') continue;
44
+ const start = startsByKey.get(key);
45
+ if (!start) {
46
+ pairs.push({
47
+ agent,
48
+ session: event.session,
49
+ startTs: null,
50
+ stopTs: event.ts,
51
+ durationMs: null,
52
+ exit: event.exit || 'unknown',
53
+ });
54
+ continue;
55
+ }
56
+ startsByKey.delete(key);
57
+ const startMs = Date.parse(start.ts);
58
+ const stopMs = Date.parse(event.ts);
59
+ const durationMs =
60
+ Number.isFinite(startMs) && Number.isFinite(stopMs) ? stopMs - startMs : null;
61
+ pairs.push({
62
+ agent,
63
+ session: event.session,
64
+ startTs: start.ts,
65
+ stopTs: event.ts,
66
+ durationMs,
67
+ exit: event.exit || 'completed',
68
+ });
69
+ }
70
+ return pairs;
71
+ }
72
+
73
+ function summarizeAgents(pairs) {
74
+ const byAgent = new Map();
75
+ for (const pair of pairs) {
76
+ if (!byAgent.has(pair.agent)) {
77
+ byAgent.set(pair.agent, {
78
+ agent: pair.agent,
79
+ invocations: 0,
80
+ completed: 0,
81
+ failed: 0,
82
+ durationMsTotal: 0,
83
+ durationMsCount: 0,
84
+ });
85
+ }
86
+ const entry = byAgent.get(pair.agent);
87
+ entry.invocations += 1;
88
+ if (pair.exit === 'completed') entry.completed += 1;
89
+ else if (pair.exit === 'failed' || pair.exit === 'error') entry.failed += 1;
90
+ if (Number.isFinite(pair.durationMs)) {
91
+ entry.durationMsTotal += pair.durationMs;
92
+ entry.durationMsCount += 1;
93
+ }
94
+ }
95
+ return [...byAgent.values()]
96
+ .map((e) => ({
97
+ agent: e.agent,
98
+ invocations: e.invocations,
99
+ completed: e.completed,
100
+ failed: e.failed,
101
+ avgDurationMs:
102
+ e.durationMsCount > 0 ? Math.round(e.durationMsTotal / e.durationMsCount) : null,
103
+ }))
104
+ .sort((a, b) => b.invocations - a.invocations);
105
+ }
106
+
107
+ async function listInstalledSkills(projectRoot) {
108
+ const skillsDir = path.join(projectRoot, '.claude', 'skills');
109
+ if (!(await fs.pathExists(skillsDir))) return [];
110
+ const entries = await fs.readdir(skillsDir, { withFileTypes: true });
111
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
112
+ }
113
+
114
+ function daysAgo(date, fromMs = Date.now()) {
115
+ const ts = Date.parse(date);
116
+ if (!Number.isFinite(ts)) return null;
117
+ return Math.floor((fromMs - ts) / (24 * 60 * 60 * 1000));
118
+ }
119
+
120
+ function buildAnomaliesAndSuggestions({ installedSkills, skillStats, agentStats, fromMs }) {
121
+ const anomalies = [];
122
+ const suggestions = [];
123
+
124
+ // Only flag never-loaded skills once SOME skill data exists. On a fresh
125
+ // install with zero captured events the comparison is uninformative noise.
126
+ if (skillStats.length > 0) {
127
+ const loadedSkills = new Set(skillStats.map((s) => s.key));
128
+ const neverLoaded = installedSkills.filter((s) => !loadedSkills.has(s));
129
+ for (const skill of neverLoaded) {
130
+ anomalies.push({
131
+ kind: 'skill-never-loaded',
132
+ message: `Skill \`${skill}\` is installed but never appeared in skill-loads.jsonl.`,
133
+ });
134
+ }
135
+ }
136
+
137
+ for (const stat of skillStats) {
138
+ if (!stat.lastSeen) continue;
139
+ const days = daysAgo(stat.lastSeen, fromMs);
140
+ if (days !== null && days > 30) {
141
+ suggestions.push({
142
+ message: `Skill \`${stat.key}\` not loaded in ${days} days — consider retiring or updating its description for keyword match.`,
143
+ });
144
+ }
145
+ }
146
+
147
+ for (const stat of agentStats) {
148
+ if (stat.invocations >= 3 && stat.failed > stat.completed) {
149
+ anomalies.push({
150
+ kind: 'agent-fails-more-than-completes',
151
+ message: `Agent \`${stat.agent}\` failed ${stat.failed} of ${stat.invocations} invocations (more failures than completions).`,
152
+ });
153
+ }
154
+ }
155
+
156
+ return { anomalies, suggestions };
157
+ }
158
+
159
+ function attachLastSeen(skillStats, skillEvents) {
160
+ const lastByKey = new Map();
161
+ for (const event of skillEvents) {
162
+ const skill = event.skill;
163
+ if (!skill) continue;
164
+ const prev = lastByKey.get(skill);
165
+ if (!prev || event.ts > prev) {
166
+ lastByKey.set(skill, event.ts);
167
+ }
168
+ }
169
+ return skillStats.map((s) => ({ ...s, lastSeen: lastByKey.get(s.key) || null }));
170
+ }
171
+
172
+ export async function computeReport(projectRoot, options = {}) {
173
+ const obsDir = path.join(projectRoot, '.claude', 'observability');
174
+ const fromMs = options.now || Date.now();
175
+
176
+ const skillEvents = await readJsonl(path.join(obsDir, 'skill-loads.jsonl'));
177
+ const commandEvents = await readJsonl(path.join(obsDir, 'command-invocations.jsonl'));
178
+ const agentEvents = await readJsonl(path.join(obsDir, 'agent-events.jsonl'));
179
+ const installedSkills = await listInstalledSkills(projectRoot);
180
+
181
+ const skillStatsRaw = tally(skillEvents, (e) => e.skill);
182
+ const skillStats = attachLastSeen(skillStatsRaw, skillEvents);
183
+ const commandStats = tally(commandEvents, (e) => e.command);
184
+ const agentPairs = pairAgentEvents(agentEvents);
185
+ const agentStats = summarizeAgents(agentPairs);
186
+
187
+ const { anomalies, suggestions } = buildAnomaliesAndSuggestions({
188
+ installedSkills,
189
+ skillStats,
190
+ agentStats,
191
+ fromMs,
192
+ });
193
+
194
+ return {
195
+ generatedAt: new Date(fromMs).toISOString(),
196
+ counts: {
197
+ skillEvents: skillEvents.length,
198
+ commandEvents: commandEvents.length,
199
+ agentEvents: agentEvents.length,
200
+ agentPairs: agentPairs.length,
201
+ installedSkills: installedSkills.length,
202
+ },
203
+ skillStats,
204
+ commandStats,
205
+ agentStats,
206
+ anomalies,
207
+ suggestions,
208
+ };
209
+ }
210
+
211
+ function formatDuration(ms) {
212
+ if (ms === null || !Number.isFinite(ms)) return '—';
213
+ if (ms < 1000) return `${ms}ms`;
214
+ const s = ms / 1000;
215
+ if (s < 60) return `${s.toFixed(1)}s`;
216
+ const m = Math.floor(s / 60);
217
+ const r = Math.round(s - m * 60);
218
+ return `${m}m ${r}s`;
219
+ }
220
+
221
+ export function renderMarkdown(report) {
222
+ const lines = [];
223
+ lines.push('# Observability Report');
224
+ lines.push('');
225
+ lines.push(`Generated: ${report.generatedAt}`);
226
+ lines.push('');
227
+
228
+ lines.push('## Captured Volume');
229
+ lines.push('');
230
+ lines.push(
231
+ `- ${report.counts.skillEvents} skill loads · ${report.counts.commandEvents} command invocations · ${report.counts.agentPairs} agent invocations (${report.counts.agentEvents} raw start/stop events)`
232
+ );
233
+ lines.push(`- ${report.counts.installedSkills} skills installed in this project`);
234
+ lines.push('');
235
+
236
+ lines.push('## Top Skills');
237
+ lines.push('');
238
+ if (report.skillStats.length === 0) {
239
+ lines.push('_No skill loads captured yet._');
240
+ } else {
241
+ lines.push('| Skill | Loads | Last seen |');
242
+ lines.push('|---|---|---|');
243
+ for (const stat of report.skillStats.slice(0, 10)) {
244
+ lines.push(`| \`${stat.key}\` | ${stat.count} | ${stat.lastSeen || '—'} |`);
245
+ }
246
+ }
247
+ lines.push('');
248
+
249
+ lines.push('## Top Commands');
250
+ lines.push('');
251
+ if (report.commandStats.length === 0) {
252
+ lines.push('_No command invocations captured yet._');
253
+ } else {
254
+ lines.push('| Command | Invocations |');
255
+ lines.push('|---|---|');
256
+ for (const stat of report.commandStats.slice(0, 10)) {
257
+ lines.push(`| \`${stat.key}\` | ${stat.count} |`);
258
+ }
259
+ }
260
+ lines.push('');
261
+
262
+ lines.push('## Agent Invocations');
263
+ lines.push('');
264
+ if (report.agentStats.length === 0) {
265
+ lines.push('_No agent events captured yet._');
266
+ } else {
267
+ lines.push('| Agent | Invocations | Completed | Failed | Avg duration |');
268
+ lines.push('|---|---|---|---|---|');
269
+ for (const stat of report.agentStats.slice(0, 10)) {
270
+ lines.push(
271
+ `| \`${stat.agent}\` | ${stat.invocations} | ${stat.completed} | ${stat.failed} | ${formatDuration(stat.avgDurationMs)} |`
272
+ );
273
+ }
274
+ }
275
+ lines.push('');
276
+
277
+ lines.push('## Anomalies');
278
+ lines.push('');
279
+ if (report.anomalies.length === 0) {
280
+ lines.push('_None detected._');
281
+ } else {
282
+ for (const a of report.anomalies) {
283
+ lines.push(`- ${a.message}`);
284
+ }
285
+ }
286
+ lines.push('');
287
+
288
+ lines.push('## Suggestions');
289
+ lines.push('');
290
+ if (report.suggestions.length === 0) {
291
+ lines.push('_None._');
292
+ } else {
293
+ for (const s of report.suggestions) {
294
+ lines.push(`- ${s.message}`);
295
+ }
296
+ }
297
+ lines.push('');
298
+
299
+ return lines.join('\n');
300
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: api-designer
3
- description: "Reviews API design for RESTful conventions"
3
+ description: Reviews API design for RESTful conventions
4
4
  model: opus
5
5
  isolation: none
6
6
  disallowedTools:
@@ -8,6 +8,12 @@ disallowedTools:
8
8
  - NotebookEdit
9
9
  - Agent
10
10
  maxTurns: 30
11
+ category: backend
12
+ triggerType: manual
13
+ whenToUse: Designing new API endpoints. Changing existing API contracts. Adding new routes or modifying request/response shapes.
14
+ whatItDoes: Reviews API design for RESTful conventions, naming consistency, backward compatibility, request/response shape validation.
15
+ expectBack: Design review with specific recommendations.
16
+ situationLabel: Designed a new API endpoint
11
17
  ---
12
18
 
13
19
  You are a senior API architect who reviews API designs for
@@ -1,9 +1,15 @@
1
1
  ---
2
2
  name: auth-auditor
3
- description: "Audits authentication and authorization"
3
+ description: Audits authentication and authorization
4
4
  model: opus
5
5
  isolation: none
6
6
  maxTurns: 40
7
+ category: backend
8
+ triggerType: manual
9
+ whenToUse: Any change to authentication or authorization flow. New roles, permissions, token handling.
10
+ whatItDoes: Reviews auth flows for correctness, token lifecycle, permission checks, session management, OWASP compliance.
11
+ expectBack: Audit report with pass/fail per check.
12
+ situationLabel: Changed auth or authorization logic
7
13
  ---
8
14
 
9
15
  You are a security-focused engineer specializing in authentication
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: database-analyst
3
- description: "Reviews database schemas and queries"
3
+ description: Reviews database schemas and queries
4
4
  model: sonnet
5
5
  isolation: none
6
6
  disallowedTools:
@@ -9,6 +9,12 @@ disallowedTools:
9
9
  - NotebookEdit
10
10
  - Agent
11
11
  maxTurns: 30
12
+ category: backend
13
+ triggerType: manual
14
+ whenToUse: Writing migrations. Changing schemas. Complex queries. Data integrity concerns.
15
+ whatItDoes: Reviews schema design, migration safety, query performance, index usage, data integrity constraints.
16
+ expectBack: Analysis with specific concerns and recommendations.
17
+ situationLabel: Wrote a database migration or schema change
12
18
  ---
13
19
 
14
20
  You are a database specialist who reviews schemas, queries, and