cclaw-cli 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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +101 -0
  5. package/dist/config.d.ts +5 -0
  6. package/dist/config.js +70 -0
  7. package/dist/constants.d.ts +12 -0
  8. package/dist/constants.js +50 -0
  9. package/dist/content/agents.d.ts +39 -0
  10. package/dist/content/agents.js +244 -0
  11. package/dist/content/autoplan.d.ts +7 -0
  12. package/dist/content/autoplan.js +297 -0
  13. package/dist/content/contracts.d.ts +2 -0
  14. package/dist/content/contracts.js +50 -0
  15. package/dist/content/examples.d.ts +2 -0
  16. package/dist/content/examples.js +327 -0
  17. package/dist/content/hooks.d.ts +16 -0
  18. package/dist/content/hooks.js +753 -0
  19. package/dist/content/learnings.d.ts +5 -0
  20. package/dist/content/learnings.js +265 -0
  21. package/dist/content/meta-skill.d.ts +10 -0
  22. package/dist/content/meta-skill.js +137 -0
  23. package/dist/content/observe.d.ts +21 -0
  24. package/dist/content/observe.js +1110 -0
  25. package/dist/content/session-hooks.d.ts +7 -0
  26. package/dist/content/session-hooks.js +137 -0
  27. package/dist/content/skills.d.ts +3 -0
  28. package/dist/content/skills.js +257 -0
  29. package/dist/content/stage-schema.d.ts +78 -0
  30. package/dist/content/stage-schema.js +1453 -0
  31. package/dist/content/subagents.d.ts +13 -0
  32. package/dist/content/subagents.js +616 -0
  33. package/dist/content/templates.d.ts +3 -0
  34. package/dist/content/templates.js +272 -0
  35. package/dist/content/utility-skills.d.ts +12 -0
  36. package/dist/content/utility-skills.js +467 -0
  37. package/dist/doctor.d.ts +7 -0
  38. package/dist/doctor.js +610 -0
  39. package/dist/flow-state.d.ts +19 -0
  40. package/dist/flow-state.js +41 -0
  41. package/dist/fs-utils.d.ts +5 -0
  42. package/dist/fs-utils.js +28 -0
  43. package/dist/gitignore.d.ts +3 -0
  44. package/dist/gitignore.js +43 -0
  45. package/dist/harness-adapters.d.ts +12 -0
  46. package/dist/harness-adapters.js +175 -0
  47. package/dist/install.d.ts +9 -0
  48. package/dist/install.js +562 -0
  49. package/dist/learnings-summarizer.d.ts +25 -0
  50. package/dist/learnings-summarizer.js +201 -0
  51. package/dist/logger.d.ts +3 -0
  52. package/dist/logger.js +6 -0
  53. package/dist/policy.d.ts +6 -0
  54. package/dist/policy.js +179 -0
  55. package/dist/runs.d.ts +18 -0
  56. package/dist/runs.js +446 -0
  57. package/dist/types.d.ts +19 -0
  58. package/dist/types.js +12 -0
  59. package/package.json +47 -0
@@ -0,0 +1,1110 @@
1
+ /**
2
+ * Tool observation system — captures PreToolUse/PostToolUse events
3
+ * to .cclaw/observations.jsonl for continuous learning.
4
+ *
5
+ * observe.sh: reads hook JSON from stdin, extracts tool name + truncated I/O,
6
+ * appends a JSONL line to .cclaw/observations.jsonl.
7
+ *
8
+ * summarize-observations.sh: run at session stop, reads recent observations,
9
+ * identifies patterns, and appends new learnings to .cclaw/learnings.jsonl.
10
+ */
11
+ import { RUNTIME_ROOT } from "../constants.js";
12
+ import { RUNTIME_SHELL_DETECT_ROOT } from "./hooks.js";
13
+ export function promptGuardScript() {
14
+ return `#!/usr/bin/env bash
15
+ # cclaw prompt guard hook — generated by cclaw sync
16
+ # Advisory-only guard for risky writes into ${RUNTIME_ROOT} runtime files.
17
+ set -uo pipefail
18
+
19
+ HARNESS="codex"
20
+ if [ -n "\${CLAUDE_PROJECT_DIR:-}" ]; then
21
+ HARNESS="claude"
22
+ elif [ -n "\${CURSOR_PROJECT_DIR:-}" ] || [ -n "\${CURSOR_PROJECT_ROOT:-}" ]; then
23
+ HARNESS="cursor"
24
+ elif [ -n "\${OPENCODE_PROJECT_DIR:-}" ] || [ -n "\${OPENCODE_PROJECT_ROOT:-}" ]; then
25
+ HARNESS="opencode"
26
+ fi
27
+
28
+ ${RUNTIME_SHELL_DETECT_ROOT}
29
+
30
+ STATE_DIR="$ROOT/${RUNTIME_ROOT}/state"
31
+ GUARD_LOG="$STATE_DIR/prompt-guard.jsonl"
32
+ mkdir -p "$STATE_DIR" 2>/dev/null || true
33
+
34
+ INPUT=$(cat 2>/dev/null || echo '{}')
35
+ [ -n "$INPUT" ] || exit 0
36
+
37
+ TOOL="unknown"
38
+ PAYLOAD=""
39
+ if command -v jq >/dev/null 2>&1; then
40
+ TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // .tool // "unknown"' 2>/dev/null || echo "unknown")
41
+ PAYLOAD=$(printf '%s' "$INPUT" | jq -r '.tool_input // .input // .arguments // .params // .payload // {} | tostring' 2>/dev/null || echo "")
42
+ elif command -v python3 >/dev/null 2>&1; then
43
+ TOOL=$(INPUT_JSON="$INPUT" python3 - <<'PY'
44
+ import json
45
+ import os
46
+
47
+ try:
48
+ value = json.loads(os.environ.get("INPUT_JSON", "{}"))
49
+ except Exception:
50
+ value = {}
51
+ tool = value.get("tool_name") or value.get("tool") or "unknown"
52
+ print(tool if isinstance(tool, str) else "unknown")
53
+ PY
54
+ )
55
+ PAYLOAD=$(printf '%s' "$INPUT")
56
+ else
57
+ PAYLOAD=$(printf '%s' "$INPUT")
58
+ fi
59
+
60
+ if [ -z "$PAYLOAD" ]; then
61
+ PAYLOAD=$(printf '%s' "$INPUT")
62
+ fi
63
+
64
+ PAYLOAD_LOWER=$(printf '%s' "$PAYLOAD" | tr '[:upper:]' '[:lower:]')
65
+ TOOL_LOWER=$(printf '%s' "$TOOL" | tr '[:upper:]' '[:lower:]')
66
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
67
+ REASONS=""
68
+
69
+ case "$TOOL_LOWER" in
70
+ write|edit|multiedit|delete|applypatch|runcommand|shell|terminal|execcommand)
71
+ if printf '%s' "$PAYLOAD_LOWER" | grep -Eq '\.cclaw/(state|artifacts|hooks|skills|commands|agents|runs|learnings)'; then
72
+ REASONS="write_to_cclaw_runtime"
73
+ fi
74
+ ;;
75
+ esac
76
+
77
+ if printf '%s' "$PAYLOAD_LOWER" | grep -Eq '(rm[[:space:]]+-rf[[:space:]]+\.cclaw|curl[[:space:]].*https?://|wget[[:space:]].*https?://|base64[[:space:]]+-d|eval\\(|python[[:space:]]+-c)'; then
78
+ if [ -n "$REASONS" ]; then
79
+ REASONS="$REASONS,suspicious_payload_pattern"
80
+ else
81
+ REASONS="suspicious_payload_pattern"
82
+ fi
83
+ fi
84
+
85
+ if [ -n "$REASONS" ]; then
86
+ NOTE="Cclaw advisory: potential risky write intent detected for ${RUNTIME_ROOT} runtime (\${REASONS}). Prefer installer commands or explicit confirmation before mutating runtime internals."
87
+ if command -v jq >/dev/null 2>&1; then
88
+ ENTRY=$(jq -n -c \
89
+ --arg ts "$TS" \
90
+ --arg harness "$HARNESS" \
91
+ --arg tool "$TOOL" \
92
+ --arg reasons "$REASONS" \
93
+ --arg note "$NOTE" \
94
+ '{ts:$ts,harness:$harness,tool:$tool,reasons:($reasons|split(",")),note:$note}' 2>/dev/null || echo "")
95
+ elif command -v python3 >/dev/null 2>&1; then
96
+ ENTRY=$(TS="$TS" HARNESS="$HARNESS" TOOL="$TOOL" REASONS="$REASONS" NOTE="$NOTE" python3 - <<'PY'
97
+ import json, os
98
+
99
+ ts = os.environ.get("TS") or ""
100
+ harness = os.environ.get("HARNESS") or ""
101
+ tool = os.environ.get("TOOL") or "unknown"
102
+ reasons_raw = os.environ.get("REASONS") or ""
103
+ note = os.environ.get("NOTE") or ""
104
+ reasons = [r for r in reasons_raw.split(",") if r]
105
+ print(json.dumps({"ts": ts, "harness": harness, "tool": tool, "reasons": reasons, "note": note}, ensure_ascii=False))
106
+ PY
107
+ )
108
+ else
109
+ ENTRY=""
110
+ fi
111
+
112
+ if [ -n "$ENTRY" ]; then
113
+ printf '%s\n' "$ENTRY" >> "$GUARD_LOG" 2>/dev/null || true
114
+ fi
115
+ printf '[cclaw] %s\n' "$NOTE" >&2
116
+ fi
117
+
118
+ exit 0
119
+ `;
120
+ }
121
+ export function observeScript() {
122
+ return `#!/usr/bin/env bash
123
+ # cclaw observe hook — generated by cclaw sync
124
+ # Captures PreToolUse/PostToolUse events to ${RUNTIME_ROOT}/observations.jsonl
125
+ # Reads hook JSON from stdin, extracts tool + truncated I/O, appends JSONL.
126
+ # Always exits 0 to never block the agent.
127
+ set -uo pipefail
128
+
129
+ # Phase: "pre" or "post" passed as $1 by the hook runner
130
+ PHASE="\${1:-post}"
131
+
132
+ ${RUNTIME_SHELL_DETECT_ROOT}
133
+
134
+ OBS_FILE="$ROOT/${RUNTIME_ROOT}/observations.jsonl"
135
+ STATE_FILE="$ROOT/${RUNTIME_ROOT}/state/flow-state.json"
136
+ ACTIVITY_FILE="$ROOT/${RUNTIME_ROOT}/state/stage-activity.jsonl"
137
+ LOCK_DIR="$ROOT/${RUNTIME_ROOT}/state/.observe.lock"
138
+ MAX_LEN=2000
139
+
140
+ # Guard: skip if disabled or observations dir missing
141
+ [ -f "$ROOT/${RUNTIME_ROOT}/.observe-disabled" ] && exit 0
142
+ [ -d "$ROOT/${RUNTIME_ROOT}" ] || exit 0
143
+ mkdir -p "$ROOT/${RUNTIME_ROOT}/state" 2>/dev/null || true
144
+
145
+ escape_json() {
146
+ local str="$1"
147
+ str=\${str//\\\\/\\\\\\\\}
148
+ str=\${str//\\"/\\\\\\"}
149
+ str=\${str//$'\\t'/\\\\t}
150
+ str=\${str//$'\\n'/\\\\n}
151
+ printf '%s' "$str"
152
+ }
153
+
154
+ acquire_lock() {
155
+ local attempt=0
156
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
157
+ attempt=$((attempt + 1))
158
+ if [ "$attempt" -ge 200 ]; then
159
+ return 1
160
+ fi
161
+ sleep 0.02
162
+ done
163
+ return 0
164
+ }
165
+
166
+ release_lock() {
167
+ rmdir "$LOCK_DIR" 2>/dev/null || true
168
+ }
169
+
170
+ rotate_file() {
171
+ local file_path="$1"
172
+ local keep_lines="$2"
173
+ if [ ! -f "$file_path" ]; then
174
+ return
175
+ fi
176
+ local line_count
177
+ line_count=$(wc -l < "$file_path" 2>/dev/null | tr -d ' ')
178
+ if [ -z "$line_count" ]; then
179
+ return
180
+ fi
181
+ if [ "$line_count" -gt $((keep_lines * 2)) ]; then
182
+ local tmp_path="\${file_path}.tmp.$$"
183
+ if tail -n "$keep_lines" "$file_path" > "$tmp_path" 2>/dev/null; then
184
+ mv "$tmp_path" "$file_path" 2>/dev/null || rm -f "$tmp_path" 2>/dev/null || true
185
+ fi
186
+ fi
187
+ }
188
+
189
+ # Read stdin (hook JSON)
190
+ INPUT=$(cat 2>/dev/null || echo '{}')
191
+ [ -z "$INPUT" ] && exit 0
192
+
193
+ # Extract fields from hook payload.
194
+ TOOL="unknown"
195
+ PAYLOAD=""
196
+ if command -v jq >/dev/null 2>&1; then
197
+ TOOL=$(echo "$INPUT" | jq -r '.tool_name // .tool // "unknown"' 2>/dev/null || echo "unknown")
198
+ if [ "$PHASE" = "pre" ]; then
199
+ PAYLOAD=$(echo "$INPUT" | jq -r --arg max "$MAX_LEN" '.tool_input // .input // {} | tostring | .[0:($max|tonumber)]' 2>/dev/null || echo "")
200
+ else
201
+ PAYLOAD=$(echo "$INPUT" | jq -r --arg max "$MAX_LEN" '.tool_response // .tool_output // .output // .result_json // "" | tostring | .[0:($max|tonumber)]' 2>/dev/null || echo "")
202
+ fi
203
+ else
204
+ TOOL=$(printf '%s' "$INPUT" | sed -n -E 's/.*"tool_name"[[:space:]]*:[[:space:]]*"([^"]+)".*/\\1/p' | head -1)
205
+ if [ -z "$TOOL" ]; then
206
+ TOOL=$(printf '%s' "$INPUT" | sed -n -E 's/.*"tool"[[:space:]]*:[[:space:]]*"([^"]+)".*/\\1/p' | head -1)
207
+ fi
208
+ [ -n "$TOOL" ] || TOOL="unknown"
209
+ PAYLOAD=$(printf '%s' "$INPUT" | cut -c1-"$MAX_LEN")
210
+ fi
211
+
212
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
213
+ STAGE="none"
214
+ ACTIVE_RUN="none"
215
+ if [ -f "$STATE_FILE" ]; then
216
+ if command -v jq >/dev/null 2>&1; then
217
+ STAGE=$(jq -r '.currentStage // "none"' "$STATE_FILE" 2>/dev/null || echo "none")
218
+ ACTIVE_RUN=$(jq -r '.activeRunId // "none"' "$STATE_FILE" 2>/dev/null || echo "none")
219
+ elif command -v python3 >/dev/null 2>&1; then
220
+ STAGE=$(python3 - "$STATE_FILE" <<'PY'
221
+ import json
222
+ import sys
223
+
224
+ stage = "none"
225
+ try:
226
+ with open(sys.argv[1], "r", encoding="utf-8") as fh:
227
+ data = json.load(fh)
228
+ value = data.get("currentStage")
229
+ if isinstance(value, str) and value:
230
+ stage = value
231
+ except Exception:
232
+ pass
233
+ print(stage)
234
+ PY
235
+ )
236
+ ACTIVE_RUN=$(python3 - "$STATE_FILE" <<'PY'
237
+ import json
238
+ import sys
239
+
240
+ run_id = "none"
241
+ try:
242
+ with open(sys.argv[1], "r", encoding="utf-8") as fh:
243
+ data = json.load(fh)
244
+ value = data.get("activeRunId")
245
+ if isinstance(value, str) and value:
246
+ run_id = value
247
+ except Exception:
248
+ pass
249
+ print(run_id)
250
+ PY
251
+ )
252
+ else
253
+ STAGE=$(grep -o '"currentStage"[[:space:]]*:[[:space:]]*"[^"]*"' "$STATE_FILE" 2>/dev/null | head -1 | sed 's/.*"\\([^"]*\\)"$/\\1/' || echo "none")
254
+ ACTIVE_RUN=$(grep -o '"activeRunId"[[:space:]]*:[[:space:]]*"[^"]*"' "$STATE_FILE" 2>/dev/null | head -1 | sed 's/.*"\\([^"]*\\)"$/\\1/' || echo "none")
255
+ fi
256
+ fi
257
+
258
+ # Skip observation of cclaw hooks to avoid recursion
259
+ case "$TOOL" in
260
+ cclaw*|*cclaw-hook*|observe) exit 0 ;;
261
+ esac
262
+
263
+ if [ "$PHASE" = "pre" ]; then
264
+ EVENT="tool_start"
265
+ else
266
+ EVENT="tool_complete"
267
+ fi
268
+
269
+ # Scrub potential secrets (env vars, tokens, keys) — BSD/macOS sed compatible
270
+ PAYLOAD=$(echo "$PAYLOAD" | sed -E 's/[A-Za-z0-9_]*([Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll])[A-Za-z0-9_]*[=:][^ ",}]+/[REDACTED]/g' 2>/dev/null || echo "$PAYLOAD")
271
+
272
+ # Build JSONL lines.
273
+ if command -v jq >/dev/null 2>&1; then
274
+ EVENT_JSON=$(jq -n -c \\
275
+ --arg ts "$TS" \\
276
+ --arg event "$EVENT" \\
277
+ --arg tool "$TOOL" \\
278
+ --arg phase "$PHASE" \\
279
+ --arg stage "$STAGE" \\
280
+ --arg runId "$ACTIVE_RUN" \\
281
+ --arg payload "$PAYLOAD" \\
282
+ '{ts:$ts,event:$event,tool:$tool,phase:$phase,stage:$stage,runId:$runId,data:$payload}' 2>/dev/null || echo "")
283
+ ACTIVITY_JSON=$(jq -n -c \\
284
+ --arg ts "$TS" \\
285
+ --arg event "$EVENT" \\
286
+ --arg tool "$TOOL" \\
287
+ --arg phase "$PHASE" \\
288
+ --arg stage "$STAGE" \\
289
+ --arg runId "$ACTIVE_RUN" \\
290
+ '{ts:$ts,event:$event,tool:$tool,phase:$phase,stage:$stage,runId:$runId}' 2>/dev/null || echo "")
291
+ else
292
+ EVENT_JSON=$(printf '{"ts":"%s","event":"%s","tool":"%s","phase":"%s","stage":"%s","runId":"%s","data":"%s"}' \\
293
+ "$(escape_json "$TS")" \\
294
+ "$(escape_json "$EVENT")" \\
295
+ "$(escape_json "$TOOL")" \\
296
+ "$(escape_json "$PHASE")" \\
297
+ "$(escape_json "$STAGE")" \\
298
+ "$(escape_json "$ACTIVE_RUN")" \\
299
+ "$(escape_json "$PAYLOAD")")
300
+ ACTIVITY_JSON=$(printf '{"ts":"%s","event":"%s","tool":"%s","phase":"%s","stage":"%s","runId":"%s"}' \\
301
+ "$(escape_json "$TS")" \\
302
+ "$(escape_json "$EVENT")" \\
303
+ "$(escape_json "$TOOL")" \\
304
+ "$(escape_json "$PHASE")" \\
305
+ "$(escape_json "$STAGE")" \\
306
+ "$(escape_json "$ACTIVE_RUN")")
307
+ fi
308
+
309
+ if acquire_lock; then
310
+ trap release_lock EXIT INT TERM
311
+ [ -n "$EVENT_JSON" ] && printf '%s\\n' "$EVENT_JSON" >> "$OBS_FILE" 2>/dev/null
312
+ [ -n "$ACTIVITY_JSON" ] && printf '%s\\n' "$ACTIVITY_JSON" >> "$ACTIVITY_FILE" 2>/dev/null
313
+ rotate_file "$OBS_FILE" 4000
314
+ rotate_file "$ACTIVITY_FILE" 3000
315
+ release_lock
316
+ trap - EXIT INT TERM
317
+ fi
318
+
319
+ exit 0
320
+ `;
321
+ }
322
+ export function contextMonitorScript() {
323
+ return `#!/usr/bin/env bash
324
+ # cclaw context monitor hook — generated by cclaw sync
325
+ # Advisory-only context pressure warnings (best effort).
326
+ set -uo pipefail
327
+
328
+ HARNESS="codex"
329
+ if [ -n "\${CLAUDE_PROJECT_DIR:-}" ]; then
330
+ HARNESS="claude"
331
+ elif [ -n "\${CURSOR_PROJECT_DIR:-}" ] || [ -n "\${CURSOR_PROJECT_ROOT:-}" ]; then
332
+ HARNESS="cursor"
333
+ elif [ -n "\${OPENCODE_PROJECT_DIR:-}" ] || [ -n "\${OPENCODE_PROJECT_ROOT:-}" ]; then
334
+ HARNESS="opencode"
335
+ fi
336
+
337
+ ${RUNTIME_SHELL_DETECT_ROOT}
338
+
339
+ STATE_DIR="$ROOT/${RUNTIME_ROOT}/state"
340
+ MONITOR_STATE="$STATE_DIR/context-monitor.json"
341
+ WARNINGS_FILE="$STATE_DIR/context-warnings.jsonl"
342
+ mkdir -p "$STATE_DIR" 2>/dev/null || true
343
+
344
+ INPUT=$(cat 2>/dev/null || echo '{}')
345
+ [ -n "$INPUT" ] || exit 0
346
+
347
+ REMAINING_PERCENT=""
348
+ if command -v python3 >/dev/null 2>&1; then
349
+ REMAINING_PERCENT=$(INPUT_JSON="$INPUT" python3 - <<'PY'
350
+ import json
351
+ import os
352
+ from typing import Any
353
+
354
+ raw = os.environ.get("INPUT_JSON", "{}")
355
+ try:
356
+ payload = json.loads(raw)
357
+ except Exception:
358
+ print("")
359
+ raise SystemExit(0)
360
+
361
+ def pick(path: list[str]) -> Any:
362
+ node: Any = payload
363
+ for key in path:
364
+ if not isinstance(node, dict):
365
+ return None
366
+ node = node.get(key)
367
+ return node
368
+
369
+ def as_percent(value: Any, invert: bool = False):
370
+ if not isinstance(value, (int, float)):
371
+ return None
372
+ number = float(value)
373
+ if number <= 1.0:
374
+ number *= 100.0
375
+ if invert:
376
+ number = 100.0 - number
377
+ if number < 0:
378
+ number = 0.0
379
+ if number > 100:
380
+ number = 100.0
381
+ return number
382
+
383
+ candidates = [
384
+ (["context", "remaining_percent"], False),
385
+ (["context", "remainingPercent"], False),
386
+ (["context_usage", "remaining_percent"], False),
387
+ (["context_usage", "remainingPercent"], False),
388
+ (["contextUsage", "remainingPercent"], False),
389
+ (["context_window", "remaining_percent"], False),
390
+ (["remaining_context_percent"], False),
391
+ (["remainingContextPercent"], False),
392
+ (["remaining_context_ratio"], False),
393
+ (["remainingContextRatio"], False),
394
+ (["context", "used_percent"], True),
395
+ (["context", "usedPercent"], True),
396
+ (["context_usage", "used_percent"], True),
397
+ (["context_usage", "usedPercent"], True),
398
+ (["contextUsage", "usedPercent"], True),
399
+ (["context_window", "used_ratio"], True),
400
+ (["context_window", "usedRatio"], True),
401
+ ]
402
+
403
+ for path, invert in candidates:
404
+ value = pick(path)
405
+ percent = as_percent(value, invert=invert)
406
+ if percent is not None:
407
+ print(f"{percent:.2f}")
408
+ raise SystemExit(0)
409
+
410
+ print("")
411
+ PY
412
+ )
413
+ fi
414
+
415
+ [ -n "$REMAINING_PERCENT" ] || exit 0
416
+
417
+ BAND="none"
418
+ if awk "BEGIN { exit !($REMAINING_PERCENT <= 20) }"; then
419
+ BAND="critical"
420
+ elif awk "BEGIN { exit !($REMAINING_PERCENT <= 35) }"; then
421
+ BAND="warning"
422
+ fi
423
+
424
+ [ "$BAND" != "none" ] || exit 0
425
+
426
+ LAST_BAND="none"
427
+ if [ -f "$MONITOR_STATE" ]; then
428
+ if command -v jq >/dev/null 2>&1; then
429
+ LAST_BAND=$(jq -r '.lastBand // "none"' "$MONITOR_STATE" 2>/dev/null || echo "none")
430
+ elif command -v python3 >/dev/null 2>&1; then
431
+ LAST_BAND=$(python3 - "$MONITOR_STATE" <<'PY'
432
+ import json
433
+ import sys
434
+ try:
435
+ with open(sys.argv[1], "r", encoding="utf-8") as handle:
436
+ value = json.load(handle)
437
+ band = value.get("lastBand")
438
+ if isinstance(band, str):
439
+ print(band)
440
+ else:
441
+ print("none")
442
+ except Exception:
443
+ print("none")
444
+ PY
445
+ )
446
+ fi
447
+ fi
448
+
449
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
450
+ if [ "$BAND" != "$LAST_BAND" ]; then
451
+ NOTE="Cclaw advisory: context remaining is \${REMAINING_PERCENT}% (\${BAND}). Consider checkpointing or compacting soon."
452
+ if command -v jq >/dev/null 2>&1; then
453
+ ENTRY=$(jq -n -c \
454
+ --arg ts "$TS" \
455
+ --arg harness "$HARNESS" \
456
+ --arg band "$BAND" \
457
+ --arg remaining "$REMAINING_PERCENT" \
458
+ --arg note "$NOTE" \
459
+ '{ts:$ts,harness:$harness,band:$band,remainingPercent:($remaining|tonumber),note:$note}' 2>/dev/null || echo "")
460
+ else
461
+ ENTRY=$(printf '{"ts":"%s","harness":"%s","band":"%s","remainingPercent":"%s","note":"%s"}' "$TS" "$HARNESS" "$BAND" "$REMAINING_PERCENT" "$NOTE")
462
+ fi
463
+
464
+ if [ -n "$ENTRY" ]; then
465
+ printf '%s\n' "$ENTRY" >> "$WARNINGS_FILE" 2>/dev/null || true
466
+ fi
467
+ printf '[cclaw] %s\n' "$NOTE" >&2
468
+ fi
469
+
470
+ TMP_STATE="$MONITOR_STATE.tmp.$$"
471
+ if command -v jq >/dev/null 2>&1; then
472
+ jq -n \
473
+ --arg ts "$TS" \
474
+ --arg band "$BAND" \
475
+ --arg remaining "$REMAINING_PERCENT" \
476
+ --arg harness "$HARNESS" \
477
+ '{lastUpdated:$ts,lastBand:$band,lastRemainingPercent:($remaining|tonumber),harness:$harness}' > "$TMP_STATE" 2>/dev/null || true
478
+ else
479
+ printf '{\n "lastUpdated": "%s",\n "lastBand": "%s",\n "lastRemainingPercent": %s,\n "harness": "%s"\n}\n' \
480
+ "$TS" "$BAND" "$REMAINING_PERCENT" "$HARNESS" > "$TMP_STATE" 2>/dev/null || true
481
+ fi
482
+ if [ -s "$TMP_STATE" ]; then
483
+ mv "$TMP_STATE" "$MONITOR_STATE" 2>/dev/null || rm -f "$TMP_STATE" 2>/dev/null || true
484
+ fi
485
+
486
+ exit 0
487
+ `;
488
+ }
489
+ export function summarizeObservationsRuntimeModule() {
490
+ return `#!/usr/bin/env node
491
+ import fs from "node:fs";
492
+
493
+ const [, , observationsPath, learningsPath, timestampArg] = process.argv;
494
+
495
+ function readTailText(filePath, maxBytes = 524288) {
496
+ let fd;
497
+ try {
498
+ fd = fs.openSync(filePath, "r");
499
+ const size = fs.fstatSync(fd).size;
500
+ if (!Number.isFinite(size) || size <= 0) return "";
501
+ const bytesToRead = Math.min(size, maxBytes);
502
+ const buffer = Buffer.allocUnsafe(bytesToRead);
503
+ fs.readSync(fd, buffer, 0, bytesToRead, size - bytesToRead);
504
+ return buffer.toString("utf8");
505
+ } catch {
506
+ return "";
507
+ } finally {
508
+ if (fd !== undefined) {
509
+ try {
510
+ fs.closeSync(fd);
511
+ } catch {
512
+ // ignore
513
+ }
514
+ }
515
+ }
516
+ }
517
+
518
+ function readTailLines(filePath, maxLines, maxBytes = 524288) {
519
+ const raw = readTailText(filePath, maxBytes).trim();
520
+ if (!raw) return [];
521
+ return raw.split(/\\r?\\n/).slice(-maxLines);
522
+ }
523
+
524
+ function writeAppend(filePath, lines) {
525
+ if (!lines.length) return;
526
+ try {
527
+ fs.appendFileSync(filePath, lines.join("\\n") + "\\n", "utf8");
528
+ } catch {
529
+ // advisory-only runtime path
530
+ }
531
+ }
532
+
533
+ function parseLine(line) {
534
+ const trimmed = line.trim();
535
+ if (!trimmed) return null;
536
+ try {
537
+ const parsed = JSON.parse(trimmed);
538
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
539
+ return parsed;
540
+ }
541
+ return null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ function toText(value) {
548
+ if (typeof value === "string") return value;
549
+ try {
550
+ return JSON.stringify(value);
551
+ } catch {
552
+ return "";
553
+ }
554
+ }
555
+
556
+ const keyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
557
+ const errorPattern = /(error|fail|timeout|exception)/i;
558
+ const timestamp = timestampArg || new Date().toISOString();
559
+ const observationLines = readTailLines(observationsPath, 4000, 1024 * 1024);
560
+ const learningLines = readTailLines(learningsPath, 6000, 1024 * 1024);
561
+
562
+ const observations = observationLines
563
+ .map(parseLine)
564
+ .filter(Boolean);
565
+
566
+ if (observations.length < 5) {
567
+ process.exit(0);
568
+ }
569
+
570
+ const toolUsage = new Map();
571
+ const toolErrors = new Map();
572
+ const stageErrors = new Map();
573
+ const longPayload = new Map();
574
+
575
+ for (const obs of observations) {
576
+ const toolRaw = typeof obs.tool === "string" ? obs.tool : "unknown";
577
+ const stageRaw = typeof obs.stage === "string" ? obs.stage : "none";
578
+ const tool = toolRaw.trim().replace(/[^A-Za-z0-9._-]+/g, "-") || "unknown";
579
+ const stage = stageRaw.trim().replace(/[^A-Za-z0-9._-]+/g, "-") || "none";
580
+ const payload = toText(obs.data);
581
+ toolUsage.set(tool, (toolUsage.get(tool) || 0) + 1);
582
+ if (payload.length >= 1500) {
583
+ longPayload.set(tool, (longPayload.get(tool) || 0) + 1);
584
+ }
585
+ if (obs.event === "tool_complete" && errorPattern.test(payload)) {
586
+ toolErrors.set(tool, (toolErrors.get(tool) || 0) + 1);
587
+ stageErrors.set(stage, (stageErrors.get(stage) || 0) + 1);
588
+ }
589
+ }
590
+
591
+ const candidates = [];
592
+
593
+ for (const [tool, errors] of toolErrors.entries()) {
594
+ if (errors < 3) continue;
595
+ candidates.push({
596
+ ts: timestamp,
597
+ skill: "observation",
598
+ type: "pitfall",
599
+ key: "frequent-errors-" + tool,
600
+ insight: "Tool " + tool + " produced " + errors + " error-like completions in a single session; add a preflight checklist before using it.",
601
+ confidence: Math.min(9, 4 + Math.floor(errors / 2)),
602
+ source: "observed"
603
+ });
604
+ }
605
+
606
+ for (const [tool, total] of toolUsage.entries()) {
607
+ if (total < 8) continue;
608
+ const errors = toolErrors.get(tool) || 0;
609
+ if (errors > Math.max(1, Math.floor(total * 0.15))) continue;
610
+ candidates.push({
611
+ ts: timestamp,
612
+ skill: "observation",
613
+ type: "pattern",
614
+ key: "reliable-tool-" + tool,
615
+ insight: "Tool " + tool + " was used " + total + " times with low failure rate; prefer it as a first option for similar tasks.",
616
+ confidence: Math.min(8, 3 + Math.floor(total / 3)),
617
+ source: "observed"
618
+ });
619
+ }
620
+
621
+ for (const [stage, errors] of stageErrors.entries()) {
622
+ if (stage === "none" || errors < 4) continue;
623
+ candidates.push({
624
+ ts: timestamp,
625
+ skill: "observation",
626
+ type: "pitfall",
627
+ key: "stage-hotspot-" + stage,
628
+ insight: "Stage " + stage + " produced " + errors + " error-like tool completions in one session; add stage-specific checks before execution.",
629
+ confidence: Math.min(8, 3 + Math.floor(errors / 2)),
630
+ source: "observed"
631
+ });
632
+ }
633
+
634
+ for (const [tool, count] of longPayload.entries()) {
635
+ if (count < 3) continue;
636
+ candidates.push({
637
+ ts: timestamp,
638
+ skill: "observation",
639
+ type: "preference",
640
+ key: "truncate-heavy-payloads-" + tool,
641
+ insight: "Tool " + tool + " produced large payloads repeatedly; summarize outputs earlier to avoid context pressure.",
642
+ confidence: Math.min(7, 3 + Math.floor(count / 2)),
643
+ source: "observed"
644
+ });
645
+ }
646
+
647
+ const valid = [];
648
+ const bestCandidate = new Map();
649
+ for (const candidate of candidates) {
650
+ if (typeof candidate.key !== "string" || !keyPattern.test(candidate.key)) continue;
651
+ if (typeof candidate.insight !== "string" || candidate.insight.trim().length < 16) continue;
652
+ if (!Number.isInteger(candidate.confidence) || candidate.confidence < 1 || candidate.confidence > 10) continue;
653
+ const token = candidate.key + ":" + candidate.type;
654
+ const current = bestCandidate.get(token);
655
+ if (!current || candidate.confidence > current.confidence) {
656
+ bestCandidate.set(token, candidate);
657
+ }
658
+ }
659
+ for (const value of bestCandidate.values()) {
660
+ valid.push(value);
661
+ }
662
+
663
+ const bestExisting = new Map();
664
+ for (const line of learningLines) {
665
+ const parsed = parseLine(line);
666
+ if (!parsed) continue;
667
+ if (typeof parsed.key !== "string" || typeof parsed.type !== "string") continue;
668
+ if (typeof parsed.confidence !== "number" || !Number.isInteger(parsed.confidence)) continue;
669
+ const token = parsed.key + ":" + parsed.type;
670
+ const current = bestExisting.get(token) || 0;
671
+ if (parsed.confidence > current) {
672
+ bestExisting.set(token, parsed.confidence);
673
+ }
674
+ }
675
+
676
+ const appended = [];
677
+ for (const candidate of valid) {
678
+ const token = candidate.key + ":" + candidate.type;
679
+ const current = bestExisting.get(token) || 0;
680
+ if (candidate.confidence > current) {
681
+ appended.push(JSON.stringify(candidate));
682
+ bestExisting.set(token, candidate.confidence);
683
+ }
684
+ }
685
+
686
+ writeAppend(learningsPath, appended);
687
+ process.exit(0);
688
+ `;
689
+ }
690
+ export function summarizeObservationsScript() {
691
+ return `#!/usr/bin/env bash
692
+ # cclaw stop-summarize hook — generated by cclaw sync
693
+ # Analyzes recent observations and creates learnings entries.
694
+ # Runs as part of the stop hook chain.
695
+ set -uo pipefail
696
+
697
+ ${RUNTIME_SHELL_DETECT_ROOT}
698
+
699
+ OBS_FILE="$ROOT/${RUNTIME_ROOT}/observations.jsonl"
700
+ LEARNINGS_FILE="$ROOT/${RUNTIME_ROOT}/learnings.jsonl"
701
+ ACTIVITY_FILE="$ROOT/${RUNTIME_ROOT}/state/stage-activity.jsonl"
702
+ LOCK_DIR="$ROOT/${RUNTIME_ROOT}/state/.observe.lock"
703
+ mkdir -p "$ROOT/${RUNTIME_ROOT}/state" 2>/dev/null || true
704
+ [ -f "$LEARNINGS_FILE" ] || : > "$LEARNINGS_FILE" 2>/dev/null || true
705
+
706
+ # Guard
707
+ [ -f "$OBS_FILE" ] || exit 0
708
+ [ -s "$OBS_FILE" ] || exit 0
709
+
710
+ acquire_lock() {
711
+ local attempt=0
712
+ while ! mkdir "$LOCK_DIR" 2>/dev/null; do
713
+ attempt=$((attempt + 1))
714
+ if [ "$attempt" -ge 200 ]; then
715
+ return 1
716
+ fi
717
+ sleep 0.02
718
+ done
719
+ return 0
720
+ }
721
+
722
+ release_lock() {
723
+ rmdir "$LOCK_DIR" 2>/dev/null || true
724
+ }
725
+
726
+ rotate_file() {
727
+ local file_path="$1"
728
+ local keep_lines="$2"
729
+ if [ ! -f "$file_path" ]; then
730
+ return
731
+ fi
732
+ local line_count
733
+ line_count=$(wc -l < "$file_path" 2>/dev/null | tr -d ' ')
734
+ if [ -z "$line_count" ]; then
735
+ return
736
+ fi
737
+ if [ "$line_count" -gt $((keep_lines * 2)) ]; then
738
+ local tmp_path="\${file_path}.tmp.$$"
739
+ if tail -n "$keep_lines" "$file_path" > "$tmp_path" 2>/dev/null; then
740
+ mv "$tmp_path" "$file_path" 2>/dev/null || rm -f "$tmp_path" 2>/dev/null || true
741
+ fi
742
+ fi
743
+ }
744
+
745
+ acquire_lock || exit 0
746
+ trap release_lock EXIT INT TERM
747
+
748
+ # Count observations in this session
749
+ OBS_COUNT=$(wc -l < "$OBS_FILE" 2>/dev/null | tr -d ' ')
750
+ [ "$OBS_COUNT" -lt 5 ] && exit 0
751
+
752
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
753
+ RUNTIME_SUMMARIZER="$ROOT/${RUNTIME_ROOT}/hooks/summarize-observations.mjs"
754
+ if command -v node >/dev/null 2>&1 && [ -f "$RUNTIME_SUMMARIZER" ]; then
755
+ node "$RUNTIME_SUMMARIZER" "$OBS_FILE" "$LEARNINGS_FILE" "$TS" >/dev/null 2>&1 || true
756
+ elif command -v python3 >/dev/null 2>&1; then
757
+ python3 - "$OBS_FILE" "$LEARNINGS_FILE" "$TS" <<'PY' >/dev/null 2>&1
758
+ import json
759
+ import re
760
+ import sys
761
+ from collections import Counter
762
+ from pathlib import Path
763
+
764
+ obs_path = Path(sys.argv[1])
765
+ learnings_path = Path(sys.argv[2])
766
+ timestamp = sys.argv[3]
767
+
768
+ error_pattern = re.compile(r"(error|fail)", re.IGNORECASE)
769
+ key_pattern = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
770
+
771
+ observations = []
772
+ for line in obs_path.read_text(encoding="utf-8").splitlines():
773
+ line = line.strip()
774
+ if not line:
775
+ continue
776
+ try:
777
+ value = json.loads(line)
778
+ except Exception:
779
+ continue
780
+ if isinstance(value, dict):
781
+ observations.append(value)
782
+
783
+ if len(observations) < 5:
784
+ raise SystemExit(0)
785
+
786
+ tool_counts: Counter[str] = Counter()
787
+ error_counts: Counter[str] = Counter()
788
+ stage_error_counts: Counter[str] = Counter()
789
+
790
+ for event in observations:
791
+ tool = event.get("tool")
792
+ if not isinstance(tool, str) or not tool:
793
+ tool = "unknown"
794
+ stage = event.get("stage")
795
+ if not isinstance(stage, str) or not stage:
796
+ stage = "none"
797
+
798
+ tool_counts[tool] += 1
799
+
800
+ payload = event.get("data")
801
+ if isinstance(payload, str):
802
+ payload_text = payload
803
+ else:
804
+ try:
805
+ payload_text = json.dumps(payload, ensure_ascii=False)
806
+ except Exception:
807
+ payload_text = ""
808
+
809
+ if event.get("event") == "tool_complete" and error_pattern.search(payload_text):
810
+ error_counts[tool] += 1
811
+ stage_error_counts[stage] += 1
812
+
813
+ candidates: list[dict[str, object]] = []
814
+ if error_counts:
815
+ top_tool, top_count = error_counts.most_common(1)[0]
816
+ if top_count >= 3:
817
+ candidates.append({
818
+ "ts": timestamp,
819
+ "skill": "observation",
820
+ "type": "pitfall",
821
+ "key": f"frequent-errors-{top_tool}",
822
+ "insight": f"Tool {top_tool} had {top_count} error-like outputs in one session; add a preflight checklist before using it.",
823
+ "confidence": min(9, 4 + (top_count // 2)),
824
+ "source": "observed"
825
+ })
826
+
827
+ if tool_counts:
828
+ top_tool, top_total = tool_counts.most_common(1)[0]
829
+ if top_total >= 8 and error_counts.get(top_tool, 0) <= 1:
830
+ candidates.append({
831
+ "ts": timestamp,
832
+ "skill": "observation",
833
+ "type": "pattern",
834
+ "key": f"reliable-tool-{top_tool}",
835
+ "insight": f"Tool {top_tool} was used {top_total} times with low failure rate; prefer it as first option for similar tasks.",
836
+ "confidence": min(8, 3 + (top_total // 3)),
837
+ "source": "observed"
838
+ })
839
+
840
+ if stage_error_counts:
841
+ top_stage, top_stage_count = stage_error_counts.most_common(1)[0]
842
+ if top_stage != "none" and top_stage_count >= 4:
843
+ candidates.append({
844
+ "ts": timestamp,
845
+ "skill": "observation",
846
+ "type": "pitfall",
847
+ "key": f"stage-hotspot-{top_stage}",
848
+ "insight": f"Stage {top_stage} produced {top_stage_count} error-like tool completions in one session; stage-specific guardrails are needed.",
849
+ "confidence": min(8, 3 + (top_stage_count // 2)),
850
+ "source": "observed"
851
+ })
852
+
853
+ def is_valid(entry: dict[str, object]) -> bool:
854
+ ts = entry.get("ts")
855
+ key = entry.get("key")
856
+ insight = entry.get("insight")
857
+ conf = entry.get("confidence")
858
+ typ = entry.get("type")
859
+ source = entry.get("source")
860
+
861
+ if not isinstance(ts, str) or not ts:
862
+ return False
863
+ if not isinstance(key, str) or not key_pattern.match(key):
864
+ return False
865
+ if not isinstance(insight, str) or len(insight.strip()) < 16:
866
+ return False
867
+ if not isinstance(conf, int) or conf < 1 or conf > 10:
868
+ return False
869
+ if typ not in {"pitfall", "pattern", "preference"}:
870
+ return False
871
+ if source not in {"observed", "user-stated"}:
872
+ return False
873
+ return True
874
+
875
+ best_existing: dict[str, int] = {}
876
+ if learnings_path.exists():
877
+ for line in learnings_path.read_text(encoding="utf-8").splitlines():
878
+ line = line.strip()
879
+ if not line:
880
+ continue
881
+ try:
882
+ entry = json.loads(line)
883
+ except Exception:
884
+ continue
885
+ if not isinstance(entry, dict):
886
+ continue
887
+ key = entry.get("key")
888
+ typ = entry.get("type")
889
+ conf = entry.get("confidence")
890
+ if isinstance(key, str) and isinstance(typ, str) and isinstance(conf, int):
891
+ token = f"{key}:{typ}"
892
+ best_existing[token] = max(best_existing.get(token, 0), conf)
893
+
894
+ appended: list[str] = []
895
+ with learnings_path.open("a", encoding="utf-8") as handle:
896
+ for candidate in candidates:
897
+ if not is_valid(candidate):
898
+ continue
899
+ token = f"{candidate['key']}:{candidate['type']}"
900
+ confidence = int(candidate["confidence"])
901
+ if confidence <= best_existing.get(token, 0):
902
+ continue
903
+ handle.write(json.dumps(candidate, ensure_ascii=False) + "\\n")
904
+ best_existing[token] = confidence
905
+ appended.append(str(candidate["key"]))
906
+
907
+ raise SystemExit(0)
908
+ PY
909
+ elif command -v jq >/dev/null 2>&1; then
910
+ ERROR_PATTERNS=$(jq -r 'select(.event=="tool_complete") | select(.data | test("error|Error|ERROR|fail|Fail|FAIL"; "g")) | .tool' "$OBS_FILE" 2>/dev/null | sort | uniq -c | sort -rn | head -3)
911
+ if [ -n "$ERROR_PATTERNS" ]; then
912
+ TOP_ERROR_TOOL=$(echo "$ERROR_PATTERNS" | head -1 | awk '{print $2}')
913
+ TOP_ERROR_COUNT=$(echo "$ERROR_PATTERNS" | head -1 | awk '{print $1}')
914
+ if [ "$TOP_ERROR_COUNT" -ge 3 ]; then
915
+ CANDIDATE=$(jq -n -c \\
916
+ --arg ts "$TS" \\
917
+ --arg tool "$TOP_ERROR_TOOL" \\
918
+ --arg count "$TOP_ERROR_COUNT" \\
919
+ '{ts:$ts,skill:"observation",type:"pitfall",key:("frequent-errors-"+$tool),insight:("Tool "+$tool+" had "+$count+" error-like outputs in one session; add a preflight checklist before using it."),confidence:(5 + (($count|tonumber) / 2 | floor)),source:"observed"}' 2>/dev/null || echo "")
920
+ if [ -n "$CANDIDATE" ]; then
921
+ CANDIDATE=$(echo "$CANDIDATE" | jq -c 'if .confidence > 10 then .confidence = 10 else . end' 2>/dev/null || echo "")
922
+ fi
923
+
924
+ if [ -n "$CANDIDATE" ]; then
925
+ CANDIDATE_OK=$(echo "$CANDIDATE" | jq -r '
926
+ (.ts|type=="string") and
927
+ (.key|type=="string") and
928
+ (.type|type=="string") and
929
+ (.insight|type=="string" and (length >= 16)) and
930
+ (.confidence|type=="number" and . >= 1 and . <= 10) and
931
+ (.source|type=="string")
932
+ ' 2>/dev/null || echo "false")
933
+ if [ "$CANDIDATE_OK" = "true" ]; then
934
+ CANDIDATE_KEY=$(echo "$CANDIDATE" | jq -r '.key' 2>/dev/null || echo "")
935
+ CANDIDATE_TYPE=$(echo "$CANDIDATE" | jq -r '.type' 2>/dev/null || echo "")
936
+ CANDIDATE_CONF=$(echo "$CANDIDATE" | jq -r '.confidence' 2>/dev/null || echo "0")
937
+ EXISTING_CONF=$(jq -r --arg key "$CANDIDATE_KEY" --arg type "$CANDIDATE_TYPE" '
938
+ select(.key == $key and .type == $type) | (.confidence // 0)
939
+ ' "$LEARNINGS_FILE" 2>/dev/null | sort -nr | head -1)
940
+ [ -n "$EXISTING_CONF" ] || EXISTING_CONF=0
941
+ if [ "$CANDIDATE_CONF" -gt "$EXISTING_CONF" ]; then
942
+ printf '%s\\n' "$CANDIDATE" >> "$LEARNINGS_FILE" 2>/dev/null || true
943
+ fi
944
+ fi
945
+ fi
946
+ fi
947
+ fi
948
+ fi
949
+
950
+ # Archive observations (rotate to prevent unbounded growth)
951
+ ARCHIVE_DIR="$ROOT/${RUNTIME_ROOT}/observations.archive"
952
+ mkdir -p "$ARCHIVE_DIR" 2>/dev/null
953
+ ARCHIVE_FILE="$ARCHIVE_DIR/$(date -u +"%Y%m%d-%H%M%S").jsonl"
954
+ cp "$OBS_FILE" "$ARCHIVE_FILE" 2>/dev/null || true
955
+ : > "$OBS_FILE" 2>/dev/null || true
956
+
957
+ # Retain only the most recent 30 archives.
958
+ ARCHIVE_LIST=$(ls -1t "$ARCHIVE_DIR"/*.jsonl 2>/dev/null || true)
959
+ COUNT=0
960
+ for file in $ARCHIVE_LIST; do
961
+ COUNT=$((COUNT + 1))
962
+ if [ "$COUNT" -gt 30 ]; then
963
+ rm -f "$file" 2>/dev/null || true
964
+ fi
965
+ done
966
+
967
+ # Keep stage activity bounded by line count.
968
+ rotate_file "$ACTIVITY_FILE" 2000
969
+
970
+ release_lock
971
+ trap - EXIT INT TERM
972
+
973
+ exit 0
974
+ `;
975
+ }
976
+ /**
977
+ * Updated hooks.json generators with PreToolUse/PostToolUse observation.
978
+ */
979
+ export function claudeHooksJsonWithObservation() {
980
+ return JSON.stringify({
981
+ hooks: {
982
+ SessionStart: [{
983
+ matcher: "startup|resume|clear|compact",
984
+ hooks: [{
985
+ type: "command",
986
+ command: `bash ${RUNTIME_ROOT}/hooks/session-start.sh`
987
+ }]
988
+ }],
989
+ PreToolUse: [{
990
+ matcher: "*",
991
+ hooks: [{
992
+ type: "command",
993
+ command: `bash ${RUNTIME_ROOT}/hooks/prompt-guard.sh`
994
+ }, {
995
+ type: "command",
996
+ command: `bash ${RUNTIME_ROOT}/hooks/observe.sh pre`
997
+ }]
998
+ }],
999
+ PostToolUse: [{
1000
+ matcher: "*",
1001
+ hooks: [{
1002
+ type: "command",
1003
+ command: `bash ${RUNTIME_ROOT}/hooks/context-monitor.sh`
1004
+ }, {
1005
+ type: "command",
1006
+ command: `bash ${RUNTIME_ROOT}/hooks/observe.sh post`
1007
+ }]
1008
+ }],
1009
+ Stop: [{
1010
+ hooks: [
1011
+ {
1012
+ type: "command",
1013
+ command: `bash ${RUNTIME_ROOT}/hooks/summarize-observations.sh`,
1014
+ timeout: 15
1015
+ },
1016
+ {
1017
+ type: "command",
1018
+ command: `bash ${RUNTIME_ROOT}/hooks/stop-checkpoint.sh`,
1019
+ timeout: 10
1020
+ }
1021
+ ]
1022
+ }]
1023
+ }
1024
+ }, null, 2);
1025
+ }
1026
+ export function cursorHooksJsonWithObservation() {
1027
+ return JSON.stringify({
1028
+ version: 1,
1029
+ hooks: {
1030
+ sessionStart: [{
1031
+ command: `${RUNTIME_ROOT}/hooks/session-start.sh`
1032
+ }],
1033
+ sessionResume: [{
1034
+ command: `${RUNTIME_ROOT}/hooks/session-start.sh`
1035
+ }],
1036
+ sessionClear: [{
1037
+ command: `${RUNTIME_ROOT}/hooks/session-start.sh`
1038
+ }],
1039
+ sessionCompact: [{
1040
+ command: `${RUNTIME_ROOT}/hooks/session-start.sh`
1041
+ }],
1042
+ preToolUse: [{
1043
+ matcher: "*",
1044
+ command: `${RUNTIME_ROOT}/hooks/prompt-guard.sh`
1045
+ }, {
1046
+ matcher: "*",
1047
+ command: `${RUNTIME_ROOT}/hooks/observe.sh pre`
1048
+ }],
1049
+ postToolUse: [{
1050
+ matcher: "*",
1051
+ command: `${RUNTIME_ROOT}/hooks/context-monitor.sh`
1052
+ }, {
1053
+ matcher: "*",
1054
+ command: `${RUNTIME_ROOT}/hooks/observe.sh post`
1055
+ }],
1056
+ stop: [
1057
+ { command: `${RUNTIME_ROOT}/hooks/summarize-observations.sh`, timeout: 15 },
1058
+ { command: `${RUNTIME_ROOT}/hooks/stop-checkpoint.sh`, timeout: 10 }
1059
+ ]
1060
+ }
1061
+ }, null, 2);
1062
+ }
1063
+ export function codexHooksJsonWithObservation() {
1064
+ return JSON.stringify({
1065
+ hooks: {
1066
+ SessionStart: [{
1067
+ matcher: "startup|resume|clear|compact",
1068
+ hooks: [{
1069
+ type: "command",
1070
+ command: `bash ${RUNTIME_ROOT}/hooks/session-start.sh`,
1071
+ statusMessage: "Loading cclaw flow state"
1072
+ }]
1073
+ }],
1074
+ PreToolUse: [{
1075
+ matcher: "*",
1076
+ hooks: [{
1077
+ type: "command",
1078
+ command: `bash ${RUNTIME_ROOT}/hooks/prompt-guard.sh`
1079
+ }, {
1080
+ type: "command",
1081
+ command: `bash ${RUNTIME_ROOT}/hooks/observe.sh pre`
1082
+ }]
1083
+ }],
1084
+ PostToolUse: [{
1085
+ matcher: "*",
1086
+ hooks: [{
1087
+ type: "command",
1088
+ command: `bash ${RUNTIME_ROOT}/hooks/context-monitor.sh`
1089
+ }, {
1090
+ type: "command",
1091
+ command: `bash ${RUNTIME_ROOT}/hooks/observe.sh post`
1092
+ }]
1093
+ }],
1094
+ Stop: [{
1095
+ hooks: [
1096
+ {
1097
+ type: "command",
1098
+ command: `bash ${RUNTIME_ROOT}/hooks/summarize-observations.sh`,
1099
+ timeout: 15
1100
+ },
1101
+ {
1102
+ type: "command",
1103
+ command: `bash ${RUNTIME_ROOT}/hooks/stop-checkpoint.sh`,
1104
+ timeout: 10
1105
+ }
1106
+ ]
1107
+ }]
1108
+ }
1109
+ }, null, 2);
1110
+ }