@uzysjung/agent-harness 26.119.0 → 26.125.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/README.ko.md +12 -0
- package/README.md +9 -0
- package/dist/{chunk-2VII24JD.js → chunk-ZSLMD5SP.js} +10 -4
- package/dist/{chunk-2VII24JD.js.map → chunk-ZSLMD5SP.js.map} +1 -1
- package/dist/index.js +737 -220
- package/dist/index.js.map +1 -1
- package/dist/trust-tier-drift.js +1 -1
- package/package.json +1 -1
- package/templates/hooks/session-start.sh +24 -3
- package/templates/hooks/spec-drift-check.sh +24 -1
- package/templates/rules/cli-development.md +16 -0
- package/templates/rules/git-policy.md +4 -0
- package/templates/rules/no-false-ship.md +55 -0
- package/templates/skills/continuous-learning-v2/SKILL.md +25 -29
- package/templates/skills/continuous-learning-v2/agents/observer-loop.sh +362 -0
- package/templates/skills/continuous-learning-v2/agents/observer.md +189 -0
- package/templates/skills/continuous-learning-v2/agents/session-guardian.sh +150 -0
- package/templates/skills/continuous-learning-v2/agents/start-observer.sh +252 -0
- package/templates/skills/continuous-learning-v2/hooks/observe.sh +189 -32
- package/templates/skills/continuous-learning-v2/scripts/detect-project.sh +113 -19
- package/templates/skills/continuous-learning-v2/scripts/instinct-cli.py +558 -28
- package/templates/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh +31 -0
- package/templates/skills/continuous-learning-v2/scripts/migrate-homunculus.sh +68 -0
- package/templates/skills/continuous-learning-v2/scripts/test_parse_instinct.py +1420 -0
- package/templates/skills/harness-health-audit/SKILL.md +14 -2
- package/templates/skills/harness-health-audit/scripts/observation-digest.mjs +212 -0
- package/templates/skills/model-orchestration/SKILL.md +7 -0
- package/templates/skills/multi-persona-review/SKILL.md +22 -0
- package/templates/skills/recurrence-prevention/SKILL.md +4 -0
- package/templates/skills/strategic-compact/SKILL.md +26 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/bin/bash
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
2
|
# Continuous Learning v2 - Observation Hook
|
|
3
3
|
#
|
|
4
4
|
# Captures tool use events for pattern analysis.
|
|
@@ -12,8 +12,20 @@
|
|
|
12
12
|
|
|
13
13
|
set -e
|
|
14
14
|
|
|
15
|
-
# Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse)
|
|
16
|
-
|
|
15
|
+
# Hook phase from CLI argument: "pre" (PreToolUse) or "post" (PostToolUse).
|
|
16
|
+
# Manual settings.json installs can call this script without the plugin
|
|
17
|
+
# wrapper's positional phase argument, but Claude Code still exposes the hook
|
|
18
|
+
# event name in CLAUDE_HOOK_EVENT_NAME. Fall back to that env var before
|
|
19
|
+
# defaulting to post so manually registered PreToolUse hooks are recorded as
|
|
20
|
+
# tool_start instead of being silently misclassified as tool_complete.
|
|
21
|
+
HOOK_PHASE="${1:-}"
|
|
22
|
+
if [ -z "$HOOK_PHASE" ]; then
|
|
23
|
+
case "${CLAUDE_HOOK_EVENT_NAME:-}" in
|
|
24
|
+
PreToolUse|pretooluse|pre_tool_use|pre) HOOK_PHASE="pre" ;;
|
|
25
|
+
PostToolUse|posttooluse|post_tool_use|post) HOOK_PHASE="post" ;;
|
|
26
|
+
*) HOOK_PHASE="post" ;;
|
|
27
|
+
esac
|
|
28
|
+
fi
|
|
17
29
|
|
|
18
30
|
# ─────────────────────────────────────────────
|
|
19
31
|
# Read stdin first (before project detection)
|
|
@@ -27,18 +39,45 @@ if [ -z "$INPUT_JSON" ]; then
|
|
|
27
39
|
exit 0
|
|
28
40
|
fi
|
|
29
41
|
|
|
42
|
+
_is_windows_app_installer_stub() {
|
|
43
|
+
# Windows 10/11 ships an "App Execution Alias" stub at
|
|
44
|
+
# %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe
|
|
45
|
+
# %LOCALAPPDATA%\Microsoft\WindowsApps\python3.exe
|
|
46
|
+
# Both are symlinks to AppInstallerPythonRedirector.exe which, when Python
|
|
47
|
+
# is not installed from the Store, neither launches Python nor honors "-c".
|
|
48
|
+
# Calls to it hang or print a bare "Python " line, silently breaking every
|
|
49
|
+
# JSON-parsing step in this hook. Detect and skip such stubs here.
|
|
50
|
+
local _candidate="$1"
|
|
51
|
+
[ -z "$_candidate" ] && return 1
|
|
52
|
+
local _resolved
|
|
53
|
+
_resolved="$(command -v "$_candidate" 2>/dev/null || true)"
|
|
54
|
+
[ -z "$_resolved" ] && return 1
|
|
55
|
+
case "$_resolved" in
|
|
56
|
+
*AppInstallerPythonRedirector.exe|*AppInstallerPythonRedirector.EXE) return 0 ;;
|
|
57
|
+
esac
|
|
58
|
+
# Also resolve one level of symlink on POSIX-like shells (Git Bash, WSL).
|
|
59
|
+
if command -v readlink >/dev/null 2>&1; then
|
|
60
|
+
local _target
|
|
61
|
+
_target="$(readlink -f "$_resolved" 2>/dev/null || readlink "$_resolved" 2>/dev/null || true)"
|
|
62
|
+
case "$_target" in
|
|
63
|
+
*AppInstallerPythonRedirector.exe|*AppInstallerPythonRedirector.EXE) return 0 ;;
|
|
64
|
+
esac
|
|
65
|
+
fi
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
|
|
30
69
|
resolve_python_cmd() {
|
|
31
70
|
if [ -n "${CLV2_PYTHON_CMD:-}" ] && command -v "$CLV2_PYTHON_CMD" >/dev/null 2>&1; then
|
|
32
71
|
printf '%s\n' "$CLV2_PYTHON_CMD"
|
|
33
72
|
return 0
|
|
34
73
|
fi
|
|
35
74
|
|
|
36
|
-
if command -v python3 >/dev/null 2>&1; then
|
|
75
|
+
if command -v python3 >/dev/null 2>&1 && ! _is_windows_app_installer_stub python3; then
|
|
37
76
|
printf '%s\n' python3
|
|
38
77
|
return 0
|
|
39
78
|
fi
|
|
40
79
|
|
|
41
|
-
if command -v python >/dev/null 2>&1; then
|
|
80
|
+
if command -v python >/dev/null 2>&1 && ! _is_windows_app_installer_stub python; then
|
|
42
81
|
printf '%s\n' python
|
|
43
82
|
return 0
|
|
44
83
|
fi
|
|
@@ -52,6 +91,11 @@ if [ -z "$PYTHON_CMD" ]; then
|
|
|
52
91
|
exit 0
|
|
53
92
|
fi
|
|
54
93
|
|
|
94
|
+
# Propagate our stub-aware selection so detect-project.sh (which is sourced
|
|
95
|
+
# below) does not re-resolve and silently fall back to the App Installer stub.
|
|
96
|
+
# detect-project.sh honors an already-set CLV2_PYTHON_CMD.
|
|
97
|
+
export CLV2_PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}"
|
|
98
|
+
|
|
55
99
|
# ─────────────────────────────────────────────
|
|
56
100
|
# Extract cwd from stdin for project detection
|
|
57
101
|
# ─────────────────────────────────────────────
|
|
@@ -72,7 +116,13 @@ except(KeyError, TypeError, ValueError):
|
|
|
72
116
|
# If cwd was provided in stdin, use it for project detection
|
|
73
117
|
if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then
|
|
74
118
|
_GIT_ROOT=$(git -C "$STDIN_CWD" rev-parse --show-toplevel 2>/dev/null || true)
|
|
75
|
-
|
|
119
|
+
if [ -n "$_GIT_ROOT" ]; then
|
|
120
|
+
export CLAUDE_PROJECT_DIR="$_GIT_ROOT"
|
|
121
|
+
unset CLV2_NO_PROJECT
|
|
122
|
+
else
|
|
123
|
+
unset CLAUDE_PROJECT_DIR
|
|
124
|
+
export CLV2_NO_PROJECT=1
|
|
125
|
+
fi
|
|
76
126
|
fi
|
|
77
127
|
|
|
78
128
|
# ─────────────────────────────────────────────
|
|
@@ -83,7 +133,9 @@ fi
|
|
|
83
133
|
# Sourcing detect-project.sh creates project-scoped directories and updates
|
|
84
134
|
# projects.json, so automated sessions must return before that point.
|
|
85
135
|
|
|
86
|
-
|
|
136
|
+
# shellcheck disable=SC1091
|
|
137
|
+
. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh"
|
|
138
|
+
CONFIG_DIR="$(_clv2_resolve_homunculus_dir)"
|
|
87
139
|
|
|
88
140
|
# Skip if disabled (check both default and CLV2_CONFIG-derived locations)
|
|
89
141
|
if [ -f "$CONFIG_DIR/disabled" ]; then
|
|
@@ -103,7 +155,7 @@ fi
|
|
|
103
155
|
# Non-interactive SDK automation is still filtered by Layers 2-5 below
|
|
104
156
|
# (ECC_HOOK_PROFILE=minimal, ECC_SKIP_OBSERVE=1, agent_id, path exclusions).
|
|
105
157
|
case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
|
|
106
|
-
cli|sdk-ts) ;;
|
|
158
|
+
cli|sdk-ts|claude-desktop|claude-vscode) ;;
|
|
107
159
|
*) exit 0 ;;
|
|
108
160
|
esac
|
|
109
161
|
|
|
@@ -216,13 +268,26 @@ if [ "$PARSED_OK" != "True" ]; then
|
|
|
216
268
|
echo "$INPUT_JSON" | "$PYTHON_CMD" -c '
|
|
217
269
|
import json, sys, os, re
|
|
218
270
|
|
|
271
|
+
# Linear-time secret matcher. Bounded quantifiers and a fixed set of auth
|
|
272
|
+
# schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class)
|
|
273
|
+
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
|
|
219
274
|
_SECRET_RE = re.compile(
|
|
220
275
|
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
|
|
221
|
-
r"""(["'"'"'\s:=]
|
|
222
|
-
r"(
|
|
223
|
-
r"([A-Za-z0-9_\-/.+=]{8,})"
|
|
276
|
+
r"""(["'"'"'\s:=]{1,8})"""
|
|
277
|
+
r"((?:bearer|basic|token|bot)\s+)?"
|
|
278
|
+
r"([A-Za-z0-9_\-/.+=]{8,256})"
|
|
224
279
|
)
|
|
225
280
|
|
|
281
|
+
import signal
|
|
282
|
+
def _clv2_bail(*_):
|
|
283
|
+
print("[observe] SIGALRM timeout: parse-error fallback observation dropped before write (#2300)", file=sys.stderr)
|
|
284
|
+
sys.exit(0)
|
|
285
|
+
try:
|
|
286
|
+
signal.signal(signal.SIGALRM, _clv2_bail)
|
|
287
|
+
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
|
|
226
291
|
raw = sys.stdin.read()[:2000]
|
|
227
292
|
raw = _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", raw)
|
|
228
293
|
print(json.dumps({"timestamp": os.environ["TIMESTAMP"], "event": "parse_error", "raw": raw}))
|
|
@@ -250,6 +315,16 @@ export TIMESTAMP="$timestamp"
|
|
|
250
315
|
|
|
251
316
|
echo "$PARSED" | "$PYTHON_CMD" -c '
|
|
252
317
|
import json, sys, os, re
|
|
318
|
+
import signal
|
|
319
|
+
|
|
320
|
+
def _clv2_bail(*_):
|
|
321
|
+
print("[observe] SIGALRM timeout: in-flight observation dropped before write (#2300)", file=sys.stderr)
|
|
322
|
+
sys.exit(0)
|
|
323
|
+
try:
|
|
324
|
+
signal.signal(signal.SIGALRM, _clv2_bail)
|
|
325
|
+
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
253
328
|
|
|
254
329
|
parsed = json.load(sys.stdin)
|
|
255
330
|
observation = {
|
|
@@ -263,11 +338,14 @@ observation = {
|
|
|
263
338
|
|
|
264
339
|
# Scrub secrets: match common key=value, key: value, and key"value patterns
|
|
265
340
|
# Includes optional auth scheme (e.g., "Bearer", "Basic") before token
|
|
341
|
+
# Linear-time secret matcher. Bounded quantifiers and a fixed set of auth
|
|
342
|
+
# schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class)
|
|
343
|
+
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
|
|
266
344
|
_SECRET_RE = re.compile(
|
|
267
345
|
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
|
|
268
|
-
r"""(["'"'"'\s:=]
|
|
269
|
-
r"(
|
|
270
|
-
r"([A-Za-z0-9_\-/.+=]{8,})"
|
|
346
|
+
r"""(["'"'"'\s:=]{1,8})"""
|
|
347
|
+
r"((?:bearer|basic|token|bot)\s+)?"
|
|
348
|
+
r"([A-Za-z0-9_\-/.+=]{8,256})"
|
|
271
349
|
)
|
|
272
350
|
|
|
273
351
|
def scrub(val):
|
|
@@ -287,6 +365,19 @@ print(json.dumps(observation))
|
|
|
287
365
|
# Use flock for atomic check-then-act to prevent race conditions
|
|
288
366
|
# Fallback for macOS (no flock): use lockfile or skip
|
|
289
367
|
LAZY_START_LOCK="${PROJECT_DIR}/.observer-start.lock"
|
|
368
|
+
_REMOVE_FILE_IF_PRESENT() {
|
|
369
|
+
local target="$1"
|
|
370
|
+
if [ -n "$target" ] && [ -e "$target" ]; then
|
|
371
|
+
rm -- "$target" 2>/dev/null || true
|
|
372
|
+
fi
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
_START_OBSERVER_LOGGED() {
|
|
376
|
+
local bootstrap_log="${PROJECT_DIR}/observer-start.log"
|
|
377
|
+
mkdir -p "$PROJECT_DIR"
|
|
378
|
+
"${SKILL_ROOT}/agents/start-observer.sh" start >> "$bootstrap_log" 2>&1 || true
|
|
379
|
+
}
|
|
380
|
+
|
|
290
381
|
_CHECK_OBSERVER_RUNNING() {
|
|
291
382
|
local pid_file="$1"
|
|
292
383
|
if [ -f "$pid_file" ]; then
|
|
@@ -295,7 +386,7 @@ _CHECK_OBSERVER_RUNNING() {
|
|
|
295
386
|
# Validate PID is a positive integer (>1) to prevent signaling invalid targets
|
|
296
387
|
case "$pid" in
|
|
297
388
|
''|*[!0-9]*|0|1)
|
|
298
|
-
|
|
389
|
+
_REMOVE_FILE_IF_PRESENT "$pid_file"
|
|
299
390
|
return 1
|
|
300
391
|
;;
|
|
301
392
|
esac
|
|
@@ -303,7 +394,7 @@ _CHECK_OBSERVER_RUNNING() {
|
|
|
303
394
|
return 0 # Process is alive
|
|
304
395
|
fi
|
|
305
396
|
# Stale PID file - remove it
|
|
306
|
-
|
|
397
|
+
_REMOVE_FILE_IF_PRESENT "$pid_file"
|
|
307
398
|
fi
|
|
308
399
|
return 1 # No PID file or process dead
|
|
309
400
|
}
|
|
@@ -312,10 +403,12 @@ if [ -f "${CONFIG_DIR}/disabled" ]; then
|
|
|
312
403
|
OBSERVER_ENABLED=false
|
|
313
404
|
else
|
|
314
405
|
OBSERVER_ENABLED=false
|
|
315
|
-
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
|
316
|
-
# Allow CLV2_CONFIG override
|
|
317
406
|
if [ -n "${CLV2_CONFIG:-}" ]; then
|
|
318
407
|
CONFIG_FILE="$CLV2_CONFIG"
|
|
408
|
+
elif [ -f "${CONFIG_DIR}/config.json" ]; then
|
|
409
|
+
CONFIG_FILE="${CONFIG_DIR}/config.json"
|
|
410
|
+
else
|
|
411
|
+
CONFIG_FILE="${SKILL_ROOT}/config.json"
|
|
319
412
|
fi
|
|
320
413
|
# Use effective config path for both existence check and reading
|
|
321
414
|
EFFECTIVE_CONFIG="$CONFIG_FILE"
|
|
@@ -348,7 +441,7 @@ if [ "$OBSERVER_ENABLED" = "true" ]; then
|
|
|
348
441
|
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
|
|
349
442
|
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
|
|
350
443
|
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
|
|
351
|
-
|
|
444
|
+
_START_OBSERVER_LOGGED
|
|
352
445
|
fi
|
|
353
446
|
) 9>"$LAZY_START_LOCK"
|
|
354
447
|
else
|
|
@@ -356,14 +449,14 @@ if [ "$OBSERVER_ENABLED" = "true" ]; then
|
|
|
356
449
|
if command -v lockfile >/dev/null 2>&1; then
|
|
357
450
|
# Use subshell to isolate exit and add trap for cleanup
|
|
358
451
|
(
|
|
359
|
-
trap '
|
|
452
|
+
trap '_REMOVE_FILE_IF_PRESENT "$LAZY_START_LOCK"' EXIT
|
|
360
453
|
lockfile -r 1 -l 30 "$LAZY_START_LOCK" 2>/dev/null || exit 0
|
|
361
454
|
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
|
|
362
455
|
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
|
|
363
456
|
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
|
|
364
|
-
|
|
457
|
+
_START_OBSERVER_LOGGED
|
|
365
458
|
fi
|
|
366
|
-
|
|
459
|
+
_REMOVE_FILE_IF_PRESENT "$LAZY_START_LOCK"
|
|
367
460
|
)
|
|
368
461
|
else
|
|
369
462
|
# POSIX fallback: mkdir is atomic -- fails if dir already exists
|
|
@@ -373,7 +466,7 @@ if [ "$OBSERVER_ENABLED" = "true" ]; then
|
|
|
373
466
|
_CHECK_OBSERVER_RUNNING "${PROJECT_DIR}/.observer.pid" || true
|
|
374
467
|
_CHECK_OBSERVER_RUNNING "${CONFIG_DIR}/.observer.pid" || true
|
|
375
468
|
if [ ! -f "${PROJECT_DIR}/.observer.pid" ] && [ ! -f "${CONFIG_DIR}/.observer.pid" ]; then
|
|
376
|
-
|
|
469
|
+
_START_OBSERVER_LOGGED
|
|
377
470
|
fi
|
|
378
471
|
)
|
|
379
472
|
fi
|
|
@@ -386,21 +479,82 @@ fi
|
|
|
386
479
|
# which caused runaway parallel Claude analysis processes.
|
|
387
480
|
SIGNAL_EVERY_N="${ECC_OBSERVER_SIGNAL_EVERY_N:-20}"
|
|
388
481
|
SIGNAL_COUNTER_FILE="${PROJECT_DIR}/.observer-signal-counter"
|
|
482
|
+
SIGNAL_COUNTER_LOCK="${SIGNAL_COUNTER_FILE}.lock"
|
|
389
483
|
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
|
|
390
484
|
|
|
391
485
|
touch "$ACTIVITY_FILE" 2>/dev/null || true
|
|
392
486
|
|
|
487
|
+
# Serialize the throttle-counter read-modify-write. observe.sh runs on every
|
|
488
|
+
# tool call (which can fire every second), so concurrent invocations previously
|
|
489
|
+
# raced on this counter: both read the same value, both incremented, and one
|
|
490
|
+
# write was lost, signaling the observer at unpredictable intervals (#2296).
|
|
491
|
+
# Prefer flock (a kernel advisory lock the OS releases automatically if the hook
|
|
492
|
+
# is killed); fall back to the atomic mkdir lock this script already uses for
|
|
493
|
+
# the lazy-start path above. Both wrap the same read-modify-write below.
|
|
393
494
|
should_signal=0
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
495
|
+
|
|
496
|
+
_clv2_bump_signal_counter() {
|
|
497
|
+
if [ -f "$SIGNAL_COUNTER_FILE" ]; then
|
|
498
|
+
counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0)
|
|
499
|
+
# Guard against a corrupt counter file: a non-integer value would abort the
|
|
500
|
+
# hook under `set -e` at the arithmetic below.
|
|
501
|
+
case "$counter" in
|
|
502
|
+
''|*[!0-9]*) counter=0 ;;
|
|
503
|
+
esac
|
|
504
|
+
counter=$((counter + 1))
|
|
505
|
+
if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then
|
|
506
|
+
should_signal=1
|
|
507
|
+
counter=0
|
|
508
|
+
fi
|
|
509
|
+
echo "$counter" > "$SIGNAL_COUNTER_FILE"
|
|
510
|
+
else
|
|
511
|
+
echo "1" > "$SIGNAL_COUNTER_FILE"
|
|
400
512
|
fi
|
|
401
|
-
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null; then
|
|
516
|
+
# flock is auto-released when fd 8 closes or the process dies, so there is no
|
|
517
|
+
# stale lock and no lost increment. Use a bounded -w wait so the hook never
|
|
518
|
+
# blocks indefinitely, and only bump the counter while the lock is held -- on
|
|
519
|
+
# a timeout we skip the tick rather than doing an unlocked read-modify-write.
|
|
520
|
+
if flock -w 2 8 2>/dev/null; then
|
|
521
|
+
_clv2_bump_signal_counter
|
|
522
|
+
flock -u 8 2>/dev/null || true
|
|
523
|
+
fi
|
|
524
|
+
exec 8>&- 2>/dev/null || true
|
|
402
525
|
else
|
|
403
|
-
|
|
526
|
+
# No flock (e.g. macOS): atomic mkdir lock with a bounded spin so the hook
|
|
527
|
+
# never blocks indefinitely. A trap releases the lock on every exit path --
|
|
528
|
+
# including the async-timeout SIGTERM -- so a killed hook does not strand the
|
|
529
|
+
# directory. We deliberately do NOT hand-roll PID-based stale reclaim:
|
|
530
|
+
# re-verifying then removing another process's lock is racy and can delete a
|
|
531
|
+
# live re-acquirer's directory, reintroducing the very race this fixes.
|
|
532
|
+
_signal_lock_held=0
|
|
533
|
+
_signal_lock_spins=0
|
|
534
|
+
while [ "$_signal_lock_spins" -lt 100 ]; do
|
|
535
|
+
if mkdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null; then
|
|
536
|
+
# EXIT cleans up on normal completion. INT/TERM must release AND exit:
|
|
537
|
+
# a signal trap that only released the lock would otherwise fall through
|
|
538
|
+
# and continue the read-modify-write without ownership.
|
|
539
|
+
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true' EXIT
|
|
540
|
+
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 130' INT
|
|
541
|
+
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 143' TERM
|
|
542
|
+
_signal_lock_held=1
|
|
543
|
+
break
|
|
544
|
+
fi
|
|
545
|
+
_signal_lock_spins=$((_signal_lock_spins + 1))
|
|
546
|
+
sleep 0.02
|
|
547
|
+
done
|
|
548
|
+
if [ "$_signal_lock_held" -eq 1 ]; then
|
|
549
|
+
# Bump only under the held lock -- never an unlocked read-modify-write.
|
|
550
|
+
_clv2_bump_signal_counter
|
|
551
|
+
rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true
|
|
552
|
+
trap - EXIT INT TERM
|
|
553
|
+
fi
|
|
554
|
+
# If the lock could not be acquired within the spin budget we skip this tick
|
|
555
|
+
# rather than racing on an unlocked counter. Dropping one throttle tick under
|
|
556
|
+
# extreme contention only delays the next observer signal slightly; it never
|
|
557
|
+
# corrupts the counter or signals spuriously.
|
|
404
558
|
fi
|
|
405
559
|
|
|
406
560
|
# Signal observer if running and throttle allows (check both project-scoped and global observer, deduplicate)
|
|
@@ -411,7 +565,10 @@ if [ "$should_signal" -eq 1 ]; then
|
|
|
411
565
|
observer_pid=$(cat "$pid_file" 2>/dev/null || true)
|
|
412
566
|
# Validate PID is a positive integer (>1)
|
|
413
567
|
case "$observer_pid" in
|
|
414
|
-
''|*[!0-9]*|0|1)
|
|
568
|
+
''|*[!0-9]*|0|1)
|
|
569
|
+
_REMOVE_FILE_IF_PRESENT "$pid_file"
|
|
570
|
+
continue
|
|
571
|
+
;;
|
|
415
572
|
esac
|
|
416
573
|
# Deduplicate: skip if already signaled this pass
|
|
417
574
|
case "$signaled_pids" in
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/bin/bash
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
2
|
# Continuous Learning v2 - Project Detection Helper
|
|
3
3
|
#
|
|
4
4
|
# Shared logic for detecting current project context.
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
# 3. git repo root path (fallback, machine-specific)
|
|
20
20
|
# 4. "global" (no project context detected)
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# shellcheck disable=SC1091
|
|
23
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh"
|
|
24
|
+
_CLV2_HOMUNCULUS_DIR="$(_clv2_resolve_homunculus_dir)"
|
|
23
25
|
_CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
|
|
24
26
|
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"
|
|
25
27
|
|
|
@@ -49,16 +51,66 @@ export CLV2_PYTHON_CMD
|
|
|
49
51
|
CLV2_OBSERVER_PROMPT_PATTERN='Can you confirm|requires permission|Awaiting (user confirmation|confirmation|approval|permission)|confirm I should proceed|once granted access|grant.*access'
|
|
50
52
|
export CLV2_OBSERVER_PROMPT_PATTERN
|
|
51
53
|
|
|
54
|
+
_clv2_normalize_remote_url() {
|
|
55
|
+
local url="$1"
|
|
56
|
+
[ -z "$url" ] && return 0
|
|
57
|
+
|
|
58
|
+
local is_network=0
|
|
59
|
+
case "$url" in
|
|
60
|
+
file://*) is_network=0 ;;
|
|
61
|
+
*://*) is_network=1 ;;
|
|
62
|
+
*@*:*) is_network=1 ;;
|
|
63
|
+
*) is_network=0 ;;
|
|
64
|
+
esac
|
|
65
|
+
|
|
66
|
+
url=$(printf '%s' "$url" | sed -E 's|://[^@]+@|://|')
|
|
67
|
+
url=$(printf '%s' "$url" | sed -E 's|^[A-Za-z][A-Za-z0-9+.-]*://||')
|
|
68
|
+
url=$(printf '%s' "$url" | sed -E 's|^[^@/:]+@([^:/]+):|\1/|')
|
|
69
|
+
url=$(printf '%s' "$url" | sed -E 's|\.git/?$||; s|/+$||')
|
|
70
|
+
|
|
71
|
+
if [ "$is_network" = "1" ]; then
|
|
72
|
+
printf '%s' "$url" | tr '[:upper:]' '[:lower:]'
|
|
73
|
+
else
|
|
74
|
+
printf '%s' "$url"
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_clv2_main_worktree_root() {
|
|
79
|
+
local root="$1"
|
|
80
|
+
[ -z "$root" ] && return 0
|
|
81
|
+
command -v git >/dev/null 2>&1 || return 0
|
|
82
|
+
|
|
83
|
+
git -C "$root" worktree list --porcelain 2>/dev/null | while IFS= read -r line; do
|
|
84
|
+
case "$line" in
|
|
85
|
+
worktree\ *)
|
|
86
|
+
printf '%s\n' "${line#worktree }"
|
|
87
|
+
break
|
|
88
|
+
;;
|
|
89
|
+
esac
|
|
90
|
+
done
|
|
91
|
+
}
|
|
92
|
+
|
|
52
93
|
_clv2_detect_project() {
|
|
53
94
|
local project_root=""
|
|
54
95
|
local project_name=""
|
|
55
96
|
local project_id=""
|
|
56
97
|
local source_hint=""
|
|
57
98
|
|
|
99
|
+
if [ "${CLV2_NO_PROJECT:-0}" = "1" ]; then
|
|
100
|
+
_CLV2_PROJECT_ID="global"
|
|
101
|
+
_CLV2_PROJECT_NAME="global"
|
|
102
|
+
_CLV2_PROJECT_ROOT=""
|
|
103
|
+
_CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}"
|
|
104
|
+
mkdir -p "$_CLV2_PROJECT_DIR"
|
|
105
|
+
return 0
|
|
106
|
+
fi
|
|
107
|
+
|
|
58
108
|
# 1. Try CLAUDE_PROJECT_DIR env var
|
|
59
|
-
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ]; then
|
|
60
|
-
project_root
|
|
61
|
-
|
|
109
|
+
if [ -n "$CLAUDE_PROJECT_DIR" ] && [ -d "$CLAUDE_PROJECT_DIR" ] && command -v git &>/dev/null; then
|
|
110
|
+
project_root=$(git -C "$CLAUDE_PROJECT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
|
|
111
|
+
if [ -n "$project_root" ]; then
|
|
112
|
+
source_hint="env"
|
|
113
|
+
fi
|
|
62
114
|
fi
|
|
63
115
|
|
|
64
116
|
# 2. Try git repo root from CWD (only if git is available)
|
|
@@ -75,11 +127,16 @@ _clv2_detect_project() {
|
|
|
75
127
|
_CLV2_PROJECT_NAME="global"
|
|
76
128
|
_CLV2_PROJECT_ROOT=""
|
|
77
129
|
_CLV2_PROJECT_DIR="${_CLV2_HOMUNCULUS_DIR}"
|
|
130
|
+
mkdir -p "$_CLV2_PROJECT_DIR"
|
|
78
131
|
return 0
|
|
79
132
|
fi
|
|
80
133
|
|
|
81
134
|
# Derive project name from directory basename
|
|
82
|
-
|
|
135
|
+
# Normalize Windows backslashes so basename works when CLAUDE_PROJECT_DIR
|
|
136
|
+
# is passed as e.g. C:\Users\...\project.
|
|
137
|
+
local _norm_root
|
|
138
|
+
_norm_root=$(printf '%s' "$project_root" | sed 's|\\|/|g')
|
|
139
|
+
project_name=$(basename "$_norm_root")
|
|
83
140
|
|
|
84
141
|
# Derive project ID: prefer git remote URL hash (portable across machines),
|
|
85
142
|
# fall back to path hash (machine-specific but still useful)
|
|
@@ -90,18 +147,37 @@ _clv2_detect_project() {
|
|
|
90
147
|
fi
|
|
91
148
|
fi
|
|
92
149
|
|
|
93
|
-
|
|
94
|
-
local legacy_hash_input="${remote_url:-$project_root}"
|
|
150
|
+
local raw_remote_url="$remote_url"
|
|
95
151
|
|
|
96
152
|
# Strip embedded credentials from remote URL (e.g., https://ghp_xxxx@github.com/...)
|
|
97
153
|
if [ -n "$remote_url" ]; then
|
|
98
154
|
remote_url=$(printf '%s' "$remote_url" | sed -E 's|://[^@]+@|://|')
|
|
99
155
|
fi
|
|
100
156
|
|
|
101
|
-
local
|
|
157
|
+
local legacy_hash_input="${remote_url:-$project_root}"
|
|
158
|
+
local normalized_remote=""
|
|
159
|
+
if [ -n "$remote_url" ]; then
|
|
160
|
+
normalized_remote=$(_clv2_normalize_remote_url "$remote_url")
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
local fallback_root="$project_root"
|
|
164
|
+
if [ -z "$remote_url" ]; then
|
|
165
|
+
local main_worktree_root
|
|
166
|
+
main_worktree_root=$(_clv2_main_worktree_root "$project_root")
|
|
167
|
+
[ -n "$main_worktree_root" ] && fallback_root="$main_worktree_root"
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
local hash_input="${normalized_remote:-${remote_url:-$fallback_root}}"
|
|
102
171
|
# Prefer Python for consistent SHA256 behavior across shells/platforms.
|
|
172
|
+
# Pass the value via env var and encode as UTF-8 inside Python so the hash
|
|
173
|
+
# is locale-independent (shells vary between UTF-8 / CP932 / CP1252, which
|
|
174
|
+
# would otherwise produce different hashes for the same non-ASCII path).
|
|
103
175
|
if [ -n "$_CLV2_PYTHON_CMD" ]; then
|
|
104
|
-
project_id=$(
|
|
176
|
+
project_id=$(_CLV2_HASH_INPUT="$hash_input" "$_CLV2_PYTHON_CMD" -c '
|
|
177
|
+
import os, hashlib
|
|
178
|
+
s = os.environ["_CLV2_HASH_INPUT"]
|
|
179
|
+
print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
|
|
180
|
+
' 2>/dev/null)
|
|
105
181
|
fi
|
|
106
182
|
|
|
107
183
|
# Fallback if Python is unavailable or hash generation failed.
|
|
@@ -111,15 +187,33 @@ _clv2_detect_project() {
|
|
|
111
187
|
echo "fallback")
|
|
112
188
|
fi
|
|
113
189
|
|
|
114
|
-
# Backward compatibility:
|
|
115
|
-
#
|
|
116
|
-
if [
|
|
117
|
-
local
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
190
|
+
# Backward compatibility: migrate a single legacy project directory from
|
|
191
|
+
# credential-stripped or raw remote hashes to the normalized remote hash.
|
|
192
|
+
if [ -n "$_CLV2_PYTHON_CMD" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
|
|
193
|
+
local legacy_inputs=()
|
|
194
|
+
[ -n "$legacy_hash_input" ] && [ "$legacy_hash_input" != "$hash_input" ] \
|
|
195
|
+
&& legacy_inputs+=("$legacy_hash_input")
|
|
196
|
+
[ -n "$raw_remote_url" ] && [ "$raw_remote_url" != "$hash_input" ] \
|
|
197
|
+
&& [ "$raw_remote_url" != "$legacy_hash_input" ] \
|
|
198
|
+
&& legacy_inputs+=("$raw_remote_url")
|
|
199
|
+
|
|
200
|
+
local legacy_input legacy_id
|
|
201
|
+
for legacy_input in "${legacy_inputs[@]}"; do
|
|
202
|
+
legacy_id=$(_CLV2_HASH_INPUT="$legacy_input" "$_CLV2_PYTHON_CMD" -c '
|
|
203
|
+
import os, hashlib
|
|
204
|
+
s = os.environ["_CLV2_HASH_INPUT"]
|
|
205
|
+
print(hashlib.sha256(s.encode("utf-8")).hexdigest()[:12])
|
|
206
|
+
' 2>/dev/null)
|
|
207
|
+
if [ -n "$legacy_id" ] && [ "$legacy_id" != "$project_id" ] \
|
|
208
|
+
&& [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ]; then
|
|
209
|
+
if mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null; then
|
|
210
|
+
break
|
|
211
|
+
else
|
|
212
|
+
project_id="$legacy_id"
|
|
213
|
+
break
|
|
214
|
+
fi
|
|
215
|
+
fi
|
|
216
|
+
done
|
|
123
217
|
fi
|
|
124
218
|
|
|
125
219
|
# Export results
|