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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +101 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +70 -0
- package/dist/constants.d.ts +12 -0
- package/dist/constants.js +50 -0
- package/dist/content/agents.d.ts +39 -0
- package/dist/content/agents.js +244 -0
- package/dist/content/autoplan.d.ts +7 -0
- package/dist/content/autoplan.js +297 -0
- package/dist/content/contracts.d.ts +2 -0
- package/dist/content/contracts.js +50 -0
- package/dist/content/examples.d.ts +2 -0
- package/dist/content/examples.js +327 -0
- package/dist/content/hooks.d.ts +16 -0
- package/dist/content/hooks.js +753 -0
- package/dist/content/learnings.d.ts +5 -0
- package/dist/content/learnings.js +265 -0
- package/dist/content/meta-skill.d.ts +10 -0
- package/dist/content/meta-skill.js +137 -0
- package/dist/content/observe.d.ts +21 -0
- package/dist/content/observe.js +1110 -0
- package/dist/content/session-hooks.d.ts +7 -0
- package/dist/content/session-hooks.js +137 -0
- package/dist/content/skills.d.ts +3 -0
- package/dist/content/skills.js +257 -0
- package/dist/content/stage-schema.d.ts +78 -0
- package/dist/content/stage-schema.js +1453 -0
- package/dist/content/subagents.d.ts +13 -0
- package/dist/content/subagents.js +616 -0
- package/dist/content/templates.d.ts +3 -0
- package/dist/content/templates.js +272 -0
- package/dist/content/utility-skills.d.ts +12 -0
- package/dist/content/utility-skills.js +467 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +610 -0
- package/dist/flow-state.d.ts +19 -0
- package/dist/flow-state.js +41 -0
- package/dist/fs-utils.d.ts +5 -0
- package/dist/fs-utils.js +28 -0
- package/dist/gitignore.d.ts +3 -0
- package/dist/gitignore.js +43 -0
- package/dist/harness-adapters.d.ts +12 -0
- package/dist/harness-adapters.js +175 -0
- package/dist/install.d.ts +9 -0
- package/dist/install.js +562 -0
- package/dist/learnings-summarizer.d.ts +25 -0
- package/dist/learnings-summarizer.js +201 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.js +6 -0
- package/dist/policy.d.ts +6 -0
- package/dist/policy.js +179 -0
- package/dist/runs.d.ts +18 -0
- package/dist/runs.js +446 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +12 -0
- 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
|
+
}
|