@pasko70/pibo 1.9.13 → 1.10.1

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 (163) hide show
  1. package/README.md +183 -183
  2. package/context/codex-base-prompt.md +148 -148
  3. package/context/compute-worker.md +23 -23
  4. package/context/pibo-compaction-prompt.md +100 -100
  5. package/context/pibo-native-tooling.md +18 -18
  6. package/context/pibo-system-prompt.md +77 -77
  7. package/dist/apps/chat/agent-profiles.js +2 -2
  8. package/dist/apps/chat/agent-store.js +263 -250
  9. package/dist/apps/chat/chat-request-normalizers.js +17 -0
  10. package/dist/apps/chat/data/chat-data-mappers.js +2 -0
  11. package/dist/apps/chat/data/project-service.js +168 -168
  12. package/dist/apps/chat/data/read-state-service.js +18 -18
  13. package/dist/apps/chat/data/session-query-service.js +25 -25
  14. package/dist/apps/chat/data/timeline-query-service.js +19 -19
  15. package/dist/apps/chat/loop-api.js +181 -0
  16. package/dist/apps/chat/static-assets.js +854 -854
  17. package/dist/apps/chat/web-app.js +36 -16
  18. package/dist/apps/chat/workflow-manual-trigger-runtime.js +149 -46
  19. package/dist/apps/chat/workflow-persistence.js +255 -255
  20. package/dist/apps/chat-ui/assets/{dist-BZ2eTC4f.js → dist-3Sts0Afa.js} +1 -1
  21. package/dist/apps/chat-ui/assets/{dist-BHa-kcGl.js → dist-4NlLjLs7.js} +1 -1
  22. package/dist/apps/chat-ui/assets/{dist-yCYNNb5d.js → dist-BA6mhAec.js} +1 -1
  23. package/dist/apps/chat-ui/assets/{dist-D811wJeV.js → dist-BPl-fzRB.js} +1 -1
  24. package/dist/apps/chat-ui/assets/{dist-Dq4GxJi3.js → dist-BPnn9e2B.js} +1 -1
  25. package/dist/apps/chat-ui/assets/{dist-CzE6k3F3.js → dist-BpfBSMzK.js} +1 -1
  26. package/dist/apps/chat-ui/assets/{dist-CnVsqwSG.js → dist-Cvx5M97R.js} +1 -1
  27. package/dist/apps/chat-ui/assets/{dist-WyXdYl-w.js → dist-DnXWNQRm.js} +1 -1
  28. package/dist/apps/chat-ui/assets/{dist-BNMu92bb.js → dist-hU-2oAb6.js} +1 -1
  29. package/dist/apps/chat-ui/assets/{dist-BCB6zezO.js → dist-r31x5Fcc.js} +1 -1
  30. package/dist/apps/chat-ui/assets/{dist-HqTN67dc.js → dist-uF6UXzI7.js} +1 -1
  31. package/dist/apps/chat-ui/assets/index-CaTGYBOS.js +173 -0
  32. package/dist/apps/chat-ui/assets/{index-C0x9nEcf.css → index-al8DeEjA.css} +1 -1
  33. package/dist/apps/chat-ui/index.html +18 -18
  34. package/dist/apps/chat-ui/manifest.webmanifest +25 -25
  35. package/dist/apps/chat-ui/sw.js +47 -47
  36. package/dist/apps/chat-vscode-web/assets/index-CK4SMZuu.js +41 -0
  37. package/dist/apps/chat-vscode-web/index.html +13 -13
  38. package/dist/apps/cli-ui/cliSessionsCommand.js +23 -23
  39. package/dist/apps/context-files-ui/index.html +11 -11
  40. package/dist/cli-session/localSessionSource.js +34 -12
  41. package/dist/cli.js +61 -44
  42. package/dist/compute/cli.js +54 -54
  43. package/dist/compute/resource-health.js +35 -2
  44. package/dist/core/events.js +6 -1
  45. package/dist/core/routed-session.js +97 -5
  46. package/dist/core/runtime.js +6 -1
  47. package/dist/core/session-router.js +27 -8
  48. package/dist/cron/cli.js +15 -15
  49. package/dist/cron/store.js +49 -49
  50. package/dist/data/cli.js +23 -23
  51. package/dist/data/event-log.js +23 -23
  52. package/dist/data/ingest-service.js +3 -1
  53. package/dist/data/message-store.js +27 -27
  54. package/dist/data/navigation-store.js +9 -9
  55. package/dist/data/observation-store.js +4 -4
  56. package/dist/data/payload-store.js +17 -17
  57. package/dist/data/schema.js +433 -433
  58. package/dist/data/session-store.js +4 -4
  59. package/dist/data/telemetry-queries.js +54 -54
  60. package/dist/data/telemetry.js +197 -197
  61. package/dist/debug/events.js +12 -12
  62. package/dist/debug/failures.js +6 -6
  63. package/dist/debug/index.js +231 -227
  64. package/dist/debug/messages.js +6 -6
  65. package/dist/debug/pty.js +124 -124
  66. package/dist/debug/session.js +29 -29
  67. package/dist/debug/tools.js +5 -5
  68. package/dist/debug/trace.js +42 -7
  69. package/dist/debug/web-snapshot-browser-scripts.js +294 -294
  70. package/dist/debug/web-streaming-browser-library.js +925 -925
  71. package/dist/debug/web-streaming-browser-scripts.js +232 -232
  72. package/dist/debug/web-streaming-provider-telemetry.js +4 -4
  73. package/dist/debug/web.js +93 -93
  74. package/dist/gateway/cli.js +19 -19
  75. package/dist/gateway/server.js +4 -2
  76. package/dist/gateway/web.js +2 -2
  77. package/dist/index.js +1 -0
  78. package/dist/loops/accounting.js +19 -0
  79. package/dist/loops/channel.js +8 -0
  80. package/dist/loops/cli.js +224 -0
  81. package/dist/loops/plugin.js +16 -0
  82. package/dist/loops/prompts.js +86 -0
  83. package/dist/loops/service.js +446 -0
  84. package/dist/loops/stopping.js +170 -0
  85. package/dist/loops/store.js +642 -0
  86. package/dist/loops/templates.js +232 -0
  87. package/dist/loops/tools.js +184 -0
  88. package/dist/loops/types.js +1 -0
  89. package/dist/mcp/config-command.js +53 -53
  90. package/dist/mcp/index.js +21 -21
  91. package/dist/mcp/registry.js +11 -11
  92. package/dist/pi-packages/cli.js +11 -11
  93. package/dist/plugins/builtin.js +8 -0
  94. package/dist/plugins/context-files-store.js +110 -110
  95. package/dist/plugins/context-files.js +4 -4
  96. package/dist/plugins/registry.js +23 -12
  97. package/dist/ralph/cli.js +18 -18
  98. package/dist/ralph/templates.js +140 -140
  99. package/dist/reliability/store.js +256 -229
  100. package/dist/runs/lifecycle.js +59 -0
  101. package/dist/runs/registry.js +47 -1
  102. package/dist/runs/tools.js +31 -13
  103. package/dist/session-ui/terminalRows.js +66 -2
  104. package/dist/sessions/pibo-data-store.js +16 -16
  105. package/dist/sessions/sqlite-store.js +53 -53
  106. package/dist/setup/cli.js +58 -58
  107. package/dist/shared/trace-async-agent-runs.js +4 -4
  108. package/dist/shared/trace-event-projection.js +59 -19
  109. package/dist/shared/trace-nodes.js +5 -0
  110. package/dist/shared/trace-page-merge.js +15 -0
  111. package/dist/shared/trace-run-notifications.js +3 -1
  112. package/dist/signals/projector.js +6 -2
  113. package/dist/tools/agent-browser-wrapper.js +80 -80
  114. package/dist/tools/browser-pool.js +50 -0
  115. package/dist/tools/browser-use-cdp.js +12 -12
  116. package/dist/tools/browser-use-leases.js +12 -8
  117. package/dist/tools/browser-use-wrapper.js +762 -762
  118. package/dist/tools/guides.js +596 -538
  119. package/dist/tools/index.js +123 -99
  120. package/dist/tools/registry.js +21 -3
  121. package/dist/tools/runtime/node-worker-source.js +205 -205
  122. package/dist/tools/runtime/python-worker-source.js +177 -177
  123. package/dist/vscode/cli.js +9 -9
  124. package/dist/web-annotations/cdp.js +900 -900
  125. package/dist/web-annotations/store.js +96 -96
  126. package/docs/README.md +23 -23
  127. package/docs/ops/install-developer-host.md +112 -112
  128. package/docs/ops/install-user-host.md +96 -96
  129. package/docs/ops/upgrade-user-to-developer-host.md +69 -69
  130. package/docs/ops/vscode-extension-release.md +160 -160
  131. package/package.json +99 -95
  132. package/skills/builtin/graphify/SKILL.md +52 -52
  133. package/skills/builtin/loop/SKILL.md +78 -0
  134. package/skills/builtin/pi-agent-harness/SKILL.md +319 -319
  135. package/skills/builtin/pi-agent-harness/agents/openai.yaml +4 -4
  136. package/skills/builtin/pibo-docker-system/SKILL.md +170 -170
  137. package/skills/builtin/pibo-spec-writing/SKILL.md +330 -330
  138. package/skills/builtin/prd/SKILL.md +143 -143
  139. package/skills/builtin/ralph-loop/SKILL.md +361 -359
  140. package/skills/builtin/ralph-prd-json/SKILL.md +123 -123
  141. package/skills/builtin/skill-creator/LICENSE.txt +201 -201
  142. package/skills/builtin/skill-creator/SKILL.md +513 -513
  143. package/skills/builtin/skill-creator/agents/analyzer.md +274 -274
  144. package/skills/builtin/skill-creator/agents/comparator.md +202 -202
  145. package/skills/builtin/skill-creator/agents/grader.md +223 -223
  146. package/skills/builtin/skill-creator/assets/eval_review.html +146 -146
  147. package/skills/builtin/skill-creator/eval-viewer/generate_review.py +471 -471
  148. package/skills/builtin/skill-creator/eval-viewer/viewer.html +1325 -1325
  149. package/skills/builtin/skill-creator/references/schemas.md +430 -430
  150. package/skills/builtin/skill-creator/scripts/aggregate_benchmark.py +401 -401
  151. package/skills/builtin/skill-creator/scripts/generate_report.py +326 -326
  152. package/skills/builtin/skill-creator/scripts/improve_description.py +247 -247
  153. package/skills/builtin/skill-creator/scripts/package_skill.py +136 -136
  154. package/skills/builtin/skill-creator/scripts/quick_validate.py +102 -102
  155. package/skills/builtin/skill-creator/scripts/run_eval.py +310 -310
  156. package/skills/builtin/skill-creator/scripts/run_loop.py +328 -328
  157. package/skills/builtin/skill-creator/scripts/utils.py +47 -47
  158. package/skills/builtin/web-annotations/SKILL.md +93 -93
  159. package/src/mcp/LICENSE.mcp-cli +21 -21
  160. package/dist/apps/chat-ui/assets/index-DwHJfmiF.js +0 -173
  161. package/dist/apps/chat-vscode-web/assets/index-BAMxIaI_.js +0 -41
  162. package/dist/apps/vscode-artifacts/latest.vsix +0 -0
  163. package/dist/apps/vscode-artifacts/pibo-vscode-ext-1.9.13.vsix +0 -0
@@ -1,310 +1,310 @@
1
- #!/usr/bin/env python3
2
- """Run trigger evaluation for a skill description.
3
-
4
- Tests whether a skill's description causes Claude to trigger (read the skill)
5
- for a set of queries. Outputs results as JSON.
6
- """
7
-
8
- import argparse
9
- import json
10
- import os
11
- import select
12
- import subprocess
13
- import sys
14
- import time
15
- import uuid
16
- from concurrent.futures import ProcessPoolExecutor, as_completed
17
- from pathlib import Path
18
-
19
- from scripts.utils import parse_skill_md
20
-
21
-
22
- def find_project_root() -> Path:
23
- """Find the project root by walking up from cwd looking for .claude/.
24
-
25
- Mimics how Claude Code discovers its project root, so the command file
26
- we create ends up where claude -p will look for it.
27
- """
28
- current = Path.cwd()
29
- for parent in [current, *current.parents]:
30
- if (parent / ".claude").is_dir():
31
- return parent
32
- return current
33
-
34
-
35
- def run_single_query(
36
- query: str,
37
- skill_name: str,
38
- skill_description: str,
39
- timeout: int,
40
- project_root: str,
41
- model: str | None = None,
42
- ) -> bool:
43
- """Run a single query and return whether the skill was triggered.
44
-
45
- Creates a command file in .claude/commands/ so it appears in Claude's
46
- available_skills list, then runs `claude -p` with the raw query.
47
- Uses --include-partial-messages to detect triggering early from
48
- stream events (content_block_start) rather than waiting for the
49
- full assistant message, which only arrives after tool execution.
50
- """
51
- unique_id = uuid.uuid4().hex[:8]
52
- clean_name = f"{skill_name}-skill-{unique_id}"
53
- project_commands_dir = Path(project_root) / ".claude" / "commands"
54
- command_file = project_commands_dir / f"{clean_name}.md"
55
-
56
- try:
57
- project_commands_dir.mkdir(parents=True, exist_ok=True)
58
- # Use YAML block scalar to avoid breaking on quotes in description
59
- indented_desc = "\n ".join(skill_description.split("\n"))
60
- command_content = (
61
- f"---\n"
62
- f"description: |\n"
63
- f" {indented_desc}\n"
64
- f"---\n\n"
65
- f"# {skill_name}\n\n"
66
- f"This skill handles: {skill_description}\n"
67
- )
68
- command_file.write_text(command_content)
69
-
70
- cmd = [
71
- "claude",
72
- "-p", query,
73
- "--output-format", "stream-json",
74
- "--verbose",
75
- "--include-partial-messages",
76
- ]
77
- if model:
78
- cmd.extend(["--model", model])
79
-
80
- # Remove CLAUDECODE env var to allow nesting claude -p inside a
81
- # Claude Code session. The guard is for interactive terminal conflicts;
82
- # programmatic subprocess usage is safe.
83
- env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
84
-
85
- process = subprocess.Popen(
86
- cmd,
87
- stdout=subprocess.PIPE,
88
- stderr=subprocess.DEVNULL,
89
- cwd=project_root,
90
- env=env,
91
- )
92
-
93
- triggered = False
94
- start_time = time.time()
95
- buffer = ""
96
- # Track state for stream event detection
97
- pending_tool_name = None
98
- accumulated_json = ""
99
-
100
- try:
101
- while time.time() - start_time < timeout:
102
- if process.poll() is not None:
103
- remaining = process.stdout.read()
104
- if remaining:
105
- buffer += remaining.decode("utf-8", errors="replace")
106
- break
107
-
108
- ready, _, _ = select.select([process.stdout], [], [], 1.0)
109
- if not ready:
110
- continue
111
-
112
- chunk = os.read(process.stdout.fileno(), 8192)
113
- if not chunk:
114
- break
115
- buffer += chunk.decode("utf-8", errors="replace")
116
-
117
- while "\n" in buffer:
118
- line, buffer = buffer.split("\n", 1)
119
- line = line.strip()
120
- if not line:
121
- continue
122
-
123
- try:
124
- event = json.loads(line)
125
- except json.JSONDecodeError:
126
- continue
127
-
128
- # Early detection via stream events
129
- if event.get("type") == "stream_event":
130
- se = event.get("event", {})
131
- se_type = se.get("type", "")
132
-
133
- if se_type == "content_block_start":
134
- cb = se.get("content_block", {})
135
- if cb.get("type") == "tool_use":
136
- tool_name = cb.get("name", "")
137
- if tool_name in ("Skill", "Read"):
138
- pending_tool_name = tool_name
139
- accumulated_json = ""
140
- else:
141
- return False
142
-
143
- elif se_type == "content_block_delta" and pending_tool_name:
144
- delta = se.get("delta", {})
145
- if delta.get("type") == "input_json_delta":
146
- accumulated_json += delta.get("partial_json", "")
147
- if clean_name in accumulated_json:
148
- return True
149
-
150
- elif se_type in ("content_block_stop", "message_stop"):
151
- if pending_tool_name:
152
- return clean_name in accumulated_json
153
- if se_type == "message_stop":
154
- return False
155
-
156
- # Fallback: full assistant message
157
- elif event.get("type") == "assistant":
158
- message = event.get("message", {})
159
- for content_item in message.get("content", []):
160
- if content_item.get("type") != "tool_use":
161
- continue
162
- tool_name = content_item.get("name", "")
163
- tool_input = content_item.get("input", {})
164
- if tool_name == "Skill" and clean_name in tool_input.get("skill", ""):
165
- triggered = True
166
- elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""):
167
- triggered = True
168
- return triggered
169
-
170
- elif event.get("type") == "result":
171
- return triggered
172
- finally:
173
- # Clean up process on any exit path (return, exception, timeout)
174
- if process.poll() is None:
175
- process.kill()
176
- process.wait()
177
-
178
- return triggered
179
- finally:
180
- if command_file.exists():
181
- command_file.unlink()
182
-
183
-
184
- def run_eval(
185
- eval_set: list[dict],
186
- skill_name: str,
187
- description: str,
188
- num_workers: int,
189
- timeout: int,
190
- project_root: Path,
191
- runs_per_query: int = 1,
192
- trigger_threshold: float = 0.5,
193
- model: str | None = None,
194
- ) -> dict:
195
- """Run the full eval set and return results."""
196
- results = []
197
-
198
- with ProcessPoolExecutor(max_workers=num_workers) as executor:
199
- future_to_info = {}
200
- for item in eval_set:
201
- for run_idx in range(runs_per_query):
202
- future = executor.submit(
203
- run_single_query,
204
- item["query"],
205
- skill_name,
206
- description,
207
- timeout,
208
- str(project_root),
209
- model,
210
- )
211
- future_to_info[future] = (item, run_idx)
212
-
213
- query_triggers: dict[str, list[bool]] = {}
214
- query_items: dict[str, dict] = {}
215
- for future in as_completed(future_to_info):
216
- item, _ = future_to_info[future]
217
- query = item["query"]
218
- query_items[query] = item
219
- if query not in query_triggers:
220
- query_triggers[query] = []
221
- try:
222
- query_triggers[query].append(future.result())
223
- except Exception as e:
224
- print(f"Warning: query failed: {e}", file=sys.stderr)
225
- query_triggers[query].append(False)
226
-
227
- for query, triggers in query_triggers.items():
228
- item = query_items[query]
229
- trigger_rate = sum(triggers) / len(triggers)
230
- should_trigger = item["should_trigger"]
231
- if should_trigger:
232
- did_pass = trigger_rate >= trigger_threshold
233
- else:
234
- did_pass = trigger_rate < trigger_threshold
235
- results.append({
236
- "query": query,
237
- "should_trigger": should_trigger,
238
- "trigger_rate": trigger_rate,
239
- "triggers": sum(triggers),
240
- "runs": len(triggers),
241
- "pass": did_pass,
242
- })
243
-
244
- passed = sum(1 for r in results if r["pass"])
245
- total = len(results)
246
-
247
- return {
248
- "skill_name": skill_name,
249
- "description": description,
250
- "results": results,
251
- "summary": {
252
- "total": total,
253
- "passed": passed,
254
- "failed": total - passed,
255
- },
256
- }
257
-
258
-
259
- def main():
260
- parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description")
261
- parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
262
- parser.add_argument("--skill-path", required=True, help="Path to skill directory")
263
- parser.add_argument("--description", default=None, help="Override description to test")
264
- parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
265
- parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
266
- parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
267
- parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
268
- parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)")
269
- parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
270
- args = parser.parse_args()
271
-
272
- eval_set = json.loads(Path(args.eval_set).read_text())
273
- skill_path = Path(args.skill_path)
274
-
275
- if not (skill_path / "SKILL.md").exists():
276
- print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
277
- sys.exit(1)
278
-
279
- name, original_description, content = parse_skill_md(skill_path)
280
- description = args.description or original_description
281
- project_root = find_project_root()
282
-
283
- if args.verbose:
284
- print(f"Evaluating: {description}", file=sys.stderr)
285
-
286
- output = run_eval(
287
- eval_set=eval_set,
288
- skill_name=name,
289
- description=description,
290
- num_workers=args.num_workers,
291
- timeout=args.timeout,
292
- project_root=project_root,
293
- runs_per_query=args.runs_per_query,
294
- trigger_threshold=args.trigger_threshold,
295
- model=args.model,
296
- )
297
-
298
- if args.verbose:
299
- summary = output["summary"]
300
- print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr)
301
- for r in output["results"]:
302
- status = "PASS" if r["pass"] else "FAIL"
303
- rate_str = f"{r['triggers']}/{r['runs']}"
304
- print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr)
305
-
306
- print(json.dumps(output, indent=2))
307
-
308
-
309
- if __name__ == "__main__":
310
- main()
1
+ #!/usr/bin/env python3
2
+ """Run trigger evaluation for a skill description.
3
+
4
+ Tests whether a skill's description causes Claude to trigger (read the skill)
5
+ for a set of queries. Outputs results as JSON.
6
+ """
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ import select
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ import uuid
16
+ from concurrent.futures import ProcessPoolExecutor, as_completed
17
+ from pathlib import Path
18
+
19
+ from scripts.utils import parse_skill_md
20
+
21
+
22
+ def find_project_root() -> Path:
23
+ """Find the project root by walking up from cwd looking for .claude/.
24
+
25
+ Mimics how Claude Code discovers its project root, so the command file
26
+ we create ends up where claude -p will look for it.
27
+ """
28
+ current = Path.cwd()
29
+ for parent in [current, *current.parents]:
30
+ if (parent / ".claude").is_dir():
31
+ return parent
32
+ return current
33
+
34
+
35
+ def run_single_query(
36
+ query: str,
37
+ skill_name: str,
38
+ skill_description: str,
39
+ timeout: int,
40
+ project_root: str,
41
+ model: str | None = None,
42
+ ) -> bool:
43
+ """Run a single query and return whether the skill was triggered.
44
+
45
+ Creates a command file in .claude/commands/ so it appears in Claude's
46
+ available_skills list, then runs `claude -p` with the raw query.
47
+ Uses --include-partial-messages to detect triggering early from
48
+ stream events (content_block_start) rather than waiting for the
49
+ full assistant message, which only arrives after tool execution.
50
+ """
51
+ unique_id = uuid.uuid4().hex[:8]
52
+ clean_name = f"{skill_name}-skill-{unique_id}"
53
+ project_commands_dir = Path(project_root) / ".claude" / "commands"
54
+ command_file = project_commands_dir / f"{clean_name}.md"
55
+
56
+ try:
57
+ project_commands_dir.mkdir(parents=True, exist_ok=True)
58
+ # Use YAML block scalar to avoid breaking on quotes in description
59
+ indented_desc = "\n ".join(skill_description.split("\n"))
60
+ command_content = (
61
+ f"---\n"
62
+ f"description: |\n"
63
+ f" {indented_desc}\n"
64
+ f"---\n\n"
65
+ f"# {skill_name}\n\n"
66
+ f"This skill handles: {skill_description}\n"
67
+ )
68
+ command_file.write_text(command_content)
69
+
70
+ cmd = [
71
+ "claude",
72
+ "-p", query,
73
+ "--output-format", "stream-json",
74
+ "--verbose",
75
+ "--include-partial-messages",
76
+ ]
77
+ if model:
78
+ cmd.extend(["--model", model])
79
+
80
+ # Remove CLAUDECODE env var to allow nesting claude -p inside a
81
+ # Claude Code session. The guard is for interactive terminal conflicts;
82
+ # programmatic subprocess usage is safe.
83
+ env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"}
84
+
85
+ process = subprocess.Popen(
86
+ cmd,
87
+ stdout=subprocess.PIPE,
88
+ stderr=subprocess.DEVNULL,
89
+ cwd=project_root,
90
+ env=env,
91
+ )
92
+
93
+ triggered = False
94
+ start_time = time.time()
95
+ buffer = ""
96
+ # Track state for stream event detection
97
+ pending_tool_name = None
98
+ accumulated_json = ""
99
+
100
+ try:
101
+ while time.time() - start_time < timeout:
102
+ if process.poll() is not None:
103
+ remaining = process.stdout.read()
104
+ if remaining:
105
+ buffer += remaining.decode("utf-8", errors="replace")
106
+ break
107
+
108
+ ready, _, _ = select.select([process.stdout], [], [], 1.0)
109
+ if not ready:
110
+ continue
111
+
112
+ chunk = os.read(process.stdout.fileno(), 8192)
113
+ if not chunk:
114
+ break
115
+ buffer += chunk.decode("utf-8", errors="replace")
116
+
117
+ while "\n" in buffer:
118
+ line, buffer = buffer.split("\n", 1)
119
+ line = line.strip()
120
+ if not line:
121
+ continue
122
+
123
+ try:
124
+ event = json.loads(line)
125
+ except json.JSONDecodeError:
126
+ continue
127
+
128
+ # Early detection via stream events
129
+ if event.get("type") == "stream_event":
130
+ se = event.get("event", {})
131
+ se_type = se.get("type", "")
132
+
133
+ if se_type == "content_block_start":
134
+ cb = se.get("content_block", {})
135
+ if cb.get("type") == "tool_use":
136
+ tool_name = cb.get("name", "")
137
+ if tool_name in ("Skill", "Read"):
138
+ pending_tool_name = tool_name
139
+ accumulated_json = ""
140
+ else:
141
+ return False
142
+
143
+ elif se_type == "content_block_delta" and pending_tool_name:
144
+ delta = se.get("delta", {})
145
+ if delta.get("type") == "input_json_delta":
146
+ accumulated_json += delta.get("partial_json", "")
147
+ if clean_name in accumulated_json:
148
+ return True
149
+
150
+ elif se_type in ("content_block_stop", "message_stop"):
151
+ if pending_tool_name:
152
+ return clean_name in accumulated_json
153
+ if se_type == "message_stop":
154
+ return False
155
+
156
+ # Fallback: full assistant message
157
+ elif event.get("type") == "assistant":
158
+ message = event.get("message", {})
159
+ for content_item in message.get("content", []):
160
+ if content_item.get("type") != "tool_use":
161
+ continue
162
+ tool_name = content_item.get("name", "")
163
+ tool_input = content_item.get("input", {})
164
+ if tool_name == "Skill" and clean_name in tool_input.get("skill", ""):
165
+ triggered = True
166
+ elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""):
167
+ triggered = True
168
+ return triggered
169
+
170
+ elif event.get("type") == "result":
171
+ return triggered
172
+ finally:
173
+ # Clean up process on any exit path (return, exception, timeout)
174
+ if process.poll() is None:
175
+ process.kill()
176
+ process.wait()
177
+
178
+ return triggered
179
+ finally:
180
+ if command_file.exists():
181
+ command_file.unlink()
182
+
183
+
184
+ def run_eval(
185
+ eval_set: list[dict],
186
+ skill_name: str,
187
+ description: str,
188
+ num_workers: int,
189
+ timeout: int,
190
+ project_root: Path,
191
+ runs_per_query: int = 1,
192
+ trigger_threshold: float = 0.5,
193
+ model: str | None = None,
194
+ ) -> dict:
195
+ """Run the full eval set and return results."""
196
+ results = []
197
+
198
+ with ProcessPoolExecutor(max_workers=num_workers) as executor:
199
+ future_to_info = {}
200
+ for item in eval_set:
201
+ for run_idx in range(runs_per_query):
202
+ future = executor.submit(
203
+ run_single_query,
204
+ item["query"],
205
+ skill_name,
206
+ description,
207
+ timeout,
208
+ str(project_root),
209
+ model,
210
+ )
211
+ future_to_info[future] = (item, run_idx)
212
+
213
+ query_triggers: dict[str, list[bool]] = {}
214
+ query_items: dict[str, dict] = {}
215
+ for future in as_completed(future_to_info):
216
+ item, _ = future_to_info[future]
217
+ query = item["query"]
218
+ query_items[query] = item
219
+ if query not in query_triggers:
220
+ query_triggers[query] = []
221
+ try:
222
+ query_triggers[query].append(future.result())
223
+ except Exception as e:
224
+ print(f"Warning: query failed: {e}", file=sys.stderr)
225
+ query_triggers[query].append(False)
226
+
227
+ for query, triggers in query_triggers.items():
228
+ item = query_items[query]
229
+ trigger_rate = sum(triggers) / len(triggers)
230
+ should_trigger = item["should_trigger"]
231
+ if should_trigger:
232
+ did_pass = trigger_rate >= trigger_threshold
233
+ else:
234
+ did_pass = trigger_rate < trigger_threshold
235
+ results.append({
236
+ "query": query,
237
+ "should_trigger": should_trigger,
238
+ "trigger_rate": trigger_rate,
239
+ "triggers": sum(triggers),
240
+ "runs": len(triggers),
241
+ "pass": did_pass,
242
+ })
243
+
244
+ passed = sum(1 for r in results if r["pass"])
245
+ total = len(results)
246
+
247
+ return {
248
+ "skill_name": skill_name,
249
+ "description": description,
250
+ "results": results,
251
+ "summary": {
252
+ "total": total,
253
+ "passed": passed,
254
+ "failed": total - passed,
255
+ },
256
+ }
257
+
258
+
259
+ def main():
260
+ parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description")
261
+ parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file")
262
+ parser.add_argument("--skill-path", required=True, help="Path to skill directory")
263
+ parser.add_argument("--description", default=None, help="Override description to test")
264
+ parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers")
265
+ parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds")
266
+ parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query")
267
+ parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold")
268
+ parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)")
269
+ parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
270
+ args = parser.parse_args()
271
+
272
+ eval_set = json.loads(Path(args.eval_set).read_text())
273
+ skill_path = Path(args.skill_path)
274
+
275
+ if not (skill_path / "SKILL.md").exists():
276
+ print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
277
+ sys.exit(1)
278
+
279
+ name, original_description, content = parse_skill_md(skill_path)
280
+ description = args.description or original_description
281
+ project_root = find_project_root()
282
+
283
+ if args.verbose:
284
+ print(f"Evaluating: {description}", file=sys.stderr)
285
+
286
+ output = run_eval(
287
+ eval_set=eval_set,
288
+ skill_name=name,
289
+ description=description,
290
+ num_workers=args.num_workers,
291
+ timeout=args.timeout,
292
+ project_root=project_root,
293
+ runs_per_query=args.runs_per_query,
294
+ trigger_threshold=args.trigger_threshold,
295
+ model=args.model,
296
+ )
297
+
298
+ if args.verbose:
299
+ summary = output["summary"]
300
+ print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr)
301
+ for r in output["results"]:
302
+ status = "PASS" if r["pass"] else "FAIL"
303
+ rate_str = f"{r['triggers']}/{r['runs']}"
304
+ print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr)
305
+
306
+ print(json.dumps(output, indent=2))
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()