
Deep Research
- 19 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Runs deep technical research using EXA tools with two-tier caching for cross-project and team reuse.
About
Coordinates EXA-based research through a deep-researcher agent with a fast cache-hit path and team-shareable cache tier. A developer uses it to investigate best practices, patterns, or architectures.
- Two-tier cache: fast per-user tier and version-controlled team tier
- Cache-manager fetch/put flow with promote and refresh suggestions
Deep Research by the numbers
- 19 all-time installs (skills.sh)
- Ranked #10,587 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill deep-researchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Runs deep technical research using EXA tools with two-tier caching for cross-project and team reuse.
Files
Deep Research
Coordinate deep technical research with intelligent caching for cross-project reuse and team knowledge sharing.
Quick Start
When research is needed:
1. Scripts path - ${CLAUDE_SKILL_DIR}/scripts/ 2. Single fetch call - Run python3 ${CLAUDE_SKILL_DIR}/scripts/cache_manager.py fetch "{topic}" (combines check+get) 3. If `exists=true` - Present the content field directly (no agent needed). Suggest promote if valid, refresh if expired. 4. If `exists=false` - Invoke deep-researcher agent for EXA research, which caches via cache_manager.py put 5. Report findings - Include cache status and promote suggestion
Cache Architecture
| Tier | Location | Purpose | Shared |
|---|---|---|---|
| 1 | ~/.claude/plugins/research/ | Fast, cross-project | User only |
| 2 | docs/research/ or JD-resolved path | Curated, version controlled | Team |
Operations
| Operation | Trigger | Fast Path? | Action |
|---|---|---|---|
| Research | /research <topic> or natural language | Yes (cache hit) | Check cache → return if valid, else research → cache |
| Promote | /research promote <slug> | Yes | Run promote.py {slug} directly |
| Refresh | /research refresh <slug> | No | Spawn agent → fresh research → cache → update promoted |
| List | /research list | Yes | Run cache_manager.py list (project-scoped by default, --all for everything) |
JD-Aware Path Resolution
Promote and refresh operations detect .jd-config.json to resolve the research output path. If an area containing "research" exists (e.g. "30": "research"), output goes to docs/30-research/. Otherwise falls back to docs/research/. The frontmatter standard is defined by the research-frontmatter skill in the doc plugin.
Project Scoping
Research entries are automatically associated with the current git repository when cached. The list operation filters by current project by default, so each project sees only its relevant research. Use --all to see everything.
- Auto-detection: Project name derived from
git rev-parse --show-toplevelbasename - Multi-project: Entries can belong to multiple projects (associations merge, never replace)
- Backward compatible: Existing entries without project associations appear in
--allbut not in project-scoped views
Scripts
All cache operations use Python scripts in ${CLAUDE_SKILL_DIR}/scripts/:
| Script | Purpose |
|---|---|
research_utils.py | Shared utilities (imported by all scripts) |
cache_manager.py | Cache CRUD: fetch, get, put, check, list, delete |
promote.py | Tier 1 → Tier 2 promotion with team notes |
index_generator.py | README index generation for both tiers |
Slug Normalization
Convert topics to cache keys:
- "Domain-Driven Design" →
domain-driven-design - "DDD" →
domain-driven-design(via alias) - "React Hooks" →
react-hooks
Output Format
After research, report:
## Research: {Topic}
**Cache:** {Hit | Miss | Expired}
**Source:** {Cached | Fresh research}
**Path:** ~/.claude/plugins/research/entries/{slug}/
[Brief summary of findings]
Run `/research promote {slug}` to add to project docs.Agent Delegation
For actual research execution (cache miss or refresh only), delegate to deep-researcher agent:
- Has MCP tool access (EXA web search, code context)
- Uses
cache_manager.py putfor cache write operations - Structures research output consistently
Additional Resources
- WORKFLOW.md - Detailed process flows
- EXAMPLES.md - Usage examples
- TROUBLESHOOTING.md - Common issues and solutions
Deep Research Examples
Real-world usage examples for the deep-research skill.
Basic Research
Research a New Topic (Cache Miss)
User: /research domain-driven design
Claude: Researching "domain-driven design"...
Normalized slug: domain-driven-design
Cache status: MISS (not found)
Conducting research via EXA...
## Research: Domain-Driven Design
**Cache:** Miss
**Source:** Fresh research via EXA
**Path:** ~/.claude/plugins/research/entries/domain-driven-design/
### Summary
Domain-Driven Design (DDD) is a software development approach that focuses on
modeling complex business domains. Key concepts include:
- **Bounded Contexts**: Explicit boundaries where domain models apply
- **Ubiquitous Language**: Shared vocabulary between developers and domain experts
- **Aggregates**: Clusters of entities treated as a single unit
- **Domain Events**: Significant occurrences in the domain
### Key Insights
- Strategic DDD (boundaries, contexts) often more valuable than tactical patterns
- Start with modular monolith, extract microservices later
- 95% of projects may not need full tactical DDD
💡 Run `/research promote domain-driven-design` to add to project docs.Research with Cache Hit
User: /research DDD
Claude: Researching "DDD"...
Normalized slug: domain-driven-design (via alias)
Cache status: HIT (valid until 2025-02-13)
## Research: Domain-Driven Design
**Cache:** Hit
**Source:** Cached (researched 2025-01-14)
**Expires:** 2025-02-13
**Path:** ~/.claude/plugins/research/entries/domain-driven-design/
[Returns cached content...]
💡 Run `/research refresh domain-driven-design` to get fresh research.Research with Expired Cache
User: /research event sourcing
Claude: Researching "event sourcing"...
Normalized slug: event-sourcing
Cache status: EXPIRED (was valid until 2024-12-31)
⚠️ Cache expired. Returning cached content but refresh recommended.
## Research: Event Sourcing
**Cache:** Expired
**Source:** Cached (researched 2024-12-01)
**Expired:** 2024-12-31
**Path:** ~/.claude/plugins/research/entries/event-sourcing/
[Returns cached content...]
💡 Run `/research refresh event-sourcing` to update with fresh research.Promotion
Promote to Project Docs
User: /research promote domain-driven-design
Claude: Promoting domain-driven-design to project docs...
✅ Promoted to docs/research/domain-driven-design.md
The file includes:
- AUTO-GENERATED section with research content
- TEAM-NOTES section for project-specific context
Next steps:
1. Edit the TEAM-NOTES section to add project context
2. Commit to version control for team access
3. Future refreshes will preserve your team notesPromote Non-Existent Research
User: /research promote microservices
Claude: ❌ Error: Research not found in cache.
The topic "microservices" hasn't been researched yet.
Run `/research microservices` first, then promote.Refresh
Refresh Cached Research
User: /research refresh domain-driven-design
Claude: Refreshing domain-driven-design...
Conducting fresh research via EXA...
✅ Refresh complete
Updated:
- ~/.claude/plugins/research/entries/domain-driven-design/ (cache)
- docs/research/domain-driven-design.md (preserved team notes)
Changes:
- New sources from 2025
- Updated best practices section
- Added Spring Modulith integration guidanceRefresh with Team Notes Preserved
User: /research refresh event-sourcing
Claude: Refreshing event-sourcing...
Found promoted version at docs/research/event-sourcing.md
Detected TEAM-NOTES section - will preserve.
Conducting fresh research via EXA...
✅ Refresh complete
Updated:
- Cache: Fully replaced
- docs/research/event-sourcing.md:
- AUTO-GENERATED: Updated with fresh content
- TEAM-NOTES: Preserved (contains team context)
Your team notes remain intact:
> "We use event sourcing in the Order aggregate. See OrderEventStore.java"Inventory
List Research (Project-Scoped — Default)
User: /research list
Claude: ## Research Inventory
Project: my-ecommerce-app (2 of 6 entries)
### Cached (Tier 1: ~/.claude/plugins/research/)
| Slug | Title | Researched | Expires | Status |
|------|-------|------------|---------|--------|
| domain-driven-design | Domain-Driven Design | 2025-01-14 | 2025-02-13 | ✅ Valid |
| event-sourcing | Event Sourcing | 2025-01-10 | 2025-02-09 | ✅ Valid |
### Promoted (Tier 2: docs/research/)
| Slug | Title | Promoted | Team Notes |
|------|-------|----------|------------|
| domain-driven-design | Domain-Driven Design | 2025-01-14 | ✅ Yes |
Use --all to see all 6 entries across all projects.List All Research (Global)
User: /research list --all
Claude: ## Research Inventory
All projects (6 entries)
### Cached (Tier 1: ~/.claude/plugins/research/)
| Slug | Title | Projects | Researched | Expires | Status |
|------|-------|----------|------------|---------|--------|
| domain-driven-design | Domain-Driven Design | my-ecommerce-app | 2025-01-14 | 2025-02-13 | ✅ Valid |
| event-sourcing | Event Sourcing | my-ecommerce-app | 2025-01-10 | 2025-02-09 | ✅ Valid |
| react-hooks | React Hooks | my-frontend-app | 2025-01-05 | 2025-02-04 | ✅ Valid |
| cqrs | CQRS Pattern | (unassociated) | 2024-11-15 | 2024-12-15 | ⚠️ Expired |
| nextjs-app-router | Next.js App Router | my-frontend-app | 2025-01-08 | 2025-02-07 | ✅ Valid |
| tailwindcss-v4 | TailwindCSS v4 | my-frontend-app | 2025-01-02 | 2025-02-01 | ✅ Valid |
### Summary
- **Cached:** 6 topics (1 expired)
- **Promoted:** 1 topic (with team notes)
💡 Commands:
- `/research <topic>` - Research new topic
- `/research promote <slug>` - Promote to project docs
- `/research refresh <slug>` - Update existing researchNatural Language Triggers
The skill auto-triggers on research-related phrases:
"Research this pattern"
User: Can you research the repository pattern for me?
Claude: [Skill auto-triggers]
Researching "repository pattern"...
[Proceeds with research flow]"Look up best practices"
User: I need to look up best practices for API versioning
Claude: [Skill auto-triggers]
Researching "api versioning best practices"...
[Proceeds with research flow]"Investigate how others implement"
User: Can you investigate how others implement circuit breakers?
Claude: [Skill auto-triggers]
Researching "circuit breaker pattern"...
[Proceeds with research flow]Cross-Project Benefit
Scenario: Research in Project A, Use in Project B
# In Project A (e-commerce)
User: /research domain-driven design
Claude: [Researches and caches at ~/.claude/plugins/research/]
# Later, in Project B (fintech)
User: /research DDD
Claude: Cache status: HIT (researched in another project)
[Returns cached content - no external API call needed]Edge Cases
Research with Special Characters
User: /research "C# async/await patterns"
Claude: Researching "C# async/await patterns"...
Normalized slug: csharp-async-await-patterns
[Proceeds with research]Ambiguous Topic Clarification
User: /research ES
Claude: Clarifying "ES"...
Found multiple possible matches:
1. event-sourcing (alias: ES)
2. elasticsearch (alias: ES)
Which topic did you mean?
1. Event Sourcing - Domain event persistence pattern
2. Elasticsearch - Search and analytics engine
[Waits for user selection]#!/usr/bin/env python3
"""
Cache management for deep research results.
Usage:
python cache_manager.py fetch <slug> (combined check+get, preferred)
python cache_manager.py get <slug>
python cache_manager.py put <slug> --title "Title" --content-file content.md [--project name]
python cache_manager.py check <slug>
python cache_manager.py list [--all | --project <name>]
python cache_manager.py delete <slug>
Environment:
RESEARCH_CACHE_DIR: Override default cache location (~/.claude/plugins/research)
RESEARCH_TTL_DAYS: Override default TTL (30 days)
"""
import argparse
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
# Ensure sibling imports work from any working directory
sys.path.insert(0, str(Path(__file__).resolve().parent))
from research_utils import (
check_expiration,
ensure_cache_dir,
find_by_alias,
get_cache_dir,
get_current_project,
get_entry,
get_index,
get_ttl_days,
normalize_slug,
save_index,
)
def put_entry(
slug: str,
title: str,
content: str,
aliases: Optional[list] = None,
tags: Optional[list] = None,
sources: Optional[list] = None,
project: Optional[str] = None,
) -> dict:
"""
Store a research entry in the cache.
The ``project`` parameter associates the entry with a project name.
When ``None``, the current git repo name is auto-detected.
Existing project associations are preserved and merged.
Returns the metadata dict.
"""
cache_dir = ensure_cache_dir()
entry_dir = cache_dir / "entries" / slug
entry_dir.mkdir(parents=True, exist_ok=True)
# Resolve project name (auto-detect if not provided)
if project is None:
project = get_current_project()
# Merge with existing projects list
existing_projects: list = []
metadata_file = entry_dir / "metadata.json"
if metadata_file.exists():
try:
existing_meta = json.loads(metadata_file.read_text())
existing_projects = existing_meta.get("projects", [])
except (json.JSONDecodeError, IOError):
pass
projects = sorted(set(existing_projects + ([project] if project else [])))
now = datetime.now(timezone.utc)
ttl_days = get_ttl_days()
expires_at = now + timedelta(days=ttl_days)
metadata = {
"slug": slug,
"title": title,
"aliases": aliases or [],
"tags": tags or [],
"sources": sources or [],
"projects": projects,
"researched_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
}
# Write metadata
metadata_file.write_text(json.dumps(metadata, indent=2))
# Write content
content_file = entry_dir / "content.md"
content_file.write_text(content)
# Update index
index = get_index()
index[slug] = {
"slug": slug,
"title": title,
"aliases": aliases or [],
"projects": projects,
"researched_at": metadata["researched_at"],
"expires_at": metadata["expires_at"],
}
save_index(index)
return metadata
def delete_entry(slug: str) -> bool:
"""
Delete a cache entry.
Returns True if deleted, False if not found.
"""
cache_dir = get_cache_dir()
entry_dir = cache_dir / "entries" / slug
if not entry_dir.exists():
return False
for f in entry_dir.iterdir():
f.unlink()
entry_dir.rmdir()
index = get_index()
if slug in index:
del index[slug]
save_index(index)
return True
def list_entries(project: Optional[str] = None) -> list:
"""
List cache entries with their status.
When *project* is given, only entries associated with that project are
returned. Entries without a ``projects`` field are included only when
*project* is ``None`` (i.e. unfiltered / ``--all`` mode).
Returns list of dicts with slug, title, projects, researched_at,
expires_at, expired.
"""
index = get_index()
entries = []
for slug, meta in index.items():
entry_projects = meta.get("projects", [])
if project is not None and project not in entry_projects:
continue
expiration = check_expiration(meta)
entries.append({
"slug": slug,
"title": meta.get("title", slug),
"aliases": meta.get("aliases", []),
"projects": entry_projects,
"researched_at": meta.get("researched_at", ""),
"expires_at": expiration["expires_at"],
"expired": expiration["expired"],
})
entries.sort(key=lambda x: x.get("researched_at", ""), reverse=True)
return entries
# ---------------------------------------------------------------------------
# CLI command handlers
# ---------------------------------------------------------------------------
def cmd_get(args) -> int:
"""Handle 'get' command."""
slug = find_by_alias(args.slug) or normalize_slug(args.slug)
entry = get_entry(slug)
if not entry:
print(json.dumps({"error": "not_found", "slug": slug}))
return 1
expiration = check_expiration(entry["metadata"])
result = {
"slug": slug,
"metadata": entry["metadata"],
"content": entry["content"],
"cache_status": "expired" if expiration["expired"] else "valid",
}
print(json.dumps(result, indent=2))
return 0
def cmd_put(args) -> int:
"""Handle 'put' command."""
slug = normalize_slug(args.slug)
if args.content_file:
content_path = Path(args.content_file)
try:
content = content_path.read_text()
except FileNotFoundError:
print(f"Error: File not found: {args.content_file}", file=sys.stderr)
return 1
except PermissionError:
print(f"Error: Permission denied: {args.content_file}", file=sys.stderr)
return 1
else:
if sys.stdin.isatty():
print(
"Error: No --content-file provided and stdin is a terminal.\n"
"Usage: cache_manager.py put <slug> --title 'Title' --content-file content.md",
file=sys.stderr,
)
return 1
content = sys.stdin.read()
if not content.strip():
print(
f"Warning: Empty content for slug '{slug}'. "
"The cache entry will have no content.",
file=sys.stderr,
)
aliases = args.aliases.split(",") if args.aliases else []
tags = args.tags.split(",") if args.tags else []
metadata = put_entry(
slug=slug,
title=args.title or slug,
content=content,
aliases=[a.strip() for a in aliases if a.strip()],
tags=[t.strip() for t in tags if t.strip()],
project=getattr(args, "project", None),
)
print(json.dumps({
"status": "cached",
"slug": slug,
"path": str(get_cache_dir() / "entries" / slug),
"expires_at": metadata["expires_at"],
}, indent=2))
return 0
def cmd_fetch(args) -> int:
"""Handle 'fetch' command - combined check+get in one call."""
slug = find_by_alias(args.slug) or normalize_slug(args.slug)
entry = get_entry(slug)
if not entry:
print(json.dumps({"exists": False, "slug": slug}))
return 0
expiration = check_expiration(entry["metadata"])
result = {
"exists": True,
"slug": slug,
"title": entry["metadata"].get("title", slug),
"metadata": entry["metadata"],
"content": entry["content"],
"cache_status": "expired" if expiration["expired"] else "valid",
"expired": expiration["expired"],
"expires_at": expiration["expires_at"],
"researched_at": entry["metadata"].get("researched_at", ""),
}
print(json.dumps(result, indent=2))
return 0
def cmd_check(args) -> int:
"""Handle 'check' command."""
slug = find_by_alias(args.slug) or normalize_slug(args.slug)
entry = get_entry(slug)
if not entry:
print(json.dumps({"exists": False, "slug": slug}))
return 0
expiration = check_expiration(entry["metadata"])
print(json.dumps({
"exists": True,
"slug": slug,
"title": entry["metadata"].get("title", slug),
"expired": expiration["expired"],
"expires_at": expiration["expires_at"],
"researched_at": entry["metadata"].get("researched_at", ""),
}, indent=2))
return 0
def cmd_list(args) -> int:
"""Handle 'list' command."""
show_all = getattr(args, "all", False)
explicit_project = getattr(args, "project", None)
# Determine filter
if show_all:
project_filter = None
elif explicit_project:
project_filter = explicit_project
else:
project_filter = get_current_project()
# Single index read — filter in memory
all_entries = list_entries(project=None)
total_count = len(all_entries)
if project_filter is not None:
entries = [e for e in all_entries if project_filter in e.get("projects", [])]
else:
entries = all_entries
if args.format == "json":
result = {
"entries": entries,
"filter": {
"project": project_filter,
"matched": len(entries),
"total": total_count,
},
}
print(json.dumps(result, indent=2))
else:
# Context header
if project_filter:
print(f"Project: {project_filter} ({len(entries)} of {total_count} entries)")
else:
print(f"All projects ({total_count} entries)")
print()
if show_all:
print(f"{'Slug':<30} {'Title':<30} {'Status':<10} {'Expires':<12} {'Projects'}")
print("-" * 105)
for e in entries:
status = "Expired" if e["expired"] else "Valid"
expires = e["expires_at"][:10] if e["expires_at"] else "N/A"
title = e["title"][:28] + ".." if len(e["title"]) > 30 else e["title"]
projects = ", ".join(e.get("projects", [])) or "(unassociated)"
print(f"{e['slug']:<30} {title:<30} {status:<10} {expires:<12} {projects}")
else:
print(f"{'Slug':<30} {'Title':<30} {'Status':<10} {'Expires':<12}")
print("-" * 85)
for e in entries:
status = "Expired" if e["expired"] else "Valid"
expires = e["expires_at"][:10] if e["expires_at"] else "N/A"
title = e["title"][:28] + ".." if len(e["title"]) > 30 else e["title"]
print(f"{e['slug']:<30} {title:<30} {status:<10} {expires:<12}")
# Hint when project-scoped
if project_filter and total_count > len(entries):
print()
print(f"Use --all to see all {total_count} entries across all projects.")
return 0
def cmd_delete(args) -> int:
"""Handle 'delete' command."""
slug = normalize_slug(args.slug)
if delete_entry(slug):
print(json.dumps({"status": "deleted", "slug": slug}))
return 0
else:
print(json.dumps({"error": "not_found", "slug": slug}))
return 1
def main() -> int:
parser = argparse.ArgumentParser(
description="Cache management for deep research results"
)
subparsers = parser.add_subparsers(dest="command", required=True)
# get
get_parser = subparsers.add_parser("get", help="Get cached research")
get_parser.add_argument("slug", help="Topic slug or alias")
get_parser.set_defaults(func=cmd_get)
# put
put_parser = subparsers.add_parser("put", help="Cache research result")
put_parser.add_argument("slug", help="Topic slug")
put_parser.add_argument("--title", "-t", help="Human-readable title")
put_parser.add_argument("--content-file", "-f", help="Path to content file")
put_parser.add_argument("--aliases", "-a", help="Comma-separated aliases")
put_parser.add_argument("--tags", help="Comma-separated tags")
put_parser.add_argument("--project", "-p", help="Project name (auto-detected from git repo if omitted)")
put_parser.set_defaults(func=cmd_put)
# fetch (combined check+get)
fetch_parser = subparsers.add_parser("fetch", help="Check and get in one call")
fetch_parser.add_argument("slug", help="Topic slug or alias")
fetch_parser.set_defaults(func=cmd_fetch)
# check
check_parser = subparsers.add_parser("check", help="Check if topic is cached")
check_parser.add_argument("slug", help="Topic slug or alias")
check_parser.set_defaults(func=cmd_check)
# list
list_parser = subparsers.add_parser("list", help="List cached research (project-scoped by default)")
list_parser.add_argument(
"--format", "-f",
choices=["table", "json"],
default="table",
help="Output format",
)
list_parser.add_argument(
"--all", "-a",
action="store_true",
help="Show entries from all projects",
)
list_parser.add_argument(
"--project", "-p",
help="Filter by specific project name",
)
list_parser.set_defaults(func=cmd_list)
# delete
delete_parser = subparsers.add_parser("delete", help="Delete cached research")
delete_parser.add_argument("slug", help="Topic slug")
delete_parser.set_defaults(func=cmd_delete)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Generate README.md index for research directories.
Usage:
python index_generator.py --cache # Generate for Tier 1 cache
python index_generator.py --docs [DIR] # Generate for Tier 2 docs
python index_generator.py --both # Generate both
Environment:
RESEARCH_CACHE_DIR: Override default cache location (~/.claude/plugins/research)
RESEARCH_DOCS_DIR: Override default docs directory (docs/research)
"""
import argparse
import json
import sys
from pathlib import Path
from typing import Optional
# Ensure sibling imports work from any working directory
sys.path.insert(0, str(Path(__file__).resolve().parent))
from research_utils import (
check_expiration,
extract_frontmatter,
format_date,
get_cache_dir,
get_docs_dir,
get_index,
has_team_notes,
)
def generate_cache_readme() -> str:
"""Generate README.md content for Tier 1 cache."""
cache_dir = get_cache_dir()
index = get_index()
if not index:
return "# Research Cache\n\nNo cached research yet.\n"
entries = sorted(
index.values(),
key=lambda x: x.get("researched_at", ""),
reverse=True,
)
lines = [
"# Research Cache",
"",
"User-level cache for cross-project research reuse.",
"",
f"**Location:** `{cache_dir}`",
f"**Entries:** {len(entries)}",
"",
"## Index",
"",
"| Slug | Title | Projects | Researched | Expires | Status |",
"|------|-------|----------|------------|---------|--------|",
]
valid_count = 0
expired_count = 0
for entry in entries:
slug = entry.get("slug", "")
title = entry.get("title", slug)
projects = entry.get("projects", [])
projects_str = ", ".join(projects) if projects else "(unassociated)"
researched = format_date(entry.get("researched_at", ""))
expires = format_date(entry.get("expires_at", ""))
expired = check_expiration(entry.get("expires_at", ""))["expired"]
status = "Expired" if expired else "Valid"
status_icon = "⚠️" if expired else "✅"
if expired:
expired_count += 1
else:
valid_count += 1
if len(title) > 35:
title = title[:32] + "..."
lines.append(f"| {slug} | {title} | {projects_str} | {researched} | {expires} | {status_icon} {status} |")
lines.extend([
"",
"## Summary",
"",
f"- **Valid:** {valid_count}",
f"- **Expired:** {expired_count}",
"",
"## Commands",
"",
"```bash",
"# Research a topic",
"/research <topic>",
"",
"# Promote to project docs",
"/research promote <slug>",
"",
"# Refresh expired research",
"/research refresh <slug>",
"```",
"",
])
return "\n".join(lines)
def generate_docs_readme(docs_dir: Optional[str] = None) -> str:
"""Generate README.md content for Tier 2 docs."""
docs_path = get_docs_dir(docs_dir)
if not docs_path.exists():
return "# Research Index\n\nNo promoted research yet.\n"
entries = []
for md_file in docs_path.glob("*.md"):
if md_file.name.lower() == "readme.md":
continue
content = md_file.read_text()
frontmatter = extract_frontmatter(content)
slug = md_file.stem
title = frontmatter.get("title", slug)
promoted = format_date(frontmatter.get("promoted_at", ""))
refreshed = format_date(frontmatter.get("last_refreshed", ""))
notes = has_team_notes(content)
entries.append({
"slug": slug,
"title": title,
"promoted_at": promoted,
"last_refreshed": refreshed,
"has_team_notes": notes,
})
entries.sort(key=lambda x: x.get("promoted_at", ""), reverse=True)
lines = [
"# Research Index",
"",
"Curated technical research for this project.",
"",
"## Topics",
"",
"| Topic | Promoted | Last Refreshed | Team Notes |",
"|-------|----------|----------------|------------|",
]
with_notes = 0
without_notes = 0
for entry in entries:
slug = entry["slug"]
title = entry["title"]
promoted = entry["promoted_at"]
refreshed = entry["last_refreshed"]
notes = entry["has_team_notes"]
notes_icon = "✅ Yes" if notes else "—"
if notes:
with_notes += 1
else:
without_notes += 1
if len(title) > 35:
title = title[:32] + "..."
lines.append(f"| [{title}]({slug}.md) | {promoted} | {refreshed} | {notes_icon} |")
lines.extend([
"",
"## Summary",
"",
f"- **Total:** {len(entries)}",
f"- **With team notes:** {with_notes}",
f"- **Without team notes:** {without_notes}",
"",
"## Contributing",
"",
"1. Research new topics: `/research <topic>`",
"2. Promote valuable research: `/research promote <slug>`",
"3. Add team context in the TEAM-NOTES section",
"4. Refresh when needed: `/research refresh <slug>`",
"",
])
return "\n".join(lines)
def cmd_generate(args) -> int:
"""Handle generate command."""
results = []
if args.cache or args.both:
cache_dir = get_cache_dir()
readme_content = generate_cache_readme()
if not args.dry_run:
cache_dir.mkdir(parents=True, exist_ok=True)
readme_path = cache_dir / "README.md"
readme_path.write_text(readme_content)
results.append({"tier": "cache", "path": str(readme_path), "status": "updated"})
else:
print("=== CACHE README ===")
print(readme_content)
results.append({"tier": "cache", "status": "dry_run"})
if args.docs or args.both:
docs_dir = get_docs_dir(args.docs_dir)
readme_content = generate_docs_readme(args.docs_dir)
if not args.dry_run:
docs_dir.mkdir(parents=True, exist_ok=True)
readme_path = docs_dir / "README.md"
readme_path.write_text(readme_content)
results.append({"tier": "docs", "path": str(readme_path), "status": "updated"})
else:
print("=== DOCS README ===")
print(readme_content)
results.append({"tier": "docs", "status": "dry_run"})
if not args.dry_run:
print(json.dumps({"results": results}, indent=2))
return 0
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate README.md index for research directories"
)
parser.add_argument("--cache", "-c", action="store_true", help="Generate README for Tier 1 cache")
parser.add_argument("--docs", "-d", action="store_true", help="Generate README for Tier 2 docs")
parser.add_argument("--both", "-b", action="store_true", help="Generate README for both tiers")
parser.add_argument("--docs-dir", help="Override docs directory path")
parser.add_argument("--dry-run", action="store_true", help="Print output without writing files")
args = parser.parse_args()
if not (args.cache or args.docs or args.both):
args.both = True
return cmd_generate(args)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Promote research from Tier 1 cache to Tier 2 project docs.
Usage:
python promote.py <slug> [--output-dir docs/research]
python promote.py <slug> --refresh # Update existing promoted file
python promote.py check <slug> # Check if promoted
Environment:
RESEARCH_CACHE_DIR: Override default cache location (~/.claude/plugins/research)
RESEARCH_DOCS_DIR: Override default output directory (docs/research)
"""
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Tuple
# Ensure sibling imports work from any working directory
sys.path.insert(0, str(Path(__file__).resolve().parent))
from research_utils import (
AUTO_END,
AUTO_START,
TEAM_END,
TEAM_NOTES_TEMPLATE,
TEAM_START,
extract_frontmatter,
extract_team_notes,
get_docs_dir,
get_entry,
)
def build_promoted_content(
metadata: dict,
auto_content: str,
team_notes: Optional[str] = None,
version: str = "1.0.0",
status: str = "Published",
) -> str:
"""Build the full promoted file content."""
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
created_date = metadata.get('researched_at', now)[:10]
title = metadata.get('title', '')
frontmatter = f"""---
title: "{title}"
version: "{version}"
status: {status}
created: {created_date}
last_updated: {created_date}
slug: {metadata.get('slug', '')}
aliases: {json.dumps(metadata.get('aliases', []))}
tags: {json.dumps(metadata.get('tags', []))}
promoted_at: {now}
last_refreshed: {metadata.get('researched_at', now)}
sources: {json.dumps(metadata.get('sources', []))}
---
"""
if team_notes is None:
team_notes = TEAM_NOTES_TEMPLATE
content = frontmatter
content += AUTO_START + "\n"
content += auto_content.strip() + "\n"
content += AUTO_END + "\n\n"
content += TEAM_START
content += team_notes
content += TEAM_END + "\n"
return content
def update_readme_index(
docs_dir: Path,
slug: str,
title: str,
metadata: Optional[dict] = None,
version: str = "1.0.0",
status: str = "Published",
) -> None:
"""Update the README.md index in the docs directory."""
readme_path = docs_dir / "README.md"
created_date = metadata.get('researched_at', '')[:10] if metadata else datetime.now(timezone.utc).strftime("%Y-%m-%d")
if readme_path.exists():
readme_content = readme_path.read_text()
else:
readme_content = """# Research Index
Curated technical research for this project. Each file includes YAML frontmatter with `version`, `status`, `created`, and `last_updated` fields — GitHub renders these as a table at the top of each document.
See [TEMPLATE.md](TEMPLATE.md) for the standard format when creating new research documents.
| Topic | Version | Status | Created | Last Updated |
|-------|---------|--------|---------|--------------|
"""
slug_pattern = re.compile(rf"\|\s*\[.*?\]\({re.escape(slug)}\.md\)")
if slug_pattern.search(readme_content):
entry_pattern = re.compile(
rf"\|\s*\[.*?\]\({re.escape(slug)}\.md\)\s*\|[^\n]*"
)
new_entry = f"| [{title}]({slug}.md) | {version} | {status} | {created_date} | {created_date} |"
readme_content = entry_pattern.sub(new_entry, readme_content)
else:
table_end = readme_content.rfind("|")
if table_end > 0:
line_end = readme_content.find("\n", table_end)
if line_end == -1:
line_end = len(readme_content)
new_entry = f"\n| [{title}]({slug}.md) | {version} | {status} | {created_date} | {created_date} |"
readme_content = (
readme_content[:line_end]
+ new_entry
+ readme_content[line_end:]
)
readme_path.write_text(readme_content)
def promote(
slug: str,
output_dir: Optional[str] = None,
refresh: bool = False,
) -> Tuple[bool, str]:
"""
Promote cached research to project docs.
Returns:
Tuple of (success, message)
"""
entry = get_entry(slug)
if not entry:
return False, f"Cache entry not found: {slug}"
docs_dir = get_docs_dir(output_dir)
docs_dir.mkdir(parents=True, exist_ok=True)
output_file = docs_dir / f"{slug}.md"
team_notes = None
version = "1.0.0"
status = "Published"
if output_file.exists() and refresh:
existing_content = output_file.read_text()
team_notes = extract_team_notes(existing_content)
existing_fm = extract_frontmatter(existing_content)
version = existing_fm.get("version", version).strip('"')
status = existing_fm.get("status", status)
content = build_promoted_content(
metadata=entry["metadata"],
auto_content=entry["content"],
team_notes=team_notes,
version=version,
status=status,
)
output_file.write_text(content)
update_readme_index(
docs_dir,
slug,
entry["metadata"].get("title", slug),
metadata=entry["metadata"],
version=version,
status=status,
)
action = "Updated" if refresh else "Promoted"
preserved = " (team notes preserved)" if team_notes else ""
return True, f"{action} to {output_file}{preserved}"
def cmd_promote(args) -> int:
"""Handle promote command."""
success, message = promote(
slug=args.slug,
output_dir=args.output_dir,
refresh=args.refresh,
)
result = {
"success": success,
"message": message,
"slug": args.slug,
}
if success:
result["path"] = str(get_docs_dir(args.output_dir) / f"{args.slug}.md")
print(json.dumps(result, indent=2))
return 0 if success else 1
def cmd_check_promoted(args) -> int:
"""Check if a slug has been promoted."""
docs_dir = get_docs_dir(args.output_dir)
promoted_file = docs_dir / f"{args.slug}.md"
result = {
"slug": args.slug,
"promoted": promoted_file.exists(),
}
if promoted_file.exists():
content = promoted_file.read_text()
result["has_team_notes"] = bool(extract_team_notes(content))
result["path"] = str(promoted_file)
print(json.dumps(result, indent=2))
return 0
def main() -> int:
# Handle backwards-compat: `promote.py <slug>` without subcommand
# Detect if first arg looks like a slug (not a subcommand)
if len(sys.argv) > 1 and sys.argv[1] not in ("promote", "check", "-h", "--help"):
promote_parser = argparse.ArgumentParser(description="Promote research from cache to project docs")
promote_parser.add_argument("slug", help="Topic slug to promote")
promote_parser.add_argument("--output-dir", "-o", help="Output directory (default: docs/research)")
promote_parser.add_argument("--refresh", "-r", action="store_true", help="Refresh existing promoted file, preserving team notes")
args = promote_parser.parse_args()
return cmd_promote(args)
parser = argparse.ArgumentParser(
description="Promote research from cache to project docs"
)
subparsers = parser.add_subparsers(dest="command", required=True)
# promote subcommand
promote_parser = subparsers.add_parser("promote", help="Promote cached research")
promote_parser.add_argument("slug", help="Topic slug to promote")
promote_parser.add_argument("--output-dir", "-o", help="Output directory (default: docs/research)")
promote_parser.add_argument("--refresh", "-r", action="store_true", help="Refresh existing promoted file, preserving team notes")
promote_parser.set_defaults(func=cmd_promote)
# check subcommand
check_parser = subparsers.add_parser("check", help="Check promotion status")
check_parser.add_argument("slug", help="Topic slug")
check_parser.add_argument("--output-dir", "-o", help="Docs directory to check")
check_parser.set_defaults(func=cmd_check_promoted)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
Shared utilities for deep research cache management.
Provides common functions used by cache_manager.py, index_generator.py,
and promote.py to avoid duplication.
"""
import json
import os
import re
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
# Configuration defaults
DEFAULT_CACHE_DIR = Path.home() / ".claude" / "plugins" / "research"
DEFAULT_DOCS_DIR = Path("docs/research")
DEFAULT_TTL_DAYS = 30
# Section markers for promoted files
AUTO_START = "<!-- AUTO-GENERATED: Start -->"
AUTO_END = "<!-- AUTO-GENERATED: End -->"
TEAM_START = "<!-- TEAM-NOTES: Start -->"
TEAM_END = "<!-- TEAM-NOTES: End -->"
TEAM_NOTES_TEMPLATE = """
## Team Context
_Add project-specific notes, implementation references, and team knowledge here._
"""
# ---------------------------------------------------------------------------
# Directory / config helpers
# ---------------------------------------------------------------------------
def get_cache_dir() -> Path:
"""Get cache directory from environment or default."""
env_dir = os.environ.get("RESEARCH_CACHE_DIR")
if env_dir:
return Path(env_dir)
return DEFAULT_CACHE_DIR
def _resolve_jd_research_path() -> Optional[Path]:
"""Try to resolve research path via .jd-config.json (minimal, self-contained)."""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0:
return None
git_root = Path(result.stdout.strip())
except Exception:
return None
config_path = git_root / ".jd-config.json"
if not config_path.exists():
return None
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError):
return None
root = config.get("root", "docs")
areas = config.get("areas", {})
for prefix, name in areas.items():
if "research" in name.lower():
return Path(root) / f"{prefix}-{name}"
return None
def get_docs_dir(override: Optional[str] = None) -> Path:
"""Get docs directory from argument, environment, or default.
Resolution order:
1. Explicit override argument
2. RESEARCH_DOCS_DIR environment variable
3. JD-aware path from .jd-config.json (if present)
4. Default: docs/research
"""
if override:
return Path(override)
env_dir = os.environ.get("RESEARCH_DOCS_DIR")
if env_dir:
return Path(env_dir)
jd_path = _resolve_jd_research_path()
if jd_path is not None:
return jd_path
return DEFAULT_DOCS_DIR
def get_ttl_days() -> int:
"""Get TTL days from environment or default."""
env_ttl = os.environ.get("RESEARCH_TTL_DAYS")
if env_ttl:
try:
return int(env_ttl)
except ValueError:
pass
return DEFAULT_TTL_DAYS
def get_current_project() -> Optional[str]:
"""Detect the current project from the git repository name.
Returns the basename of the git repo root directory, or None if
not inside a git repository or git is unavailable.
"""
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return Path(result.stdout.strip()).name
except Exception:
pass
return None
# ---------------------------------------------------------------------------
# Slug helpers
# ---------------------------------------------------------------------------
def normalize_slug(topic: str) -> str:
"""
Convert a topic to a normalized slug.
Examples:
"Domain-Driven Design" -> "domain-driven-design"
"React Hooks" -> "react-hooks"
"C# async/await" -> "csharp-async-await"
"""
slug = topic.lower()
slug = slug.replace("c#", "csharp").replace("c++", "cpp")
slug = re.sub(r"[^a-z0-9]+", "-", slug)
slug = slug.strip("-")
slug = re.sub(r"-+", "-", slug)
return slug
# ---------------------------------------------------------------------------
# Cache directory management
# ---------------------------------------------------------------------------
def ensure_cache_dir() -> Path:
"""Ensure cache directory structure exists."""
cache_dir = get_cache_dir()
entries_dir = cache_dir / "entries"
entries_dir.mkdir(parents=True, exist_ok=True)
index_file = cache_dir / "index.json"
if not index_file.exists():
index_file.write_text("{}")
return cache_dir
# ---------------------------------------------------------------------------
# Index operations
# ---------------------------------------------------------------------------
def get_index() -> dict:
"""
Read the cache index.
Handles two formats:
- Legacy: {"version": "...", "entries": [{slug, title, ...}, ...]}
- Current: {"slug-key": {slug, title, ...}, ...}
Always returns the current dict-keyed-by-slug format.
"""
cache_dir = get_cache_dir()
index_file = cache_dir / "index.json"
if not index_file.exists():
return {}
try:
data = json.loads(index_file.read_text())
except json.JSONDecodeError:
return {}
# Handle legacy array format
if isinstance(data, dict) and "entries" in data and isinstance(data["entries"], list):
converted = {}
for entry in data["entries"]:
slug = entry.get("slug", "")
if slug:
converted[slug] = entry
return converted
return data
def save_index(index: dict) -> None:
"""Write the cache index."""
cache_dir = ensure_cache_dir()
index_file = cache_dir / "index.json"
index_file.write_text(json.dumps(index, indent=2, sort_keys=True))
# ---------------------------------------------------------------------------
# Alias lookup
# ---------------------------------------------------------------------------
def find_by_alias(topic: str) -> Optional[str]:
"""
Find a slug by alias lookup.
Returns the canonical slug if found, None otherwise.
"""
normalized = normalize_slug(topic)
index = get_index()
if normalized in index:
return normalized
for slug, metadata in index.items():
aliases = metadata.get("aliases", [])
normalized_aliases = [normalize_slug(a) for a in aliases]
if normalized in normalized_aliases:
return slug
return None
# ---------------------------------------------------------------------------
# Cache entry read
# ---------------------------------------------------------------------------
def _strip_yaml_frontmatter(text: str) -> str:
"""Strip YAML frontmatter (--- ... ---) from markdown content."""
if not text.startswith("---"):
return text
end = text.find("---", 3)
if end == -1:
return text
return text[end + 3:].lstrip("\n")
def get_entry(slug: str) -> Optional[dict]:
"""
Get a cache entry by slug.
Returns dict with 'metadata' and 'content' keys, or None if not found.
Falls back to reading ``research.md`` (with YAML frontmatter stripped)
when ``content.md`` is missing or empty — a resilience measure against
agent misbehavior where content is written to the wrong file.
"""
cache_dir = get_cache_dir()
entry_dir = cache_dir / "entries" / slug
metadata_file = entry_dir / "metadata.json"
content_file = entry_dir / "content.md"
research_file = entry_dir / "research.md"
if not metadata_file.exists():
return None
try:
metadata = json.loads(metadata_file.read_text())
except (json.JSONDecodeError, IOError):
return None
# Primary: read content.md
content = ""
if content_file.exists():
try:
content = content_file.read_text()
except IOError:
pass
# Fallback: if content.md is empty/missing, try research.md
if not content.strip() and research_file.exists():
try:
raw = research_file.read_text()
if raw.strip():
content = _strip_yaml_frontmatter(raw)
except IOError:
pass
if not content_file.exists() and not content.strip():
return None
return {"metadata": metadata, "content": content}
# ---------------------------------------------------------------------------
# Expiration / date helpers
# ---------------------------------------------------------------------------
def check_expiration(metadata_or_str) -> dict:
"""
Check if a cache entry is expired.
Accepts either a metadata dict (with 'expires_at' key) or a raw
ISO timestamp string.
Returns dict with 'expired' (bool) and 'expires_at' (str).
"""
if isinstance(metadata_or_str, dict):
expires_at_str = metadata_or_str.get("expires_at", "")
else:
expires_at_str = metadata_or_str or ""
if not expires_at_str:
return {"expired": True, "expires_at": None}
try:
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
return {
"expired": now > expires_at,
"expires_at": expires_at_str,
}
except ValueError:
return {"expired": True, "expires_at": expires_at_str}
def format_date(iso_string: str) -> str:
"""Format ISO date string to YYYY-MM-DD."""
if not iso_string:
return "N/A"
try:
return iso_string[:10]
except (ValueError, IndexError):
return "N/A"
# ---------------------------------------------------------------------------
# Markdown helpers
# ---------------------------------------------------------------------------
def extract_frontmatter(content: str) -> dict:
"""Extract YAML frontmatter from markdown."""
if not content.startswith("---"):
return {}
end = content.find("---", 3)
if end == -1:
return {}
frontmatter = content[3:end].strip()
result = {}
for line in frontmatter.split("\n"):
if ":" in line:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if value.startswith("["):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
result[key] = value
return result
def extract_team_notes(content: str) -> Optional[str]:
"""Extract existing team notes from promoted file."""
pattern = re.compile(
rf"{re.escape(TEAM_START)}(.*?){re.escape(TEAM_END)}",
re.DOTALL,
)
match = pattern.search(content)
if match:
return match.group(1)
return None
def has_team_notes(content: str) -> bool:
"""Check if promoted file has non-empty team notes."""
notes = extract_team_notes(content)
if not notes:
return False
notes = notes.strip()
if "_Add project-specific notes" in notes:
return False
return bool(notes)
Troubleshooting Deep Research
Common issues and solutions when using the deep research skill.
Cache Issues
Problem: Cache directory doesn't exist
Symptoms: "Cache entry not found" even after researching.
Solution: The cache is created on first use at ~/.claude/plugins/research/. If it doesn't exist, ensure the agent has write permissions to your home directory. You can manually create it:
mkdir -p ~/.claude/plugins/research/entries
echo "{}" > ~/.claude/plugins/research/index.jsonProblem: Research not found after researching
Symptoms: /research list shows empty, but research was conducted.
Solution: Check if the research was successful. The agent needs to complete the full flow including cache write. If interrupted, re-run the research command.
Problem: Cache entries showing as expired immediately
Symptoms: Research expires right after being cached.
Solution: Check system clock. The TTL calculation uses UTC timestamps. If your system clock is significantly wrong, entries may appear expired.
---
EXA API Issues
Problem: EXA tools not available
Symptoms: Agent reports MCP tools not found or unavailable.
Solution: 1. Verify EXA MCP server is configured in your Claude Code settings 2. Check that the EXA API key is valid 3. Restart Claude Code to reload MCP connections
Problem: Research returns empty or poor results
Symptoms: Research completes but content is minimal or irrelevant.
Solutions: 1. Try more specific search terms 2. Use quotes for exact phrases: /research "React Server Components" 3. Add context words: /research domain-driven design best practices
Problem: Rate limiting or API errors
Symptoms: Research fails with API errors.
Solution: EXA has rate limits. If you hit them: 1. Wait a few minutes before retrying 2. Check your EXA account quota 3. Use cached research when available to reduce API calls
---
Promotion Issues
Problem: Promote fails with "not found"
Symptoms: /research promote <slug> says research not cached.
Solutions: 1. Run /research list to see available slugs 2. Check slug spelling matches exactly (lowercase, hyphenated) 3. Run /research <topic> first to cache it
Problem: Team notes lost after refresh
Symptoms: <!-- TEAM-NOTES --> section is empty after refresh.
Solution: This shouldn't happen - the refresh flow preserves team notes. If it does: 1. Check git history for the previous version 2. Verify the file had proper <!-- TEAM-NOTES: Start --> and <!-- TEAM-NOTES: End --> markers 3. Report as a bug if markers were correct
Problem: Promoted file not appearing in README
Symptoms: File exists in docs/research/ but not in README index.
Solution: Run the index generator manually from the deep-research skill directory:
python3 scripts/index_generator.py --docs---
Slug/Alias Issues
Problem: Different topics colliding to same slug
Symptoms: Researching "ES6" overwrites "Event Sourcing" (both normalize to similar slugs).
Solution: Use more specific topic names:
- "ES6 JavaScript features" →
es6-javascript-features - "Event Sourcing pattern" →
event-sourcing-pattern
Problem: Alias not resolving
Symptoms: /research DDD doesn't find cached "domain-driven-design".
Solution: Aliases must be explicitly added during research. To add an alias: 1. Edit the cached metadata.json file 2. Add to the aliases array 3. The next lookup will resolve it
---
Script Execution Issues
Problem: Scripts fail with permission denied
Symptoms: Python scripts can't execute.
Solution: Make scripts executable from the deep-research skill directory:
chmod +x scripts/*.pyProblem: Scripts fail with import errors
Symptoms: ModuleNotFoundError or similar.
Solution: Scripts use standard library only. Verify Python 3.8+ is installed:
python3 --version---
Getting Help
If issues persist: 1. Check /research list output for diagnostic info 2. Review cache files at ~/.claude/plugins/research/ 3. Check for JSON parsing errors in index.json 4. Report persistent issues with steps to reproduce
Deep Research Workflow
Detailed process flows for the deep-research skill.
Research Flow
┌─────────────────────────────────────────────────────────────────┐
│ USER REQUEST │
│ "Research event sourcing patterns" │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. NORMALIZE TO SLUG │
│ │
│ Input: "event sourcing patterns" │
│ Output: "event-sourcing-patterns" │
│ │
│ Rules: │
│ - Lowercase all characters │
│ - Replace spaces with hyphens │
│ - Remove special characters │
│ - Check alias mappings │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. CHECK CACHE │
│ │
│ Path: ~/.claude/plugins/research/index.json │
│ │
│ Lookup by: │
│ - Direct slug match │
│ - Alias match (e.g., "ES" → "event-sourcing") │
│ │
│ If found, check expiration: │
│ - expires_at > now → VALID │
│ - expires_at <= now → EXPIRED (suggest refresh) │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────┴─────────┐
│ │
CACHE HIT CACHE MISS
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ 3a. RETURN CACHED │ │ 3b. CONDUCT RESEARCH │
│ │ │ │
│ Read content.md from: │ │ Invoke deep-researcher │
│ ~/.claude/plugins/ │ │ agent with EXA tools: │
│ research/entries/ │ │ │
│ {slug}/content.md │ │ - web_search_exa for │
│ │ │ concepts & guides │
│ Report: │ │ - get_code_context_exa │
│ - Cache status │ │ for implementations │
│ - Expiration date │ │ │
│ - Content summary │ │ Structure results in │
│ │ │ standard format │
└─────────────────────────┘ └─────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 4. CACHE RESULTS │
│ │
│ Create entry directory: │
│ ~/.claude/plugins/research/ │
│ entries/{slug}/ │
│ │
│ Write: │
│ - metadata.json (timestamps, │
│ aliases, tags, projects) │
│ - content.md (full research) │
│ │
│ Auto-detect project from git │
│ repo and add to projects list. │
│ Update index.json │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ 5. REPORT TO USER │
│ │
│ - Cache status (hit/miss) │
│ - Brief summary │
│ - File path │
│ - Promote suggestion │
└─────────────────────────────────┘Promote Flow
┌─────────────────────────────────────────────────────────────────┐
│ /research promote {slug} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. READ FROM CACHE │
│ │
│ Path: ~/.claude/plugins/research/entries/{slug}/content.md │
│ │
│ If not found → Error: "Research not cached. Run /research │
│ {topic} first." │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. ADD SECTION MARKERS │
│ │
│ Wrap existing content: │
│ <!-- AUTO-GENERATED: Start --> │
│ {cached content} │
│ <!-- AUTO-GENERATED: End --> │
│ │
│ Add team section: │
│ <!-- TEAM-NOTES: Start --> │
│ ## Team Context │
│ [Add project-specific notes here] │
│ <!-- TEAM-NOTES: End --> │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. WRITE TO PROJECT DOCS │
│ │
│ Path: JD-resolved or docs/research/{slug}.md │
│ (checks .jd-config.json for research area, e.g. 30-research) │
│ │
│ Create directory if doesn't exist │
│ Add promoted_at timestamp to frontmatter │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. UPDATE PROJECT INDEX │
│ │
│ Path: docs/research/README.md │
│ │
│ Add/update entry in index table: │
│ | Slug | Title | Promoted | Has Team Notes | │
│ |------|-------|----------|----------------| │
│ | {slug} | {title} | {date} | No | │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. CONFIRM TO USER │
│ │
│ "Promoted to docs/research/{slug}.md" │
│ "Edit TEAM-NOTES section to add project context" │
└─────────────────────────────────────────────────────────────────┘Refresh Flow
┌─────────────────────────────────────────────────────────────────┐
│ /research refresh {slug} │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. CONDUCT FRESH RESEARCH │
│ │
│ Bypass cache, invoke deep-researcher agent │
│ Use EXA tools for new content │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. UPDATE TIER 1 CACHE │
│ │
│ Replace entirely: │
│ - metadata.json (new timestamps) │
│ - content.md (new content) │
│ - index.json entry │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. CHECK FOR PROMOTED VERSION │
│ │
│ Path: JD-resolved or docs/research/{slug}.md │
│ │
│ If exists → Update Tier 2 │
│ If not → Done │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────┴─────────┐
│ │
EXISTS NOT FOUND
│ │
▼ ▼
┌─────────────────────────┐ ┌──────────┐
│ 4. UPDATE TIER 2 │ │ DONE │
│ │ └──────────┘
│ Parse existing file: │
│ - Extract TEAM-NOTES │
│ - Replace AUTO-GENERATED│
│ with new content │
│ - Preserve TEAM-NOTES │
│ - Update timestamps │
└─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. REPORT TO USER │
│ │
│ "Refreshed {slug}" │
│ "Updated: Tier 1 cache" │
│ "Updated: docs/research/{slug}.md (preserved team notes)" │
└─────────────────────────────────────────────────────────────────┘File Structures
Tier 1 Cache Entry
~/.claude/plugins/research/entries/{slug}/
├── metadata.json
│ {
│ "slug": "domain-driven-design",
│ "title": "Domain-Driven Design",
│ "aliases": ["DDD", "domain driven design"],
│ "tags": ["architecture", "patterns", "modeling"],
│ "projects": ["my-ecommerce-app"],
│ "researched_at": "2025-01-14T10:30:00Z",
│ "expires_at": "2025-02-13T10:30:00Z",
│ "sources": [
│ {"url": "https://...", "title": "..."}
│ ]
│ }
│
└── content.md
---
slug: domain-driven-design
title: Domain-Driven Design
...
---
# Domain-Driven Design
## Overview
...Tier 2 Promoted Entry
docs/research/{slug}.md (default)
docs/30-research/{slug}.md (JD-aware, if .jd-config.json exists)
---
slug: domain-driven-design
title: Domain-Driven Design
promoted_at: 2025-01-14T12:00:00Z
last_refreshed: 2025-01-14T10:30:00Z
---
<!-- AUTO-GENERATED: Start -->
# Domain-Driven Design
## Overview
[Auto-generated content...]
<!-- AUTO-GENERATED: End -->
<!-- TEAM-NOTES: Start -->
## Team Context
- We use DDD in our Order Management bounded context
- See `src/order/` for implementation
- Contact @jane for DDD questions
<!-- TEAM-NOTES: End -->Index Files
Tier 1 Index (~/.claude/plugins/research/index.json):
{
"domain-driven-design": {
"slug": "domain-driven-design",
"title": "Domain-Driven Design",
"aliases": ["DDD", "domain driven design"],
"projects": ["my-ecommerce-app"],
"researched_at": "2025-01-14T10:30:00Z",
"expires_at": "2025-02-13T10:30:00Z"
},
"event-sourcing": {
"slug": "event-sourcing",
"title": "Event Sourcing",
"aliases": ["ES"],
"projects": ["my-ecommerce-app"],
"researched_at": "2025-01-10T08:00:00Z",
"expires_at": "2025-02-09T08:00:00Z"
}
}Tier 2 Index (docs/research/README.md):
# Research Index
Curated technical research for this project.
| Topic | Promoted | Last Refreshed | Team Notes |
|-------|----------|----------------|------------|
| [Domain-Driven Design](domain-driven-design.md) | 2025-01-14 | 2025-01-14 | Yes |
| [Event Sourcing](event-sourcing.md) | 2025-01-12 | 2025-01-10 | No |