mishkan-harness 0.2.0 → 0.2.3
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.
- package/README.md +108 -172
- package/bin/mishkan.js +677 -16
- package/docs/design/MISHKAN_decisions.md +450 -8
- package/docs/design/MISHKAN_harness_design.md +1 -33
- package/docs/design/MISHKAN_observability.md +13 -9
- package/docs/research/graphify-token-saving-poc.md +108 -0
- package/docs/usage/09-workflows.md +74 -4
- package/docs/usage/10-observability.md +37 -13
- package/docs/usage/11-graphify.md +264 -0
- package/docs/usage/12-skill-discovery.md +294 -0
- package/docs/usage/README.md +5 -3
- package/package.json +1 -1
- package/payload/install/settings.hooks.json +24 -0
- package/payload/mishkan/AGENT_SPEC.md +25 -7
- package/payload/mishkan/agents/ahikam.md +4 -2
- package/payload/mishkan/agents/aholiab.md +4 -2
- package/payload/mishkan/agents/asaph.md +5 -2
- package/payload/mishkan/agents/baruch.md +19 -2
- package/payload/mishkan/agents/benaiah.md +4 -2
- package/payload/mishkan/agents/bezalel.md +4 -2
- package/payload/mishkan/agents/caleb.md +4 -2
- package/payload/mishkan/agents/deborah.md +4 -2
- package/payload/mishkan/agents/elasah.md +4 -2
- package/payload/mishkan/agents/eliashib.md +4 -2
- package/payload/mishkan/agents/ezra.md +4 -2
- package/payload/mishkan/agents/hanun.md +5 -2
- package/payload/mishkan/agents/hiram.md +5 -2
- package/payload/mishkan/agents/hizkiah.md +5 -2
- package/payload/mishkan/agents/huldah.md +4 -2
- package/payload/mishkan/agents/huram.md +4 -2
- package/payload/mishkan/agents/hushai.md +5 -2
- package/payload/mishkan/agents/igal.md +4 -2
- package/payload/mishkan/agents/ira.md +5 -2
- package/payload/mishkan/agents/jahaziel.md +5 -2
- package/payload/mishkan/agents/jakin.md +4 -2
- package/payload/mishkan/agents/jehonathan.md +5 -2
- package/payload/mishkan/agents/jehoshaphat.md +4 -2
- package/payload/mishkan/agents/joab.md +5 -2
- package/payload/mishkan/agents/joah.md +5 -2
- package/payload/mishkan/agents/maaseiah.md +4 -2
- package/payload/mishkan/agents/meremoth.md +5 -2
- package/payload/mishkan/agents/meshullam.md +5 -2
- package/payload/mishkan/agents/nathan.md +5 -2
- package/payload/mishkan/agents/nehemiah.md +4 -2
- package/payload/mishkan/agents/obed.md +4 -2
- package/payload/mishkan/agents/oholiab.md +5 -2
- package/payload/mishkan/agents/palal.md +5 -2
- package/payload/mishkan/agents/phinehas.md +4 -2
- package/payload/mishkan/agents/rehum.md +4 -2
- package/payload/mishkan/agents/salma.md +5 -2
- package/payload/mishkan/agents/seraiah.md +4 -2
- package/payload/mishkan/agents/shallum.md +5 -2
- package/payload/mishkan/agents/shaphan.md +4 -2
- package/payload/mishkan/agents/shemaiah.md +4 -2
- package/payload/mishkan/agents/shevna.md +5 -2
- package/payload/mishkan/agents/uriah.md +5 -2
- package/payload/mishkan/agents/zaccur.md +4 -2
- package/payload/mishkan/agents/zadok.md +5 -2
- package/payload/mishkan/agents/zerubbabel.md +4 -2
- package/payload/mishkan/commands/code-graph.md +35 -0
- package/payload/mishkan/commands/eval-baruch.md +57 -0
- package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
- package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
- package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
- package/payload/mishkan/commands/skills.md +35 -0
- package/payload/mishkan/commands/sprint-close.md +3 -1
- package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
- package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
- package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
- package/payload/mishkan/observability/schema.json +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
- package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
- package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
- package/payload/mishkan/org/org.json +141 -0
- package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
- package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
- package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
- package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
- package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
- package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
- package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
- package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
- package/payload/mishkan/templates/case-node.schema.json +1 -0
- package/payload/mishkan/templates/observability-log.schema.json +1 -0
- package/payload/mishkan/workflows/README.md +78 -13
- package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
- package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
- package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
- package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
- package/payload/mishkan/workflows/mishkan-init.js +58 -0
- package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
- package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
- package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
- package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
- package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
- package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
- package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
- package/payload/mishkan/templates/user-CLAUDE.md +0 -62
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
skill-discovery-indexer — universal indexer for Claude Code skills.
|
|
4
|
+
|
|
5
|
+
Scans every installed skill on the user's Claude Code instance and produces a
|
|
6
|
+
single flat JSON index at ~/.claude/mishkan/skill-discovery/index.json.
|
|
7
|
+
|
|
8
|
+
Roots scanned (in precedence order — first wins on name collisions):
|
|
9
|
+
1. ~/.claude/mishkan/skills/ (origin = mishkan)
|
|
10
|
+
2. ~/.claude/skills/ (origin = user)
|
|
11
|
+
3. ~/.claude/plugins/*/skills/ (origin = plugin)
|
|
12
|
+
4. <repo>/.claude/skills/ (origin = project)
|
|
13
|
+
|
|
14
|
+
Frontmatter is YAML-ish but parsed by a minimal stdlib parser (no PyYAML).
|
|
15
|
+
Triggers are extracted from "Use when…" / "Use this when…" sentences inside
|
|
16
|
+
the description, plus any explicit ``triggers: [...]`` list.
|
|
17
|
+
|
|
18
|
+
Categories are inferred from path segment first (e.g. -craft / mishkan- /
|
|
19
|
+
agent skills), then from a keyword heuristic on the description.
|
|
20
|
+
|
|
21
|
+
CLI:
|
|
22
|
+
python3 skill-discovery-indexer.py [--rebuild | --stat-only | --manual]
|
|
23
|
+
|
|
24
|
+
Modes:
|
|
25
|
+
--rebuild (default on install/update) full rescan + sha256 + write
|
|
26
|
+
--stat-only cheap session-boot sweep; rebuilds only when any source
|
|
27
|
+
path's mtime is newer than index.json.meta.last_scan
|
|
28
|
+
--manual same as --rebuild but writes manual=True into meta
|
|
29
|
+
|
|
30
|
+
Stdlib only. Fail-open: any error indexing a single skill is logged and
|
|
31
|
+
that skill is skipped; the index still writes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from dataclasses import dataclass, asdict, field
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Iterable
|
|
46
|
+
|
|
47
|
+
HOME = Path(os.path.expanduser("~"))
|
|
48
|
+
INDEX_DIR = HOME / ".claude" / "mishkan" / "skill-discovery"
|
|
49
|
+
INDEX_PATH = INDEX_DIR / "index.json"
|
|
50
|
+
MISSES_PATH = INDEX_DIR / "misses.jsonl"
|
|
51
|
+
ERRORS_PATH = INDEX_DIR / "indexer-errors.jsonl"
|
|
52
|
+
|
|
53
|
+
# Precedence order matters: first hit on a duplicate name wins.
|
|
54
|
+
ROOTS = [
|
|
55
|
+
("mishkan", HOME / ".claude" / "mishkan" / "skills"),
|
|
56
|
+
("user", HOME / ".claude" / "skills"),
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def discover_plugin_roots() -> list[tuple[str, Path]]:
|
|
61
|
+
"""~/.claude/plugins/*/skills."""
|
|
62
|
+
out: list[tuple[str, Path]] = []
|
|
63
|
+
plugins_root = HOME / ".claude" / "plugins"
|
|
64
|
+
if not plugins_root.is_dir():
|
|
65
|
+
return out
|
|
66
|
+
for plugin_dir in sorted(plugins_root.iterdir()):
|
|
67
|
+
if not plugin_dir.is_dir():
|
|
68
|
+
continue
|
|
69
|
+
skills_dir = plugin_dir / "skills"
|
|
70
|
+
if skills_dir.is_dir():
|
|
71
|
+
out.append(("plugin", skills_dir))
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discover_project_roots(cwd: Path) -> list[tuple[str, Path]]:
|
|
76
|
+
"""<repo>/.claude/skills/ — walk up from cwd looking for a .git or repo root."""
|
|
77
|
+
out: list[tuple[str, Path]] = []
|
|
78
|
+
here = cwd.resolve()
|
|
79
|
+
seen: set[Path] = set()
|
|
80
|
+
for parent in [here, *here.parents]:
|
|
81
|
+
if parent in seen:
|
|
82
|
+
continue
|
|
83
|
+
seen.add(parent)
|
|
84
|
+
candidate = parent / ".claude" / "skills"
|
|
85
|
+
if candidate.is_dir():
|
|
86
|
+
out.append(("project", candidate))
|
|
87
|
+
# Stop at filesystem root or after we hit a .git
|
|
88
|
+
if (parent / ".git").exists():
|
|
89
|
+
break
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Minimal frontmatter parser (no PyYAML dependency)
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
101
|
+
"""Returns (frontmatter_dict, body). Tolerant — bad lines are skipped."""
|
|
102
|
+
m = FRONTMATTER_RE.match(text)
|
|
103
|
+
if not m:
|
|
104
|
+
return {}, text
|
|
105
|
+
raw = m.group(1)
|
|
106
|
+
body = text[m.end():]
|
|
107
|
+
fm: dict = {}
|
|
108
|
+
current_key: str | None = None
|
|
109
|
+
current_list: list[str] | None = None
|
|
110
|
+
for line in raw.splitlines():
|
|
111
|
+
if not line.strip():
|
|
112
|
+
current_key, current_list = None, None
|
|
113
|
+
continue
|
|
114
|
+
# List item under previous key
|
|
115
|
+
if line.lstrip().startswith("-") and current_list is not None:
|
|
116
|
+
item = line.lstrip()[1:].strip()
|
|
117
|
+
item = _strip_quotes(item)
|
|
118
|
+
if item:
|
|
119
|
+
current_list.append(item)
|
|
120
|
+
continue
|
|
121
|
+
if ":" in line:
|
|
122
|
+
key, _, value = line.partition(":")
|
|
123
|
+
key = key.strip()
|
|
124
|
+
value = value.strip()
|
|
125
|
+
if not key:
|
|
126
|
+
continue
|
|
127
|
+
if value == "":
|
|
128
|
+
# Possible list to follow
|
|
129
|
+
current_list = []
|
|
130
|
+
fm[key] = current_list
|
|
131
|
+
current_key = key
|
|
132
|
+
continue
|
|
133
|
+
if value.startswith("[") and value.endswith("]"):
|
|
134
|
+
# Inline list
|
|
135
|
+
inner = value[1:-1].strip()
|
|
136
|
+
if inner:
|
|
137
|
+
items = [
|
|
138
|
+
_strip_quotes(part.strip())
|
|
139
|
+
for part in _split_inline_list(inner)
|
|
140
|
+
]
|
|
141
|
+
fm[key] = [i for i in items if i]
|
|
142
|
+
else:
|
|
143
|
+
fm[key] = []
|
|
144
|
+
current_key, current_list = None, None
|
|
145
|
+
continue
|
|
146
|
+
fm[key] = _strip_quotes(value)
|
|
147
|
+
current_key, current_list = None, None
|
|
148
|
+
else:
|
|
149
|
+
# Continuation of previous scalar (rare; folded value)
|
|
150
|
+
if current_key and isinstance(fm.get(current_key), str):
|
|
151
|
+
fm[current_key] = (fm[current_key] + " " + line.strip()).strip()
|
|
152
|
+
# Demote empty lists to absent
|
|
153
|
+
return fm, body
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _strip_quotes(s: str) -> str:
|
|
157
|
+
s = s.strip()
|
|
158
|
+
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
|
|
159
|
+
return s[1:-1]
|
|
160
|
+
return s
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _split_inline_list(s: str) -> list[str]:
|
|
164
|
+
# Naive split on commas; good enough for skill frontmatter.
|
|
165
|
+
return [p for p in s.split(",")]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Trigger + category extraction
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
TRIGGER_PATTERNS = [
|
|
173
|
+
re.compile(r"Use\s+(?:this\s+)?when\s+([^\.\n]+)", re.IGNORECASE),
|
|
174
|
+
re.compile(r"Invoke\s+when\s+([^\.\n]+)", re.IGNORECASE),
|
|
175
|
+
re.compile(r"Trigger(?:s)?\s+(?:when|on)\s+([^\.\n]+)", re.IGNORECASE),
|
|
176
|
+
re.compile(r"Apply\s+when\s+([^\.\n]+)", re.IGNORECASE),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
CATEGORY_KEYWORDS = [
|
|
180
|
+
("research", ["research", "investigate", "evaluate evidence"]),
|
|
181
|
+
("security", ["security", "threat", "vulnerab", "cve", "auth", "rbac"]),
|
|
182
|
+
("testing", ["test", "qa", "coverage", "e2e", "playwright", "jest", "bats"]),
|
|
183
|
+
("docs", ["documentation", "adr", "readme", "changelog"]),
|
|
184
|
+
("infra", ["kubernetes", "helm", "terraform", "docker", "cloud", "k8s", "deploy"]),
|
|
185
|
+
("data", ["database", "sql", "postgres", "schema", "migration", "etl"]),
|
|
186
|
+
("frontend", ["ui", "react", "tailwind", "nextjs", "frontend", "design system"]),
|
|
187
|
+
("backend", ["fastapi", "api", "backend", "server"]),
|
|
188
|
+
("observability", ["observ", "monitor", "metrics", "grafana", "prometheus", "tracing", "slo"]),
|
|
189
|
+
("ml", ["llm", "embedding", "rag", "langchain", "machine learning"]),
|
|
190
|
+
("orchestration", ["workflow", "orchestrat", "scheduling", "saga", "temporal"]),
|
|
191
|
+
("process", ["sprint", "review", "incident", "postmortem", "onboarding"]),
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def extract_triggers(description: str, frontmatter: dict) -> list[str]:
|
|
196
|
+
out: list[str] = []
|
|
197
|
+
# Explicit list in frontmatter
|
|
198
|
+
triggers = frontmatter.get("triggers")
|
|
199
|
+
if isinstance(triggers, list):
|
|
200
|
+
out.extend(str(t).strip() for t in triggers if str(t).strip())
|
|
201
|
+
elif isinstance(triggers, str) and triggers.strip():
|
|
202
|
+
out.append(triggers.strip())
|
|
203
|
+
# Parsed from description prose
|
|
204
|
+
for pat in TRIGGER_PATTERNS:
|
|
205
|
+
for m in pat.finditer(description or ""):
|
|
206
|
+
phrase = m.group(1).strip()
|
|
207
|
+
phrase = re.sub(r"\s+", " ", phrase)
|
|
208
|
+
if phrase and phrase not in out:
|
|
209
|
+
out.append(phrase)
|
|
210
|
+
return out
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def infer_category(name: str, source_path: Path, description: str) -> str:
|
|
214
|
+
"""Path segment heuristic first, then keyword on description."""
|
|
215
|
+
parts = [p.lower() for p in source_path.parts]
|
|
216
|
+
# Path-segment hits
|
|
217
|
+
if name.endswith("-craft"):
|
|
218
|
+
return "craft"
|
|
219
|
+
if name.startswith("mishkan-"):
|
|
220
|
+
return "mishkan-workflow"
|
|
221
|
+
if "research" in name:
|
|
222
|
+
return "research"
|
|
223
|
+
if "cognee" in name:
|
|
224
|
+
return "memory"
|
|
225
|
+
# Description keyword hits
|
|
226
|
+
desc_low = (description or "").lower()
|
|
227
|
+
for cat, kws in CATEGORY_KEYWORDS:
|
|
228
|
+
for kw in kws:
|
|
229
|
+
if kw in desc_low:
|
|
230
|
+
return cat
|
|
231
|
+
# Fallback by enclosing directory
|
|
232
|
+
for seg in parts[::-1]:
|
|
233
|
+
if seg in {"skills", "mishkan"}:
|
|
234
|
+
continue
|
|
235
|
+
if seg.startswith("."):
|
|
236
|
+
continue
|
|
237
|
+
return seg
|
|
238
|
+
return "general"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Walking + indexing
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
@dataclass
|
|
246
|
+
class IndexEntry:
|
|
247
|
+
name: str
|
|
248
|
+
source_path: str
|
|
249
|
+
origin: str
|
|
250
|
+
description: str
|
|
251
|
+
triggers: list[str]
|
|
252
|
+
category: str
|
|
253
|
+
frontmatter_raw: dict
|
|
254
|
+
sha256: str
|
|
255
|
+
indexed_at: str
|
|
256
|
+
mtime: float = 0.0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def iter_skill_files(root: Path) -> Iterable[Path]:
|
|
260
|
+
if not root.is_dir():
|
|
261
|
+
return
|
|
262
|
+
for entry in root.iterdir():
|
|
263
|
+
if entry.is_dir():
|
|
264
|
+
skill_md = entry / "SKILL.md"
|
|
265
|
+
if skill_md.is_file():
|
|
266
|
+
yield skill_md
|
|
267
|
+
elif entry.is_file() and entry.name == "SKILL.md":
|
|
268
|
+
# rare flat case
|
|
269
|
+
yield entry
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sha256_file(path: Path) -> str:
|
|
273
|
+
h = hashlib.sha256()
|
|
274
|
+
try:
|
|
275
|
+
with path.open("rb") as f:
|
|
276
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
277
|
+
h.update(chunk)
|
|
278
|
+
except OSError:
|
|
279
|
+
return ""
|
|
280
|
+
return h.hexdigest()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _log_error(stage: str, path: Path | None, exc: Exception) -> None:
|
|
284
|
+
INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
285
|
+
rec = {
|
|
286
|
+
"stage": stage,
|
|
287
|
+
"path": str(path) if path else None,
|
|
288
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
289
|
+
"timestamp": _now_iso(),
|
|
290
|
+
}
|
|
291
|
+
try:
|
|
292
|
+
with ERRORS_PATH.open("a", encoding="utf-8") as f:
|
|
293
|
+
f.write(json.dumps(rec) + "\n")
|
|
294
|
+
except OSError:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _now_iso() -> str:
|
|
299
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def build_entry(skill_md: Path, origin: str) -> IndexEntry | None:
|
|
303
|
+
try:
|
|
304
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
305
|
+
except OSError as exc:
|
|
306
|
+
_log_error("read", skill_md, exc)
|
|
307
|
+
return None
|
|
308
|
+
fm, _body = parse_frontmatter(text)
|
|
309
|
+
name = str(fm.get("name") or skill_md.parent.name).strip()
|
|
310
|
+
if not name:
|
|
311
|
+
return None
|
|
312
|
+
description = str(fm.get("description") or "").strip()
|
|
313
|
+
triggers = extract_triggers(description, fm)
|
|
314
|
+
category = infer_category(name, skill_md, description)
|
|
315
|
+
try:
|
|
316
|
+
mtime = skill_md.stat().st_mtime
|
|
317
|
+
except OSError:
|
|
318
|
+
mtime = 0.0
|
|
319
|
+
return IndexEntry(
|
|
320
|
+
name=name,
|
|
321
|
+
source_path=str(skill_md),
|
|
322
|
+
origin=origin,
|
|
323
|
+
description=description,
|
|
324
|
+
triggers=triggers,
|
|
325
|
+
category=category,
|
|
326
|
+
frontmatter_raw=fm,
|
|
327
|
+
sha256=sha256_file(skill_md),
|
|
328
|
+
indexed_at=_now_iso(),
|
|
329
|
+
mtime=mtime,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def collect_roots(cwd: Path) -> list[tuple[str, Path]]:
|
|
334
|
+
roots: list[tuple[str, Path]] = []
|
|
335
|
+
roots.extend(ROOTS)
|
|
336
|
+
roots.extend(discover_plugin_roots())
|
|
337
|
+
roots.extend(discover_project_roots(cwd))
|
|
338
|
+
return roots
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def rebuild_index(manual: bool = False, cwd: Path | None = None) -> dict:
|
|
342
|
+
cwd = cwd or Path.cwd()
|
|
343
|
+
INDEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
roots = collect_roots(cwd)
|
|
345
|
+
|
|
346
|
+
entries: list[IndexEntry] = []
|
|
347
|
+
seen_names: dict[str, str] = {}
|
|
348
|
+
collisions: list[dict] = []
|
|
349
|
+
|
|
350
|
+
for origin, root in roots:
|
|
351
|
+
try:
|
|
352
|
+
for skill_md in iter_skill_files(root):
|
|
353
|
+
try:
|
|
354
|
+
entry = build_entry(skill_md, origin)
|
|
355
|
+
except Exception as exc: # fail-open per skill
|
|
356
|
+
_log_error("build_entry", skill_md, exc)
|
|
357
|
+
continue
|
|
358
|
+
if entry is None:
|
|
359
|
+
continue
|
|
360
|
+
if entry.name in seen_names:
|
|
361
|
+
collisions.append({
|
|
362
|
+
"name": entry.name,
|
|
363
|
+
"kept_origin": seen_names[entry.name],
|
|
364
|
+
"shadowed_origin": entry.origin,
|
|
365
|
+
"shadowed_path": entry.source_path,
|
|
366
|
+
})
|
|
367
|
+
continue
|
|
368
|
+
seen_names[entry.name] = entry.origin
|
|
369
|
+
entries.append(entry)
|
|
370
|
+
except Exception as exc:
|
|
371
|
+
_log_error("walk", root, exc)
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
meta = {
|
|
375
|
+
"version": 1,
|
|
376
|
+
"generated_at": _now_iso(),
|
|
377
|
+
"last_scan": time.time(),
|
|
378
|
+
"manual": bool(manual),
|
|
379
|
+
"roots": [{"origin": o, "path": str(p), "exists": p.is_dir()} for o, p in roots],
|
|
380
|
+
"count": len(entries),
|
|
381
|
+
"collisions": collisions,
|
|
382
|
+
}
|
|
383
|
+
payload = {"meta": meta, "entries": [asdict(e) for e in entries]}
|
|
384
|
+
tmp = INDEX_PATH.with_suffix(".json.tmp")
|
|
385
|
+
try:
|
|
386
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
387
|
+
os.replace(tmp, INDEX_PATH)
|
|
388
|
+
except OSError as exc:
|
|
389
|
+
_log_error("write_index", INDEX_PATH, exc)
|
|
390
|
+
# Still return payload so callers see it succeeded in-memory
|
|
391
|
+
return payload
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def stat_only_check(cwd: Path | None = None) -> bool:
|
|
395
|
+
"""Returns True if a rebuild was triggered."""
|
|
396
|
+
cwd = cwd or Path.cwd()
|
|
397
|
+
if not INDEX_PATH.is_file():
|
|
398
|
+
rebuild_index(cwd=cwd)
|
|
399
|
+
return True
|
|
400
|
+
try:
|
|
401
|
+
existing = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
|
|
402
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
403
|
+
_log_error("read_index", INDEX_PATH, exc)
|
|
404
|
+
rebuild_index(cwd=cwd)
|
|
405
|
+
return True
|
|
406
|
+
last_scan = float(existing.get("meta", {}).get("last_scan", 0.0))
|
|
407
|
+
roots = collect_roots(cwd)
|
|
408
|
+
for _origin, root in roots:
|
|
409
|
+
if not root.is_dir():
|
|
410
|
+
continue
|
|
411
|
+
for skill_md in iter_skill_files(root):
|
|
412
|
+
try:
|
|
413
|
+
if skill_md.stat().st_mtime > last_scan:
|
|
414
|
+
rebuild_index(cwd=cwd)
|
|
415
|
+
return True
|
|
416
|
+
except OSError:
|
|
417
|
+
continue
|
|
418
|
+
# Also rebuild if any indexed source_path no longer exists
|
|
419
|
+
for entry in existing.get("entries", []):
|
|
420
|
+
p = entry.get("source_path")
|
|
421
|
+
if p and not Path(p).exists():
|
|
422
|
+
rebuild_index(cwd=cwd)
|
|
423
|
+
return True
|
|
424
|
+
return False
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def main() -> int:
|
|
428
|
+
parser = argparse.ArgumentParser(description="Universal skill-discovery indexer")
|
|
429
|
+
group = parser.add_mutually_exclusive_group()
|
|
430
|
+
group.add_argument("--rebuild", action="store_true", help="Full rescan + write")
|
|
431
|
+
group.add_argument("--stat-only", action="store_true", help="Session-boot mtime sweep")
|
|
432
|
+
group.add_argument("--manual", action="store_true", help="Manual rebuild via /mishkan-skills-reindex")
|
|
433
|
+
parser.add_argument("--cwd", default=None, help="Override cwd for project root discovery")
|
|
434
|
+
parser.add_argument("--quiet", action="store_true")
|
|
435
|
+
args = parser.parse_args()
|
|
436
|
+
|
|
437
|
+
cwd = Path(args.cwd) if args.cwd else Path.cwd()
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
if args.stat_only:
|
|
441
|
+
rebuilt = stat_only_check(cwd=cwd)
|
|
442
|
+
if not args.quiet:
|
|
443
|
+
print(json.dumps({"mode": "stat-only", "rebuilt": rebuilt}))
|
|
444
|
+
return 0
|
|
445
|
+
payload = rebuild_index(manual=args.manual, cwd=cwd)
|
|
446
|
+
if not args.quiet:
|
|
447
|
+
print(json.dumps({
|
|
448
|
+
"mode": "manual" if args.manual else "rebuild",
|
|
449
|
+
"count": payload["meta"]["count"],
|
|
450
|
+
"index_path": str(INDEX_PATH),
|
|
451
|
+
"collisions": len(payload["meta"]["collisions"]),
|
|
452
|
+
}))
|
|
453
|
+
return 0
|
|
454
|
+
except Exception as exc:
|
|
455
|
+
_log_error("main", None, exc)
|
|
456
|
+
# Fail-open: print error to stderr, exit 0 so callers never block on us
|
|
457
|
+
print(f"skill-discovery-indexer: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
458
|
+
return 0
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
if __name__ == "__main__":
|
|
462
|
+
sys.exit(main())
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
skill-discovery-misses — aggregate the router miss log into a tuning report.
|
|
4
|
+
|
|
5
|
+
Reads ``~/.claude/mishkan/skill-discovery/misses.jsonl`` (one JSON record per
|
|
6
|
+
empty-bucket routing, written by the router) and produces a compact summary:
|
|
7
|
+
|
|
8
|
+
- Top N task patterns that produced empty buckets, with count + last_seen.
|
|
9
|
+
- Breakdown by error reason (no_match_above_threshold vs index_missing vs
|
|
10
|
+
router_exception …).
|
|
11
|
+
- Total miss count + observation window.
|
|
12
|
+
|
|
13
|
+
Used at sprint close (and on demand via ``/mishkan-skills-misses``) to
|
|
14
|
+
identify skills whose ``description`` / ``triggers`` need richer keywords so
|
|
15
|
+
the trigger-match catches the recurring patterns. Threshold-tuning happens
|
|
16
|
+
*after* description-tuning has been tried — premature threshold changes chase
|
|
17
|
+
noise (D-011 §"Phase 1 → Phase 2 path").
|
|
18
|
+
|
|
19
|
+
Stdlib only. Fail-open: missing file → empty report, exit 0.
|
|
20
|
+
|
|
21
|
+
CLI:
|
|
22
|
+
python3 skill-discovery-misses.py [--top N] [--json]
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
from collections import Counter
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
HOME = Path(os.path.expanduser("~"))
|
|
36
|
+
MISSES_PATH = HOME / ".claude" / "mishkan" / "skill-discovery" / "misses.jsonl"
|
|
37
|
+
|
|
38
|
+
STOPWORDS = {
|
|
39
|
+
"the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with",
|
|
40
|
+
"this", "that", "is", "are", "be", "by", "as", "at", "it", "its", "from",
|
|
41
|
+
"into", "i", "we", "you", "your", "my", "our", "use", "using", "when",
|
|
42
|
+
"how", "what", "which", "do", "does", "did", "should", "would", "could",
|
|
43
|
+
}
|
|
44
|
+
TOK_RE = re.compile(r"[A-Za-z][A-Za-z0-9\-_/]{2,}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _norm_pattern(task: str) -> str:
|
|
48
|
+
"""Collapse a task into a short keyword signature — same shape repeats
|
|
49
|
+
cluster together in the top-N. Lowercased non-stopword tokens, sorted,
|
|
50
|
+
capped at 6 to keep clusters tight."""
|
|
51
|
+
toks = [t.lower() for t in TOK_RE.findall(task or "")]
|
|
52
|
+
toks = [t for t in toks if t not in STOPWORDS]
|
|
53
|
+
if not toks:
|
|
54
|
+
return "(empty)"
|
|
55
|
+
return " ".join(sorted(set(toks))[:6])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def aggregate(top: int = 10) -> dict:
|
|
59
|
+
if not MISSES_PATH.is_file():
|
|
60
|
+
return {
|
|
61
|
+
"miss_log_path": str(MISSES_PATH),
|
|
62
|
+
"exists": False,
|
|
63
|
+
"total": 0,
|
|
64
|
+
"by_reason": {},
|
|
65
|
+
"top_patterns": [],
|
|
66
|
+
"first_seen": None,
|
|
67
|
+
"last_seen": None,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
total = 0
|
|
71
|
+
by_reason: Counter[str] = Counter()
|
|
72
|
+
pattern_counts: Counter[str] = Counter()
|
|
73
|
+
pattern_examples: dict[str, str] = {}
|
|
74
|
+
pattern_last_seen: dict[str, str] = {}
|
|
75
|
+
first_seen: str | None = None
|
|
76
|
+
last_seen: str | None = None
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with MISSES_PATH.open("r", encoding="utf-8") as f:
|
|
80
|
+
for raw in f:
|
|
81
|
+
raw = raw.strip()
|
|
82
|
+
if not raw:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
rec = json.loads(raw)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
continue
|
|
88
|
+
total += 1
|
|
89
|
+
reason = (rec.get("error") or "unknown").split(":", 1)[0]
|
|
90
|
+
by_reason[reason] += 1
|
|
91
|
+
task = rec.get("task_summary") or ""
|
|
92
|
+
ts = rec.get("timestamp") or ""
|
|
93
|
+
if ts:
|
|
94
|
+
if first_seen is None or ts < first_seen:
|
|
95
|
+
first_seen = ts
|
|
96
|
+
if last_seen is None or ts > last_seen:
|
|
97
|
+
last_seen = ts
|
|
98
|
+
pat = _norm_pattern(task)
|
|
99
|
+
pattern_counts[pat] += 1
|
|
100
|
+
pattern_last_seen[pat] = ts or pattern_last_seen.get(pat, "")
|
|
101
|
+
pattern_examples.setdefault(pat, task[:140])
|
|
102
|
+
except OSError:
|
|
103
|
+
# Fail-open on read errors.
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
top_patterns = [
|
|
107
|
+
{
|
|
108
|
+
"pattern": pat,
|
|
109
|
+
"count": cnt,
|
|
110
|
+
"last_seen": pattern_last_seen.get(pat, ""),
|
|
111
|
+
"example": pattern_examples.get(pat, ""),
|
|
112
|
+
}
|
|
113
|
+
for pat, cnt in pattern_counts.most_common(top)
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"miss_log_path": str(MISSES_PATH),
|
|
118
|
+
"exists": True,
|
|
119
|
+
"total": total,
|
|
120
|
+
"by_reason": dict(by_reason),
|
|
121
|
+
"top_patterns": top_patterns,
|
|
122
|
+
"first_seen": first_seen,
|
|
123
|
+
"last_seen": last_seen,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_text(report: dict) -> str:
|
|
128
|
+
lines = []
|
|
129
|
+
lines.append(f"Skill-discovery misses — {report['miss_log_path']}")
|
|
130
|
+
if not report["exists"]:
|
|
131
|
+
lines.append("(no miss log yet; nothing to aggregate)")
|
|
132
|
+
return "\n".join(lines) + "\n"
|
|
133
|
+
lines.append(f"Total misses : {report['total']}")
|
|
134
|
+
if report["first_seen"] or report["last_seen"]:
|
|
135
|
+
lines.append(f"Window : {report['first_seen']} → {report['last_seen']}")
|
|
136
|
+
if report["by_reason"]:
|
|
137
|
+
lines.append("By reason :")
|
|
138
|
+
for k, v in sorted(report["by_reason"].items(), key=lambda kv: -kv[1]):
|
|
139
|
+
lines.append(f" - {k:32s} {v}")
|
|
140
|
+
if report["top_patterns"]:
|
|
141
|
+
lines.append(f"Top {len(report['top_patterns'])} patterns (signature = sorted unique keywords):")
|
|
142
|
+
for p in report["top_patterns"]:
|
|
143
|
+
lines.append(f" [{p['count']:4d}] {p['pattern']}")
|
|
144
|
+
if p["example"]:
|
|
145
|
+
lines.append(f" e.g. {p['example']}")
|
|
146
|
+
if p["last_seen"]:
|
|
147
|
+
lines.append(f" last seen: {p['last_seen']}")
|
|
148
|
+
else:
|
|
149
|
+
lines.append("No patterns recorded.")
|
|
150
|
+
return "\n".join(lines) + "\n"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> int:
|
|
154
|
+
parser = argparse.ArgumentParser(description="Aggregate skill-discovery miss log")
|
|
155
|
+
parser.add_argument("--top", type=int, default=10, help="Top N patterns (default 10)")
|
|
156
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON instead of text")
|
|
157
|
+
args = parser.parse_args()
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
report = aggregate(top=args.top)
|
|
161
|
+
except Exception as exc: # fail-open
|
|
162
|
+
print(f"skill-discovery-misses: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
if args.json:
|
|
166
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
167
|
+
else:
|
|
168
|
+
sys.stdout.write(render_text(report))
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
sys.exit(main())
|