@kaydash9999/banker-plugins 0.19.2 → 0.19.3

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.19.2"
9
+ "version": "0.19.3"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.19.3] - 2026-10-17
4
+
5
+ ### Fixed
6
+ - 업데이트 확인 체크인 훅: 보내기 전에 사용 기록의 이름을 바꿔 차지한다. 예전에는 첫 체크인이 보내는 사이 함께 시작한 세션이 같은 기록을 다시 보내 대시보드 수가 실제보다 커졌다. 보내는 동안 더해진 줄은 다음 체크인이 보낸다(예전에는 지워졌다). 기록 자리가 FIFO 면 기다리지 않는다. 기록이 링크면 링크만 옮겨 지우고 가리키던 파일은 그대로 두며, 다음 기록은 일반 파일로 새로 생긴다. `last-flush` 자리가 링크면 그 너머를 덮지 않는다. 백신 등이 기록을 잠시 쥐어 이름을 못 바꾸면 두 번 더 해 보고, 그래도 안 되면 다음 체크인에 남긴다. 죽은 체크인이 남긴 차지 파일은 차지한 지 한 시간이 지나면 지운다.
7
+ - `tone-compact`, `anti-drm`: 잠금을 잡지 못한 회차마다 쉰다. 예전에는 잠금 자리를 볼 때는 없다고 하고 만들 때는 있다고 하는 공유(NFS 의 캐시 등)에서 기다리는 동안 쉬지 않고 돌았다. 잠금을 만들거나 죽은 실행의 잠금을 지울 때 ESTALE 이 나면 쉬고 다시 본다. 만들 때의 ESTALE 이 기다림이 끝나도 계속되면 그 원인을 밝혀 거부한다(종료 1). 예전에는 예기치 못한 오류로 종료 2 였다. 일을 마친 뒤 잠금을 돌려줄 때의 ESTALE 은 세 번까지 다시 한다. 예전에는 잠금이 1분 남아 그동안 다음 작업이 거부됐다.
8
+ - `lineage` 3.0.8: 쪼개진 키, 비밀번호, `--redact-extra` 키워드도 통째로 가린다. 쪼개는 글자는 보이지 않는 글자(폭 없는 공백, 소프트 하이픈, 방향 제어 같은 기본 무시 글자와 그 밖의 서식 문자), 공백, 탭, 줄바꿈이 아닌 제어 문자와 특수 공백(줄바꿈 없는 공백 등), 사용자 정의 영역 글자, 점자 빈칸, U+FFFC, U+FFFD 다. 예전에는 쪼개진 조각이 가림 패턴에 걸리지 않아 페이지와 검토자 글에 남을 수 있었다. 다시 이은 결과가 보이는 글자 가운데 3.0.7 의 한 번이 가린 글자는 3.0.7 의 표식으로 다시 가리고, 어떤 종류의 값이든 3.0.7 보다 적게 가리면 3.0.7 한 번의 결과에서 이어 간다. 그래서 3.0.7 이 가리던 글자는 모두 그대로 가린다. 비밀 밖의 글은 그대로이고, 다시 이은 비밀 바로 옆의 그런 글자는 함께 빠질 수 있다.
9
+
3
10
  ## [0.19.2] - 2026-10-16
4
11
 
5
12
  ### Changed
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /*
3
3
  * banker 카운팅 체크인 훅 - usage-log 를 스킬별 x 시간대(hour 0-23)별 count 로 집계해 엔드포인트로
4
- * 한 번 POST 하고, 응답의 latest 로 update-check 캐시를 겸해 갱신한다. 전송 시도 후(성공/실패 무관)
5
- * 로그를 truncate 해 24h 윈도우로 상한한다. telemetry-flush 를 대체(evolve)한다.
4
+ * 한 번 POST 하고, 응답의 latest 로 update-check 캐시를 겸해 갱신한다. 보내기 전에 로그 이름을 바꿔
5
+ * 차지하고, 시도 뒤(성공/실패 무관) 차지한 파일을 지워 24h 윈도우로 상한한다. telemetry-flush 를
6
+ * 대체(evolve)한다.
6
7
  *
7
8
  * fail-closed 규약(각 항목은 update-checkin.test.mjs 가 검증한다):
8
9
  * - 미활성(countingActive) 또는 엔드포인트 없음이면 무동작(로그도 건드리지 않는다).
@@ -10,13 +11,19 @@
10
11
  * counts=스킬별 x 시간대별 호출수).
11
12
  * - 자체 네트워크 timeout 으로 hang 방지. 어떤 오류도 삼키고 항상 exit 0. 어떤 경로도 reject 안 함.
12
13
  * - 비-loopback 평문 http 는 전송하지 않는다(https-only). loopback http 만 로컬 테스트 mock 에 허용.
14
+ * - 함께 시작한 체크인은 같은 줄을 두 번 보내지 않는다(로그 이름 바꾸기는 하나만 성공한다).
15
+ * - 로그 자리가 FIFO 면 기다리지 않고, 로그나 last-flush 자리가 링크면 링크 너머 파일을 비우거나 덮지 않는다.
13
16
  *
14
17
  * count-default-on: 이 훅은 hooks.json 에 배선되지 않는다(스탠드얼론 .mjs). update-notify 가
15
18
  * countingActive + 엔드포인트 설정 시 fetcher 로 detached spawn 한다(응답 latest 가 알림 캐시를 겸함).
16
19
  */
17
- import { readFileSync, writeFileSync } from 'node:fs';
20
+ import {
21
+ closeSync, constants, fstatSync, lstatSync, openSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync,
22
+ } from 'node:fs';
23
+ import { randomBytes } from 'node:crypto';
18
24
  import http from 'node:http';
19
25
  import https from 'node:https';
26
+ import { dirname, join } from 'node:path';
20
27
 
21
28
  import {
22
29
  countingActive,
@@ -160,13 +167,80 @@ function fetchSkillChangesMap(url) {
160
167
  }
161
168
 
162
169
  // 보낼 본문. PII/IP/지속ID 없음: version + os(플랫폼 계열) + counts(스킬별 x 시간대)뿐.
163
- // 로그 없음도 유효한 체크인(설치 펄스 + 알림 fetch 겸함): 빈 counts 로 진행한다.
164
- function checkinBody() {
165
- let raw = '';
166
- try { raw = readFileSync(USAGE_LOG_PATH, 'utf8'); } catch { raw = ''; }
170
+ // 차지한 줄이 없어도 유효한 체크인(설치 펄스 + 알림 fetch 겸함): 빈 counts 로 진행한다.
171
+ function checkinBody(raw) {
167
172
  return JSON.stringify({ version: installedVersion() || 'unknown', os: process.platform, counts: aggregate(raw) });
168
173
  }
169
174
 
175
+ // 차지한 로그의 이름(usage-log.sending-<pid>-<차지한 ms>-<난수>)과, 죽은 체크인이 남긴 것으로 볼 나이. 나이는
176
+ // 이름의 시각으로 센다: 이름을 바꿔도 수정 시각은 로그를 마지막으로 쓴 때 그대로다. 난수는 PID 가 같은 다른
177
+ // 이름공간(컨테이너)의 체크인과 이름이 겹치지 않게 한다.
178
+ const CLAIM_RE = /^usage-log\.sending-\d+-(\d+)(?:-[0-9a-f]+)?$/;
179
+ const CLAIM_MAX_AGE_MS = 3_600_000;
180
+ // 백신이나 동기화 도구가 로그를 잠시 쥐어 이름을 못 바꿀 때(Windows 의 EPERM, EBUSY) 쉬었다 다시 해 볼 횟수와 쉼.
181
+ const RENAME_TRIES = 3;
182
+ const RENAME_REST_MS = 50;
183
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
184
+
185
+ // 일반 파일의 글. 그 밖(FIFO, 폴더)과 오류는 ''. 막지 않고 열어 FIFO 를 기다리지 않는다.
186
+ function readRegular(path) {
187
+ let fd;
188
+ try {
189
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
190
+ return fstatSync(fd).isFile() ? readFileSync(fd, 'utf8') : '';
191
+ } catch {
192
+ return '';
193
+ } finally {
194
+ if (fd !== undefined) try { closeSync(fd); } catch { /* best-effort */ }
195
+ }
196
+ }
197
+
198
+ // 보내기 전에 로그 이름을 바꿔 차지한다. rename 은 원자적이라 함께 시작한 체크인 가운데 하나만 그 줄을 읽고,
199
+ // 보내는 동안 더해진 줄은 새 로그로 가 다음 체크인이 보낸다. 링크면 링크 자체가 옮겨진다(대상은 그대로).
200
+ // 로그가 없거나 폴더, FIFO 이거나 이름을 바꾸지 못하면(다른 체크인이 먼저 차지, Windows 에서 백신이 쥠)
201
+ // 아무것도 차지하지 않고 로그는 다음 체크인에 남긴다.
202
+ function claimLog() {
203
+ try {
204
+ const st = lstatSync(USAGE_LOG_PATH);
205
+ if (!st.isFile() && !st.isSymbolicLink()) return { raw: '', claim: null };
206
+ } catch {
207
+ return { raw: '', claim: null };
208
+ }
209
+ const claim = join(dirname(USAGE_LOG_PATH), `usage-log.sending-${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`);
210
+ return renameAside(USAGE_LOG_PATH, claim) ? { raw: readRegular(claim), claim } : { raw: '', claim: null };
211
+ }
212
+
213
+ // 이름을 바꿨으면 true. 잠시 쥔 것(EPERM, EBUSY, EACCES)은 RENAME_TRIES 번까지 쉬었다 다시 하고, 없어졌으면
214
+ // (다른 체크인이 먼저 차지) 곧 false.
215
+ function renameAside(from, to) {
216
+ for (let tried = 1; ; tried++) {
217
+ try {
218
+ renameSync(from, to);
219
+ return true;
220
+ } catch (e) {
221
+ if (tried >= RENAME_TRIES || !['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) return false;
222
+ Atomics.wait(SLEEP, 0, 0, RENAME_REST_MS);
223
+ }
224
+ }
225
+ }
226
+
227
+ // 죽은 체크인이 남긴 차지 파일(차지한 지 한 시간이 넘은 것, 시계를 되돌려 한 시간 넘게 앞선 것)을 지운다(배치
228
+ // 유실 허용). 더 젊은 것은 아직 보내는 다른 체크인의 것이라 둔다. 이름 자체를 보고 지운다(링크면 링크만 지워진다).
229
+ function dropOldClaims(now = Date.now()) {
230
+ const dir = dirname(USAGE_LOG_PATH);
231
+ let names;
232
+ try { names = readdirSync(dir); } catch { return; }
233
+ for (const name of names) {
234
+ const made = CLAIM_RE.exec(name);
235
+ if (!made || Math.abs(now - Number(made[1])) <= CLAIM_MAX_AGE_MS) continue;
236
+ const path = join(dir, name);
237
+ try {
238
+ const st = lstatSync(path);
239
+ if (st.isFile() || st.isSymbolicLink()) unlinkSync(path);
240
+ } catch { /* best-effort */ }
241
+ }
242
+ }
243
+
170
244
  // 응답의 latest 로 공유 update-check 캐시를 갱신(알림 겸함). 없으면 캐시 미변경.
171
245
  // update-fetch 처럼 notified:null 재무장을 하지 않아도 등가다: dedupe 판정이 notified===latest
172
246
  // (update-notify)라 전진 버전에서 구 notified 는 항상 신 latest 와 달라 재고지가 정상 발화하고,
@@ -182,18 +256,27 @@ async function refreshUpdateCache(responseBody) {
182
256
  try { writeUpdateCache(patch); } catch { /* best-effort */ }
183
257
  }
184
258
 
185
- // 전송 시도 후(성공/실패 무관) 로그를 비워 24h 윈도우로 상한(배치 유실 허용).
186
- function resetLog() {
187
- try { writeFileSync(USAGE_LOG_PATH, ''); } catch { /* best-effort */ }
188
- try { writeFileSync(LAST_FLUSH_PATH, new Date().toISOString()); } catch { /* best-effort */ }
259
+ // 전송 시도 후(성공/실패 무관) 차지한 파일을 지워 24h 윈도우로 상한(배치 유실 허용)하고 last-flush 를 남긴다.
260
+ // last-flush 는 새 파일에 쓴 뒤 rename 으로 놓아, 그 이름 자리의 링크를 따라 다른 파일을 덮지 않는다.
261
+ function finishClaim(claim) {
262
+ if (claim) try { unlinkSync(claim); } catch { /* best-effort */ }
263
+ const tmp = `${LAST_FLUSH_PATH}.${process.pid}.tmp`;
264
+ try {
265
+ writeFileSync(tmp, new Date().toISOString(), { flag: 'wx' });
266
+ renameSync(tmp, LAST_FLUSH_PATH);
267
+ } catch {
268
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
269
+ }
189
270
  }
190
271
 
191
272
  async function main() {
192
273
  if (!countingActive()) return; // 미활성 - 무동작(로그 미변경).
193
274
  const url = endpoint();
194
275
  if (!url) return; // 엔드포인트 없음 - 무동작(로그 미변경).
195
- const responseBody = await post(url, checkinBody());
196
- resetLog(); // 보낸 직후 비운다. 매니페스트를 받는 동안(최대 30초) 시작한 세션이 같은 로그를 다시 보내지 않게.
276
+ dropOldClaims();
277
+ const { raw, claim } = claimLog();
278
+ const responseBody = await post(url, checkinBody(raw));
279
+ finishClaim(claim); // 보내는 동안 더해진 줄은 새 로그에 남아 다음 체크인이 보낸다.
197
280
  await refreshUpdateCache(responseBody);
198
281
  }
199
282
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
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"
@@ -258,4 +258,5 @@ argument-hint: "on | off | status | config"
258
258
  | 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
259
259
  | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
260
260
  | 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
261
+ | 네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다 | Codex 폴더가 NFS 같은 네트워크 공유에 있고 공유가 파일 핸들을 잃음. 잠시 뒤 다시 실행. 계속되면 공유 연결 상태를 확인하도록 알림. 잠금을 임의로 지우지 않음 |
261
262
  | 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
@@ -529,55 +529,67 @@ const LOCK_STALE_MS = 60_000;
529
529
  const LOCK_STEP_MS = 50;
530
530
  const SLEEP = new Int32Array(new SharedArrayBuffer(4));
531
531
 
532
- // Makes the lock file; false when one is there already. Anything else (permissions) throws.
532
+ // Makes the lock file: "taken", "held" when one is there already, or "stale" when a network share answered with a
533
+ // stale file handle (ESTALE), which the next try may not. Anything else (permissions) throws.
533
534
  function tryLock(path, token) {
534
535
  try {
535
536
  writeFileSync(path, token, { flag: "wx", mode: 0o600 });
536
- return true;
537
+ return "taken";
537
538
  } catch (e) {
538
- if (e.code === "EEXIST") return false;
539
+ if (e.code === "EEXIST") return "held";
540
+ if (e.code === "ESTALE") return "stale";
539
541
  throw e;
540
542
  }
541
543
  }
542
544
 
543
- // Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
544
- // looked at, not where it leads: a link to nothing or a link loop there is a lock like any other. A lock dated more
545
- // than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
546
- // and the wait ends in a refusal.
545
+ // Removes a lock a dead run left. The name itself is looked at, not where it leads: a link to nothing or a link loop
546
+ // there is a lock like any other. A lock dated more than a minute away either way (a clock set back) is stale too.
547
+ // A folder there is none of this module's: it stays, and the wait ends in a refusal. A name gone in between, or a
548
+ // network share's stale file handle (ESTALE) at the look or the removal, is left for the next round.
547
549
  function dropStale(path) {
548
550
  let st;
549
551
  try {
550
552
  st = lstatSync(path);
551
553
  } catch (e) {
552
- if (e.code === "ENOENT") return true; /* gone in between */
553
- if (e.code === "ESTALE") return false; /* a network share's stale handle: rest and look again, not spin */
554
+ if (e.code === "ENOENT" || e.code === "ESTALE") return;
554
555
  throw e;
555
556
  }
556
- if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
557
- rmSync(path, { force: true });
558
- return true;
557
+ if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return;
558
+ try {
559
+ rmSync(path, { force: true });
560
+ } catch (e) {
561
+ if (e.code !== "ESTALE") throw e;
562
+ }
559
563
  }
560
564
 
561
- // Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
562
- // round, so no lock spot, whatever is there, keeps the wait going past it.
565
+ // Takes the lock: { path, token }, or { path, why } once the wait ends without it: why is "stale" when the last try
566
+ // met a stale file handle instead of a lock, else "held". Every round that does not take the lock rests, so no answer
567
+ // of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
563
568
  function takeLock(dir, waitMs) {
564
569
  const path = join(dir, LOCK);
565
570
  const token = `${process.pid} ${Date.now()} ${Math.random()}`;
566
571
  const until = Date.now() + waitMs;
567
572
  for (;;) {
568
- if (tryLock(path, token)) return { path, token };
569
- if (Date.now() >= until) return null;
570
- if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
573
+ const got = tryLock(path, token);
574
+ if (got === "taken") return { path, token };
575
+ if (Date.now() >= until) return { path, why: got };
576
+ if (got === "held") dropStale(path);
577
+ Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
571
578
  }
572
579
  }
573
580
 
574
581
  // Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
575
- // this run's lock and stays.
582
+ // this run's lock and stays. A network share's stale file handle (ESTALE) is tried again after a rest, three times
583
+ // at most; a lock still left goes stale in a minute.
576
584
  function dropLock(lock) {
577
- try {
578
- if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
579
- } catch {
580
- /* already gone */
585
+ for (let tried = 1; ; tried++) {
586
+ try {
587
+ if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
588
+ return;
589
+ } catch (e) {
590
+ if (e.code !== "ESTALE" || tried >= 3) return; /* already gone, or not this run's to take away */
591
+ Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
592
+ }
581
593
  }
582
594
  }
583
595
 
@@ -592,7 +604,11 @@ function locked(o, fn) {
592
604
  if (denied(e)) return fn();
593
605
  throw e;
594
606
  }
595
- if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
607
+ if (!lock.token) {
608
+ return refuse(lock.why === "stale"
609
+ ? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
610
+ : `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
611
+ }
596
612
  try {
597
613
  return fn();
598
614
  } finally {
@@ -2,7 +2,7 @@
2
2
  name: lineage
3
3
  description: "현재 세션 대화를 카카오톡 스타일의 단일 HTML 로 내보냄. Claude 답변은 한 줄 요약 아래에 원문을 접어 두고, 펼치면 마크다운으로 렌더해 보여 줌. 기본은 세션 모델이 요약을 쓰고 노이즈 턴을 걸러 내며, `--rulebase` 는 모델 없이 규칙만 씀. 여러 세션을 합칠 수 있음. 'lineage'/'대화 export'/'카톡 스타일 html' 시 사용."
4
4
  invocation: /lineage
5
- version: 3.0.7
5
+ version: 3.0.8
6
6
  schema_version: 1
7
7
  ---
8
8
 
@@ -225,6 +225,7 @@ Schema-tolerant 파서: 미지 record type → stderr WARN + 다음 line. `v1`/`
225
225
  1차 `detect-secrets>=1.5`(pip 설치 시), 미설치 → 내장 fallback + WARN.
226
226
  내장: AWS IAM(AKIA/ASIA)·GitHub PAT·Slack·JWT·private key·한/영 평문 password·Shannon entropy≥4.5.
227
227
  확장 `--redact-extra "k1,k2"`. 부분 마스킹 `--redact-mode mask`(페이지 패턴만. 검토자 패턴이 찾은 값은 늘 전부 가린다).
228
+ 쪼개진 값도, 쪼개는 글자를 뺀 모양을 패턴이 찾으면 그 구간에서만 글자를 빼고 통째로 가린다(3.0.8). 쪼개는 글자는 보이지 않는 글자(폭 없는 공백, 소프트 하이픈, 변형 선택자 같은 기본 무시 글자와 서식 문자), 공백, 탭, 줄바꿈이 아닌 제어 문자와 특수 공백, 사용자 정의 영역 글자, 점자 빈칸, U+FFFC, U+FFFD 다. 지금 모양 그대로 패턴이 찾는 값을 반으로 가르게 되면 잇지 않는다. 다시 이은 결과가 보이는 글자 가운데 3.0.7 의 한 번이 가린 글자는 3.0.7 의 표식으로 다시 가리고(`KEY=` 처럼 3.0.7 이 값과 한 덩어리로 가린 앞 글자는 값 표식 옆의 표식 하나가 된다), 어떤 종류의 값이든 3.0.7 보다 적게 가리면 3.0.7 한 번의 결과에서 이어 간다. 그래서 3.0.7 이 가리던 글자는 모두 그대로 가린다. 비밀 밖의 글은 바꾸지 않고, 다시 이은 비밀 바로 옆의 그런 글자는 함께 빠질 수 있다. 무엇이든 이었으면 결과가 더 바뀌지 않을 때까지 되풀이한다(최대 네 번).
228
229
 
229
230
  ### 5. Render
230
231
 
@@ -331,6 +332,7 @@ pip install 'detect-secrets>=1.5' # (선택) 강한 redaction
331
332
  - 규칙 요약 캐시는 본문만으로 키를 잡는다. `--redact-mode` 나 `--redact-extra` 를 바꿔 다시 돌린 `--rulebase` 는 앞선 실행의 요약을 쓸 수 있다(2.x 동작 그대로). 기본 흐름의 묶음은 요약을 매번 새로 만든다. 묶음 형식은 `lineage-review/2` 라 0.15.1 이 만든 묶음은 `--emit-review` 부터 다시 한다.
332
333
  - 검토자는 긴 턴의 앞뒤만 본다(`clipped: true`). 가운데에만 있는 결론은 놓칠 수 있다.
333
334
  - 검토자 패턴은 흔한 모양만 가린다. `/` 가 든 URL 비밀번호와 키 이름이 100자를 넘는 16진 키는 가리지 못한다(값이 길고 무작위면 엔트로피 규칙이 가릴 수 있다). curl `-u` 패턴은 값 전체가 `docker -u 1000:1000` 같은 uid:gid, `rsync -u host:/path`, `date -u +%H:%M` 같은 날짜 형식(`+%` 뒤에 글자가 오면 나머지는 보지 않는다), 양쪽 모두 변수인 꼴(`$UID:$GID`, `${UID}:${GID}`, `%USER%:%PASS%`, `$(id -u):$(id -g)`), 기본값이 콜론 없는 경로(`/`, `~`, `.` 로 시작)인 변수(`mktemp -u "${TMPDIR:-/tmp}/x"`), 앞선 가림이 남긴 표식(`-u [REDACTED:JWT]`)일 때만 둔다. 이스케이프한 따옴표 안에서도 같다. 다른 명령의 `-u 이름:값`(`-u root:root`, `ps -u postgres:postgres`, `rsync -avu host:dir/`, `ls -lu a:bcd`), 기본값이 경로가 아닌 변수(`${UID:-1000}:${GID:-1000}`, `${API_KEY:-…}`)는 가린다. 숫자만으로 된 이름과 비밀번호, `/` 로 시작하는 비밀번호, `$` 로 시작해 변수처럼 보이는 비밀번호(`$USER:$ecret`), `-U` 와 `--proxy-user`, 붙여 쓴 묶음(`-suUSER:PW`), `-u=USER:PW`, 공백이 든 비밀번호의 둘째 낱말부터, 인자 목록에서 다른 종류의 따옴표가 든 비밀번호(`'-u', "USER:P'W"`), 사용자 안에서 따옴표 뒤에 `-eu`, `--user` 같은 플래그 꼴과 따옴표가 오는 꼴(`"$USER"-eu":PW"`), requests 의 `auth=(...)`, value 가 name 보다 앞에 온 HAR 은 가리지 못한다. 백틱 명령 치환(`` `id -u`:`id -g` ``)과 `\$(id -u):\$(id -g)` 는 가운데 일부가 가려진다(3.0.0 과 같다). 값이 `/`, `~`, `$` 로 시작하는 password 값(`$PWD:/workspace`, `OLDPWD=/home/...`, `password=$DB_PASSWORD`), 이미 가린 표식, 값 전체가 마스크 꼴(`abcd****wxyz`, `****`)인 값은 두고, `PGPASSWORD=`, `MYSQL_PWD=` 의 값은 가린다. `password=os.environ[...]`, `암호: AES-256-GCM` 처럼 코드나 이름인 값과 `sk-` 로 시작하는 긴 세션 이름은 지나치게 가린다. Basic 인증 패턴은 `HTTP_AUTHORIZATION` 처럼 앞에 `_` 가 붙은 이름, 구분자 없는 `Authorization Basic …`, 백틱이나 대괄호(`["Basic …"]`) 안의 값을 가리지 못한다.
335
+ - 쪼갠 값 가운데 detect-secrets 만 찾는 값은 찾지 못한다. 키 바로 앞이 글자나 숫자이고 그 사이와 키 안에 쪼개는 글자가 있으면, 앞 글자 조건이 있는 검토자 패턴(Anthropic, OpenAI, Google 키, Bearer, Basic 인증)은 값 꼬리를 놓칠 수 있다. 태그 글자로 통째로 적거나 base64 로 감싼 값처럼 일부러 숨긴 값은 풀어 보지 않는다. ANSI 색 코드처럼 ESC 뒤에 `[01;31m` 같은 글자가 붙는 열은 ESC 만 쪼개는 글자라 다시 잇지 못한다. 3.0.7 한 번의 결과에서 이어 갈 때는 3.0.7 이 앞부분만 가린 키의 나머지가 남을 수 있다(3.0.7 과 같다). `--redact-mode mask` 의 앞뒤 4글자에는 쪼개는 글자도 한 글자로 든다. 쪼개는 글자가 있는 글은 이을 곳을 찾느라 3.0.7 의 약 3배 걸리고, 이은 곳이 있으면 가림을 몇 번 더 돌아 약 6배 걸린다(5MB 한 글에서 잼).
334
336
  - 페이지 패턴 가운데 JWT 와 개인 키 패턴은 같은 머리(`eyJ`, `-----BEGIN`)가 긴 줄에 되풀이되면 시간이 줄 길이의 제곱으로 는다(2.x 동작 그대로). 기본 흐름은 턴마다 여러 번 가리므로 그만큼 더 걸린다.
335
337
  - 마크다운의 **중첩 리스트는 평탄화**되고 각주는 미지원.
336
338
  - 사용자가 질문에 래퍼 블록(`<task-notification>…`)을 **인용**하면 문장은 남고 그 블록만 사라진다.
@@ -10,7 +10,9 @@ Runtime-agnostic: works from any transcript via --session / --from-transcript.
10
10
  Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
11
11
  """
12
12
  import argparse
13
+ import bisect
13
14
  import hashlib
15
+ import heapq
14
16
  import hmac
15
17
  import html
16
18
  import json
@@ -22,6 +24,7 @@ import stat
22
24
  import sys
23
25
  import tempfile
24
26
  import unicodedata
27
+ from array import array
25
28
  from html.parser import HTMLParser
26
29
  from typing import Any
27
30
 
@@ -170,15 +173,187 @@ def _mask(s: str) -> str:
170
173
  return (s[:4] + "****" + s[-4:]) if len(s) > 8 else "[REDACTED]"
171
174
 
172
175
 
173
- def redact(text: str, extra: "str | None" = None, mode: str = "full"):
174
- """Multi-layer redaction. Returns (redacted_text, count_by_kind)."""
175
- counts = {}
176
- out = text
176
+ class _Trail:
177
+ """Where each character of a text that redaction rewrites stood in the text it started as (-1 for one a marker
178
+ or a mask put in). A text whose split secrets were joined first is kept only if it shows no character that the
179
+ one pass over the text as it stands (3.0.7) hid (3.0.8)."""
180
+
181
+ def __init__(self, n):
182
+ self.n, self.pos = n, None # None: each character is where it was
183
+
184
+ def carry(self, edits):
185
+ """Follow `edits` (start, end, replacement), sorted and apart: an empty replacement drops what it covers, a
186
+ mask keeps where its first and last four characters were, a marker is all new."""
187
+ if not edits:
188
+ return
189
+ pos = array("q", range(self.n)) if self.pos is None else self.pos
190
+ out, at, new = array("q"), 0, array("q", [-1])
191
+ for s, e, r in edits:
192
+ out += pos[at:s]
193
+ if r.startswith("[REDACTED"):
194
+ out += new * len(r)
195
+ elif r:
196
+ out += pos[s:s + 4] + new * (len(r) - 8) + pos[e - 4:e]
197
+ at = e
198
+ self.pos = out + pos[at:]
199
+
200
+ def sub(self, pat, repl, text):
201
+ """pat.sub(repl, text), the trail following what it replaced."""
202
+ edits = []
203
+
204
+ def noted(m):
205
+ r = repl(m)
206
+ if r != m.group(0):
207
+ edits.append((m.start(), m.end(), r))
208
+ return r
209
+ out = pat.sub(noted, text)
210
+ self.carry(edits)
211
+ return out
212
+
213
+ def shown(self):
214
+ """A byte for each character of the text it started as: 1 where the text it follows shows that character."""
215
+ if self.pos is None:
216
+ return bytearray(b"\x01") * self.n
217
+ seen = bytearray(self.n)
218
+ for p in self.pos:
219
+ if p >= 0:
220
+ seen[p] = 1
221
+ return seen
222
+
223
+
224
+ def _plain_sub(pat, repl, text):
225
+ return pat.sub(repl, text)
226
+
227
+
228
+ def _sub_for(trail):
229
+ """The substitution a redaction layer makes its replacements with: kept on `trail` when there is one."""
230
+ return _plain_sub if trail is None else trail.sub
231
+
232
+
233
+ def _shows_more(trail, shown):
234
+ """Whether the text `trail` follows shows a character of the text it started as that `shown` (a _Trail.shown of
235
+ another text from the same start) has hidden."""
236
+ return (int.from_bytes(trail.shown(), "little") & ~int.from_bytes(shown, "little")) != 0
237
+
238
+
239
+ # Characters that cut a value for the patterns while no person reads them as a separator: Unicode's default-ignorable
240
+ # code points (the zero-width space and joiners, the soft hyphen, the direction controls, the variation selectors,
241
+ # the tag characters, the Hangul fillers) and the other format characters, the controls and spaces that are no
242
+ # space, tab or line break, private use, the braille blank, and U+FFFC and U+FFFD, which stand for something lost
243
+ # (a lone surrogate became U+FFFD). A value cut by them is put back together where a pattern finds it once they
244
+ # are gone (3.0.8).
245
+ _SPLITTER_RANGES = (
246
+ (0x0000, 0x0008), (0x000E, 0x001B), (0x007F, 0x0084), (0x0086, 0x009F), (0x00A0, 0x00A0), (0x00AD, 0x00AD),
247
+ (0x034F, 0x034F), (0x0600, 0x0605), (0x061C, 0x061C), (0x06DD, 0x06DD), (0x070F, 0x070F), (0x0890, 0x0891),
248
+ (0x08E2, 0x08E2), (0x115F, 0x1160), (0x1680, 0x1680), (0x17B4, 0x17B5), (0x180B, 0x180F), (0x2000, 0x200F),
249
+ (0x202A, 0x202F), (0x205F, 0x206F), (0x2800, 0x2800), (0x3000, 0x3000), (0x3164, 0x3164), (0xE000, 0xF8FF),
250
+ (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF), (0xFFA0, 0xFFA0), (0xFFF0, 0xFFFD), (0x110BD, 0x110BD), (0x110CD, 0x110CD),
251
+ (0x13430, 0x1343F), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF), (0xF0000, 0xFFFFD),
252
+ (0x100000, 0x10FFFD))
253
+ _SPLITTERS = "".join(chr(a) if a == b else chr(a) + "-" + chr(b) for a, b in _SPLITTER_RANGES)
254
+ _SPLITTER_RE = re.compile("[" + _SPLITTERS + "]")
255
+ _UNSPLIT_RE = re.compile("[^" + _SPLITTERS + "]+")
256
+ # A character a value can hold: past a splitter next to a found value, one of these means another value begins.
257
+ _VALUE_CHAR_RE = re.compile(r"[A-Za-z0-9_+/=.\-]")
258
+
259
+
260
+ def _unsplit_runs(text):
261
+ """`text` without its splitters, and where each run of other characters starts in it and in `text`."""
262
+ firsts, origins, parts, at = array("q"), array("q"), [], 0
263
+ for m in _UNSPLIT_RE.finditer(text):
264
+ firsts.append(at)
265
+ origins.append(m.start())
266
+ parts.append(m.group(0))
267
+ at += m.end() - m.start()
268
+ return "".join(parts), firsts, origins
269
+
270
+
271
+ def _matches(text, patterns, entropy):
272
+ """The matches of `patterns` in `text`, and with `entropy` the runs the entropy rule hides."""
273
+ found = [m for pat in patterns for m in pat.finditer(text)]
274
+ if entropy:
275
+ found += [m for m in ENTROPY_LONG.finditer(text) if shannon_entropy(m.group(0)) >= 4.5]
276
+ return found
277
+
278
+
279
+ def _cut_spans(text, joined, firsts, origins, matches):
280
+ """Where in `text` the values `matches` found in `joined` lie, for those splitters cut or touch. A stretch takes
281
+ in the splitters next to its value when the character past them is no value character (so the value's
282
+ neighbours are those it had in `joined`); a splitter between the value and another value stays."""
283
+ def at(j): # where joined[j] is in text
284
+ k = bisect.bisect_right(firsts, j) - 1
285
+ return origins[k] + j - firsts[k]
286
+ spans = []
287
+ for m in matches:
288
+ s, e = at(m.start()), at(m.end() - 1) + 1
289
+ before = at(m.start() - 1) + 1 if m.start() else 0
290
+ after = at(m.end()) if m.end() < len(joined) else len(text)
291
+ if before == 0 or not _VALUE_CHAR_RE.match(text[before - 1]):
292
+ s = before
293
+ if after == len(text) or not _VALUE_CHAR_RE.match(text[after]):
294
+ e = after
295
+ if e - s > m.end() - m.start():
296
+ spans.append((s, e))
297
+ return spans
298
+
299
+
300
+ def _kept(spans, plain):
301
+ """The stretches of `spans` to join: those no value the patterns find in the text as it stands holds already,
302
+ and that cut none of those values in two. Joining across such a value would glue it to its neighbour, so the
303
+ neighbour's own pattern (a lookbehind, a keyword the greedy class ate) no longer finds it. Both lists are swept
304
+ in order with a heap of the values still open, so many of them stay fast."""
305
+ plain, out, heap, at = sorted(plain), [], [], 0
306
+ for s, e in sorted(spans):
307
+ while at < len(plain) and plain[at][0] < e:
308
+ heapq.heappush(heap, (plain[at][1], plain[at][0]))
309
+ at += 1
310
+ while heap and heap[0][0] <= s:
311
+ heapq.heappop(heap)
312
+ if not any((o_s <= s and e <= o_e) or not (s <= o_s and o_e <= e) for o_e, o_s in heap):
313
+ out.append((s, e))
314
+ return out
315
+
316
+
317
+ def _merged(spans):
318
+ """Sorted stretches with those that overlap or touch put together."""
319
+ out = []
320
+ for s, e in spans:
321
+ if out and s <= out[-1][1]:
322
+ out[-1][1] = max(out[-1][1], e)
323
+ else:
324
+ out.append([s, e])
325
+ return out
326
+
327
+
328
+ def _rejoin_split(text, patterns, entropy=False, trail=None):
329
+ """`text` with the splitters taken out of each stretch where, once they are gone, one of `patterns` (and with
330
+ `entropy` the entropy rule) finds a value that the text as it stands does not show whole: the layers after
331
+ then see that value and hide it. The rest of the text is left as it is (3.0.8). `trail` follows what is taken out."""
332
+ if not _SPLITTER_RE.search(text):
333
+ return text
334
+ joined, firsts, origins = _unsplit_runs(text)
335
+ spans = _cut_spans(text, joined, firsts, origins, _matches(joined, patterns, entropy))
336
+ if spans:
337
+ spans = _kept(spans, [(m.start(), m.end()) for m in _matches(text, patterns, entropy)])
338
+ drops = [(m.start(), m.end(), "") for s, e in _merged(sorted(spans)) for m in _SPLITTER_RE.finditer(text, s, e)]
339
+ if trail is not None:
340
+ trail.carry(drops)
341
+ out, at = [], 0
342
+ for s, e, _ in drops:
343
+ out.append(text[at:s])
344
+ at = e
345
+ return "".join(out) + text[at:]
346
+
347
+
348
+ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None):
349
+ """Multi-layer redaction; page_redact and review_redact join split secrets first (3.0.8). Returns (text, counts).
350
+ With `trail` (a _Trail), where each character of the result came from is followed too."""
351
+ counts, out, sub = {}, text, _sub_for(trail)
177
352
  for name, pat in SECRET_PATTERNS:
178
353
  def repl(m, n=name):
179
354
  counts[n] = counts.get(n, 0) + 1
180
355
  return _mask(m.group(0)) if mode == "mask" else f"[REDACTED:{n}]"
181
- out = pat.sub(repl, out)
356
+ out = sub(pat, repl, out)
182
357
 
183
358
  def ent_repl(m):
184
359
  s = m.group(0)
@@ -186,7 +361,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
186
361
  counts["entropy"] = counts.get("entropy", 0) + 1
187
362
  return _mask(s) if mode == "mask" else "[REDACTED:entropy]"
188
363
  return s
189
- out = ENTROPY_LONG.sub(ent_repl, out)
364
+ out = sub(ENTROPY_LONG, ent_repl, out)
190
365
 
191
366
  try:
192
367
  from detect_secrets import SecretsCollection
@@ -204,7 +379,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
204
379
  for s in col[fname]:
205
380
  sv = s.secret_value
206
381
  if sv and sv in out:
207
- out = out.replace(sv, "[REDACTED:ds]")
382
+ out = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", out)
208
383
  counts["ds:detect-secrets"] = counts.get(
209
384
  "ds:detect-secrets", 0) + 1
210
385
  finally:
@@ -228,8 +403,8 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
228
403
  # a marker an earlier pass left stays whole: a keyword inside its name is no secret
229
404
  pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
230
405
  hits = []
231
- out = pat.sub(lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
232
- else hits.append(1) or "[REDACTED]", out)
406
+ out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
407
+ else hits.append(1) or "[REDACTED]", out)
233
408
  if hits:
234
409
  counts[f"custom:{kw}"] = len(hits)
235
410
  return out, counts
@@ -315,35 +490,177 @@ REVIEW_SECRET_PATTERNS = [
315
490
  ]
316
491
 
317
492
 
318
- def _reviewer_patterns(text):
493
+ def _reviewer_patterns(text, trail=None):
319
494
  """`text` with what the reviewer patterns find replaced whole by [REDACTED:<name>], and the
320
- count by name."""
321
- counts = {}
495
+ count by name. A split secret is put back together by its callers first (3.0.8)."""
496
+ counts, sub = {}, _sub_for(trail)
322
497
  for name, pat in REVIEW_SECRET_PATTERNS:
323
- text, k = pat.subn(f"[REDACTED:{name}]", text)
324
- if k:
325
- counts[name] = k
498
+ hits = []
499
+ text = sub(pat, lambda m, marker=f"[REDACTED:{name}]": hits.append(1) or marker, text)
500
+ if hits:
501
+ counts[name] = len(hits)
326
502
  return text, counts
327
503
 
328
504
 
505
+ def _rejoin_secrets(text, extra=None, trail=None):
506
+ """`text` with each secret a splitter cuts put back together, for every pattern a page or a reviewer's text is
507
+ hidden with: the reviewer's, the page's, the entropy rule and the --redact-extra keywords (3.0.8)."""
508
+ keywords = [k.strip() for k in (extra or "").split(",") if k.strip()]
509
+ patterns = ([p for _, p in REVIEW_SECRET_PATTERNS] + [p for _, p in SECRET_PATTERNS]
510
+ + [re.compile(re.escape(k), re.IGNORECASE) for k in keywords])
511
+ return _rejoin_split(text, patterns, entropy=True, trail=trail)
512
+
513
+
514
+ def _join_first(text, extra, one_pass):
515
+ """`one_pass` (text, trail -> (text, found)) over `text` with its split secrets put back together. Once anything
516
+ was joined, again over its output until a pass changes nothing (at most four passes): a value next to one a
517
+ marker replaced may be found only then. Returns (text, [found], the _Trail of the text, or None when nothing
518
+ was joined)."""
519
+ trail = _Trail(len(text)) if _SPLITTER_RE.search(text) else None
520
+ joined = _rejoin_secrets(text, extra, trail)
521
+ moved = joined != text
522
+ red, found = one_pass(joined, trail)
523
+ founds = [found]
524
+ for _ in range(3):
525
+ joined = _rejoin_secrets(red, extra, trail)
526
+ if joined == red and not moved:
527
+ break # nothing joined: 3.0.7's one pass is final
528
+ moved = True
529
+ again, found = one_pass(joined, trail)
530
+ if again == red:
531
+ break
532
+ red = again
533
+ founds.append(found)
534
+ return red, founds, trail if moved else None
535
+
536
+
537
+ # The key patterns that may find the same key: one kind when passes are compared.
538
+ _KEY_FAMILY = {"AnthropicKey": "key", "OpenAIKey": "key", "GoogleKey": "key"}
539
+
540
+
541
+ def _kinds(founds):
542
+ """How many values of each kind the passes hid, the entropy rule and detect-secrets apart: joining pieces
543
+ changes how many runs the entropy rule sees."""
544
+ kinds = {}
545
+ for found in founds:
546
+ for k, v in found.items():
547
+ if k != "entropy" and not k.startswith("ds:"):
548
+ k = _KEY_FAMILY.get(k, k)
549
+ kinds[k] = kinds.get(k, 0) + v
550
+ return kinds
551
+
552
+
553
+ def _from_floor(red, found, extra, one_pass):
554
+ """Joining as _join_first does, but from the output of one pass over the text as it stands."""
555
+ founds = [found]
556
+ for _ in range(4):
557
+ joined = _rejoin_secrets(red, extra)
558
+ if joined == red:
559
+ break
560
+ again, found = one_pass(joined)
561
+ if again == red:
562
+ break
563
+ red = again
564
+ founds.append(found)
565
+ return red, founds
566
+
567
+
568
+ def _hidden_by(floor, base):
569
+ """The stretches of the text it started as that `floor` (whose trail is `base`) hides, in order, each with what
570
+ stands for it in `floor`: [(start, end, marker)]."""
571
+ pos, out, i, n = base.pos, [], 0, len(floor)
572
+ while i < n:
573
+ if pos[i] >= 0:
574
+ i += 1
575
+ continue
576
+ j = i
577
+ while j < n and pos[j] < 0:
578
+ j += 1
579
+ out.append((pos[i - 1] + 1 if i else 0, pos[j] if j < n else base.n, floor[i:j]))
580
+ i = j
581
+ return out
582
+
583
+
584
+ def _patched(red, trail, shown, hidden):
585
+ """`red` (whose trail is `trail`) with each run of characters it shows that `shown` has hidden put under what
586
+ stands for them in that text (`hidden`, from _hidden_by), and how many markers that took by name."""
587
+ pos, starts = trail.pos, [lo for lo, _, _ in hidden]
588
+ out, counts, at, i = [], {}, 0, 0
589
+ while i < len(red):
590
+ if pos[i] < 0 or shown[pos[i]]:
591
+ i += 1
592
+ continue
593
+ j = i + 1
594
+ while j < len(red) and pos[j] >= 0 and not shown[pos[j]]:
595
+ j += 1
596
+ marker = hidden[bisect.bisect_right(starts, pos[i]) - 1][2]
597
+ name = re.fullmatch(r"\[REDACTED:(\w+)\]", marker)
598
+ if name:
599
+ counts[name.group(1)] = counts.get(name.group(1), 0) + 1
600
+ out += [red[at:i], marker]
601
+ at = i = j
602
+ return "".join(out) + red[at:], counts
603
+
604
+
605
+ def _with_rejoins(text, extra, one_pass):
606
+ """_join_first, with each character it shows that one pass over the text as it stands hid (3.0.7 did that one
607
+ pass) put back under that pass's marker; but if it hides fewer values of some kind, the joining starts from that
608
+ pass's output instead. Each pass only hides more, so no character 3.0.7 hid shows (3.0.8). Returns (text,
609
+ [found])."""
610
+ red, founds, trail = _join_first(text, extra, one_pass)
611
+ if trail is None:
612
+ return red, founds # nothing joined: that was the one pass over the text as it stands
613
+ base = _Trail(len(text))
614
+ floor, found = one_pass(text, base)
615
+ joined = _kinds(founds)
616
+ if not all(joined.get(k, 0) >= v for k, v in _kinds([found]).items()):
617
+ return _from_floor(floor, found, extra, one_pass)
618
+ shown = base.shown()
619
+ if not _shows_more(trail, shown):
620
+ return red, founds
621
+ patched, put_back = _patched(red, trail, shown, _hidden_by(floor, base))
622
+ again, found = one_pass(patched)
623
+ out, more = _from_floor(again, found, extra, one_pass)
624
+ return out, founds + [put_back] + more
625
+
626
+
627
+ def _review_pass(text, extra, trail=None):
628
+ pre, counts = _reviewer_patterns(text, trail)
629
+ red, found = redact(pre, extra=extra, mode="full", trail=trail)
630
+ for k, v in found.items():
631
+ counts[k] = counts.get(k, 0) + v
632
+ return red, counts
633
+
634
+
329
635
  def review_redact(text, extra=None):
330
636
  """Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
331
637
  are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
332
- redacted (never masked, whatever --redact-mode says). Returns (text, count)."""
333
- pre, counts = _reviewer_patterns(text)
334
- red, found = redact(pre, extra=extra, mode="full")
335
- return red, sum(counts.values()) + sum(found.values())
638
+ redacted (never masked, whatever --redact-mode says). A secret a splitter cuts is put back
639
+ together (3.0.8). Returns (text, count)."""
640
+ red, founds = _with_rejoins(text, extra, lambda t, trail=None: _review_pass(t, extra, trail))
641
+ return red, sum(sum(f.values()) for f in founds)
642
+
643
+
644
+ def _page_pass(text, extra, mode, trail=None):
645
+ pre, counts = _reviewer_patterns(text, trail)
646
+ red, found = redact(pre, extra=extra, mode=mode, trail=trail)
647
+ for k, v in found.items():
648
+ counts[k] = counts.get(k, 0) + v
649
+ return red, counts
336
650
 
337
651
 
338
652
  def page_redact(text, extra=None, mode="full"):
339
653
  """Text as every page shows it (3.0.2): the reviewer patterns first, while keys are whole,
340
654
  each hit replaced whole whatever --redact-mode says (a mask keeps 4+4 characters, most of
341
- a short password); then the page's redaction with this run's mode and keywords. Returns
342
- (text, count_by_kind); a second pass over its output finds nothing."""
343
- pre, counts = _reviewer_patterns(text)
344
- red, found = redact(pre, extra=extra, mode=mode)
345
- for k, v in found.items():
346
- counts[k] = counts.get(k, 0) + v
655
+ a short password); then the page's redaction with this run's mode and keywords. A secret a
656
+ splitter cuts is put back together (3.0.8). Returns (text, count_by_kind). A second pass
657
+ over its output finds nothing, but for two values glued with no separator at all (a key
658
+ run into `Bearer`), where the second shows only once the first is a marker, as in 3.0.7."""
659
+ red, founds = _with_rejoins(text, extra, lambda t, trail=None: _page_pass(t, extra, mode, trail))
660
+ counts = {}
661
+ for found in founds:
662
+ for k, v in found.items():
663
+ counts[k] = counts.get(k, 0) + v
347
664
  return red, counts
348
665
 
349
666
 
@@ -209,4 +209,5 @@ argument-hint: "on | off | status"
209
209
  | 쓰는 사이에 같은 이름이 생겨 그대로 두었습니다 | 다른 프로그램이 그 사이에 같은 이름을 만들었음(링크일 수 있음). 지우거나 바꾸지 않음. 무엇인지 보이고(링크면 `ls -l` 로 링크와 대상 경로만 보이고 대상 내용은 열지 않음) 어떻게 할지 물은 뒤 다시 실행 |
210
210
  | 일반 파일이 아님 | 지침 파일 자리(AGENTS.md, AGENTS.override.md)의 폴더나 특수 파일은 지우지 않음. 무엇인지 사용자에게 보이고, 옮기거나 이름을 바꿀지 물은 뒤 다시 실행 |
211
211
  | 다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다 | 다른 banker 스킬의 `on`, `off` 가 도는 중. 잠시 뒤 다시 실행. 끝나지 않은 작업이 남긴 잠금은 1분 뒤 저절로 넘어감. 1분이 지나도 같거나 잠금 자리가 폴더면 잠금 경로를 보이고 어떻게 할지 물은 뒤 다시 실행. 잠금을 임의로 지우지 않음 |
212
+ | 네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다 | Codex 폴더가 NFS 같은 네트워크 공유에 있고 공유가 파일 핸들을 잃음. 잠시 뒤 다시 실행. 계속되면 공유 연결 상태를 확인하도록 알림. 잠금을 임의로 지우지 않음 |
212
213
  | 쓸 권한이 없음 | 해당 파일과 폴더 권한 확인 |
@@ -529,55 +529,67 @@ const LOCK_STALE_MS = 60_000;
529
529
  const LOCK_STEP_MS = 50;
530
530
  const SLEEP = new Int32Array(new SharedArrayBuffer(4));
531
531
 
532
- // Makes the lock file; false when one is there already. Anything else (permissions) throws.
532
+ // Makes the lock file: "taken", "held" when one is there already, or "stale" when a network share answered with a
533
+ // stale file handle (ESTALE), which the next try may not. Anything else (permissions) throws.
533
534
  function tryLock(path, token) {
534
535
  try {
535
536
  writeFileSync(path, token, { flag: "wx", mode: 0o600 });
536
- return true;
537
+ return "taken";
537
538
  } catch (e) {
538
- if (e.code === "EEXIST") return false;
539
+ if (e.code === "EEXIST") return "held";
540
+ if (e.code === "ESTALE") return "stale";
539
541
  throw e;
540
542
  }
541
543
  }
542
544
 
543
- // Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
544
- // looked at, not where it leads: a link to nothing or a link loop there is a lock like any other. A lock dated more
545
- // than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
546
- // and the wait ends in a refusal.
545
+ // Removes a lock a dead run left. The name itself is looked at, not where it leads: a link to nothing or a link loop
546
+ // there is a lock like any other. A lock dated more than a minute away either way (a clock set back) is stale too.
547
+ // A folder there is none of this module's: it stays, and the wait ends in a refusal. A name gone in between, or a
548
+ // network share's stale file handle (ESTALE) at the look or the removal, is left for the next round.
547
549
  function dropStale(path) {
548
550
  let st;
549
551
  try {
550
552
  st = lstatSync(path);
551
553
  } catch (e) {
552
- if (e.code === "ENOENT") return true; /* gone in between */
553
- if (e.code === "ESTALE") return false; /* a network share's stale handle: rest and look again, not spin */
554
+ if (e.code === "ENOENT" || e.code === "ESTALE") return;
554
555
  throw e;
555
556
  }
556
- if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
557
- rmSync(path, { force: true });
558
- return true;
557
+ if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return;
558
+ try {
559
+ rmSync(path, { force: true });
560
+ } catch (e) {
561
+ if (e.code !== "ESTALE") throw e;
562
+ }
559
563
  }
560
564
 
561
- // Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
562
- // round, so no lock spot, whatever is there, keeps the wait going past it.
565
+ // Takes the lock: { path, token }, or { path, why } once the wait ends without it: why is "stale" when the last try
566
+ // met a stale file handle instead of a lock, else "held". Every round that does not take the lock rests, so no answer
567
+ // of the file system makes the wait spin; the deadline is looked at every round, so no lock spot keeps it going past.
563
568
  function takeLock(dir, waitMs) {
564
569
  const path = join(dir, LOCK);
565
570
  const token = `${process.pid} ${Date.now()} ${Math.random()}`;
566
571
  const until = Date.now() + waitMs;
567
572
  for (;;) {
568
- if (tryLock(path, token)) return { path, token };
569
- if (Date.now() >= until) return null;
570
- if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
573
+ const got = tryLock(path, token);
574
+ if (got === "taken") return { path, token };
575
+ if (Date.now() >= until) return { path, why: got };
576
+ if (got === "held") dropStale(path);
577
+ Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
571
578
  }
572
579
  }
573
580
 
574
581
  // Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
575
- // this run's lock and stays.
582
+ // this run's lock and stays. A network share's stale file handle (ESTALE) is tried again after a rest, three times
583
+ // at most; a lock still left goes stale in a minute.
576
584
  function dropLock(lock) {
577
- try {
578
- if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
579
- } catch {
580
- /* already gone */
585
+ for (let tried = 1; ; tried++) {
586
+ try {
587
+ if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
588
+ return;
589
+ } catch (e) {
590
+ if (e.code !== "ESTALE" || tried >= 3) return; /* already gone, or not this run's to take away */
591
+ Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
592
+ }
581
593
  }
582
594
  }
583
595
 
@@ -592,7 +604,11 @@ function locked(o, fn) {
592
604
  if (denied(e)) return fn();
593
605
  throw e;
594
606
  }
595
- if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
607
+ if (!lock.token) {
608
+ return refuse(lock.why === "stale"
609
+ ? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
610
+ : `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
611
+ }
596
612
  try {
597
613
  return fn();
598
614
  } finally {