@kaydash9999/banker-plugins 0.21.0 → 0.23.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 (129) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +69 -0
  4. package/PRIVACY.md +8 -3
  5. package/README.md +78 -15
  6. package/bin/banker.js +154 -32
  7. package/bin/lib/codex-hooks.mjs +123 -20
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +204 -0
  10. package/bin/lib/command-skill.cjs +50 -0
  11. package/bin/lib/telemetry-config.mjs +2 -1
  12. package/codex/command-skills/front-qa/SKILL.md +56 -0
  13. package/codex/command-skills/setup/SKILL.md +87 -0
  14. package/codex/hooks.json +29 -0
  15. package/codex/manifest.json +18 -15
  16. package/codex/skills/graceful-pause/SKILL.md +2 -2
  17. package/codex/skills/progress/scripts/progress.mjs +44 -14
  18. package/codex/skills/teamwork/SKILL.md +137 -0
  19. package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
  20. package/codex/transform-matrix.md +5 -3
  21. package/commands/setup.md +2 -2
  22. package/hooks/obsidize-hook.mjs +75 -32
  23. package/hooks/telemetry-count-codex.mjs +303 -35
  24. package/hooks/update-fetch.mjs +2 -1
  25. package/hooks/update-notify.mjs +146 -13
  26. package/package.json +3 -1
  27. package/skills/3d-intro-build/SKILL.md +101 -20
  28. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  29. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  30. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  31. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  32. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  33. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  34. package/skills/3d-intro-build/references/prompts.md +51 -4
  35. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  36. package/skills/3d-intro-build/references/step-nav.css +29 -0
  37. package/skills/3d-intro-build/references/step-nav.js +277 -106
  38. package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
  39. package/skills/3d-intro-build/references/verify.md +6 -0
  40. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  41. package/skills/3d-intro-setup/SKILL.md +20 -7
  42. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  43. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  44. package/skills/all-in-one/SKILL.md +125 -86
  45. package/skills/anti-drm/SKILL.md +1 -1
  46. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  47. package/skills/arch-diagram/build_pptx_template.py +47 -28
  48. package/skills/audit-web-page/SKILL.md +1 -1
  49. package/skills/audit-web-page/harness.py +0 -1
  50. package/skills/lineage/lineage.py +1435 -533
  51. package/skills/motion-graphic-make/SKILL.md +1 -1
  52. package/skills/obsidizer/SKILL.md +20 -5
  53. package/skills/omc-patch/SKILL.md +1 -1
  54. package/skills/payload-mon/SKILL.md +2 -2
  55. package/skills/play-qa/SKILL.md +7 -5
  56. package/skills/progress-mon/README.md +162 -0
  57. package/skills/progress-mon/SKILL.md +165 -0
  58. package/skills/progress-mon/assets/fixture/README.md +1 -0
  59. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  60. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  61. package/skills/progress-mon/assets/fixture/config.json +30 -0
  62. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  63. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  64. package/skills/progress-mon/assets/fixture/items.json +14 -0
  65. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  66. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  67. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  68. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  69. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  70. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  71. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  72. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  73. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  74. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  75. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  76. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  77. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  78. package/skills/progress-mon/references/arch.md +280 -0
  79. package/skills/progress-mon/references/authoring.md +199 -0
  80. package/skills/progress-mon/references/config.md +115 -0
  81. package/skills/progress-mon/references/flow.md +75 -0
  82. package/skills/progress-mon/references/inventory.md +58 -0
  83. package/skills/progress-mon/references/items.md +74 -0
  84. package/skills/progress-mon/references/req-spec.md +74 -0
  85. package/skills/progress-mon/references/reqs.md +30 -0
  86. package/skills/progress-mon/scripts/index.js +31 -0
  87. package/skills/progress-mon/scripts/pm-check.mjs +526 -0
  88. package/skills/progress-mon/scripts/pm-config.mjs +341 -0
  89. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  90. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  91. package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
  92. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  93. package/skills/progress-mon/scripts/pm-server.mjs +735 -0
  94. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  95. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  96. package/skills/ralph-qa/SKILL.md +105 -16
  97. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  98. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  99. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  100. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  101. package/skills/ralph-qa/references/seat-run.mjs +159 -155
  102. package/skills/remains/SKILL.md +2 -1
  103. package/skills/remains/scripts/boxes.mjs +6 -2
  104. package/skills/rfp-author/scripts/md2docx.py +63 -34
  105. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  106. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  107. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  108. package/skills/setup-mcp/SKILL.md +1 -1
  109. package/skills/setup-omc-hud/SKILL.md +2 -1
  110. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  111. package/skills/smart-compact/SKILL.md +1 -1
  112. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  113. package/skills/theme-selection/SKILL.md +1 -1
  114. package/skills/tone-compact/SKILL.md +1 -1
  115. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  116. package/skills/ultra-chain/SKILL.md +34 -16
  117. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  118. package/skills/ultra-init/SKILL.md +30 -22
  119. package/skills/ultra-ui-qa/SKILL.md +1 -1
  120. package/skills/ultraqa/NOTICE.md +34 -0
  121. package/skills/ultraqa/SKILL.md +196 -0
  122. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  123. package/skills/ultraqa/references/run-folder.mjs +299 -0
  124. package/skills/ultraqa/references/tmux-drive.md +52 -0
  125. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  126. package/skills/update-banker/SKILL.md +32 -7
  127. package/skills/vertical-pptx/SKILL.md +1 -1
  128. package/skills/vertical-pptx/build_a4p.js +20 -2
  129. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -1,7 +1,7 @@
1
1
  # Transform matrix: Claude Code surfaces → Codex CLI
2
2
 
3
3
  Source of truth: this repo's `skills/<name>/` + `commands/<name>.md`. `codex/manifest.json` tags each surface.
4
- The generator (`banker setup --codex`) applies the rules below for `target: both` and `codex-only` surfaces (`claude-only` surfaces are never written to Codex — currently there are none). A `codex-only` surface has its source in `codex/skills/<name>/` and is never part of the Claude plugin (currently `graceful-pause` and `progress`).
4
+ The generator (`banker setup --codex`) applies the rules below for `target: both` and `codex-only` surfaces (`claude-only` surfaces are never written to Codex — currently there are none). A `codex-only` surface has its source in `codex/skills/<name>/` and is never part of the Claude plugin (currently `graceful-pause`, `progress` and `teamwork`).
5
5
 
6
6
  | Source (Claude) | Codex destination | Transform |
7
7
  |---|---|---|
@@ -19,8 +19,8 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
19
19
  - `--scope user` (default) → `~/.codex/…`. `--scope project` → `./.codex/…`.
20
20
 
21
21
  ## Runtime-aware surfaces (both) — Codex uses OMX / Codex equivalents
22
- 현재 `claude-only` 표면은 **없다**(Claude 플러그인의 표면은 전부 `both`, Codex 에만 있는 `codex-only` 는 `graceful-pause`, `progress`). OMC/Claude 에 결합됐던 표면은 본문이 런타임 인식으로 작성돼 Codex에서도 동작한다 — Codex에선 OMC 대신 oh-my-codex(OMX)의 동명 스킬을 쓴다:
23
- - 오케스트레이터 `all-in-one`·`ultra-init`·커맨드 `front-qa` → OMX `ralplan`/`ralph`/`ultraqa`. `ultra-init` 은 0.14.0 부터 ultragoal 원장을 쓰지 않는다(OMC 5.6.1 checkpoint 가 `/goal` 스냅샷을 요구해 ralph 와 Stop 훅 루프가 겹친다). Claude 쪽은 OMC 5 기준: `ultraqa` 가 5.0.0 에서 삭제돼 QA 게이트를 `verify` 순환(독립 verifier → architect 진단 → executor 수정, 최대 5회)으로 직접 돌리고, `ralph` 가 부르는 `omc ralph verify` 때문에 전역 `omc` CLI 가 플러그인과 같은 5.x 여야 한다(사전 점검은 종료 코드가 아니라 출력 문구로).
22
+ `claude-only` 표면은 `ultraqa` 하나다(Codex 는 OMX 의 `$ultraqa` 를 쓰므로 npm 설치와 Codex 플러그인 스킬 목록에서 빠진다). 그 밖의 Claude 플러그인 표면은 `both`, Codex 에만 있는 `codex-only` 는 `graceful-pause`, `progress`, `teamwork`. Codex 이름이나 설명이 다른 `both` 스킬은 manifest 의 `codexName`, `codexDescription` 으로 정한다(`setup-omc` 는 `setup-omx`, `omc-reference` 는 `omx-reference`, `setup-omc-hud` 는 `setup-omx-hud`, 설명만 다른 것은 `append_wiki`, `compact-wiki`, `setup-tmux`, 커맨드 `setup`). 플러그인 길은 `codex/renamed-skills/` 의 생성 사본, npm 길은 설치 때 바꿔 쓴다. OMC/Claude 에 결합됐던 표면은 본문이 런타임 인식으로 작성돼 Codex에서도 동작한다 — Codex에선 OMC 대신 oh-my-codex(OMX)의 동명 스킬을 쓴다:
23
+ - 오케스트레이터 `all-in-one`·`ultra-init`·커맨드 `front-qa` → OMX `ralplan`/`ralph`. 마지막 QA 단계는 두 런타임 모두 banker `ralph-qa` 하나다(`--qa` 목표를 `--gates` 로 넘김, OMX `ultraqa` 에 기대지 않음). `ultra-init` 은 0.14.0 부터 ultragoal 원장을 쓰지 않는다(OMC 5.6.1 checkpoint 가 `/goal` 스냅샷을 요구해 ralph 와 Stop 훅 루프가 겹친다). Claude 쪽은 `ralph` 가 부르는 `omc ralph verify` 때문에 전역 `omc` CLI 가 플러그인과 같은 5.x 여야 한다(사전 점검은 종료 코드가 아니라 출력 문구로).
24
24
  - `setup-omc` → `omx setup`(본문에 이미 존재). `setup-omc-hud` → Codex 자신의 상태표시줄이면 `[tui] status_line` 을 OMX 프리셋으로 켜고 끔(`assets/codex-status-line.mjs`, 없을 때만 넣고 banker 가 넣은 줄만 뺌), 같은 머신의 Claude Code 상태표시줄이면 그대로 설치(대상을 말하지 않으면 먼저 묻는다). `setup-stitch` → `codex mcp add`. `setup-bypass-permissions` → 대상 `codex` 면 `config.toml` 최상위 `approval_policy = "never"`, `sandbox_mode = "danger-full-access"`(`scripts/codex-bypass.mjs`, 켜기 전 값 기록과 `off` 원복, 조직 정책 확인), 대상 `claude` 면 같은 머신의 Claude Code 설정.
25
25
  - `compact-copy` → Codex 내장 `/copy`(경로는 Codex 규약). `omc-reference` → Codex는 OMX 카탈로그 기준. `setup` 커맨드 → Codex는 프롬프트 선택 UX.
26
26
  `setup-insane-search` 도 `both`: Codex에선 `codex plugin add insane-research-codex@gptaku-codex`.
@@ -50,8 +50,10 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
50
50
  - `remains` — the same procedure in both runtimes; box runs go through `scripts/boxes.mjs` (Codex: `~/.codex/skills/banker-remains/scripts/boxes.mjs`), which needs `ssh`, `scp` and `git` on the machine and key-only logins to the boxes in `~/.config/banker/test-boxes.json`. It reads and runs tests only: no fix, no commit, no push.
51
51
  - `ultra-chain` — the same nine stages in both runtimes. Claude=OMC 5 (`ralplan --deliberate`, `ralph --critic=critic`); Codex=OMX (`$ralplan --deliberate`, then `$ralph` with the skill's own critic loop through `spawn_agent`, because OMX `ralph` has no `--critic`). Both add a `ralph-qa` plan quorum after ralplan (two reviewers, no option to add more), run OS tests through the sibling `remains/scripts/boxes.mjs` (Codex: `banker-remains`), judge screenshots with a fresh agent (OMX 0.20.2's `visual-verdict` is a deprecated stub), and render the process report from the skill's own ledger (`lineage` reads Claude Code sessions only). Box runs need `ssh`, `scp` and `git`; screenshots need Playwright with Chromium; under Codex's `workspace-write` sandbox the ssh calls and the review seats' home writes may ask for approval.
52
52
  - `theme-selection` — the same procedure in both runtimes; the engine is `scripts/themes.mjs` (Codex: `~/.codex/skills/banker-theme-selection/scripts/themes.mjs`), which reads `assets/palettes.json` beside it. Pure Node with no network; it writes only the file `--out` names (refused when that file itself is a symbolic link), and the skill asks before it overwrites one that exists.
53
+ - `progress-mon` — the same procedure in both runtimes; the engine is `scripts/progress-mon.mjs` (Codex npm install: `~/.codex/skills/banker-progress-mon/scripts/progress-mon.mjs`; plugin install: `~/.codex/plugins/cache/<marketplace>/banker/<version>/skills/progress-mon/scripts/progress-mon.mjs`), every command with `--runtime claude|codex`. The data folder is `.omc/progress-mon/` for Claude Code and `.omx/progress-mon/` for Codex. Pure Node with no network; the server binds 127.0.0.1 by default (`--host` takes a private IPv4 only). On Codex `start`, `stop`, `status` and `init` each ask for `sandbox_permissions: "require_escalated"` (`init` writes the git exclude list), and since closing the session may stop the server, a long-lived screen runs from the user's terminal (`serve`). `init --memory agents-md` writes its rule to `AGENTS.override.md` when that file has content of its own, as Codex then reads it instead of `AGENTS.md`. Not the same as `/progress` (the session's own task panel).
53
54
  - `/graceful-pause` — in Claude Code a function-hooks command (`hooks/register.mjs`, 2.1.289+, not a manifest surface) registered `immediate`, so it runs mid-turn; it appends one hidden user row the running turn reads at its next model request. In Codex the `codex-only` skill `graceful-pause` (`$banker-graceful-pause`): a message sent with Enter while a turn runs is held as "Messages to be submitted after next tool call" and put into the same turn when the running tool call ends, so the session stops after the current step (Codex 0.144.5: verified with `sleep 40` in a real TUI).
54
55
  - `/progress` — in Claude Code a mod panel (`hooks/progress.mjs`). In Codex the `codex-only` skill `progress` (`$banker-progress`, `codex/skills/progress/scripts/progress.mjs`): it reads this session's rollout, found by `CODEX_THREAD_ID` (the id at the end of the rollout file name), and prints the last plan file the session wrote or read (tool-call commands and `apply_patch` file heads; `.omx/plans/` counts like `.omc/plans/`) as a tree with the same engine (`progress-plan.mjs`, a byte-identical copy kept by `scripts/sync-adapter.js`), then the last `update_plan` steps (or the typed requests) with the recent tool calls under the step in progress. Not a live panel: `--pane` opens a tmux side pane that redraws every 2 s.
56
+ - `teamwork` — `codex-only` (`$banker-teamwork`, plugin `$banker:teamwork`, `codex/skills/teamwork/scripts/teamwork.mjs`): peer work between the Codex sessions of one Codex home. `on` copies the engine to `$CODEX_HOME/teamwork/bin/` beside an sh wrapper, adds a `SessionStart` and a `UserPromptSubmit` hook last under each event (taking the same `hooks.json.lock` as `bin/lib/codex-hooks.mjs`, `timeout: 5`, fixed in place; the command ends with `--rev <hash of engine and wrapper>`, so a newer engine asks for trust once more) and writes `$CODEX_HOME/rules/teamwork.rules`, a `prefix_rule` that lets only the wrapper's `peers`, `role`, `send`, `reply`, `status` and `version` run outside the sandbox (`on`, `off`, `uninstall` ask for approval; a same-named file without the `# teamwork` first line is left alone). The wrapper and the hooks run outside the sandbox, so `node`, `codex`, `ps` and `sh` are recorded as absolute paths at `on` and nothing is looked up on PATH afterwards; programs, and a Codex home, under a temp folder, the session folder or its git top, or a `writable_roots` entry are refused (exit 6). Requests and results travel by `codex queue --thread <UUID>` (Codex 0.149.0 or later). Windows ends `on` with exit 6 and the reason: on Codex 0.160.1 for Windows, `codex execpolicy check` matches the rule only for the wrapper called directly, not for the PowerShell call form (`& '<wrapper>' send ...`), so every call would ask for approval. `banker uninstall --codex` removes it only when no other way of banker into that Codex home is left.
55
57
  - Update notice and usage counting — Claude Code uses the plugin's own hooks (`SessionStart`, `PostToolUse` Skill, `UserPromptExpansion`). Codex gets `SessionStart` (`update-notify.mjs --codex`, shown as a warning line in the TUI) and `Stop` (`telemetry-count-codex.mjs`, once per turn rather than per tool call). Codex shows a skill in two ways and the Stop hook counts both from this turn of the rollout: a model-picked skill is read by a tool call (`cat .../skills/banker-<name>/SKILL.md`), and a skill the user names with `$banker-<name>` is injected without a tool call as a user-role message starting `<skill>\n<name>..</name>\n<path>..</path>`, of which only the `<path>` head is read. Codex runs trusted hooks outside the sandbox, so the update query and check-in reach the network as in Claude Code.
56
58
  - Rollout shape — `lineage`, `progress` and the Stop hook read Codex's session record, whose shape Codex does not promise (0.144 and 0.160 already differ: event `user_message`/`agent_message` vs `item_completed` items, `exec_command` calls vs `exec` code). A shape banker cannot read is never an empty result: lineage stops with exit 2 naming the Codex version when a rollout gives no user turn, progress prints a warning line, and the Stop hook, finding no `task_started` for the turn in the whole record, notes the version in `codex-shape-miss.json` so the next SessionStart (`update-notify.mjs --codex`) shows one warning. `banker doctor` checks the newest rollout (turn start, user text, tool calls) after a Codex update.
57
59
 
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
 
@@ -56,35 +56,46 @@ const CODEX = process.argv.includes('--codex');
56
56
  // JSON names, regular single-name pages, a canonicalizer pinned to the checked omx_wiki/ of the session folder).
57
57
  const OMX_WIKI_WRITE_RE = /^\s*(?:&\s*)?['"]?omx(?:\.cmd|\.ps1)?['"]?\s+wiki\s+(?:wiki_)?(?:ingest|add)\b/;
58
58
 
59
- // A command's statements, split at `;`, `&&`, `||` and line ends outside quotes. A backslash keeps the next character
60
- // outside quotes and inside double quotes; a backtick does so only inside double quotes (PowerShell's escape there; in
61
- // sh a backtick starts a command substitution, so outside quotes it is an ordinary character here). Inside single
62
- // quotes nothing is special.
63
- function statements(command) {
64
- const out = [];
65
- let cur = '';
66
- let quote = '';
67
- for (let i = 0; i < command.length; i++) {
68
- const c = command[i];
69
- const two = command.slice(i, i + 2);
70
- if ((c === '\\' && quote !== "'") || (c === '`' && quote === '"')) {
71
- cur += command.slice(i, i + 2);
72
- i++;
73
- } else if (quote) {
74
- if (c === quote) quote = '';
75
- cur += c;
76
- } else if (c === '"' || c === "'") {
77
- quote = c;
78
- cur += c;
79
- } else if (c === ';' || c === '\n' || c === '\r' || two === '&&' || two === '||') {
80
- out.push(cur);
81
- cur = '';
82
- if (two === '&&' || two === '||') i++;
83
- } else cur += c;
59
+ // A command's statements, split at `;`, `&&`, `||` and line ends outside quotes, by the rules of the shell Codex runs
60
+ // it in: PowerShell on Windows, sh elsewhere (shell defaults by process.platform).
61
+ // - sh: a backslash keeps the next character, outside quotes and inside double quotes. A backtick outside quotes opens
62
+ // a command substitution that the next backtick closes; its inside is part of the statement around it.
63
+ // - PowerShell: a backslash is an ordinary character (`cd C:\work\; omx wiki add` is two statements). A backtick keeps
64
+ // the next character, outside quotes and inside double quotes.
65
+ // Inside single quotes nothing is special. A quote or backtick left open (an apostrophe in a here-string or a comment)
66
+ // leaves the scan inside it to the end; then the plain split is taken too, so such a command is not missed.
67
+ const SHELL_RULES = {
68
+ sh: { escape: (c, quote) => c === '\\' && quote !== "'", opens: `"'\``, },
69
+ powershell: { escape: (c, quote) => c === '`' && quote !== "'", opens: `"'` },
70
+ };
71
+ const PAIRS = new Set(['&&', '||']);
72
+ const isBreak = (c, two) => c === ';' || c === '\n' || c === '\r' || PAIRS.has(two);
73
+ export function statements(command, shell = process.platform === 'win32' ? 'powershell' : 'sh') {
74
+ const rule = SHELL_RULES[shell] ?? SHELL_RULES.sh;
75
+ const st = { out: [], cur: '', quote: '' };
76
+ for (let i = 0; i < command.length; i++) i += scan(st, rule, command[i], command.slice(i, i + 2));
77
+ return st.quote ? [...st.out, st.cur, ...command.split(/;|&&|\|\||\r?\n/)] : [...st.out, st.cur];
78
+ }
79
+
80
+ // One character of statements(): it joins the statement or ends one. Returns how many more characters it took (0, 1).
81
+ function scan(st, rule, c, two) {
82
+ if (rule.escape(c, st.quote)) {
83
+ st.cur += two;
84
+ return 1;
85
+ }
86
+ if (st.quote || rule.opens.includes(c)) {
87
+ if (!st.quote) st.quote = c;
88
+ else if (c === st.quote) st.quote = '';
89
+ st.cur += c;
90
+ return 0;
91
+ }
92
+ if (!isBreak(c, two)) {
93
+ st.cur += c;
94
+ return 0;
84
95
  }
85
- // A lone quote (an apostrophe in a here-string or a comment) leaves the scan inside quotes to the end; then the plain
86
- // split is taken too, so such a command is not missed.
87
- return quote ? [...out, cur, ...command.split(/;|&&|\|\||\r?\n/)] : [...out, cur];
96
+ st.out.push(st.cur);
97
+ st.cur = '';
98
+ return PAIRS.has(two) ? 1 : 0;
88
99
  }
89
100
  const PAGE_NAME_RE = /^[\w.-]+\.md$/;
90
101
 
@@ -270,9 +281,29 @@ function codexWikiWrite(payload) {
270
281
  return typeof command === 'string' && statements(command).some((part) => OMX_WIKI_WRITE_RE.test(part));
271
282
  }
272
283
 
284
+ // Codex: when where this hook runs (the script, its plugin or copy folder, node) is under a config.toml writable_roots
285
+ // entry, it does nothing and says why on stderr (the session start hook says it as a systemMessage). Unreadable: runs.
286
+ // Loaded only on Codex, so the Claude Code hook needs nothing beside it.
287
+ async function codexUnsafe(payload) {
288
+ let why = '';
289
+ try {
290
+ const r = await import('../bin/lib/codex-routes.mjs');
291
+ why = r.writableRootsNote(r.codexHomeOf(payload?.transcript_path), r.hookPlaces(import.meta.url));
292
+ } catch { /* as before */ }
293
+ if (why) process.stderr.write(`${why}\n`);
294
+ return Boolean(why);
295
+ }
296
+
297
+ // Codex: only an `omx wiki` write goes on (an unreadable payload goes on to be logged), and only from outside the
298
+ // writable roots.
299
+ async function codexSkip(payload, error) {
300
+ if (!error && !codexWikiWrite(payload)) return true;
301
+ return codexUnsafe(payload);
302
+ }
303
+
273
304
  async function main() {
274
305
  const { payload, error } = await readPayload();
275
- if (CODEX && !error && !codexWikiWrite(payload)) return;
306
+ if (CODEX && await codexSkip(payload, error)) return;
276
307
  const wikiDir = findArmedWikiDir(writeRoot(payload));
277
308
  if (!wikiDir) return; // Not armed: change nothing, log nothing.
278
309
 
@@ -286,6 +317,18 @@ async function main() {
286
317
  }
287
318
  }
288
319
 
289
- main()
290
- .catch(() => {}) // An un-canonicalized page is not-yet-formatted; the next bare run repairs it.
291
- .finally(() => process.exit(0));
320
+ // Run as the hook only (node, run.cjs, or the npm hook command's import); a test imports statements() alone. Both
321
+ // paths go through realpath: Node resolves links for import.meta.url but not for process.argv[1].
322
+ function invokedDirectly() {
323
+ try {
324
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ if (invokedDirectly()) {
331
+ main()
332
+ .catch(() => {}) // An un-canonicalized page is not-yet-formatted; the next bare run repairs it.
333
+ .finally(() => process.exit(0));
334
+ }
@@ -7,25 +7,43 @@
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,codex/renamed-skills}/<name>/`
12
+ * 에 있다. 두 길의 경로를 모두 센다. Codex 가 다른 이름으로 부르는 스킬(setup-omc 는 Codex 에서 setup-omx)은 Codex 이름으로
13
+ * 설치를 확인한 뒤 Claude 이름으로 센다(bin/lib/codex-names.mjs, codex/manifest.json 의 codexName 에서 생성). 개인화 알림의
14
+ * 바뀐 스킬 목록(skill-changes.json)도 Claude 이름이라 두 런타임의 셈이 한 이름으로 맞는다.
10
15
  * 도구 호출마다 도는 PreToolUse 대신 턴마다 한 번 도는 Stop 에서, 훅 입력의 transcript_path(rollout) 중
11
16
  * 이번 턴(task_started 의 turn_id 뒤)의 두 기록에 `skills/banker-<name>/SKILL.md` 가 있으면 센다. 나눠 읽어도 한 번이고, Stop 이 한 턴에 두 번 와도
12
- * 최근 턴 키(configDir 의 codex-count-turns)로 한 번만 센다. 기록은 끝에서부터 32 MB 까지만 읽는다.
17
+ * (턴, 이름)마다 configDir 의 codex-count-turns.d 에 표지 파일을 만든 훅만 센다(옛 판과는 codex-count-turns 를 함께 봄).
18
+ * 기록은 끝에서부터 32 MB 를 줄로 읽고, 턴 시작이 그 밖이면 256 MB 까지 바이트로 찾는다.
13
19
  *
14
20
  * fail-closed 규약은 Claude 쪽과 같다(telemetry-count-codex.test.mjs 가 검증):
15
21
  * - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다.
16
- * - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md)만.
22
+ * - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md, 또는
23
+ * 기록의 경로 그대로 <CODEX_HOME>/plugins/cache/ 아래에 있는 플러그인의 SKILL.md)만.
17
24
  * 모델이 `echo skills/banker-<아무 글>/SKILL.md` 를 돌려 체크인의 이름 칸으로 글을 내보내지 못하게 한다. 턴 키 파일은 일반 파일만 읽고, 링크 자리에 쓰지 않는다.
18
25
  * - 어떤 오류도 삼키고 항상 exit 0. 도구 호출을 절대 막지 않는다. 표준 출력은 비운다.
19
26
  */
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';
27
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ import { join } from 'node:path';
23
30
 
24
- import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
31
+ import { CLAUDE_NAMES } from '../bin/lib/codex-names.mjs';
32
+ import { codexHomeOf, hookPlaces, pluginRoutes, writableRootsNote } from '../bin/lib/codex-routes.mjs';
33
+ import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, randomHex, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
25
34
 
26
- const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/gi;
35
+ export { codexHomeOf };
36
+
37
+ const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
38
+ // 플러그인 길: plugins/cache/<마켓플레이스>/banker/<판>/<스킬 폴더>/<name>/SKILL.md. 1 마켓플레이스, 2 판, 3 스킬 폴더, 4 이름.
39
+ // 대소문자를 가리지 않는 파일 시스템에서 `FRONT-QA` 같은 변형이 다른 이름으로 세어지지 않게 i 를 두지 않는다.
40
+ 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|codex[\\/]+renamed-skills)[\\/]+([a-z0-9_-]{1,64})[\\/]+SKILL\.md/g;
27
41
  const KEEP_TURNS = 100;
28
42
  export const TURNS_FILE = () => join(configDir(), 'codex-count-turns');
43
+ // 센 (턴, 이름)마다 하나씩 만드는 표지 파일의 폴더, 기록 끝 256 MB 안에서도 턴 시작을 못 찾은 일을 적는 한 줄 파일.
44
+ export const MARKS_DIR = () => join(configDir(), 'codex-count-turns.d');
45
+ export const FAR_NOTE_FILE = () => join(configDir(), 'codex-count-note');
46
+ const requireBuiltin = createRequire(import.meta.url);
29
47
 
30
48
  function readStdin() {
31
49
  return new Promise((resolvePromise, rejectPromise) => {
@@ -40,12 +58,18 @@ function readStdin() {
40
58
  const MAX_READ = 32 << 20;
41
59
  const FIRST_READ = 1 << 20;
42
60
 
43
- // 글에 나오는 banker 스킬 이름(banker:<name>), 중복 없이.
61
+ // 글에 나오는 banker 스킬, 중복 없이. npm 길은 'banker:<name>', 플러그인 길은 'banker:<name>\t<홈 기준 SKILL.md 상대 경로>'
62
+ // (설치 확인에 쓰고, 셀 때는 이름만 남긴다).
44
63
  export function namesIn(text, names = new Set()) {
45
64
  for (const m of text.matchAll(SKILL_PATH_RE)) {
46
65
  const name = `banker:${m[1]}`;
47
66
  if (SKILL_NAME_RE.test(name)) names.add(name);
48
67
  }
68
+ for (const m of text.matchAll(PLUGIN_PATH_RE)) {
69
+ const name = `banker:${m[4]}`;
70
+ if (m[1] === '.' || m[1] === '..' || m[2] === '.' || m[2] === '..' || !SKILL_NAME_RE.test(name)) continue;
71
+ names.add(`${name}\t${['plugins', 'cache', m[1], 'banker', m[2], ...m[3].split(/[\\/]+/), m[4], 'SKILL.md'].join('/')}`);
72
+ }
49
73
  return names;
50
74
  }
51
75
 
@@ -70,24 +94,98 @@ const isCall = (rec) => rec?.type === 'response_item' && /_call$/.test(String(re
70
94
  const SKILL_HEAD_RE = /^<skill>\n<name>[^\n<]{1,80}<\/name>\n<path>([^\n<]{1,4096})<\/path>\n/;
71
95
 
72
96
  // $banker-<name> 으로 부른 스킬을 Codex 가 넣은 메시지의 <path>. 그 밖의 기록은 ''.
97
+ const isUserMessage = (rec) => rec?.type === 'response_item' && rec.payload?.type === 'message' && rec.payload.role === 'user';
98
+
73
99
  function injectedPath(rec) {
74
- const p = rec?.payload;
75
- if (rec?.type !== 'response_item' || p?.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return '';
76
- const text = p.content[0]?.text;
100
+ if (!isUserMessage(rec) || !Array.isArray(rec.payload.content)) return '';
101
+ const text = rec.payload.content[0]?.text;
77
102
  return typeof text === 'string' ? (SKILL_HEAD_RE.exec(text)?.[1] ?? '') : '';
78
103
  }
79
104
 
80
105
  // 이번 턴(turn_id 의 task_started 뒤)의 도구 호출과 넣은 스킬 머리에서 읽은 banker 스킬 이름. 사용자 글과 모델 답은 세지 않는다.
81
106
  // 턴 시작은 대개 기록 끝 가까이에 있으므로 끝 1 MB 부터 읽고, 못 찾으면 두 배씩 max 까지 넓힌다.
107
+ // 그래도 없으면 farSkills 가 그 앞을 바이트로 찾는다(한 턴이 32 MB 를 넘게 쓴 경우).
82
108
  // 기록 전체를 읽고도 task_started 가 하나도 없으면 null: Codex 는 턴마다 task_started 를 쓰므로 기록 형식이 바뀐 것이다.
83
109
  // 이 턴의 것만 없을 때(시작이 기록되지 않는 턴)는 형식 변경으로 보지 않는다.
84
- export function turnSkills(file, turnId, max = MAX_READ) {
110
+ export function turnSkills(file, turnId, max = MAX_READ, far = {}) {
85
111
  for (let size = Math.min(FIRST_READ, max); ; size = Math.min(size * 2, max)) {
86
112
  const { lines, whole } = tailLines(file, size);
87
113
  const start = turnStart(lines, turnId);
88
114
  if (start >= 0) return skillsAfter(lines, start);
89
115
  if (whole) return lines.some((l) => l.trim()) && !lines.some((l) => l.includes('"task_started"')) ? null : [];
90
- if (size >= max) return [];
116
+ if (size >= max) return farSkills(file, turnId, max, far);
117
+ }
118
+ }
119
+
120
+ // 기록 끝 max 바이트 앞에서 이 턴의 시작을 찾는다. 끝에서 앞으로 chunk 씩 읽어 Buffer.lastIndexOf 로 턴 id 를 찾고
121
+ // (문자열로 바꾸지 않음, 조각 경계는 4 KB 겹쳐 읽음), 그 줄에 task_started 가 있으면 거기서부터 chunk 씩 앞으로 읽으며
122
+ // 이름만 모은다. 기록 끝에서 cap 바이트까지, ms 밀리초까지만(훅 시간 제한 5초). 못 찾으면 빈 목록에 far 표시를 단다.
123
+ const FAR_READ = 256 << 20;
124
+ const FAR_CHUNK = 32 << 20;
125
+ const FAR_MS = 2000;
126
+ const OVERLAP = 4 << 10;
127
+ const NL = 0x0a;
128
+ export function farSkills(file, turnId, max, { cap = FAR_READ, chunk = FAR_CHUNK, ms = FAR_MS } = {}) {
129
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
130
+ try {
131
+ const size = fstatSync(fd).size;
132
+ const deadline = Date.now() + ms;
133
+ const at = findStart(fd, { size, needle: Buffer.from(`"${turnId}"`), from: size - max, floor: Math.max(0, size - cap), chunk, deadline });
134
+ if (at < 0) return Object.assign([], { far: true });
135
+ return [...namesFrom(fd, at, size, chunk, deadline)];
136
+ } finally {
137
+ closeSync(fd);
138
+ }
139
+ }
140
+
141
+ function readAt(fd, start, len) {
142
+ const buf = Buffer.alloc(len);
143
+ return buf.subarray(0, readSync(fd, buf, 0, len, start));
144
+ }
145
+
146
+ // task_started 와 이 턴 id 가 한 줄에 있는 마지막 자리(파일 위치). 없으면 -1.
147
+ function findStart(fd, { size, needle, from, floor, chunk, deadline }) {
148
+ const mark = Buffer.from('"task_started"');
149
+ for (let end = from; end > floor && Date.now() <= deadline; end -= chunk) {
150
+ const lo = Math.max(floor, end - chunk);
151
+ const buf = readAt(fd, lo, Math.min(end + OVERLAP, size) - lo);
152
+ for (let i = buf.lastIndexOf(needle); i >= 0; i = i > 0 ? buf.lastIndexOf(needle, i - 1) : -1) {
153
+ const ls = buf.lastIndexOf(NL, i) + 1;
154
+ const le = buf.indexOf(NL, i);
155
+ const at = buf.subarray(ls, le < 0 ? buf.length : le).indexOf(mark);
156
+ if (at >= 0) return lo + ls + at;
157
+ }
158
+ }
159
+ return -1;
160
+ }
161
+
162
+ // at(턴 시작 줄) 뒤로 기록 끝까지 chunk 씩 읽으며 SKILL.md 가 든 줄만 문자열로 바꿔 이름을 모은다.
163
+ function namesFrom(fd, at, size, chunk, deadline) {
164
+ const names = new Set();
165
+ let carry = Buffer.alloc(0);
166
+ let skip = true; // 턴 시작 줄의 남은 부분
167
+ for (let pos = at; pos < size && Date.now() <= deadline; pos += chunk) {
168
+ let buf = Buffer.concat([carry, readAt(fd, pos, Math.min(chunk, size - pos))]);
169
+ carry = Buffer.alloc(0);
170
+ if (skip) {
171
+ const nl = buf.indexOf(NL);
172
+ if (nl < 0) continue;
173
+ [buf, skip] = [buf.subarray(nl + 1), false];
174
+ }
175
+ const cut = pos + chunk >= size ? buf.length : buf.lastIndexOf(NL) + 1;
176
+ linesWithSkill(buf.subarray(0, cut), names);
177
+ carry = buf.subarray(cut);
178
+ }
179
+ return names;
180
+ }
181
+
182
+ const SKILL_BYTES = Buffer.from('SKILL.md');
183
+ function linesWithSkill(buf, names) {
184
+ for (let hit = buf.indexOf(SKILL_BYTES); hit >= 0;) {
185
+ const le = buf.indexOf(NL, hit);
186
+ const end = le < 0 ? buf.length : le;
187
+ lineNames(buf.toString('utf8', buf.lastIndexOf(NL, hit) + 1, end), names);
188
+ hit = buf.indexOf(SKILL_BYTES, end);
91
189
  }
92
190
  }
93
191
 
@@ -126,16 +224,18 @@ function turnStart(lines, turnId) {
126
224
 
127
225
  function skillsAfter(lines, start) {
128
226
  const names = new Set();
129
- for (const line of lines.slice(start + 1)) {
130
- if (!line.includes('SKILL.md')) continue;
131
- let rec;
132
- try { rec = JSON.parse(line); } catch { continue; }
133
- if (isCall(rec)) namesIn(JSON.stringify(rec.payload), names);
134
- else namesIn(injectedPath(rec), names);
135
- }
227
+ for (const line of lines.slice(start + 1)) lineNames(line, names);
136
228
  return [...names];
137
229
  }
138
230
 
231
+ function lineNames(line, names) {
232
+ if (!line.includes('SKILL.md')) return;
233
+ let rec;
234
+ try { rec = JSON.parse(line); } catch { return; }
235
+ if (isCall(rec)) namesIn(JSON.stringify(rec.payload), names);
236
+ else namesIn(injectedPath(rec), names);
237
+ }
238
+
139
239
  const ID_RE = /^[A-Za-z0-9._-]{1,80}$/;
140
240
 
141
241
  // 이 턴의 키. session_id 와 turn_id 가 모양이 맞을 때만(파일 한 줄 형식을 깨지 않게).
@@ -170,40 +270,204 @@ function writeTurns(lines) {
170
270
  }
171
271
  }
172
272
 
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');
273
+ // 이 Codex 에 설치된 banker 스킬인지. 플러그인 길은 그 홈의 config.toml 에 켜져 있는 banker@<마켓플레이스> 의 것이고,
274
+ // 기록의 경로 그대로 plugins/cache 아래에 있어야 한다(지운 플러그인이 남긴 폴더나 다른 마켓플레이스의 banker 는 세지 않음).
275
+ export function installed(entry, home, routes = pluginRoutes(home)) {
276
+ const [name, rel] = entry.split('\t');
277
+ if (rel) return routes.includes(`banker@${rel.split('/')[2]}`) && existsSync(join(home, ...rel.split('/')));
278
+ return existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
179
279
  }
180
280
 
181
- // 이 Codex 에 설치된 banker 스킬인지.
182
- export const installed = (name, home) => existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
281
+ // 'banker:<Codex 이름>' 을 'banker:<Claude 이름>' 으로. 이름이 같은 스킬은 그대로.
282
+ export function claudeName(entry) {
283
+ const name = entry.slice('banker:'.length);
284
+ return Object.hasOwn(CLAUDE_NAMES, name) ? `banker:${CLAUDE_NAMES[name]}` : entry;
285
+ }
183
286
 
184
- // 이 턴에 아직 세지 않은 이름만 센다.
185
- export function countOnce(payload) {
287
+ // 이 턴에 아직 세지 않은 이름만 센다. far 는 기록 읽기 한도(시험이 낮춤): max 줄로 읽는 끝 부분, 그 밖은 farSkills 의 것.
288
+ export function countOnce(payload, far = {}) {
186
289
  const key = turnKey(payload);
187
290
  const file = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
188
291
  const home = codexHomeOf(file);
189
- const got = key && file ? turnSkills(file, payload.turn_id) : [];
292
+ const got = readTurn(key, file, payload, far);
190
293
  if (got === null) {
191
294
  noteShapeMiss(file);
192
295
  return [];
193
296
  }
194
- const names = got.filter((n) => installed(n, home));
297
+ if (got.far) noteFar(key);
298
+ const routes = got.some((n) => n.includes('\t')) ? pluginRoutes(home) : []; // config.toml 은 한 번만 읽는다
299
+ const names = [...new Set(got.filter((n) => installed(n, home, routes)).map((n) => claudeName(n.split('\t')[0])))];
195
300
  if (!names.length) return [];
301
+ return withTurnsLock(() => countFresh(key, names));
302
+ }
303
+
304
+ function readTurn(key, file, payload, far) {
305
+ return key && file ? turnSkills(file, payload.turn_id, far.max ?? MAX_READ, far) : [];
306
+ }
307
+
308
+ // 옛 판(0.22.0 까지)은 codex-count-turns 만 보고, 이 판은 (턴, 이름)마다 표지 파일을 wx 로 만든 훅만 센다.
309
+ // 옛 파일에 이미 있는 것은 표지만 만들고 세지 않는다. 센 것은 옛 판이 다시 세지 않게 옛 파일에도 적는다.
310
+ function countFresh(key, names) {
196
311
  const seen = readTurns();
197
- const fresh = names.filter((n) => !seen.includes(`${key}\t${n}`));
198
- if (!fresh.length) return [];
312
+ const fresh = names.filter((n) => markTurn(key, n) && !seen.includes(`${key}\t${n}`));
199
313
  for (const n of fresh) {
200
314
  appendUsageLog(n);
201
315
  recordUsedSkill(n);
202
316
  }
203
- writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
317
+ if (fresh.length) writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
318
+ dropOldMarks();
204
319
  return fresh;
205
320
  }
206
321
 
322
+ // (턴, 이름)의 표지를 이 훅이 만들었는지. 만들기는 wx(이미 있으면 실패, 링크도 따라가지 않음)라 함께 도는 훅 가운데
323
+ // 하나만 성공한다. 표지 폴더가 링크이거나 남의 폴더이면, 또 Windows 의 wx 가 끊어진 링크를 따라가 만든 것이면 세지 않는다.
324
+ // Windows 의 wx 는 끊어진 링크를 따라가 그 대상을 만들므로, 그 자리에 무엇이든(끊어진 링크 포함) 있으면 열지 않는다.
325
+ function markTurn(key, name) {
326
+ const dir = MARKS_DIR();
327
+ const file = join(dir, markName(`${key}\t${name}`));
328
+ try {
329
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
330
+ const st = lstatSync(dir);
331
+ if (!st.isDirectory() || (typeof process.getuid === 'function' && st.uid !== process.getuid())) return false;
332
+ if (taken(file)) return false;
333
+ closeSync(openSync(file, 'wx', 0o600));
334
+ return lstatSync(file).isFile();
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ function taken(file) {
341
+ try {
342
+ lstatSync(file);
343
+ return true;
344
+ } catch (e) {
345
+ return e.code !== 'ENOENT';
346
+ }
347
+ }
348
+
349
+ // 표지 이름: sha256 앞 32자. node:crypto 는 셀 것이 있을 때만 불러오고, crypto 없이 빌드한 Node 에서는 FNV-1a 넷.
350
+ function markName(text) {
351
+ try {
352
+ return requireBuiltin('node:crypto').createHash('sha256').update(text).digest('hex').slice(0, 32);
353
+ } catch {
354
+ let out = '';
355
+ for (let seed = 0; seed < 4; seed++) {
356
+ let h = (0x811c9dc5 ^ seed) >>> 0;
357
+ for (const c of Buffer.from(text)) h = Math.imul(h ^ c, 0x01000193) >>> 0;
358
+ out += h.toString(16).padStart(8, '0');
359
+ }
360
+ return out;
361
+ }
362
+ }
363
+
364
+ // 이틀 지난 표지를 지운다. Codex 는 끝난 턴에 Stop 을 다시 보내지 않는다.
365
+ const MARK_KEEP_MS = 2 * 24 * 60 * 60 * 1000;
366
+ function dropOldMarks() {
367
+ const dir = MARKS_DIR();
368
+ try {
369
+ for (const name of readdirSync(dir)) {
370
+ try {
371
+ if (Date.now() - lstatSync(join(dir, name)).mtimeMs > MARK_KEEP_MS) rmSync(join(dir, name), { force: true });
372
+ } catch { /* 다른 훅이 먼저 지움 */ }
373
+ }
374
+ } catch { /* 다음에 */ }
375
+ }
376
+
377
+ // 턴 시작이 기록 끝 256 MB 밖이라 세지 않은 일을 한 줄로 남긴다(마지막 한 번만).
378
+ function noteFar(key) {
379
+ try {
380
+ writeByRename(FAR_NOTE_FILE(), `${new Date().toISOString()}\t${key}\t턴 시작이 기록 끝 256 MB 밖, 이 턴은 세지 않음\n`);
381
+ } catch { /* 기록만 못 함 */ }
382
+ }
383
+
384
+ // npm 길과 플러그인 길의 Stop 훅이 같은 턴에 함께 돌면 둘 다 턴 키를 읽고 셀 수 있다. 셀지는 표지 파일(markTurn)이
385
+ // 정하고, 잠금은 codex-count-turns 를 읽고 고쳐 쓰는 동안 잡는다(mkdir 은 이미 있으면 실패하고 링크를 따라가지 않음).
386
+ // 2초 안에 못 잡으면 잠금 없이 표지로 정한다. 잠금은 몇 ms 만 잡으므로 훅 시간 제한(5초)보다 조금 긴 6초가 지난
387
+ // 잠금은 죽은 훅의 것이다: 고유 이름으로 옮겨(rename 은 하나만 성공) 지운 뒤 다시 잡는다. 옮긴 것이 새것이면(그사이
388
+ // 다른 훅이 넘겨받아 잡은 것) 그 훅에 이 턴을 맡기고 세지 않고 물러난다.
389
+ // configDir 을 만들 수 없거나 잠금 폴더를 만들 수 없는 다른 오류면 잠금 없이 표지로 정한다.
390
+ const LOCK_WAIT_MS = 2000;
391
+ const LOCK_STALE_MS = 6000;
392
+ export function withTurnsLock(fn, { wait = LOCK_WAIT_MS } = {}) {
393
+ const lock = `${TURNS_FILE()}.lock`;
394
+ try {
395
+ mkdirSync(configDir(), { recursive: true });
396
+ } catch {
397
+ return fn();
398
+ }
399
+ const end = Date.now() + wait;
400
+ const token = `${process.pid}-${randomHex()}`;
401
+ for (;;) {
402
+ const got = takeLock(lock, token);
403
+ if (got === 'error') return fn();
404
+ if (got === 'held') break;
405
+ const old = takeOverStale(lock);
406
+ if (old === 'backoff') return [];
407
+ if (old === 'retry') continue;
408
+ if (Date.now() >= end) return fn();
409
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
410
+ }
411
+ try {
412
+ dropMovedLocks(lock);
413
+ return fn();
414
+ } finally {
415
+ releaseLock(lock, token);
416
+ }
417
+ }
418
+
419
+ // 'held' 잡음, 'busy' 다른 훅이 잡고 있음, 'error' 잠금 폴더를 만들 수 없음.
420
+ function takeLock(lock, token) {
421
+ try {
422
+ mkdirSync(lock);
423
+ } catch (e) {
424
+ return e?.code === 'EEXIST' ? 'busy' : 'error';
425
+ }
426
+ try { writeFileSync(join(lock, 'owner'), token, { flag: 'wx' }); } catch { /* 표시 없이도 잠금은 잡았다 */ }
427
+ return 'held';
428
+ }
429
+
430
+ // 낡은 잠금을 넘겨받는다. 'retry' 치웠으니 다시 잡기, 'backoff' 물러남, 'live' 살아 있는 잠금(또는 방금 풀림).
431
+ function takeOverStale(lock) {
432
+ try {
433
+ if (Date.now() - statSync(lock).mtimeMs <= LOCK_STALE_MS) return 'live';
434
+ const old = `${lock}.stale-${process.pid}-${randomHex()}`;
435
+ renameSync(lock, old);
436
+ // 본 것과 옮긴 것 사이에 다른 훅이 낡은 잠금을 치우고 새로 잡았을 수 있다. 옮긴 것이 새것이면 그 훅이 이
437
+ // 턴을 세는 중이므로 세지 않고 물러난다. 돌려놓지 않는다(그사이 세 번째 훅이 잡은 잠금을 덮을 수 있다).
438
+ // 옮긴 폴더는 다음에 잠금을 잡은 훅이 치운다.
439
+ if (Date.now() - statSync(old).mtimeMs <= LOCK_STALE_MS) return 'backoff';
440
+ rmSync(old, { recursive: true, force: true });
441
+ return 'retry';
442
+ } catch {
443
+ return 'live'; // 방금 풀렸거나 다른 훅이 먼저 치움: 다시 잡아 본다
444
+ }
445
+ }
446
+
447
+ // 잠금 폴더 안의 주인 표시(owner)가 이 훅의 것일 때만 놓는다. 넘겨받던 훅이 이 잠금을 옮기고 다른 훅이 그 이름을
448
+ // 새로 잡았으면, 그 훅의 잠금은 지우지 않는다.
449
+ function releaseLock(lock, token) {
450
+ try {
451
+ if (readFileSync(join(lock, 'owner'), 'utf8') !== token) return;
452
+ rmSync(join(lock, 'owner'), { force: true });
453
+ rmdirSync(lock);
454
+ } catch { /* 없거나 남의 것 */ }
455
+ }
456
+
457
+ // 넘겨받다 물러난 훅이 남긴 옮긴 잠금(<잠금>.stale-*) 가운데 오래된 것을 지운다.
458
+ function dropMovedLocks(lock) {
459
+ const dir = configDir();
460
+ const prefix = `${lock.slice(dir.length + 1)}.stale-`;
461
+ try {
462
+ for (const name of readdirSync(dir)) {
463
+ if (!name.startsWith(prefix)) continue;
464
+ try {
465
+ if (Date.now() - statSync(join(dir, name)).mtimeMs > LOCK_STALE_MS) rmSync(join(dir, name), { recursive: true, force: true });
466
+ } catch { /* 다른 훅이 먼저 지움 */ }
467
+ }
468
+ } catch { /* 다음에 */ }
469
+ }
470
+
207
471
  async function main() {
208
472
  let payload;
209
473
  try {
@@ -212,6 +476,10 @@ async function main() {
212
476
  return;
213
477
  }
214
478
  if (!countingActive()) return;
479
+ // 이 훅이 도는 곳이 config.toml 의 writable_roots 아래면 세지 않고 까닭만 stderr 로(세션 시작 훅은 systemMessage 로 알림).
480
+ let unsafe = '';
481
+ try { unsafe = writableRootsNote(codexHomeOf(payload?.transcript_path), hookPlaces(import.meta.url)); } catch { /* 읽지 못하면 전처럼 */ }
482
+ if (unsafe) { process.stderr.write(`${unsafe}\n`); return; }
215
483
  countOnce(payload);
216
484
  }
217
485