@kaydash9999/banker-plugins 0.19.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/hooks/update-checkin.mjs +96 -13
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +2 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +39 -22
- package/skills/lineage/SKILL.md +3 -1
- package/skills/lineage/lineage.py +406 -37
- package/skills/tone-compact/SKILL.md +1 -0
- package/skills/tone-compact/scripts/instruction-toggle.mjs +39 -22
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.19.
|
|
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,24 @@
|
|
|
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
|
+
|
|
10
|
+
## [0.19.2] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `anti-drm` SKILL: 종료 코드 1 행에 `앞서 바꾼 파일:` 줄이 있으면 그 파일은 이미 바뀌었다고 적는다(tone-compact 와 같게).
|
|
14
|
+
- 유지보수자용: SKILL 과 명령 문서의 PowerShell 블록이 Windows PowerShell 5.1 에서 구문 오류 없이 읽히는지 Windows 에서 시험한다. 자리 표시는 괄호 안쪽에 공백이 없는 꼴만 낱말로 바꿔 읽는다(공백 낀 `< in.txt >` 는 5.1 이 거부하는 리디렉션이라 그대로 둠). 알려진 오류 두 줄을 시험이 잡는지 먼저 보고, 파싱하며 코드를 불러오는 블록(`using module`, `using assembly`)은 파싱하지 않고 실패로 알린다. Windows PowerShell 5.1 은 제 경로로 부른다. pwsh 7 은 5.1 보다 넓게 받아 Windows 가 아닌 OS 에서는 건너뛴다.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `lineage` 3.0.7: 짝 없는 서로게이트(U+D800~U+DFFF 가 짝 없이 든 JSON 이스케이프)가 든 전사 줄은 그 글자를 U+FFFD 로 바꾸고 줄마다 한 번 경고한 뒤 계속한다. 검토 묶음, 검토 결정, 결정 캐시를 읽을 때와 페이지와 묶음을 쓸 때(제목이나 파일 이름에서 온 것)도 경고 없이 같은 글자로 바꾼다. 예전에는 기본 흐름의 검토 묶음(`--emit-review`)과 `--rulebase` 페이지가 트레이스백으로 멈췄다. uuid 가 글이 아닌 줄(목록, 객체, 숫자, 참과 거짓)은 경고 뒤 줄 번호로 id 를 만든다(빈 글과 없는 uuid 는 예전처럼 경고 없이). 예전에는 검토 묶음이 멈췄다. 역할이 user, assistant 가 아닌 줄은 기록 종류로 읽는다. 예전에는 묶음 적용이 `malformed turns` 로 멈췄다.
|
|
18
|
+
- `lineage`: 검토자가 쓴 결정 id 와 묶음의 턴 id 는 한 줄로 찍히지 않는 글자가 있으면 이스케이프해 보인다. 예전에는 줄바꿈이나 제어 문자가 든 id 가 `[lineage] PASS:` 같은 가짜 줄을 만들 수 있었다.
|
|
19
|
+
- 업데이트 확인 체크인 훅: 사용 기록을 보낸 직후 비운다. 예전에는 바뀐 스킬 목록을 받는 동안(최대 30초) 비우지 않아, 그 사이 시작한 세션이 같은 기록을 다시 보냈다.
|
|
20
|
+
- `tone-compact`, `anti-drm`: 잠금 자리가 ESTALE 을 계속 내면 쉬면서 다시 보고, 기다림이 끝나면 거부한다. 예전에는 기다리는 동안 쉬지 않고 lstat 을 되풀이해 코어 하나를 썼다. ESTALE 이 한 번만 나는 죽은 잠금은 예전처럼 정리한다.
|
|
21
|
+
|
|
3
22
|
## [0.19.1] - 2026-10-16
|
|
4
23
|
|
|
5
24
|
### Changed
|
package/hooks/update-checkin.mjs
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
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
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,19 +256,28 @@ async function refreshUpdateCache(responseBody) {
|
|
|
182
256
|
try { writeUpdateCache(patch); } catch { /* best-effort */ }
|
|
183
257
|
}
|
|
184
258
|
|
|
185
|
-
// 전송 시도 후(성공/실패 무관)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
try {
|
|
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
|
-
|
|
276
|
+
dropOldClaims();
|
|
277
|
+
const { raw, claim } = claimLog();
|
|
278
|
+
const responseBody = await post(url, checkinBody(raw));
|
|
279
|
+
finishClaim(claim); // 보내는 동안 더해진 줄은 새 로그에 남아 다음 체크인이 보낸다.
|
|
196
280
|
await refreshUpdateCache(responseBody);
|
|
197
|
-
resetLog();
|
|
198
281
|
}
|
|
199
282
|
|
|
200
283
|
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.19.
|
|
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"
|
package/skills/anti-drm/SKILL.md
CHANGED
|
@@ -130,7 +130,7 @@ argument-hint: "on | off | status | config"
|
|
|
130
130
|
| 코드 | 뜻 | 처리 |
|
|
131
131
|
|---|---|---|
|
|
132
132
|
| 0 | 완료. 이미 그 상태였던 경우 포함 | 결과 전달 |
|
|
133
|
-
| 1 | 거부 또는 사용법 오류. 설정 값을 준 `on` 과 `config` 는 설정을 저장한 뒤 갱신하지 못한 런타임이 있어도 1 | 메시지 그대로 전달 |
|
|
133
|
+
| 1 | 거부 또는 사용법 오류. 설정 값을 준 `on` 과 `config` 는 설정을 저장한 뒤 갱신하지 못한 런타임이 있어도 1. 메시지에 `앞서 바꾼 파일:` 이 있으면 그 파일은 이미 바뀜 | 메시지 그대로 전달 |
|
|
134
134
|
| 2 | 예기치 못한 오류 | 메시지 전달 후 `status` 로 확인 |
|
|
135
135
|
|
|
136
136
|
- 출력이 비어 있으면 실행 실패. 성공으로 보고하지 않음
|
|
@@ -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,54 +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
|
|
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
|
|
537
|
+
return "taken";
|
|
537
538
|
} catch (e) {
|
|
538
|
-
if (e.code === "EEXIST") return
|
|
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
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
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" || e.code === "ESTALE") return
|
|
554
|
+
if (e.code === "ENOENT" || e.code === "ESTALE") return;
|
|
553
555
|
throw e;
|
|
554
556
|
}
|
|
555
|
-
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|
|
558
563
|
}
|
|
559
564
|
|
|
560
|
-
// Takes the lock: { path, token }, or
|
|
561
|
-
//
|
|
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.
|
|
562
568
|
function takeLock(dir, waitMs) {
|
|
563
569
|
const path = join(dir, LOCK);
|
|
564
570
|
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
565
571
|
const until = Date.now() + waitMs;
|
|
566
572
|
for (;;) {
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
if (
|
|
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);
|
|
570
578
|
}
|
|
571
579
|
}
|
|
572
580
|
|
|
573
581
|
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
574
|
-
// 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.
|
|
575
584
|
function dropLock(lock) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
}
|
|
580
593
|
}
|
|
581
594
|
}
|
|
582
595
|
|
|
@@ -591,7 +604,11 @@ function locked(o, fn) {
|
|
|
591
604
|
if (denied(e)) return fn();
|
|
592
605
|
throw e;
|
|
593
606
|
}
|
|
594
|
-
if (!lock)
|
|
607
|
+
if (!lock.token) {
|
|
608
|
+
return refuse(lock.why === "stale"
|
|
609
|
+
? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
|
|
610
|
+
: `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
|
|
611
|
+
}
|
|
595
612
|
try {
|
|
596
613
|
return fn();
|
|
597
614
|
} finally {
|
package/skills/lineage/SKILL.md
CHANGED
|
@@ -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.
|
|
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,7 +24,9 @@ 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
|
|
29
|
+
from typing import Any
|
|
26
30
|
|
|
27
31
|
if sys.version_info < (3, 7):
|
|
28
32
|
sys.stderr.write("lineage requires Python 3.7+ (found %s)\n"
|
|
@@ -169,15 +173,187 @@ def _mask(s: str) -> str:
|
|
|
169
173
|
return (s[:4] + "****" + s[-4:]) if len(s) > 8 else "[REDACTED]"
|
|
170
174
|
|
|
171
175
|
|
|
172
|
-
|
|
173
|
-
"""
|
|
174
|
-
|
|
175
|
-
|
|
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)
|
|
176
352
|
for name, pat in SECRET_PATTERNS:
|
|
177
353
|
def repl(m, n=name):
|
|
178
354
|
counts[n] = counts.get(n, 0) + 1
|
|
179
355
|
return _mask(m.group(0)) if mode == "mask" else f"[REDACTED:{n}]"
|
|
180
|
-
out =
|
|
356
|
+
out = sub(pat, repl, out)
|
|
181
357
|
|
|
182
358
|
def ent_repl(m):
|
|
183
359
|
s = m.group(0)
|
|
@@ -185,7 +361,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
185
361
|
counts["entropy"] = counts.get("entropy", 0) + 1
|
|
186
362
|
return _mask(s) if mode == "mask" else "[REDACTED:entropy]"
|
|
187
363
|
return s
|
|
188
|
-
out =
|
|
364
|
+
out = sub(ENTROPY_LONG, ent_repl, out)
|
|
189
365
|
|
|
190
366
|
try:
|
|
191
367
|
from detect_secrets import SecretsCollection
|
|
@@ -203,7 +379,7 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
203
379
|
for s in col[fname]:
|
|
204
380
|
sv = s.secret_value
|
|
205
381
|
if sv and sv in out:
|
|
206
|
-
out =
|
|
382
|
+
out = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", out)
|
|
207
383
|
counts["ds:detect-secrets"] = counts.get(
|
|
208
384
|
"ds:detect-secrets", 0) + 1
|
|
209
385
|
finally:
|
|
@@ -227,8 +403,8 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
|
|
|
227
403
|
# a marker an earlier pass left stays whole: a keyword inside its name is no secret
|
|
228
404
|
pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
|
|
229
405
|
hits = []
|
|
230
|
-
out =
|
|
231
|
-
|
|
406
|
+
out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
|
|
407
|
+
else hits.append(1) or "[REDACTED]", out)
|
|
232
408
|
if hits:
|
|
233
409
|
counts[f"custom:{kw}"] = len(hits)
|
|
234
410
|
return out, counts
|
|
@@ -314,35 +490,177 @@ REVIEW_SECRET_PATTERNS = [
|
|
|
314
490
|
]
|
|
315
491
|
|
|
316
492
|
|
|
317
|
-
def _reviewer_patterns(text):
|
|
493
|
+
def _reviewer_patterns(text, trail=None):
|
|
318
494
|
"""`text` with what the reviewer patterns find replaced whole by [REDACTED:<name>], and the
|
|
319
|
-
count by name."""
|
|
320
|
-
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)
|
|
321
497
|
for name, pat in REVIEW_SECRET_PATTERNS:
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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)
|
|
325
502
|
return text, counts
|
|
326
503
|
|
|
327
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
|
+
|
|
328
635
|
def review_redact(text, extra=None):
|
|
329
636
|
"""Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
|
|
330
637
|
are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
|
|
331
|
-
redacted (never masked, whatever --redact-mode says).
|
|
332
|
-
|
|
333
|
-
red,
|
|
334
|
-
return red, sum(
|
|
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
|
|
335
650
|
|
|
336
651
|
|
|
337
652
|
def page_redact(text, extra=None, mode="full"):
|
|
338
653
|
"""Text as every page shows it (3.0.2): the reviewer patterns first, while keys are whole,
|
|
339
654
|
each hit replaced whole whatever --redact-mode says (a mask keeps 4+4 characters, most of
|
|
340
|
-
a short password); then the page's redaction with this run's mode and keywords.
|
|
341
|
-
(text, count_by_kind)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
346
664
|
return red, counts
|
|
347
665
|
|
|
348
666
|
|
|
@@ -424,6 +742,56 @@ def session_start(path):
|
|
|
424
742
|
KNOWN_TYPES = {"user", "assistant", "agent-setting", "permission-mode", "summary"}
|
|
425
743
|
KNOWN_CONTENT_ITEMS = {"text", "tool_use", "tool_result", "thinking"}
|
|
426
744
|
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") # strip control chars (keeps \t\n)
|
|
745
|
+
# What a JSON escape for a lone surrogate (U+D800 to U+DFFF with no pair) leaves in a str. UTF-8 cannot hold one,
|
|
746
|
+
# so a page, a review pack or a cache key that has it stops with UnicodeEncodeError.
|
|
747
|
+
_LONE_SURROGATE_RE = re.compile("[" + chr(0xD800) + "-" + chr(0xDFFF) + "]")
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _without_lone_surrogates(value: Any) -> Any:
|
|
751
|
+
"""value with each lone surrogate in its strings as U+FFFD, through lists and dicts (keys too). Any, as the
|
|
752
|
+
JSON it takes is: the callers check the shape after."""
|
|
753
|
+
if isinstance(value, str):
|
|
754
|
+
return _LONE_SURROGATE_RE.sub(chr(0xFFFD), value)
|
|
755
|
+
if isinstance(value, list):
|
|
756
|
+
return [_without_lone_surrogates(v) for v in value]
|
|
757
|
+
if isinstance(value, dict):
|
|
758
|
+
return {_without_lone_surrogates(k): _without_lone_surrogates(v) for k, v in value.items()}
|
|
759
|
+
return value
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _lone_to_fffd(value):
|
|
763
|
+
"""A str with each lone surrogate as U+FFFD; anything else as it is (no walk into it)."""
|
|
764
|
+
return _LONE_SURROGATE_RE.sub(chr(0xFFFD), value) if isinstance(value, str) else value
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _shown_id(value):
|
|
768
|
+
"""An id from a transcript or a reviewer as a message shows it: as it is when it prints as one plain line, else its
|
|
769
|
+
repr, so a line break or a control character in it cannot forge a line of lineage's own."""
|
|
770
|
+
return value if isinstance(value, str) and value.isprintable() else repr(value)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _clean_strings(line_no, role, text, tools, ts, uuid_):
|
|
774
|
+
"""A record's role, text, tool counts, timestamp and uuid with each lone surrogate as U+FFFD, after one WARN for
|
|
775
|
+
the line. Only text is touched: a uuid of another shape is replaced later, whole."""
|
|
776
|
+
if not any(isinstance(s, str) and _LONE_SURROGATE_RE.search(s) for s in (role, text, ts, uuid_, *tools)):
|
|
777
|
+
return role, text, tools, ts, uuid_
|
|
778
|
+
print(f"[WARN] line {line_no}: lone surrogate replaced with U+FFFD", file=sys.stderr)
|
|
779
|
+
counts = {}
|
|
780
|
+
for name, n in tools.items():
|
|
781
|
+
name = _lone_to_fffd(name)
|
|
782
|
+
counts[name] = counts.get(name, 0) + n
|
|
783
|
+
clean = _lone_to_fffd
|
|
784
|
+
return clean(role), clean(text), counts, clean(ts), clean(uuid_)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _turn_id(uuid_, line_no, ts):
|
|
788
|
+
"""The record's uuid when it is text, else an id from the line and the timestamp: a missing or empty one as
|
|
789
|
+
before, another shape (a list, an object, a number) after a WARN, since ids are dict keys and joined text."""
|
|
790
|
+
if isinstance(uuid_, str) and uuid_:
|
|
791
|
+
return uuid_
|
|
792
|
+
if uuid_ is not None and uuid_ != "":
|
|
793
|
+
print(f"[WARN] line {line_no}: uuid that is not text replaced by an id from the line", file=sys.stderr)
|
|
794
|
+
return hashlib.sha256(f"{line_no}:{ts}".encode()).hexdigest()[:16]
|
|
427
795
|
|
|
428
796
|
|
|
429
797
|
def _json_object(raw, line_no):
|
|
@@ -534,10 +902,6 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
|
534
902
|
if obj is None or not _is_turn_record(obj, seen_unknown_type, unsafe_schema, line_no):
|
|
535
903
|
continue
|
|
536
904
|
msg = obj.get("message") or {}
|
|
537
|
-
role = msg.get("role") or obj.get("type")
|
|
538
|
-
ts = _stamp(obj.get("timestamp"))
|
|
539
|
-
uuid_ = obj.get("uuid") or hashlib.sha256(
|
|
540
|
-
f"{line_no}:{ts}".encode()).hexdigest()[:16]
|
|
541
905
|
got = _content_parts(msg.get("content"), line_no)
|
|
542
906
|
if got is None:
|
|
543
907
|
continue
|
|
@@ -545,6 +909,11 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
|
545
909
|
text = _CTRL_RE.sub("", "\n".join(p for p in text_parts if p).strip())
|
|
546
910
|
if not text and not tools:
|
|
547
911
|
continue
|
|
912
|
+
role = msg.get("role")
|
|
913
|
+
role = role if role in ("user", "assistant") else obj.get("type") # the type _is_turn_record let through
|
|
914
|
+
role, text, tools, ts, uuid_ = _clean_strings(
|
|
915
|
+
line_no, role, text, tools, _stamp(obj.get("timestamp")), obj.get("uuid"))
|
|
916
|
+
uuid_ = _turn_id(uuid_, line_no, ts)
|
|
548
917
|
|
|
549
918
|
yield {"role": role, "text": text, "ts": ts, "uuid": uuid_,
|
|
550
919
|
"tools": tools, "line_no": line_no,
|
|
@@ -1477,7 +1846,7 @@ def _write_private(path, text):
|
|
|
1477
1846
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
1478
1847
|
try:
|
|
1479
1848
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
1480
|
-
f.write(text)
|
|
1849
|
+
f.write(_lone_to_fffd(text)) # a title or a file name may still bring one: UTF-8 cannot hold it
|
|
1481
1850
|
os.replace(tmp, str(path))
|
|
1482
1851
|
except BaseException:
|
|
1483
1852
|
try:
|
|
@@ -1491,7 +1860,7 @@ def _write_text_lf(path, text):
|
|
|
1491
1860
|
"""Write `text` to `path` as UTF-8 with LF line ends on every OS. Text mode on Windows
|
|
1492
1861
|
would write CRLF, so the same transcript would give a different page there."""
|
|
1493
1862
|
with open(str(path), "w", encoding="utf-8", newline="\n") as f:
|
|
1494
|
-
f.write(text)
|
|
1863
|
+
f.write(_lone_to_fffd(text)) # a title or a file name may still bring one: UTF-8 cannot hold it
|
|
1495
1864
|
|
|
1496
1865
|
|
|
1497
1866
|
def _safe_name(s):
|
|
@@ -1537,7 +1906,7 @@ def read_llm_cache(turn_id, session, digest):
|
|
|
1537
1906
|
keep, summary = got.get("keep"), got.get("summary")
|
|
1538
1907
|
if not (_is_keep(keep) and _is_summary(summary)):
|
|
1539
1908
|
return None
|
|
1540
|
-
return {"keep": keep, "summary": summary}
|
|
1909
|
+
return {"keep": keep, "summary": _lone_to_fffd(summary)}
|
|
1541
1910
|
|
|
1542
1911
|
|
|
1543
1912
|
def write_llm_cache(turn_id, session, digest, keep, summary):
|
|
@@ -1811,7 +2180,7 @@ def _is_pack_turn(x):
|
|
|
1811
2180
|
|
|
1812
2181
|
def _load_pack(path):
|
|
1813
2182
|
try:
|
|
1814
|
-
pack = json.loads(path.read_text(encoding="utf-8"))
|
|
2183
|
+
pack = _without_lone_surrogates(json.loads(path.read_text(encoding="utf-8")))
|
|
1815
2184
|
except (OSError, ValueError, UnicodeDecodeError, RecursionError) as e:
|
|
1816
2185
|
return _bad(f"cannot read review pack {path}: {e}")
|
|
1817
2186
|
if not isinstance(pack, dict) or pack.get("schema") != REVIEW_SCHEMA:
|
|
@@ -1914,10 +2283,10 @@ def _load_decisions(files):
|
|
|
1914
2283
|
why = _decision_problem(d)
|
|
1915
2284
|
if why:
|
|
1916
2285
|
return _bad(f"{f}[{k}]: {why}")
|
|
1917
|
-
cur = merged.setdefault(d["id"], {})
|
|
2286
|
+
cur = merged.setdefault(_lone_to_fffd(d["id"]), {}) # a model's JSON may hold a lone surrogate
|
|
1918
2287
|
for key in ("keep", "summary"):
|
|
1919
2288
|
if d.get(key) is not None:
|
|
1920
|
-
cur[key] = d[key]
|
|
2289
|
+
cur[key] = _lone_to_fffd(d[key])
|
|
1921
2290
|
if items and all(d.get("keep") is None and d.get("summary") is None for d in items):
|
|
1922
2291
|
print(f"[lineage] WARN: {f}: all {len(items)} decisions are null; the rules "
|
|
1923
2292
|
"decide those turns", file=sys.stderr)
|
|
@@ -2044,7 +2413,7 @@ def _check_pack(pack, merged, args):
|
|
|
2044
2413
|
unknown = [i for i in merged if i not in ids]
|
|
2045
2414
|
if unknown:
|
|
2046
2415
|
print(f"[lineage] WARN: decisions name {len(unknown)} turn(s) not in the pack, "
|
|
2047
|
-
f"ignored: {', '.join(unknown[:5])}", file=sys.stderr)
|
|
2416
|
+
f"ignored: {', '.join(_shown_id(i) for i in unknown[:5])}", file=sys.stderr)
|
|
2048
2417
|
_warn_coverage(pack, set(merged) | {x["id"] for x in pack["turns"]
|
|
2049
2418
|
if x["llm"].get("cached")})
|
|
2050
2419
|
if pack.get("redact_mode") in ("full", "mask"):
|
|
@@ -2109,7 +2478,7 @@ def _report_review(stats, turns, pack, typed):
|
|
|
2109
2478
|
print(f"[lineage] note: {stats['flag-kept']} turn(s) a reviewer marked keep: false "
|
|
2110
2479
|
"stay, as --keep-trivia or --keep-tool-only asked", file=sys.stderr)
|
|
2111
2480
|
if typed:
|
|
2112
|
-
print(f"[lineage] WARN: reviewers dropped {len(typed)} typed user turn(s): {', '.join(typed[:10])}"
|
|
2481
|
+
print(f"[lineage] WARN: reviewers dropped {len(typed)} typed user turn(s): {', '.join(_shown_id(i) for i in typed[:10])}"
|
|
2113
2482
|
f"{' ...' if len(typed) > 10 else ''}; tell the user", file=sys.stderr)
|
|
2114
2483
|
|
|
2115
2484
|
|
|
@@ -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,54 +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
|
|
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
|
|
537
|
+
return "taken";
|
|
537
538
|
} catch (e) {
|
|
538
|
-
if (e.code === "EEXIST") return
|
|
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
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
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" || e.code === "ESTALE") return
|
|
554
|
+
if (e.code === "ENOENT" || e.code === "ESTALE") return;
|
|
553
555
|
throw e;
|
|
554
556
|
}
|
|
555
|
-
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}
|
|
558
563
|
}
|
|
559
564
|
|
|
560
|
-
// Takes the lock: { path, token }, or
|
|
561
|
-
//
|
|
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.
|
|
562
568
|
function takeLock(dir, waitMs) {
|
|
563
569
|
const path = join(dir, LOCK);
|
|
564
570
|
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
565
571
|
const until = Date.now() + waitMs;
|
|
566
572
|
for (;;) {
|
|
567
|
-
|
|
568
|
-
if (
|
|
569
|
-
if (
|
|
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);
|
|
570
578
|
}
|
|
571
579
|
}
|
|
572
580
|
|
|
573
581
|
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
574
|
-
// 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.
|
|
575
584
|
function dropLock(lock) {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
+
}
|
|
580
593
|
}
|
|
581
594
|
}
|
|
582
595
|
|
|
@@ -591,7 +604,11 @@ function locked(o, fn) {
|
|
|
591
604
|
if (denied(e)) return fn();
|
|
592
605
|
throw e;
|
|
593
606
|
}
|
|
594
|
-
if (!lock)
|
|
607
|
+
if (!lock.token) {
|
|
608
|
+
return refuse(lock.why === "stale"
|
|
609
|
+
? `네트워크 공유가 잠금 자리에 계속 ESTALE(오래된 파일 핸들)을 돌려줘 그대로 두었습니다: ${where(lock.path)}`
|
|
610
|
+
: `다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(lock.path)}`);
|
|
611
|
+
}
|
|
595
612
|
try {
|
|
596
613
|
return fn();
|
|
597
614
|
} finally {
|