@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +69 -0
- package/PRIVACY.md +8 -3
- package/README.md +78 -15
- package/bin/banker.js +154 -32
- package/bin/lib/codex-hooks.mjs +123 -20
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +204 -0
- package/bin/lib/command-skill.cjs +50 -0
- package/bin/lib/telemetry-config.mjs +2 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +18 -15
- package/codex/skills/graceful-pause/SKILL.md +2 -2
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +137 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +957 -0
- package/codex/transform-matrix.md +5 -3
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +303 -35
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +146 -13
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +277 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +387 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/lineage/lineage.py +1435 -533
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +20 -5
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +2 -2
- package/skills/play-qa/SKILL.md +7 -5
- package/skills/progress-mon/README.md +162 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +526 -0
- package/skills/progress-mon/scripts/pm-config.mjs +341 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +308 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +735 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +105 -16
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +159 -155
- package/skills/remains/SKILL.md +2 -1
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +2 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/smart-compact/SKILL.md +1 -1
- package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +34 -16
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +196 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +32 -7
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- 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 `
|
|
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
|
-
|
|
23
|
-
- 오케스트레이터 `all-in-one`·`ultra-init`·커맨드 `front-qa` → OMX `ralplan`/`ralph
|
|
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>`
|
|
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
|
|
package/hooks/obsidize-hook.mjs
CHANGED
|
@@ -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
|
|
60
|
-
//
|
|
61
|
-
// sh a
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
return
|
|
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 &&
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
*
|
|
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 {
|
|
22
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
// 이
|
|
174
|
-
//
|
|
175
|
-
export function
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
return
|
|
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
|
-
//
|
|
182
|
-
export
|
|
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
|
|
292
|
+
const got = readTurn(key, file, payload, far);
|
|
190
293
|
if (got === null) {
|
|
191
294
|
noteShapeMiss(file);
|
|
192
295
|
return [];
|
|
193
296
|
}
|
|
194
|
-
|
|
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
|
|