@kaydash9999/banker-plugins 0.20.2 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +179 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +25 -2
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +4 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +9 -6
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -27,7 +27,7 @@ The generator (`banker setup --codex`) applies the rules below for `target: both
27
27
  - `visual-ralph` → OMX `$ralph`·`$imagegen`(+`omx imagegen continuation`)·Visual Verdict; Claude은 `ralph`+`visual-verdict`+Stitch/ccg imagegen.
28
28
  - `deep-init` → 서브에이전트 Claude=OMC explore/architect/writer, Codex=OMX worker/explore(부재 시 직접 수행). 순수 fs+doc.
29
29
  - `deep-research` → Claude 번들 워크플로/`WebSearch`, Codex OMX `autoresearch`/`best-practice-research`.
30
- - `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`)도 막는다. 그래서 외부 좌석 전송의 명령 전부(폴더, 페이로드, 사본, 좌석, 정리)를 승인을 받아 샌드박스 밖에서 실행한다.
30
+ - `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
31
  - `lineage` → 기본 흐름의 검토자는 세션 모델이다(Claude=Agent 의 `Plan` 을 `model` 없이, Codex=`spawn_agent` 를 역할 없이, 저자 대화를 넘기지 않고). 띄울 수 없으면 세션이 파트를 직접 검토한다. Codex 검토자에게는 파일 읽기 도구가 없어 파트 파일을 읽는 셸 명령만 쓰게 한다. `workspace-write` 샌드박스에서는 `~/.cache` 에 쓸 수 없어 이번 실행의 검토 결정을 캐시에 쓰지 못한다(WARN). 앞선 실행이 남긴 결정은 읽는다. 읽는 기록은 Claude Code 형식뿐이라 Codex 에서는 Claude Code 기록을 내보낼 때만 쓴다. `--rulebase` 는 모델 호출 없이 양쪽 같다.
32
32
  - `smart-compact` → Claude statusLine `context_window.used_percentage`+hook; Codex 신호 미확인 시 휴리스틱 폴백. TUI 3단(`/copy`·`/compact`·paste)은 유저.
33
33
  - `curation` → 런타임 무관(외부 의존 0, 양쪽 동일).
package/commands/setup.md CHANGED
@@ -10,6 +10,29 @@ banker 플러그인이 제공하는 설치 스킬들을 **multi-select**로 골
10
10
 
11
11
  ## 절차
12
12
 
13
+ 0. **네트워크 확인**: 항목을 고르기 전에 원천에 닿는지 한 번 본다(curl 은 10초, npm 은 15초 안). 결과는 알림으로만 쓰고, 이것만으로 항목을 건너뛰지 않는다.
14
+ ```bash
15
+ [ "${CODEX_SANDBOX_NETWORK_DISABLED:-}" = 1 ] && echo "Codex sandbox 가 네트워크를 막음(아래 실패는 오프라인이 아님)"
16
+ in15() { "$@" & p=$!; ( sleep 15 && kill "$p" ) > /dev/null 2>&1 & k=$!; wait "$p" 2>/dev/null; r=$?; kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; [ "$r" = 143 ] && echo "15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)"; return "$r"; }
17
+ if command -v npm >/dev/null; then in15 sh -c 'cd ~ && exec npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier'; else echo "npm 없음(확인하지 못함)"; fi
18
+ curl --ssl-no-revoke -sSI -o /dev/null --connect-timeout 5 --max-time 10 https://github.com
19
+ ```
20
+ - `curl -sSI` 는 머리만 받는다. 확인 줄은 `--ssl-no-revoke` 를 준다. Windows 의 Git Bash curl 도 Schannel 이라, 폐지 목록 서버에 닿지 못하는 망에서 폐지 확인이 실패하면 종료 60 이다(Windows 박스 실측). 다른 curl 은 이 깃발을 받고 무시한다(macOS curl 8.7.1, Rocky 8.10 curl 7.61.1 실측).
21
+ - 둘 다 실패하면 "네트워크가 없거나 막힌 것으로 보임. 항목마다 자기 원천을 다시 확인하고, 닿지 않는 원천에서 받는 단계는 하지 않음" 으로 먼저 알린다.
22
+ - 항목을 건너뛸지는 각 스킬의 0단계가 그 스킬의 원천으로 정한다. 사내 미러만 열린 망(`npm ping` 에 답하지 않는 npm 미러, PyPI 미러)에서도 그 원천을 쓰는 항목은 진행된다. 닿지 않는 원천에서 받는 단계만 "오프라인이라 설치하지 않음(필요한 것: <항목>)" 으로 보고된다.
23
+ - `npm` 이 아직 없으면(새 PC) npm 쪽은 오프라인이 아니라 확인하지 못한 것이다. `setup-node` 가 npm 을 깐 뒤 각 스킬의 0단계가 다시 확인한다.
24
+ - `npm ping` 은 홈 폴더에서 부른다. 지금 폴더에 있는 프로젝트의 `.npmrc` 가 정한 레지스트리와 토큰을 따르지 않게 하려는 것이다.
25
+ - `npm ping` 은 15초 감시(`in15`) 안에서 부른다. 패킷을 버리는 망에서 npm 은 연결에 시간 제한을 두지 않아(`--fetch-timeout` 은 받는 중의 쉼만 잰다) OS 의 연결 재시도가 끝날 때까지 기다린다(Rocky 8.10 실측 130초). 15초 안에 끝나지 않으면 `15초 안에 끝나지 않아 멈춤(패킷을 버리는 망일 수 있음)` 과 종료 143 이다. `--no-update-notifier` 는 npm 이 제 판을 묻는 요청을 하나 더 내지 않게 한다.
26
+ - OS 패키지 관리자(dnf, apt, brew, winget)만 쓰는 단계는 자기 설정의 미러를 쓰므로 이 확인과 관계없이 각 스킬의 규칙대로 시도한다.
27
+ - 패킷을 버리거나 답하지 않는 망에서 dnf 는 저장소마다 약 2분(SYN 을 버리는 망 실측 121초), apt 는 원천마다 약 4분(답하지 않는 원천 실측 247초) 기다린 뒤 실패로 끝난다. 시간 제한은 관리자 설정을 따르므로 이 스킬이 바꾸지 않는다.
28
+ - `apt-get update` 는 받지 못해도 종료 0 이다. 출력의 `W: Failed to fetch` 가 있으면 그 단계를 실패로 보고한다.
29
+ - 내려받기가 없는 항목(`setup-omc-hud`)과 이미 설치된 것의 확인은 그대로 진행한다.
30
+ - Windows PowerShell 에서는 `curl.exe --ssl-no-revoke -sSI -o NUL --connect-timeout 5 --max-time 10 https://github.com` 의 `$LASTEXITCODE` 로 본다. `curl.exe` 는 Windows 시스템 프록시를 따르지 않고 `HTTPS_PROXY` 만 따른다. `--ssl-no-revoke` 는 폐지 목록 서버에 닿지 못하는 망에서 Schannel 의 폐지 확인 실패를 오프라인으로 읽지 않게 한다. 닿는지만 보고 받은 내용은 쓰지 않으므로 이 확인에만 준다. `curl.exe` 가 없으면 `$LASTEXITCODE` 에 앞 명령의 값이 남으므로 `Get-Command curl.exe -ErrorAction SilentlyContinue` 로 먼저 보고, 없으면 확인하지 못한 것으로 적는다. `npm ping` 은 `Push-Location ~; npm ping --fetch-retries=0 --fetch-timeout=10000 --no-update-notifier; Pop-Location` 처럼 홈에서 부르고 돌아온다(돌아오지 않으면 뒤 단계가 프로젝트가 아니라 홈에서 돈다). PowerShell 에는 15초 감시를 두지 않는다(Windows 의 연결 시간 제한은 약 21초로 알려져 있다. 미실측).
31
+ - Codex sandbox 가 네트워크를 막는 모드(기본 `workspace-write`, `read-only`)에서는 `CODEX_SANDBOX_NETWORK_DISABLED=1` 이 서고 확인이 모두 실패한다(curl 종료 7, npm `EPERM`). 블록의 첫 줄이 그렇다고 알린다. 그때는 '오프라인' 이 아니라 'sandbox 가 네트워크를 막음' 으로 보고하고, 승인을 받아 sandbox 밖에서 확인부터 다시 한다. Claude Code sandbox 안(`SANDBOX_RUNTIME=1`)에서는 허용 목록 밖 주소를 sandbox 프록시가 막는다. Windows PowerShell 에서는 `$env:CODEX_SANDBOX_NETWORK_DISABLED -eq '1'` 로 본다.
32
+ - 확인이 실패하면 보고에 그 명령, 종료 코드, stderr 첫 줄을 붙인다(예: `npm error code ETIMEDOUT`, `curl: (28) ...`). 원인을 '오프라인' 하나로 짐작하지 않는다.
33
+ - curl 의 종료 60(인증서 검증 실패)도 오프라인이 아니다. 망은 열려 있고, TLS 를 가로채는 사내 프록시의 인증서를 OS 가 믿지 않는 것이다. '인증서 문제로 설치하지 않음' 으로 보고한다.
34
+ - curl 은 닿는데 npm 만 인증서 오류(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`)로 실패하면 망은 열려 있고 npm 이 사내 인증서를 믿지 않는 것이다(npm 은 OS 인증서 저장소를 쓰지 않는다). '인증서 문제로 설치하지 않음' 으로 보고하고 `NODE_EXTRA_CA_CERTS=<사내 CA 파일>` 을 안내한다. Node 24 는 `NODE_OPTIONS=--use-system-ca` 도 된다.
35
+ - 이 실행에서 부르는 스킬의 0단계는 /banker:setup 0단계와 앞 스킬이 이미 돌린 같은 확인 줄(같은 명령과 같은 주소)의 결과를 쓰고 다시 돌리지 않는다. 처음 보는 원천만 새로 본다. 결과는 이 실행 안에서만 쓴다(그 사이 망이 바뀌면 옛 결과일 수 있다). 막힌 망에서 항목마다 같은 확인을 다시 기다리지 않게 하려는 것이다.
13
36
  1. **선택 받기**: `$ARGUMENTS` 에 컴포넌트명이 명시돼 있으면 그것만 실행(질문 생략).
14
37
  비어 있으면 아래를 `AskUserQuestion`(**multiSelect: true**)으로 제시한다(**Codex 런타임엔 AskUserQuestion 이 없으므로 목록을 제시하고 설치할 항목을 사용자에게 물어 받는다**). 항목은 **계층/의존 순서**로 묶여 있으니, 뒤 계층을 고르면 앞 계층 전제를 먼저 채운다:
15
38
  - 질문 하나에 선택지는 4개까지다. 5개 이상인 묶음은 두 질문으로 나눈다.
@@ -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
  };
@@ -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
  }
@@ -5,10 +5,13 @@
5
5
  * 자체는 캐시만 읽는 별도 훅이 담당한다(이 파일은 네트워크 조회만 - 화면 출력 없음).
6
6
  *
7
7
  * fail-closed 규약(각 항목은 update-fetch.test.mjs 가 검증한다):
8
- * - throttle(UPDATE_THROTTLE_MS) 미경과면 무동작(캐시가 없으면 진행).
8
+ * - 마지막 성공(checkedAt)이나 마지막 실패한 시도(triedAt)에서 하루가 지나지 않았으면 무동작(updateCheckDue).
9
9
  * - 비-https 는 loopback(localhost/127.0.0.1/::1) 에서만 http 허용, 그 외 비-https 는 거부.
10
10
  * - 3xx 리다이렉트는 따라가지 않고 no-op.
11
- * - 응답 JSON 에서 version 문자열만 방어 추출 - 없거나 비문자열이면 무동작(캐시 미기록).
11
+ * - 무활동 10초 timeout 과 요청마다 30초 교환 전체 마감으로 hang 하지 않는다.
12
+ * - 응답 본문은 256KiB 까지만 모은다. 넘으면 연결을 끊고 그 응답을 쓰지 않는다.
13
+ * - 응답 JSON 에서 version 문자열만 방어 추출. 버전을 얻지 못하면(조회 실패, 거부, 없거나 비문자열) 시도 시각
14
+ * (triedAt)만 써서 하루를 쉰다. latest 와 checkedAt 은 쓰지 않는다.
12
15
  * - 캐시는 writeUpdateCache 로 병합 기록: fetch 된 latest 가 기존 캐시와 다르면 notified 를 초기화
13
16
  * (재무장)하고, 같으면 형제 필드(notified 등)를 patch 에서 생략해 그대로 보존한다.
14
17
  * - 어떤 오류도 삼키고 항상 exit 0. GET 뿐이라 페이로드/식별자 전송이 없다.
@@ -19,7 +22,7 @@ import http from 'node:http';
19
22
  import https from 'node:https';
20
23
 
21
24
  import {
22
- UPDATE_CHECK_PATH, UPDATE_THROTTLE_MS, NPM_LATEST_URL, writeUpdateCache, collectBody, netDeadlineMs, readRegular,
25
+ UPDATE_CHECK_PATH, NPM_LATEST_URL, writeUpdateCache, collectBody, netDeadlineMs, readRegular, updateCheckDue,
23
26
  } from '../bin/lib/telemetry-config.mjs';
24
27
 
25
28
  const NET_TIMEOUT_MS = 10_000;
@@ -45,7 +48,8 @@ function readCache() {
45
48
  }
46
49
 
47
50
  // url 에 GET 요청해 응답 JSON 의 version 문자열만 추출한다. 실패·거부·리다이렉트·malformed 는 전부
48
- // null. reject 하지 않고 항상 resolve 한다(hang 방지). GET 뿐이라 페이로드·식별자 헤더가 없다.
51
+ // null. reject 하지 않고 항상 resolve 한다(무활동 timeout 과 교환 전체 마감, 본문 256KiB 상한). GET 뿐이라
52
+ // 페이로드·식별자 헤더가 없다.
49
53
  function fetchLatestVersion(url) {
50
54
  return new Promise((resolvePromise) => {
51
55
  let done = false;
@@ -94,21 +98,23 @@ function fetchLatestVersion(url) {
94
98
 
95
99
  async function main() {
96
100
  const cache = readCache();
97
- const checkedAt = typeof cache.checkedAt === 'number' ? cache.checkedAt : null;
98
- if (checkedAt !== null && (Date.now() - checkedAt) < UPDATE_THROTTLE_MS) return; // throttle 미경과 - no-op.
101
+ if (!updateCheckDue(cache)) return; // 성공이나 실패한 시도에서 하루가 지나지 않음 - no-op.
99
102
 
100
103
  const url = process.env.BANKER_NPM_URL || NPM_LATEST_URL; // 테스트 주입용 override.
101
104
  const latest = await fetchLatestVersion(url);
102
- if (!latest) return; // 조회 실패·거부·malformed - 캐시 미기록.
105
+ if (!latest) { // 조회 실패·거부·malformed - 버전은 쓰지 않고 시도 시각만 남겨 하루를 쉰다.
106
+ writeUpdateCache({ triedAt: Date.now() });
107
+ return;
108
+ }
103
109
 
104
110
  // 개인화 매니페스트는 여기서 조회하지 않는다: update-fetch 는 카운팅 opt-out(countingActive false) 경로의
105
111
  // fetcher 이고, 개인화 표시도 countingActive() 로 게이트되므로 이 경로에선 표시될 일이 없다. 매니페스트
106
112
  // 조회는 카운팅 활성 경로의 update-checkin 만 수행한다(중복·죽은 조회 제거).
107
113
  const cachedLatest = typeof cache.latest === 'string' ? cache.latest : null;
108
114
  if (cachedLatest !== latest) {
109
- writeUpdateCache({ latest, checkedAt: Date.now(), notified: null }); // 신버전 - notified 재무장.
115
+ writeUpdateCache({ latest, checkedAt: Date.now(), notified: null, triedAt: null }); // 신버전 - notified 재무장. 남은 실패 시도 시각은 비움.
110
116
  } else {
111
- writeUpdateCache({ latest, checkedAt: Date.now() }); // 동일 버전 - notified 등 형제 필드는 병합 보존.
117
+ writeUpdateCache({ latest, checkedAt: Date.now(), triedAt: null }); // 동일 버전 - notified 등 형제 필드는 병합 보존, 실패 시도 시각은 비움.
112
118
  }
113
119
  }
114
120
 
@@ -19,7 +19,7 @@ import { dirname, join } from 'node:path';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
21
21
  import {
22
- UPDATE_CHECK_PATH, UPDATE_THROTTLE_MS,
22
+ UPDATE_CHECK_PATH, updateCheckDue,
23
23
  noUpdateCheck, countingActive, endpoint,
24
24
  installedVersion, compareVersions, writeUpdateCache, readUsedSkills, readRegular,
25
25
  } from '../bin/lib/telemetry-config.mjs';
@@ -89,13 +89,6 @@ function maybeNotify(cached) {
89
89
  writeUpdateCache({ notified: cached.latest }); // 형제 필드(latest/checkedAt/changedSkills) 보존하며 notified 만 병합.
90
90
  }
91
91
 
92
- // checkedAt 기준 throttle 경과 여부. 캐시 없음·checkedAt 미기록/비수치 → 경과로 본다(첫 조회 유도).
93
- function throttleElapsed(cached) {
94
- const checkedAt = cached?.checkedAt;
95
- if (typeof checkedAt !== 'number') return true;
96
- return (Date.now() - checkedAt) >= UPDATE_THROTTLE_MS;
97
- }
98
-
99
92
  // 갱신 fetcher 절대경로 결정: count-default-on 활성(countingActive) + 엔드포인트 설정 시
100
93
  // update-checkin.mjs(체크인이 알림 조회를 겸함), 그 외에는 update-fetch.mjs(공개 npm GET 폴백).
101
94
  // 경로는 이 파일 기준으로 resolve 한다. (형제 스토리가 생성 중 - 런타임에 존재하면 된다.)
@@ -121,7 +114,7 @@ async function main() {
121
114
 
122
115
  const cached = readCache(); // 포그라운드는 캐시 읽기만(네트워크 없음).
123
116
  maybeNotify(cached);
124
- if (throttleElapsed(cached)) spawnFetcher(); // 갱신은 백그라운드로 위임.
117
+ if (updateCheckDue(cached)) spawnFetcher(); // 갱신은 백그라운드로 위임. 실패한 시도(triedAt)에서도 하루를 쉰다.
125
118
  }
126
119
 
127
120
  // 직접 실행(훅)일 때만 main() 을 돌린다. import(테스트)시엔 chooseFetcher 만 노출하고 main 은 미실행.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.20.2",
3
+ "version": "0.20.5",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"