@ericrisco/rsc 0.1.32 → 0.1.33

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 (38) hide show
  1. package/README.md +4 -4
  2. package/manifest.json +24 -5
  3. package/package.json +1 -1
  4. package/scripts/lib/domains.js +1 -1
  5. package/skills/analyze/SKILL.md +1 -0
  6. package/skills/author-skill/SKILL.md +20 -0
  7. package/skills/author-skill/references/description-recipe.md +2 -0
  8. package/skills/debug/SKILL.md +1 -1
  9. package/skills/implement/SKILL.md +72 -2
  10. package/skills/implement/references/per-task-review.md +46 -0
  11. package/skills/implement/scripts/review-package +59 -0
  12. package/skills/implement/scripts/sdd-workspace +47 -0
  13. package/skills/implement/scripts/task-brief +77 -0
  14. package/skills/parallel/SKILL.md +29 -0
  15. package/skills/plan/references/plan-template.md +18 -0
  16. package/skills/roast-me/SKILL.md +124 -0
  17. package/skills/roast-me/evals/README.md +76 -0
  18. package/skills/roast-me/evals/cases.yaml +75 -0
  19. package/skills/roast-me/prompts/analyze.md +90 -0
  20. package/skills/roast-me/prompts/compute.md +100 -0
  21. package/skills/roast-me/prompts/roast.md +181 -0
  22. package/skills/roast-me/tools/adapters/__init__.py +1 -0
  23. package/skills/roast-me/tools/adapters/__pycache__/__init__.cpython-312.pyc +0 -0
  24. package/skills/roast-me/tools/adapters/__pycache__/base.cpython-312.pyc +0 -0
  25. package/skills/roast-me/tools/adapters/__pycache__/claude.cpython-312.pyc +0 -0
  26. package/skills/roast-me/tools/adapters/__pycache__/codex.cpython-312.pyc +0 -0
  27. package/skills/roast-me/tools/adapters/__pycache__/gemini.cpython-312.pyc +0 -0
  28. package/skills/roast-me/tools/adapters/__pycache__/registry.cpython-312.pyc +0 -0
  29. package/skills/roast-me/tools/adapters/base.py +53 -0
  30. package/skills/roast-me/tools/adapters/claude.py +140 -0
  31. package/skills/roast-me/tools/adapters/codex.py +113 -0
  32. package/skills/roast-me/tools/adapters/gemini.py +121 -0
  33. package/skills/roast-me/tools/adapters/registry.py +68 -0
  34. package/skills/roast-me/tools/extract_prompts.py +520 -0
  35. package/skills/ship/SKILL.md +9 -1
  36. package/skills/specify/SKILL.md +21 -1
  37. package/skills/tasks/SKILL.md +25 -0
  38. package/skills/worktrees/SKILL.md +25 -0
@@ -0,0 +1,53 @@
1
+ """Base adapter contract for roast-me transcript discovery.
2
+
3
+ Every runtime adapter implements two methods:
4
+ discover() -> list[Path] — find local session files; return [] if none found
5
+ parse(path) -> list[dict] — yield raw prompt-like records from one file
6
+
7
+ The adapter contract is intentionally minimal. Higher-level normalisation
8
+ (truncation, field enrichment, error/correction detection) happens in
9
+ extract_prompts.py, not here.
10
+
11
+ An adapter MUST:
12
+ - Never raise on missing files or unexpected formats — return [] instead.
13
+ - Tag every record with {"runtime": self.RUNTIME_ID}.
14
+ - Return only records that look like user-initiated prompts (not tool results,
15
+ not system messages).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from abc import ABC, abstractmethod
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+
25
+ class BaseAdapter(ABC):
26
+ """Contract every runtime adapter must satisfy."""
27
+
28
+ #: Short identifier used to tag records and in CLI --runtime flag.
29
+ RUNTIME_ID: str = "unknown"
30
+
31
+ @abstractmethod
32
+ def discover(self) -> list[Path]:
33
+ """Return a list of session file paths found for this runtime.
34
+
35
+ Must return [] (not raise) when the runtime is not installed or no
36
+ data directory exists.
37
+ """
38
+
39
+ @abstractmethod
40
+ def parse(self, path: Path) -> list[dict[str, Any]]:
41
+ """Parse one session file and return a list of raw prompt records.
42
+
43
+ Each record must include at minimum:
44
+ - "runtime": str — the RUNTIME_ID of this adapter
45
+ - "prompt_text": str — the user's message text
46
+ - "timestamp": float | None — unix timestamp of the message (or None)
47
+ - "session_file": str — stringified path of the source file
48
+
49
+ Additional fields are welcomed and used by extract_prompts.py if present
50
+ (e.g. "model", "cost_usd").
51
+
52
+ Must return [] (not raise) on parse failures.
53
+ """
@@ -0,0 +1,140 @@
1
+ """Claude Code adapter — first-class reference implementation.
2
+
3
+ Session files: ~/.claude/projects/*/*.jsonl
4
+ Format: JSON Lines, one object per line.
5
+
6
+ Each line is a conversation event with a "type" field:
7
+ "user" — user message (prompt or tool result)
8
+ "assistant" — model response
9
+
10
+ User prompt records are lines where:
11
+ - type == "user"
12
+ - message.isMeta is falsy
13
+ - message.content contains at least one text block (not only tool_result blocks)
14
+
15
+ This is the canonical adapter; its parsing logic is the reference for all others.
16
+
17
+ Confirmed: path and format verified against live ~/.claude/projects structure on
18
+ macOS (2026-06). The JSONL schema is stable across Claude Code versions.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import time
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .base import BaseAdapter
29
+
30
+
31
+ class ClaudeAdapter(BaseAdapter):
32
+ RUNTIME_ID = "claude"
33
+
34
+ # Location of Claude Code project session files.
35
+ PROJECTS_DIR = Path.home() / ".claude" / "projects"
36
+
37
+ def discover(self) -> list[Path]:
38
+ """Find all *.jsonl session files under ~/.claude/projects/."""
39
+ if not self.PROJECTS_DIR.exists():
40
+ return []
41
+ files: list[Path] = []
42
+ try:
43
+ for jsonl in self.PROJECTS_DIR.rglob("*.jsonl"):
44
+ try:
45
+ if jsonl.is_file():
46
+ files.append(jsonl)
47
+ except OSError:
48
+ continue
49
+ except OSError:
50
+ return []
51
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
52
+
53
+ def parse(self, path: Path) -> list[dict[str, Any]]:
54
+ """Parse one Claude Code .jsonl session file."""
55
+ records: list[dict[str, Any]] = []
56
+ try:
57
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
58
+ except OSError:
59
+ return []
60
+
61
+ for line in lines:
62
+ line = line.strip()
63
+ if not line:
64
+ continue
65
+ try:
66
+ obj = json.loads(line)
67
+ except json.JSONDecodeError:
68
+ continue
69
+
70
+ if obj.get("type") != "user":
71
+ continue
72
+
73
+ msg = obj.get("message", {})
74
+ if msg.get("isMeta"):
75
+ continue
76
+
77
+ content = msg.get("content", [])
78
+ text = _extract_text(content)
79
+ if not text.strip():
80
+ continue
81
+ # Skip messages that are only tool_result blocks
82
+ if _is_only_tool_results(content):
83
+ continue
84
+
85
+ ts = _parse_timestamp(obj)
86
+ records.append({
87
+ "runtime": self.RUNTIME_ID,
88
+ "session_file": str(path),
89
+ "prompt_text": text,
90
+ "timestamp": ts,
91
+ # Forward the raw object so the main extractor can inspect
92
+ # adjacent lines for error/correction context.
93
+ "_raw": obj,
94
+ })
95
+
96
+ return records
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _extract_text(content: Any) -> str:
104
+ if isinstance(content, str):
105
+ return content
106
+ if isinstance(content, list):
107
+ parts = []
108
+ for block in content:
109
+ if isinstance(block, dict) and block.get("type") == "text":
110
+ parts.append(block.get("text", ""))
111
+ return "\n".join(parts)
112
+ return ""
113
+
114
+
115
+ def _is_only_tool_results(content: Any) -> bool:
116
+ if not isinstance(content, list):
117
+ return False
118
+ has_text = any(
119
+ isinstance(b, dict) and b.get("type") == "text" for b in content
120
+ )
121
+ has_tool_result = any(
122
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
123
+ )
124
+ return has_tool_result and not has_text
125
+
126
+
127
+ def _parse_timestamp(obj: dict) -> float | None:
128
+ ts = obj.get("timestamp")
129
+ if ts is None:
130
+ return None
131
+ if isinstance(ts, (int, float)):
132
+ return float(ts)
133
+ if isinstance(ts, str):
134
+ try:
135
+ import datetime
136
+ dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
137
+ return dt.timestamp()
138
+ except (ValueError, AttributeError):
139
+ pass
140
+ return None
@@ -0,0 +1,113 @@
1
+ """Codex CLI adapter (@openai/codex).
2
+
3
+ Session files: ~/.codex/sessions/YYYY/MM/DD/rollout-<timestamp>.jsonl
4
+ Format: JSON Lines. Each line is an event object.
5
+
6
+ CONFIRMED (2026-06, via official CLI reference and community tooling):
7
+ - Base path: ~/.codex/sessions/
8
+ - Subdirectory layout: year/month/day
9
+ - File naming: rollout-<ISO-timestamp>.jsonl
10
+ - Format: JSONL, one event per line
11
+
12
+ STUBBED (format details):
13
+ The internal schema of each JSONL line has not been verified against a live
14
+ Codex installation. Based on community-authored session viewers, events appear
15
+ to carry {"role": "user"|"assistant", "content": "...", ...}. If the role
16
+ field is absent or the schema differs, parse() returns [] gracefully.
17
+
18
+ Degradation: if ~/.codex/ does not exist, discover() returns [].
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from .base import BaseAdapter
28
+
29
+
30
+ class CodexAdapter(BaseAdapter):
31
+ RUNTIME_ID = "codex"
32
+
33
+ # CONFIRMED: path layout from official CLI docs and community tools.
34
+ SESSIONS_DIR = Path.home() / ".codex" / "sessions"
35
+
36
+ def discover(self) -> list[Path]:
37
+ """Find all rollout-*.jsonl files under ~/.codex/sessions/."""
38
+ if not self.SESSIONS_DIR.exists():
39
+ return []
40
+ files: list[Path] = []
41
+ try:
42
+ for jsonl in self.SESSIONS_DIR.rglob("rollout-*.jsonl"):
43
+ try:
44
+ if jsonl.is_file():
45
+ files.append(jsonl)
46
+ except OSError:
47
+ continue
48
+ except OSError:
49
+ return []
50
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
51
+
52
+ def parse(self, path: Path) -> list[dict[str, Any]]:
53
+ """Parse one Codex session JSONL file.
54
+
55
+ STUBBED schema: assumes {"role": "user", "content": "..."} event lines.
56
+ Falls back silently if the schema differs.
57
+ """
58
+ records: list[dict[str, Any]] = []
59
+ try:
60
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
61
+ except OSError:
62
+ return []
63
+
64
+ for line in lines:
65
+ line = line.strip()
66
+ if not line:
67
+ continue
68
+ try:
69
+ obj = json.loads(line)
70
+ except json.JSONDecodeError:
71
+ continue
72
+
73
+ # STUBBED: expected schema based on community session viewers.
74
+ role = obj.get("role") or obj.get("type", "")
75
+ if role != "user":
76
+ continue
77
+
78
+ content = obj.get("content", "")
79
+ if isinstance(content, list):
80
+ # Some formats use a content array like OpenAI chat API.
81
+ parts = [
82
+ c.get("text", "") if isinstance(c, dict) else str(c)
83
+ for c in content
84
+ if isinstance(c, dict) and c.get("type") == "text"
85
+ ]
86
+ text = "\n".join(parts)
87
+ elif isinstance(content, str):
88
+ text = content
89
+ else:
90
+ text = ""
91
+
92
+ if not text.strip():
93
+ continue
94
+
95
+ ts = obj.get("timestamp") or obj.get("created_at")
96
+ if isinstance(ts, str):
97
+ try:
98
+ import datetime
99
+ dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
100
+ ts = dt.timestamp()
101
+ except (ValueError, AttributeError):
102
+ ts = None
103
+ elif not isinstance(ts, (int, float)):
104
+ ts = None
105
+
106
+ records.append({
107
+ "runtime": self.RUNTIME_ID,
108
+ "session_file": str(path),
109
+ "prompt_text": text,
110
+ "timestamp": ts,
111
+ })
112
+
113
+ return records
@@ -0,0 +1,121 @@
1
+ """Gemini CLI adapter (@google/gemini-cli).
2
+
3
+ Session files: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
4
+ Format: JSON Lines. Each line is a MessageRecord.
5
+
6
+ CONFIRMED (2026-06, via official Gemini CLI docs at google-gemini.github.io
7
+ and github.com/google-gemini/gemini-cli session-management.md):
8
+ - Base path: ~/.gemini/tmp/
9
+ - Subdirectory: <project_hash>/chats/
10
+ - File format: JSONL
11
+ - MessageRecord fields include: sessionId, projectHash, model, role,
12
+ content (array of {text: "..."}), and token usage fields.
13
+
14
+ STUBBED (exact MessageRecord schema):
15
+ The exact JSONL schema per line has not been verified against a live Gemini
16
+ CLI installation. The role/content structure is inferred from the official
17
+ session-management docs. If the schema differs, parse() returns [] gracefully.
18
+
19
+ Degradation: if ~/.gemini/ does not exist, discover() returns [].
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from .base import BaseAdapter
29
+
30
+
31
+ class GeminiAdapter(BaseAdapter):
32
+ RUNTIME_ID = "gemini"
33
+
34
+ # CONFIRMED: path from official gemini-cli session-management docs.
35
+ GEMINI_TMP_DIR = Path.home() / ".gemini" / "tmp"
36
+
37
+ def discover(self) -> list[Path]:
38
+ """Find all *.jsonl chat files under ~/.gemini/tmp/<hash>/chats/."""
39
+ if not self.GEMINI_TMP_DIR.exists():
40
+ return []
41
+ files: list[Path] = []
42
+ try:
43
+ # Pattern: ~/.gemini/tmp/<project_hash>/chats/*.jsonl
44
+ for jsonl in self.GEMINI_TMP_DIR.rglob("chats/*.jsonl"):
45
+ try:
46
+ if jsonl.is_file():
47
+ files.append(jsonl)
48
+ except OSError:
49
+ continue
50
+ except OSError:
51
+ return []
52
+ return sorted(files, key=lambda f: f.stat().st_mtime, reverse=True)
53
+
54
+ def parse(self, path: Path) -> list[dict[str, Any]]:
55
+ """Parse one Gemini CLI chat JSONL file.
56
+
57
+ STUBBED schema: expected MessageRecord with {role, content: [{text}], ...}.
58
+ Falls back silently if the schema differs.
59
+ """
60
+ records: list[dict[str, Any]] = []
61
+ try:
62
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
63
+ except OSError:
64
+ return []
65
+
66
+ for line in lines:
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ obj = json.loads(line)
72
+ except json.JSONDecodeError:
73
+ continue
74
+
75
+ # STUBBED: inferred from gemini-cli session-management docs.
76
+ role = obj.get("role", "")
77
+ if role not in ("user", "USER"):
78
+ continue
79
+
80
+ content = obj.get("content", [])
81
+ if isinstance(content, list):
82
+ parts = []
83
+ for block in content:
84
+ if isinstance(block, dict):
85
+ text = block.get("text", "")
86
+ if text:
87
+ parts.append(text)
88
+ text = "\n".join(parts)
89
+ elif isinstance(content, str):
90
+ text = content
91
+ else:
92
+ text = ""
93
+
94
+ if not text.strip():
95
+ continue
96
+
97
+ # Gemini MessageRecord may carry token counts and model name.
98
+ model = obj.get("model") or obj.get("modelVersion")
99
+ ts = obj.get("timestamp") or obj.get("createTime")
100
+ if isinstance(ts, str):
101
+ try:
102
+ import datetime
103
+ dt = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00"))
104
+ ts = dt.timestamp()
105
+ except (ValueError, AttributeError):
106
+ ts = None
107
+ elif not isinstance(ts, (int, float)):
108
+ ts = None
109
+
110
+ record: dict[str, Any] = {
111
+ "runtime": self.RUNTIME_ID,
112
+ "session_file": str(path),
113
+ "prompt_text": text,
114
+ "timestamp": ts,
115
+ }
116
+ if model:
117
+ record["model"] = str(model)
118
+
119
+ records.append(record)
120
+
121
+ return records
@@ -0,0 +1,68 @@
1
+ """Registry mapping runtime IDs to adapter classes.
2
+
3
+ Usage:
4
+
5
+ from adapters.registry import get_adapters
6
+
7
+ # Get adapters to run for a given --runtime flag value.
8
+ for adapter in get_adapters("auto"):
9
+ files = adapter.discover()
10
+ ...
11
+
12
+ auto mode: runs EVERY registered adapter and merges results. If an adapter
13
+ finds no files (discover() returns []), it is silently skipped. Records from
14
+ each adapter are tagged with their RUNTIME_ID.
15
+
16
+ Unknown runtime: raises ValueError with a clear message so the caller can
17
+ print it and exit 0 with an empty result (never crash).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .base import BaseAdapter
23
+ from .claude import ClaudeAdapter
24
+ from .codex import CodexAdapter
25
+ from .gemini import GeminiAdapter
26
+
27
+ # All registered adapters. Add new runtimes here.
28
+ _ALL_ADAPTERS: list[type[BaseAdapter]] = [
29
+ ClaudeAdapter,
30
+ CodexAdapter,
31
+ GeminiAdapter,
32
+ ]
33
+
34
+ # Map runtime ID -> adapter class
35
+ _REGISTRY: dict[str, type[BaseAdapter]] = {
36
+ cls.RUNTIME_ID: cls for cls in _ALL_ADAPTERS
37
+ }
38
+
39
+
40
+ def get_adapters(runtime: str) -> list[BaseAdapter]:
41
+ """Return instantiated adapters for the given runtime identifier.
42
+
43
+ Args:
44
+ runtime: One of "auto", or a specific runtime ID such as "claude",
45
+ "codex", "gemini".
46
+
47
+ Returns:
48
+ A list of adapter instances. In "auto" mode, all registered adapters
49
+ are returned so the caller can try each one and keep those with data.
50
+
51
+ Raises:
52
+ ValueError: If the runtime ID is not recognised.
53
+ """
54
+ runtime = runtime.strip().lower()
55
+ if runtime == "auto":
56
+ return [cls() for cls in _ALL_ADAPTERS]
57
+ if runtime not in _REGISTRY:
58
+ known = ", ".join(sorted(_REGISTRY.keys()))
59
+ raise ValueError(
60
+ f"Unknown runtime '{runtime}'. Known runtimes: {known}. "
61
+ f"Use 'auto' to try all of them."
62
+ )
63
+ return [_REGISTRY[runtime]()]
64
+
65
+
66
+ def list_runtime_ids() -> list[str]:
67
+ """Return all registered runtime IDs (excluding 'auto')."""
68
+ return sorted(_REGISTRY.keys())