beeops 0.1.0

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +156 -0
  3. package/README.md +80 -0
  4. package/bin/beeops.js +502 -0
  5. package/command/bo.md +120 -0
  6. package/contexts/agent-modes.json +100 -0
  7. package/contexts/code-reviewer.md +118 -0
  8. package/contexts/coder.md +247 -0
  9. package/contexts/default.md +1 -0
  10. package/contexts/en/agent-modes.json +100 -0
  11. package/contexts/en/code-reviewer.md +129 -0
  12. package/contexts/en/coder.md +247 -0
  13. package/contexts/en/default.md +1 -0
  14. package/contexts/en/fb.md +15 -0
  15. package/contexts/en/leader.md +158 -0
  16. package/contexts/en/log.md +16 -0
  17. package/contexts/en/queen.md +240 -0
  18. package/contexts/en/review-leader.md +190 -0
  19. package/contexts/en/reviewer-base.md +27 -0
  20. package/contexts/en/security-reviewer.md +200 -0
  21. package/contexts/en/test-auditor.md +146 -0
  22. package/contexts/en/tester.md +135 -0
  23. package/contexts/en/worker-base.md +69 -0
  24. package/contexts/fb.md +15 -0
  25. package/contexts/ja/agent-modes.json +100 -0
  26. package/contexts/ja/code-reviewer.md +129 -0
  27. package/contexts/ja/coder.md +247 -0
  28. package/contexts/ja/default.md +1 -0
  29. package/contexts/ja/fb.md +15 -0
  30. package/contexts/ja/leader.md +158 -0
  31. package/contexts/ja/log.md +17 -0
  32. package/contexts/ja/queen.md +240 -0
  33. package/contexts/ja/review-leader.md +190 -0
  34. package/contexts/ja/reviewer-base.md +27 -0
  35. package/contexts/ja/security-reviewer.md +200 -0
  36. package/contexts/ja/test-auditor.md +146 -0
  37. package/contexts/ja/tester.md +135 -0
  38. package/contexts/ja/worker-base.md +68 -0
  39. package/contexts/leader.md +158 -0
  40. package/contexts/log.md +16 -0
  41. package/contexts/queen.md +240 -0
  42. package/contexts/review-leader.md +190 -0
  43. package/contexts/reviewer-base.md +27 -0
  44. package/contexts/security-reviewer.md +200 -0
  45. package/contexts/test-auditor.md +146 -0
  46. package/contexts/tester.md +135 -0
  47. package/contexts/worker-base.md +69 -0
  48. package/hooks/checkpoint.py +89 -0
  49. package/hooks/prompt-context.py +139 -0
  50. package/hooks/resolve-log-path.py +93 -0
  51. package/hooks/run-log.py +429 -0
  52. package/package.json +42 -0
  53. package/scripts/launch-leader.sh +282 -0
  54. package/scripts/launch-worker.sh +184 -0
  55. package/skills/bo-dispatch/SKILL.md +299 -0
  56. package/skills/bo-issue-sync/SKILL.md +103 -0
  57. package/skills/bo-leader-dispatch/SKILL.md +211 -0
  58. package/skills/bo-log-writer/SKILL.md +101 -0
  59. package/skills/bo-review-backend/SKILL.md +234 -0
  60. package/skills/bo-review-database/SKILL.md +243 -0
  61. package/skills/bo-review-frontend/SKILL.md +236 -0
  62. package/skills/bo-review-operations/SKILL.md +268 -0
  63. package/skills/bo-review-process/SKILL.md +181 -0
  64. package/skills/bo-review-security/SKILL.md +214 -0
  65. package/skills/bo-review-security/references/finance-security.md +351 -0
  66. package/skills/bo-self-improver/SKILL.md +145 -0
  67. package/skills/bo-self-improver/refs/agent-manager.md +61 -0
  68. package/skills/bo-self-improver/refs/command-manager.md +46 -0
  69. package/skills/bo-self-improver/refs/skill-manager.md +59 -0
  70. package/skills/bo-self-improver/scripts/analyze.py +359 -0
  71. package/skills/bo-task-decomposer/SKILL.md +130 -0
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """Inject context based on environment variables (UserPromptSubmit hook).
3
+
4
+ Reads agent-modes.json to determine the active mode, then outputs the
5
+ corresponding context file content.
6
+
7
+ Context resolution order (4-step fallback):
8
+ 1. Project local (locale): <project>/.claude/beeops/contexts/<locale>/<file>
9
+ 2. Project local (root): <project>/.claude/beeops/contexts/<file>
10
+ 3. Package (locale): <pkg>/contexts/<locale>/<file>
11
+ 4. Package (root): <pkg>/contexts/<file>
12
+
13
+ Locale is determined by BO_LOCALE env var (default: "en").
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import subprocess
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ PKG_CONTEXT_DIR = Path(__file__).resolve().parent.parent / "contexts"
24
+ DEFAULT_LOCALE = "en"
25
+
26
+
27
+ def get_project_root() -> Optional[Path]:
28
+ """Get project root via git rev-parse. Returns None on failure."""
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", "rev-parse", "--show-toplevel"],
32
+ capture_output=True,
33
+ text=True,
34
+ timeout=5,
35
+ )
36
+ if result.returncode == 0:
37
+ return Path(result.stdout.strip())
38
+ except (subprocess.TimeoutExpired, FileNotFoundError):
39
+ pass
40
+ return None
41
+
42
+
43
+ def get_locale(root: Optional[Path]) -> str:
44
+ """Get locale from BO_LOCALE env var or .claude/beeops/locale file."""
45
+ env_locale = os.environ.get("BO_LOCALE")
46
+ if env_locale:
47
+ return env_locale
48
+ if root:
49
+ locale_file = root / ".claude" / "beeops" / "locale"
50
+ if locale_file.is_file():
51
+ return locale_file.read_text().strip() or DEFAULT_LOCALE
52
+ return DEFAULT_LOCALE
53
+
54
+
55
+ def get_local_context_dir(root: Optional[Path]) -> Optional[Path]:
56
+ """Return project-local contexts directory if it exists."""
57
+ if root is None:
58
+ return None
59
+ local_dir = root / ".claude" / "beeops" / "contexts"
60
+ return local_dir if local_dir.is_dir() else None
61
+
62
+
63
+ def resolve_file(filename: str, local_dir: Optional[Path], locale: str) -> Optional[Path]:
64
+ """Resolve a context file with 4-step locale fallback."""
65
+ candidates = []
66
+
67
+ # 1. Project local (locale-specific)
68
+ if local_dir:
69
+ candidates.append(local_dir / locale / filename)
70
+ # 2. Project local (root)
71
+ if local_dir:
72
+ candidates.append(local_dir / filename)
73
+ # 3. Package (locale-specific)
74
+ candidates.append(PKG_CONTEXT_DIR / locale / filename)
75
+ # 4. Package (root)
76
+ candidates.append(PKG_CONTEXT_DIR / filename)
77
+
78
+ for path in candidates:
79
+ if path.is_file():
80
+ return path
81
+ return None
82
+
83
+
84
+ def load_modes(local_dir: Optional[Path], locale: str) -> dict:
85
+ """Load agent-modes.json with locale fallback."""
86
+ modes_path = resolve_file("agent-modes.json", local_dir, locale)
87
+ if modes_path:
88
+ return json.loads(modes_path.read_text())
89
+ return {"modes": {}, "default_context": "default.md"}
90
+
91
+
92
+ def main():
93
+ root = get_project_root()
94
+ locale = get_locale(root)
95
+ local_dir = get_local_context_dir(root)
96
+ config = load_modes(local_dir, locale)
97
+ modes = config.get("modes", {})
98
+
99
+ # Detect active modes (non-append modes take priority)
100
+ primary_contexts = []
101
+ append_contexts = []
102
+
103
+ for env_var, mode_conf in modes.items():
104
+ if os.environ.get(env_var) != "1":
105
+ continue
106
+ context_files = mode_conf.get("context", [])
107
+ if mode_conf.get("append"):
108
+ append_contexts.extend(context_files)
109
+ else:
110
+ primary_contexts.extend(context_files)
111
+
112
+ # If any mode is active, output its context
113
+ if primary_contexts or append_contexts:
114
+ for ctx_file in primary_contexts:
115
+ resolved = resolve_file(ctx_file, local_dir, locale)
116
+ if resolved:
117
+ print(resolved.read_text().strip())
118
+
119
+ for ctx_file in append_contexts:
120
+ resolved = resolve_file(ctx_file, local_dir, locale)
121
+ if resolved:
122
+ print("\n---\n")
123
+ print(resolved.read_text().strip())
124
+ else:
125
+ # Default context
126
+ default_name = config.get("default_context", "default.md")
127
+ default_path = resolve_file(default_name, local_dir, locale)
128
+ if default_path:
129
+ print(default_path.read_text().strip())
130
+
131
+ return 0
132
+
133
+
134
+ if __name__ == "__main__":
135
+ try:
136
+ sys.exit(main())
137
+ except Exception as e:
138
+ print(f"[HOOK ERROR] prompt-context.py: {e}", file=sys.stderr)
139
+ sys.exit(0)
@@ -0,0 +1,93 @@
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()
@@ -0,0 +1,429 @@
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)