@cheongfish/bouncer 0.0.0-stage → 0.1.13
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/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
// Cursor가 대화를 담아 두는 ItemTable key. 이 밖의 key는 대화가 아니므로 읽지 않는다.
|
|
5
|
+
const CURSOR_KEYS = [
|
|
6
|
+
'workbench.panel.aichat.view.aichat.chatdata',
|
|
7
|
+
'aiService.prompts',
|
|
8
|
+
'composer.composerData',
|
|
9
|
+
];
|
|
10
|
+
const UNSUPPORTED = { ok: false, reason: 'unsupported-schema' };
|
|
11
|
+
/**
|
|
12
|
+
* 값이 null이 아닌 일반 객체인지 판정한다. JSON 줄의 모양이 제각각이라
|
|
13
|
+
* 접근 전에 항상 이 가드를 거친다.
|
|
14
|
+
*
|
|
15
|
+
* @param {unknown} v - 검사할 값
|
|
16
|
+
* @returns {boolean} 객체(배열 제외)이면 true
|
|
17
|
+
*/
|
|
18
|
+
function isObj(v) {
|
|
19
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Claude `tool_result`의 content에서 텍스트를 뽑는다. 문자열은 그대로, 배열은
|
|
23
|
+
* 그 안의 `text` 문자열을 개행으로 잇는다. 그 밖의 모양은 텍스트가 없는 것이다.
|
|
24
|
+
*
|
|
25
|
+
* @param {unknown} content - `tool_result.content`
|
|
26
|
+
* @returns {string} 추출한 텍스트(없으면 빈 문자열)
|
|
27
|
+
*/
|
|
28
|
+
function toolResultText(content) {
|
|
29
|
+
if (typeof content === 'string')
|
|
30
|
+
return content;
|
|
31
|
+
if (!Array.isArray(content))
|
|
32
|
+
return '';
|
|
33
|
+
return content
|
|
34
|
+
.filter((c) => isObj(c) && typeof c.text === 'string')
|
|
35
|
+
.map((c) => c.text)
|
|
36
|
+
.join('\n');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Claude Code 메시지 줄(`type`이 user·assistant)에서 메시지를 뽑는다.
|
|
40
|
+
* 토큰 사용량·첨부 같은 텍스트 외 필드는 의도적으로 버린다.
|
|
41
|
+
*
|
|
42
|
+
* @param {Json} obj - 파싱된 JSONL 줄
|
|
43
|
+
* @returns {Message[]} text 메시지 다음 tool_error 메시지 순서의 목록(없으면 빈 배열)
|
|
44
|
+
*/
|
|
45
|
+
function claudeMessages(obj) {
|
|
46
|
+
const role = obj.type;
|
|
47
|
+
const content = obj.message.content;
|
|
48
|
+
if (typeof content === 'string')
|
|
49
|
+
return [{ role, text: content }];
|
|
50
|
+
if (!Array.isArray(content))
|
|
51
|
+
return [];
|
|
52
|
+
const out = [];
|
|
53
|
+
const texts = content
|
|
54
|
+
.filter((c) => isObj(c) && c.type === 'text' && typeof c.text === 'string')
|
|
55
|
+
.map((c) => c.text);
|
|
56
|
+
if (texts.length > 0)
|
|
57
|
+
out.push({ role, text: texts.join('\n') });
|
|
58
|
+
for (const c of content) {
|
|
59
|
+
if (isObj(c) && c.type === 'tool_result' && c.is_error === true) {
|
|
60
|
+
out.push({ role: 'tool_error', text: toolResultText(c.content) });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Codex `response_item`/`message` 줄에서 메시지 하나를 만든다. developer·system
|
|
67
|
+
* 같은 역할은 사용자·모델 발화가 아니므로 `unknown`으로 내려 구분한다.
|
|
68
|
+
*
|
|
69
|
+
* @param {Json} payload - 줄의 `payload`
|
|
70
|
+
* @returns {Message[]} 텍스트가 있으면 메시지 하나, 없으면 빈 배열
|
|
71
|
+
*/
|
|
72
|
+
function codexMessages(payload) {
|
|
73
|
+
const content = Array.isArray(payload.content) ? payload.content : [];
|
|
74
|
+
const texts = content
|
|
75
|
+
.filter((c) => isObj(c) && (c.type === 'input_text' || c.type === 'output_text') && typeof c.text === 'string')
|
|
76
|
+
.map((c) => c.text);
|
|
77
|
+
if (texts.length === 0)
|
|
78
|
+
return [];
|
|
79
|
+
const role = payload.role === 'user' || payload.role === 'assistant' ? payload.role : 'unknown';
|
|
80
|
+
return [{ role, text: texts.join('\n') }];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* JSONL 한 줄을 분류하고 정규화한다. `invalid`는 JSON 파싱 실패만, `ignored`는
|
|
84
|
+
* 메시지 줄이 아닌 JSON(session_meta, summary 등)만 뜻한다. 메시지 줄이지만
|
|
85
|
+
* 텍스트가 없으면 빈 messages로 돌려 host 판정에는 쓰이되 목록에는 기여하지 않는다.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} line - 원시 JSONL 한 줄
|
|
88
|
+
* @returns {JsonlLine} invalid / ignored / messages(host 포함)
|
|
89
|
+
*/
|
|
90
|
+
function normalizeJsonlLine(line) {
|
|
91
|
+
let obj;
|
|
92
|
+
try {
|
|
93
|
+
obj = JSON.parse(line);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// JSON.parse는 SyntaxError만 던진다. 깨진 줄은 세어 보고하되 전체 읽기는 계속한다.
|
|
97
|
+
if (err instanceof SyntaxError)
|
|
98
|
+
return { kind: 'invalid' };
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
if (!isObj(obj))
|
|
102
|
+
return { kind: 'ignored' };
|
|
103
|
+
if ((obj.type === 'user' || obj.type === 'assistant') && isObj(obj.message)) {
|
|
104
|
+
return { kind: 'messages', host: 'claude-code', messages: claudeMessages(obj) };
|
|
105
|
+
}
|
|
106
|
+
if (obj.type === 'response_item' && isObj(obj.payload) && obj.payload.type === 'message') {
|
|
107
|
+
return { kind: 'messages', host: 'codex', messages: codexMessages(obj.payload) };
|
|
108
|
+
}
|
|
109
|
+
return { kind: 'ignored' };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 값을 문서 순서로 훑어 `text` 문자열 필드를 모은다. Cursor 저장소는 스키마가
|
|
113
|
+
* 버전마다 달라 고정 경로 대신 모든 깊이의 `text`를 대상으로 한다.
|
|
114
|
+
*
|
|
115
|
+
* @param {unknown} v - 파싱된 JSON 값
|
|
116
|
+
* @param {Message[]} out - 메시지를 누적할 목록
|
|
117
|
+
* @returns {void}
|
|
118
|
+
*/
|
|
119
|
+
function collectTexts(v, out) {
|
|
120
|
+
if (Array.isArray(v)) {
|
|
121
|
+
for (const x of v)
|
|
122
|
+
collectTexts(x, out);
|
|
123
|
+
}
|
|
124
|
+
else if (isObj(v)) {
|
|
125
|
+
for (const [k, x] of Object.entries(v)) {
|
|
126
|
+
if (k === 'text' && typeof x === 'string')
|
|
127
|
+
out.push({ role: 'unknown', text: x });
|
|
128
|
+
else
|
|
129
|
+
collectTexts(x, out);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Cursor `state.vscdb`를 읽기 전용으로 열어 메시지를 뽑는다. `node:sqlite`는
|
|
135
|
+
* 이 경로에서만 필요하므로 lazy require해 다른 형식의 시작 비용을 늘리지 않는다.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} filePath - `.vscdb` 경로
|
|
138
|
+
* @returns {Message[] | null} 메시지 목록. 대상 key가 없거나 SQLite가 아니면 null
|
|
139
|
+
*/
|
|
140
|
+
function readCursor(filePath) {
|
|
141
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
142
|
+
const messages = [];
|
|
143
|
+
let found = false;
|
|
144
|
+
let db;
|
|
145
|
+
try {
|
|
146
|
+
db = new DatabaseSync(filePath, { readOnly: true });
|
|
147
|
+
const stmt = db.prepare('SELECT value FROM ItemTable WHERE key = ?');
|
|
148
|
+
for (const key of CURSOR_KEYS) {
|
|
149
|
+
const row = stmt.get(key);
|
|
150
|
+
if (!row)
|
|
151
|
+
continue;
|
|
152
|
+
found = true;
|
|
153
|
+
try {
|
|
154
|
+
collectTexts(JSON.parse(String(row.value)), messages);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// 값이 JSON이 아닌 행은 건너뛴다. SyntaxError만 흡수하고 나머지는 던진다.
|
|
158
|
+
if (!(err instanceof SyntaxError))
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
// SQLite 파일이 아니거나 ItemTable이 없는 경우(ERR_SQLITE_ERROR)만 스키마 불일치로 본다.
|
|
165
|
+
// 다른 오류(권한 등)를 이 사유로 뭉개지 않도록 코드를 확인한다.
|
|
166
|
+
if (err.code === 'ERR_SQLITE_ERROR')
|
|
167
|
+
return null;
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
if (db)
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
174
|
+
return found ? messages : null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* 합계가 상한을 넘으면 가장 최근 메시지부터 남긴다. retro는 대화 끝쪽(최근 실패)이
|
|
178
|
+
* 더 중요하므로 앞을 버린다. 최근 메시지 하나가 혼자 상한을 넘으면 그 꼬리만 남긴다.
|
|
179
|
+
*
|
|
180
|
+
* @param {Message[]} messages - 시간순 메시지
|
|
181
|
+
* @param {number} maxChars - 텍스트 글자 수 상한
|
|
182
|
+
* @returns {{ messages: Message[], truncated: boolean }} 상한 적용 결과
|
|
183
|
+
*/
|
|
184
|
+
function applyCap(messages, maxChars) {
|
|
185
|
+
const total = messages.reduce((n, m) => n + m.text.length, 0);
|
|
186
|
+
if (total <= maxChars)
|
|
187
|
+
return { messages, truncated: false };
|
|
188
|
+
const kept = [];
|
|
189
|
+
let used = 0;
|
|
190
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
191
|
+
const m = messages[i];
|
|
192
|
+
if (used + m.text.length > maxChars) {
|
|
193
|
+
// 가장 최근 메시지가 혼자 상한을 넘는 경우에만 꼬리를 잘라 남긴다.
|
|
194
|
+
if (kept.length === 0)
|
|
195
|
+
kept.push({ role: m.role, text: m.text.slice(-maxChars) });
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
kept.unshift(m);
|
|
199
|
+
used += m.text.length;
|
|
200
|
+
}
|
|
201
|
+
return { messages: kept, truncated: true };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* transcript 하나를 `{ role, text }` 목록으로 정규화한다. 파일을 쓰지 않는다.
|
|
205
|
+
* 확장자 `.vscdb`는 Cursor SQLite, `.jsonl`은 첫 메시지 줄의 모양으로 host를 정한다.
|
|
206
|
+
*
|
|
207
|
+
* @param {string} filePath - transcript 경로
|
|
208
|
+
* @param {{ maxChars: number }} opts - maxChars: 텍스트 합계 상한
|
|
209
|
+
* @returns {ReadResult} 성공 시 메시지·truncated·skipped_lines, 실패 시 reason
|
|
210
|
+
* (`unreadable`: 파일 접근 불가, `unsupported-schema`: 알 수 없는 형식이거나 메시지 없음)
|
|
211
|
+
*/
|
|
212
|
+
function readTranscript(filePath, opts) {
|
|
213
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
214
|
+
let raw = null;
|
|
215
|
+
try {
|
|
216
|
+
if (ext === '.jsonl')
|
|
217
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
218
|
+
else
|
|
219
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
// 없음·권한·디렉터리는 모두 "읽을 수 없음"이다. 그 외 오류는 원인을 숨기지 않도록 던진다.
|
|
223
|
+
const code = err.code;
|
|
224
|
+
if (['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'EISDIR'].includes(code)) {
|
|
225
|
+
return { ok: false, reason: 'unreadable' };
|
|
226
|
+
}
|
|
227
|
+
throw err;
|
|
228
|
+
}
|
|
229
|
+
if (ext === '.vscdb') {
|
|
230
|
+
const found = readCursor(filePath);
|
|
231
|
+
if (!found)
|
|
232
|
+
return UNSUPPORTED;
|
|
233
|
+
const capped = applyCap(found, opts.maxChars);
|
|
234
|
+
return { ok: true, host: 'cursor', format: 'sqlite', ...capped, skipped_lines: 0 };
|
|
235
|
+
}
|
|
236
|
+
if (raw === null)
|
|
237
|
+
return UNSUPPORTED;
|
|
238
|
+
let host = null;
|
|
239
|
+
let skipped = 0;
|
|
240
|
+
const messages = [];
|
|
241
|
+
for (const line of raw.split('\n')) {
|
|
242
|
+
if (line.trim() === '')
|
|
243
|
+
continue;
|
|
244
|
+
const r = normalizeJsonlLine(line);
|
|
245
|
+
if (r.kind === 'invalid')
|
|
246
|
+
skipped += 1;
|
|
247
|
+
else if (r.kind === 'messages') {
|
|
248
|
+
// host는 첫 메시지 줄이 정한다. 다른 host 모양의 줄이 섞여도 목록을 오염시키지 않는다.
|
|
249
|
+
host = host ?? r.host;
|
|
250
|
+
if (r.host === host)
|
|
251
|
+
messages.push(...r.messages);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (host === null || messages.length === 0)
|
|
255
|
+
return UNSUPPORTED;
|
|
256
|
+
const capped = applyCap(messages, opts.maxChars);
|
|
257
|
+
return { ok: true, host, format: 'jsonl', ...capped, skipped_lines: skipped };
|
|
258
|
+
}
|
|
259
|
+
module.exports = { readTranscript, normalizeJsonlLine };
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const url = require('node:url');
|
|
5
|
+
// Codex 첫 줄(session_meta)만 읽는다. 본문 전체를 읽지 않으려는 상한이며,
|
|
6
|
+
// 이보다 긴 첫 줄은 해석 불가로 skipped에 남는다.
|
|
7
|
+
const CODEX_META_MAX_BYTES = 64 * 1024;
|
|
8
|
+
/**
|
|
9
|
+
* 파일시스템 오류를 host별 skipped 사유로 바꾼다. 예상한 코드(없음·권한)만
|
|
10
|
+
* 흡수하고 나머지는 그대로 던져 원인이 다른 실패가 한 사유로 뭉개지지 않게 한다.
|
|
11
|
+
*
|
|
12
|
+
* @param {unknown} err - fs 호출이 던진 값
|
|
13
|
+
* @param {Host} host - 실패가 난 host
|
|
14
|
+
* @param {string} target - 실패한 경로(루트 또는 파일)
|
|
15
|
+
* @param {Skipped[]} skipped - 사유를 누적할 목록
|
|
16
|
+
* @returns {void} 흡수하면 skipped에 한 건을 더하고 돌아오며, 모르는 오류는 다시 던진다
|
|
17
|
+
*/
|
|
18
|
+
function absorbFsError(err, host, target, skipped) {
|
|
19
|
+
const code = err?.code;
|
|
20
|
+
// ENOENT/ENOTDIR: 설치되지 않았거나 경로가 디렉터리가 아님 — 루트가 없는 것과 같다.
|
|
21
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
22
|
+
skipped.push({ host, path: target, reason: 'root-missing' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// EACCES/EPERM: 존재는 하나 읽을 수 없음. 없음과 구분해 사용자가 권한을 고치게 한다.
|
|
26
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
27
|
+
skipped.push({ host, path: target, reason: 'permission-denied' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 정규화한 경로 p가 root와 같거나 root 아래인지 판정한다. `/r2`가 `/r` 아래로
|
|
34
|
+
* 오인되지 않도록 구분자를 붙여 비교한다.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} p - 검사할 경로
|
|
37
|
+
* @param {string} root - project root
|
|
38
|
+
* @returns {boolean} 같거나 아래이면 true
|
|
39
|
+
*/
|
|
40
|
+
function isUnderRoot(p, root) {
|
|
41
|
+
const np = path.resolve(p);
|
|
42
|
+
const nr = path.resolve(root);
|
|
43
|
+
return np === nr || np.startsWith(nr + path.sep);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 파일 하나의 stat을 Source로 만든다.
|
|
47
|
+
*
|
|
48
|
+
* @param {Host} host - 출처 host
|
|
49
|
+
* @param {string} file - 절대 경로
|
|
50
|
+
* @param {'jsonl' | 'sqlite'} format - 파일 형식
|
|
51
|
+
* @returns {Source} mtime은 ISO 8601, size는 bytes
|
|
52
|
+
*/
|
|
53
|
+
function toSource(host, file, format) {
|
|
54
|
+
const st = fs.statSync(file);
|
|
55
|
+
return { host, path: file, format, mtime: st.mtime.toISOString(), size: st.size };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Claude Code 프로젝트 디렉터리 이름(slug)을 만든다. Claude Code가 cwd를
|
|
59
|
+
* 디렉터리 이름으로 바꾸는 규칙과 같게 영숫자가 아닌 글자를 모두 `-`로 바꾼다.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} projectRoot - project root 절대 경로
|
|
62
|
+
* @returns {string} 예: `/home/a/repo.x` → `-home-a-repo-x`
|
|
63
|
+
*/
|
|
64
|
+
function claudeProjectSlug(projectRoot) {
|
|
65
|
+
return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Claude Code 후보를 모은다. slug와 같은 이름, 또는 `<slug>--`로 시작하는
|
|
69
|
+
* 이름(`.worktrees` 아래 worktree의 slug)의 디렉터리 바로 아래 `*.jsonl`만 본다.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} root - claude projects 루트
|
|
72
|
+
* @param {string} projectRoot - project root
|
|
73
|
+
* @param {Skipped[]} skipped - 건너뛴 사유 누적 목록
|
|
74
|
+
* @returns {Source[]} 후보 목록(정렬 전)
|
|
75
|
+
*/
|
|
76
|
+
function collectClaude(root, projectRoot, skipped) {
|
|
77
|
+
let names;
|
|
78
|
+
try {
|
|
79
|
+
names = fs.readdirSync(root);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
absorbFsError(err, 'claude-code', root, skipped);
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
const slug = claudeProjectSlug(projectRoot);
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const name of names) {
|
|
88
|
+
if (name !== slug && !name.startsWith(`${slug}--`))
|
|
89
|
+
continue;
|
|
90
|
+
const dir = path.join(root, name);
|
|
91
|
+
let files;
|
|
92
|
+
try {
|
|
93
|
+
files = fs.readdirSync(dir);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
absorbFsError(err, 'claude-code', dir, skipped);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const f of files) {
|
|
100
|
+
if (f.endsWith('.jsonl'))
|
|
101
|
+
out.push(toSource('claude-code', path.join(dir, f), 'jsonl'));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Codex rollout 파일의 첫 줄만 읽어 JSON으로 해석한다. 본문은 읽지 않는다.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} file - rollout jsonl 절대 경로
|
|
110
|
+
* @returns {unknown} 해석한 첫 줄. 줄이 없거나 JSON이 아니면 undefined
|
|
111
|
+
*/
|
|
112
|
+
function readFirstLineJson(file) {
|
|
113
|
+
const fd = fs.openSync(file, 'r');
|
|
114
|
+
let text;
|
|
115
|
+
try {
|
|
116
|
+
const buf = Buffer.alloc(CODEX_META_MAX_BYTES);
|
|
117
|
+
const n = fs.readSync(fd, buf, 0, CODEX_META_MAX_BYTES, 0);
|
|
118
|
+
text = buf.subarray(0, n).toString('utf8');
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
fs.closeSync(fd);
|
|
122
|
+
}
|
|
123
|
+
const line = text.split('\n', 1)[0];
|
|
124
|
+
try {
|
|
125
|
+
return JSON.parse(line);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
// JSON.parse는 SyntaxError만 던진다. 첫 줄이 깨진 파일은 해석 불가로
|
|
129
|
+
// 보고하는 것이 목적이라 흡수하고, 다른 오류가 끼어들 자리는 없다.
|
|
130
|
+
if (err instanceof SyntaxError)
|
|
131
|
+
return undefined;
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Codex sessions 루트 아래 모든 `*.jsonl`을 재귀로 훑어 cwd가 project root
|
|
137
|
+
* 아래인 파일을 후보로 모은다.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} root - codex sessions 루트
|
|
140
|
+
* @param {string} projectRoot - project root
|
|
141
|
+
* @param {Skipped[]} skipped - 건너뛴 사유 누적 목록
|
|
142
|
+
* @returns {Source[]} 후보 목록(정렬 전)
|
|
143
|
+
*/
|
|
144
|
+
function collectCodex(root, projectRoot, skipped) {
|
|
145
|
+
const out = [];
|
|
146
|
+
const walk = (dir) => {
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
absorbFsError(err, 'codex', dir, skipped);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
for (const e of entries) {
|
|
156
|
+
const full = path.join(dir, e.name);
|
|
157
|
+
if (e.isDirectory()) {
|
|
158
|
+
walk(full);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!e.isFile() || !e.name.endsWith('.jsonl'))
|
|
162
|
+
continue;
|
|
163
|
+
let meta;
|
|
164
|
+
try {
|
|
165
|
+
meta = readFirstLineJson(full);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
absorbFsError(err, 'codex', full, skipped);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const cwd = meta?.payload?.cwd;
|
|
172
|
+
if (typeof cwd !== 'string') {
|
|
173
|
+
skipped.push({ host: 'codex', path: full, reason: 'unparseable-session-meta' });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (isUnderRoot(cwd, projectRoot))
|
|
177
|
+
out.push(toSource('codex', full, 'jsonl'));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
walk(root);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Cursor workspaceStorage를 훑어 `workspace.json`의 folder가 project root 아래인
|
|
185
|
+
* 작업공간의 `state.vscdb`를 후보로 모은다.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} root - cursor workspaceStorage 루트
|
|
188
|
+
* @param {string} projectRoot - project root
|
|
189
|
+
* @param {Skipped[]} skipped - 건너뛴 사유 누적 목록
|
|
190
|
+
* @returns {Source[]} 후보 목록(정렬 전)
|
|
191
|
+
*/
|
|
192
|
+
function collectCursor(root, projectRoot, skipped) {
|
|
193
|
+
let names;
|
|
194
|
+
try {
|
|
195
|
+
names = fs.readdirSync(root);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
absorbFsError(err, 'cursor', root, skipped);
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const name of names) {
|
|
203
|
+
const dir = path.join(root, name);
|
|
204
|
+
let parsed;
|
|
205
|
+
try {
|
|
206
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8'));
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
// workspace.json이 없거나(ENOENT/ENOTDIR) 깨진 작업공간은 이 프로젝트와
|
|
210
|
+
// 무관한 항목이다. skipped 사유 enum에 Cursor용 해석 불가가 없어 조용히 건너뛰고,
|
|
211
|
+
// 권한 오류 등 그 밖의 코드는 숨기지 않고 던진다.
|
|
212
|
+
const code = err.code;
|
|
213
|
+
if (code === 'ENOENT' || code === 'ENOTDIR' || err instanceof SyntaxError)
|
|
214
|
+
continue;
|
|
215
|
+
absorbFsError(err, 'cursor', dir, skipped);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
// JSON `null`이나 배열·원시값처럼 객체가 아닌 workspace.json은 `.folder` 접근이
|
|
219
|
+
// TypeError를 던지므로, try 밖에서 형태를 확인해 folder 없음과 같게 건너뛴다.
|
|
220
|
+
const folder = parsed !== null && typeof parsed === 'object'
|
|
221
|
+
? parsed.folder
|
|
222
|
+
: undefined;
|
|
223
|
+
if (typeof folder !== 'string' || !folder.startsWith('file://'))
|
|
224
|
+
continue;
|
|
225
|
+
let folderPath;
|
|
226
|
+
try {
|
|
227
|
+
folderPath = url.fileURLToPath(folder);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
// 이 플랫폼에서 해석할 수 없는 file URI(예: 다른 OS의 드라이브 경로)는
|
|
231
|
+
// 현재 project root와 일치할 수 없으므로 건너뛴다. POSIX에서 호스트가 든
|
|
232
|
+
// `file://host/...`(ERR_INVALID_FILE_URL_HOST)도 로컬 project root일 수 없어 같다.
|
|
233
|
+
// 그 밖의 오류는 던진다.
|
|
234
|
+
const code = err.code;
|
|
235
|
+
if (code === 'ERR_INVALID_FILE_URL_PATH' || code === 'ERR_INVALID_FILE_URL_HOST'
|
|
236
|
+
|| code === 'ERR_INVALID_URL')
|
|
237
|
+
continue;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
if (!isUnderRoot(folderPath, projectRoot))
|
|
241
|
+
continue;
|
|
242
|
+
const db = path.join(dir, 'state.vscdb');
|
|
243
|
+
try {
|
|
244
|
+
out.push(toSource('cursor', db, 'sqlite'));
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
// state.vscdb가 아직 없는 작업공간은 읽을 transcript가 없는 것이다.
|
|
248
|
+
if (err.code === 'ENOENT')
|
|
249
|
+
continue;
|
|
250
|
+
absorbFsError(err, 'cursor', db, skipped);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* 현재 프로젝트에 속한 Claude Code·Codex·Cursor transcript 후보를 나열한다.
|
|
257
|
+
* transcript 본문은 읽지 않으며(Codex 첫 줄 메타만), host별 실패는 전체 실패가
|
|
258
|
+
* 아니라 skipped에 남긴다. 루트는 있으나 일치 항목이 없는 host는 `no-project-match`다.
|
|
259
|
+
*
|
|
260
|
+
* @param {object} opts - 탐색 입력
|
|
261
|
+
* @param {string} opts.projectRoot - project root 절대 경로
|
|
262
|
+
* @param {Roots} opts.roots - host별 transcript 루트(테스트는 임시 디렉터리를 주입)
|
|
263
|
+
* @param {number} opts.limit - 최대 후보 수(호출자가 1..200으로 검증)
|
|
264
|
+
* @returns {{project_root: string, sources: Source[], skipped: Skipped[]}}
|
|
265
|
+
* sources는 mtime 내림차순으로 limit개까지
|
|
266
|
+
*/
|
|
267
|
+
function listRetroSources(opts) {
|
|
268
|
+
const { projectRoot, roots, limit } = opts;
|
|
269
|
+
const skipped = [];
|
|
270
|
+
const hosts = [
|
|
271
|
+
['claude-code', roots.claude, collectClaude],
|
|
272
|
+
['codex', roots.codex, collectCodex],
|
|
273
|
+
['cursor', roots.cursor, collectCursor],
|
|
274
|
+
];
|
|
275
|
+
const all = [];
|
|
276
|
+
for (const [host, root, collect] of hosts) {
|
|
277
|
+
const before = skipped.length;
|
|
278
|
+
const found = collect(root, projectRoot, skipped);
|
|
279
|
+
all.push(...found);
|
|
280
|
+
// 루트 접근 실패(root-missing/permission-denied)와 겹치지 않도록, 그 사유가
|
|
281
|
+
// 이 host에 이미 기록된 경우에는 no-project-match를 더하지 않는다.
|
|
282
|
+
const rootFailed = skipped.slice(before).some((s) => s.host === host && s.path === root);
|
|
283
|
+
if (found.length === 0 && !rootFailed) {
|
|
284
|
+
skipped.push({ host, path: root, reason: 'no-project-match' });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// mtime 동률은 경로로 고정해 같은 입력이 같은 순서를 내게 한다.
|
|
288
|
+
all.sort((a, b) => (a.mtime < b.mtime ? 1 : a.mtime > b.mtime ? -1 : a.path.localeCompare(b.path)));
|
|
289
|
+
return { project_root: projectRoot, sources: all.slice(0, limit), skipped };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* host별 기본 transcript 루트를 정한다. 호출자가 home·platform·env를 주입해
|
|
293
|
+
* 테스트가 실제 home을 읽지 않는다.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} opts - 입력
|
|
296
|
+
* @param {string} opts.homeDir - 사용자 home
|
|
297
|
+
* @param {NodeJS.Platform} opts.platform - Cursor 기본 경로를 가르는 플랫폼
|
|
298
|
+
* @param {Record<string, string | undefined>} opts.env - CLAUDE_CONFIG_DIR·CODEX_HOME·APPDATA를 읽는 환경
|
|
299
|
+
* @returns {Roots} claude/codex/cursor 루트
|
|
300
|
+
*/
|
|
301
|
+
function defaultRetroRoots(opts) {
|
|
302
|
+
const { homeDir, platform, env } = opts;
|
|
303
|
+
const claude = path.join(env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude'), 'projects');
|
|
304
|
+
const codex = path.join(env.CODEX_HOME || path.join(homeDir, '.codex'), 'sessions');
|
|
305
|
+
let cursorBase;
|
|
306
|
+
if (platform === 'darwin') {
|
|
307
|
+
cursorBase = path.join(homeDir, 'Library', 'Application Support');
|
|
308
|
+
}
|
|
309
|
+
else if (platform === 'win32') {
|
|
310
|
+
// APPDATA가 없으면 Windows 기본 위치(home 아래 AppData/Roaming)로 추정한다.
|
|
311
|
+
cursorBase = env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
cursorBase = path.join(homeDir, '.config');
|
|
315
|
+
}
|
|
316
|
+
return { claude, codex, cursor: path.join(cursorBase, 'Cursor', 'User', 'workspaceStorage') };
|
|
317
|
+
}
|
|
318
|
+
module.exports = { listRetroSources, defaultRetroRoots, claudeProjectSlug };
|