@kaydash9999/banker-plugins 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +28 -0
  4. package/PRIVACY.md +7 -3
  5. package/README.md +41 -4
  6. package/bin/banker.js +38 -12
  7. package/bin/lib/codex-hooks.mjs +7 -2
  8. package/bin/lib/codex-routes.mjs +112 -0
  9. package/bin/lib/command-skill.cjs +19 -0
  10. package/bin/lib/telemetry-config.mjs +2 -1
  11. package/codex/command-skills/front-qa/SKILL.md +56 -0
  12. package/codex/command-skills/setup/SKILL.md +87 -0
  13. package/codex/hooks.json +29 -0
  14. package/codex/skills/graceful-pause/SKILL.md +2 -2
  15. package/commands/setup.md +2 -2
  16. package/hooks/telemetry-count-codex.mjs +112 -25
  17. package/hooks/update-notify.mjs +82 -11
  18. package/package.json +1 -1
  19. package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
  20. package/skills/anti-drm/SKILL.md +1 -1
  21. package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
  22. package/skills/arch-diagram/build_pptx_template.py +47 -28
  23. package/skills/audit-web-page/SKILL.md +1 -1
  24. package/skills/audit-web-page/harness.py +0 -1
  25. package/skills/lineage/lineage.py +1435 -533
  26. package/skills/motion-graphic-make/SKILL.md +1 -1
  27. package/skills/obsidizer/SKILL.md +20 -5
  28. package/skills/payload-mon/SKILL.md +1 -1
  29. package/skills/play-qa/SKILL.md +1 -1
  30. package/skills/ralph-qa/SKILL.md +1 -1
  31. package/skills/remains/SKILL.md +1 -1
  32. package/skills/rfp-author/scripts/md2docx.py +63 -34
  33. package/skills/setup-bypass-permissions/SKILL.md +2 -2
  34. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
  35. package/skills/smart-compact/SKILL.md +1 -1
  36. package/skills/smart-compact/scripts/codex-gate.mjs +2 -1
  37. package/skills/theme-selection/SKILL.md +1 -1
  38. package/skills/tone-compact/SKILL.md +1 -1
  39. package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
  40. package/skills/ultra-chain/SKILL.md +2 -2
  41. package/skills/ultra-ui-qa/SKILL.md +1 -1
  42. package/skills/update-banker/SKILL.md +31 -6
  43. package/skills/vertical-pptx/SKILL.md +1 -1
  44. package/skills/vertical-pptx/build_a4p.js +20 -2
  45. package/skills/vertical-pptx/build_a4p.py +560 -359
@@ -14,7 +14,7 @@ description: "10초 안팎의 내레이션 없는 모션 그래픽(키네틱 타
14
14
  이 스킬은 hyperframes CLI 가 설치, 정상 동작해야 한다.
15
15
  **진행 전 가용성을 먼저 확인**하고, 문제가 있으면 **설치부터 안내**한다(추정, 임의 재설치 금지):
16
16
  - 확인: `npx hyperframes doctor`(오프라인에서는 전역 설치한 `hyperframes doctor`. 깔린 것이 없으면 `npx` 가 패키지를 받으려 해 네트워크가 없으면 약 70초 뒤 실패한다)
17
- - 실패/미설치면 먼저 `motion-graphic-setup` 스킬로 설치(Claude Code: `/banker:setup` 에서 motion-graphic-setup / Codex: `banker-motion-graphic-setup`). 설치, 검증 후 이 스킬을 이어서 진행한다.
17
+ - 실패/미설치면 먼저 `motion-graphic-setup` 스킬로 설치(Claude Code: `/banker:setup` 에서 motion-graphic-setup / Codex: `banker-motion-graphic-setup`, 플러그인 설치면 `banker:motion-graphic-setup`). 설치, 검증 후 이 스킬을 이어서 진행한다.
18
18
  - 독립 검증에서 배치한 페이지를 잴 때는 Node 용 playwright 패키지와 Chromium 이 필요하다(`npm i -g playwright && npx playwright install chromium`).
19
19
 
20
20
  ## hyperframes 란 (요약)
@@ -82,21 +82,36 @@ banker의 **첫 플러그인 선언 hook**(`hooks/hooks.json`)이 OMC 위키 쓰
82
82
  없음). 플래그가 없으면 hook은 즉시 exit 0. 방금 banker를 설치/갱신한 세션이면 완료 보고에
83
83
  **`/reload-plugins`** 실행을 안내한다(hot-reload 경로).
84
84
 
85
- Codex 에서는 플래그 자리가 `omx_wiki/.obsidizer` 이고, hook 은 banker 가 Codex 사용자 범위에 따로 설치한다.
85
+ Codex 에서는 플래그 자리가 `omx_wiki/.obsidizer` 이고, hook 은 `--enable` 할 때만 Codex 사용자 범위에 따로 설치한다.
86
+ hook 이 셸 호출마다 node 를 띄우므로 쓰지 않는 사람에게는 넣지 않는다. 설치 길과 관계없이 같다.
87
+ 아래 `<banker>` 는 `"<node>" "<banker.js>"` 다. 샌드박스 밖에서 도는 명령이므로 PATH 의 `banker` 를 이름으로 부르지 않고 두 절대경로로 쓴다.
88
+ - `<banker.js>`: Codex 플러그인 설치(`codex plugin add`, 이 SKILL.md 가 `plugins/cache/` 아래)면 `<이 SKILL.md 가 있는 폴더>/../../bin/banker.js`
89
+ (플러그인 폴더의 같은 프로그램). npm 설치면 PATH 의 `banker` 가 가리키는 파일의 실제 경로
90
+ - macOS, Linux: `node -p "require('fs').realpathSync(process.argv[1])" "$(command -v banker)"`(npm 이 만든 링크가 `bin/banker.js` 로 풀림)
91
+ - Windows: `Join-Path (Split-Path (Get-Command banker.cmd).Source) 'node_modules\@kaydash9999\banker-plugins\bin\banker.js'`
92
+ - 그 파일이 없거나 이름이 `banker.js` 가 아니면 샌드박스 밖 실행을 요청하지 않고 그 이유를 보고한 뒤 멈춘다
93
+ - `<node>`: 샌드박스 안에서 `node -p "require('fs').realpathSync(process.execPath)"` 로 얻은 실제 경로
94
+ - 두 경로 중 하나라도 임시 폴더나 작업 폴더 안이면(샌드박스가 바꿀 수 있는 곳) 샌드박스 밖 실행을 요청하지 않고 그 이유를 보고한 뒤 멈춘다. 작업 폴더가 홈 폴더
95
+ 자체이면 작업 폴더 규칙은 적용하지 않는다(nvm 처럼 홈 아래 둔 node)
96
+ - Windows 의 PowerShell 에서는 앞에 호출 연산자를 붙여 `& "<node>" "<banker.js>" hooks enable obsidize` 처럼 쓴다(없으면 구문 오류)
97
+ 이 hook 은 그때의 사본(`~/.codex/hooks-custom/banker/`)으로 돌아, 플러그인이 새 판으로 올라가도 바뀌지 않는다. `hooks status obsidize` 가
98
+ `stale` 이면 `--enable` 을 다시 실행해 새 판으로 바꾼다.
86
99
 
87
100
  - `--enable`
88
101
  1. 작업 폴더에 `omx_wiki/` 가 없으면 만들고 `omx_wiki/.obsidizer` 를 쓴다. `omx_wiki/` 는 저장소에
89
102
  넣는 폴더라 이 플래그도 커밋 대상이 될 수 있음을 보고에 적는다.
90
- 2. `banker hooks status obsidize` 로 hook 상태를 본다. `on` 이면 3을 건너뛴다.
91
- 3. `banker hooks enable obsidize` 를 실행한다. 이 명령은 `~/.codex/hooks.json` 과
103
+ 2. `<banker> hooks status obsidize` 로 hook 상태를 본다. `on` 이면 3을 건너뛴다.
104
+ 3. `<banker> hooks enable obsidize` 를 실행한다. 이 명령은 `~/.codex/hooks.json` 과
92
105
  `~/.codex/hooks-custom/banker/` 에 쓰므로 `workspace-write` 샌드박스 밖에서 돌아야 한다. 그 한 명령만
93
106
  샌드박스 밖 실행 승인을 요청한다. `--add-dir ~/.codex` 는 권하지 않는다(세션이 훅과 신뢰 기록을 고칠 수
94
107
  있게 됨). 승인을 요청할 수 없으면(`codex exec`, 승인 정책 `never`) 실행하지 않고, 사용자가 입력창에
95
- `! banker hooks enable obsidize` 로 직접 실행하도록 안내한 뒤 멈춘다.
108
+ `! <banker> hooks enable obsidize`(실제 명령으로 바꿔 적음)로 직접 실행하도록 안내한 뒤 멈춘다.
96
109
  4. Codex 를 다시 열 때 뜨는 Hooks need review 창이나 `/hooks` 에서 banker obsidize hook 을 신뢰해야
97
110
  돈다고 보고한다. 신뢰 전에는 플래그가 있어도 정규화되지 않는다.
98
111
  - `--disable`: `omx_wiki/.obsidizer` 를 지운다. hook 은 다른 프로젝트의 플래그도 보므로 남겨 두고, 어느
99
- 프로젝트에서도 쓰지 않으면 `banker hooks disable obsidize` 로 뺄 수 있다고 안내한다(같은 승인 규칙).
112
+ 프로젝트에서도 쓰지 않으면 `<banker> hooks disable obsidize` 로 뺄 수 있다고 안내한다(같은 승인 규칙).
113
+ 이 hook 은 `codex plugin remove` 로 지워지지 않는다(사용자 범위에 따로 있음). 플러그인을 지우기 전에 `<banker> hooks disable obsidize`
114
+ 를 실행하라고 함께 안내한다.
100
115
 
101
116
  ## 변환 규칙
102
117
 
@@ -16,7 +16,7 @@ Claude Code 는 요청 본문이 32MB 를 넘으면 "Request too large (max 32MB
16
16
  ## 실행
17
17
 
18
18
  이 SKILL.md 가 있는 디렉터리의 절대경로로 스크립트를 실행하고, 출력 내용을 짧게 전한다.
19
- Claude Code 는 그 경로를 스킬 맨 위 "Base directory for this skill:" 로 보여 주고, Codex 는 `~/.codex/skills/banker-payload-mon/` 이다(프로젝트 범위 설치면 `./.codex/skills/banker-payload-mon/`).
19
+ Claude Code 는 그 경로를 스킬 맨 위 "Base directory for this skill:" 로 보여 주고, Codex 는 스킬 목록의 이 SKILL.md 경로(file)로 보여 준다. npm 설치는 `~/.codex/skills/banker-payload-mon/`(프로젝트 범위 설치면 `./.codex/skills/banker-payload-mon/`), 플러그인 설치(`codex plugin add`)는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/payload-mon/` 이다.
20
20
  `~` 는 따옴표 안에서 펼쳐지지 않으니 절대경로를 쓴다.
21
21
 
22
22
  ```bash
@@ -10,7 +10,7 @@ description: "Godot 로 만든 HTML5 웹 게임을 실제 브라우저에서 Pla
10
10
  ## 0. 전제조건: playwright (없으면 설치부터)
11
11
  이 스킬은 playwright 브라우저가 있어야 동작한다. **진행 전 설치 여부를 먼저 확인**하고, 없으면 **설치부터 안내**한다(추정·임의 재설치 금지):
12
12
  - 확인: `ls "${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}" 2>/dev/null | grep -qi chromium && echo ok`
13
- - 미설치면 먼저 `setup-playwright` 스킬로 설치(Claude Code: `/banker:setup` → setup-playwright / Codex: `banker-setup-playwright`). 설치·검증 후 이 스킬을 이어서 진행한다.
13
+ - 미설치면 먼저 `setup-playwright` 스킬로 설치(Claude Code: `/banker:setup` → setup-playwright / Codex: `banker-setup-playwright`, 플러그인 설치면 `banker:setup-playwright`). 설치·검증 후 이 스킬을 이어서 진행한다.
14
14
 
15
15
  ## 언제
16
16
  - 오너가 "네가 직접 플레이/qa해봐", "게임 테스트해봐" 류로 위임.
@@ -112,7 +112,7 @@ node "<이 스킬 디렉터리 절대경로>/references/verifier-probe.mjs" --ru
112
112
  외부 좌석 CLI 는 에이전트다. 읽기 도구로 읽은 파일도 그 프로바이더로 간다. 그래서 좌석마다 페이로드 사본 하나만 든 폴더에서 띄우고, 답은 좌석 폴더 밖의 다른 폴더로 받는다.
113
113
 
114
114
  폴더, 페이로드, 비밀 검사, 좌석 명령, 시간 상한, 정리는 실행기 `references/seat-run.mjs` 가 한다. **좌석 명령과 git 사슬을 손으로 짜지 않는다.** 실행기가 멈추면 출력 JSON 의 `stop` 을 아래 멈춤 표대로 처리한다.
115
- `<R>` 은 `<이 스킬 디렉터리 절대경로>/references` 다(Codex 설치본은 `~/.codex/skills/banker-ralph-qa/references`, `CODEX_HOME` 이 있으면 그 아래 `skills/banker-ralph-qa/references`). 호출 사이에 셸 변수가 남지 않으므로 호출마다 실제 경로를 적는다.
115
+ `<R>` 은 `<이 스킬 디렉터리 절대경로>/references` 다(Codex 의 npm 설치본은 `~/.codex/skills/banker-ralph-qa/references`, 플러그인 설치본은 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/ralph-qa/references`. `CODEX_HOME` 이 있으면 `~/.codex` 대신 그 폴더). 호출 사이에 셸 변수가 남지 않으므로 호출마다 실제 경로를 적는다.
116
116
  명령마다 stdout 에 JSON 하나를 내고, 같은 JSON 을 `<d>/result-<명령>.md` 에도 쓴다(멈춘 send 는 `result-send-stop.md`). 종료 코드는 0 끝남, 1 비밀처럼 보이는 줄, 2 인자나 실행기 오류, 3 멈춤, 4 512 KiB 초과, 5 send 가 아직 돎이다.
117
117
  반복마다 새 폴더를 만든다.
118
118
 
@@ -41,7 +41,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
41
41
 
42
42
  ## 4. 테스트박스 (등록돼 있으면)
43
43
 
44
- 박스 작업은 이 스킬 폴더의 `scripts/boxes.mjs` 가 한다(Codex 에서는 `~/.codex/skills/banker-remains/scripts/boxes.mjs`). Node 만 쓰고, 이 머신에 `ssh`, `scp`, `git`, 박스에 `tar` 가 있어야 한다. `--no-boxes` 를 받으면 이 단계를 건너뛰고 확인 범위에 그렇게 적는다.
44
+ 박스 작업은 이 스킬 폴더의 `scripts/boxes.mjs` 가 한다(Codex 의 npm 설치는 `~/.codex/skills/banker-remains/scripts/boxes.mjs`, 플러그인 설치는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/remains/scripts/boxes.mjs`). Node 만 쓰고, 이 머신에 `ssh`, `scp`, `git`, 박스에 `tar` 가 있어야 한다. `--no-boxes` 를 받으면 이 단계를 건너뛰고 확인 범위에 그렇게 적는다.
45
45
 
46
46
  1. `node <스킬 폴더>/scripts/boxes.mjs list` 로 등록을 본다. 등록 파일이 없으면 확인 범위에 "테스트박스 미등록" 이라 적고 넘어간다.
47
47
  2. `node <스킬 폴더>/scripts/boxes.mjs probe` 로 접속을 확인한다.
@@ -16,30 +16,42 @@ if len(sys.argv) < 3:
16
16
  sys.exit(2)
17
17
  SRC = sys.argv[1]
18
18
  OUT = sys.argv[2]
19
- FONT = sys.argv[3] if len(sys.argv) > 3 else "Malgun Gothic" # 한글 폰트(서버에 설치된 것으로 교체 가능)
19
+ FONT = (
20
+ sys.argv[3] if len(sys.argv) > 3 else "Malgun Gothic"
21
+ ) # 한글 폰트(서버에 설치된 것으로 교체 가능)
22
+
20
23
 
21
24
  def set_korean(style):
22
25
  style.font.name = FONT
23
26
  style.font.size = Pt(10)
24
27
  rpr = style.element.get_or_add_rPr()
25
- rfonts = rpr.find(qn('w:rFonts'))
28
+ rfonts = rpr.find(qn("w:rFonts"))
26
29
  if rfonts is None:
27
- rfonts = OxmlElement('w:rFonts'); rpr.append(rfonts)
28
- for a in ('w:ascii', 'w:hAnsi', 'w:eastAsia', 'w:cs'): # eastAsia 필수(누락 시 한글 깨짐)
30
+ rfonts = OxmlElement("w:rFonts")
31
+ rpr.append(rfonts)
32
+ for a in (
33
+ "w:ascii",
34
+ "w:hAnsi",
35
+ "w:eastAsia",
36
+ "w:cs",
37
+ ): # eastAsia 필수(누락 시 한글 깨짐)
29
38
  rfonts.set(qn(a), FONT)
30
39
 
40
+
31
41
  def add_runs(p, text):
32
- for part in re.split(r'(\*\*[^*]+\*\*)', text):
42
+ for part in re.split(r"(\*\*[^*]+\*\*)", text):
33
43
  if not part:
34
44
  continue
35
- if part.startswith('**') and part.endswith('**'):
36
- p.add_run(part[2:-2].replace('`', '')).bold = True
45
+ if part.startswith("**") and part.endswith("**"):
46
+ p.add_run(part[2:-2].replace("`", "")).bold = True
37
47
  else:
38
- p.add_run(part.replace('`', ''))
48
+ p.add_run(part.replace("`", ""))
49
+
39
50
 
40
51
  def is_sep(cells):
41
52
  nonempty = [c.strip() for c in cells if c.strip()]
42
- return bool(nonempty) and all(re.fullmatch(r':?-{1,}:?', c) for c in nonempty)
53
+ return bool(nonempty) and all(re.fullmatch(r":?-{1,}:?", c) for c in nonempty)
54
+
43
55
 
44
56
  def set_table_widths(t):
45
57
  # 균등 너비 금지: 맨 왼쪽 컬럼은 내용에 맞춰 최소폭(개행 없이), 나머지 컬럼이 잔여 폭 분배
@@ -60,66 +72,77 @@ def set_table_widths(t):
60
72
  t.autofit = False
61
73
  t.allow_autofit = False
62
74
  tblPr = t._tbl.tblPr
63
- lay = tblPr.find(qn('w:tblLayout'))
75
+ lay = tblPr.find(qn("w:tblLayout"))
64
76
  if lay is None:
65
- lay = OxmlElement('w:tblLayout'); tblPr.append(lay)
66
- lay.set(qn('w:type'), 'fixed') # 고정 레이아웃(지정 폭 준수)
77
+ lay = OxmlElement("w:tblLayout")
78
+ tblPr.append(lay)
79
+ lay.set(qn("w:type"), "fixed") # 고정 레이아웃(지정 폭 준수)
67
80
  widths = [col0] + [rest] * (ncol - 1)
68
- grid = t._tbl.find(qn('w:tblGrid'))
81
+ grid = t._tbl.find(qn("w:tblGrid"))
69
82
  if grid is not None:
70
- for i, gc in enumerate(grid.findall(qn('w:gridCol'))):
83
+ for i, gc in enumerate(grid.findall(qn("w:gridCol"))):
71
84
  if i < len(widths):
72
- gc.set(qn('w:w'), str(int(widths[i] * 1440))) # inch→twips, 고정레이아웃 실폭(★ 이게 핵심)
85
+ gc.set(
86
+ qn("w:w"), str(int(widths[i] * 1440))
87
+ ) # inch→twips, 고정레이아웃 실폭(★ 이게 핵심)
73
88
  for i, col in enumerate(t.columns):
74
89
  for cell in col.cells:
75
90
  cell.width = Inches(widths[i])
76
91
 
92
+
77
93
  doc = Document()
78
- set_korean(doc.styles['Normal'])
94
+ set_korean(doc.styles["Normal"])
79
95
 
80
- lines = open(SRC, encoding='utf-8').read().split('\n')
96
+ lines = open(SRC, encoding="utf-8").read().split("\n")
81
97
  i, n = 0, len(lines)
82
98
  while i < n:
83
99
  s = lines[i].strip()
84
100
  if not s:
85
- i += 1; continue
86
- if s.startswith('|'):
101
+ i += 1
102
+ continue
103
+ if s.startswith("|"):
87
104
  block = []
88
- while i < n and lines[i].strip().startswith('|'):
89
- block.append(lines[i].strip()); i += 1
105
+ while i < n and lines[i].strip().startswith("|"):
106
+ block.append(lines[i].strip())
107
+ i += 1
108
+
90
109
  def split_row(tl):
91
- return [c.strip() for c in tl.strip('|').split('|')]
110
+ return [c.strip() for c in tl.strip("|").split("|")]
111
+
92
112
  rows = [split_row(tl) for tl in block]
93
113
  rows = [r for r in rows if not is_sep(r)]
94
114
  if rows:
95
115
  ncol = max(len(r) for r in rows)
96
- t = doc.add_table(rows=0, cols=ncol); t.style = 'Table Grid'
116
+ t = doc.add_table(rows=0, cols=ncol)
117
+ t.style = "Table Grid"
97
118
  for ri, r in enumerate(rows):
98
119
  rc = t.add_row().cells
99
120
  for ci in range(ncol):
100
121
  para = rc[ci].paragraphs[0]
101
- add_runs(para, r[ci] if ci < len(r) else '')
122
+ add_runs(para, r[ci] if ci < len(r) else "")
102
123
  if ri == 0:
103
124
  for run in para.runs:
104
125
  run.bold = True
105
126
  set_table_widths(t)
106
127
  doc.add_paragraph() # spacer after table (표와 다음 단락 겹침 방지)
107
128
  continue
108
- if s.startswith('### '):
129
+ if s.startswith("### "):
109
130
  doc.add_heading(s[4:], level=2)
110
- elif s.startswith('## '):
131
+ elif s.startswith("## "):
111
132
  doc.add_heading(s[3:], level=1)
112
- elif s.startswith('# '):
113
- h0 = doc.add_heading('', level=0); add_runs(h0, s[2:])
133
+ elif s.startswith("# "):
134
+ h0 = doc.add_heading("", level=0)
135
+ add_runs(h0, s[2:])
114
136
  for r in h0.runs:
115
137
  r.font.size = Pt(20)
116
- elif s.startswith('> '):
117
- p = doc.add_paragraph(); add_runs(p, s[2:])
138
+ elif s.startswith("> "):
139
+ p = doc.add_paragraph()
140
+ add_runs(p, s[2:])
118
141
  for run in p.runs:
119
142
  run.italic = True
120
143
  run.font.size = Pt(8)
121
- elif s.startswith('- '):
122
- add_runs(doc.add_paragraph(style='List Bullet'), s[2:])
144
+ elif s.startswith("- "):
145
+ add_runs(doc.add_paragraph(style="List Bullet"), s[2:])
123
146
  else:
124
147
  add_runs(doc.add_paragraph(), s)
125
148
  i += 1
@@ -128,9 +151,15 @@ while i < n:
128
151
  fp = doc.sections[0].footer.paragraphs[0]
129
152
  fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
130
153
  run = fp.add_run()
131
- for tag, kw in (('w:fldChar', {'w:fldCharType': 'begin'}), (None, None), ('w:fldChar', {'w:fldCharType': 'end'})):
154
+ for tag, kw in (
155
+ ("w:fldChar", {"w:fldCharType": "begin"}),
156
+ (None, None),
157
+ ("w:fldChar", {"w:fldCharType": "end"}),
158
+ ):
132
159
  if tag is None:
133
- el = OxmlElement('w:instrText'); el.set(qn('xml:space'), 'preserve'); el.text = 'PAGE'
160
+ el = OxmlElement("w:instrText")
161
+ el.set(qn("xml:space"), "preserve")
162
+ el.text = "PAGE"
134
163
  else:
135
164
  el = OxmlElement(tag)
136
165
  for k, v in kw.items():
@@ -28,7 +28,7 @@ disable-model-invocation: true
28
28
  (대상 codex) 확인만 줄이고 샌드박스는 남기려면 이 스킬 대신 `approval_policy = "never"` 만 두는 방법이 있습니다. 그때 작업 폴더 밖 쓰기와 네트워크는 실패합니다.
29
29
  대상이 `claude` 면 Codex 에서 실행해도 바뀌는 것은 같은 머신의 Claude Code 설정입니다.
30
30
  대상이 `codex` 면 이 계정의 모든 Codex 세션이 명령을 묻지 않고 샌드박스 없이 실행합니다(`codex --dangerously-bypass-approvals-and-sandbox` 와 같음). `codex exec` 같은 비대화형 실행도 같습니다. 샌드박스가 꺼져 작업 폴더 밖 파일과 네트워크에도 제한이 없습니다.
31
- 되돌리려면 `/banker:setup-bypass-permissions off` 를 실행하십시오. Codex 대상은 `$banker-setup-bypass-permissions off codex` 입니다.
31
+ 되돌리려면 `/banker:setup-bypass-permissions off` 를 실행하십시오. Codex 대상은 `$banker-setup-bypass-permissions off codex`(플러그인 설치면 `$banker:setup-bypass-permissions off codex`) 입니다.
32
32
 
33
33
  ## 대상
34
34
 
@@ -52,7 +52,7 @@ disable-model-invocation: true
52
52
  ## 절차
53
53
 
54
54
  `<스킬 폴더>` 는 이 SKILL.md 가 있는 폴더의 절대경로다.
55
- Codex 에서는 설치된 `banker-setup-bypass-permissions` 폴더다.
55
+ Codex 에서는 설치된 `banker-setup-bypass-permissions` 폴더다(플러그인 설치면 스킬 목록의 이 SKILL.md 경로가 든 폴더).
56
56
 
57
57
  1. 확인 (`on` 일 때만)
58
58
  - 위 경고 전부를 일반 출력으로 먼저 보여 준다.
@@ -21,6 +21,7 @@ Exit status: 0 done, or nothing to do. 1 refused. 2 unexpected error. 3 this acc
21
21
  the file or its folder. Every result ends with the line `settings file: <path>` on stdout.
22
22
  Python 3.6 or later.
23
23
  """
24
+
24
25
  import errno
25
26
  import json
26
27
  import os
@@ -43,7 +44,9 @@ def settings_path():
43
44
 
44
45
  def policy_home():
45
46
  # The override exists for this skill's tests alone.
46
- if os.environ.get("BANKER_BYPASS_TEST") == "1" and os.environ.get("BANKER_BYPASS_POLICY_HOME"):
47
+ if os.environ.get("BANKER_BYPASS_TEST") == "1" and os.environ.get(
48
+ "BANKER_BYPASS_POLICY_HOME"
49
+ ):
47
50
  return os.environ["BANKER_BYPASS_POLICY_HOME"]
48
51
  return POLICY_HOMES.get(sys.platform, "/etc/claude-code")
49
52
 
@@ -59,7 +62,11 @@ ABSENT = (errno.ENOENT, errno.ENOTDIR, errno.EISDIR)
59
62
 
60
63
 
61
64
  def unreadable_policy(path):
62
- return Refused("this machine's managed policy cannot be read (" + path + "); ask the administrator whether it allows the mode")
65
+ return Refused(
66
+ "this machine's managed policy cannot be read ("
67
+ + path
68
+ + "); ask the administrator whether it allows the mode"
69
+ )
63
70
 
64
71
 
65
72
  def policy_files():
@@ -73,7 +80,12 @@ def policy_files():
73
80
  if e.errno not in ABSENT:
74
81
  raise unreadable_policy(drop)
75
82
  names = []
76
- files += [os.path.join(drop, n) for n in sorted(n for n in names if n.endswith(".json") and not n.startswith("."))]
83
+ files += [
84
+ os.path.join(drop, n)
85
+ for n in sorted(
86
+ n for n in names if n.endswith(".json") and not n.startswith(".")
87
+ )
88
+ ]
77
89
  return files
78
90
 
79
91
 
@@ -108,6 +120,7 @@ def load(path):
108
120
  raise Refused("settings.json is not valid UTF-8")
109
121
  if not text.strip():
110
122
  return {}, raw
123
+
111
124
  def reject(token):
112
125
  raise ValueError("the constant " + token + " is not JSON")
113
126
 
@@ -134,7 +147,9 @@ def replace(path, data, mode):
134
147
  except OSError as e:
135
148
  if e.errno not in (errno.EBUSY, errno.EXDEV):
136
149
  raise
137
- with open(path, "wb") as target: # a bind-mounted single file refuses the rename: write in place
150
+ with open(
151
+ path, "wb"
152
+ ) as target: # a bind-mounted single file refuses the rename: write in place
138
153
  target.write(data)
139
154
  os.remove(tmp)
140
155
  except BaseException:
@@ -148,21 +163,34 @@ def save(path, data, raw):
148
163
  real = os.path.realpath(path)
149
164
  mode = os.stat(real).st_mode & 0o777 if os.path.exists(real) else 0o600
150
165
  bom = BOM if raw is not None and raw.startswith(BOM) else b""
151
- replace(real, bom + (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"), mode)
166
+ replace(
167
+ real,
168
+ bom + (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"),
169
+ mode,
170
+ )
152
171
 
153
172
 
154
173
  def check_on(path, data):
155
174
  # Claude Code lets root use the mode inside a sandbox it recognises: IS_SANDBOX exactly "1", or
156
175
  # CLAUDE_CODE_BUBBLEWRAP set to a true value (2.1.289 reads 1, true, yes, on; 0 or false is not one).
157
- bubblewrap = os.environ.get("CLAUDE_CODE_BUBBLEWRAP", "").strip().lower() in ("1", "true", "yes", "on")
176
+ bubblewrap = os.environ.get("CLAUDE_CODE_BUBBLEWRAP", "").strip().lower() in (
177
+ "1",
178
+ "true",
179
+ "yes",
180
+ "on",
181
+ )
158
182
  sandboxed = os.environ.get("IS_SANDBOX") == "1" or bubblewrap
159
183
  if hasattr(os, "geteuid") and os.geteuid() == 0 and not sandboxed:
160
- raise Refused("Claude Code will not start in bypassPermissions as root; IS_SANDBOX=1 marks a real sandbox")
184
+ raise Refused(
185
+ "Claude Code will not start in bypassPermissions as root; IS_SANDBOX=1 marks a real sandbox"
186
+ )
161
187
  blocked = policy_block()
162
188
  if blocked:
163
189
  raise Refused("managed policy forbids bypassPermissions: " + blocked)
164
190
  if forbids(data):
165
- raise Refused("settings.json itself sets permissions.disableBypassPermissionsMode")
191
+ raise Refused(
192
+ "settings.json itself sets permissions.disableBypassPermissionsMode"
193
+ )
166
194
 
167
195
 
168
196
  def forget_record(path):
@@ -176,7 +204,9 @@ def forget_record(path):
176
204
 
177
205
  def turn_on(path):
178
206
  if not os.path.isdir(os.path.dirname(path)):
179
- raise Refused("there is no Claude Code config folder at " + os.path.dirname(path))
207
+ raise Refused(
208
+ "there is no Claude Code config folder at " + os.path.dirname(path)
209
+ )
180
210
  data, raw = load(path)
181
211
  check_on(path, data)
182
212
  if data.get("permissions", {}).get("defaultMode") == MODE:
@@ -197,13 +227,21 @@ def turn_off(path):
197
227
  data, raw = load(path)
198
228
  current = data.get("permissions", {}).get("defaultMode")
199
229
  if current != MODE:
200
- print("defaultMode = " + str(current) + " (not bypassPermissions, nothing changed)")
230
+ print(
231
+ "defaultMode = "
232
+ + str(current)
233
+ + " (not bypassPermissions, nothing changed)"
234
+ )
201
235
  return 0
202
- if not os.access(path, os.W_OK): # a read-only settings file is a lock: off honours it as on does
236
+ if not os.access(
237
+ path, os.W_OK
238
+ ): # a read-only settings file is a lock: off honours it as on does
203
239
  raise PermissionError(errno.EACCES, "this account may not write", path)
204
240
  del data["permissions"]["defaultMode"]
205
241
  save(path, data, raw)
206
- forget_record(path) # only after the write: if it failed, the record is still the way back
242
+ forget_record(
243
+ path
244
+ ) # only after the write: if it failed, the record is still the way back
207
245
  print("defaultMode removed (Claude Code's default start mode)")
208
246
  return 0
209
247
 
@@ -212,7 +250,10 @@ def outcome(argv, path):
212
250
  actions = {("on", "--yes"): turn_on, ("off",): turn_off}
213
251
  action = actions.get(tuple(argv[1:]))
214
252
  if action is None:
215
- print("refused: usage is `bypass-permissions.py on --yes` or `bypass-permissions.py off`", file=sys.stderr)
253
+ print(
254
+ "refused: usage is `bypass-permissions.py on --yes` or `bypass-permissions.py off`",
255
+ file=sys.stderr,
256
+ )
216
257
  return 1
217
258
  try:
218
259
  return action(path)
@@ -221,12 +262,22 @@ def outcome(argv, path):
221
262
  return 1
222
263
  except OSError as e:
223
264
  if e.errno in (errno.EACCES, errno.EPERM, errno.EROFS):
224
- print("write blocked: " + str(e) + "; settings.json left as it was", file=sys.stderr)
265
+ print(
266
+ "write blocked: " + str(e) + "; settings.json left as it was",
267
+ file=sys.stderr,
268
+ )
225
269
  return 3
226
270
  print("unexpected error: " + str(e) + "; check settings.json", file=sys.stderr)
227
271
  return 2
228
272
  except Exception as e: # anything else stopped it partway too
229
- print("unexpected error: " + type(e).__name__ + ": " + str(e) + "; check settings.json", file=sys.stderr)
273
+ print(
274
+ "unexpected error: "
275
+ + type(e).__name__
276
+ + ": "
277
+ + str(e)
278
+ + "; check settings.json",
279
+ file=sys.stderr,
280
+ )
230
281
  return 2
231
282
 
232
283
 
@@ -98,7 +98,7 @@ description: "컨텍스트 사용률이 임계(기본 50%)를 넘으면 `append_
98
98
 
99
99
  1~3은 어시스턴트가 자동 수행. 4는 유저가 실행한다. 안내는 2에서 끝내고 3 뒤에는 아무것도 붙이지 않는다.
100
100
  (Claude=`/banker:append_wiki`·`/banker:ready-compact`,
101
- Codex=`$banker-append_wiki`·`$banker-ready-compact`; `/copy`·compaction은 양쪽 TUI 내장.)
101
+ Codex=`$banker-append_wiki`·`$banker-ready-compact`, 플러그인 설치면 `$banker:append_wiki`·`$banker:ready-compact`; `/copy`·compaction은 양쪽 TUI 내장.)
102
102
 
103
103
  ## 활성화 (idempotent · compose-safe)
104
104
  `smart-compact [--threshold N]` (기본 N=50). 아래는 Claude Code 절차이고, Codex 는 위 Codex 절의 `codex-gate.mjs on` 으로 대신한다:
@@ -138,7 +138,8 @@ export const percent = (u) => Math.round((u.used * 100) / u.window);
138
138
  export function nudge(pct, threshold) {
139
139
  return `smart-compact: 컨텍스트 사용률 ${pct}% (임계 ${threshold}%). 이번 응답에서 위생 체인을 실행한다: `
140
140
  + "$banker-append_wiki, 그다음 한 줄 안내(다음 메시지가 resume 프롬프트이고 그 뒤 /copy, /compact, 붙여 넣기 순서), "
141
- + "마지막으로 $banker-ready-compact. /copy, /compact, 붙여 넣기는 사용자가 한다.";
141
+ + "마지막으로 $banker-ready-compact. banker 를 Codex 플러그인으로 설치했으면 $banker:append_wiki, $banker:ready-compact 다. "
142
+ + "/copy, /compact, 붙여 넣기는 사용자가 한다.";
142
143
  }
143
144
 
144
145
  const text = (o, key) => (o && typeof o[key] === "string" ? o[key] : "");
@@ -24,7 +24,7 @@ argument-hint: "list | css | json | widget | check [--design-md PATH] [--only SL
24
24
  | 런타임 | 스킬 디렉터리 |
25
25
  |---|---|
26
26
  | Claude Code | 이 파일 맨 위에 넣어 준 "Base directory for this skill:" 줄의 경로 |
27
- | Codex | `~/.codex/skills/banker-theme-selection/` 의 절대경로. 프로젝트 범위 설치면 `./.codex/skills/banker-theme-selection/` |
27
+ | Codex | Codex 스킬 목록이 보이는 이 SKILL.md 경로(file)의 폴더. npm 설치는 `~/.codex/skills/banker-theme-selection/`(프로젝트 범위 설치면 `./.codex/skills/banker-theme-selection/`), 플러그인 설치(`codex plugin add`)는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/theme-selection/`. 절대경로로 씀 |
28
28
 
29
29
  - `~` 는 따옴표 안에서 펼쳐지지 않으니 절대경로를 쓴다.
30
30
  2. 프로젝트에 `DESIGN.md` 가 있으면 `--design-md` 로 함께 먹인다. 없으면 번들 74종만 쓴다.
@@ -24,7 +24,7 @@ argument-hint: "on | off | status"
24
24
  | 런타임 | `--runtime` | 스킬 디렉터리 |
25
25
  |---|---|---|
26
26
  | Claude Code | `claude` | 스킬 맨 위 "Base directory for this skill:" 경로 |
27
- | Codex | `codex` | `~/.codex/skills/banker-tone-compact/` 의 절대경로. 프로젝트 범위 설치면 `./.codex/skills/banker-tone-compact/` |
27
+ | Codex | `codex` | Codex 스킬 목록이 보이는 이 SKILL.md 경로(file)의 폴더. npm 설치는 `~/.codex/skills/banker-tone-compact/`(프로젝트 범위 설치면 `./.codex/skills/banker-tone-compact/`), 플러그인 설치(`codex plugin add`)는 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/tone-compact/`. 절대경로로 씀 |
28
28
 
29
29
  3. 결과 처리
30
30
 
@@ -186,7 +186,7 @@ export function writeFresh(file, data, mode = 0o644) {
186
186
  const tmp = `${file}.${process.pid}.tmp`;
187
187
  rmSync(tmp, { force: true });
188
188
  try {
189
- writeFileSync(tmp, data, { flag: "wx", mode });
189
+ writeNew(tmp, data, mode);
190
190
  renameOver(tmp, file);
191
191
  } catch (e) {
192
192
  rmSync(tmp, { force: true });
@@ -194,6 +194,48 @@ export function writeFresh(file, data, mode = 0o644) {
194
194
  }
195
195
  }
196
196
 
197
+ // Opens a file that is not there yet, as "wx" does, and makes sure the name is the file made. macOS and Linux refuse
198
+ // any name that is there, a link to nothing among them (EEXIST). Windows follows a link to nothing and makes the file
199
+ // it leads to: then that file is removed again when it is still the empty one made, and the open is refused with
200
+ // EEXIST as elsewhere. A look before the open catches such a link; this catches one planted between the look and the
201
+ // open. A look that fails after the open leaves the file to the caller, as a plain "wx" would.
202
+ export function openNew(path, mode) {
203
+ const fd = openSync(path, "wx", mode);
204
+ let made = null;
205
+ let st;
206
+ try {
207
+ made = fstatSync(fd, { bigint: true });
208
+ st = lstatSync(path, { bigint: true });
209
+ } catch {
210
+ return fd;
211
+ }
212
+ if (!st.isSymbolicLink() && (made.ino === 0n || sameInode(made, st))) return fd;
213
+ closeSync(fd);
214
+ if (st.isSymbolicLink()) dropFollowed(path, made);
215
+ throw Object.assign(new Error(`EEXIST: file already exists, open '${path}'`), { code: "EEXIST", syscall: "open", path });
216
+ }
217
+
218
+ // Removes the file a "wx" open made through the link at path, when it is still the empty file that open made.
219
+ function dropFollowed(path, made) {
220
+ try {
221
+ const target = realpathSync(path);
222
+ const st = statSync(target, { bigint: true });
223
+ if (sameInode(made, st) && st.size === 0n) rmSync(target, { force: true });
224
+ } catch {
225
+ // gone already, or the link changed again: nothing of ours to take back
226
+ }
227
+ }
228
+
229
+ // Writes data to a file that is not there yet (openNew) and closes it.
230
+ function writeNew(path, data, mode) {
231
+ const fd = openNew(path, mode);
232
+ try {
233
+ writeFileSync(fd, data);
234
+ } finally {
235
+ closeSync(fd);
236
+ }
237
+ }
238
+
197
239
  // Windows does not rename a file over a link to a folder: there the link goes first, and the folder it
198
240
  // leads to stays.
199
241
  function renameOver(tmp, file) {
@@ -510,13 +552,14 @@ function writeBlocked(changes) {
510
552
  // whatever the umask allows more. A file left empty is removed, unless it is a
511
553
  // link or has other names (a hard link), whose other names would keep the old
512
554
  // text: it is emptied instead. Codex skips an empty file anyway.
513
- // A file that was not there when it was read is created only if it still is not ("wx"): a name that appeared in
555
+ // A file that was not there when it was read is created only if it still is not (openNew): a name that appeared in
514
556
  // between, a link planted there among them, is not written through.
515
557
  function apply(t, changes) {
516
558
  for (const { file, after } of changes) {
517
559
  if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
518
560
  if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
519
- else writeFileSync(file.path, after, { mode: 0o644, flag: file.bytes === null ? "wx" : "w" });
561
+ else if (file.bytes === null) writeNew(file.path, after, 0o644);
562
+ else writeFileSync(file.path, after, { mode: 0o644, flag: "w" });
520
563
  }
521
564
  }
522
565
 
@@ -568,9 +611,19 @@ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
568
611
  // when it may still be there (no stats or no inode number to prove it is that file, or the share could not say).
569
612
  // Any other error (permissions, EIO) throws, after the same taking away.
570
613
  function tryLock(path, token) {
614
+ // A name that is there in any form is held. Windows opens "wx" through a link to nothing and makes the file it leads
615
+ // to; macOS and Linux answer EEXIST for it below, and openNew takes back a file Windows made through a link planted
616
+ // after this look. A look that fails leaves the answer to the open, as before.
617
+ let there = false;
618
+ try {
619
+ there = lstatSync(path, { throwIfNoEntry: false }) !== undefined;
620
+ } catch {
621
+ // ESTALE and the like: the open below answers
622
+ }
623
+ if (there) return "held";
571
624
  let fd;
572
625
  try {
573
- fd = openSync(path, "wx", 0o600);
626
+ fd = openNew(path, 0o600);
574
627
  } catch (e) {
575
628
  if (e.code === "EEXIST") return "held";
576
629
  if (e.code === "ESTALE") return "stale";
@@ -65,7 +65,7 @@ ultra-chain 이 직접 하는 일은 여섯 가지다.
65
65
  | 이어 하기 메모 | OMC 작업 노트에 덧붙이기(`notepad_write_working`). 우선 노트는 통째로 바뀌므로 쓰지 않음 | `.omx/notepad.md` 에 한 줄 덧붙이기 |
66
66
  | 실행 기록 기본 폴더 | `.omc/ultra-chain/` | `.omx/ultra-chain/` |
67
67
 
68
- - Codex 에는 Skill 도구가 없다. 위 표의 `$이름` 은 그 스킬의 `SKILL.md` 를 읽고 그대로 따른다는 뜻이다. banker 스킬은 `<S>/../banker-<이름>/SKILL.md`, OMX 스킬은 `~/.codex/skills/<이름>/SKILL.md` 다. 그 스킬이 상태 기록을 요구하면 그 지시대로 직접 기록한다. 큰 `SKILL.md`(ralph-qa 는 약 71KB)는 셸 출력 한도에서 잘리니 나눠 읽는다.
68
+ - Codex 에는 Skill 도구가 없다. 위 표의 `$이름` 은 그 스킬의 `SKILL.md` 를 읽고 그대로 따른다는 뜻이다. banker 스킬은 npm 설치면 `<S>/../banker-<이름>/SKILL.md`, Codex 플러그인 설치(`<S>` 가 `plugins/cache/` 아래, 스킬 이름 `banker:<이름>`)면 `<S>/../<이름>/SKILL.md` 다. 표의 `$banker-<이름>` 은 플러그인 설치에서 `$banker:<이름>` 이다. OMX 스킬은 `~/.codex/skills/<이름>/SKILL.md` 다. 그 스킬이 상태 기록을 요구하면 그 지시대로 직접 기록한다. 큰 `SKILL.md`(ralph-qa 는 약 71KB)는 셸 출력 한도에서 잘리니 나눠 읽는다.
69
69
  - Codex 의 하위 에이전트(critic, 시각 판정)는 저자 대화를 넘기지 않는다. multi-agent v2 는 `fork_turns="none"` 을 주고, v1 은 `fork_context` 를 주지 않는다. 판정을 받으면 `close_agent` 로 닫는다.
70
70
  - Codex 환경 지시가 역할(`agent_type`)을 요구하면, 고정 모델이 세션 모델과 같은 역할을 쓰고 지시 앞머리에 파일 쓰기 금지를 적는다. 그런 역할이 없으면 역할 없이 띄운다. 쓴 역할과 모델, 강도를 판정 기록의 `note` 에 적는다.
71
71
  - Codex 의 오래 도는 명령은 기다리는 시간을 박스 `--timeout` 보다 길게 둔다. 기다리다 끊기면 박스 정리와 결과 JSON 이 남지 않는다.
@@ -424,7 +424,7 @@ node "<S>/references/chain-report.mjs" "<run>"
424
424
 
425
425
  압축(compaction)이나 중단 뒤에는 다음 순서로 잇는다.
426
426
 
427
- 1. 이 스킬을 다시 부른다: `--run=<실행 폴더> --from=<단계>`(Claude Code `Skill("banker:ultra-chain")`, Codex `$banker-ultra-chain`). 지시와 `<S>` 를 되찾기 위해서다. 실행 폴더는 이어 하기 메모나 압축 요약에서 찾는다.
427
+ 1. 이 스킬을 다시 부른다: `--run=<실행 폴더> --from=<단계>`(Claude Code `Skill("banker:ultra-chain")`, Codex `$banker-ultra-chain`, 플러그인 설치면 `$banker:ultra-chain`). 지시와 `<S>` 를 되찾기 위해서다. 실행 폴더는 이어 하기 메모나 압축 요약에서 찾는다.
428
428
  2. `node "<S>/references/chain-ledger.mjs" show "<run>"` 로 `current`(다시 시작할 단계), 단계별 `lastVerdict`(마지막 판정과 blocker), `maxRound` 를 본다. 멈추거나 실패한 실행은 그 단계가 `current` 다. 원래 플래그는 `task.md` 첫 줄과 `preflight` 기록에 있다.
429
429
  3. 끊길 때 돌던 작업(박스 시험, 검토 좌석, 서버)이 끝났는지 확인한다. 끝나지 않았으면 알림을 기다리거나 멈춘다. 끊기기 전 실행의 늦은 판정은 쓰지 않는다.
430
430
  4. 돌던 ralph-qa 실행은 되살리지 않는다. 좌석과 끈끈한 반대는 한 실행 안에서만 유지되기 때문이다.
@@ -11,7 +11,7 @@ description: "웹 UI 를 Playwright 로 렌더해 디자인 원본(PDF 나 스
11
11
  ## 0. 전제조건: playwright (없으면 설치부터)
12
12
  이 스킬은 playwright 브라우저가 있어야 동작한다. **진행 전 설치 여부를 먼저 확인**하고, 없으면 **설치부터 안내**한다(추정·임의 재설치 금지):
13
13
  - 확인: `ls "${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}" 2>/dev/null | grep -qi chromium && echo ok`
14
- - 미설치면 먼저 `setup-playwright` 스킬로 설치(Claude Code: `/banker:setup` → setup-playwright / Codex: `banker-setup-playwright`). 설치·검증 후 이 스킬을 이어서 진행한다.
14
+ - 미설치면 먼저 `setup-playwright` 스킬로 설치(Claude Code: `/banker:setup` → setup-playwright / Codex: `banker-setup-playwright`, 플러그인 설치면 `banker:setup-playwright`). 설치·검증 후 이 스킬을 이어서 진행한다.
15
15
 
16
16
  ## When to use
17
17
  - "ultra ui qa", "UI 대조", "디자인 PDF와 대조", "내부개념/어색한 내용 점검", "표 title·차트 크기 비교"