@kaydash9999/banker-plugins 0.20.2 → 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 (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  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 +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -122,11 +122,14 @@ const looseStep = () => ({ key: 'loose', title: '작업 목록 밖의 작업', .
122
122
  // list last came from.
123
123
  // `plan` is the plan last read from `planPath`, `planShut` whether the whole plan is folded and
124
124
  // `planOpen` the ids of its nodes unfolded. `touch` counts the calls that touched a plan file and
125
- // `taken` is the touch the plan came from, to tell a read that a newer one overtook. `followed` is the step in
125
+ // `taken` is the newest touch whose read was taken in, to tell a read that a newer one overtook (a weak plan's touch does
126
+ // not count). `shown` is the touch, or the read again's count of touches, that the words on the pane came from: a later
127
+ // read of the file shown that began before them does not take their place; `shownOrder` is the order a read again of
128
+ // theirs began in (0 for a touch's read), so of two begun after the same touches the later one stays. `followed` is the step in
126
129
  // progress the pane last asked to bring into view.
127
130
  export const freshState = () => ({ turns: [], tasks: new Map(), todos: null, loose: looseStep(), open: new Set(), selected: null, seq: 0, rev: 0,
128
131
  order: 0, marks: new Map(), listAt: 0, plan: null, planPath: null, planShut: false, planOpen: new Set(), touch: 0, taken: 0,
129
- followed: null });
132
+ shown: 0, shownOrder: 0, followed: null, reads: [], base: null, folded: 0 });
130
133
 
131
134
  const tail = (path) => String(path).split(/[\\/]/).filter(Boolean).slice(-2).join('/');
132
135
  const seconds = (ms) => {
@@ -262,33 +265,51 @@ function stamp(t, status, now) {
262
265
 
263
266
  // A list longer than the pane holds loses its oldest finished item first, else its oldest.
264
267
  const overflow = (items) => items.find((t) => t.status === 'completed') ?? items[0];
268
+ // A list cut to `max` items as overflow would cut it one by one, in one pass: the oldest finished ones go first, then
269
+ // the oldest.
270
+ function trimList(items, max) {
271
+ let extra = items.length - max;
272
+ if (extra <= 0) return items;
273
+ const left = items.filter((t) => t.status !== 'completed' || extra-- <= 0);
274
+ return left.slice(left.length - max);
275
+ }
265
276
 
266
277
  // One item of a TodoWrite list: while its text stays it is the same step (the first unused one of
267
278
  // that text), with its key, calls and times.
268
279
  function todoItem(s, before, d, now) {
269
- const t = before.get(d.content)?.shift() ?? { key: `d-${++s.seq}`, description: d.content, ...tally() };
280
+ const words = kept(d.content);
281
+ const t = before.get(words)?.shift() ?? { key: `d-${++s.seq}`, description: words, ...tally() };
270
282
  t.title = clip(d.content, 60);
271
- t.activeForm = d.activeForm;
283
+ t.activeForm = kept(d.activeForm);
272
284
  stamp(t, d.status, now);
273
285
  return t;
274
286
  }
275
287
 
288
+ // A task's or an item's words as the state keeps them: a string cut to what any description reads of it (clip reads
289
+ // 4n + 1024 characters), anything else as it came.
290
+ const MAX_WORDS = 4096;
291
+ const kept = (v) => (typeof v === 'string' ? v.slice(0, MAX_WORDS) : v);
292
+
276
293
  // TodoWrite sends the whole list each time. A subagent keeps a list of its own, not the session's.
277
294
  function writeTodos(s, e, now) {
278
295
  if (e.agentId || !Array.isArray(e.todos)) return;
279
296
  const before = new Map();
280
297
  for (const t of s.todos ?? []) before.set(t.description, [...(before.get(t.description) ?? []), t]);
281
- s.todos = e.todos.map((d) => todoItem(s, before, d, now));
282
- while (s.todos.length > MAX_STEPS) s.todos.splice(s.todos.indexOf(overflow(s.todos)), 1);
298
+ s.todos = trimList(e.todos.map((d) => todoItem(s, before, d, now)), MAX_STEPS);
283
299
  }
284
300
 
301
+ // A task's key on the pane: `k-` and its id where the id is a plain one (the store hands out numbers), else its first
302
+ // 1,024 characters as code points, so ids that differ there never share a key (longer ones alike that far do) and none
303
+ // holds what the engine drops a Box key for (a control character, more than 10,000 characters).
304
+ const taskKey = (id) => (/^[\w.-]{1,64}$/.test(id) ? `k-${id}` : `k~${[...id.slice(0, 1024)].map((c) => c.codePointAt(0).toString(36)).join('.')}`);
305
+
285
306
  // Adds a task or brings it up to date; a task keeps its key, calls and times. One known only by
286
307
  // its id is named by it until a TaskGet or a TaskList says more.
287
308
  function putTask(s, t, now) {
288
- const held = s.tasks.get(t.id) ?? { key: `k-${t.id}`, id: t.id, title: `작업 #${t.id}`, description: '', ...tally() };
309
+ const held = s.tasks.get(t.id) ?? { key: taskKey(t.id), id: t.id, title: clip(`작업 #${t.id}`, 60), description: '', ...tally() };
289
310
  const title = clip(t.subject, 60);
290
311
  if (title) held.title = title;
291
- for (const k of ['description', 'activeForm']) if (t[k] !== undefined) held[k] = t[k];
312
+ for (const k of ['description', 'activeForm']) if (t[k] !== undefined) held[k] = kept(t[k]);
292
313
  if (t.status !== undefined) stamp(held, t.status, now);
293
314
  s.tasks.set(t.id, held);
294
315
  while (s.tasks.size > MAX_STEPS) s.tasks.delete(overflow([...s.tasks.values()]).id);
@@ -297,15 +318,29 @@ function putTask(s, t, now) {
297
318
  // The marks of a task id, made on first use. They live in the state, not on the task, so a task deleted, or not
298
319
  // created yet, keeps them: `changedRev` the change count after its last TaskCreate or TaskUpdate (a delete
299
320
  // included), `statusAt` and `status` the order and the status of the last status update begun, `seenAt` the order
300
- // of the last read taken in.
321
+ // of the last read taken in. Past MAX_MARKS the oldest marks of tasks off the pane go (trimMarks), so reads of ids the
322
+ // store does not have cannot grow them without end.
323
+ const MAX_MARKS = 1000;
324
+ // A task id longer than this is no id the store hands out: a read that misses it leaves no mark.
325
+ const MAX_ID = 64;
301
326
  function markOf(s, id) {
302
327
  if (!s.marks.has(id)) s.marks.set(id, {});
303
328
  return s.marks.get(id);
304
329
  }
305
330
 
306
- // Whether a list or a read begun at (since, order) is older than what the pane holds of a task: a list begun after it
307
- // was taken in (a list names the whole list, so this holds for a task it left out that the pane never held, too), the
308
- // task changed after it began (a delete too), or a later-begun read already brought it.
331
+ // Drops the oldest marks of tasks off the pane down to MAX_MARKS. It runs once a call is taken in, when every task the
332
+ // pane holds is in place again: a TaskList fills a new list, and a task not moved yet must not count as off the pane.
333
+ function trimMarks(s) {
334
+ for (const k of s.marks.keys()) {
335
+ if (s.marks.size <= MAX_MARKS) return;
336
+ if (!s.tasks.has(k)) s.marks.delete(k);
337
+ }
338
+ }
339
+
340
+ // Whether a list or a read begun at (since, order) is older than what the pane holds of a task: a list begun after this
341
+ // one began was already taken in (a list names the whole list, so this holds for a task it left out that the pane never
342
+ // held, too), the task changed after this one began (a delete too), or a later-begun read already brought it. The first
343
+ // takes the order calls began in for the order they read the list in, which holds where a call reads soon after it begins.
309
344
  function stale(s, id, since, order) {
310
345
  if (order < s.listAt) return true;
311
346
  const m = s.marks.get(id);
@@ -389,10 +424,7 @@ function listTasks(s, _e, r, now, { since, order }) {
389
424
  function getTask(s, e, r, now, { since, order }) {
390
425
  if (stale(s, String(e.taskId), since, order)) return;
391
426
  const t = r?.result?.task;
392
- if (t === null) {
393
- s.tasks.delete(String(e.taskId));
394
- markOf(s, String(e.taskId)).seenAt = order;
395
- }
427
+ if (t === null) noTask(s, String(e.taskId), order);
396
428
  if (!t) return;
397
429
  const id = String(t.id);
398
430
  const status = readStatus(s, id, order, t.status);
@@ -401,6 +433,13 @@ function getTask(s, e, r, now, { since, order }) {
401
433
  markOf(s, id).seenAt = order;
402
434
  }
403
435
 
436
+ // The store has no such task: it leaves the pane and the read is marked, unless the id is longer than any the store
437
+ // hands out.
438
+ function noTask(s, id, order) {
439
+ s.tasks.delete(id);
440
+ if (id.length <= MAX_ID) markOf(s, id).seenAt = order;
441
+ }
442
+
404
443
  const TASK_NOTES = {
405
444
  TaskCreate: createTask,
406
445
  TaskUpdate: updateTask,
@@ -422,10 +461,14 @@ const changedId = (e, r) => (e.tool === 'TaskCreate' ? r?.result?.task?.id : e.t
422
461
  export function noteTaskTool(s, e, r, now, since = s.rev, order = ++s.order) {
423
462
  if (refused(r)) return;
424
463
  TASK_NOTES[e.tool]?.(s, e, r, now, { since, order });
425
- if (!CHANGES.has(e.tool)) return;
464
+ if (CHANGES.has(e.tool)) noteChange(s, e, r);
465
+ trimMarks(s);
466
+ }
467
+
468
+ // Only a change the pane took in marks the task: one it holds after the change, or a delete. An update of a task the
469
+ // pane does not hold leaves the lists alone (its status still counts, through readStatus).
470
+ function noteChange(s, e, r) {
426
471
  s.rev += 1;
427
- // Only a change the pane took in marks the task: one it holds after the change, or a delete. An update of a task the
428
- // pane does not hold leaves the lists alone (its status still counts, through readStatus).
429
472
  const id = changedId(e, r);
430
473
  if (id !== undefined && (s.tasks.has(String(id)) || updatedStatus(e, r) === 'deleted')) markOf(s, String(id)).changedRev = s.rev;
431
474
  }
@@ -477,11 +520,25 @@ function parsed(path, text) {
477
520
  }
478
521
 
479
522
  // Whether a read may change the plan: a read for a touch newer than the one the plan came from, or a
480
- // read again of the plan's own file (begun when `since` touches were counted) that no touch taken
481
- // in the meantime overtook.
482
- function current(s, path, { touch, since }) {
523
+ // read again of the plan's own file (begun when `since` touches were counted) begun after the words on the pane
524
+ // came in (`shown`), so neither an older touch nor an older read again takes their place.
525
+ function current(s, path, { touch, since, order = 0 }) {
483
526
  if (touch !== undefined) return touch > s.taken;
484
- return path === s.planPath && s.taken <= since;
527
+ return path === s.planPath && (s.shown < since || (s.shown === since && order >= (s.shownOrder ?? 0)));
528
+ }
529
+
530
+ // What a read taken in leaves, and whether its words go on the pane. `taken` makes older touches late (a weak plan's
531
+ // touch does not), `shown` is the touch or the read again's since the words came from: a touch's read of the file
532
+ // shown, begun before the read whose words are shown, leaves them.
533
+ function noteTouch(s, read, moving) {
534
+ if (read.touch === undefined) {
535
+ [s.shown, s.shownOrder] = [read.since, read.order ?? 0];
536
+ return true;
537
+ }
538
+ if (!read.weak) s.taken = read.touch;
539
+ if (!moving && read.touch <= s.shown) return false;
540
+ [s.shown, s.shownOrder] = [read.touch, 0];
541
+ return true;
485
542
  }
486
543
 
487
544
  // A file with no steps does not push out a plan that has some, unless the session wrote it itself; a weak plan holds no
@@ -505,20 +562,60 @@ function treeIds(nodes, out = new Set()) {
505
562
  return out;
506
563
  }
507
564
 
565
+ // The plan as reads leave it: the plan, its path, `taken` and `shown` (see freshState), with nothing of the pane.
566
+ const planOf = (s) => ({ plan: s.plan, planPath: s.planPath, taken: s.taken, shown: s.shown, shownOrder: s.shownOrder });
567
+
568
+ // What one read leaves of a plan state, as takePlan always did when reads landed in the order their touches came.
569
+ function applyRead(st, e) {
570
+ if (!current(st, e.path, e.read) || keepsOff(st, e.path, e.plan, e.read)) return st;
571
+ const next = { ...st };
572
+ if (!noteTouch(next, e.read, e.path !== st.planPath)) return next;
573
+ return { ...next, planPath: e.path, plan: { ...e.plan, path: e.path } };
574
+ }
575
+
576
+ // Where a read goes among the touches: a touch's read at its touch, a read again just after the touches counted when
577
+ // it began.
578
+ const placeOf = (read) => (read.touch !== undefined ? read.touch : read.since + 0.5);
579
+ // Reads kept past this wait for a touch whose read never came: that touch then counts as a read of nothing. Each
580
+ // kept read holds a parsed plan, so the bound keeps the worst at about 8 MB.
581
+ const MAX_READS = 16;
582
+
583
+ // Folds into the base the reads whose earlier touches have all landed, so the base is the plan of those touches in their
584
+ // order; a read again goes in once the touches it began after are in.
585
+ function foldReads(s) {
586
+ if (s.reads.length > MAX_READS) s.folded = Math.max(s.folded, Math.ceil(placeOf(s.reads[0].read)) - 1);
587
+ while (s.reads.length) {
588
+ const { read } = s.reads[0];
589
+ const ready = read.touch !== undefined ? read.touch === s.folded + 1 : read.since <= s.folded;
590
+ if (!ready) return;
591
+ s.base = applyRead(s.base, s.reads.shift());
592
+ if (read.touch !== undefined) s.folded = read.touch;
593
+ }
594
+ }
595
+
508
596
  // What a read of a plan file found (`read` is `{ touch }` for a touch's read, `{ since }` for a read
509
- // again). A file that cannot be read or is no plan leaves the plan as it was. Another plan starts
510
- // folded; the same plan read again keeps the nodes unfolded that it still holds.
597
+ // again). Reads land in any order: each is kept in its touch's place, and the pane shows the plan the touches
598
+ // would leave in their order, as far as their reads have landed. A file that cannot be read or is no plan leaves
599
+ // the plan as it was. Another plan starts folded; the same plan read again keeps the nodes unfolded that it still holds.
511
600
  export function takePlan(s, path, text, read = {}) {
512
- if (!current(s, path, read)) return;
513
- const plan = parsed(path, text);
514
- if (keepsOff(s, path, plan, read)) return;
515
- const moving = path !== s.planPath;
516
- if (read.touch !== undefined && !read.weak) s.taken = read.touch; // a weak plan does not make an older touch late
601
+ s.base ??= planOf(s);
602
+ if (read.touch !== undefined && read.touch <= s.folded) return; // its place went by as a read of nothing (MAX_READS)
603
+ const entry = { path, plan: parsed(path, text), read };
604
+ const at = s.reads.findIndex((r) => placeOf(r.read) > placeOf(read));
605
+ s.reads.splice(at < 0 ? s.reads.length : at, 0, entry);
606
+ foldReads(s);
607
+ showPlan(s, s.reads.reduce(applyRead, s.base));
608
+ }
609
+
610
+ // Puts a plan state on the pane: another plan starts folded, and the same plan keeps the nodes unfolded that it holds.
611
+ function showPlan(s, st) {
612
+ const moving = st.planPath !== s.planPath;
517
613
  if (moving && s.selected === 'plan') s.selected = null;
518
- const ids = treeIds(plan.tree);
519
- s.planOpen = moving ? new Set() : new Set([...s.planOpen].filter((id) => ids.has(id)));
520
- s.planPath = path;
521
- s.plan = { ...plan, path };
614
+ if (st.plan && st.plan !== s.plan) {
615
+ const ids = treeIds(st.plan.tree);
616
+ s.planOpen = moving ? new Set() : new Set([...s.planOpen].filter((id) => ids.has(id)));
617
+ }
618
+ Object.assign(s, st);
522
619
  }
523
620
 
524
621
  // ── what the pane shows ──────────────────────────────────────────────────────
@@ -551,9 +648,12 @@ function stepState(step, now) {
551
648
  const status = statusOf(step);
552
649
  const doing = status === 'in_progress' && step.activeForm ? `(${clip(step.activeForm, 40)})` : '';
553
650
  const time = Number.isFinite(step.startedAt) ? `, ${seconds((step.endedAt ?? now) - step.startedAt)}` : '';
554
- return `${STATUS[status] ?? status}${doing}${time}`;
651
+ return `${stateName(status)}${doing}${time}`;
555
652
  }
556
653
 
654
+ // A state's word: the pane's own, else the state cleaned (a name such as `constructor` is no word of the table).
655
+ const stateName = (status) => pick(STATUS, status) ?? clip(status, 20);
656
+
557
657
  function stepWords(step, now) {
558
658
  if (step.key === 'idle') return '아직 시작한 작업이 없습니다. 요청을 보내면 여기에 단계가 쌓입니다.';
559
659
  const words = step.text !== undefined ? `요청: ${clip(step.text, 160) || '(이어서 진행)'}` : clip(step.description || step.title, 160);
@@ -627,7 +727,7 @@ function stepRow(ui, s, step, act) {
627
727
  ? Button({ key: `arrow-${step.key}`, label: isOpen ? '▾' : '▸', plain: true, onPress: () => act.fold(step.key) })
628
728
  : Text({ children: [' '] });
629
729
  const status = statusOf(step);
630
- const state = STATUS[status] ?? status ?? '';
730
+ const state = stateName(status);
631
731
  return Box({ key: `row-${step.key}`, flexDirection: 'row', columnGap: 1, children: [
632
732
  arrow,
633
733
  Button({ key: step.key, label: room(step.title, act, 2, state), plain: true, onPress: () => act.select(step.key) }),
@@ -901,10 +1001,15 @@ export function progressTurnCompleted(e) {
901
1001
 
902
1002
  // The plan file to read again as a turn starts and ends (register.mjs reads it), with the touches
903
1003
  // counted when the read begins; null without a plan.
1004
+ // A read again begins with the touches counted and its place in the order reads begin, so of two begun after the same
1005
+ // touches the later one wins, whichever lands last (the file may change between them without a touch: a shell edit).
1006
+ export const planTarget = (s) => (s.planPath ? { path: s.planPath, since: s.touch, order: ++s.order } : null);
1007
+ export const planRead = (s, target, text) => takePlan(s, target.path, text, { since: target.since, order: target.order });
1008
+
904
1009
  export function progressPlanTarget() {
905
- return ref.s.planPath ? { path: ref.s.planPath, since: ref.s.touch } : null;
1010
+ return planTarget(ref.s);
906
1011
  }
907
1012
 
908
1013
  export function progressPlanRead(target, text) {
909
- takePlan(ref.s, target.path, text, { since: target.since });
1014
+ planRead(ref.s, target, text);
910
1015
  }
@@ -83,6 +83,16 @@ async function voidLateRequest($, late) {
83
83
  $.ui.log(late.log);
84
84
  }
85
85
 
86
+ // Asks the engine to draw the pane again. The engine sends the call on and catches its own failure (2.1.305); an engine
87
+ // that throws instead still runs the hooks beneath, so the session and the turn go on.
88
+ function redraw($) {
89
+ try {
90
+ $.ui.invalidate('ui.render');
91
+ } catch {
92
+ /* nothing to redraw now; the next event draws the pane */
93
+ }
94
+ }
95
+
86
96
  export const register = (on) => {
87
97
  registerGracefulPause(on);
88
98
  registerProgress(on);
@@ -97,7 +107,7 @@ export const register = (on) => {
97
107
  on('session.end', async ($, e, next) => {
98
108
  pauseReset();
99
109
  progressReset();
100
- $.ui.invalidate('ui.render'); // the pane shows at once what /clear emptied
110
+ redraw($); // the pane shows at once what /clear emptied
101
111
  return next(e);
102
112
  });
103
113
 
@@ -105,7 +115,7 @@ export const register = (on) => {
105
115
  pauseTurnStarted(e);
106
116
  progressTurnStarted(e);
107
117
  await rereadPlan($);
108
- $.ui.invalidate('ui.render');
118
+ redraw($);
109
119
  return next(e);
110
120
  });
111
121
 
@@ -114,7 +124,7 @@ export const register = (on) => {
114
124
  if (late) await voidLateRequest($, late);
115
125
  progressTurnCompleted(e);
116
126
  await rereadPlan($);
117
- $.ui.invalidate('ui.render');
127
+ redraw($);
118
128
  return next(e);
119
129
  });
120
130
  };
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * banker 텔레메트리 count 훅 (Codex Stop) - Codex 세션이 banker 스킬을 쓴 턴이 끝날 때 로컬 usage-log 에
4
+ * `banker:<name>\t<hour>` 한 줄을 남긴다. Claude Code 의 telemetry-count(.mjs, -skill.mjs)에 해당한다.
5
+ *
6
+ * Codex 0.144.5 실측으로 스킬이 쓰이는 길은 둘이다.
7
+ * - 모델이 고른 스킬: 도구 호출로 SKILL.md 를 읽는다(Bash `cat <CODEX_HOME>/skills/banker-<name>/SKILL.md`).
8
+ * - 사용자가 $banker-<name> 으로 부른 스킬: Codex 가 도구 호출 없이 `<skill>\n<name>..</name>\n<path>..</path>` 로
9
+ * 시작하는 사용자 역할 메시지로 본문을 넣는다. 이때는 머리의 <path> 줄만 보고 본문은 보지 않는다.
10
+ * 도구 호출마다 도는 PreToolUse 대신 턴마다 한 번 도는 Stop 에서, 훅 입력의 transcript_path(rollout) 중
11
+ * 이번 턴(task_started 의 turn_id 뒤)의 두 기록에 `skills/banker-<name>/SKILL.md` 가 있으면 센다. 나눠 읽어도 한 번이고, Stop 이 한 턴에 두 번 와도
12
+ * 최근 턴 키(configDir 의 codex-count-turns)로 한 번만 센다. 기록은 끝에서부터 32 MB 까지만 읽는다.
13
+ *
14
+ * fail-closed 규약은 Claude 쪽과 같다(telemetry-count-codex.test.mjs 가 검증):
15
+ * - countingActive() 가 아니면 로컬 쓰기조차 하지 않는다.
16
+ * - 이름은 SKILL_NAME_RE 전체 일치이고 이 Codex 에 설치된 banker 스킬(<CODEX_HOME>/skills/banker-<name>/SKILL.md)만.
17
+ * 모델이 `echo skills/banker-<아무 글>/SKILL.md` 를 돌려 체크인의 이름 칸으로 글을 내보내지 못하게 한다. 턴 키 파일은 일반 파일만 읽고, 링크 자리에 쓰지 않는다.
18
+ * - 어떤 오류도 삼키고 항상 exit 0. 도구 호출을 절대 막지 않는다. 표준 출력은 비운다.
19
+ */
20
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { basename, dirname, isAbsolute, join } from 'node:path';
23
+
24
+ import { appendUsageLog, CODEX_SHAPE_MISS_PATH, configDir, countingActive, readRegular, recordUsedSkill, SKILL_NAME_RE, writeByRename } from '../bin/lib/telemetry-config.mjs';
25
+
26
+ const SKILL_PATH_RE = /skills[\\/]+banker-([a-z0-9_-]{1,64})[\\/]+SKILL\.md/gi;
27
+ const KEEP_TURNS = 100;
28
+ export const TURNS_FILE = () => join(configDir(), 'codex-count-turns');
29
+
30
+ function readStdin() {
31
+ return new Promise((resolvePromise, rejectPromise) => {
32
+ let data = '';
33
+ process.stdin.setEncoding('utf8');
34
+ process.stdin.on('data', (chunk) => { data += chunk; });
35
+ process.stdin.on('end', () => resolvePromise(data));
36
+ process.stdin.on('error', rejectPromise);
37
+ });
38
+ }
39
+
40
+ const MAX_READ = 32 << 20;
41
+ const FIRST_READ = 1 << 20;
42
+
43
+ // 글에 나오는 banker 스킬 이름(banker:<name>), 중복 없이.
44
+ export function namesIn(text, names = new Set()) {
45
+ for (const m of text.matchAll(SKILL_PATH_RE)) {
46
+ const name = `banker:${m[1]}`;
47
+ if (SKILL_NAME_RE.test(name)) names.add(name);
48
+ }
49
+ return names;
50
+ }
51
+
52
+ // rollout 끝에서 max 바이트의 줄과, 파일 전체를 읽었는지. 일반 파일만: 기다리지 않게 O_NONBLOCK 으로 열고 연 것을 fstat 으로 본다. 앞에서 잘린 첫 줄은 버린다.
53
+ function tailLines(file, max) {
54
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
55
+ try {
56
+ if (!fstatSync(fd).isFile()) return { lines: [], whole: true };
57
+ const size = fstatSync(fd).size;
58
+ const start = Math.max(0, size - max);
59
+ const buf = Buffer.alloc(size - start);
60
+ readSync(fd, buf, 0, buf.length, start);
61
+ const lines = buf.toString('utf8').split('\n');
62
+ if (start > 0) lines.shift();
63
+ return { lines, whole: start === 0 };
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
69
+ const isCall = (rec) => rec?.type === 'response_item' && /_call$/.test(String(rec?.payload?.type ?? ''));
70
+ const SKILL_HEAD_RE = /^<skill>\n<name>[^\n<]{1,80}<\/name>\n<path>([^\n<]{1,4096})<\/path>\n/;
71
+
72
+ // $banker-<name> 으로 부른 스킬을 Codex 가 넣은 메시지의 <path>. 그 밖의 기록은 ''.
73
+ function injectedPath(rec) {
74
+ const p = rec?.payload;
75
+ if (rec?.type !== 'response_item' || p?.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return '';
76
+ const text = p.content[0]?.text;
77
+ return typeof text === 'string' ? (SKILL_HEAD_RE.exec(text)?.[1] ?? '') : '';
78
+ }
79
+
80
+ // 이번 턴(turn_id 의 task_started 뒤)의 도구 호출과 넣은 스킬 머리에서 읽은 banker 스킬 이름. 사용자 글과 모델 답은 세지 않는다.
81
+ // 턴 시작은 대개 기록 끝 가까이에 있으므로 끝 1 MB 부터 읽고, 못 찾으면 두 배씩 max 까지 넓힌다.
82
+ // 기록 전체를 읽고도 task_started 가 하나도 없으면 null: Codex 는 턴마다 task_started 를 쓰므로 기록 형식이 바뀐 것이다.
83
+ // 이 턴의 것만 없을 때(시작이 기록되지 않는 턴)는 형식 변경으로 보지 않는다.
84
+ export function turnSkills(file, turnId, max = MAX_READ) {
85
+ for (let size = Math.min(FIRST_READ, max); ; size = Math.min(size * 2, max)) {
86
+ const { lines, whole } = tailLines(file, size);
87
+ const start = turnStart(lines, turnId);
88
+ if (start >= 0) return skillsAfter(lines, start);
89
+ if (whole) return lines.some((l) => l.trim()) && !lines.some((l) => l.includes('"task_started"')) ? null : [];
90
+ if (size >= max) return [];
91
+ }
92
+ }
93
+
94
+ // rollout 첫 기록(session_meta)의 Codex 판. Codex 기록이 아니면 ''.
95
+ export function codexVersionOf(file) {
96
+ try {
97
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
98
+ try {
99
+ if (!fstatSync(fd).isFile()) return '';
100
+ const buf = Buffer.alloc(Math.min(fstatSync(fd).size, 8 << 20)); // session_meta 는 지침을 품어 클 수 있다
101
+ readSync(fd, buf, 0, buf.length, 0);
102
+ const rec = JSON.parse(buf.toString('utf8').split('\n')[0]);
103
+ return rec?.type === 'session_meta' ? String(rec.payload?.cli_version ?? '?').slice(0, 40) : '';
104
+ } finally {
105
+ closeSync(fd);
106
+ }
107
+ } catch {
108
+ return '';
109
+ }
110
+ }
111
+
112
+ // 이 Codex 판의 기록을 읽지 못했음을 남긴다(같은 판은 한 번만). 다음 세션 시작 때 update-notify --codex 가 알린다.
113
+ function noteShapeMiss(file) {
114
+ const version = codexVersionOf(file);
115
+ if (!version) return;
116
+ let prev = {};
117
+ try { prev = JSON.parse(readRegular(CODEX_SHAPE_MISS_PATH)); } catch { /* 없음 */ }
118
+ if (prev?.version !== version) writeByRename(CODEX_SHAPE_MISS_PATH, JSON.stringify({ version, notified: false }));
119
+ }
120
+
121
+ function turnStart(lines, turnId) {
122
+ let start = lines.length - 1;
123
+ while (start >= 0 && !(lines[start].includes('"task_started"') && lines[start].includes(turnId))) start--;
124
+ return start;
125
+ }
126
+
127
+ function skillsAfter(lines, start) {
128
+ const names = new Set();
129
+ for (const line of lines.slice(start + 1)) {
130
+ if (!line.includes('SKILL.md')) continue;
131
+ let rec;
132
+ try { rec = JSON.parse(line); } catch { continue; }
133
+ if (isCall(rec)) namesIn(JSON.stringify(rec.payload), names);
134
+ else namesIn(injectedPath(rec), names);
135
+ }
136
+ return [...names];
137
+ }
138
+
139
+ const ID_RE = /^[A-Za-z0-9._-]{1,80}$/;
140
+
141
+ // 이 턴의 키. session_id 와 turn_id 가 모양이 맞을 때만(파일 한 줄 형식을 깨지 않게).
142
+ export function turnKey(payload) {
143
+ const s = payload?.session_id;
144
+ const t = payload?.turn_id;
145
+ return typeof s === 'string' && typeof t === 'string' && ID_RE.test(s) && ID_RE.test(t) ? `${s}:${t}` : null;
146
+ }
147
+
148
+ function readTurns() {
149
+ try {
150
+ return readRegular(TURNS_FILE()).split('\n').filter(Boolean);
151
+ } catch {
152
+ return [];
153
+ }
154
+ }
155
+
156
+ // 링크나 일반 파일이 아닌 자리에는 쓰지 않는다. 새 파일을 만들어 바꿔 넣는다.
157
+ function writeTurns(lines) {
158
+ const file = TURNS_FILE();
159
+ try {
160
+ if (!lstatSync(file).isFile()) return;
161
+ } catch (e) {
162
+ if (e?.code !== 'ENOENT') return;
163
+ }
164
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
165
+ try {
166
+ writeFileSync(tmp, `${lines.slice(-KEEP_TURNS).join('\n')}\n`, { flag: 'wx', mode: 0o600 });
167
+ renameSync(tmp, file);
168
+ } catch {
169
+ rmSync(tmp, { force: true });
170
+ }
171
+ }
172
+
173
+ // 이 세션의 Codex 홈: rollout 이 <홈>/sessions/YYYY/MM/DD/ 에 있으면 그 홈(상대 경로 CODEX_HOME 도 Codex 가 푼 그대로),
174
+ // 아니면 CODEX_HOME, 그것도 없으면 ~/.codex.
175
+ export function codexHomeOf(transcript, env = process.env) {
176
+ const sessions = dirname(dirname(dirname(dirname(String(transcript)))));
177
+ if (isAbsolute(String(transcript)) && basename(sessions) === 'sessions') return dirname(sessions);
178
+ return env.CODEX_HOME || join(homedir(), '.codex');
179
+ }
180
+
181
+ // 이 Codex 에 설치된 banker 스킬인지.
182
+ export const installed = (name, home) => existsSync(join(home, 'skills', `banker-${name.slice('banker:'.length)}`, 'SKILL.md'));
183
+
184
+ // 이 턴에 아직 세지 않은 이름만 센다.
185
+ export function countOnce(payload) {
186
+ const key = turnKey(payload);
187
+ const file = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
188
+ const home = codexHomeOf(file);
189
+ const got = key && file ? turnSkills(file, payload.turn_id) : [];
190
+ if (got === null) {
191
+ noteShapeMiss(file);
192
+ return [];
193
+ }
194
+ const names = got.filter((n) => installed(n, home));
195
+ if (!names.length) return [];
196
+ const seen = readTurns();
197
+ const fresh = names.filter((n) => !seen.includes(`${key}\t${n}`));
198
+ if (!fresh.length) return [];
199
+ for (const n of fresh) {
200
+ appendUsageLog(n);
201
+ recordUsedSkill(n);
202
+ }
203
+ writeTurns([...seen, ...fresh.map((n) => `${key}\t${n}`)]);
204
+ return fresh;
205
+ }
206
+
207
+ async function main() {
208
+ let payload;
209
+ try {
210
+ payload = JSON.parse(await readStdin());
211
+ } catch {
212
+ return;
213
+ }
214
+ if (!countingActive()) return;
215
+ countOnce(payload);
216
+ }
217
+
218
+ if (process.argv[1] && /telemetry-count-codex\.mjs$/.test(process.argv[1])) {
219
+ main()
220
+ .catch(() => {})
221
+ .finally(() => process.exit(0));
222
+ }
@@ -9,7 +9,10 @@
9
9
  * - 미활성(countingActive) 또는 엔드포인트 없음이면 무동작(로그도 건드리지 않는다).
10
10
  * - PII/IP/지속ID 절대 미포함. 페이로드는 {version, os, counts} 세 키뿐(os=플랫폼 계열,
11
11
  * counts=스킬별 x 시간대별 호출수).
12
- * - 자체 네트워크 timeout 으로 hang 방지. 어떤 오류도 삼키고 항상 exit 0. 어떤 경로도 reject 안 함.
12
+ * - 무활동 10초 timeout 과 요청마다 30초 교환 전체 마감으로 hang 하지 않는다.
13
+ * - 응답 본문은 256KiB 까지만 모은다. 넘으면 연결을 끊고 그 응답을 쓰지 않는다.
14
+ * - 응답에서 latest 를 얻지 못하면(전송 실패, 차단, 없는 값) 시도 시각(triedAt)만 캐시에 써 하루를 쉰다.
15
+ * - 어떤 오류도 삼키고 항상 exit 0. 어떤 경로도 reject 안 함.
13
16
  * - 비-loopback 평문 http 는 전송하지 않는다(https-only). loopback http 만 로컬 테스트 mock 에 허용.
14
17
  * - 함께 시작한 체크인은 같은 줄을 두 번 보내지 않는다(로그 이름 바꾸기는 하나만 성공한다).
15
18
  * - 로그 자리가 FIFO 면 기다리지 않고, 로그나 last-flush 자리가 링크면 링크 너머 파일을 비우거나 덮지 않는다.
@@ -73,7 +76,8 @@ function isLoopback(hostname) {
73
76
  }
74
77
 
75
78
  // 엔드포인트로 JSON POST. 어떤 경로에서도 reject 하지 않고, 전송 시도가 끝나면 응답 본문(문자열)
76
- // 또는 null 로 resolve 한다. 자체 timeout(NET_TIMEOUT_MS)으로 무응답 엔드포인트에도 hang 하지 않는다.
79
+ // 또는 null 로 resolve 한다. 무활동 timeout(NET_TIMEOUT_MS)과 교환 전체 마감(NET_DEADLINE_MS)으로 무응답
80
+ // 엔드포인트에도 hang 하지 않고, 본문은 MAX_BODY_BYTES 까지만 모은다.
77
81
  function post(url, body) {
78
82
  return new Promise((resolvePromise) => {
79
83
  let done = false;
@@ -124,8 +128,9 @@ function extractLatest(responseBody) {
124
128
 
125
129
  // 변경-스킬 매니페스트(공개 GitHub raw JSON: {"x.y.z": ["banker:skill", ...]})를 GET 해 객체로 반환한다.
126
130
  // update-fetch 의 동일 함수와 self-contained 로 나란히 둔다(훅별 자기완결 관례·isLoopback 도 이미 각자 보유).
127
- // post() 와 동일한 안전 가드: 비-https 는 loopback 만 허용·3xx no-op·자체 timeout·malformed→null. 어떤
128
- // 실패에도 reject 하지 않고 null 로 resolve 한다(개인화는 best-effort). 비배열 객체만 반환.
131
+ // post() 와 동일한 안전 가드: 비-https 는 loopback 만 허용·3xx no-op·무활동 timeout 과 교환 전체 마감·
132
+ // 본문 256KiB 상한·malformed→null. 어떤 실패에도 reject 하지 않고 null 로 resolve 한다(개인화는 best-effort).
133
+ // 비배열 객체만 반환.
129
134
  function fetchSkillChangesMap(url) {
130
135
  return new Promise((resolvePromise) => {
131
136
  let done = false;
@@ -228,17 +233,20 @@ function dropOldClaims(now = Date.now()) {
228
233
  }
229
234
  }
230
235
 
231
- // 응답의 latest 로 공유 update-check 캐시를 갱신(알림 겸함). 없으면 캐시 미변경.
236
+ // 응답의 latest 로 공유 update-check 캐시를 갱신(알림 겸함). 없으면 시도 시각(triedAt)만 쓴다.
232
237
  // update-fetch 처럼 notified:null 재무장을 하지 않아도 등가다: dedupe 판정이 notified===latest
233
238
  // (update-notify)라 전진 버전에서 구 notified 는 항상 신 latest 와 달라 재고지가 정상 발화하고,
234
239
  // count-default-on 은 이 훅만·npm 배포는 update-fetch 만 써 같은 캐시를 겹쳐 쓰지 않는다.
235
240
  async function refreshUpdateCache(responseBody) {
236
241
  const latest = extractLatest(responseBody);
237
- if (!latest) return;
242
+ if (!latest) { // 시도 시각만 남겨 update-notify 가 하루 동안 다시 띄우지 않게 한다(네트워크 없는 곳의 세션마다 시도).
243
+ try { writeUpdateCache({ triedAt: Date.now() }); } catch { /* best-effort */ }
244
+ return;
245
+ }
238
246
  // 개인화용: 변경-스킬 매니페스트를 best-effort GET(실패 시 changedSkills 생략, 기존 값 보존).
239
247
  const changesUrl = process.env.BANKER_SKILL_CHANGES_URL || SKILL_CHANGES_URL; // 테스트 주입용 override.
240
248
  const map = await fetchSkillChangesMap(changesUrl);
241
- const patch = { latest, checkedAt: Date.now() };
249
+ const patch = { latest, checkedAt: Date.now(), triedAt: null }; // 성공: 남은 실패 시도 시각은 비움
242
250
  if (map) patch.changedSkills = changedSkillsBetween(map, installedVersion(), latest);
243
251
  try { writeUpdateCache(patch); } catch { /* best-effort */ }
244
252
  }