flonat-research 0.1.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 (285) hide show
  1. package/.claude/agents/domain-reviewer.md +336 -0
  2. package/.claude/agents/fixer.md +226 -0
  3. package/.claude/agents/paper-critic.md +370 -0
  4. package/.claude/agents/peer-reviewer.md +289 -0
  5. package/.claude/agents/proposal-reviewer.md +215 -0
  6. package/.claude/agents/referee2-reviewer.md +367 -0
  7. package/.claude/agents/references/journal-referee-profiles.md +354 -0
  8. package/.claude/agents/references/paper-critic/council-personas.md +77 -0
  9. package/.claude/agents/references/paper-critic/council-prompts.md +198 -0
  10. package/.claude/agents/references/peer-reviewer/report-template.md +199 -0
  11. package/.claude/agents/references/peer-reviewer/sa-prompts.md +260 -0
  12. package/.claude/agents/references/peer-reviewer/security-scan.md +188 -0
  13. package/.claude/agents/references/proposal-reviewer/report-template.md +144 -0
  14. package/.claude/agents/references/proposal-reviewer/sa-prompts.md +149 -0
  15. package/.claude/agents/references/referee-config.md +114 -0
  16. package/.claude/agents/references/referee2-reviewer/audit-checklists.md +287 -0
  17. package/.claude/agents/references/referee2-reviewer/report-template.md +334 -0
  18. package/.claude/rules/design-before-results.md +52 -0
  19. package/.claude/rules/ignore-agents-md.md +17 -0
  20. package/.claude/rules/ignore-gemini-md.md +17 -0
  21. package/.claude/rules/lean-claude-md.md +45 -0
  22. package/.claude/rules/learn-tags.md +99 -0
  23. package/.claude/rules/overleaf-separation.md +67 -0
  24. package/.claude/rules/plan-first.md +175 -0
  25. package/.claude/rules/read-docs-first.md +50 -0
  26. package/.claude/rules/scope-discipline.md +28 -0
  27. package/.claude/settings.json +125 -0
  28. package/.context/current-focus.md +33 -0
  29. package/.context/preferences/priorities.md +36 -0
  30. package/.context/preferences/task-naming.md +28 -0
  31. package/.context/profile.md +29 -0
  32. package/.context/projects/_index.md +41 -0
  33. package/.context/projects/papers/nudge-exp.md +22 -0
  34. package/.context/projects/papers/uncertainty.md +31 -0
  35. package/.context/resources/claude-scientific-writer-review.md +48 -0
  36. package/.context/resources/cunningham-multi-analyst-agents.md +104 -0
  37. package/.context/resources/cunningham-multilang-code-audit.md +62 -0
  38. package/.context/resources/google-ai-co-scientist-review.md +72 -0
  39. package/.context/resources/karpathy-llm-council-review.md +58 -0
  40. package/.context/resources/multi-coder-reliability-protocol.md +175 -0
  41. package/.context/resources/pedro-santanna-takeaways.md +96 -0
  42. package/.context/resources/venue-rankings/abs_ajg_2024.csv +1823 -0
  43. package/.context/resources/venue-rankings/abs_ajg_2024_econ.csv +356 -0
  44. package/.context/resources/venue-rankings/cabs_4_4star_theory.csv +40 -0
  45. package/.context/resources/venue-rankings/core_2026.csv +801 -0
  46. package/.context/resources/venue-rankings.md +147 -0
  47. package/.context/workflows/README.md +69 -0
  48. package/.context/workflows/daily-review.md +91 -0
  49. package/.context/workflows/meeting-actions.md +108 -0
  50. package/.context/workflows/replication-protocol.md +155 -0
  51. package/.context/workflows/weekly-review.md +113 -0
  52. package/.mcp-server-biblio/formatters.py +158 -0
  53. package/.mcp-server-biblio/pyproject.toml +11 -0
  54. package/.mcp-server-biblio/server.py +678 -0
  55. package/.mcp-server-biblio/sources/__init__.py +14 -0
  56. package/.mcp-server-biblio/sources/base.py +73 -0
  57. package/.mcp-server-biblio/sources/formatters.py +83 -0
  58. package/.mcp-server-biblio/sources/models.py +22 -0
  59. package/.mcp-server-biblio/sources/multi_source.py +243 -0
  60. package/.mcp-server-biblio/sources/openalex_source.py +183 -0
  61. package/.mcp-server-biblio/sources/scopus_source.py +309 -0
  62. package/.mcp-server-biblio/sources/wos_source.py +508 -0
  63. package/.mcp-server-biblio/uv.lock +896 -0
  64. package/.scripts/README.md +161 -0
  65. package/.scripts/ai_pattern_density.py +446 -0
  66. package/.scripts/conf +445 -0
  67. package/.scripts/config.py +122 -0
  68. package/.scripts/count_inventory.py +275 -0
  69. package/.scripts/daily_digest.py +288 -0
  70. package/.scripts/done +177 -0
  71. package/.scripts/extract_meeting_actions.py +223 -0
  72. package/.scripts/focus +176 -0
  73. package/.scripts/generate-codex-agents-md.py +217 -0
  74. package/.scripts/inbox +194 -0
  75. package/.scripts/notion_helpers.py +325 -0
  76. package/.scripts/openalex/query_helpers.py +306 -0
  77. package/.scripts/papers +227 -0
  78. package/.scripts/query +223 -0
  79. package/.scripts/session-history.py +201 -0
  80. package/.scripts/skill-health.py +516 -0
  81. package/.scripts/skill-log-miner.py +273 -0
  82. package/.scripts/sync-to-codex.sh +252 -0
  83. package/.scripts/task +213 -0
  84. package/.scripts/tasks +190 -0
  85. package/.scripts/week +206 -0
  86. package/CLAUDE.md +197 -0
  87. package/LICENSE +21 -0
  88. package/MEMORY.md +38 -0
  89. package/README.md +269 -0
  90. package/docs/agents.md +44 -0
  91. package/docs/bibliography-setup.md +55 -0
  92. package/docs/council-mode.md +36 -0
  93. package/docs/getting-started.md +245 -0
  94. package/docs/hooks.md +38 -0
  95. package/docs/mcp-servers.md +82 -0
  96. package/docs/notion-setup.md +109 -0
  97. package/docs/rules.md +33 -0
  98. package/docs/scripts.md +303 -0
  99. package/docs/setup-overview/setup-overview.pdf +0 -0
  100. package/docs/skills.md +70 -0
  101. package/docs/system.md +159 -0
  102. package/hooks/block-destructive-git.sh +66 -0
  103. package/hooks/context-monitor.py +114 -0
  104. package/hooks/postcompact-restore.py +157 -0
  105. package/hooks/precompact-autosave.py +181 -0
  106. package/hooks/promise-checker.sh +124 -0
  107. package/hooks/protect-source-files.sh +81 -0
  108. package/hooks/resume-context-loader.sh +53 -0
  109. package/hooks/startup-context-loader.sh +102 -0
  110. package/package.json +51 -0
  111. package/packages/cli-council/.github/workflows/claude-code-review.yml +44 -0
  112. package/packages/cli-council/.github/workflows/claude.yml +50 -0
  113. package/packages/cli-council/README.md +100 -0
  114. package/packages/cli-council/pyproject.toml +43 -0
  115. package/packages/cli-council/src/cli_council/__init__.py +19 -0
  116. package/packages/cli-council/src/cli_council/__main__.py +185 -0
  117. package/packages/cli-council/src/cli_council/backends/__init__.py +8 -0
  118. package/packages/cli-council/src/cli_council/backends/base.py +81 -0
  119. package/packages/cli-council/src/cli_council/backends/claude.py +25 -0
  120. package/packages/cli-council/src/cli_council/backends/codex.py +27 -0
  121. package/packages/cli-council/src/cli_council/backends/gemini.py +26 -0
  122. package/packages/cli-council/src/cli_council/checkpoint.py +212 -0
  123. package/packages/cli-council/src/cli_council/config.py +51 -0
  124. package/packages/cli-council/src/cli_council/council.py +391 -0
  125. package/packages/cli-council/src/cli_council/models.py +46 -0
  126. package/packages/llm-council/.github/workflows/claude-code-review.yml +44 -0
  127. package/packages/llm-council/.github/workflows/claude.yml +50 -0
  128. package/packages/llm-council/README.md +453 -0
  129. package/packages/llm-council/pyproject.toml +42 -0
  130. package/packages/llm-council/src/llm_council/__init__.py +23 -0
  131. package/packages/llm-council/src/llm_council/__main__.py +259 -0
  132. package/packages/llm-council/src/llm_council/checkpoint.py +193 -0
  133. package/packages/llm-council/src/llm_council/client.py +253 -0
  134. package/packages/llm-council/src/llm_council/config.py +232 -0
  135. package/packages/llm-council/src/llm_council/council.py +482 -0
  136. package/packages/llm-council/src/llm_council/models.py +46 -0
  137. package/packages/mcp-bibliography/MEMORY.md +31 -0
  138. package/packages/mcp-bibliography/_app.py +226 -0
  139. package/packages/mcp-bibliography/formatters.py +158 -0
  140. package/packages/mcp-bibliography/log/2026-03-13-2100.md +35 -0
  141. package/packages/mcp-bibliography/pyproject.toml +15 -0
  142. package/packages/mcp-bibliography/run.sh +20 -0
  143. package/packages/mcp-bibliography/scholarly_formatters.py +83 -0
  144. package/packages/mcp-bibliography/server.py +1857 -0
  145. package/packages/mcp-bibliography/tools/__init__.py +28 -0
  146. package/packages/mcp-bibliography/tools/_registry.py +19 -0
  147. package/packages/mcp-bibliography/tools/altmetric.py +107 -0
  148. package/packages/mcp-bibliography/tools/core.py +92 -0
  149. package/packages/mcp-bibliography/tools/dblp.py +52 -0
  150. package/packages/mcp-bibliography/tools/openalex.py +296 -0
  151. package/packages/mcp-bibliography/tools/opencitations.py +102 -0
  152. package/packages/mcp-bibliography/tools/openreview.py +179 -0
  153. package/packages/mcp-bibliography/tools/orcid.py +131 -0
  154. package/packages/mcp-bibliography/tools/scholarly.py +575 -0
  155. package/packages/mcp-bibliography/tools/unpaywall.py +63 -0
  156. package/packages/mcp-bibliography/tools/zenodo.py +123 -0
  157. package/packages/mcp-bibliography/uv.lock +711 -0
  158. package/scripts/setup.sh +143 -0
  159. package/skills/beamer-deck/SKILL.md +199 -0
  160. package/skills/beamer-deck/references/quality-rubric.md +54 -0
  161. package/skills/beamer-deck/references/review-prompts.md +106 -0
  162. package/skills/bib-validate/SKILL.md +261 -0
  163. package/skills/bib-validate/references/council-mode.md +34 -0
  164. package/skills/bib-validate/references/deep-verify.md +79 -0
  165. package/skills/bib-validate/references/fix-mode.md +36 -0
  166. package/skills/bib-validate/references/openalex-verification.md +45 -0
  167. package/skills/bib-validate/references/preprint-check.md +31 -0
  168. package/skills/bib-validate/references/ref-manager-crossref.md +41 -0
  169. package/skills/bib-validate/references/report-template.md +82 -0
  170. package/skills/code-archaeology/SKILL.md +141 -0
  171. package/skills/code-review/SKILL.md +265 -0
  172. package/skills/code-review/references/quality-rubric.md +67 -0
  173. package/skills/consolidate-memory/SKILL.md +208 -0
  174. package/skills/context-status/SKILL.md +126 -0
  175. package/skills/creation-guard/SKILL.md +230 -0
  176. package/skills/devils-advocate/SKILL.md +130 -0
  177. package/skills/devils-advocate/references/competing-hypotheses.md +83 -0
  178. package/skills/init-project/SKILL.md +115 -0
  179. package/skills/init-project-course/references/memory-and-settings.md +92 -0
  180. package/skills/init-project-course/references/organise-templates.md +94 -0
  181. package/skills/init-project-course/skill.md +147 -0
  182. package/skills/init-project-light/skill.md +139 -0
  183. package/skills/init-project-research/SKILL.md +368 -0
  184. package/skills/init-project-research/references/atlas-pipeline-sync.md +70 -0
  185. package/skills/init-project-research/references/atlas-schema.md +81 -0
  186. package/skills/init-project-research/references/confirmation-report.md +39 -0
  187. package/skills/init-project-research/references/domain-profile-template.md +104 -0
  188. package/skills/init-project-research/references/interview-round3.md +34 -0
  189. package/skills/init-project-research/references/literature-discovery.md +43 -0
  190. package/skills/init-project-research/references/scaffold-details.md +197 -0
  191. package/skills/init-project-research/templates/field-calibration.md +60 -0
  192. package/skills/init-project-research/templates/pipeline-manifest.md +63 -0
  193. package/skills/init-project-research/templates/run-all.sh +116 -0
  194. package/skills/init-project-research/templates/seed-files.md +337 -0
  195. package/skills/insights-deck/SKILL.md +151 -0
  196. package/skills/interview-me/SKILL.md +157 -0
  197. package/skills/latex/SKILL.md +141 -0
  198. package/skills/latex/references/latex-configs.md +183 -0
  199. package/skills/latex-autofix/SKILL.md +230 -0
  200. package/skills/latex-autofix/references/known-errors.md +183 -0
  201. package/skills/latex-autofix/references/quality-rubric.md +50 -0
  202. package/skills/latex-health-check/SKILL.md +161 -0
  203. package/skills/learn/SKILL.md +220 -0
  204. package/skills/learn/scripts/validate_skill.py +265 -0
  205. package/skills/lessons-learned/SKILL.md +201 -0
  206. package/skills/literature/SKILL.md +335 -0
  207. package/skills/literature/references/agent-templates.md +393 -0
  208. package/skills/literature/references/bibliometric-apis.md +44 -0
  209. package/skills/literature/references/cli-council-search.md +79 -0
  210. package/skills/literature/references/openalex-api-guide.md +371 -0
  211. package/skills/literature/references/openalex-common-queries.md +381 -0
  212. package/skills/literature/references/openalex-workflows.md +248 -0
  213. package/skills/literature/references/reference-manager-sync.md +36 -0
  214. package/skills/literature/references/scopus-api-guide.md +208 -0
  215. package/skills/literature/references/wos-api-guide.md +308 -0
  216. package/skills/multi-perspective/SKILL.md +311 -0
  217. package/skills/multi-perspective/references/computational-many-analysts.md +77 -0
  218. package/skills/pipeline-manifest/SKILL.md +226 -0
  219. package/skills/pre-submission-report/SKILL.md +153 -0
  220. package/skills/process-reviews/SKILL.md +244 -0
  221. package/skills/process-reviews/references/rr-routing.md +101 -0
  222. package/skills/project-deck/SKILL.md +87 -0
  223. package/skills/project-safety/SKILL.md +135 -0
  224. package/skills/proofread/SKILL.md +254 -0
  225. package/skills/proofread/references/quality-rubric.md +104 -0
  226. package/skills/python-env/SKILL.md +57 -0
  227. package/skills/quarto-deck/SKILL.md +226 -0
  228. package/skills/quarto-deck/references/markdown-format.md +143 -0
  229. package/skills/quarto-deck/references/quality-rubric.md +54 -0
  230. package/skills/save-context/SKILL.md +174 -0
  231. package/skills/session-log/SKILL.md +98 -0
  232. package/skills/shared/concept-validation-gate.md +161 -0
  233. package/skills/shared/council-protocol.md +265 -0
  234. package/skills/shared/distribution-diagnostics.md +164 -0
  235. package/skills/shared/engagement-stratified-sampling.md +218 -0
  236. package/skills/shared/escalation-protocol.md +74 -0
  237. package/skills/shared/external-audit-protocol.md +205 -0
  238. package/skills/shared/intercoder-reliability.md +256 -0
  239. package/skills/shared/mcp-degradation.md +81 -0
  240. package/skills/shared/method-probing-questions.md +163 -0
  241. package/skills/shared/multi-language-conventions.md +143 -0
  242. package/skills/shared/paid-api-safety.md +174 -0
  243. package/skills/shared/palettes.md +90 -0
  244. package/skills/shared/progressive-disclosure.md +92 -0
  245. package/skills/shared/project-documentation-content.md +443 -0
  246. package/skills/shared/project-documentation-format.md +281 -0
  247. package/skills/shared/project-documentation.md +100 -0
  248. package/skills/shared/publication-output.md +138 -0
  249. package/skills/shared/quality-scoring.md +70 -0
  250. package/skills/shared/reference-resolution.md +77 -0
  251. package/skills/shared/research-quality-rubric.md +165 -0
  252. package/skills/shared/rhetoric-principles.md +54 -0
  253. package/skills/shared/skill-design-patterns.md +272 -0
  254. package/skills/shared/skill-index.md +240 -0
  255. package/skills/shared/system-documentation.md +334 -0
  256. package/skills/shared/tikz-rules.md +402 -0
  257. package/skills/shared/validation-tiers.md +121 -0
  258. package/skills/shared/venue-guides/README.md +46 -0
  259. package/skills/shared/venue-guides/cell_press_style.md +483 -0
  260. package/skills/shared/venue-guides/conferences_formatting.md +564 -0
  261. package/skills/shared/venue-guides/cs_conference_style.md +463 -0
  262. package/skills/shared/venue-guides/examples/cell_summary_example.md +247 -0
  263. package/skills/shared/venue-guides/examples/medical_structured_abstract.md +313 -0
  264. package/skills/shared/venue-guides/examples/nature_abstract_examples.md +213 -0
  265. package/skills/shared/venue-guides/examples/neurips_introduction_example.md +245 -0
  266. package/skills/shared/venue-guides/journals_formatting.md +486 -0
  267. package/skills/shared/venue-guides/medical_journal_styles.md +535 -0
  268. package/skills/shared/venue-guides/ml_conference_style.md +556 -0
  269. package/skills/shared/venue-guides/nature_science_style.md +405 -0
  270. package/skills/shared/venue-guides/reviewer_expectations.md +417 -0
  271. package/skills/shared/venue-guides/venue_writing_styles.md +321 -0
  272. package/skills/split-pdf/SKILL.md +172 -0
  273. package/skills/split-pdf/methodology.md +48 -0
  274. package/skills/sync-notion/SKILL.md +93 -0
  275. package/skills/system-audit/SKILL.md +157 -0
  276. package/skills/system-audit/references/sub-agent-prompts.md +294 -0
  277. package/skills/task-management/SKILL.md +131 -0
  278. package/skills/update-focus/SKILL.md +204 -0
  279. package/skills/update-project-doc/SKILL.md +194 -0
  280. package/skills/validate-bib/SKILL.md +242 -0
  281. package/skills/validate-bib/references/council-mode.md +34 -0
  282. package/skills/validate-bib/references/deep-verify.md +71 -0
  283. package/skills/validate-bib/references/openalex-verification.md +45 -0
  284. package/skills/validate-bib/references/preprint-check.md +31 -0
  285. package/skills/validate-bib/references/report-template.md +62 -0
@@ -0,0 +1,516 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skill Health Assessment — Phase A, Step 2
4
+
5
+ Reads observation events from ~/.claude/ecc/observations-*.jsonl,
6
+ correlates pre/post events, computes per-skill health metrics,
7
+ and outputs a structured report.
8
+
9
+ Usage:
10
+ uv run python .scripts/skill-health.py # full report
11
+ uv run python .scripts/skill-health.py --skill proofread # single skill
12
+ uv run python .scripts/skill-health.py --health failing # filter by health
13
+ uv run python .scripts/skill-health.py --activity dormant # filter by activity
14
+ uv run python .scripts/skill-health.py --top 10 # top N most-used
15
+ uv run python .scripts/skill-health.py --purge # run maintenance
16
+ uv run python .scripts/skill-health.py --json # JSON output
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+ from collections import defaultdict
24
+ from datetime import datetime, timedelta, timezone
25
+ from pathlib import Path
26
+ from statistics import median
27
+
28
+ ECC_DIR = Path.home() / ".claude" / "ecc"
29
+ ROLLING_WINDOW_DAYS = 30
30
+ PURGE_DAYS = 90
31
+ MAX_TOTAL_SIZE_MB = 50
32
+
33
+
34
+ def parse_args():
35
+ p = argparse.ArgumentParser(description="Skill health assessment")
36
+ p.add_argument("--skill", help="Show detail for a single skill")
37
+ p.add_argument("--health", choices=["failing", "declining", "watch", "healthy", "insufficient_data", "low_observability"],
38
+ help="Filter by health status")
39
+ p.add_argument("--activity", choices=["active", "regular", "dormant"],
40
+ help="Filter by activity status")
41
+ p.add_argument("--top", type=int, help="Show top N most-used skills")
42
+ p.add_argument("--purge", action="store_true", help="Run maintenance (delete old files)")
43
+ p.add_argument("--json", action="store_true", help="Output as JSON")
44
+ p.add_argument("--all-time", action="store_true", help="Use all data, not rolling window")
45
+ return p.parse_args()
46
+
47
+
48
+ def load_events(window_start: datetime | None = None) -> list[dict]:
49
+ """Load all events from daily JSONL files, optionally filtered by date."""
50
+ events = []
51
+ if not ECC_DIR.exists():
52
+ return events
53
+
54
+ for filepath in sorted(ECC_DIR.glob("observations-*.jsonl")):
55
+ # Extract date from filename for quick filtering
56
+ try:
57
+ file_date_str = filepath.stem.replace("observations-", "")
58
+ file_date = datetime.strptime(file_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
59
+ if window_start and file_date < window_start - timedelta(days=1):
60
+ continue
61
+ except ValueError:
62
+ continue
63
+
64
+ try:
65
+ with open(filepath) as f:
66
+ for line_num, line in enumerate(f, 1):
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ event = json.loads(line)
72
+ if event.get("schema") == "skill-event.v1":
73
+ events.append(event)
74
+ except json.JSONDecodeError:
75
+ print(f" Warning: malformed line {line_num} in {filepath.name}", file=sys.stderr)
76
+ except OSError as e:
77
+ print(f" Warning: cannot read {filepath}: {e}", file=sys.stderr)
78
+
79
+ # Also load rule-based outcome logs
80
+ outcomes_file = ECC_DIR / "skill-outcomes.jsonl"
81
+ if outcomes_file.exists():
82
+ try:
83
+ with open(outcomes_file) as f:
84
+ for line_num, line in enumerate(f, 1):
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ try:
89
+ record = json.loads(line)
90
+ # Convert outcome log to skill-event.v1 format
91
+ if "skill" in record and "outcome" in record:
92
+ ts = record.get("timestamp", "")
93
+ if window_start and ts:
94
+ try:
95
+ record_dt = datetime.fromisoformat(ts)
96
+ if record_dt.tzinfo is None:
97
+ record_dt = record_dt.replace(tzinfo=timezone.utc)
98
+ if record_dt < window_start:
99
+ continue
100
+ except ValueError:
101
+ pass
102
+ events.append({
103
+ "schema": "skill-event.v1",
104
+ "phase": "outcome",
105
+ "skill": record["skill"],
106
+ "timestamp": ts,
107
+ "outcome": record["outcome"],
108
+ "outcome_source": "rule",
109
+ "heuristic_version": 1,
110
+ "error_class": record.get("note", "")[:100] if record.get("outcome") != "success" else None,
111
+ "session_hash": record.get("session", ""),
112
+ "project_label": record.get("project", ""),
113
+ "project_hash": record.get("project", ""),
114
+ })
115
+ except json.JSONDecodeError:
116
+ print(f" Warning: malformed line {line_num} in skill-outcomes.jsonl", file=sys.stderr)
117
+ except OSError:
118
+ pass
119
+
120
+ return events
121
+
122
+
123
+ def correlate_events(events: list[dict]) -> dict:
124
+ """
125
+ Group events by skill, correlate pre/post by invocation_id.
126
+ Returns: {skill_name: {"pre": [...], "post": [...], "paired": [...]}}
127
+ """
128
+ by_skill: dict[str, dict] = defaultdict(lambda: {"pre": [], "post": [], "paired": [], "mined": []})
129
+
130
+ # Index by invocation_id for correlation
131
+ pre_index: dict[str, dict] = {}
132
+ post_index: dict[str, dict] = {}
133
+
134
+ for event in events:
135
+ skill = event.get("skill", "unknown")
136
+ phase = event.get("phase", "")
137
+ inv_id = event.get("invocation_id", "")
138
+
139
+ if phase == "pre":
140
+ by_skill[skill]["pre"].append(event)
141
+ if inv_id:
142
+ pre_index[inv_id] = event
143
+ elif phase == "post":
144
+ by_skill[skill]["post"].append(event)
145
+ if inv_id:
146
+ post_index[inv_id] = event
147
+ elif phase == "mined":
148
+ by_skill[skill]["mined"].append(event)
149
+ elif phase == "outcome":
150
+ by_skill[skill]["post"].append(event) # outcome events have the same shape as post events
151
+
152
+ # Pair pre/post events by invocation_id
153
+ for inv_id, pre_event in pre_index.items():
154
+ if inv_id in post_index:
155
+ post_event = post_index[inv_id]
156
+ skill = pre_event.get("skill", "unknown")
157
+ try:
158
+ pre_ts = datetime.fromisoformat(pre_event["timestamp"])
159
+ post_ts = datetime.fromisoformat(post_event["timestamp"])
160
+ duration_ms = int((post_ts - pre_ts).total_seconds() * 1000)
161
+ by_skill[skill]["paired"].append({
162
+ "pre": pre_event,
163
+ "post": post_event,
164
+ "duration_ms": max(0, duration_ms),
165
+ })
166
+ except (KeyError, ValueError):
167
+ pass
168
+
169
+ return dict(by_skill)
170
+
171
+
172
+ def _windowed_success_rate(post_events: list[dict], now: datetime, days: int) -> float | None:
173
+ """Compute success rate over the last N days from post/outcome events."""
174
+ cutoff = now - timedelta(days=days)
175
+ success = 0
176
+ classified = 0
177
+ for post in post_events:
178
+ ts_str = post.get("timestamp", "")
179
+ if not ts_str:
180
+ continue
181
+ try:
182
+ ts = datetime.fromisoformat(ts_str)
183
+ if ts.tzinfo is None:
184
+ ts = ts.replace(tzinfo=timezone.utc)
185
+ except ValueError:
186
+ continue
187
+ if ts < cutoff:
188
+ continue
189
+ outcome = post.get("outcome", "unknown")
190
+ if outcome in ("success", "error"):
191
+ classified += 1
192
+ if outcome == "success":
193
+ success += 1
194
+ return success / classified if classified >= 3 else None
195
+
196
+
197
+ TREND_THRESHOLD = 0.10 # 10% drop triggers "worsening"
198
+
199
+
200
+ def compute_metrics(skill_data: dict, now: datetime) -> dict:
201
+ """Compute health metrics for a single skill."""
202
+ pre_events = skill_data["pre"]
203
+ post_events = skill_data["post"]
204
+ paired = skill_data["paired"]
205
+ mined_events = skill_data.get("mined", [])
206
+
207
+ # Total invocations: prefer hook data, fall back to mined
208
+ total = len(pre_events)
209
+ if total == 0:
210
+ total = len(post_events)
211
+ # Add mined invocations (each mined event = 1 session mention)
212
+ total += len(mined_events)
213
+
214
+ # Outcome classification
215
+ outcomes = defaultdict(int)
216
+ error_classes = defaultdict(int)
217
+ for post in post_events:
218
+ outcome = post.get("outcome", "unknown")
219
+ outcomes[outcome] += 1
220
+ if outcome == "error" and post.get("error_class"):
221
+ error_classes[post["error_class"]] += 1
222
+
223
+ classified = outcomes.get("success", 0) + outcomes.get("error", 0)
224
+ unknown_count = outcomes.get("unknown", 0)
225
+
226
+ success_rate = outcomes["success"] / classified if classified > 0 else None
227
+ error_rate = outcomes["error"] / total if total > 0 else 0.0
228
+ unknown_rate = unknown_count / total if total > 0 else 1.0
229
+
230
+ # --- Trend detection (7d vs 30d windowed success rates) ---
231
+ rate_7d = _windowed_success_rate(post_events, now, 7)
232
+ rate_30d = _windowed_success_rate(post_events, now, 30)
233
+
234
+ if rate_7d is not None and rate_30d is not None:
235
+ delta = rate_7d - rate_30d
236
+ if delta <= -TREND_THRESHOLD:
237
+ trend = "worsening"
238
+ declining = True
239
+ elif delta >= TREND_THRESHOLD:
240
+ trend = "improving"
241
+ declining = False
242
+ else:
243
+ trend = "stable"
244
+ declining = False
245
+ else:
246
+ trend = "insufficient_data"
247
+ declining = False
248
+
249
+ # Duration from paired events
250
+ durations = [p["duration_ms"] for p in paired if p["duration_ms"] > 0]
251
+ median_duration = median(durations) if durations else None
252
+
253
+ # Last used
254
+ timestamps = []
255
+ for e in pre_events + post_events + mined_events:
256
+ try:
257
+ timestamps.append(datetime.fromisoformat(e["timestamp"]))
258
+ except (KeyError, ValueError):
259
+ pass
260
+ last_used = max(timestamps) if timestamps else None
261
+
262
+ # Project diversity
263
+ project_hashes = set()
264
+ for e in pre_events + mined_events:
265
+ ph = e.get("project_hash")
266
+ if ph:
267
+ project_hashes.add(ph)
268
+
269
+ # Re-invocation rate (from hook data only — mined data is per-session)
270
+ sessions = defaultdict(int)
271
+ for e in pre_events:
272
+ sh = e.get("session_hash")
273
+ if sh:
274
+ sessions[sh] += 1
275
+ # Mined events with mention_count > 1 suggest re-invocation
276
+ for e in mined_events:
277
+ sh = e.get("session_hash")
278
+ mc = e.get("mention_count", 1)
279
+ if sh:
280
+ sessions[sh] += mc
281
+ sessions_with_reinvoke = sum(1 for count in sessions.values() if count >= 2)
282
+ reinvoke_rate = sessions_with_reinvoke / len(sessions) if sessions else 0.0
283
+
284
+ # Top errors
285
+ top_errors = sorted(error_classes.items(), key=lambda x: -x[1])[:3]
286
+
287
+ # Skill mtime (most recent)
288
+ mtimes = [e.get("skill_mtime") for e in pre_events if e.get("skill_mtime")]
289
+ latest_mtime = max(mtimes) if mtimes else None
290
+
291
+ # Health status
292
+ if classified < 10:
293
+ health = "insufficient_data"
294
+ elif unknown_rate > 0.7:
295
+ health = "low_observability"
296
+ elif error_rate >= 0.3:
297
+ health = "failing"
298
+ elif declining:
299
+ health = "declining"
300
+ elif error_rate >= 0.1:
301
+ health = "watch"
302
+ else:
303
+ health = "healthy"
304
+
305
+ # Activity status
306
+ if last_used:
307
+ days_since = (now - last_used).days
308
+ if days_since <= 7:
309
+ activity = "active"
310
+ elif days_since <= 30:
311
+ activity = "regular"
312
+ else:
313
+ activity = "dormant"
314
+ else:
315
+ activity = "dormant"
316
+
317
+ return {
318
+ "total_invocations": total,
319
+ "classified_invocations": classified,
320
+ "success_rate": round(success_rate, 3) if success_rate is not None else None,
321
+ "error_rate": round(error_rate, 3),
322
+ "unknown_rate": round(unknown_rate, 3),
323
+ "error_count": outcomes.get("error", 0),
324
+ "rate_7d": round(rate_7d, 3) if rate_7d is not None else None,
325
+ "rate_30d": round(rate_30d, 3) if rate_30d is not None else None,
326
+ "trend": trend,
327
+ "declining": declining,
328
+ "median_duration_ms": int(median_duration) if median_duration else None,
329
+ "last_used": last_used.isoformat() if last_used else None,
330
+ "project_count": len(project_hashes),
331
+ "re_invocation_rate": round(reinvoke_rate, 3),
332
+ "top_errors": [{"class": cls, "count": cnt} for cls, cnt in top_errors],
333
+ "health": health,
334
+ "activity": activity,
335
+ "latest_skill_mtime": latest_mtime,
336
+ }
337
+
338
+
339
+ def purge_old_files():
340
+ """Delete observation files older than PURGE_DAYS. Enforce total size cap."""
341
+ if not ECC_DIR.exists():
342
+ print("No ecc directory found.")
343
+ return
344
+
345
+ cutoff = datetime.now() - timedelta(days=PURGE_DAYS)
346
+ files = sorted(ECC_DIR.glob("observations-*.jsonl"))
347
+ deleted = 0
348
+
349
+ # Phase 1: delete by age
350
+ for f in files:
351
+ try:
352
+ date_str = f.stem.replace("observations-", "")
353
+ file_date = datetime.strptime(date_str, "%Y-%m-%d")
354
+ if file_date < cutoff:
355
+ f.unlink()
356
+ deleted += 1
357
+ print(f" Purged: {f.name} (older than {PURGE_DAYS} days)")
358
+ except (ValueError, OSError):
359
+ pass
360
+
361
+ # Phase 2: enforce size cap
362
+ remaining = sorted(ECC_DIR.glob("observations-*.jsonl"))
363
+ total_size = sum(f.stat().st_size for f in remaining if f.exists())
364
+ max_bytes = MAX_TOTAL_SIZE_MB * 1024 * 1024
365
+
366
+ while total_size > max_bytes and remaining:
367
+ oldest = remaining.pop(0)
368
+ size = oldest.stat().st_size
369
+ oldest.unlink()
370
+ total_size -= size
371
+ deleted += 1
372
+ print(f" Purged: {oldest.name} (size cap)")
373
+
374
+ print(f"Maintenance complete. {deleted} files purged. "
375
+ f"{len(list(ECC_DIR.glob('observations-*.jsonl')))} files remaining.")
376
+
377
+
378
+ def format_table(report: dict, args) -> str:
379
+ """Format the health report as a readable table."""
380
+ if not report:
381
+ return "No observation data found. The skill observer hook may not have fired yet.\n" \
382
+ "Invoke any skill (e.g., /context-status) and check ~/.claude/ecc/ for data."
383
+
384
+ # Sort by total invocations descending
385
+ items = sorted(report.items(), key=lambda x: -x[1]["total_invocations"])
386
+
387
+ if args.top:
388
+ items = items[:args.top]
389
+
390
+ lines = []
391
+ lines.append(f"{'Skill':<30} {'Invocations':>11} {'Success':>8} {'Errors':>7} {'Trend':<13} {'Health':<18} {'Activity':<10} {'Last Used':<12}")
392
+ lines.append("-" * 115)
393
+
394
+ for skill, m in items:
395
+ sr = f"{m['success_rate']:.0%}" if m["success_rate"] is not None else "n/a"
396
+ ec = str(m["error_count"])
397
+ lu = m["last_used"][:10] if m["last_used"] else "never"
398
+
399
+ # Trend indicator
400
+ trend = m.get("trend", "")
401
+ if trend == "worsening":
402
+ trend_display = "!! DECLINING"
403
+ elif trend == "improving":
404
+ trend_display = "^ improving"
405
+ elif trend == "stable":
406
+ trend_display = "-- stable"
407
+ else:
408
+ trend_display = " n/a"
409
+
410
+ # Health indicator
411
+ health_display = m["health"]
412
+ if m["health"] == "failing":
413
+ health_display = "!! FAILING"
414
+ elif m["health"] == "declining":
415
+ health_display = "!! DECLINING"
416
+ elif m["health"] == "watch":
417
+ health_display = "? WATCH"
418
+ elif m["health"] == "healthy":
419
+ health_display = "ok HEALTHY"
420
+
421
+ lines.append(f"{skill:<30} {m['total_invocations']:>11} {sr:>8} {ec:>7} {trend_display:<13} {health_display:<18} {m['activity']:<10} {lu:<12}")
422
+
423
+ # Summary
424
+ total_skills = len(report)
425
+ failing = sum(1 for m in report.values() if m["health"] == "failing")
426
+ declining_count = sum(1 for m in report.values() if m["health"] == "declining")
427
+ watching = sum(1 for m in report.values() if m["health"] == "watch")
428
+ healthy = sum(1 for m in report.values() if m["health"] == "healthy")
429
+ insufficient = sum(1 for m in report.values() if m["health"] in ("insufficient_data", "low_observability"))
430
+
431
+ lines.append("")
432
+ lines.append(f"Summary: {total_skills} skills observed | "
433
+ f"{failing} failing | {declining_count} declining | {watching} watch | {healthy} healthy | {insufficient} insufficient data")
434
+
435
+ return "\n".join(lines)
436
+
437
+
438
+ def format_detail(skill: str, metrics: dict) -> str:
439
+ """Format detailed view for a single skill."""
440
+ lines = [f"Skill: {skill}", "=" * 40]
441
+
442
+ for key, val in metrics.items():
443
+ if key == "top_errors" and val:
444
+ lines.append(f" top_errors:")
445
+ for err in val:
446
+ lines.append(f" - {err['class']} ({err['count']}x)")
447
+ else:
448
+ lines.append(f" {key}: {val}")
449
+
450
+ return "\n".join(lines)
451
+
452
+
453
+ def main():
454
+ args = parse_args()
455
+
456
+ if args.purge:
457
+ purge_old_files()
458
+ return
459
+
460
+ now = datetime.now(timezone.utc)
461
+ window_start = None if args.all_time else now - timedelta(days=ROLLING_WINDOW_DAYS)
462
+
463
+ events = load_events(window_start)
464
+ if not events:
465
+ if args.json:
466
+ print(json.dumps({"skills": {}, "meta": {"event_count": 0}}))
467
+ else:
468
+ print("No observation data found in ~/.claude/ecc/.")
469
+ print("The skill observer hook may not have fired yet.")
470
+ print("Invoke any skill and check ~/.claude/ecc/ for observation files.")
471
+ return
472
+
473
+ # Filter by window
474
+ if window_start:
475
+ events = [e for e in events
476
+ if datetime.fromisoformat(e.get("timestamp", "2000-01-01T00:00:00+00:00")) >= window_start]
477
+
478
+ by_skill = correlate_events(events)
479
+
480
+ # Compute metrics
481
+ report = {}
482
+ for skill_name, data in by_skill.items():
483
+ report[skill_name] = compute_metrics(data, now)
484
+
485
+ # Apply filters
486
+ if args.health:
487
+ report = {k: v for k, v in report.items() if v["health"] == args.health}
488
+ if args.activity:
489
+ report = {k: v for k, v in report.items() if v["activity"] == args.activity}
490
+
491
+ # Output
492
+ if args.skill:
493
+ if args.skill in report:
494
+ if args.json:
495
+ print(json.dumps({args.skill: report[args.skill]}, indent=2))
496
+ else:
497
+ print(format_detail(args.skill, report[args.skill]))
498
+ else:
499
+ print(f"No data for skill '{args.skill}' in the current window.")
500
+ return
501
+
502
+ if args.json:
503
+ print(json.dumps({
504
+ "skills": report,
505
+ "meta": {
506
+ "event_count": len(events),
507
+ "window_days": ROLLING_WINDOW_DAYS if not args.all_time else "all",
508
+ "generated_at": now.isoformat(),
509
+ }
510
+ }, indent=2))
511
+ else:
512
+ print(format_table(report, args))
513
+
514
+
515
+ if __name__ == "__main__":
516
+ main()