@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
package/bin/banker.js CHANGED
@@ -12,6 +12,7 @@ const os = require('os');
12
12
  const path = require('path');
13
13
  const cp = require('child_process');
14
14
  const { pathToFileURL } = require('url');
15
+ const { codexFrontmatter, commandSkillText } = require('./lib/command-skill.cjs');
15
16
 
16
17
  const PKG_ROOT = path.resolve(__dirname, '..');
17
18
  const MANIFEST = path.join(PKG_ROOT, 'codex', 'manifest.json');
@@ -33,15 +34,22 @@ function isoTime(t) {
33
34
  return Number.isNaN(d.getTime()) ? '' : d.toISOString();
34
35
  }
35
36
 
37
+ // What banker starts by bare name (gh, claude) runs from the home folder with Windows' search of the current folder off:
38
+ // on Windows the program search (cmd, and CreateProcess through libuv) looks in the current folder before PATH, and the
39
+ // current folder may be a project with a planted gh.exe or claude.cmd.
40
+ function bareSpawnOpts() {
41
+ return { cwd: os.homedir(), env: { ...process.env, NoDefaultCurrentDirectoryInExePath: '1' } };
42
+ }
43
+
36
44
  // GitHub 저장소에 별을 단다 (gh CLI 인증 사용). star 는 repo 쓰기 권한이 필요 없어 인증된 어느 계정으로도
37
45
  // 가능하다. gh 미설치/미인증/실패는 {ok:false, reason} 로 반환해 호출부가 URL 안내로 폴백하게 한다.
38
46
  // spawn 은 테스트 주입용(기본 cp.spawnSync). 자체 timeout·오류삼킴으로 setup 을 절대 막지 않는다.
39
47
  function starRepo(spawn = cp.spawnSync) {
40
48
  try {
41
- const v = spawn('gh', ['--version'], { stdio: 'ignore', timeout: 3000, windowsHide: true });
49
+ const v = spawn('gh', ['--version'], { stdio: 'ignore', timeout: 3000, windowsHide: true, ...bareSpawnOpts() });
42
50
  if (v.error || v.status !== 0) return { ok: false, reason: 'gh CLI 없음' };
43
51
  const r = spawn('gh', ['api', '-X', 'PUT', `/user/starred/${REPO_SLUG}`],
44
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, windowsHide: true });
52
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, windowsHide: true, ...bareSpawnOpts() });
45
53
  if (r.error) return { ok: false, reason: r.error.message };
46
54
  if (r.status !== 0) {
47
55
  const msg = (`${r.stderr || r.stdout || ''}`).trim().split('\n')[0] || `gh exited ${r.status}`;
@@ -150,29 +158,31 @@ function readSkillName(skillMd) {
150
158
 
151
159
  // Codex discovers skills by directory and expects the SKILL.md frontmatter `name:` to equal the
152
160
  // directory name. Our Codex dir is `banker-<name>` (prefixed to avoid colliding with omx/system
153
- // skills), so rewrite ONLY the first `name:` line inside the YAML frontmatter to match.
154
- function setCodexSkillName(skillMd, newName, dryRun) {
155
- if (dryRun) { log(` [dry-run] set frontmatter name: ${newName}`); return; }
161
+ // skills), so rewrite ONLY the first `name:` line inside the YAML frontmatter to match, and the
162
+ // `description:` line when the manifest gives the skill a Codex one (codexDescription).
163
+ function setCodexSkillName(skillMd, newName, dryRun, description) {
164
+ if (dryRun) { log(` [dry-run] set frontmatter name: ${newName}${description ? ' and the Codex description' : ''}`); return; }
156
165
  let src;
157
166
  try { src = fs.readFileSync(skillMd, 'utf8'); } catch { return; }
158
- const fm = src.match(/^---\r?\n([\s\S]*?)\r?\n---/);
159
- if (!fm) return; // no frontmatter — leave untouched
160
- let block = fm[1];
161
- block = /^name:.*$/m.test(block) ? block.replace(/^name:.*$/m, `name: ${newName}`) : `name: ${newName}\n${block}`;
162
- fs.writeFileSync(skillMd, src.slice(0, fm.index) + `---\n${block}\n---` + src.slice(fm.index + fm[0].length));
167
+ const out = codexFrontmatter(src, newName, description); // no frontmatter: left untouched
168
+ if (out !== src) fs.writeFileSync(skillMd, out);
163
169
  }
164
170
 
165
- // A command as a Codex skill: the command's description becomes the skill's, under name: <name>. Codex skills get
166
- // no $ARGUMENTS substitution, so a note says where the arguments come from.
167
- function writeCommandSkill(src, destDir, name, dryRun) {
171
+ // The name Codex gives a skill (manifest codexName: setup-omc is setup-omx there), and Claude name to Codex name.
172
+ function codexNameOf(s) { return s.codexName || s.name; }
173
+ function codexRenames(m) { return Object.fromEntries(eligible(m).filter(s => s.codexName).map(s => [s.name, s.codexName])); }
174
+
175
+ // Installed banker-<old> skill folders whose skill Codex now names apart, as [old, new] folder names (found before the sweep).
176
+ function renamedInstalls(skillsDir, m) {
177
+ return eligible(m).filter(s => s.codexName && fs.existsSync(path.join(skillsDir, `banker-${s.name}`, 'SKILL.md')))
178
+ .map(s => [`banker-${s.name}`, `banker-${s.codexName}`]);
179
+ }
180
+
181
+ // A command as a Codex skill (bin/lib/command-skill.cjs, shared with the Codex plugin's codex/command-skills/).
182
+ function writeCommandSkill(src, destDir, name, dryRun, codex = {}) {
168
183
  if (dryRun) { log(` [dry-run] write command skill ${path.relative(PKG_ROOT, src)} -> ${destDir}`); return; }
169
- const text = fs.readFileSync(src, 'utf8');
170
- const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
171
- const desc = fm && fm[1].match(/^description:\s*(.*)$/m);
172
- const body = fm ? text.slice(fm[0].length) : text;
173
- const note = '> Codex: 이 명령은 스킬로 설치됐다. 본문의 `$ARGUMENTS`, `$1` 은 사용자가 `$' + name + '` 뒤에 쓴 글이다.\n\n';
174
184
  fs.mkdirSync(destDir, { recursive: true });
175
- fs.writeFileSync(path.join(destDir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${desc ? desc[1] : '""'}\n---\n\n${note}${body}`);
185
+ fs.writeFileSync(path.join(destDir, 'SKILL.md'), commandSkillText(fs.readFileSync(src, 'utf8'), name, `$${name}`, codex));
176
186
  }
177
187
 
178
188
  /* ---------- Claude Code target ---------- */
@@ -185,7 +195,7 @@ function setupClaude(dryRun) {
185
195
  ];
186
196
  for (const [bin, args] of steps) {
187
197
  if (dryRun) { log(` [dry-run] ${bin} ${args.join(' ')}`); continue; }
188
- try { cp.execFileSync(bin, args, { stdio: 'inherit' }); }
198
+ try { cp.execFileSync(bin, args, { stdio: 'inherit', ...bareSpawnOpts() }); }
189
199
  catch (e) { warn(` step failed (may already be applied): ${bin} ${args.join(' ')}`); }
190
200
  }
191
201
  log(' → skills/commands available as /banker:* (reload-plugins or restart to apply).');
@@ -200,30 +210,38 @@ function setupCodex(dryRun, scope) {
200
210
  const base = codexBase(scope);
201
211
  const skillsDir = path.join(base, 'skills');
202
212
  const promptsDir = path.join(base, 'prompts');
213
+ const renames = renamedInstalls(skillsDir, m);
203
214
  // Clean reinstall: remove any prior banker-* first so an update never leaves a stale duplicate.
204
215
  const swept = sweepCodexArtifacts(base, dryRun);
205
216
  if (swept) log(` swept ${swept} prior banker-* artifact(s) before reinstall.`);
206
217
  let nSkill = 0, nCmd = 0;
207
218
  for (const s of eligible(m)) {
208
219
  if (s.type === 'skill') {
209
- const dest = path.join(skillsDir, `banker-${s.name}`);
220
+ const dest = path.join(skillsDir, `banker-${codexNameOf(s)}`);
210
221
  copyDir(skillSource(s), dest, dryRun);
211
- setCodexSkillName(path.join(dest, 'SKILL.md'), `banker-${s.name}`, dryRun); // dir==name (Codex discovery)
222
+ setCodexSkillName(path.join(dest, 'SKILL.md'), `banker-${codexNameOf(s)}`, dryRun, s.codexDescription); // dir==name (Codex discovery)
212
223
  nSkill++;
213
224
  } else if (s.type === 'command') {
214
225
  const src = path.join(PKG_ROOT, 'commands', `${s.name}.md`);
215
226
  copyFile(src, path.join(promptsDir, `banker-${s.name}.md`), dryRun);
216
- writeCommandSkill(src, path.join(skillsDir, `banker-${s.name}`), `banker-${s.name}`, dryRun);
227
+ writeCommandSkill(src, path.join(skillsDir, `banker-${s.name}`), `banker-${s.name}`, dryRun, { description: s.codexDescription, rename: codexRenames(m) });
217
228
  nCmd++;
218
229
  }
219
230
  }
220
231
  log(` → ${nSkill} skills -> ${path.join(base, 'skills', 'banker-*')}, ${nCmd} commands -> skills banker-* and prompts ${path.join(base, 'prompts', 'banker-*.md')}`);
232
+ reportRenames(renames);
221
233
  log(' → Codex 0.144+ has no custom prompts, so each command is also a skill: invoke it as $banker-<name>.');
222
234
  log(' → skills are invoked as `banker-<name>` (frontmatter name rewritten to match the dir).');
223
235
  log(' → ~/.codex/AGENTS.md is NOT modified (omx regenerates it); skills auto-discovered from ~/.codex/skills/.');
224
236
  return true;
225
237
  }
226
238
 
239
+ // The skills an update installed under their new Codex name, and what a user who turned an old name off must do.
240
+ function reportRenames(renames) {
241
+ for (const [from, to] of renames) log(` 이름 바뀜: ${from} 에서 ${to} (부를 때는 $${to})`);
242
+ if (renames.length) log(' 옛 이름으로 끈 설정(config.toml 의 [[skills.config]])은 새 이름에 닿지 않으니 새 이름으로 다시 적으십시오.');
243
+ }
244
+
227
245
  /* ---------- doctor ---------- */
228
246
  // The newest Codex session record (rollout) under <base>/sessions/YYYY/MM/DD/, or null.
229
247
  function newestRollout(base) {
@@ -269,16 +287,72 @@ function doctorCodexShape(base) {
269
287
  const file = newestRollout(base);
270
288
  if (!file) { log(' Codex record shape: no session record yet (run one Codex session, then doctor again)'); return; }
271
289
  const r = codexShapeReport(file);
272
- if (r && r.tooLong) { log(` Codex record shape: ${path.basename(file)} opens with a line over 8 MB, not checked`); return; }
273
- if (r && r.fresh) { log(` Codex record shape: ${path.basename(file)} holds no turn yet (run one Codex turn, then doctor again)`); return; }
274
- if (!r || !r.version) { log(` ⚠ Codex record shape: ${path.basename(file)} does not open with session_meta - lineage, progress and usage counting cannot read it`); return; }
290
+ const early = shapeEarlyNote(r, path.basename(file));
291
+ if (early) { log(early); return; }
275
292
  const mark = (ok) => (ok ? 'ok' : 'MISSING');
276
293
  log(` Codex record shape (Codex ${r.version}, ${path.basename(file)}): turn start ${mark(r.turnStart)}, user text ${mark(r.userText)}, tool calls ${mark(r.toolCall)}`);
294
+ doctorShapeVerdict(r);
295
+ }
296
+
297
+ // What doctor says before the three checks: the first line is too long, no turn yet, or not a Codex record. Lines after
298
+ // session_meta of which banker reads none (no turn start, user text or tool call) are what a Codex update that changed
299
+ // the whole record shape leaves, so they are flagged, not taken for a session with no turn yet.
300
+ function shapeEarlyNote(r, name) {
301
+ const unread = ` ⚠ Codex record shape: ${name} does not open with session_meta - lineage, progress and usage counting cannot read it`;
302
+ if (!r) return unread;
303
+ if (r.tooLong) return ` Codex record shape: ${name} opens with a line over 8 MB, not checked`;
304
+ if (r.fresh) return ` Codex record shape: ${name} holds no turn yet (run one Codex turn, then doctor again)`;
305
+ if (r.version && !(r.turnStart || r.userText || r.toolCall)) {
306
+ return ` ⚠ Codex record shape (Codex ${r.version}, ${name}): it has lines after session_meta but no turn start, user text or tool call banker reads - `
307
+ + 'the record shape may have changed (lineage stops, progress warns, usage counting misses); update banker. If this session has run no turn yet, run one and doctor again';
308
+ }
309
+ return r.version ? '' : unread;
310
+ }
311
+
312
+ // A turn opens with task_started, then the user's text. Text or tool calls with no turn start, or a turn start with no
313
+ // text, is a record shape banker does not know. Neither one is a session whose first turn has not begun.
314
+ function doctorShapeVerdict(r) {
277
315
  if (!r.turnStart || !r.userText) log(' ⚠ banker may not read this Codex version\'s records (lineage stops, progress warns, usage counting misses); update banker');
278
316
  else if (!r.toolCall) log(' (no tool calls: fine for a session that used no tools)');
279
317
  }
280
318
 
281
- function doctor() {
319
+ // bin/lib/codex-routes.mjs is ESM: the two ways banker gets into Codex (this npm install, and the Codex plugin).
320
+ async function loadCodexRoutes() {
321
+ return import(pathToFileURL(path.join(__dirname, 'lib', 'codex-routes.mjs')).href);
322
+ }
323
+ // Codex plugin installs of banker in <base> as 'banker@<marketplace> (<versions in the plugin cache>)'.
324
+ async function codexPluginInstalls(base) {
325
+ const r = await loadCodexRoutes();
326
+ return r.pluginRoutes(base).map((id) => {
327
+ let vers = [];
328
+ try { vers = fs.readdirSync(path.join(base, 'plugins', 'cache', id.slice(id.indexOf('@') + 1), PLUGIN_NAME)); } catch {}
329
+ return `${id}${vers.length ? ` (${vers.join(', ')})` : ''}`;
330
+ });
331
+ }
332
+ // Beside the Codex plugin, this npm install shows every skill twice and runs every hook twice. Say so; remove nothing.
333
+ async function warnCodexPlugin(base) {
334
+ const ids = await codexPluginInstalls(base);
335
+ if (!ids.length) return;
336
+ warn(` ⚠ banker is also installed as a Codex plugin here: ${ids.join(', ')}. Skills show twice ($banker-<name> and $banker:<name>) and the hooks run twice.`);
337
+ warn(` Keep one: codex plugin remove ${ids[0].split(' ')[0]}, or banker uninstall --codex.`);
338
+ }
339
+
340
+ // The plugin's per-skill list was measured on Codex 0.160.1 and later; below that the banker skills may not show.
341
+ async function warnOldCodex(plugins) {
342
+ if (!plugins.length) return;
343
+ const r = await loadCodexRoutes();
344
+ const note = r.pluginVersionNote(r.codexCliVersion(cp.spawnSync));
345
+ if (note) log(` ⚠ ${note}`);
346
+ }
347
+
348
+ // A Codex home inside the folder doctor runs in (not its top .codex, which Codex's sandbox keeps read-only) is one a
349
+ // Codex session opened here can change from the sandbox: its config, the hook copies, the plugins.
350
+ async function warnCodexHomeHere(base) {
351
+ if (!(await loadCodexRoutes()).homeInSession(base, process.cwd())) return;
352
+ log(` ⚠ the Codex home ${base} is inside this folder (not its top .codex): a Codex session opened here can change its config and hooks from the sandbox; use a Codex home outside the folders you open Codex in`);
353
+ }
354
+
355
+ async function doctor() {
282
356
  log(`banker v${version()} (pkg: ${PKG_ROOT})`);
283
357
  log(`plugin.json version: ${(() => { try { return require(PLUGIN_JSON).version; } catch { return '?'; } })()}`);
284
358
  log('Claude Code:');
@@ -290,6 +364,7 @@ function doctor() {
290
364
  try { installed = fs.readdirSync(sdir).filter(d => d.startsWith('banker-')); } catch {}
291
365
  log(` codex CLI: ${have('codex') ? 'found' : 'NOT found'}`);
292
366
  log(` installed banker skills in ${sdir}: ${installed.length}`);
367
+ await warnCodexHomeHere(base);
293
368
  doctorCodexShape(base);
294
369
  // Codex only discovers a skill when its dir name equals the SKILL.md `name:` — flag any mismatch.
295
370
  const mismatched = installed
@@ -299,7 +374,14 @@ function doctor() {
299
374
  log(` ⚠ dir/name mismatch (Codex will NOT list these) — reinstall with this version:`);
300
375
  for (const x of mismatched) log(` ${x.d}/ has name: ${x.name}`);
301
376
  }
302
- if (have('codex') && installed.length === 0) log(' ⚠ codex found but 0 banker skills installed — run: banker setup --codex');
377
+ const plugins = await codexPluginInstalls(base);
378
+ log(` Codex plugin (codex plugin add): ${plugins.length ? plugins.join(', ') : 'not installed'}`);
379
+ await warnOldCodex(plugins);
380
+ doctorTeamwork(base);
381
+ // A project-scope npm install (banker setup --codex --scope project) in this folder doubles the skills here too.
382
+ const projectNpm = (await loadCodexRoutes()).projectNpmRoot(process.cwd());
383
+ if (plugins.length && (installed.length || projectNpm)) log(` ⚠ banker is installed both ways${projectNpm && !installed.length ? ` (npm in ${path.join(projectNpm, '.codex')})` : ''}: skills show twice ($banker-<name> and $banker:<name>). Keep one: banker uninstall --codex, or codex plugin remove ${plugins[0].split(' ')[0]}`);
384
+ if (have('codex') && installed.length === 0 && !plugins.length) log(' ⚠ codex found but 0 banker skills installed — run: banker setup --codex, or codex plugin add banker@banker-plugins');
303
385
  const m = readManifest();
304
386
  const nSkills = eligible(m).filter(s => s.type === 'skill').length;
305
387
  log(` manifest: ${eligible(m).length} codex-eligible surfaces (${nSkills} skills) of ${m.surfaces.length}.`);
@@ -307,13 +389,27 @@ function doctor() {
307
389
  log(' → install via /banker:setup (Claude) or the setup-* skills; skills also guide you if a dependency is missing.');
308
390
  }
309
391
 
392
+ // The Codex-only teamwork skill keeps its engine, hooks and approval rule in the Codex home: say whether the programs it
393
+ // recorded still exist, and whether a rules/teamwork.rules it did not write sits where its rule goes.
394
+ function doctorTeamwork(base) {
395
+ let st = null;
396
+ try { st = JSON.parse(fs.readFileSync(path.join(base, 'teamwork', 'state.json'), 'utf8')); } catch { return; }
397
+ const missing = ['node', 'codex'].filter((k) => !(st.tools && st.tools[k] && fs.existsSync(st.tools[k])));
398
+ // The hooks' wrapper needs the engine and node only; send and reply start the recorded codex.
399
+ const fails = [...(missing.includes('node') ? ['its hooks stay silent'] : []), ...(missing.includes('codex') ? ['send and reply fail'] : [])];
400
+ log(` teamwork: ${st.enabled ? 'on' : 'off'} (engine ${st.version || '?'})${missing.length ? ` ⚠ recorded ${missing.join(', ')} missing: ${fails.join(' and ')} until $banker-teamwork on runs again` : ''}`);
401
+ let rules = '';
402
+ try { rules = fs.readFileSync(path.join(base, 'rules', 'teamwork.rules'), 'utf8'); } catch { return; }
403
+ if (!rules.startsWith('# teamwork ')) log(` ⚠ ${path.join(base, 'rules', 'teamwork.rules')} was not written by teamwork; teamwork leaves it as it is and asks for approval on every call`);
404
+ }
405
+
310
406
  /* ---------- uninstall ---------- */
311
407
  function uninstallClaude(dryRun) {
312
408
  log('• Claude Code uninstall:');
313
409
  if (!have('claude')) { warn(' claude CLI not found; skipping.'); return; }
314
410
  for (const args of [['plugin', 'uninstall', PLUGIN_NAME], ['plugin', 'marketplace', 'remove', MARKETPLACE]]) {
315
411
  if (dryRun) { log(` [dry-run] claude ${args.join(' ')}`); continue; }
316
- try { cp.execFileSync('claude', args, { stdio: 'inherit' }); } catch { warn(` (not present) claude ${args.join(' ')}`); }
412
+ try { cp.execFileSync('claude', args, { stdio: 'inherit', ...bareSpawnOpts() }); } catch { warn(` (not present) claude ${args.join(' ')}`); }
317
413
  }
318
414
  }
319
415
  function uninstallCodex(dryRun, scopes) {
@@ -337,6 +433,21 @@ function uninstallCodex(dryRun, scopes) {
337
433
  }
338
434
  }
339
435
 
436
+ // teamwork (codex-only) goes only when no other way of banker into this Codex home is left: the user-scope npm skills
437
+ // (kept by --scope project) and the Codex plugin. Its own uninstall removes its two hooks, its rule and its state.
438
+ // It runs only with the user scope in the sweep, so a dry run counts the npm skills as gone, as the real run finds them.
439
+ async function uninstallTeamwork(dryRun) {
440
+ const base = codexBase('user');
441
+ if (!fs.existsSync(path.join(base, 'teamwork'))) return;
442
+ const npmLeft = !dryRun && fs.existsSync(path.join(base, 'skills', 'banker-teamwork'));
443
+ const left = [...(npmLeft ? ['npm skills'] : []), ...(await codexPluginInstalls(base))];
444
+ if (left.length) { log(` teamwork kept: banker is still in this Codex home (${left.join(', ')})`); return; }
445
+ if (dryRun) { log(` [dry-run] teamwork uninstall in ${base}`); return; }
446
+ const r = cp.spawnSync(process.execPath, [path.join(PKG_ROOT, 'codex', 'skills', 'teamwork', 'scripts', 'teamwork.mjs'), 'uninstall'], { env: { ...process.env, CODEX_HOME: base }, encoding: 'utf8' });
447
+ if (r.status === 0) log(' removed teamwork (its hooks, approval rule and state)');
448
+ else warn(` teamwork not removed (exit ${r.status}): ${(r.stderr || '').trim()}`);
449
+ }
450
+
340
451
  /* ---------- Codex hooks ---------- */
341
452
  // bin/lib/codex-hooks.mjs is ESM; loaded lazily like telemetry-config. Hooks go into the same Codex home as the skills.
342
453
  async function loadCodexHooks() {
@@ -353,6 +464,7 @@ async function setupCodexHooks(dryRun, scope) {
353
464
  const r = ch.enableHooks(codexHookPaths(ch), ch.DEFAULT_HOOKS, PKG_ROOT);
354
465
  if (r.code) { warn(` Codex hooks not changed: ${r.msg}`); return; }
355
466
  log(` → Codex hooks ${ch.DEFAULT_HOOKS.join(', ')} in ${codexHookPaths(ch).hooks}${r.changed.length ? ` (changed: ${r.changed.join(', ')})` : ' (already in place)'}.`);
467
+ if (r.note) log(` → ${r.note}`);
356
468
  if (r.changed.length) log(` → ${ch.TRUST_NOTE}`);
357
469
  }
358
470
 
@@ -380,7 +492,12 @@ async function hooksCmd(sub, ids) {
380
492
  }
381
493
  const r = sub === 'enable' ? ch.enableHooks(p, want, PKG_ROOT) : ch.disableHooks(p, want);
382
494
  if (r.code) { warn(`banker hooks: not changed: ${r.msg}`); process.exitCode = 1; return; }
495
+ reportHooks(sub, r, ch);
496
+ }
497
+
498
+ function reportHooks(sub, r, ch) {
383
499
  log(`banker hooks ${sub}: ${r.changed.length ? r.changed.join(', ') : 'nothing to change'}`);
500
+ if (r.note) log(r.note);
384
501
  if (sub === 'enable' && r.changed.length) log(ch.TRUST_NOTE);
385
502
  if (r.shifted && r.shifted.length) log(`later ${r.shifted.join(', ')} hooks may need trusting again in /hooks.`);
386
503
  }
@@ -543,6 +660,8 @@ Notes:
543
660
  - --claude registers the marketplace + installs banker@banker-plugins (skills as /banker:*).
544
661
  - --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md
545
662
  ($CODEX_HOME instead of ~/.codex when it is set).
663
+ - Codex can also take banker as a plugin (codex plugin marketplace add smallOpenSource/banker_plugins, then
664
+ codex plugin add banker@banker-plugins; skills as $banker:<name>). Keep one of the two ways in.
546
665
  - telemetry on|off toggles anonymous usage counting opt-out (default-on; config only); status prints counting + update-check state. See PRIVACY.md.
547
666
  - Never runs as root. --dry-run prints planned actions without writing.`;
548
667
 
@@ -550,14 +669,17 @@ async function main() {
550
669
  const a = parseArgs(process.argv.slice(2));
551
670
  if (a.version) { log(version()); return; }
552
671
  if (a.help || a.cmd === 'help' || !a.cmd) { log(HELP); return; }
553
- if (a.cmd === 'doctor') { doctor(); return; }
672
+ if (a.cmd === 'doctor') { await doctor(); return; }
554
673
  if (a.cmd === 'telemetry') { await telemetryCmd(a.sub); return; }
555
674
  if (a.cmd === 'hooks') { assertNotRoot(); await hooksCmd(a.sub, a.hookIds); return; }
556
675
  assertNotRoot();
557
676
  if (a.cmd === 'setup') {
558
677
  log(`banker setup${a.dryRun ? ' (dry-run)' : ''}: claude=${a.claude} codex=${a.codex}`);
559
678
  if (a.claude) setupClaude(a.dryRun);
560
- if (a.codex && setupCodex(a.dryRun, a.scope)) await setupCodexHooks(a.dryRun, a.scope);
679
+ if (a.codex && setupCodex(a.dryRun, a.scope)) {
680
+ await setupCodexHooks(a.dryRun, a.scope);
681
+ if (a.scope === 'user') await warnCodexPlugin(codexBase('user'));
682
+ }
561
683
  // Best-effort first-run prompts (interactive TTY only). A prompt failure must never fail setup.
562
684
  try { await maybeFirstRunPrompts(a); } catch { /* non-fatal */ }
563
685
  log('done.');
@@ -567,7 +689,7 @@ async function main() {
567
689
  if (a.claude) uninstallClaude(a.dryRun);
568
690
  if (a.codex) {
569
691
  uninstallCodex(a.dryRun, a.scopeExplicit ? [a.scope] : ['user', 'project']);
570
- if (!a.scopeExplicit || a.scope === 'user') await uninstallCodexHooks(a.dryRun);
692
+ if (!a.scopeExplicit || a.scope === 'user') { await uninstallCodexHooks(a.dryRun); await uninstallTeamwork(a.dryRun); }
571
693
  }
572
694
  log('done.');
573
695
  return;
@@ -6,11 +6,12 @@
6
6
  * 돌린다. Codex 는 신뢰한 훅을 샌드박스 밖에서 돌리므로, 작업 폴더 안의 파일을 가리키지 않게 하기 위해서다.
7
7
  * - 묶음은 각 이벤트의 맨 뒤에 더한다. Codex 는 신뢰를 <hooks.json>:<이벤트>:<묶음 번호>:<항목 번호> 로 기록해, 앞에
8
8
  * 끼우면 OMX 훅의 번호가 밀려 다시 신뢰해야 한다. 빼기는 우리 항목만, 낡은 명령은 같은 자리에서 고친다.
9
- * - Windows 의 Codex 는 훅 명령을 PowerShell 로 돌린다(Codex 0.160.1 실측): `& '<node>' '<스크립트>' 인자`.
9
+ * - Windows 의 Codex 는 훅 명령을 PowerShell 로 돌린다(Codex 0.160.1 실측): `& '<node>' -e '<검사>' '<스크립트>' 인자`.
10
+ * 검사(hookGuard)는 스크립트의 실제 경로가 임시 폴더나 세션 폴더 아래면 돌리지 않는다.
10
11
  * - hooks.json 이 링크이거나 JSON 객체가 아니거나 "없음" 말고 다른 이유로 확인할 수 없으면 바꾸지 않는다. 바꿀 때마다
11
12
  * 직전 내용을 hooks.json.banker.bak 에 둔다. smart-compact 의 codex-gate.mjs 와 같은 규칙이다.
12
13
  */
13
- import { spawnSync } from 'node:child_process';
14
+ import crypto from 'node:crypto';
14
15
  import fs from 'node:fs';
15
16
  import os from 'node:os';
16
17
  import path from 'node:path';
@@ -25,6 +26,8 @@ export const HOOKS = {
25
26
  export const PAYLOAD = [
26
27
  '.claude-plugin/plugin.json',
27
28
  'bin/lib/telemetry-config.mjs',
29
+ 'bin/lib/codex-routes.mjs',
30
+ 'bin/lib/codex-names.mjs',
28
31
  'hooks/update-notify.mjs',
29
32
  'hooks/update-fetch.mjs',
30
33
  'hooks/update-checkin.mjs',
@@ -42,8 +45,9 @@ export function codexPaths(env = process.env) {
42
45
 
43
46
  const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
44
47
 
48
+ // 임시 파일은 난수 이름과 wx: 남이 미리 심은 같은 이름의 파일이나 링크를 따라가지 않는다.
45
49
  function writeAtomic(file, data) {
46
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
50
+ const tmp = `${file}.${crypto.randomBytes(6).toString('hex')}.tmp`;
47
51
  try {
48
52
  fs.writeFileSync(tmp, data, { flag: 'wx' });
49
53
  const mode = modeOf(file);
@@ -65,19 +69,45 @@ function modeOf(file) {
65
69
  }
66
70
 
67
71
  // 명령 글. POSIX 는 큰따옴표, 셸이 그 안에서 읽는 글자는 거부. Windows 는 PowerShell 호출 연산자와 작은따옴표.
68
- export function formatCommand(node, script, args, win = process.platform === 'win32') {
72
+ // guard 가 있으면 node -e <guard> <스크립트> 인자 꼴(guard 는 큰따옴표, $, 백틱, 백슬래시가 없는 고정 글).
73
+ export function formatCommand(node, script, args, win = process.platform === 'win32', guard = '') {
69
74
  const all = [node, script, ...args].join('');
70
75
  if (/[\r\n\0]/.test(all)) return null;
76
+ const pre = guard ? ['-e'] : [];
71
77
  if (win) {
72
78
  const q = (s) => `'${s.replace(/['‘-‛]/g, '$&$&')}'`;
73
- return ['&', q(node), q(script), ...args].join(' ');
79
+ return ['&', q(node), ...pre, ...(guard ? [q(guard)] : []), q(script), ...args].join(' ');
74
80
  }
75
81
  if (/["$`\\]/.test(all)) return null;
76
- return [`"${node}"`, `"${script}"`, ...args].join(' ');
82
+ return [`"${node}"`, ...pre, ...(guard ? [`"${guard}"`] : []), `"${script}"`, ...args].join(' ');
77
83
  }
78
84
 
85
+ // npm 길 훅 명령이 스크립트를 부르기 전에 하는 검사(Codex 플러그인 훅의 래퍼와 같은 규칙): 스크립트의 실제 경로가 임시
86
+ // 폴더나 세션 폴더(Codex 가 훅을 도는 폴더) 아래면 돌리지 않고 까닭을 알린다. 세션 폴더가 홈 폴더 자체면 세션 폴더
87
+ // 규칙은 쓰지 않는다. Codex 홈이 세션 폴더 깊은 곳(CODEX_HOME=<세션>/deep/.codex)이면 샌드박스가 그 사본을 고칠 수
88
+ // 있기 때문이다. 검사는 신뢰 해시에 묶인 명령 글에 있다. 세션 시작 훅은 systemMessage 로, 다른 훅은 stderr 로 알린다.
89
+ // 실제 경로는 realpathSync.native 로 푼다: Windows 의 JS 판은 8.3 짧은 이름의 임시 폴더(TEMP=C:\Users\LONGNA~1\...)를
90
+ // 긴 이름으로 풀지 않아 임시 폴더 규칙이 빗나간다(Windows 박스 실측).
91
+ export function hookGuard(notice) {
92
+ const say = notice
93
+ ? 'process.stdout.write(JSON.stringify({systemMessage:m}))'
94
+ : 'process.stderr.write(m+String.fromCharCode(10))';
95
+ return [
96
+ "const f=require('fs'),p=require('path'),o=require('os');",
97
+ 'const r=(x)=>{try{return f.realpathSync.native(x)}catch{try{return f.realpathSync(x)}catch{return x}}};',
98
+ 'const s=r(process.argv[1]),w=r(process.cwd()),h=r(o.homedir());',
99
+ "const u=(d)=>{const q=p.relative(d,s);return q===''||!(q.startsWith('..')||p.isAbsolute(q))};",
100
+ "if([o.tmpdir()].concat(process.platform==='win32'?[]:['/tmp']).map(r).some(u)||(w!==h&&u(w))){",
101
+ "const m='banker hooks skipped: the hook script is in a temp folder or the session folder ('+s+')';",
102
+ `${say};process.exit(0)}`,
103
+ "import(require('url').pathToFileURL(s).href)",
104
+ ].join('');
105
+ }
106
+
107
+ const hookCommand = (p, id, node, win = process.platform === 'win32') => formatCommand(node, scriptOf(p, id), HOOKS[id].args, win, hookGuard(HOOKS[id].event === 'SessionStart'));
108
+
79
109
  // 임시 폴더의 node 는 거부한다. Codex 안에서 돌 때는 작업 폴더 안의 node 도(샌드박스 안 세션이 고칠 수 있다).
80
- export function safeNode(node, { win = process.platform === 'win32', cwd = process.cwd(), codex = Boolean(process.env.CODEX_THREAD_ID) } = {}) {
110
+ export function safeNode(node, { win = process.platform === 'win32', cwd = process.cwd(), codex = Boolean(process.env.CODEX_THREAD_ID), home = os.homedir() } = {}) {
81
111
  const P = win ? path.win32 : path.posix;
82
112
  const under = (f, dir) => {
83
113
  const r = P.relative(dir, f);
@@ -85,14 +115,25 @@ export function safeNode(node, { win = process.platform === 'win32', cwd = proce
85
115
  };
86
116
  // A link elsewhere is judged where it leads too, so a link in a safe place cannot pin a node from the temp folder.
87
117
  const places = [node, real(node)];
88
- return !places.some((f) => under(f, os.tmpdir()) || (codex && under(f, cwd)));
118
+ // A session in the home folder is no workspace to judge by (as homeInWorkspace): nvm keeps node under ~/.nvm. Only an
119
+ // absolute home names one (HOME=. would make every folder the home); path.relative compares as the OS does (case on Windows).
120
+ const isHome = P.isAbsolute(home) && P.relative(real(home), real(cwd)) === '';
121
+ const inWork = codex && !isHome;
122
+ return !places.some((f) => under(f, os.tmpdir()) || (inWork && under(f, cwd)));
89
123
  }
90
124
 
125
+ // 실제 경로. 아직 없는 절대 경로는 있는 조상까지만 풀고 나머지를 붙인다(macOS 의 /var 는 /private/var 로 가는 링크라,
126
+ // 풀지 않으면 링크 아래의 없는 Codex 홈이 풀린 작업 폴더 밖으로 보인다). native 먼저: Windows 의 8.3 짧은 이름을 푼다.
91
127
  function real(file) {
92
128
  try {
93
- return fs.realpathSync(file);
129
+ return fs.realpathSync.native(file);
94
130
  } catch {
95
- return file;
131
+ try {
132
+ return fs.realpathSync(file);
133
+ } catch {
134
+ const up = path.dirname(file);
135
+ return path.isAbsolute(file) && up !== file ? path.join(real(up), path.basename(file)) : file;
136
+ }
96
137
  }
97
138
  }
98
139
 
@@ -141,6 +182,49 @@ function saveHooks(p, doc) {
141
182
  writeAtomic(p.hooks, `${JSON.stringify(doc, null, 2)}\n`);
142
183
  }
143
184
 
185
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
186
+
187
+ // hooks.json 을 고치는 쪽(banker 와 Codex 전용 스킬 teamwork 의 엔진)이 함께 쓰는 잠금: hooks.json 옆의 폴더
188
+ // hooks.json.lock. 10초 넘은 잠금은 죽은 쓰기로 보고 치우고, 5초 안에 못 얻으면 바꾸지 않는다(까닭을 돌려줌).
189
+ // 치우지 못한 낡은 잠금(일반 파일, 비지 않은 폴더, 권한)도 기다림과 횟수에 들어, 끝없이 돌지 않고 그 까닭으로 끝난다.
190
+ export function withHooksLock(file, fn, onBusy) {
191
+ const lock = `${file}.lock`;
192
+ let stuck = '';
193
+ for (let i = 0; ; i++) {
194
+ try {
195
+ fs.mkdirSync(lock);
196
+ break;
197
+ } catch (e) {
198
+ // Codex 홈이 아직 없으면 다툴 파일도 없다. 그 밖에 잠금을 만들 수 없으면 바꾸지 않는다.
199
+ if (e.code === 'ENOENT') return fn();
200
+ if (e.code !== 'EEXIST') return onBusy(`잠금 ${lock} 을 확인하지 못함(${e.code})`);
201
+ if (i > 200) return onBusy(stuck ? `${file} 의 낡은 잠금 ${lock} 을 지우지 못함(${stuck}). 다른 banker 나 teamwork 가 돌고 있지 않으면 직접 지우십시오` : `${file} 의 잠금(${lock})을 얻지 못함`);
202
+ stuck = clearStale(lock);
203
+ if (stuck !== '') pause(25);
204
+ }
205
+ }
206
+ try {
207
+ return fn();
208
+ } finally {
209
+ fs.rmSync(lock, { recursive: true, force: true });
210
+ }
211
+ }
212
+
213
+ // 낡은 잠금 치우기: 치웠거나 그사이 풀렸으면 ''(바로 다시 잡음), 10초가 안 된 잠금이면 null(기다림), 10초 넘었는데
214
+ // 지우지 못했으면 그 오류 코드(기다림, 마지막에 까닭으로 알림).
215
+ function clearStale(lock) {
216
+ try {
217
+ if (Date.now() - fs.statSync(lock).mtimeMs <= 10000) return null;
218
+ fs.rmdirSync(lock);
219
+ return '';
220
+ } catch (e) {
221
+ // Windows 의 rmdir 은 파일인 잠금에 ENOENT 를 내므로, 정말 없어졌는지는 다시 보고 정한다.
222
+ let st;
223
+ try { st = fs.lstatSync(lock); } catch { return ''; }
224
+ return st.isDirectory() ? String((e && e.code) || e) : 'ENOTDIR';
225
+ }
226
+ }
227
+
144
228
  // 사본 폴더. 링크인 폴더는 거부하고, 파일은 내용이 다를 때만 새로 쓴다.
145
229
  export function installPayload(p, pkgRoot) {
146
230
  fs.mkdirSync(p.dir, { recursive: true });
@@ -167,33 +251,52 @@ function place(doc, id, command) {
167
251
  return true;
168
252
  }
169
253
 
170
- // Codex 홈이 지금 폴더나 그 저장소 안이면(예: 저장소의 .envrc 가 CODEX_HOME=./.codex) 훅 사본도 작업 폴더 안에 놓여,
171
- // 샌드박스 안의 세션이 샌드박스 밖에서 도는 훅을 고칠 수 있다. 홈 폴더와 파일 시스템 루트는 작업 폴더로 보지 않는다.
254
+ // Codex 홈이 지금 폴더 안이면(예: 폴더의 .envrc 가 CODEX_HOME=./.codex) 훅 사본도 작업 폴더 안에 놓여, 샌드박스 안의
255
+ // 세션이 샌드박스 밖에서 도는 훅을 고칠 수 있다. 홈 폴더와 파일 시스템 루트는 작업 폴더로 보지 않는다.
172
256
  export function homeInWorkspace(home, cwd = process.cwd()) {
173
- const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true });
174
- const roots = [cwd, top.status === 0 ? top.stdout.trim() : ''].filter(Boolean).map(real);
175
- const skip = new Set([real(os.homedir()), path.parse(real(cwd)).root]);
257
+ const r = real(cwd);
258
+ if (r === real(os.homedir()) || r === path.parse(r).root) return false;
176
259
  const h = real(home);
177
- return roots.some((r) => !skip.has(r) && (h === r || h.startsWith(r.endsWith(path.sep) ? r : r + path.sep)));
260
+ return h === r || h.startsWith(r.endsWith(path.sep) ? r : r + path.sep);
178
261
  }
179
262
 
180
- // ids 를 켠다. { code, changed:[id], msg }. code 1 이면 아무것도 바꾸지 않았다.
263
+ // Codex 홈에서 위로 올라가며 .git(폴더나 파일)이 있는 첫 폴더, 없으면 ''. git 을 실행하지 않는다. 사용자 홈 폴더 자체의
264
+ // .git(dotfiles 저장소)은 보지 않는다.
265
+ export function repoAbove(home, userHome = os.homedir()) {
266
+ const skip = real(userHome);
267
+ for (let d = real(home); ; d = path.dirname(d)) {
268
+ if (d !== skip && fs.existsSync(path.join(d, '.git'))) return d;
269
+ if (path.dirname(d) === d) return '';
270
+ }
271
+ }
272
+
273
+ // ids 를 켠다. { code, changed:[id], msg, note }. code 1 이면 아무것도 바꾸지 않았다. note 는 알릴 것(Codex 홈 위의 저장소).
181
274
  export function enableHooks(p, ids, pkgRoot, node = process.execPath, cwd = process.cwd()) {
275
+ return withHooksLock(p.hooks, () => enableLocked(p, ids, pkgRoot, node, cwd), (msg) => ({ code: 1, changed: [], msg }));
276
+ }
277
+
278
+ function enableLocked(p, ids, pkgRoot, node, cwd) {
182
279
  if (!safeNode(node)) return { code: 1, changed: [], msg: `node 가 안전하지 않은 곳에 있음(${node})` };
183
280
  if (homeInWorkspace(p.home, cwd)) return { code: 1, changed: [], msg: `Codex 홈(${p.home})이 작업 폴더 안이라 훅을 설치하지 않음. 샌드박스가 고칠 수 있는 곳임` };
184
281
  const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
185
282
  if (why) return { code: 1, changed: [], msg: why };
186
- const cmds = ids.map((id) => [id, formatCommand(node, scriptOf(p, id), HOOKS[id].args)]);
283
+ const cmds = ids.map((id) => [id, hookCommand(p, id, node)]);
187
284
  if (cmds.some(([, c]) => !c)) return { code: 1, changed: [], msg: '경로에 셸이 읽는 글자가 있음' };
188
285
  const bad = installPayload(p, pkgRoot);
189
286
  if (bad) return { code: 1, changed: [], msg: bad };
190
287
  const changed = cmds.filter(([id, c]) => place(doc, id, c)).map(([id]) => id);
191
288
  if (changed.length) saveHooks(p, doc);
192
- return { code: 0, changed, msg: '' };
289
+ const repo = repoAbove(p.home);
290
+ const note = repo ? `Codex 홈(${p.home})이 git 저장소(${repo}) 안에 있음. Codex 홈을 품은 폴더에서 Codex 를 열면 banker 훅은 돌지 않고 세션 시작 때 까닭을 알림` : '';
291
+ return { code: 0, changed, msg: '', note };
193
292
  }
194
293
 
195
294
  // ids 를 끈다: 우리 항목만 빼고, 비는 묶음과 이벤트를 지운다. 뒤 묶음의 번호가 밀리면 shifted 에 이벤트를 적는다.
196
295
  export function disableHooks(p, ids) {
296
+ return withHooksLock(p.hooks, () => disableLocked(p, ids), (msg) => ({ code: 1, changed: [], shifted: [], msg }));
297
+ }
298
+
299
+ function disableLocked(p, ids) {
197
300
  const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
198
301
  if (why) return { code: 1, changed: [], shifted: [], msg: why };
199
302
  const changed = [];
@@ -226,7 +329,7 @@ export function hookStatus(p, ids, pkgRoot, node = process.execPath) {
226
329
  const groups = doc.hooks[HOOKS[id].event] || [];
227
330
  const at = findOurs(groups, id);
228
331
  if (!at) out[id] = 'off';
229
- else out[id] = groups[at[0]].hooks[at[1]].command === formatCommand(node, scriptOf(p, id), HOOKS[id].args) && copyCurrent(p, pkgRoot) ? 'on' : 'stale';
332
+ else out[id] = groups[at[0]].hooks[at[1]].command === hookCommand(p, id, node) && copyCurrent(p, pkgRoot) ? 'on' : 'stale';
230
333
  }
231
334
  return { status: out };
232
335
  }
@@ -0,0 +1,4 @@
1
+ // Written by scripts/sync-codex-plugin.js from codex/manifest.json (codexName). Do not edit.
2
+ // CODEX_NAMES: Claude name to Codex name; CLAUDE_NAMES: the other way.
3
+ export const CODEX_NAMES = Object.freeze({"omc-reference":"omx-reference","setup-omc":"setup-omx","setup-omc-hud":"setup-omx-hud"});
4
+ export const CLAUDE_NAMES = Object.freeze(Object.fromEntries(Object.entries(CODEX_NAMES).map(([a, b]) => [b, a])));