@kaydash9999/banker-plugins 0.20.5 → 0.21.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 (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +24 -0
  4. package/PRIVACY.md +21 -1
  5. package/README.md +22 -14
  6. package/bin/banker.js +144 -11
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +2 -0
  9. package/codex/manifest.json +13 -10
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +18 -13
  15. package/hooks/obsidize-hook.mjs +116 -4
  16. package/hooks/progress-plan.mjs +1 -1
  17. package/hooks/telemetry-count-codex.mjs +222 -0
  18. package/hooks/update-notify.mjs +31 -11
  19. package/package.json +1 -1
  20. package/skills/append_wiki/SKILL.md +34 -8
  21. package/skills/compact-wiki/SKILL.md +37 -6
  22. package/skills/deep-research/SKILL.md +6 -0
  23. package/skills/lfm/SKILL.md +148 -0
  24. package/skills/lineage/SKILL.md +14 -4
  25. package/skills/lineage/lineage.py +168 -4
  26. package/skills/obsidizer/SKILL.md +29 -10
  27. package/skills/obsidizer/obsidize.mjs +42 -11
  28. package/skills/omc-patch/SKILL.md +6 -0
  29. package/skills/payload-mon/SKILL.md +2 -0
  30. package/skills/rfp-author/SKILL.md +5 -3
  31. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  32. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  33. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  34. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  35. package/skills/setup-omc-hud/SKILL.md +33 -3
  36. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  37. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  38. package/skills/smart-compact/SKILL.md +58 -12
  39. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  40. package/skills/ultra-chain/SKILL.md +8 -4
  41. package/skills/ultra-interview/SKILL.md +13 -2
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker progress (Codex) - 지금 Codex 세션의 진행 상황을 텍스트 트리로 보인다. Claude Code 의 /progress 패널에 해당한다.
4
+ *
5
+ * 읽는 것은 이 세션의 기록(rollout) 하나다. Codex 는 셸에 CODEX_THREAD_ID 를 주고, 그 id 가 rollout 파일 이름 끝에 있다.
6
+ * - 계획: 이 세션이 도구 호출(exec_command 의 명령, apply_patch 의 파일 머리)로 다룬 마지막 계획 파일(.omx/plans/*.md,
7
+ * .omc/plans/*.md, docs/superpowers/plans/*.md, ~/.claude/plans/*.md, prd.json, ultragoal/goals.json). 트리 엔진은
8
+ * Claude Code 패널과 같은 progress-plan.mjs(hooks/progress-plan.mjs 의 같은 사본, scripts/sync-adapter.js 가 지킴).
9
+ * - 단계: 마지막 update_plan 의 계획. 없으면 이 세션에서 사용자가 입력한 요청.
10
+ * - 하위 목록: 진행 중인 단계(없으면 마지막 요청) 뒤의 최근 도구 호출.
11
+ * 파일은 읽기만 한다. --pane 만 tmux 칸을 열고 닫는다.
12
+ *
13
+ * 사용: node progress.mjs [--depth N] [--rollout FILE] [--follow] [--pane on|off]
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { closeSync, constants, existsSync, fstatSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { isAbsolute, join, resolve } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import { clip, fit, parsePlan, planKind } from './progress-plan.mjs';
22
+
23
+ const MAX_READ = 32 << 20;
24
+ const PLAN_READ = 1 << 20;
25
+ const RECENT_CALLS = 5;
26
+ const RECENT_PROMPTS = 10;
27
+ const PANE_TITLE = 'banker-progress';
28
+ const ID_RE = /^[0-9A-Za-z-]{8,64}$/;
29
+
30
+ // 계획 파일인지: Claude Code 패널의 규칙에 OMX 의 계획 폴더(.omx/plans)를 더한다. 'plan', 'weak', null.
31
+ export function codexPlanKind(path) {
32
+ const p = String(path ?? '').replaceAll('\\', '/');
33
+ return planKind(p.replace(/\/\.omx\/plans\//, '/.omc/plans/'));
34
+ }
35
+
36
+ export function rolloutFor(thread, env = process.env) {
37
+ if (!ID_RE.test(thread ?? '')) return null;
38
+ const root = join(env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
39
+ const hits = [];
40
+ for (const y of list(root)) for (const m of list(join(root, y))) for (const d of list(join(root, y, m))) {
41
+ for (const f of list(join(root, y, m, d))) if (f.startsWith('rollout-') && f.endsWith(`-${thread}.jsonl`)) hits.push(join(root, y, m, d, f));
42
+ }
43
+ return hits.sort().at(-1) ?? null;
44
+ }
45
+
46
+ function list(dir) {
47
+ try { return readdirSync(dir).sort(); } catch { return []; }
48
+ }
49
+
50
+ // 일반 파일만 연다: 기다리지 않게 O_NONBLOCK 으로 열고, 연 것이 일반 파일인지 fstat 으로 본다. 아니면 null.
51
+ function openRegular(file) {
52
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
53
+ if (fstatSync(fd).isFile()) return fd;
54
+ closeSync(fd);
55
+ return null;
56
+ }
57
+
58
+ // 일반 파일의 끝 max 바이트를 줄로. 앞에서 잘린 첫 줄은 버린다.
59
+ export function tail(file, max = MAX_READ) {
60
+ const fd = openRegular(file);
61
+ if (fd === null) return [];
62
+ try {
63
+ const size = fstatSync(fd).size;
64
+ const start = Math.max(0, size - max);
65
+ const buf = Buffer.alloc(size - start);
66
+ readSync(fd, buf, 0, buf.length, start);
67
+ const lines = buf.toString('utf8').split('\n');
68
+ if (start > 0) lines.shift();
69
+ lines.cut = start > 0; // 앞쪽을 읽지 않았는지
70
+ return lines;
71
+ } finally {
72
+ closeSync(fd);
73
+ }
74
+ }
75
+
76
+ // 기록 첫 줄(session_meta)의 Codex 판. 끝만 읽은 기록에서도 판을 알기 위해 앞에서 따로 읽는다.
77
+ function headVersion(file) {
78
+ const fd = openRegular(file);
79
+ if (fd === null) return '';
80
+ try {
81
+ const buf = Buffer.alloc(Math.min(fstatSync(fd).size, 8 << 20));
82
+ readSync(fd, buf, 0, buf.length, 0);
83
+ const rec = json(buf.toString('utf8').split('\n')[0]);
84
+ return rec?.type === 'session_meta' ? String(rec.payload?.cli_version ?? '?') : '';
85
+ } finally {
86
+ closeSync(fd);
87
+ }
88
+ }
89
+
90
+ const json = (text) => { try { return JSON.parse(text); } catch { return null; } };
91
+
92
+ // rollout 줄에서 이 도구가 쓰는 기록만: 세션 머리, 사용자 요청, 도구 호출.
93
+ export function records(lines) {
94
+ const out = { cwd: '', version: '', prompts: [], calls: [], plan: null, planAt: -1 };
95
+ for (const line of lines) {
96
+ if (!line.includes('"session_meta"') && !line.includes('"user_message"') && !line.includes('"UserMessage"') && !line.includes('_call"')) continue;
97
+ const rec = json(line);
98
+ const p = rec?.payload;
99
+ if (!p || typeof p !== 'object') continue;
100
+ if (rec.type === 'session_meta') [out.cwd, out.version] = [String(p.cwd ?? ''), String(p.cli_version ?? '?')];
101
+ else if (rec.type === 'event_msg' && p.type === 'user_message' && typeof p.message === 'string') takePrompt(out, p.message);
102
+ else if (rec.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') takePrompt(out, itemText(p.item));
103
+ else if (rec.type === 'response_item' && /_call$/.test(String(p.type))) takeCall(out, p);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // Codex 0.160 은 사용자 글을 item_completed 의 UserMessage 로 쓴다. 두 형식이 함께 오면 같은 글은 한 번만.
109
+ const itemText = (item) => (Array.isArray(item.content) ? item.content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n') : '');
110
+ function takePrompt(out, text) {
111
+ const last = out.prompts.at(-1);
112
+ if (text && !(last && last.text === text && last.at === out.calls.length)) out.prompts.push({ text, at: out.calls.length });
113
+ }
114
+
115
+ function takeCall(out, p) {
116
+ const name = typeof p.name === 'string' && p.name ? p.name : String(p.type).slice(0, -5);
117
+ if (name === 'update_plan') {
118
+ const plan = json(p.arguments)?.plan;
119
+ if (Array.isArray(plan)) [out.plan, out.planAt] = [plan.filter((s) => s && typeof s.step === 'string'), out.calls.length];
120
+ return;
121
+ }
122
+ const args = typeof p.arguments === 'string' ? json(p.arguments) ?? {} : {};
123
+ const input = typeof p.input === 'string' ? p.input : '';
124
+ out.calls.push({ name, cmd: typeof args.cmd === 'string' ? args.cmd : '', workdir: typeof args.workdir === 'string' ? args.workdir : '', input });
125
+ }
126
+
127
+ // 한 도구 호출이 다룬 파일 경로들(명령의 낱말, apply_patch 의 파일 머리). Codex 0.160 은 도구를 코드로 부르므로
128
+ // (`exec` 의 입력 `tools.exec_command({cmd:'...'})`) 그 입력의 낱말도 본다.
129
+ export function pathsOf(call, cwd) {
130
+ // The code input is JS source, so a Windows path in it has each backslash written twice.
131
+ const code = call.name === 'apply_patch' ? '' : call.input.replace(/\\\\/g, '\\');
132
+ const words = `${call.cmd} ${code}`.match(/[^\s'"`;|&<>(){},]+\.(?:md|json)\b/g) ?? [];
133
+ const heads = [...call.input.matchAll(/^\*\*\* (?:Add|Update) File: (.+)$/gm)].map((m) => m[1].trim());
134
+ const base = call.workdir || cwd;
135
+ return [...words, ...heads].map((w) => (w.startsWith('~/') ? join(homedir(), w.slice(2)) : isAbsolute(w) || !base ? w : resolve(base, w)));
136
+ }
137
+
138
+ // 이 세션이 마지막으로 다룬 계획 파일. 리뷰 기록 같은 약한 계획은 다른 계획이 없을 때만.
139
+ export function lastPlan(calls, cwd) {
140
+ let weak = null;
141
+ for (let i = calls.length - 1; i >= 0; i--) {
142
+ for (const path of pathsOf(calls[i], cwd).reverse()) {
143
+ const kind = codexPlanKind(path);
144
+ if (kind === 'plan' && existsSync(path)) return path;
145
+ if (kind === 'weak' && !weak && existsSync(path)) weak = path;
146
+ }
147
+ }
148
+ return weak;
149
+ }
150
+
151
+ function readPlan(path) {
152
+ try {
153
+ const fd = openRegular(path);
154
+ if (fd === null) return null;
155
+ try {
156
+ const buf = Buffer.alloc(Math.min(fstatSync(fd).size, PLAN_READ));
157
+ readSync(fd, buf, 0, buf.length, 0);
158
+ return parsePlan(path, buf.toString('utf8'));
159
+ } finally {
160
+ closeSync(fd);
161
+ }
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ const BOX = { completed: '[x]', in_progress: '[>]', pending: '[ ]' };
168
+ const box = (status) => BOX[status] ?? '';
169
+ const one = (text, n) => clip(String(text ?? '').replace(/\s+/g, ' ').trim(), n);
170
+
171
+ function nodeText(n) {
172
+ const t = n.text || (Array.isArray(n.parts) ? n.parts.join(' ') : '') || n.name || n.head || '';
173
+ return [box(n.status), n.kind === 'item' && !n.status && n.marker ? (/\d/.test(n.marker) ? `${n.marker}.` : '-') : '', one(t, 100), n.count ? `(${n.count})` : '']
174
+ .filter(Boolean).join(' ');
175
+ }
176
+
177
+ export function renderTree(nodes, depth, out = [], level = 0) {
178
+ for (const n of nodes) {
179
+ out.push(`${' '.repeat(level + 1)}${nodeText(n)}`);
180
+ if (n.children?.length && level + 1 < depth) renderTree(n.children, depth, out, level + 1);
181
+ else if (n.children?.length) out[out.length - 1] += ` (+${n.children.length})`;
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // 한 도구 호출의 한 줄. 코드로 부른 도구는 그 안의 도구 이름과 cmd 를 보인다.
187
+ function callLine(c) {
188
+ const inner = c.input.match(/\btools\.(\w+)\(\{\s*cmd:\s*(['"`])((?:\\.|(?!\2).){0,200})/);
189
+ const name = inner ? inner[1] : c.name;
190
+ const what = c.cmd || (inner ? inner[3].replace(/\\(.)/g, '$1') : c.input.match(/^\*\*\* \w+ File: .+$/m)?.[0] ?? '');
191
+ return ` - ${name}${what ? `: ${one(what, 80)}` : ''}`;
192
+ }
193
+
194
+ function stepLines(r) {
195
+ const out = [];
196
+ if (r.plan) {
197
+ out.push('단계 (update_plan)');
198
+ const current = r.plan.findIndex((s) => s.status === 'in_progress');
199
+ r.plan.forEach((s, i) => {
200
+ out.push(` ${box(s.status) || '[ ]'} ${one(s.step, 100)}`);
201
+ if (i === current) out.push(...r.calls.slice(r.planAt).slice(-RECENT_CALLS).map(callLine));
202
+ });
203
+ return out;
204
+ }
205
+ const prompts = r.prompts.slice(-RECENT_PROMPTS);
206
+ out.push(prompts.length ? '요청 (update_plan 없음)' : '요청 없음');
207
+ prompts.forEach((p, i) => {
208
+ const last = i === prompts.length - 1;
209
+ out.push(` ${last ? '[>]' : '[x]'} ${one(p.text, 100)}`);
210
+ if (last) out.push(...r.calls.slice(p.at).slice(-RECENT_CALLS).map(callLine));
211
+ });
212
+ return out;
213
+ }
214
+
215
+ // rollout 하나를 진행 상황 글로.
216
+ export function render(file, depth = 2, max = MAX_READ) {
217
+ const lines = tail(file, max);
218
+ const r = records(lines);
219
+ if (lines.cut) r.version = headVersion(file);
220
+ const out = [`진행 상황 (${file.split(/[\\/]/).at(-1).slice(-42, -6)})`];
221
+ const planPath = lastPlan(r.calls, r.cwd);
222
+ const plan = planPath && readPlan(planPath);
223
+ if (plan) {
224
+ out.push(`계획: ${clip(planPath, 200)}${plan.hasStates ? ` ${plan.done}/${plan.total}` : ''}`);
225
+ out.push(...renderTree(plan.tree, depth));
226
+ if (plan.treeCut) out.push(' (계획이 길어 일부만 보임)');
227
+ } else out.push('계획 파일 없음');
228
+ out.push('', ...stepLines(r));
229
+ // Codex 세션은 언제나 사용자 글로 시작한다. 하나도 못 읽었으면 이 Codex 판의 기록 형식이 바뀐 것이라 빈 결과로 넘기지 않는다.
230
+ if (r.version && !r.prompts.length && lines.cut) out.push('', '참고: 기록이 32 MB 를 넘어 끝부분만 읽었고, 그 안에는 사용자 요청이 없음');
231
+ else if (r.version && !r.prompts.length) {
232
+ out.push('', `경고: 이 Codex ${r.version} 기록에서 사용자 요청을 하나도 읽지 못함. 기록 형식이 바뀌었을 수 있음(banker 업데이트 확인)`);
233
+ }
234
+ return out.join('\n');
235
+ }
236
+
237
+ function opt(argv, name, fallback) {
238
+ const i = argv.indexOf(name);
239
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : fallback;
240
+ }
241
+
242
+ function pane(mode, file, depth) {
243
+ if (!process.env.TMUX) return 'tmux 안이 아니라 칸을 열 수 없음. 진행 상황은 이 출력으로 봄';
244
+ const ids = spawnSync('tmux', ['list-panes', '-F', '#{pane_id} #{pane_title}'], { encoding: 'utf8' });
245
+ if (ids.status !== 0) return `tmux 에 닿지 못함(${(ids.stderr || '').trim() || ids.error?.code}). 샌드박스 밖 실행 승인이 필요할 수 있음`;
246
+ for (const l of ids.stdout.split('\n')) if (l.endsWith(` ${PANE_TITLE}`)) spawnSync('tmux', ['kill-pane', '-t', l.split(' ')[0]]);
247
+ if (mode === 'off') return 'progress 칸을 닫음';
248
+ const me = fileURLToPath(import.meta.url);
249
+ const cmd = [process.execPath, me, '--follow', '--rollout', file, '--depth', String(depth)].map((a) => `'${a.replaceAll("'", "'\\''")}'`).join(' ');
250
+ const r = spawnSync('tmux', ['split-window', '-h', '-d', '-l', '40%', '-P', '-F', '#{pane_id}', cmd], { encoding: 'utf8' });
251
+ if (r.status !== 0) return `칸을 열지 못함(${(r.stderr || '').trim()})`;
252
+ spawnSync('tmux', ['select-pane', '-t', r.stdout.trim(), '-T', PANE_TITLE]);
253
+ return 'progress 칸을 오른쪽에 엶(2초마다 갱신). 닫기: --pane off';
254
+ }
255
+
256
+ // 기록 크기, 바뀐 시각, 칸 폭이 그대로면 다시 읽지 않는다(2초마다 32 MB 를 읽지 않게).
257
+ function follow(file, depth) {
258
+ let seen = '';
259
+ const draw = () => {
260
+ let key = '';
261
+ // 10초마다는 다시 그린다: 계획 파일만 바뀐 것은 기록 크기로 알 수 없다.
262
+ try { const st = statSync(file); key = `${st.size}:${st.mtimeMs}:${process.stdout.columns}:${Math.floor(Date.now() / 10000)}`; } catch { key = 'gone'; }
263
+ if (key === seen) return;
264
+ seen = key;
265
+ let text;
266
+ try { text = render(file, depth); } catch (e) { text = `읽지 못함: ${e.message}`; }
267
+ const width = process.stdout.columns || 80;
268
+ process.stdout.write(`\x1b[2J\x1b[H${text.split('\n').map((l) => fit(l, width)).join('\n')}\n`);
269
+ };
270
+ draw();
271
+ setInterval(draw, 2000);
272
+ }
273
+
274
+ function main(argv) {
275
+ const depth = Math.max(1, Math.min(9, Number.parseInt(opt(argv, '--depth', '2'), 10) || 2));
276
+ const file = opt(argv, '--rollout', null) ?? rolloutFor(process.env.CODEX_THREAD_ID);
277
+ if (!file) {
278
+ console.error('Codex 세션 기록을 찾지 못함: Codex 안(CODEX_THREAD_ID)에서 실행하거나 --rollout FILE 을 줌');
279
+ return 2;
280
+ }
281
+ if (argv.includes('--follow')) return follow(file, depth);
282
+ if (argv.includes('--pane')) {
283
+ console.log(pane(opt(argv, '--pane', 'on') === 'off' ? 'off' : 'on', file, depth));
284
+ return 0;
285
+ }
286
+ console.log(render(file, depth));
287
+ return 0;
288
+ }
289
+
290
+ // 직접 실행일 때만 main. 경로에 링크가 있어도(~/.codex 가 링크인 경우) 같은 파일로 본다.
291
+ const same = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return false; } };
292
+ if (process.argv[1] && same(fileURLToPath(import.meta.url), resolve(process.argv[1]))) {
293
+ const code = main(process.argv.slice(2));
294
+ if (typeof code === 'number') process.exitCode = code;
295
+ }
@@ -1,25 +1,27 @@
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` surfaces ONLY (`claude-only` surfaces are never written to Codex — currently there are none; every surface is `both`).
4
+ The generator (`banker setup --codex`) applies the rules below for `target: both` and `codex-only` surfaces (`claude-only` surfaces are never written to Codex — currently there are none). A `codex-only` surface has its source in `codex/skills/<name>/` and is never part of the Claude plugin (currently `graceful-pause` and `progress`).
5
5
 
6
6
  | Source (Claude) | Codex destination | Transform |
7
7
  |---|---|---|
8
8
  | `skills/<name>/SKILL.md` (+ subtree) | `~/.codex/skills/banker-<name>/` (whole dir) | **COPY subtree** (Windows: no symlink), then **rewrite the frontmatter `name:` to `banker-<name>`**. Codex discovers skills by directory and requires `name:` == directory name, so the prefix must be applied to both (leaving `name: <name>` while the dir is `banker-<name>` makes Codex skip the skill). `description` is left as-is. |
9
- | `commands/<name>.md` | `~/.codex/prompts/banker-<name>.md` | Near-identical (frontmatter `description`+`argument-hint`). Codex prompts are flat → the `banker-` prefix lives in the filename; invoked as `/banker-<name>`. |
9
+ | `commands/<name>.md` | `~/.codex/prompts/banker-<name>.md` **and** `~/.codex/skills/banker-<name>/SKILL.md` | The prompt file is near-identical (frontmatter `description`+`argument-hint`), for Codex builds that still read custom prompts (`/banker-<name>`). Codex 0.144+ has no custom prompts (`/banker-x` is an unrecognized command), so each command is also written as a skill: frontmatter `name: banker-<name>` + the command's `description`, then one line saying `$ARGUMENTS`/`$1` mean the text after `$banker-<name>`, then the command body. Invoked as `$banker-<name>`. |
10
+ | `codex/skills/<name>/` (`codex-only`) | `~/.codex/skills/banker-<name>/` | Same copy and `name:` rewrite as a `both` skill. |
11
+ | `hooks/update-notify.mjs`, `hooks/telemetry-count-codex.mjs` (+ the modules they import) | `~/.codex/hooks-custom/banker/` + one group each in `~/.codex/hooks.json` | `bin/lib/codex-hooks.mjs`: copies the hook payload out of any workspace, then appends one group per hook last under its event (`SessionStart` for the update notice with `--codex`, `Stop` for usage counting). Only our handlers are ever removed (`banker uninstall --codex`, `banker hooks disable <name>`); a stale command is fixed in place; a link or a malformed `hooks.json` is refused; a Codex home inside the current folder or its git repository (the home folder and the filesystem root aside) gets no hooks, since a sandboxed session could edit the copies; the file is backed up to `hooks.json.banker.bak` before each write. Windows uses the PowerShell call form `& '<node>' '<script>'`. Codex runs a new hook only after the user trusts it (Hooks need review, `/hooks`). |
10
12
  | (none) | `~/.codex/AGENTS.md` | **NOT TOUCHED by setup.** omx regenerates it (clobber risk, `:7`/`:253`); `omx setup --merge-agents` keeps only `<!-- USER:OMX:POLICY:START -->`…`END` blocks on a rewrite; `--force`, a confirmed overwrite, and a plain `omx setup` with team mode off replace the file whole; a plain `omx setup` with team mode on only refreshes the model table. Rely on `~/.codex/skills/` auto-discovery. The runtime writers are the `tone-compact` and `anti-drm` skills, at the user's request, each inside such a block. |
11
13
 
12
14
  ## Naming
13
15
  - Codex skill dir **and** frontmatter `name:`: `banker-<name>` (avoids collision with omx/system skills; kept in sync so Codex discovers it).
14
- - Codex prompt file: `banker-<name>.md` → invoked `/banker-<name>`.
16
+ - Codex prompt file: `banker-<name>.md` → invoked `/banker-<name>` where custom prompts exist; the same command as a skill is `$banker-<name>`.
15
17
 
16
18
  ## Scope
17
19
  - `--scope user` (default) → `~/.codex/…`. `--scope project` → `./.codex/…`.
18
20
 
19
21
  ## Runtime-aware surfaces (both) — Codex uses OMX / Codex equivalents
20
- 현재 `claude-only` 표면은 **없다**(전부 `both`). OMC/Claude 에 결합됐던 표면은 본문이 런타임 인식으로 작성돼 Codex에서도 동작한다 — Codex에선 OMC 대신 oh-my-codex(OMX)의 동명 스킬을 쓴다:
22
+ 현재 `claude-only` 표면은 **없다**(Claude 플러그인의 표면은 전부 `both`, Codex 에만 있는 `codex-only` 는 `graceful-pause`, `progress`). OMC/Claude 에 결합됐던 표면은 본문이 런타임 인식으로 작성돼 Codex에서도 동작한다 — Codex에선 OMC 대신 oh-my-codex(OMX)의 동명 스킬을 쓴다:
21
23
  - 오케스트레이터 `all-in-one`·`ultra-init`·커맨드 `front-qa` → OMX `ralplan`/`ralph`/`ultraqa`. `ultra-init` 은 0.14.0 부터 ultragoal 원장을 쓰지 않는다(OMC 5.6.1 checkpoint 가 `/goal` 스냅샷을 요구해 ralph 와 Stop 훅 루프가 겹친다). Claude 쪽은 OMC 5 기준: `ultraqa` 가 5.0.0 에서 삭제돼 QA 게이트를 `verify` 순환(독립 verifier → architect 진단 → executor 수정, 최대 5회)으로 직접 돌리고, `ralph` 가 부르는 `omc ralph verify` 때문에 전역 `omc` CLI 가 플러그인과 같은 5.x 여야 한다(사전 점검은 종료 코드가 아니라 출력 문구로).
22
- - `setup-omc` → `omx setup`(본문에 이미 존재). `setup-omc-hud` → OMX `hud`. `setup-stitch` → `codex mcp add`.
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 설정.
23
25
  - `compact-copy` → Codex 내장 `/copy`(경로는 Codex 규약). `omc-reference` → Codex는 OMX 카탈로그 기준. `setup` 커맨드 → Codex는 프롬프트 선택 UX.
24
26
  `setup-insane-search` 도 `both`: Codex에선 `codex plugin add insane-research-codex@gptaku-codex`.
25
27
 
@@ -28,27 +30,30 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
28
30
  - `deep-init` → 서브에이전트 Claude=OMC explore/architect/writer, Codex=OMX worker/explore(부재 시 직접 수행). 순수 fs+doc.
29
31
  - `deep-research` → Claude 번들 워크플로/`WebSearch`, Codex OMX `autoresearch`/`best-practice-research`.
30
32
  - `ralph-qa` → 백본은 세션 모델로 띄운 검토 에이전트 3개 이상이다(Claude=Agent 의 `Plan` 을 `model` 없이, Codex=`spawn_agent` 를 역할 없이, 저자 대화를 넘기지 않고). 두 런타임 모두 Bash 가 있어 읽기 전용은 지시로 선다. 외부 좌석은 `--codex`·`--gemini`·`--opencode` 를 줄 때만 앉고, 저자 계열은 앉지 않는다(Codex 런타임의 `--codex` 는 자기 자신이라 부적격). Codex 의 `workspace-write` 샌드박스는 HOME 쓰기를 막아 gemini, opencode 가 시작부터 실패하고, Linux 에서는 페이로드 기준 폴더(`$XDG_RUNTIME_DIR`, `~/.cache`)도 막는다. 그래서 외부 좌석 전송의 실행기 명령 전부(`seat-run.mjs` 의 init, untracked, prepare, check, send, cleanup, put)를 승인을 받아 샌드박스 밖에서 실행한다.
31
- - `lineage` → 기본 흐름의 검토자는 세션 모델이다(Claude=Agent 의 `Plan` 을 `model` 없이, Codex=`spawn_agent` 를 역할 없이, 저자 대화를 넘기지 않고). 띄울 수 없으면 세션이 파트를 직접 검토한다. Codex 검토자에게는 파일 읽기 도구가 없어 파트 파일을 읽는 셸 명령만 쓰게 한다. `workspace-write` 샌드박스에서는 `~/.cache` 에 쓸 수 없어 이번 실행의 검토 결정을 캐시에 쓰지 못한다(WARN). 앞선 실행이 남긴 결정은 읽는다. 읽는 기록은 Claude Code 형식뿐이라 Codex 에서는 Claude Code 기록을 내보낼 때만 쓴다. `--rulebase` 는 모델 호출 없이 양쪽 같다.
32
- - `smart-compact` → Claude statusLine `context_window.used_percentage`+hook; Codex 신호 미확인 시 휴리스틱 폴백. TUI 3단(`/copy`·`/compact`·paste)은 유저.
33
+ - `lineage` → 기본 흐름의 검토자는 세션 모델이다(Claude=Agent 의 `Plan` 을 `model` 없이, Codex=`spawn_agent` 를 역할 없이, 저자 대화를 넘기지 않고). 띄울 수 없으면 세션이 파트를 직접 검토한다. Codex 검토자에게는 파일 읽기 도구가 없어 파트 파일을 읽는 셸 명령만 쓰게 한다. `workspace-write` 샌드박스에서는 `~/.cache` 에 쓸 수 없어 이번 실행의 검토 결정을 캐시에 쓰지 못한다(WARN). 앞선 실행이 남긴 결정은 읽는다. Codex rollout 도 읽는다: Codex 안에서는 `CODEX_THREAD_ID`(rollout 파일 이름 끝의 id)로 지금 세션을 찾고, `--all-sessions` 는 `session_meta` 의 cwd 가 지금 폴더인 rollout 을 합친다. 사용자가 입력한 글(`user_message`), 모델의 답(`agent_message`), 도구 호출 이름(`*_call`)만 읽고 모델 입력, 추론, 도구 결과는 읽지 않는다. 캐시는 4단계를 승인받아 샌드박스 밖에서 돌리거나 `--add-dir ~/.cache/lineage` 로 쓴다. `--rulebase` 는 모델 호출 없이 양쪽 같다.
34
+ - `smart-compact` → Claude statusLine `context_window.used_percentage`+hook; Codex 는 `scripts/codex-gate.mjs` 사본(`$CODEX_HOME/hooks-custom/`)을 `UserPromptSubmit` 훅으로 `hooks.json` 맨 뒤에 더해, 훅 입력의 `transcript_path`(rollout)에서 마지막 `token_count` 의 `last_token_usage.total_tokens` / `model_context_window` 로 잰다(0.144.5 실측). 새 훅은 사용자가 신뢰해야 돈다("Hooks need review", `/hooks`). TUI 3단(`/copy`·`/compact`·paste)은 유저.
33
35
  - `curation` → 런타임 무관(외부 의존 0, 양쪽 동일).
34
36
 
35
37
  ## Caveats (documented; not blockers)
36
- - `append_wiki`, `compact-wiki` — use `wiki_*` MCP at runtime → copy fine, need a wiki MCP available in Codex.
38
+ - `append_wiki`, `compact-wiki` — Claude Code uses the OMC `wiki_*` MCP over `.omc/wiki`; Codex has no wiki MCP, so both skills tell it to use the `omx wiki <tool> --input <json> --json` CLI over `omx_wiki/` (same tool names and inputs, workspace-only writes).
37
39
  - `audit-web-page`, `play-qa`, `ultra-ui-qa` — need playwright (install via `setup-playwright`).
38
40
  - `setup-playwright` — genericize the `/banker:setup` trigger phrasing for the Codex copy.
39
41
  - `visual-ralph` — needs an imagegen path (Stitch via `setup-stitch`, or `/ccg`/Gemini) + a frontend repo; static/live-URL reference works without imagegen.
40
42
  - `ralph-qa` — runs with no external dependency: the backbone of 3+ session-model reviewer agents always runs, and an external CLI seat joins only on its flag (`--codex`, `--gemini`, `--opencode`), on that CLI's best model as the probe reads it from local config and the CLI's bundled list (the probe sends no HTTP request and no prompt; opencode is started with its model-list refresh off). Its absence is a coverage fact (model axis uncovered), not a degraded mode. A same-runtime `critic` is never substituted for an external seat — on Codex that would be GPT approving GPT, i.e. self-approval.
41
43
  - `deep-research` — needs web search/fetch (Claude WebSearch/WebFetch or bundled workflow; Codex OMX autoresearch).
42
- - `smart-compact` — Claude statusLine exposes context%; Codex signal unconfirmed → heuristic fallback; `/copy`·`/compact`·paste stay user-driven (TUI).
43
- - `obsidizer` — OMC-managed trees respect the 9-field frontmatter whitelist + reserved files + no-rename + bare `[[slug]]` links only; `aliases` are written in generic vaults only; Canvas sidecars and body-inline `::` are durable in-place; no `wiki_*` write dependency, `wiki_lint` read-only for verification. `--enable` is Claude-only (plugin-declared PostToolUse hook); Codex has no MCP tool hooks → honest no-op.
44
- - `payload-mon` — needs the OMC custom HUD wrapper (`setup-omc-hud`). It measures Claude Code sessions only; from Codex it manages the same machine's Claude Code HUD wrapper and never measures Codex's own requests.
44
+ - `smart-compact` — Claude statusLine exposes context%; Codex hooks get no percentage but every hook input carries `transcript_path`, whose `token_count` events give it (`scripts/codex-gate.mjs`). `on` copies the script to `$CODEX_HOME/hooks-custom/` and points the hook there (Codex runs trusted hooks outside the sandbox, so the hook must not run from a workspace); on Windows the command is PowerShell's `& '<node>' '<copy>' hook` (Codex 0.160.1). `on`/`off` write `~/.codex`, so under `workspace-write` they need approval to run outside the sandbox (not `--add-dir ~/.codex`); `/copy`·`/compact`·paste stay user-driven (TUI).
45
+ - `obsidizer` — OMC-managed trees respect the 9-field frontmatter whitelist + reserved files + no-rename + bare `[[slug]]` links only; `aliases` are written in generic vaults only; Canvas sidecars and body-inline `::` are durable in-place; no `wiki_*` write dependency, `wiki_lint` read-only for verification. `--enable` in Claude Code arms the plugin-declared PostToolUse hook on the OMC wiki write tools. Codex has no wiki MCP (wiki writes are `omx wiki` shell calls), so there `--enable` writes `omx_wiki/.obsidizer` and installs a user-scope PostToolUse hook with matcher `Bash` (`banker hooks enable obsidize`, a copy in `$CODEX_HOME/hooks-custom/banker/` along with `skills/obsidizer/obsidize.mjs`). The hook runs `obsidize-hook.mjs --codex`: any shell call that is not `omx wiki` ingest/add ends after reading the input; for one that is, it canonicalizes the `omx_wiki/` pages named in the CLI's JSON stdout (`tool_response`) under the session cwd, with the same `omc` profile (the OMX wiki keeps the OMC schema and reserved files). Because the hook spawns node after every shell call in every project, it is installed only on `--enable`.
46
+ - `payload-mon` — needs the OMC custom HUD wrapper (`setup-omc-hud`). It measures Claude Code sessions only; from Codex it manages the same machine's Claude Code HUD wrapper and never measures Codex's own requests. Under Codex's `workspace-write` sandbox, `on` and `off` need approval to run outside it (not `--add-dir ~/.claude`, which would let the session rewrite hooks); `status` only reads.
45
47
  - `tone-compact` — per runtime: Claude Code loads `<config>/rules/banker-tone-compact.md`; Codex reads one marked block at the end of `$CODEX_HOME/AGENTS.override.md` (when it has content of its own) or `AGENTS.md`, wrapped in `USER:OMX:POLICY` so `omx setup --merge-agents` keeps it (`--force` or team mode off replaces the file; run `on` again). Turning it on in one runtime leaves the other as it is.
46
48
  - `anti-drm` — the same places as `tone-compact` through the same shared module (`instruction-toggle.mjs`, a byte-identical copy in each skill): `<config>/rules/banker-anti-drm.md` for Claude Code, one `USER:OMX:POLICY`-wrapped block in the instructions file Codex reads. The settings (`~/.config/banker/anti-drm.json`) are shared by both runtimes; `on` and `off` act on one runtime, while `config` and `on` with settings refresh every runtime that holds the rules.
47
- - `omc-patch` — acts on the Claude Code OMC plugin cache (`~/.claude/plugins/`) even when run from Codex; Codex's own OMX is untouched. The skill always passes `--no-update` (a plain apply would switch the active OMC to the tool's hard-coded `TARGET_VERSION`). `--revert --root` restores only the active plugin folder when an older kit left the timeout helper inside a block, and `scripts/probe-pm.mjs` runs OMC's project-memory hook in a temp scratch project with its own home and state folder.
49
+ - `omc-patch` — acts on the Claude Code OMC plugin cache (`~/.claude/plugins/`) even when run from Codex; Codex's own OMX is untouched. The skill always passes `--no-update` (a plain apply would switch the active OMC to the tool's hard-coded `TARGET_VERSION`). `--revert --root` restores only the active plugin folder when an older kit left the timeout helper inside a block, and `scripts/probe-pm.mjs` runs OMC's project-memory hook in a temp scratch project with its own home and state folder. Under `workspace-write`, `--check` and `probe-pm.mjs` run as they are; apply and revert need approval to run outside the sandbox (not `--add-dir ~/.claude`).
48
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.
49
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.
50
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.
51
- - `/graceful-pause` (not a manifest surface) — a Claude Code function-hooks command (`hooks/register.mjs`, 2.1.289+) registered `immediate`, so it runs mid-turn; it appends one hidden user row the running turn reads at its next model request. Codex has no equivalent (no function hooks), and the earlier `graceful_pause` skill was removed because a typed skill waits until the turn ends.
53
+ - `/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
+ - `/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.
55
+ - 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
+ - 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.
52
57
 
53
58
  신규 스킬(0.7.0)도 `both`·런타임 인식:
54
59
  - `ultra-interview` → 리서치 선행 질문 최소화 + 모호성 3% 이하 종료. **자체 루브릭 소유**가 설계 요점이다: OMC deep-interview 는 3차원, OMX 는 5차원으로 산식이 달라 네이티브 채점을 물려받으면 같은 임계값이 런타임마다 다른 뜻이 된다. OMC/OMX 네이티브 `deep-interview` 를 번들하거나 대체하지 않는다. 규제 조회는 엔드포인트 하드코딩이 아니라 "해당 관할의 공식 1차 출처를 찾아라"는 일반 지시다.
@@ -19,9 +19,16 @@
19
19
  * stderr too, so that log is the only channel by which a permanently broken hook is
20
20
  * discoverable rather than silently doing nothing forever. Only a regular file there is
21
21
  * written: a link (a cloned repository can carry one) or a pipe gets no line.
22
+ *
23
+ * Codex (`--codex`): Codex has no wiki MCP, so a wiki write is a shell call to the `omx wiki` CLI, and Codex fires
24
+ * PostToolUse with tool_name Bash, tool_input.command and the command's stdout as tool_response (Codex 0.144.5).
25
+ * Only a command that runs `omx wiki` ingest or add goes on; every other shell call ends before any file is read. The
26
+ * vault is omx_wiki/ under the session cwd (the CLI writes under the cwd it runs in), and the page names come from the
27
+ * CLI's JSON (`{"created":["a.md"],...}`), which the same name pattern reads. The OMX wiki keeps the OMC page schema
28
+ * and reserved files, so the canonicalizer runs with the same profile.
22
29
  */
23
30
  import { spawnSync } from 'node:child_process';
24
- import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readdirSync, statSync, writeSync } from 'node:fs';
31
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readdirSync, realpathSync, statSync, writeSync } from 'node:fs';
25
32
  import { basename, dirname, join, resolve } from 'node:path';
26
33
  import { fileURLToPath } from 'node:url';
27
34
 
@@ -40,6 +47,46 @@ const RESERVED = new Set(['index.md', 'log.md', 'environment.md']);
40
47
  const RECENT_MS = 10_000;
41
48
  // Stay inside the `timeout: 5` declared in hooks.json.
42
49
  const CANONICALIZE_TIMEOUT_MS = 4000;
50
+ const CODEX = process.argv.includes('--codex');
51
+ // A statement that IS `omx wiki ingest|add` (omx its first word, PowerShell's `&` allowed before it), with or without
52
+ // the wiki_ prefix the CLI also takes, and omx.cmd/omx.ps1 on Windows. A command may set the JSON up first
53
+ // (`$j = '...'; omx wiki <ingest> --input $j`, as models write it for PowerShell), so each statement split at
54
+ // `;`, `&&`, `||` and line ends outside quotes is looked at. `echo omx wiki add` and `echo "x; omx wiki add"` are
55
+ // not one. This only spares the hook needless runs: what keeps a woken hook inside the vault is the rest (the CLI's
56
+ // JSON names, regular single-name pages, a canonicalizer pinned to the checked omx_wiki/ of the session folder).
57
+ const OMX_WIKI_WRITE_RE = /^\s*(?:&\s*)?['"]?omx(?:\.cmd|\.ps1)?['"]?\s+wiki\s+(?:wiki_)?(?:ingest|add)\b/;
58
+
59
+ // A command's statements, split at `;`, `&&`, `||` and line ends outside quotes. A backslash keeps the next character
60
+ // outside quotes and inside double quotes; a backtick does so only inside double quotes (PowerShell's escape there; in
61
+ // sh a backtick starts a command substitution, so outside quotes it is an ordinary character here). Inside single
62
+ // quotes nothing is special.
63
+ function statements(command) {
64
+ const out = [];
65
+ let cur = '';
66
+ let quote = '';
67
+ for (let i = 0; i < command.length; i++) {
68
+ const c = command[i];
69
+ const two = command.slice(i, i + 2);
70
+ if ((c === '\\' && quote !== "'") || (c === '`' && quote === '"')) {
71
+ cur += command.slice(i, i + 2);
72
+ i++;
73
+ } else if (quote) {
74
+ if (c === quote) quote = '';
75
+ cur += c;
76
+ } else if (c === '"' || c === "'") {
77
+ quote = c;
78
+ cur += c;
79
+ } else if (c === ';' || c === '\n' || c === '\r' || two === '&&' || two === '||') {
80
+ out.push(cur);
81
+ cur = '';
82
+ if (two === '&&' || two === '||') i++;
83
+ } else cur += c;
84
+ }
85
+ // A lone quote (an apostrophe in a here-string or a comment) leaves the scan inside quotes to the end; then the plain
86
+ // split is taken too, so such a command is not missed.
87
+ return quote ? [...out, cur, ...command.split(/;|&&|\|\||\r?\n/)] : [...out, cur];
88
+ }
89
+ const PAGE_NAME_RE = /^[\w.-]+\.md$/;
43
90
 
44
91
  function readStdin() {
45
92
  return new Promise((resolvePromise, rejectPromise) => {
@@ -60,7 +107,8 @@ function log(wikiDir, tool, reason) {
60
107
  if (!NO_FOLLOW && isLink(at)) return;
61
108
  const fd = openSync(at, LOG_FLAGS, 0o600);
62
109
  try {
63
- if (fstatSync(fd).isFile()) writeSync(fd, line);
110
+ const st = fstatSync(fd);
111
+ if (st.isFile() && st.nlink === 1) writeSync(fd, line); // a hard link to a file elsewhere gets no line
64
112
  } finally {
65
113
  closeSync(fd);
66
114
  }
@@ -78,6 +126,7 @@ function isDir(path) {
78
126
  }
79
127
 
80
128
  function wikiDirCandidates(root) {
129
+ if (CODEX) return root ? [join(root, 'omx_wiki')] : [];
81
130
  const dirs = [];
82
131
  // OMC relocates state to <OMC_STATE_DIR>/<projectId>/wiki. projectId is an OMC internal
83
132
  // we must not reimplement, so enumerate the candidates instead of deriving the name.
@@ -95,7 +144,49 @@ function wikiDirCandidates(root) {
95
144
 
96
145
  // The flag both arms the hook and identifies which vault it may touch.
97
146
  function findArmedWikiDir(root) {
98
- return wikiDirCandidates(root).find((dir) => isDir(dir) && existsSync(join(dir, FLAG))) ?? null;
147
+ return wikiDirCandidates(root).find((dir) => isDir(dir) && existsSync(join(dir, FLAG)) && (!CODEX || ownDir(root, dir))) ?? null;
148
+ }
149
+
150
+ // Codex runs this hook outside its sandbox, so the vault must really be omx_wiki/ inside the session folder: not a
151
+ // link (nor under one) that a sandboxed session or a cloned repository could point at another project's wiki.
152
+ function ownDir(root, dir) {
153
+ try {
154
+ return !lstatSync(dir).isSymbolicLink() && realpathSync(dir) === join(realpathSync(root), basename(dir));
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // Codex: only a regular page with one name, so a link or a hard link cannot carry the rewrite out of the vault,
161
+ // and a pipe cannot hold the hook.
162
+ function plainPage(path) {
163
+ try {
164
+ const st = lstatSync(path);
165
+ return st.isFile() && st.nlink === 1;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ // Codex: the pages the `omx wiki` CLI's JSON says it wrote ({"created":[..],"updated":[..]}); else none. Other
172
+ // output may come before it, so the whole output and then each line from the end is tried.
173
+ function cliResult(stdout) {
174
+ const text = String(stdout ?? '');
175
+ for (const part of [text, ...text.split(/\r?\n/).reverse()]) {
176
+ try {
177
+ const out = JSON.parse(part);
178
+ if (out && typeof out === 'object' && (Array.isArray(out.created) || Array.isArray(out.updated))) return out;
179
+ } catch {
180
+ // not this one
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function namesFromCli(stdout) {
187
+ const out = cliResult(stdout);
188
+ const list = [out?.created, out?.updated].filter(Array.isArray).flat();
189
+ return [...new Set(list.filter((n) => typeof n === 'string' && PAGE_NAME_RE.test(n)))];
99
190
  }
100
191
 
101
192
  function namesFromResponse(toolResponse) {
@@ -115,6 +206,9 @@ function namesFromScan(wikiDir) {
115
206
  }
116
207
 
117
208
  function resolvePages(wikiDir, toolResponse) {
209
+ if (CODEX) {
210
+ return namesFromCli(toolResponse).filter((name) => !RESERVED.has(name)).map((name) => join(wikiDir, name)).filter(plainPage);
211
+ }
118
212
  const names = namesFromResponse(toolResponse);
119
213
  return (names.length > 0 ? names : namesFromScan(wikiDir))
120
214
  .filter((name) => !RESERVED.has(name))
@@ -143,9 +237,19 @@ function runFailure(run) {
143
237
  }
144
238
  }
145
239
 
240
+ // Codex: the canonicalizer runs in the vault folder on a bare page name, after it checks the folder is the one
241
+ // looked at here (dev:ino), so swapping omx_wiki/ for a link after the check cannot lead it outside.
242
+ function codexArgs(wikiDir, page) {
243
+ const st = lstatSync(wikiDir, { bigint: true });
244
+ if (!st.isDirectory()) throw new Error('omx_wiki is no folder');
245
+ return { args: [basename(page), '--page', `--vault=${st.dev}:${st.ino}`], cwd: wikiDir };
246
+ }
247
+
146
248
  // Canonicalizes one page; a failure appends its one log line.
147
249
  function canonicalize(wikiDir, tool, page) {
148
- const run = spawnSync(process.execPath, [CANONICALIZER, page, '--profile=omc', '--json'], {
250
+ const at = CODEX ? codexArgs(wikiDir, page) : { args: [page], cwd: undefined };
251
+ const run = spawnSync(process.execPath, [CANONICALIZER, ...at.args, '--profile=omc', '--json'], {
252
+ cwd: at.cwd,
149
253
  encoding: 'utf8',
150
254
  timeout: CANONICALIZE_TIMEOUT_MS,
151
255
  windowsHide: true,
@@ -156,11 +260,19 @@ function canonicalize(wikiDir, tool, page) {
156
260
 
157
261
  // A write can be redirected with workingDirectory; fall back to the session cwd.
158
262
  function writeRoot(payload) {
263
+ if (CODEX) return payload?.cwd || process.cwd();
159
264
  return payload?.tool_input?.workingDirectory || payload?.cwd || process.cwd();
160
265
  }
161
266
 
267
+ // Codex fires this after every shell call; only an `omx wiki` write is ours.
268
+ function codexWikiWrite(payload) {
269
+ const command = payload?.tool_input?.command;
270
+ return typeof command === 'string' && statements(command).some((part) => OMX_WIKI_WRITE_RE.test(part));
271
+ }
272
+
162
273
  async function main() {
163
274
  const { payload, error } = await readPayload();
275
+ if (CODEX && !error && !codexWikiWrite(payload)) return;
164
276
  const wikiDir = findArmedWikiDir(writeRoot(payload));
165
277
  if (!wikiDir) return; // Not armed: change nothing, log nothing.
166
278
 
@@ -222,7 +222,7 @@ const step = (title, text, status) => ({
222
222
 
223
223
  const str = (v) => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '');
224
224
  // A map's own entry: a key like `constructor` finds nothing.
225
- export const pick = (map, key) => (Object.hasOwn(map, key) ? map[key] : undefined);
225
+ export const pick = (map, key) => (Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined);
226
226
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
227
227
  // ultragoal's states, and the ones like them; one it does not know shows no state.
228
228
  const GOAL_STATUS = { complete: 'completed', completed: 'completed', in_progress: 'in_progress', active: 'in_progress',