@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.
Files changed (30) hide show
  1. package/README.ko.md +12 -0
  2. package/README.md +9 -0
  3. package/dist/{chunk-2VII24JD.js → chunk-ZSLMD5SP.js} +10 -4
  4. package/dist/{chunk-2VII24JD.js.map → chunk-ZSLMD5SP.js.map} +1 -1
  5. package/dist/index.js +737 -220
  6. package/dist/index.js.map +1 -1
  7. package/dist/trust-tier-drift.js +1 -1
  8. package/package.json +1 -1
  9. package/templates/hooks/session-start.sh +24 -3
  10. package/templates/hooks/spec-drift-check.sh +24 -1
  11. package/templates/rules/cli-development.md +16 -0
  12. package/templates/rules/git-policy.md +4 -0
  13. package/templates/rules/no-false-ship.md +55 -0
  14. package/templates/skills/continuous-learning-v2/SKILL.md +25 -29
  15. package/templates/skills/continuous-learning-v2/agents/observer-loop.sh +362 -0
  16. package/templates/skills/continuous-learning-v2/agents/observer.md +189 -0
  17. package/templates/skills/continuous-learning-v2/agents/session-guardian.sh +150 -0
  18. package/templates/skills/continuous-learning-v2/agents/start-observer.sh +252 -0
  19. package/templates/skills/continuous-learning-v2/hooks/observe.sh +189 -32
  20. package/templates/skills/continuous-learning-v2/scripts/detect-project.sh +113 -19
  21. package/templates/skills/continuous-learning-v2/scripts/instinct-cli.py +558 -28
  22. package/templates/skills/continuous-learning-v2/scripts/lib/homunculus-dir.sh +31 -0
  23. package/templates/skills/continuous-learning-v2/scripts/migrate-homunculus.sh +68 -0
  24. package/templates/skills/continuous-learning-v2/scripts/test_parse_instinct.py +1420 -0
  25. package/templates/skills/harness-health-audit/SKILL.md +14 -2
  26. package/templates/skills/harness-health-audit/scripts/observation-digest.mjs +212 -0
  27. package/templates/skills/model-orchestration/SKILL.md +7 -0
  28. package/templates/skills/multi-persona-review/SKILL.md +22 -0
  29. package/templates/skills/recurrence-prevention/SKILL.md +4 -0
  30. 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
- HOOK_PHASE="${1:-post}"
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
- export CLAUDE_PROJECT_DIR="${_GIT_ROOT:-$STDIN_CWD}"
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
- CONFIG_DIR="${HOME}/.claude/homunculus"
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"([A-Za-z]+\s+)?"
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"([A-Za-z]+\s+)?"
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
- rm -f "$pid_file" 2>/dev/null || true
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
- rm -f "$pid_file" 2>/dev/null || true
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
- nohup "${SKILL_ROOT}/agents/start-observer.sh" start >/dev/null 2>&1 &
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 'rm -f "$LAZY_START_LOCK" 2>/dev/null || true' EXIT
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
- nohup "${SKILL_ROOT}/agents/start-observer.sh" start >/dev/null 2>&1 &
457
+ _START_OBSERVER_LOGGED
365
458
  fi
366
- rm -f "$LAZY_START_LOCK" 2>/dev/null || true
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
- nohup "${SKILL_ROOT}/agents/start-observer.sh" start >/dev/null 2>&1 &
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
- if [ -f "$SIGNAL_COUNTER_FILE" ]; then
395
- counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0)
396
- counter=$((counter + 1))
397
- if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then
398
- should_signal=1
399
- counter=0
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
- echo "$counter" > "$SIGNAL_COUNTER_FILE"
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
- echo "1" > "$SIGNAL_COUNTER_FILE"
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) rm -f "$pid_file" 2>/dev/null || true; continue ;;
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
- _CLV2_HOMUNCULUS_DIR="${HOME}/.claude/homunculus"
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="$CLAUDE_PROJECT_DIR"
61
- source_hint="env"
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
- project_name=$(basename "$project_root")
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
- # Compute hash from the original remote URL (legacy, for backward compatibility)
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 hash_input="${remote_url:-$project_root}"
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=$(printf '%s' "$hash_input" | "$_CLV2_PYTHON_CMD" -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null)
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: if credentials were stripped and the hash changed,
115
- # check if a project dir exists under the legacy hash and reuse it
116
- if [ "$legacy_hash_input" != "$hash_input" ] && [ -n "$_CLV2_PYTHON_CMD" ]; then
117
- local legacy_id=""
118
- legacy_id=$(printf '%s' "$legacy_hash_input" | "$_CLV2_PYTHON_CMD" -c "import sys,hashlib; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest()[:12])" 2>/dev/null)
119
- if [ -n "$legacy_id" ] && [ -d "${_CLV2_PROJECTS_DIR}/${legacy_id}" ] && [ ! -d "${_CLV2_PROJECTS_DIR}/${project_id}" ]; then
120
- # Migrate legacy directory to new hash
121
- mv "${_CLV2_PROJECTS_DIR}/${legacy_id}" "${_CLV2_PROJECTS_DIR}/${project_id}" 2>/dev/null || project_id="$legacy_id"
122
- fi
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