beeops 0.1.2 → 0.1.5

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.
@@ -1,93 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Resolve the log directory path for the current project.
3
-
4
- Priority: BO_LOG_DIR env var > git root detection > cwd fallback.
5
- Default log location: <project>/.claude/beeops/logs/
6
-
7
- Usage:
8
- python3 resolve-log-path.py # Print LOG_BASE path
9
- python3 resolve-log-path.py --json # JSON output: project, log_base, log_file
10
- """
11
-
12
- import json
13
- import re
14
- import subprocess
15
- import sys
16
- from pathlib import Path
17
-
18
-
19
- def resolve_project_name() -> str:
20
- """Resolve project name from GitHub remote > git root > cwd."""
21
- # GitHub remote: owner-repo
22
- try:
23
- remote = subprocess.run(
24
- ["git", "remote", "get-url", "origin"],
25
- capture_output=True, text=True, check=True
26
- ).stdout.strip()
27
- m = re.search(r'[:/]([^/]+/[^/]+?)(?:\.git)?$', remote)
28
- if m:
29
- return m.group(1).replace("/", "-")
30
- except (subprocess.CalledProcessError, FileNotFoundError):
31
- pass
32
-
33
- # Git repository root directory name
34
- try:
35
- result = subprocess.run(
36
- ["git", "rev-parse", "--show-toplevel"],
37
- capture_output=True, text=True, check=True
38
- )
39
- return Path(result.stdout.strip()).name
40
- except (subprocess.CalledProcessError, FileNotFoundError):
41
- pass
42
-
43
- # Fallback to current directory name
44
- return Path.cwd().name
45
-
46
-
47
- def resolve_git_root() -> Path | None:
48
- """Resolve git repository root."""
49
- try:
50
- result = subprocess.run(
51
- ["git", "rev-parse", "--show-toplevel"],
52
- capture_output=True, text=True, check=True
53
- )
54
- return Path(result.stdout.strip())
55
- except (subprocess.CalledProcessError, FileNotFoundError):
56
- return None
57
-
58
-
59
- def resolve_log_base() -> Path:
60
- """Resolve log base directory.
61
-
62
- Priority:
63
- 1. BO_LOG_DIR environment variable (absolute path)
64
- 2. <git-root>/.claude/beeops/logs/
65
- 3. <cwd>/.claude/beeops/logs/
66
- """
67
- import os
68
- env_dir = os.environ.get("BO_LOG_DIR")
69
- if env_dir:
70
- return Path(env_dir)
71
-
72
- git_root = resolve_git_root()
73
- if git_root:
74
- return git_root / ".claude" / "beeops" / "logs"
75
-
76
- return Path.cwd() / ".claude" / "beeops" / "logs"
77
-
78
-
79
- def main():
80
- log_base = resolve_log_base()
81
-
82
- if "--json" in sys.argv:
83
- print(json.dumps({
84
- "project": resolve_project_name(),
85
- "log_base": str(log_base),
86
- "log_file": str(log_base / "log.jsonl"),
87
- }))
88
- else:
89
- print(log_base)
90
-
91
-
92
- if __name__ == "__main__":
93
- main()
package/hooks/run-log.py DELETED
@@ -1,429 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Stop hook: Launch a background log-recording agent on session exit.
3
-
4
- 100% chance: run bo-log-writer (work log recording).
5
- ~10% chance: additionally run bo-self-improver (self-improvement analysis).
6
-
7
- Reads hook input (transcript_path etc.) from stdin, extracts session context,
8
- and embeds it into the prompt so the agent can record accurately without -c.
9
- """
10
-
11
- import json
12
- import os
13
- import random
14
- import re
15
- import subprocess
16
- import sys
17
- import tempfile
18
- from dataclasses import dataclass, field
19
- from pathlib import Path
20
- from typing import Optional
21
-
22
-
23
- @dataclass
24
- class Turn:
25
- user_prompt: str
26
- file_changes: dict[str, list[str]] = field(default_factory=dict)
27
- bash_commands: list[str] = field(default_factory=list)
28
- errors: list[str] = field(default_factory=list)
29
- skills_used: list[str] = field(default_factory=list)
30
- assistant_texts: list[str] = field(default_factory=list)
31
-
32
-
33
- # --- Read hook input from stdin ---
34
- try:
35
- hook_input = json.load(sys.stdin)
36
- except (json.JSONDecodeError, ValueError):
37
- hook_input = {}
38
-
39
- transcript_path = hook_input.get("transcript_path", "")
40
- stop_hook_active = hook_input.get("stop_hook_active", False)
41
-
42
- # Loop prevention: skip if agent-modes.json marks this mode as skip_log
43
- _skip_log = stop_hook_active
44
-
45
- # Resolve agent-modes.json path via BO_CONTEXTS_DIR or package detection
46
- _modes_file = None
47
- _ctx_dir = os.environ.get("BO_CONTEXTS_DIR")
48
- if _ctx_dir:
49
- _modes_file = Path(_ctx_dir) / "agent-modes.json"
50
- else:
51
- # Try to resolve via require.resolve
52
- try:
53
- pkg_dir = subprocess.run(
54
- ["node", "-e", "console.log(require.resolve('beeops/package.json').replace('/package.json',''))"],
55
- capture_output=True, text=True, check=True,
56
- ).stdout.strip()
57
- _modes_file = Path(pkg_dir) / "contexts" / "agent-modes.json"
58
- except Exception:
59
- pass
60
-
61
- if not _skip_log and _modes_file and _modes_file.exists():
62
- try:
63
- _modes = json.loads(_modes_file.read_text()).get("modes", {})
64
- for _env_var, _conf in _modes.items():
65
- if os.environ.get(_env_var) == "1" and _conf.get("skip_log"):
66
- _skip_log = True
67
- break
68
- except Exception:
69
- pass
70
- if _skip_log:
71
- sys.exit(0)
72
-
73
-
74
- def _is_real_user_prompt(text: str) -> Optional[str]:
75
- """Extract meaningful instruction text from a user message. Returns None if not applicable."""
76
- text = text.strip()
77
- if len(text) <= 10:
78
- return None
79
- if (text.startswith("<") or text.startswith('{"session_id"')
80
- or text.startswith("<local-command")):
81
- return None
82
- cleaned = re.sub(r'<[^>]+>[^<]*</[^>]+>', '', text)
83
- cleaned = re.sub(
84
- r'\{"session_id":"[^"]*".*?"stop_hook_active":\s*(?:true|false)\}',
85
- '', cleaned,
86
- ).strip()
87
- if len(cleaned) > 10:
88
- return cleaned[:300]
89
- return None
90
-
91
-
92
- def extract_session_turns(transcript_path: str) -> list[Turn]:
93
- """Segment transcript JSONL into user-instruction turns."""
94
- path = Path(transcript_path)
95
- if not path.exists():
96
- return []
97
-
98
- turns: list[Turn] = []
99
- current: Optional[Turn] = None
100
- tool_id_map: dict[str, str] = {}
101
-
102
- with open(path) as f:
103
- for line in f:
104
- try:
105
- obj = json.loads(line)
106
- except json.JSONDecodeError:
107
- continue
108
-
109
- msg_type = obj.get("type")
110
- if msg_type not in ("user", "assistant"):
111
- continue
112
-
113
- content = obj.get("message", {}).get("content", "")
114
-
115
- if msg_type == "user" and not obj.get("isMeta"):
116
- if isinstance(content, str):
117
- prompt = _is_real_user_prompt(content)
118
- if prompt is not None:
119
- if current is not None:
120
- turns.append(current)
121
- current = Turn(user_prompt=prompt)
122
- continue
123
-
124
- if isinstance(content, list) and current is not None:
125
- for block in content:
126
- if block.get("type") != "tool_result":
127
- continue
128
- tid = block.get("tool_use_id", "")
129
- is_err = block.get("is_error", False)
130
- result_text = block.get("content", "")
131
- if isinstance(result_text, list):
132
- result_text = " ".join(
133
- b.get("text", "") for b in result_text if b.get("type") == "text"
134
- )
135
- if is_err and result_text:
136
- et = result_text.strip()
137
- if not any(skip in et.lower() for skip in [
138
- "tool_use_error", "requested permissions",
139
- "doesn't want to proceed", "requires approval",
140
- "was rejected", "command substitution",
141
- "command contains", "sensitive file",
142
- "exceeds maximum allowed size",
143
- ]):
144
- current.errors.append(et[:200])
145
- elif tool_id_map.get(tid) == "Bash" and result_text:
146
- for err_line in result_text.split("\n"):
147
- el = err_line.strip()
148
- if el and any(kw in el.lower() for kw in
149
- ["error", "traceback", "exception",
150
- "failed", "errno", "not found"]):
151
- current.errors.append(el[:200])
152
- break
153
-
154
- elif msg_type == "assistant" and isinstance(content, list):
155
- if current is None:
156
- current = Turn(user_prompt="(session start)")
157
- for block in content:
158
- btype = block.get("type")
159
- if btype == "text":
160
- text = block.get("text", "").strip()
161
- if len(text) > 30:
162
- current.assistant_texts.append(text)
163
- elif btype == "tool_use":
164
- tool_name = block.get("name", "")
165
- tool_id = block.get("id", "")
166
- inp = block.get("input", {})
167
- tool_id_map[tool_id] = tool_name
168
- if tool_name == "Edit":
169
- fp = inp.get("file_path", "?")
170
- current.file_changes.setdefault(fp, []).append("Edit")
171
- elif tool_name == "Write":
172
- fp = inp.get("file_path", "?")
173
- current.file_changes.setdefault(fp, []).append("Write")
174
- elif tool_name == "Bash":
175
- cmd = inp.get("command", "").strip()
176
- if cmd and len(cmd) < 300:
177
- current.bash_commands.append(cmd)
178
- elif tool_name == "Skill":
179
- skill = inp.get("skill", "")
180
- if skill and skill not in current.skills_used:
181
- current.skills_used.append(skill)
182
-
183
- if current is not None:
184
- turns.append(current)
185
- return turns
186
-
187
-
188
- def merge_trivial_turns(turns: list[Turn]) -> list[Turn]:
189
- """Merge short turns with no file changes or errors into the previous turn."""
190
- if not turns:
191
- return turns
192
-
193
- merged: list[Turn] = [turns[0]]
194
- for turn in turns[1:]:
195
- is_trivial = (
196
- len(turn.user_prompt) <= 20
197
- and not turn.file_changes
198
- and not turn.errors
199
- )
200
- if is_trivial and merged:
201
- prev = merged[-1]
202
- prev.bash_commands.extend(turn.bash_commands)
203
- prev.skills_used.extend(
204
- s for s in turn.skills_used if s not in prev.skills_used
205
- )
206
- prev.assistant_texts.extend(turn.assistant_texts)
207
- else:
208
- merged.append(turn)
209
- return merged
210
-
211
-
212
- def format_turns_summary(turns: list[Turn], max_chars: int = 12000) -> str:
213
- """Convert turn list to structured text with ### Turn N sections."""
214
- if not turns:
215
- return "(no extractable information)"
216
-
217
- budget_per_turn = max_chars // max(len(turns), 1)
218
- sections: list[str] = []
219
-
220
- for i, turn in enumerate(turns, 1):
221
- parts: list[str] = []
222
- parts.append(f"### Turn {i}: {turn.user_prompt}")
223
-
224
- if turn.file_changes:
225
- lines = []
226
- for fp, actions in turn.file_changes.items():
227
- counts: dict[str, int] = {}
228
- for a in actions:
229
- counts[a] = counts.get(a, 0) + 1
230
- action_str = ", ".join(
231
- f"{a} x{c}" if c > 1 else a for a, c in counts.items()
232
- )
233
- lines.append(f"- {fp} ({action_str})")
234
- parts.append("**File changes:**\n" + "\n".join(lines))
235
-
236
- if turn.bash_commands:
237
- seen: set[str] = set()
238
- deduped: list[str] = []
239
- for cmd in turn.bash_commands:
240
- key = cmd[:80]
241
- if key not in seen:
242
- seen.add(key)
243
- deduped.append(cmd[:150])
244
- cmd_lines = [f"- {c}" for c in deduped[:10]]
245
- parts.append("**Commands:**\n" + "\n".join(cmd_lines))
246
-
247
- if turn.errors:
248
- seen_e: set[str] = set()
249
- deduped_e: list[str] = []
250
- for e in turn.errors:
251
- key = e[:60]
252
- if key not in seen_e:
253
- seen_e.add(key)
254
- deduped_e.append(e)
255
- err_lines = [f"- {e}" for e in deduped_e[:5]]
256
- parts.append("**Errors:**\n" + "\n".join(err_lines))
257
-
258
- if turn.skills_used:
259
- parts.append("**Skills:** " + ", ".join(turn.skills_used))
260
-
261
- if turn.assistant_texts:
262
- filtered = [t for t in turn.assistant_texts if not t.startswith("<thinking>")]
263
- if filtered:
264
- tail = filtered[-2:]
265
- tail_lines = [t[:200] for t in tail]
266
- parts.append("**Response (tail):**\n" + "\n---\n".join(tail_lines))
267
-
268
- turn_text = "\n".join(parts)
269
- if len(turn_text) > budget_per_turn:
270
- turn_text = turn_text[:budget_per_turn] + "\n...(truncated)"
271
- sections.append(turn_text)
272
-
273
- result = "\n\n".join(sections)
274
- if len(result) > max_chars:
275
- result = result[:max_chars] + "\n...(truncated)"
276
- return result
277
-
278
-
279
- # --- Resolve log directory via resolve-log-path.py ---
280
- cwd = Path.cwd()
281
-
282
- # Find resolve-log-path.py: BO_CONTEXTS_DIR -> package detection -> fallback
283
- _resolve_script = None
284
- if _ctx_dir:
285
- _candidate = Path(_ctx_dir).parent / "hooks" / "resolve-log-path.py"
286
- if _candidate.exists():
287
- _resolve_script = _candidate
288
- if not _resolve_script:
289
- try:
290
- pkg_dir = subprocess.run(
291
- ["node", "-e", "console.log(require.resolve('beeops/package.json').replace('/package.json',''))"],
292
- capture_output=True, text=True, check=True,
293
- ).stdout.strip()
294
- _candidate = Path(pkg_dir) / "hooks" / "resolve-log-path.py"
295
- if _candidate.exists():
296
- _resolve_script = _candidate
297
- except Exception:
298
- pass
299
-
300
- try:
301
- if _resolve_script:
302
- _log_info = json.loads(subprocess.run(
303
- ["python3", str(_resolve_script), "--json"],
304
- capture_output=True, text=True, check=True,
305
- ).stdout)
306
- LOG_DIR = Path(_log_info["log_base"])
307
- project_name = _log_info["project"]
308
- else:
309
- raise FileNotFoundError("resolve-log-path.py not found")
310
- except Exception:
311
- project_name = cwd.name
312
- LOG_DIR = cwd / ".claude" / "beeops" / "logs"
313
- LOG_DIR.mkdir(parents=True, exist_ok=True)
314
-
315
- # --- log-pending.jsonl one-time cleanup ---
316
- pending_file = LOG_DIR / "log-pending.jsonl"
317
- log_file = LOG_DIR / "log.jsonl"
318
- pending_merged = False
319
- if pending_file.exists():
320
- try:
321
- pending_content = pending_file.read_text().strip()
322
- if pending_content:
323
- with open(log_file, "a") as f:
324
- f.write(pending_content + "\n")
325
- pending_merged = True
326
- pending_file.unlink()
327
- except Exception:
328
- pass
329
-
330
- # --- Mode decision ---
331
- include_fb = random.random() < 0.1
332
-
333
- # --- Session context extraction ---
334
- if transcript_path:
335
- turns = merge_trivial_turns(extract_session_turns(transcript_path))
336
- session_summary = format_turns_summary(turns)
337
- turn_count = len(turns)
338
- else:
339
- session_summary = "(transcript_path not provided)"
340
- turn_count = 0
341
-
342
- # --- Build prompt ---
343
- log_file_path = LOG_DIR / "log.jsonl"
344
-
345
- prompt = f"""\
346
- Invoke bo-log-writer skill via Skill tool to record the previous session's work log.
347
-
348
- ## Log file path (fixed — do not change)
349
-
350
- **{log_file_path}**
351
-
352
- Do NOT run resolve-log-path.py. Append directly to the path above.
353
- Do NOT create temporary files (.tmp-log-entries.jsonl etc.).
354
-
355
- ## Previous session structured summary (extracted by Python — {turn_count} turns)
356
-
357
- The following data was auto-extracted and deduplicated from the transcript.
358
- Use file changes and commands as-is; no need to re-verify with git diff.
359
-
360
- {session_summary}
361
-
362
- ## Rules
363
- - **Append one JSONL entry per Turn to {log_file_path}**
364
- - Skip turns with no file changes and no skill usage
365
- - Get timestamp via `date` command, increment by 1 second between turns
366
- - changes: use the "File changes" data above as-is
367
- - decisions: interpret the user instruction intent and record reasoning (main LLM task)
368
- - errors: if "Errors" are listed, infer cause and resolution
369
- - learnings: record reusable insights if any
370
- - Only record logs — do NOT perform any other work (code changes, design, planning)
371
- - **Append ONLY to {log_file_path}. Never create files with different names/paths**
372
- """
373
-
374
- if include_fb:
375
- prompt += """
376
- ## Additional task: Self-improvement
377
- After log recording is complete, invoke bo-self-improver skill via Skill tool.
378
- """
379
-
380
- mode = "log + fb" if include_fb else "log"
381
- if pending_merged:
382
- prompt += f"\n(Note: merged log-pending.jsonl contents into log.jsonl)\n"
383
-
384
- # --- Generate bash script + run with osascript notification ---
385
- escaped_prompt = prompt.replace("'", "'\\''")
386
-
387
- allowed_tools = "Skill Read Write Edit 'Bash(git:*)' 'Bash(date:*)' 'Bash(python3:*)' Grep Glob"
388
-
389
- # Dynamic max-turns based on turn count (base 6 + 2 per turn, max 25)
390
- max_turns = min(6 + turn_count * 2, 25)
391
-
392
- bash_script = f"""\
393
- #!/bin/bash
394
- osascript -e 'display notification "mode: {mode}" with title "BO Log Agent: started"' 2>/dev/null
395
- claude --model sonnet --no-session-persistence --allowedTools {allowed_tools} --max-turns {max_turns} -p '{escaped_prompt}' > '{LOG_DIR}/temp.log' 2>&1
396
- EXIT_CODE=$?
397
- if [ $EXIT_CODE -eq 0 ]; then
398
- osascript -e 'display notification "mode: {mode}" with title "BO Log Agent: done"' 2>/dev/null
399
- else
400
- osascript -e 'display notification "exit: '$EXIT_CODE'" with title "BO Log Agent: error"' 2>/dev/null
401
- fi
402
- exit $EXIT_CODE
403
- """
404
-
405
- try:
406
- env = os.environ.copy()
407
- env["BO_FB_AGENT"] = "1"
408
- env["BO_LOG_DIR"] = str(LOG_DIR)
409
- if include_fb:
410
- env["BO_FB_INCLUDE_FB"] = "1"
411
-
412
- with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as tmp:
413
- tmp.write(bash_script)
414
- tmp_path = tmp.name
415
-
416
- os.chmod(tmp_path, 0o755)
417
-
418
- subprocess.Popen(
419
- ["bash", tmp_path],
420
- cwd=str(cwd),
421
- env=env,
422
- stdout=subprocess.DEVNULL,
423
- stderr=subprocess.DEVNULL,
424
- start_new_session=True,
425
- )
426
- print(f"bo log agent started in background [{mode}]")
427
- except Exception as e:
428
- print(f"bo log agent failed to start: {e}", file=sys.stderr)
429
- sys.exit(1)
@@ -1,101 +0,0 @@
1
- ---
2
- name: bo-log-writer
3
- description: Record work logs to JSONL. Extract changes, decisions, error resolutions, and learnings from git diff and conversation context.
4
- ---
5
-
6
- # bo-log-writer: Work Log Recording
7
-
8
- Record session work to `.claude/beeops/logs/log.jsonl` in JSONL format.
9
-
10
- ## Procedure
11
-
12
- 1. Get log path and current timestamp:
13
- ```bash
14
- LOG_BASE=$(python3 "$(dirname "$(python3 -c "import beeops; print(beeops.__file__)" 2>/dev/null || echo "$BO_CONTEXTS_DIR/../hooks/resolve-log-path.py")")/resolve-log-path.py" 2>/dev/null || python3 hooks/resolve-log-path.py) && mkdir -p "$LOG_BASE" && date '+%Y-%m-%dT%H:%M:%S'
15
- ```
16
- If the above fails, use the simpler fallback:
17
- ```bash
18
- LOG_BASE=".claude/beeops/logs" && mkdir -p "$LOG_BASE" && date '+%Y-%m-%dT%H:%M:%S'
19
- ```
20
- 2. Check changed files with `git diff --name-only` and `git status`
21
- 3. Extract work content, intent, errors, and learnings from conversation context
22
- 4. Append JSONL entry to `$LOG_BASE/log.jsonl`
23
-
24
- ## Log Format (JSONL)
25
-
26
- File: `$LOG_BASE/log.jsonl`
27
- One line = one JSON object per work unit. All entries appended to a single file.
28
-
29
- ```json
30
- {
31
- "timestamp": "2026-03-02T14:30:00",
32
- "title": "Brief work title",
33
- "category": "implementation | review | design | bugfix | refactor | research | meta",
34
- "changes": [{ "file": "src/domain/foo.ts", "description": "Added validation" }],
35
- "decisions": [
36
- { "what": "What was decided", "why": "Rationale", "alternatives": "Alternatives considered" }
37
- ],
38
- "errors": [
39
- {
40
- "message": "Error message",
41
- "cause": "Root cause",
42
- "solution": "How it was resolved",
43
- "tags": ["prisma", "enum"]
44
- }
45
- ],
46
- "learnings": ["Reusable insights"],
47
- "patterns": ["Recurring patterns observed"],
48
- "remaining": ["Unresolved issues / TODOs"],
49
- "skills_used": ["bo-task-decomposer"],
50
- "agents_used": ["code-reviewer", "planner"],
51
- "commands_used": ["commit", "review"],
52
- "resources_created": [{ "type": "skill", "name": "meta-task-planner", "action": "created" }]
53
- }
54
- ```
55
-
56
- ### Field Description
57
-
58
- | Field | Required | Purpose |
59
- | ------------------- | --------- | ------------------------------------ |
60
- | `timestamp` | Required | Chronological tracking |
61
- | `title` | Required | Summary generation, search |
62
- | `category` | Required | Pattern classification |
63
- | `changes` | Required | Change tracking, design change detection |
64
- | `decisions` | Required | Knowledge capture of reasoning |
65
- | `errors` | When applicable | Error knowledge accumulation |
66
- | `learnings` | When applicable | Generic knowledge extraction |
67
- | `patterns` | When applicable | Recurring operation detection |
68
- | `remaining` | When applicable | Remaining issue tracking |
69
- | `skills_used` | When applicable | Usage frequency analysis |
70
- | `agents_used` | When applicable | Usage frequency analysis |
71
- | `commands_used` | When applicable | Usage frequency analysis |
72
- | `resources_created` | When applicable | Resource change recording |
73
-
74
- ### Omission Rules
75
-
76
- - `errors`, `learnings`, `patterns`, `remaining`, `skills_used`, `agents_used`, `commands_used`, `resources_created` may be omitted when not applicable (exclude the key entirely)
77
- - `decisions` must never be omitted — always record at least one
78
- - `changes` must come from git diff, never guessed
79
-
80
- ## Deduplication Check (Required)
81
-
82
- Before appending, verify no duplicates exist:
83
-
84
- 1. Read the last 50 lines of `log.jsonl` with the Read tool
85
- 2. Check if any planned entry's `title` is similar to existing entries
86
- 3. Skip if the same session content has already been recorded
87
-
88
- **Dedup criteria (skip if any match):**
89
- - Exact title match
90
- - Title keywords match AND same category
91
- - Same changes (2+ matching file paths) already exist
92
- - Same category + same file changes recorded within the last 24 hours
93
-
94
- ## Rules
95
-
96
- - **timestamp MUST be obtained via `date` command. LLM must never fabricate timestamps**
97
- - One log entry = one line (JSONL), appended
98
- - When multiple turn summaries are provided, append one line per turn
99
- - Increment timestamp by 1 second between turns to avoid duplicates
100
- - `decisions` must never be omitted — always record why
101
- - Logs must be fact-based. No embellishment or opinions