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.
- package/README.md +1 -1
- package/bin/beeops.js +22 -32
- package/contexts/agent-modes.json +8 -19
- package/contexts/en/agent-modes.json +8 -19
- package/contexts/en/worker-base.md +1 -11
- package/contexts/ja/agent-modes.json +8 -19
- package/contexts/ja/worker-base.md +1 -11
- package/contexts/worker-base.md +1 -11
- package/package.json +1 -1
- package/contexts/en/fb.md +0 -15
- package/contexts/en/log.md +0 -16
- package/contexts/fb.md +0 -15
- package/contexts/ja/fb.md +0 -15
- package/contexts/ja/log.md +0 -17
- package/contexts/log.md +0 -16
- package/hooks/checkpoint.py +0 -89
- package/hooks/resolve-log-path.py +0 -93
- package/hooks/run-log.py +0 -429
- package/skills/bo-log-writer/SKILL.md +0 -101
- package/skills/bo-self-improver/SKILL.md +0 -145
- package/skills/bo-self-improver/refs/agent-manager.md +0 -61
- package/skills/bo-self-improver/refs/command-manager.md +0 -46
- package/skills/bo-self-improver/refs/skill-manager.md +0 -59
- package/skills/bo-self-improver/scripts/analyze.py +0 -359
- /package/hooks/{prompt-context.py → bo-prompt-context.py} +0 -0
|
@@ -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
|