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.
Files changed (113) hide show
  1. package/README.md +108 -172
  2. package/bin/mishkan.js +677 -16
  3. package/docs/design/MISHKAN_decisions.md +450 -8
  4. package/docs/design/MISHKAN_harness_design.md +1 -33
  5. package/docs/design/MISHKAN_observability.md +13 -9
  6. package/docs/research/graphify-token-saving-poc.md +108 -0
  7. package/docs/usage/09-workflows.md +74 -4
  8. package/docs/usage/10-observability.md +37 -13
  9. package/docs/usage/11-graphify.md +264 -0
  10. package/docs/usage/12-skill-discovery.md +294 -0
  11. package/docs/usage/README.md +5 -3
  12. package/package.json +1 -1
  13. package/payload/install/settings.hooks.json +24 -0
  14. package/payload/mishkan/AGENT_SPEC.md +25 -7
  15. package/payload/mishkan/agents/ahikam.md +4 -2
  16. package/payload/mishkan/agents/aholiab.md +4 -2
  17. package/payload/mishkan/agents/asaph.md +5 -2
  18. package/payload/mishkan/agents/baruch.md +19 -2
  19. package/payload/mishkan/agents/benaiah.md +4 -2
  20. package/payload/mishkan/agents/bezalel.md +4 -2
  21. package/payload/mishkan/agents/caleb.md +4 -2
  22. package/payload/mishkan/agents/deborah.md +4 -2
  23. package/payload/mishkan/agents/elasah.md +4 -2
  24. package/payload/mishkan/agents/eliashib.md +4 -2
  25. package/payload/mishkan/agents/ezra.md +4 -2
  26. package/payload/mishkan/agents/hanun.md +5 -2
  27. package/payload/mishkan/agents/hiram.md +5 -2
  28. package/payload/mishkan/agents/hizkiah.md +5 -2
  29. package/payload/mishkan/agents/huldah.md +4 -2
  30. package/payload/mishkan/agents/huram.md +4 -2
  31. package/payload/mishkan/agents/hushai.md +5 -2
  32. package/payload/mishkan/agents/igal.md +4 -2
  33. package/payload/mishkan/agents/ira.md +5 -2
  34. package/payload/mishkan/agents/jahaziel.md +5 -2
  35. package/payload/mishkan/agents/jakin.md +4 -2
  36. package/payload/mishkan/agents/jehonathan.md +5 -2
  37. package/payload/mishkan/agents/jehoshaphat.md +4 -2
  38. package/payload/mishkan/agents/joab.md +5 -2
  39. package/payload/mishkan/agents/joah.md +5 -2
  40. package/payload/mishkan/agents/maaseiah.md +4 -2
  41. package/payload/mishkan/agents/meremoth.md +5 -2
  42. package/payload/mishkan/agents/meshullam.md +5 -2
  43. package/payload/mishkan/agents/nathan.md +5 -2
  44. package/payload/mishkan/agents/nehemiah.md +4 -2
  45. package/payload/mishkan/agents/obed.md +4 -2
  46. package/payload/mishkan/agents/oholiab.md +5 -2
  47. package/payload/mishkan/agents/palal.md +5 -2
  48. package/payload/mishkan/agents/phinehas.md +4 -2
  49. package/payload/mishkan/agents/rehum.md +4 -2
  50. package/payload/mishkan/agents/salma.md +5 -2
  51. package/payload/mishkan/agents/seraiah.md +4 -2
  52. package/payload/mishkan/agents/shallum.md +5 -2
  53. package/payload/mishkan/agents/shaphan.md +4 -2
  54. package/payload/mishkan/agents/shemaiah.md +4 -2
  55. package/payload/mishkan/agents/shevna.md +5 -2
  56. package/payload/mishkan/agents/uriah.md +5 -2
  57. package/payload/mishkan/agents/zaccur.md +4 -2
  58. package/payload/mishkan/agents/zadok.md +5 -2
  59. package/payload/mishkan/agents/zerubbabel.md +4 -2
  60. package/payload/mishkan/commands/code-graph.md +35 -0
  61. package/payload/mishkan/commands/eval-baruch.md +57 -0
  62. package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
  63. package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
  64. package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
  65. package/payload/mishkan/commands/skills.md +35 -0
  66. package/payload/mishkan/commands/sprint-close.md +3 -1
  67. package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
  68. package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
  69. package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
  70. package/payload/mishkan/observability/schema.json +1 -1
  71. package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
  72. package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
  73. package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
  74. package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
  75. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
  76. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
  77. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
  78. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
  79. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
  80. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
  81. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
  82. package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
  83. package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
  84. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
  85. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
  86. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
  87. package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
  88. package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
  89. package/payload/mishkan/org/org.json +141 -0
  90. package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
  91. package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
  92. package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
  93. package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
  94. package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
  95. package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
  96. package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
  97. package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
  98. package/payload/mishkan/templates/case-node.schema.json +1 -0
  99. package/payload/mishkan/templates/observability-log.schema.json +1 -0
  100. package/payload/mishkan/workflows/README.md +78 -13
  101. package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
  102. package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
  103. package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
  104. package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
  105. package/payload/mishkan/workflows/mishkan-init.js +58 -0
  106. package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
  107. package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
  108. package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
  109. package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
  110. package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
  111. package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
  112. package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
  113. 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())