@kaydash9999/banker-plugins 0.21.0 → 0.22.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -0
  4. package/PRIVACY.md +7 -3
  5. package/README.md +41 -4
  6. package/bin/banker.js +38 -12
  7. package/bin/lib/codex-hooks.mjs +7 -2
  8. package/bin/lib/codex-routes.mjs +112 -0
  9. package/bin/lib/command-skill.cjs +19 -0
  10. package/bin/lib/telemetry-config.mjs +2 -1
  11. package/codex/command-skills/front-qa/SKILL.md +56 -0
  12. package/codex/command-skills/setup/SKILL.md +87 -0
  13. package/codex/hooks.json +29 -0
  14. package/codex/skills/graceful-pause/SKILL.md +2 -2
  15. package/commands/setup.md +2 -2
  16. package/hooks/telemetry-count-codex.mjs +112 -25
  17. package/hooks/update-notify.mjs +82 -11
  18. package/package.json +1 -1
  19. package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
  20. package/skills/anti-drm/SKILL.md +1 -1
  21. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  22. package/skills/arch-diagram/build_pptx_template.py +47 -28
  23. package/skills/audit-web-page/SKILL.md +1 -1
  24. package/skills/audit-web-page/harness.py +0 -1
  25. package/skills/lineage/lineage.py +1435 -533
  26. package/skills/motion-graphic-make/SKILL.md +1 -1
  27. package/skills/obsidizer/SKILL.md +20 -5
  28. package/skills/payload-mon/SKILL.md +1 -1
  29. package/skills/play-qa/SKILL.md +1 -1
  30. package/skills/ralph-qa/SKILL.md +1 -1
  31. package/skills/remains/SKILL.md +1 -1
  32. package/skills/rfp-author/scripts/md2docx.py +63 -34
  33. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  34. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  35. package/skills/smart-compact/SKILL.md +1 -1
  36. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  37. package/skills/theme-selection/SKILL.md +1 -1
  38. package/skills/tone-compact/SKILL.md +1 -1
  39. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  40. package/skills/ultra-chain/SKILL.md +2 -2
  41. package/skills/ultra-ui-qa/SKILL.md +1 -1
  42. package/skills/update-banker/SKILL.md +31 -6
  43. package/skills/vertical-pptx/SKILL.md +1 -1
  44. package/skills/vertical-pptx/build_a4p.js +20 -2
  45. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -0,0 +1,87 @@
1
+ ---
2
+ name: setup
3
+ description: "구성요소와 의존성을 여러 개 골라 설치하는 오케스트레이터. 런타임(Node, Python, Java), LSP, tmux, MCP, 샌드박스, OMC, Playwright 등. 권한 확인 끄기는 따로 묻고 기본으로 고르지 않으며, 골라도 실행하지 않고 직접 입력할 명령만 안내."
4
+ ---
5
+
6
+ > Codex: 이 명령은 스킬로 설치됐다. 본문의 `$ARGUMENTS`, `$1` 은 사용자가 `$banker:setup` 뒤에 쓴 글이다.
7
+
8
+
9
+ # /banker:setup — 구성요소 설치 오케스트레이터
10
+
11
+ banker 플러그인이 제공하는 설치 스킬들을 **multi-select**로 골라 실행한다.
12
+ 기존 스킬들의 OS 의존성(playwright 등)을 해결하기 위함. 답변은 한글(기술 토큰 영문).
13
+
14
+ ## 절차
15
+
16
+ 0. **네트워크 확인**: 항목을 고르기 전에 원천에 닿는지 한 번 본다(curl 은 10초, npm 은 15초 안). 결과는 알림으로만 쓰고, 이것만으로 항목을 건너뛰지 않는다.
17
+ ```bash
18
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
19
+ in15() { "$@" & p=$!; ( sleep 15 && kill "$p" ) > /dev/null 2>&1 & k=$!; wait "$p" 2>/dev/null; r=$?; kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; [ "$r" = 143 ] && echo "15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)"; return "$r"; }
20
+ if command -v npm >/dev/null; then in15 sh -c 'cd ~ && exec npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier'; else echo "npm 없음(확인하지 못함)"; fi
21
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com
22
+ ```
23
+ - `curl -sSI` 는 머리만 받는다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
24
+ - 둘 다 실패하면 "네트워크가 없거나 막힌 것으로 보임. 항목마다 자기 원천을 다시 확인하고, 닿지 않는 원천에서 받는 단계는 하지 않음" 으로 먼저 알린다.
25
+ - 항목을 건너뛸지는 각 스킬의 0단계가 그 스킬의 원천으로 정한다. 사내 미러만 열린 망(`npm ping` 에 답하지 않는 npm 미러, PyPI 미러)에서도 그 원천을 쓰는 항목은 진행된다. 닿지 않는 원천에서 받는 단계만 "오프라인이라 설치하지 않음(필요한 것: <항목>)" 으로 보고된다.
26
+ - `npm` 이 아직 없으면(새 PC) npm 쪽은 오프라인이 아니라 확인하지 못한 것이다. `setup-node` 가 npm 을 깐 뒤 각 스킬의 0단계가 다시 확인한다.
27
+ - `npm ping` 은 홈 폴더에서 부른다. 지금 폴더에 있는 프로젝트의 `.npmrc` 가 정한 레지스트리와 토큰을 따르지 않게 하려는 것이다.
28
+ - `npm ping` 은 15초 감시(`in15`) 안에서 부른다. 패킷을 버리는 망에서 npm 은 연결에 시간 제한을 두지 않아(`--fetch-timeout` 은 받는 중의 쉼만 잰다) OS 의 연결 재시도가 끝날 때까지 기다린다(Rocky 8.10 실측 130초). 15초 안에 끝나지 않으면 `15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)` 과 종료 143 이다. `--no-update-notifier` 는 npm 이 제 판을 묻는 요청을 하나 더 내지 않게 한다.
29
+ - OS 패키지 관리자(dnf, apt, brew, winget)만 쓰는 단계는 자기 설정의 미러를 쓰므로 이 확인과 관계없이 각 스킬의 규칙대로 시도한다.
30
+ - 패킷을 버리거나 답하지 않는 망에서 dnf 는 저장소마다 약 2분(SYN 을 버리는 망 실측 121초), apt 는 원천마다 약 4분(답하지 않는 원천 실측 247초) 기다린 뒤 실패로 끝난다. 시간 제한은 관리자 설정을 따르므로 이 스킬이 바꾸지 않는다.
31
+ - `apt-get update` 는 받지 못해도 종료 0 이다. 출력의 `W: Failed to fetch` 가 있으면 그 단계를 실패로 보고한다.
32
+ - 내려받기가 없는 항목(`setup-omc-hud`)과 이미 설치된 것의 확인은 그대로 진행한다.
33
+ - Windows PowerShell 에서는 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 https://github.com` 의 `$LASTEXITCODE` 로 본다. `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다. `npm ping` 은 `Push-Location ~; npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier; Pop-Location` 처럼 홈에서 부르고 돌아온다(돌아오지 않으면 뒤 단계가 프로젝트가 아니라 홈에서 돈다). PowerShell 에는 15초 감시를 두지 않는다(Windows 의 연결 시간 제한은 약 21초로 알려져 있다. 미실측).
34
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
35
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
36
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
37
+ - curl 은 닿는데 npm 만 인증서 오류(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`)로 실패하면 망은 열려 있고 npm 이 사내 인증서를 믿지 않는 것이다(npm 은 OS 인증서 저장소를 쓰지 않는다). '인증서 문제로 설치하지 않음' 으로 보고하고 `NODE_EXTRA_CA_CERTS=<사내 CA 파일>` 을 안내한다. Node 24 는 `NODE_OPTIONS=--use-system-ca` 도 된다.
38
+ - 이 실행에서 부르는 스킬의 0단계는 /banker:setup 0단계와 앞 스킬이 이미 돌린 같은 확인 줄(같은 명령과 같은 주소)의 결과를 쓰고 다시 돌리지 않는다. 처음 보는 원천만 새로 본다. 결과는 이 실행 안에서만 쓴다(그 사이 망이 바뀌면 옛 결과일 수 있다). 막힌 망에서 항목마다 같은 확인을 다시 기다리지 않게 하려는 것이다.
39
+ 1. **선택 받기**: `$ARGUMENTS` 에 컴포넌트명이 명시돼 있으면 그것만 실행(질문 생략).
40
+ 비어 있으면 아래를 `AskUserQuestion`(**multiSelect: true**)으로 제시한다(**Codex 런타임엔 AskUserQuestion 이 없으므로 목록을 제시하고 설치할 항목을 사용자에게 물어 받는다**). 항목은 **계층/의존 순서**로 묶여 있으니, 뒤 계층을 고르면 앞 계층 전제를 먼저 채운다:
41
+ - 질문 하나에 선택지는 4개까지다. 5개 이상인 묶음은 두 질문으로 나눈다.
42
+ - 맨 아래 **권한** 항목은 이 다중 선택에 넣지 않고, 그 항목의 규칙대로 따로 묻는다.
43
+
44
+ **런타임 (기반 — 먼저)**
45
+ - **setup-node** — nvm + Node 22(winget/nvm-windows). npx 기반 MCP·CLI 전제.
46
+ - **setup-python** — Python 3.11 + pipx + uv(PEP668). `setup-lsp`·`setup-mcp`·`docs-setup` 전제.
47
+ - **setup-java** — JDK 21 + JAVA_HOME(Debian=Adoptium). `setup-lsp` 의 jdtls 요구.
48
+
49
+ **개발도구**
50
+ - **setup-lsp** — 언어별 LSP(vtsls·basedpyright·bash·jdtls·spring) + lsp-mcp 브리지. (의존: node·python·java)
51
+ - **setup-tmux** — tmux(Rocky8 3.6a 소스빌드)/psmux(Windows). OMC team·OMX `$team`/HUD 전제.
52
+ - **setup-pwsh** — Windows PowerShell 7 환경($PROFILE UTF-8·Terminal·Git). 비-Windows는 no-op.
53
+ - **setup-mcp** — 공통 MCP 서버(context7·seq-thinking·filesystem·git·fetch). (의존: node·python)
54
+ - **setup-sandbox** — OS별 샌드박스(bubblewrap+userns / AppContainer / Seatbelt) + rust + git safe.directory.
55
+
56
+ **프레임워크·플러그인**
57
+ - **setup-omc** — oh-my-claudecode(OMC) 설치/갱신(Codex는 OMX). `all-in-one`·`ultra-chain`·`ultra-init`·`/banker:front-qa` 의존성.
58
+ - **setup-harness-factory** — revfactory/harness(팀 아키텍처 팩토리) 설치+구성+안내(Codex=meta-harness).
59
+ - **setup-insane-search** — insane-search 플러그인(차단 사이트 우회, Claude/Codex 양쪽).
60
+ - **setup-omc-hud** — omc_hud 상태표시줄(래퍼 v4, 스킬에 든 파일로 오프라인 설치).
61
+ - **setup-stitch** — Stitch(디자인 생성) MCP 프록시 등록(RockyLinux8 proxy-script, API key 필요).
62
+
63
+ **의존성 라이브러리**
64
+ - **setup-playwright** — Playwright + 브라우저 + Xvfb(RHEL8/Rocky8 폴백). (`ultra-ui-qa`·`audit-web-page`·`play-qa`·`ultra-chain` 의존성)
65
+ - **docs-setup** — arch-diagram·pdf-vision-extract 의존성(python-pptx·pymupdf·plantuml). (의존: python)
66
+ - **motion-graphic-setup** — hyperframes(무료 모션 그래픽 CLI) 전제조건(Node≥22 + ffmpeg → `npx hyperframes`). (`motion-graphic-make` 의존성)
67
+ - **3d-intro-setup** — 3D 인트로용 크레덴셜(Azure gpt-image·Sora-2, 선택 WAN 영상 키 풀)·의존성(Node/ffmpeg) + 무과금 프리플라이트. (`3d-intro-build` 의존성)
68
+
69
+ **권한 (주의, 기본 미선택)**
70
+ - **setup-bypass-permissions** — Claude Code 의 기본 권한 모드를 `bypassPermissions` 로 바꿔 모든 세션에서 도구 실행 확인을 끔.
71
+ - 위 묶음과 섞지 않고 단일 선택 질문 하나로 따로 묻는다(`multiSelect: false`). 선택지는 `넣지 않음 (권장)`, `넣기 (위험)` 순서다.
72
+ - 사용자가 이 항목을 직접 고를 때만 넣는다. "전부" 같은 묶음 선택이나 번호 범위에는 들어가지 않는다.
73
+ - 이 스킬은 모델이 호출할 수 없다(`disable-model-invocation`). `넣기` 를 고르면 실행하지 말고, 사용자에게 `/banker:setup-bypass-permissions on` 을 직접 입력하라고 안내한다. Codex 는 `$banker-setup-bypass-permissions`(플러그인 설치면 `$banker:setup-bypass-permissions`)를 직접 부르라고 안내한다.
74
+ - 입력하면 그 스킬이 위험 경고를 보여 주고 한 번 더 확인받는다. 되돌리기는 `off`.
75
+
76
+ 2. **실행**: 선택된 각 항목을 호출한다(미선택은 건드리지 않음). 단 `setup-bypass-permissions` 는 호출하지 않는다(위 권한 항목의 안내만). Claude Code는 `Skill("banker:<name>")`, **Codex 런타임에선 설치된 `banker-<name>` 스킬(플러그인 설치면 `banker:<name>`)을 적용**한다(예: `Skill("banker:setup-node")`).
77
+ - **의존 순서 강제**: 뒤 계층을 고르면 전제 스킬을 먼저 실행한다 — `setup-lsp` 전에 setup-node·setup-python·setup-java; `setup-mcp` 전에 setup-node·setup-python; `docs-setup` 전에 setup-python. 이미 설치돼 있으면 각 스킬이 감지해 skip(멱등).
78
+ - 각 스킬이 OS를 감지해 알맞은 절차로 설치하고 끝에 검증한다. 한 항목이 실패해도 정직히 보고하고 다음 항목 진행.
79
+
80
+ 3. **마무리 보고(한글)**: 설치된 항목·검증 결과·**재시작/reload 필요 여부**.
81
+ - 플러그인 설치(insane-search)는 `/reload-plugins`(Claude) 또는 재시작 후 반영(Codex는 재시작).
82
+ - MCP 추가(stitch)는 세션 갱신 후 연결 확인 — Claude `claude mcp list`, Codex `codex mcp list`.
83
+
84
+ ## 원칙
85
+ - 각 설치는 **비파괴·멱등**(이미 설치면 skip).
86
+ - 시크릿(API key 등)은 사용자 입력/환경변수로만 — 출력·커밋 금지.
87
+ - root 불가/conda 부재 등은 각 스킬의 폴백 경로로 처리(추정 금지, 감지 기반).
@@ -0,0 +1,29 @@
1
+ {
2
+ "description": "banker hooks for the Codex plugin (codex plugin add banker@banker-plugins), written by scripts/sync-codex-plugin.js. Codex reads this file instead of hooks/hooks.json because .codex-plugin/plugin.json names it. update notice (SessionStart, opt-out BANKER_NO_UPDATE_CHECK), usage counting (Stop, opt-out BANKER_NO_TELEMETRY). The obsidizer hook is installed only by obsidizer --enable. Codex runs them only after you trust them in /hooks, and outside its sandbox: each command runs the node PATH names (a multicall shim such as Volta or mise, or a .cmd shim on Windows: the node binary it names) only when its real path is absolute and outside the temp folder and the session folder (a session in the home folder itself excepted), from the plugin folder, and otherwise says why it skipped. Each command starts with a hash of the hook files (banker-hooks-rev), so new hook code asks for trust again. command is for sh, commandWindows for PowerShell; PLUGIN_ROOT is the installed plugin folder.",
3
+ "hooks": {
4
+ "SessionStart": [
5
+ {
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": ": banker-hooks-rev 625aade934d2; x() { m=$(printf '%s' \"banker hooks skipped: $1\" | command -p tr -d '\\000-\\037\"\\\\'); printf '{\"systemMessage\":\"%s\"}\\n' \"$m\"; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; n=$(command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do [ -x \"$d/node\" ] && n=$d/node && break; done; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; set -f; o=$IFS; IFS=:; q=; for v in $PATH; do case \"$v\" in /*) ;; *) continue;; esac; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; IFS=$o; set +f; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$PLUGIN_ROOT\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$PLUGIN_ROOT/hooks/run.cjs\" \"$PLUGIN_ROOT/hooks/update-notify.mjs\" --codex --plugin",
10
+ "commandWindows": "$null = 'banker-hooks-rev 625aade934d2'; function x($m) { Write-Output (ConvertTo-Json -Compress @{ systemMessage = \"banker hooks skipped: $m\" }); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $env:PLUGIN_ROOT -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$env:PLUGIN_ROOT\\hooks\\run.cjs\" \"$env:PLUGIN_ROOT\\hooks\\update-notify.mjs\" --codex --plugin",
11
+ "timeout": 5
12
+ }
13
+ ]
14
+ }
15
+ ],
16
+ "Stop": [
17
+ {
18
+ "hooks": [
19
+ {
20
+ "type": "command",
21
+ "command": ": banker-hooks-rev 625aade934d2; x() { printf '%s\\n' \"banker hooks skipped: $1\" >&2; exit 0; }; [ -n \"$PLUGIN_ROOT\" ] || x \"PLUGIN_ROOT is not set\"; p=$(pwd -P); [ -n \"$p\" ] && [ \"$p\" != / ] || x \"the session folder is /\"; u=$(cd /tmp 2>/dev/null && pwd -P); t=$(cd \"${TMPDIR:-/tmp}\" 2>/dev/null && pwd -P); h=; case \"$HOME\" in /?*) h=$(cd \"$HOME\" 2>/dev/null && pwd -P);; esac; w() { for z in \"$u\" \"$t\"; do [ -n \"$z\" ] && case \"$1/\" in \"$z\"/*) return 0;; esac; done; [ \"$p\" = \"$h\" ] && return 1; case \"$1/\" in \"$p\"/*) return 0;; esac; return 1; }; n=$(command -v node) || for d in /opt/homebrew/bin /usr/local/bin; do [ -x \"$d/node\" ] && n=$d/node && break; done; [ -n \"$n\" ] || x \"node is not on PATH (a Codex started from the desktop may not have the shell's PATH)\"; case \"$n\" in /*) ;; *) x \"node on PATH is not an absolute path ($n)\";; esac; set -f; o=$IFS; IFS=:; q=; for v in $PATH; do case \"$v\" in /*) ;; *) continue;; esac; y=$(cd \"$v\" 2>/dev/null && pwd -P) || continue; w \"$y\" || q=$q${q:+:}$v; done; IFS=$o; set +f; PATH=${q:-/usr/bin:/bin}; export PATH; cd \"$PLUGIN_ROOT\" || x \"cannot enter the plugin folder\"; rp() { realpath \"$1\" 2>/dev/null || readlink -f \"$1\" 2>/dev/null || { f=$1; i=0; while [ -L \"$f\" ]; do i=$((i+1)); [ \"$i\" -le 40 ] || return 1; l=$(readlink \"$f\") || return 1; case \"$l\" in /*) f=$l;; *) f=${f%/*}/$l;; esac; done; [ -e \"$f\" ] || return 1; d=$(cd -P \"${f%/*}/\" 2>/dev/null && pwd -P) || return 1; printf '%s\\n' \"${d%/}/${f##*/}\"; }; }; r=$(rp \"$n\") || x \"cannot resolve the real path of $n\"; k() { case \"$1\" in /*) ;; *) x \"node path is not absolute ($1)\";; esac; for d in \"$u\" \"$t\"; do [ -n \"$d\" ] && case \"$1\" in \"$d\"/*) x \"node is in a temp folder ($1)\";; esac; done; [ \"$p\" = \"$h\" ] || case \"$1\" in \"$p\"/*) x \"node is in the session folder ($1)\";; esac; }; case \"${r##*/}\" in node) k \"$r\"; e=$r;; *) k \"$n\"; k \"$r\"; e=$(\"$n\" -p 'require(\"fs\").realpathSync(process.execPath)' 2>/dev/null) || x \"the node shim $n did not name its node\"; k \"$e\";; esac; \"$e\" \"$PLUGIN_ROOT/hooks/run.cjs\" \"$PLUGIN_ROOT/hooks/telemetry-count-codex.mjs\"",
22
+ "commandWindows": "$null = 'banker-hooks-rev 625aade934d2'; function x($m) { [Console]::Error.WriteLine(\"banker hooks skipped: $m\"); exit 0 }; if (-not $env:PLUGIN_ROOT) { x 'PLUGIN_ROOT is not set' }; function G($p) { try { (Get-Item -LiteralPath $p -Force -ErrorAction Stop).FullName } catch { try { [IO.Path]::GetFullPath($p) } catch { $p } } }; $sb = @{}; foreach ($l in @(& (Join-Path ([Environment]::SystemDirectory) 'subst.exe') 2>$null)) { if ($l -match '^([A-Za-z]:)\\\\: => (.+)$') { $sb[$matches[1].ToUpper()] = $matches[2] } }; function L($p) { if (-not $p) { return '' }; $p = G $p; for ($i = 0; $i -lt 32; $i++) { $d2 = if ($p.Length -ge 2) { $p.Substring(0, 2).ToUpper() } else { '' }; if ($sb.Contains($d2)) { $p = G ($sb[$d2].TrimEnd('\\') + $p.Substring(2)); continue }; $c = $p; $t = $null; while ($c) { $it = Get-Item -LiteralPath $c -Force -ErrorAction SilentlyContinue; if ($it -and $it.LinkType -in 'SymbolicLink', 'Junction' -and @($it.Target)[0]) { $t = [string]@($it.Target)[0]; break }; $c = Split-Path -LiteralPath $c }; if (-not $t) { break }; if (-not [IO.Path]::IsPathRooted($t)) { $t = Join-Path (Split-Path -LiteralPath $c) $t }; $p = G ($t.TrimEnd('\\') + $p.Substring($c.Length)) }; $p.TrimEnd('\\') }; $ds = @($env:TEMP, $env:TMP); if ($env:LOCALAPPDATA) { $ds += \"$env:LOCALAPPDATA\\Temp\" }; $w = L $PWD.Path; if ($w -ne (L $env:USERPROFILE)) { $ds += $w }; $ds = @($ds | Where-Object { $_ } | ForEach-Object { L $_ } | Where-Object { $_ }); function u($f) { $f = L $f; foreach ($d in $ds) { if ($f -eq $d -or $f.StartsWith($d + '\\', 'OrdinalIgnoreCase')) { return $true } }; $false }; function k($f) { if (u $f) { x \"node is in a temp folder or the session folder ($(L $f))\" }; L $f }; $nd = @(@($env:PATH -split ';') | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.Trim().Trim('\"')) } | Where-Object { $_ -match '^[A-Za-z]:\\\\' -and ([IO.DriveInfo]::new($_.Substring(0, 1))).DriveType -ne 'Network' }); $n = $null; foreach ($d in $nd) { foreach ($e in 'node.exe', 'node.bat', 'node.cmd') { $f = Join-Path $d $e; if (Test-Path -LiteralPath $f -PathType Leaf) { $n = $f; break } }; if ($n) { break } }; if (-not $n) { x 'node is not on PATH' }; $env:PATH = ($nd | Where-Object { -not (u $_) }) -join ';'; $r = k $n; if (-not (Set-Location -LiteralPath $env:PLUGIN_ROOT -PassThru -ErrorAction SilentlyContinue)) { x 'cannot enter the plugin folder' }; if ($r -notmatch '\\.exe$') { $e = & $r -p process.execPath 2>$null | Select-Object -Last 1; if (-not $e -or $e -notmatch '\\.exe$' -or -not (Test-Path -LiteralPath $e -PathType Leaf)) { x \"the node shim $r did not name its node\" }; $r = k $e }; & $r \"$env:PLUGIN_ROOT\\hooks\\run.cjs\" \"$env:PLUGIN_ROOT\\hooks\\telemetry-count-codex.mjs\"",
23
+ "timeout": 5
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+ }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: graceful-pause
3
- description: "Codex 에서 작업 중에 보내면 지금 하던 단계만 끝내고 멈춘 뒤 보고하고 지시를 기다림. 작업이 도는 동안 입력창에 `$banker-graceful-pause` 를 쓰고 Enter 하면, 실행 중인 도구 호출이 끝난 뒤 그 턴에 전달됨. 'graceful-pause'/'멈춰'/'지금 단계만 끝내고 멈춤' 시 사용."
3
+ description: "Codex 에서 작업 중에 보내면 지금 하던 단계만 끝내고 멈춘 뒤 보고하고 지시를 기다림. 작업이 도는 동안 입력창에 `$banker-graceful-pause`(플러그인 설치면 `$banker:graceful-pause`)를 쓰고 Enter 하면, 실행 중인 도구 호출이 끝난 뒤 그 턴에 전달됨. 'graceful-pause'/'멈춰'/'지금 단계만 끝내고 멈춤' 시 사용."
4
4
  ---
5
5
 
6
6
  # graceful-pause (Codex)
@@ -23,7 +23,7 @@ Codex 는 작업 중에 Enter 로 보낸 글을 "Messages to be submitted after
23
23
 
24
24
  ## 사용자가 쓰는 법
25
25
 
26
- - 작업이 도는 동안 입력창에 `$banker-graceful-pause` 를 쓰고 Enter 한다. 새 지시를 뒤에 붙여도 된다.
26
+ - 작업이 도는 동안 입력창에 `$banker-graceful-pause` 를 쓰고 Enter 한다. Codex 플러그인으로 설치했으면 `$banker:graceful-pause` 다. 새 지시를 뒤에 붙여도 된다.
27
27
  - 지금 도구 호출까지 바로 끊으려면 Esc 를 누른다. 그때는 이 스킬이 필요 없다.
28
28
  - 작업이 없을 때 쓰면 멈출 작업이 없다고 답한다.
29
29
  - 스킬 이름을 치면 자동 완성 창이 뜬다. 첫 Enter 는 그 선택이고, 둘째 Enter 가 보낸다. 보낸 글은 입력창 위에
package/commands/setup.md CHANGED
@@ -67,10 +67,10 @@ banker 플러그인이 제공하는 설치 스킬들을 **multi-select**로 골
67
67
  - **setup-bypass-permissions** — Claude Code 의 기본 권한 모드를 `bypassPermissions` 로 바꿔 모든 세션에서 도구 실행 확인을 끔.
68
68
  - 위 묶음과 섞지 않고 단일 선택 질문 하나로 따로 묻는다(`multiSelect: false`). 선택지는 `넣지 않음 (권장)`, `넣기 (위험)` 순서다.
69
69
  - 사용자가 이 항목을 직접 고를 때만 넣는다. "전부" 같은 묶음 선택이나 번호 범위에는 들어가지 않는다.
70
- - 이 스킬은 모델이 호출할 수 없다(`disable-model-invocation`). `넣기` 를 고르면 실행하지 말고, 사용자에게 `/banker:setup-bypass-permissions on` 을 직접 입력하라고 안내한다. Codex 는 `$banker-setup-bypass-permissions` 를 직접 부르라고 안내한다.
70
+ - 이 스킬은 모델이 호출할 수 없다(`disable-model-invocation`). `넣기` 를 고르면 실행하지 말고, 사용자에게 `/banker:setup-bypass-permissions on` 을 직접 입력하라고 안내한다. Codex 는 `$banker-setup-bypass-permissions`(플러그인 설치면 `$banker:setup-bypass-permissions`)를 직접 부르라고 안내한다.
71
71
  - 입력하면 그 스킬이 위험 경고를 보여 주고 한 번 더 확인받는다. 되돌리기는 `off`.
72
72
 
73
- 2. **실행**: 선택된 각 항목을 호출한다(미선택은 건드리지 않음). 단 `setup-bypass-permissions` 는 호출하지 않는다(위 권한 항목의 안내만). Claude Code는 `Skill("banker:<name>")`, **Codex 런타임에선 설치된 `banker-<name>` 스킬을 적용**한다(예: `Skill("banker:setup-node")`).
73
+ 2. **실행**: 선택된 각 항목을 호출한다(미선택은 건드리지 않음). 단 `setup-bypass-permissions` 는 호출하지 않는다(위 권한 항목의 안내만). Claude Code는 `Skill("banker:<name>")`, **Codex 런타임에선 설치된 `banker-<name>` 스킬(플러그인 설치면 `banker:<name>`)을 적용**한다(예: `Skill("banker:setup-node")`).
74
74
  - **의존 순서 강제**: 뒤 계층을 고르면 전제 스킬을 먼저 실행한다 — `setup-lsp` 전에 setup-node·setup-python·setup-java; `setup-mcp` 전에 setup-node·setup-python; `docs-setup` 전에 setup-python. 이미 설치돼 있으면 각 스킬이 감지해 skip(멱등).
75
75
  - 각 스킬이 OS를 감지해 알맞은 절차로 설치하고 끝에 검증한다. 한 항목이 실패해도 정직히 보고하고 다음 항목 진행.
76
76
 
@@ -7,23 +7,32 @@
7
7
  * - 모델이 고른 스킬: 도구 호출로 SKILL.md 를 읽는다(Bash `cat <CODEX_HOME>/skills/banker-<name>/SKILL.md`).
8
8
  * - 사용자가 $banker-<name> 으로 부른 스킬: Codex 가 도구 호출 없이 `<skill>\n<name>..</name>\n<path>..</path>` 로
9
9
  * 시작하는 사용자 역할 메시지로 본문을 넣는다. 이때는 머리의 <path> 줄만 보고 본문은 보지 않는다.
10
+ * Codex 플러그인으로 설치한 banker(codex plugin add, $banker:<name>)는 SKILL.md 가
11
+ * `<CODEX_HOME>/plugins/cache/<마켓플레이스>/banker/<판>/{skills,codex/skills,codex/command-skills}/<name>/` 에 있다.
12
+ * 두 길의 경로를 모두 센다.
10
13
  * 도구 호출마다 도는 PreToolUse 대신 턴마다 한 번 도는 Stop 에서, 훅 입력의 transcript_path(rollout) 중
11
14
  * 이번 턴(task_started 의 turn_id 뒤)의 두 기록에 `skills/banker-<name>/SKILL.md` 가 있으면 센다. 나눠 읽어도 한 번이고, Stop 이 한 턴에 두 번 와도
12
15
  * 최근 턴 키(configDir 의 codex-count-turns)로 한 번만 센다. 기록은 끝에서부터 32 MB 까지만 읽는다.
13
16
  *
14
17
  * fail-closed 규약은 Claude 쪽과 같다(telemetry-count-codex.test.mjs 가 검증):
15
18
  * - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다.
16
- * - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md)만.
19
+ * - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md, 또는
20
+ * 기록의 경로 그대로 <CODEX_HOME>/plugins/cache/ 아래에 있는 플러그인의 SKILL.md)만.
17
21
  * 모델이 `echo skills/banker-<아무 글>/SKILL.md` 를 돌려 체크인의 이름 칸으로 글을 내보내지 못하게 한다. 턴 키 파일은 일반 파일만 읽고, 링크 자리에 쓰지 않는다.
18
22
  * - 어떤 오류도 삼키고 항상 exit 0. 도구 호출을 절대 막지 않는다. 표준 출력은 비운다.
19
23
  */
20
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs';
21
- import { homedir } from 'node:os';
22
- import { basename, dirname, isAbsolute, join } from 'node:path';
24
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
23
26
 
24
- import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
27
+ import { codexHomeOf, pluginRoutes } from '../bin/lib/codex-routes.mjs';
28
+ import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, randomHex, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
25
29
 
26
- const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/gi;
30
+ export { codexHomeOf };
31
+
32
+ const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
33
+ // 플러그인 길: plugins/cache/<마켓플레이스>/banker/<판>/<스킬 폴더>/<name>/SKILL.md. 1 마켓플레이스, 2 판, 3 스킬 폴더, 4 이름.
34
+ // 대소문자를 가리지 않는 파일 시스템에서 `FRONT-QA` 같은 변형이 다른 이름으로 세어지지 않게 i 를 두지 않는다.
35
+ const PLUGIN_PATH_RE = /plugins[\\/]+cache[\\/]+([A-Za-z0-9._-]{1,80})[\\/]+banker[\\/]+([A-Za-z0-9._-]{1,80})[\\/]+(skills|codex[\\/]+skills|codex[\\/]+command-skills)[\\/]+([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
27
36
  const KEEP_TURNS = 100;
28
37
  export const TURNS_FILE = () => join(configDir(), 'codex-count-turns');
29
38
 
@@ -40,12 +49,18 @@ function readStdin() {
40
49
  const MAX_READ = 32 << 20;
41
50
  const FIRST_READ = 1 << 20;
42
51
 
43
- // 글에 나오는 banker 스킬 이름(banker:<name>), 중복 없이.
52
+ // 글에 나오는 banker 스킬, 중복 없이. npm 길은 'banker:<name>', 플러그인 길은 'banker:<name>\t<홈 기준 SKILL.md 상대 경로>'
53
+ // (설치 확인에 쓰고, 셀 때는 이름만 남긴다).
44
54
  export function namesIn(text, names = new Set()) {
45
55
  for (const m of text.matchAll(SKILL_PATH_RE)) {
46
56
  const name = `banker:${m[1]}`;
47
57
  if (SKILL_NAME_RE.test(name)) names.add(name);
48
58
  }
59
+ for (const m of text.matchAll(PLUGIN_PATH_RE)) {
60
+ const name = `banker:${m[4]}`;
61
+ if (m[1] === '.' || m[1] === '..' || m[2] === '.' || m[2] === '..' || !SKILL_NAME_RE.test(name)) continue;
62
+ names.add(`${name}\t${['plugins', 'cache', m[1], 'banker', m[2], ...m[3].split(/[\\/]+/), m[4], 'SKILL.md'].join('/')}`);
63
+ }
49
64
  return names;
50
65
  }
51
66
 
@@ -170,17 +185,14 @@ function writeTurns(lines) {
170
185
  }
171
186
  }
172
187
 
173
- // 이 세션의 Codex 홈: rollout 이 <홈>/sessions/YYYY/MM/DD/ 에 있으면 그 홈(상대 경로 CODEX_HOME 도 Codex 가 푼 그대로),
174
- // 아니면 CODEX_HOME, 그것도 없으면 ~/.codex.
175
- export function codexHomeOf(transcript, env = process.env) {
176
- const sessions = dirname(dirname(dirname(dirname(String(transcript)))));
177
- if (isAbsolute(String(transcript)) && basename(sessions) === 'sessions') return dirname(sessions);
178
- return env.CODEX_HOME || join(homedir(), '.codex');
188
+ // 이 Codex 에 설치된 banker 스킬인지. 플러그인 길은 그 홈의 config.toml 에 켜져 있는 banker@<마켓플레이스> 의 것이고,
189
+ // 기록의 경로 그대로 plugins/cache 아래에 있어야 한다(지운 플러그인이 남긴 폴더나 다른 마켓플레이스의 banker 는 세지 않음).
190
+ export function installed(entry, home, routes = pluginRoutes(home)) {
191
+ const [name, rel] = entry.split('\t');
192
+ if (rel) return routes.includes(`banker@${rel.split('/')[2]}`) && existsSync(join(home, ...rel.split('/')));
193
+ return existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
179
194
  }
180
195
 
181
- // 이 Codex 에 설치된 banker 스킬인지.
182
- export const installed = (name, home) => existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
183
-
184
196
  // 이 턴에 아직 세지 않은 이름만 센다.
185
197
  export function countOnce(payload) {
186
198
  const key = turnKey(payload);
@@ -191,17 +203,92 @@ export function countOnce(payload) {
191
203
  noteShapeMiss(file);
192
204
  return [];
193
205
  }
194
- const names = got.filter((n) => installed(n, home));
206
+ const routes = got.some((n) => n.includes('\t')) ? pluginRoutes(home) : []; // config.toml 은 한 번만 읽는다
207
+ const names = [...new Set(got.filter((n) => installed(n, home, routes)).map((n) => n.split('\t')[0]))];
195
208
  if (!names.length) return [];
196
- const seen = readTurns();
197
- const fresh = names.filter((n) => !seen.includes(`${key}\t${n}`));
198
- if (!fresh.length) return [];
199
- for (const n of fresh) {
200
- appendUsageLog(n);
201
- recordUsedSkill(n);
209
+ return withTurnsLock(() => {
210
+ const seen = readTurns();
211
+ const fresh = names.filter((n) => !seen.includes(`${key}\t${n}`));
212
+ if (!fresh.length) return [];
213
+ for (const n of fresh) {
214
+ appendUsageLog(n);
215
+ recordUsedSkill(n);
216
+ }
217
+ writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
218
+ return fresh;
219
+ });
220
+ }
221
+
222
+ // npm 길과 플러그인 길의 Stop 훅이 같은 턴에 함께 돌면 둘 다 턴 키를 읽고 셀 수 있다. 턴 키를 읽고 쓰는 동안
223
+ // codex-count-turns.lock 폴더를 잡는다(mkdir 은 이미 있으면 실패하고 링크를 따라가지 않아 둘 중 하나만 잡음).
224
+ // 2초 안에 못 잡으면 잡은 쪽이 이 턴을 세므로 세지 않는다. 잠금은 몇 ms 만 잡으므로 훅 시간 제한(5초)보다 조금 긴
225
+ // 6초가 지난 잠금은 죽은 훅의 것이다: 고유 이름으로 옮겨(rename 은 하나만 성공) 지운 뒤 다시 잡는다. 옮긴 것이
226
+ // 새것이면(그사이 다른 훅이 넘겨받아 잡은 것) 그 훅에 이 턴을 맡기고 물러난다.
227
+ // configDir 을 만들 수 없거나 잠금 폴더를 만들 수 없는 다른 오류면 잠금 없이 전처럼 센다.
228
+ const LOCK_WAIT_MS = 2000;
229
+ const LOCK_STALE_MS = 6000;
230
+ export function withTurnsLock(fn, { wait = LOCK_WAIT_MS } = {}) {
231
+ const lock = `${TURNS_FILE()}.lock`;
232
+ try {
233
+ mkdirSync(configDir(), { recursive: true });
234
+ } catch {
235
+ return fn();
202
236
  }
203
- writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
204
- return fresh;
237
+ const end = Date.now() + wait;
238
+ const token = `${process.pid}-${randomHex()}`;
239
+ for (;;) {
240
+ try {
241
+ mkdirSync(lock);
242
+ try { writeFileSync(join(lock, 'owner'), token, { flag: 'wx' }); } catch { /* 표시 없이도 잠금은 잡았다 */ }
243
+ break;
244
+ } catch (e) {
245
+ if (e?.code !== 'EEXIST') return fn();
246
+ }
247
+ try {
248
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) {
249
+ const old = `${lock}.stale-${process.pid}-${randomHex()}`;
250
+ renameSync(lock, old);
251
+ // 본 것과 옮긴 것 사이에 다른 훅이 낡은 잠금을 치우고 새로 잡았을 수 있다. 옮긴 것이 새것이면 그 훅이 이
252
+ // 턴을 세는 중이므로 세지 않고 물러난다. 돌려놓지 않는다(그사이 세 번째 훅이 잡은 잠금을 덮을 수 있다).
253
+ // 옮긴 폴더는 다음에 잠금을 잡은 훅이 치운다.
254
+ if (Date.now() - statSync(old).mtimeMs <= LOCK_STALE_MS) return [];
255
+ rmSync(old, { recursive: true, force: true });
256
+ continue;
257
+ }
258
+ } catch { /* 방금 풀렸거나 다른 훅이 먼저 치움: 다시 잡아 본다 */ }
259
+ if (Date.now() >= end) return [];
260
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
261
+ }
262
+ try {
263
+ dropMovedLocks(lock);
264
+ return fn();
265
+ } finally {
266
+ releaseLock(lock, token);
267
+ }
268
+ }
269
+
270
+ // 잠금 폴더 안의 주인 표시(owner)가 이 훅의 것일 때만 놓는다. 넘겨받던 훅이 이 잠금을 옮기고 다른 훅이 그 이름을
271
+ // 새로 잡았으면, 그 훅의 잠금은 지우지 않는다.
272
+ function releaseLock(lock, token) {
273
+ try {
274
+ if (readFileSync(join(lock, 'owner'), 'utf8') !== token) return;
275
+ rmSync(join(lock, 'owner'), { force: true });
276
+ rmdirSync(lock);
277
+ } catch { /* 없거나 남의 것 */ }
278
+ }
279
+
280
+ // 넘겨받다 물러난 훅이 남긴 옮긴 잠금(<잠금>.stale-*) 가운데 오래된 것을 지운다.
281
+ function dropMovedLocks(lock) {
282
+ const dir = configDir();
283
+ const prefix = `${lock.slice(dir.length + 1)}.stale-`;
284
+ try {
285
+ for (const name of readdirSync(dir)) {
286
+ if (!name.startsWith(prefix)) continue;
287
+ try {
288
+ if (Date.now() - statSync(join(dir, name)).mtimeMs > LOCK_STALE_MS) rmSync(join(dir, name), { recursive: true, force: true });
289
+ } catch { /* 다른 훅이 먼저 지움 */ }
290
+ }
291
+ } catch { /* 다음에 */ }
205
292
  }
206
293
 
207
294
  async function main() {
@@ -14,7 +14,7 @@
14
14
  * - 어떤 오류도 삼키고 항상 exit 0. 세션을 절대 막지 않는다. 비TTY 안전.
15
15
  */
16
16
  import { spawn } from 'node:child_process';
17
- import { realpathSync } from 'node:fs';
17
+ import { mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, rmSync, statSync } from 'node:fs';
18
18
  import { dirname, join } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
@@ -22,12 +22,19 @@ import {
22
22
  UPDATE_CHECK_PATH, updateCheckDue,
23
23
  noUpdateCheck, countingActive, endpoint,
24
24
  installedVersion, compareVersions, writeUpdateCache, readUsedSkills, readRegular,
25
- CODEX_SHAPE_MISS_PATH, writeByRename,
25
+ CODEX_SHAPE_MISS_PATH, writeByRename, configDir, randomHex,
26
26
  } from '../bin/lib/telemetry-config.mjs';
27
+ import { codexHomeOf, DEFAULT_MARKETPLACE, dualInstallNote, pluginRootInfo } from '../bin/lib/codex-routes.mjs';
27
28
 
28
29
  const HERE = dirname(fileURLToPath(import.meta.url));
29
30
  // 업데이트 안내에 쓰는 npm 패키지 이름(고지 문구·이모지/em-dash 없음).
30
31
  const NPM_PKG = '@kaydash9999/banker-plugins';
32
+ // Codex 의 훅은 --codex 를 받는다. 그중 Codex 플러그인(codex/hooks.json)의 훅은 --plugin 도 받는다. npm 길의 훅은
33
+ // banker setup --codex 가 <CODEX_HOME>/hooks.json 에 넣는다.
34
+ const CODEX = process.argv.includes('--codex');
35
+ const PLUGIN = CODEX && process.argv.includes('--plugin');
36
+ // Codex 에서 banker 스킬을 부르는 이름: npm 길은 $banker-<이름>, 플러그인은 $banker:<이름>.
37
+ const skillCall = (name) => (PLUGIN ? `$banker:${name}` : `$banker-${name}`);
31
38
 
32
39
  function readStdin() {
33
40
  return new Promise((resolvePromise, rejectPromise) => {
@@ -66,11 +73,14 @@ function personalizedSkills(cached) {
66
73
  // (기존 동작). banker: 접두는 떼서 짧게. 이모지/em-dash 없음(사용자 가시 채널).
67
74
  function buildMessage(latest, installed, cached) {
68
75
  const head = `banker ${latest} 사용 가능 (현재 ${installed}).`;
69
- // Codex 의 훅은 --codex 를 받는다(banker setup --codex 가 설치). Codex 에는 /banker: 명령이 없어 스킬 이름과
70
- // npm 뒤의 Codex 설치 명령을 안내한다.
71
- const tail = process.argv.includes('--codex')
72
- ? `업데이트: $banker-update-banker 또는 npm i -g ${NPM_PKG} 뒤 banker setup --codex`
73
- : `업데이트: /banker:update-banker 또는 npm i -g ${NPM_PKG}`;
76
+ // Codex 에는 /banker: 명령이 없어 스킬 이름과 그 설치 길의 갱신 명령을 안내한다. 플러그인은 마켓플레이스를 올리면
77
+ // 설치된 플러그인도 새 판으로 바뀌고, Codex 는 세션을 시작할 때 git 마켓플레이스를 스스로 올린다(0.144.5 실측).
78
+ const mkt = pluginRootInfo(process.env.PLUGIN_ROOT)?.marketplace ?? DEFAULT_MARKETPLACE;
79
+ const tail = PLUGIN
80
+ ? `업데이트: Codex 가 세션을 시작할 때 마켓플레이스와 함께 올립니다. 바로 올리려면 codex plugin marketplace upgrade ${mkt} 뒤 Codex 를 다시 여십시오`
81
+ : CODEX
82
+ ? `업데이트: ${skillCall('update-banker')} 또는 npm i -g ${NPM_PKG} 뒤 banker setup --codex`
83
+ : `업데이트: /banker:update-banker 또는 npm i -g ${NPM_PKG}`;
74
84
  const hits = personalizedSkills(cached);
75
85
  if (hits.length) {
76
86
  const names = hits.slice(0, 5).map((s) => s.replace(/^banker:/, '')).join(', ');
@@ -100,7 +110,7 @@ export function shapeMissNotice() {
100
110
  const miss = JSON.parse(readRegular(CODEX_SHAPE_MISS_PATH));
101
111
  if (!miss || typeof miss.version !== 'string' || miss.notified) return '';
102
112
  writeByRename(CODEX_SHAPE_MISS_PATH, JSON.stringify({ version: miss.version, notified: true }));
103
- return `banker 사용량 카운팅이 Codex ${miss.version.slice(0, 40)} 의 기록 형식을 읽지 못함. $banker-update-banker 로 banker 를 업데이트하고, 그래도 같으면 알려 주십시오`;
113
+ return `banker 사용량 카운팅이 Codex ${miss.version.slice(0, 40)} 의 기록 형식을 읽지 못함. ${skillCall('update-banker')} 로 banker 를 업데이트하고, 그래도 같으면 알려 주십시오`;
104
114
  } catch {
105
115
  return '';
106
116
  }
@@ -123,11 +133,72 @@ function spawnFetcher() {
123
133
  }
124
134
  }
125
135
 
136
+ // Codex: npm 길과 플러그인 길이 함께 있으면 알린다. 세션마다 한 번만 알리는 일은 claimSession 이 맡는다.
137
+ export function dualNotice(payload, env = process.env) {
138
+ try {
139
+ const cwd = typeof payload?.cwd === 'string' ? payload.cwd : '';
140
+ return dualInstallNote(codexHomeOf(payload?.transcript_path, env), cwd);
141
+ } catch {
142
+ return '';
143
+ }
144
+ }
145
+
146
+ // Codex: 두 길의 SessionStart 훅이 모두 신뢰돼 같은 세션에서 함께 돌면 같은 알림이 두 번 나간다. 세션 id 이름의
147
+ // 폴더를 configDir 의 codex-sessions 에 만든 훅만 알린다. mkdir 은 이미 있으면 실패하고 링크를 따라가지 않아 둘이
148
+ // 동시에 와도 하나만 만든다. 함께 도는 훅은 훅 시간 제한(5초) 안에 오므로, 30초 넘게 지난 폴더는 이어 연 세션(resume)
149
+ // 의 것이다: 고유 이름으로 옮겨(rename 은 하나만 성공) 다시 만든 훅이 알린다. 옮긴 것이 새것이면(그사이 짝 훅이
150
+ // 넘겨받아 만든 것) 알리지 않는다. 세션 id 가 없거나 폴더를 만들 수 없으면
151
+ // 전처럼 알린다. 이틀 지난 폴더와 예전 판의 codex-dual-notified 는 지운다.
152
+ const SESSION_RE = /^[A-Za-z0-9._-]{1,80}$/;
153
+ const CLAIM_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
154
+ const CLAIM_TWIN_MS = 30 * 1000;
155
+ export function claimSession(payload, now = Date.now()) {
156
+ const sid = typeof payload?.session_id === 'string' && SESSION_RE.test(payload.session_id) && !/^\.+$/.test(payload.session_id) ? payload.session_id : '';
157
+ if (!sid) return true;
158
+ const dir = join(configDir(), 'codex-sessions');
159
+ try {
160
+ mkdirSync(dir, { recursive: true });
161
+ } catch {
162
+ return true; // 폴더 자리에 다른 것이 있음: 중복을 막지 못해도 알림은 낸다
163
+ }
164
+ const mine = join(dir, sid);
165
+ try {
166
+ mkdirSync(mine);
167
+ } catch (e) {
168
+ if (e?.code !== 'EEXIST') return true;
169
+ try {
170
+ if (now - statSync(mine).mtimeMs < CLAIM_TWIN_MS) return false; // 같은 세션의 다른 길 훅이 방금 알렸다
171
+ const old = `${mine}.old-${process.pid}-${randomHex()}`;
172
+ renameSync(mine, old);
173
+ if (now - statSync(old).mtimeMs < CLAIM_TWIN_MS) {
174
+ // 본 것과 옮긴 것 사이에 짝 훅이 넘겨받아 새로 만든 폴더였다: 그 훅이 알리므로 알리지 않는다. 돌려놓지 않고
175
+ // (그사이 다른 훅이 만든 폴더를 덮을 수 있다) 옮긴 폴더는 이틀 뒤 정리에서 지운다.
176
+ return false;
177
+ }
178
+ rmdirSync(old);
179
+ mkdirSync(mine);
180
+ } catch {
181
+ return false; // 함께 온 다른 훅이 먼저 옮기거나 만들었다
182
+ }
183
+ }
184
+ try {
185
+ rmSync(join(configDir(), 'codex-dual-notified'), { force: true });
186
+ for (const d of readdirSync(dir)) {
187
+ try {
188
+ if (d !== sid && now - statSync(join(dir, d)).mtimeMs > CLAIM_KEEP_MS) rmdirSync(join(dir, d));
189
+ } catch { /* 다른 훅이 먼저 지움 */ }
190
+ }
191
+ } catch { /* 정리는 다음 세션에 */ }
192
+ return true;
193
+ }
194
+
126
195
  async function main() {
127
- // SessionStart payload 를 드레인(파이프 위생). 내용을 쓰지 않으므로 파싱 실패해도 무해.
128
- try { await readStdin(); } catch { /* stdin 없음/오류 무해 */ }
196
+ // SessionStart payload. Codex 는 두 길 알림에 transcript_path, session_id 를 쓴다. 파싱 실패해도 무해.
197
+ let payload = null;
198
+ try { payload = JSON.parse(await readStdin()); } catch { /* stdin 없음/오류 무해 */ }
129
199
 
130
- const notes = process.argv.includes('--codex') ? [shapeMissNotice()] : [];
200
+ if (CODEX && !claimSession(payload)) return; // 같은 세션의 다른 길 훅이 이미 알렸다
201
+ const notes = CODEX ? [shapeMissNotice(), dualNotice(payload)] : [];
131
202
  if (!noUpdateCheck()) { // opt-out 이면 업데이트 확인만 무동작.
132
203
  const cached = readCache(); // 포그라운드는 캐시 읽기만(네트워크 없음).
133
204
  notes.unshift(maybeNotify(cached));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -304,11 +304,25 @@ async function pixels(context, png) {
304
304
  return { width: img.width, height: img.height, data: Uint8ClampedArray.from(img.data) };
305
305
  }
306
306
 
307
+ // Chromium now and then answers a screenshot with "Unable to capture screenshot" while it is busy (seen once in three
308
+ // full runs on a loaded Linux box). That error alone is tried again, up to three times with a short wait; any other
309
+ // error is the page's and is thrown at once.
310
+ export async function screenshot(page, opts, { tries = 3, waitMs = 150 } = {}) {
311
+ for (let n = 1; ; n++) {
312
+ try {
313
+ return await page.screenshot(opts);
314
+ } catch (e) {
315
+ if (n >= tries || !/Unable to capture screenshot/i.test(String(e?.message))) throw e;
316
+ await new Promise((r) => setTimeout(r, waitMs * n));
317
+ }
318
+ }
319
+ }
320
+
307
321
  async function lineContrast(page, i, panel) {
308
322
  const clip = { x: Math.max(0, Math.floor(panel.x)), y: Math.max(0, Math.floor(panel.y)), width: Math.ceil(panel.width), height: Math.ceil(panel.height) };
309
323
  const lines = await page.evaluate(hideTextLines, { i, parts: TEXT_PARTS });
310
324
  await page.waitForTimeout(80);
311
- const png = await page.screenshot({ type: 'png', clip });
325
+ const png = await screenshot(page, { type: 'png', clip });
312
326
  await page.evaluate(showText, { i, parts: TEXT_PARTS });
313
327
  const img = await pixels(page.context(), png);
314
328
  return lines.map((l) => {
@@ -30,7 +30,7 @@ argument-hint: "on | off | status | config"
30
30
  | 런타임 | `--runtime` | 스킬 디렉터리 |
31
31
  |---|---|---|
32
32
  | Claude Code | `claude` | 스킬 맨 위 "Base directory for this skill:" 경로 |
33
- | Codex | `codex` | `~/.codex/skills/banker-anti-drm/` 의 절대경로. 프로젝트 범위 설치면 `./.codex/skills/banker-anti-drm/` |
33
+ | Codex | `codex` | Codex 스킬 목록이 보이는 이 SKILL.md 경로(file)의 폴더. npm 설치는 `~/.codex/skills/banker-anti-drm/`(프로젝트 범위 설치면 `./.codex/skills/banker-anti-drm/`), 플러그인 설치(`codex plugin add`)는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/anti-drm/`. 절대경로로 씀 |
34
34
 
35
35
  3. 아래 인자별 절차를 따름
36
36