lostvibe 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,25 +16,58 @@ AI 코딩 도구(Claude Code, Codex 등)의 로컬 로그와 git에서 **날짜
16
16
  AI가 아래 명령을 대신 실행한다.
17
17
 
18
18
  ```
19
- npx -y lostvibe@0.7.0 setup --token <사이트에서 받은 토큰>
20
- npx -y lostvibe@0.7.0 draft --file .lostvibe/project.json
21
- npx -y lostvibe@0.7.0 sync
19
+ npx -y lostvibe@0.8.0 setup --token <사이트에서 받은 토큰>
20
+ npx -y lostvibe@0.8.0 draft --file .lostvibe/project.json
21
+ npx -y lostvibe@0.8.0 sync
22
22
  ```
23
23
 
24
+ 여러 프로젝트를 한 번에 가져올 때:
25
+
26
+ ```
27
+ lostvibe scan --all --json # 목록(JSON)만 출력. 고를 때 쓴다
28
+ lostvibe setup # setup:"folder" 인 프로젝트: 그 폴더에서 (git 없어도 된다)
29
+ lostvibe setup --source <id> # setup:"source" 인 프로젝트: 로그만 남은 것, 어느 폴더에서나
30
+ lostvibe setup --merge <id>[,<id>] # 같은 프로젝트의 다른 폴더 기록을 합친다 (주 프로젝트 폴더에서)
31
+ lostvibe draft --file .lostvibe/project.json --of <N> # N: 이번에 가져오는 프로젝트 수 (1~200)
32
+ lostvibe draft --file project.json --project <프로젝트 id> --of <N> # 폴더 없는 프로젝트
33
+ ```
34
+
35
+ `scan --all --json` 출력: `{"projects":[{"n","id","name","tokens","last","path","exists","git","setup","url"}]}`.
36
+ 경로가 들어간 전체 목록은 `~/.lostvibe/scan-all.json`(권한 600)에만 저장되고 보내지 않는다.
37
+
24
38
  토큰은 비밀이다. 화면·코드·저장소 어디에도 남기지 않는다.
25
39
 
40
+ ## 자동 갱신 훅
41
+
42
+ `hooks install`은 Claude Code와 Codex에 `Stop`, `SessionEnd` 훅을 넣는다. 다른 훅은 건드리지 않고,
43
+ 예전 버전이 넣은 Lost Vibe 훅은 찾아서 바꾼다. 훅은 항상 `~/.lostvibe/runtime/current`를 실행하므로
44
+ 버전을 올려도 명령이 같아서 Codex를 다시 승인할 필요가 없다. `Stop`은 프로젝트마다 10분에 한 번만 동기화한다.
45
+
46
+ `hooks status`는 클라이언트마다 `미설치` / `설치됨·승인 전` / `승인됨·실행 기록 없음` / `실행됨`을 보여 준다.
47
+ Claude Code는 승인이 필요 없다. Codex는 Codex CLI에서 `/hooks`를 열고 Lost Vibe 항목 2개를 Trust 해야 한다.
48
+
26
49
  ## 명령
27
50
 
28
51
  ```
29
- lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
30
- lostvibe scan --all 로그 전체에서 내 프로젝트를 전부 찾아 표로 출력
31
- lostvibe setup 현재 프로젝트만 비공개로 등록하고 이 컴퓨터와 연결
32
- lostvibe draft --file F AI가 만든 JSON을 검토할 초안으로 보낸다
33
- lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
34
- lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결
35
- lostvibe hooks status 설치 상태 확인
36
- lostvibe hooks remove Lostvibe가 추가한 항목만 제거
37
- lostvibe collect 연결한 성과를 읽어 갱신
52
+ lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
53
+ lostvibe scan --all [--json] 로그 전체에서 내 프로젝트를 전부 찾아 표(또는 JSON)로 출력
54
+ lostvibe setup [--no-hooks] 현재 폴더의 프로젝트만 비공개로 등록하고 이 컴퓨터와 연결
55
+ lostvibe setup --source ID 폴더 없이 로그만 남은 프로젝트를 등록
56
+ lostvibe setup --merge ID[,ID] scan --all 의 다른 프로젝트를 이 프로젝트에 합친다
57
+ lostvibe draft --file F [--of N] [--project ID]
58
+ AI가 만든 JSON을 검토할 초안으로 보낸다
59
+ lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
60
+ lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결
61
+ lostvibe hooks status 설치·승인·실행 상태 확인
62
+ lostvibe hooks remove Lostvibe가 추가한 항목만 제거
63
+ lostvibe collector add --project ID --slot result --command-json '[...]' [--cwd 폴더]
64
+ 성과 조회 명령 등록 (--cwd 는 프로젝트 폴더 안)
65
+ lostvibe collector test --command-json '[...]' [--cwd 폴더]
66
+ 보내지 않고 실행만 해서 "값: N (날짜)" 또는 실패 종류를 보여 준다
67
+ lostvibe collect 연결한 성과를 읽어 갱신
38
68
  ```
39
69
 
70
+ `collector test` 실패 종류: 환경 변수 없음 / 모듈을 찾지 못함 / 테이블 없음 / DB 연결 실패 / JSON 형식 오류 / 시간 초과 / 알 수 없는 오류.
71
+ 명령의 출력과 오류 내용은 화면에 내보내지 않는다.
72
+
40
73
  Node 22.15 이상. MIT.
package/bin/lostvibe.mjs CHANGED
@@ -4,13 +4,16 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { execFileSync, spawn } from 'node:child_process';
7
- import { scanAll, stripLocal, readJson } from '../src/scan.mjs';
7
+ import { scanAll, stripLocal, readJson, scanEntry } from '../src/scan.mjs';
8
8
  import { landTable, projectTable } from '../src/table.mjs';
9
9
  import { askReported } from '../src/reported.mjs';
10
10
  import { resolveCwd, sha, projectIdentity } from '../src/gitinfo.mjs';
11
11
  import { push } from '../src/push.mjs';
12
12
  import {
13
13
  registerCurrent,
14
+ registerProject,
15
+ setMerge,
16
+ mergeMap,
14
17
  activeConnection,
15
18
  selectedProjects,
16
19
  siteOf,
@@ -19,8 +22,8 @@ import {
19
22
  saveJson,
20
23
  currentIdentity,
21
24
  } from '../src/connections.mjs';
22
- import { addCollector, removeCollector, collect } from '../src/collectors.mjs';
23
- import { installHooks, removeHooks, hookStatus } from '../src/hooks.mjs';
25
+ import { addCollector, removeCollector, collect, testCollector, collectorCwd } from '../src/collectors.mjs';
26
+ import { installHooks, removeHooks, hookStatus, statusLines, installLines } from '../src/hooks.mjs';
24
27
 
25
28
  const DEFAULT_SITE = 'https://lostvibesociety.com';
26
29
 
@@ -44,6 +47,12 @@ function parse(argv) {
44
47
  else if (a === '--client') o.client = argv[++i];
45
48
  else if (a === '--no-hooks') o.noHooks = true;
46
49
  else if (a === '--only') o.only = argv[++i];
50
+ else if (a === '--json') o.json = true;
51
+ else if (a === '--source') o.source = argv[++i];
52
+ else if (a === '--merge') o.merge = argv[++i];
53
+ else if (a === '--of') o.of = argv[++i];
54
+ else if (a === '--from') o.from = argv[++i];
55
+ else if (a === '--cwd') o.cwd = argv[++i];
47
56
  else if (a === '-h' || a === '--help') o.help = true;
48
57
  else o._.push(a);
49
58
  }
@@ -54,15 +63,22 @@ const HELP = `lostvibe - 프로젝트의 객관 지표를 뽑는다
54
63
 
55
64
  lostvibe scan [폴더] 폴더 하나를 읽어 그 폴더에 lostvibe.json 저장
56
65
  lostvibe scan --all 로그 전체에서 내 프로젝트를 전부 찾아 표로 출력
66
+ lostvibe scan --all --json 같은 목록을 JSON 하나로만 출력 (~/.lostvibe/scan-all.json 에도 저장)
57
67
  lostvibe login --token T 이 컴퓨터를 내 땅에 연결한다
58
- lostvibe setup 현재 프로젝트만 비공개로 등록하고 이 컴퓨터와 연결
59
- lostvibe draft --file F AI가 만든 JSON을 검토할 초안으로 보낸다
68
+ lostvibe setup 현재 폴더의 프로젝트를 비공개로 등록하고 이 컴퓨터와 연결 (git 없어도 돼요)
69
+ lostvibe setup --source ID 폴더 없이 로그만 남은 프로젝트를 등록 (scan --all 의 id, 어느 폴더에서나)
70
+ lostvibe setup --merge ID[,ID]
71
+ scan --all 의 다른 프로젝트를 이 프로젝트 기록에 합친다
72
+ lostvibe draft --file F [--of N] [--project ID]
73
+ AI가 만든 JSON을 검토할 초안으로 보낸다 (--of: 가져오는 프로젝트 수)
60
74
  lostvibe sync 등록한 프로젝트의 바뀐 기록만 올린다
61
- lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결
62
- lostvibe hooks status 설치 상태 확인 (Codex는 신뢰 검토도 필요)
63
- lostvibe hooks remove Lostvibe가 추가한 항목만 제거
64
- lostvibe collector add --project ID --slot result --command-json '["node","script.mjs"]'
65
- 프로젝트 성과 조회 명령을 등록
75
+ lostvibe hooks install 기존 훅을 보존하며 자동 갱신 연결 (Stop, SessionEnd)
76
+ lostvibe hooks status 클라이언트별 상태: 미설치 / 설치됨·승인 전 / 승인됨·실행 기록 없음 / 실행됨
77
+ lostvibe hooks remove Lostvibe 훅만 제거
78
+ lostvibe collector add --project ID --slot result --command-json '["node","script.mjs"]' [--cwd 폴더]
79
+ 프로젝트 성과 조회 명령을 등록 (--cwd 는 프로젝트 폴더 안)
80
+ lostvibe collector test --command-json '[...]' [--cwd 폴더] [--project ID]
81
+ 보내지 않고 실행만 해서 값을 확인
66
82
  lostvibe collector remove --project ID --slot result
67
83
  lostvibe collect 연결한 성과를 읽어 갱신
68
84
  lostvibe push --all 결과를 확인하고 올린다
@@ -76,6 +92,7 @@ const HELP = `lostvibe - 프로젝트의 객관 지표를 뽑는다
76
92
  --root 폴더 이 폴더 바로 아래 레포도 후보에 넣는다 (--all)
77
93
  --detail 프로젝트별 상세 표도 출력 (--all)
78
94
  --only a,b 등록한 프로젝트 중 선택 (이름 또는 ID)
95
+ --no-hooks setup 에서 훅 설치를 건너뛴다
79
96
  --yes push 확인을 건너뛴다
80
97
  --quiet 출력 없이 돈다 (sync)
81
98
  --site URL push 대상 (기본 ${DEFAULT_SITE})
@@ -107,20 +124,34 @@ async function scanMany(o) {
107
124
  const out = path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
108
125
  fs.mkdirSync(out, { recursive: true });
109
126
  const landFile = path.join(out, 'land.json');
110
- const prev = readJson(landFile);
111
- const prevById = new Map((prev?.projects || []).map((p) => [p.project.id, p.reported]));
127
+ const prevById = previousReported(out);
112
128
  const land = await scanAll({
113
129
  extraRoots: o.root,
114
- onProgress: progress,
130
+ onProgress: o.json ? () => {} : progress,
115
131
  cacheDir: homeDir(o),
116
132
  reportedFor: (id) => prevById.get(id),
117
133
  });
118
134
  doneProgress();
119
- if (o.ask) {
135
+ if (o.ask && !o.json) {
120
136
  for (const p of land.projects) p.reported = await askReported(p.project.name, p.reported);
121
137
  }
122
138
  const clean = { ...land, projects: land.projects.map(stripLocal) };
123
139
  fs.writeFileSync(landFile, `${JSON.stringify(clean, null, 2)}\n`);
140
+ // The full list with local paths stays on this computer. setup --source / --merge read it.
141
+ const entries = land.projects.map(scanEntry);
142
+ const scanFile = path.join(out, 'scan-all.json');
143
+ saveJson(scanFile, {
144
+ scanned_at: land.scanned_at,
145
+ projects: entries.map((e, i) => ({
146
+ ...e,
147
+ folder: land.projects[i]._local?.folder || null,
148
+ cwds: land.projects[i]._local?.cwds || [],
149
+ })),
150
+ });
151
+ if (o.json) {
152
+ process.stdout.write(`${JSON.stringify({ projects: entries })}\n`);
153
+ return;
154
+ }
124
155
  console.log(landTable(land));
125
156
  if (o.detail) for (const p of land.projects) console.log(projectTable(p));
126
157
  console.log(`\n프로젝트 ${land.projects.length}개. 어느 프로젝트에도 속하지 않는 임시 폴더 토큰은 뺐다.`);
@@ -128,6 +159,21 @@ async function scanMany(o) {
128
159
  console.log(`저장: ${landFile}`);
129
160
  }
130
161
 
162
+ // reported answers from the last scan --all and the last sync, sync winning.
163
+ function previousReported(dir) {
164
+ const map = new Map();
165
+ for (const f of ['land.json', 'sync-land.json'])
166
+ for (const p of readJson(path.join(dir, f))?.projects || []) if (p?.project?.id) map.set(p.project.id, p.reported);
167
+ return map;
168
+ }
169
+
170
+ function readScanAll(o) {
171
+ const scan = readJson(path.join(homeDir(o), 'scan-all.json'));
172
+ if (!scan?.projects) throw new Error('먼저 scan --all 을 실행해 주세요.');
173
+ return scan.projects;
174
+ }
175
+ const rootsOf = (e) => (e.folder ? [path.dirname(e.folder)] : []);
176
+
131
177
  async function scanOne(o) {
132
178
  const dir = path.resolve(o._[1] || '.');
133
179
  const file = path.join(dir, 'lostvibe.json');
@@ -263,18 +309,25 @@ async function syncCmd(o) {
263
309
  const namespace = sha(`${site}:${token}`);
264
310
  const sentFile = path.join(dir, `sent-v4-${namespace}.json`);
265
311
  const sent = readJson(sentFile) || {};
266
- const prev = readJson(path.join(dir, 'land.json'));
267
- const prevById = new Map((prev?.projects || []).map((p) => [p.project.id, p.reported]));
312
+ const prevById = previousReported(dir);
313
+ const merge = mergeMap(allowed);
268
314
  const land = await scanAll({
269
- extraRoots: [...o.root, ...allowed.map((p) => path.dirname(p.cwd))],
315
+ // Folders without git are placed by their parent folder, so every known parent is a root.
316
+ extraRoots: [
317
+ ...o.root,
318
+ ...allowed.filter((p) => p.cwd).map((p) => path.dirname(p.cwd)),
319
+ ...allowed.flatMap((p) => p.roots || []),
320
+ ],
270
321
  cacheDir: dir,
271
- only: new Set(allowed.map((p) => p.sourceId)),
322
+ only: new Set([...allowed.map((p) => p.sourceId), ...merge.keys()]),
323
+ merge,
272
324
  onProgress: o.quiet ? () => {} : progress,
273
325
  reportedFor: (id) => prevById.get(id),
274
326
  });
275
327
  doneProgress();
276
328
  const clean = { ...land, projects: land.projects.map(stripLocal) };
277
- writeAtomic(path.join(dir, 'land.json'), clean);
329
+ // Kept apart from land.json so a sync never wipes the scan --all result.
330
+ writeAtomic(path.join(dir, 'sync-land.json'), clean);
278
331
  if (!clean.projects.length) {
279
332
  if (!o.quiet)
280
333
  console.log(
@@ -328,16 +381,31 @@ async function syncCmd(o) {
328
381
  const child = spawn(
329
382
  process.execPath,
330
383
  [process.argv[1], 'worker', '--quiet', '--out', dir, '--site', site, '--project', p.id],
331
- { cwd: p.cwd, detached: true, stdio: ['ignore', log, log], windowsHide: true },
384
+ { cwd: p.cwd && fs.existsSync(p.cwd) ? p.cwd : dir, detached: true, stdio: ['ignore', log, log], windowsHide: true },
332
385
  );
333
386
  child.unref();
334
387
  fs.closeSync(log);
335
388
  }
336
389
  }
337
390
  }
391
+ // The registered project a session folder belongs to: its own id, a merged id, or (without git) a parent folder.
392
+ function linkedFor(cwd, projects) {
393
+ const target = resolveCwd(cwd);
394
+ const ids = [projectIdentity(target.repoPath, target.key)];
395
+ if (!target.repoPath)
396
+ for (let d = path.dirname(cwd); d !== path.dirname(d) && d !== os.homedir(); d = path.dirname(d))
397
+ ids.push(projectIdentity(null, `folder:${path.basename(d).toLowerCase()}`));
398
+ for (const id of ids) {
399
+ const p = projects.find((x) => x.sourceId === id) || projects.find((x) => (x.merge || []).includes(id));
400
+ if (p) return p;
401
+ }
402
+ return null;
403
+ }
404
+ const STOP_GAP_MS = 10 * 60 * 1000;
338
405
  function hookCmd(o) {
339
406
  // Detached child survives the short SessionEnd deadline on both clients.
340
407
  let cwd = process.cwd();
408
+ let event = null;
341
409
  try {
342
410
  const payload = o._[1]
343
411
  ? JSON.parse(o._[1])
@@ -345,15 +413,30 @@ function hookCmd(o) {
345
413
  ? JSON.parse(fs.readFileSync(0, 'utf8'))
346
414
  : {};
347
415
  if (payload.cwd && fs.existsSync(payload.cwd)) cwd = payload.cwd;
416
+ if (typeof payload.hook_event_name === 'string') event = payload.hook_event_name.slice(0, 40);
348
417
  } catch {
349
418
  /* no notification payload */
350
419
  }
351
- const target = resolveCwd(cwd);
352
- const sourceId = projectIdentity(target.repoPath, target.key);
353
- const linked = selectedProjects(o).find((p) => p.sourceId === sourceId);
354
- if (!linked) return;
355
420
  const dir = homeDir(o);
356
421
  fs.mkdirSync(dir, { recursive: true });
422
+ const from = ['claude', 'codex'].includes(o.from) ? o.from : null;
423
+ const at = new Date().toISOString();
424
+ try {
425
+ const last = readJson(path.join(dir, 'hook-last.json')) || {};
426
+ const clients = { ...(last.clients || {}), ...(from ? { [from]: { event, at } } : {}) };
427
+ saveJson(path.join(dir, 'hook-last.json'), { from, event, at, clients });
428
+ } catch {
429
+ /* a record must not stop the sync */
430
+ }
431
+ const linked = linkedFor(cwd, selectedProjects(o));
432
+ if (!linked) return;
433
+ // Stop fires after every turn; one sync per 10 minutes is enough. SessionEnd always syncs.
434
+ const stamp = path.join(dir, `hook-spawn-${linked.id}`);
435
+ if (event === 'Stop') {
436
+ const prev = Number(readJson(stamp)?.at) || 0;
437
+ if (Date.now() - prev < STOP_GAP_MS) return;
438
+ }
439
+ fs.writeFileSync(stamp, JSON.stringify({ at: Date.now() }), { mode: 0o600 });
357
440
  const log = fs.openSync(path.join(dir, 'sync.log'), 'a', 0o600);
358
441
  const args = [process.argv[1], 'worker', '--quiet', '--project', linked.id];
359
442
  if (o.site) args.push('--site', o.site);
@@ -491,20 +574,49 @@ async function pushCmd(o) {
491
574
  async function setupCmd(o) {
492
575
  const token = savedToken(o);
493
576
  if (!token) throw new Error(NO_TOKEN);
577
+ const ids = (o.merge || '')
578
+ .split(',')
579
+ .map((x) => x.trim())
580
+ .filter(Boolean);
581
+ // Read the scan list before touching anything, so a wrong id changes nothing.
582
+ const scan = o.source || ids.length ? readScanAll(o) : [];
583
+ let entry = null;
584
+ if (o.source) {
585
+ entry = scan.find((e) => e.id === o.source);
586
+ if (!entry) throw new Error('scan --all 목록에 없는 id예요. scan --all --json 을 다시 실행해 확인해 주세요.');
587
+ if (entry.setup === 'folder' && entry.path)
588
+ throw new Error(`이 프로젝트는 폴더가 있어요. 그 폴더에서 setup을 실행해 주세요: ${entry.path}`);
589
+ }
590
+ const merged = ids.map((id) => {
591
+ const e = scan.find((x) => x.id === id);
592
+ if (!e) throw new Error(`scan --all 목록에 없는 id예요: ${id}`);
593
+ return e;
594
+ });
494
595
  // Validate the account before replacing a saved key.
495
596
  await request(siteOf(o), token, '/api/me/connection');
496
597
  if (o.token) loginCmd(o);
497
- const p = await registerCurrent(o, token);
598
+ let p = entry
599
+ ? await registerProject(o, token, { sourceId: entry.id, name: entry.name, cwd: null, roots: rootsOf(entry) })
600
+ : await registerCurrent(o, token);
601
+ if (merged.length) {
602
+ const own = selectedProjects({ ...o, project: undefined, only: undefined });
603
+ for (const e of merged) {
604
+ if (e.id === p.sourceId) throw new Error('자기 자신은 합칠 수 없어요.');
605
+ const other = own.find((x) => x.sourceId === e.id);
606
+ if (other) throw new Error(`이미 따로 연결한 프로젝트는 합칠 수 없어요: ${other.name}`);
607
+ }
608
+ p = setMerge(o, p.sourceId, merged.map((e) => e.id), merged.flatMap(rootsOf));
609
+ console.log(`합친 프로젝트: ${merged.map((e) => e.name).join(', ')}`);
610
+ }
498
611
  await syncCmd({ ...o, project: p.id });
499
612
  if (!o.noHooks) {
500
613
  try {
501
- const clients = installHooks(o);
502
- console.log(`자동 갱신 설정: ${clients.join(', ')}. Codex 훅은 /hooks에서 신뢰 검토 후 실행돼요.`);
614
+ for (const line of installLines(o, installHooks(o))) console.log(line);
503
615
  } catch (e) {
504
616
  console.log(`자동 갱신 설정 필요: ${e.message}`);
505
617
  }
506
618
  }
507
- console.log(`프로젝트 연결됨 · 공개 범위는 사이트에서 직접 선택해 주세요. ${siteOf(o)}/?p=${p.id}`);
619
+ console.log(`프로젝트 연결됨 (id ${p.id}) · 공개 범위는 사이트에서 직접 선택해 주세요. ${siteOf(o)}/?p=${p.id}`);
508
620
  }
509
621
  async function draftCmd(o) {
510
622
  if (!o.file) throw new Error('--file 에 프로젝트 JSON 파일을 지정해 주세요.');
@@ -512,9 +624,17 @@ async function draftCmd(o) {
512
624
  if (Buffer.byteLength(source) > 64000) throw new Error('파일은 64KB까지예요.');
513
625
  const data = JSON.parse(source);
514
626
  if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('프로젝트 JSON이 필요해요.');
515
- const current = currentIdentity();
516
- const p = selectedProjects(o).find((p) => p.sourceId === current.sourceId);
517
- if (!p) throw new Error('이 폴더에서 먼저 setup을 실행해 주세요.');
627
+ const validOf = (v) => Number.isInteger(v) && v >= 1 && v <= 200;
628
+ let importOf = data.importOf;
629
+ if (o.of !== undefined) {
630
+ importOf = /^\d+$/.test(o.of) ? Number(o.of) : NaN;
631
+ if (!validOf(importOf)) throw new Error('--of 에는 1부터 200까지의 정수를 넣어 주세요.');
632
+ } else if (importOf !== undefined && !validOf(importOf)) throw new Error('importOf 는 1부터 200까지의 정수예요.');
633
+ // --project picks a linked project by id from any folder (needed for setup --source projects).
634
+ const p = o.project
635
+ ? selectedProjects(o).find((x) => x.id === o.project)
636
+ : selectedProjects(o).find((x) => x.sourceId === currentIdentity().sourceId);
637
+ if (!p) throw new Error(o.project ? '연결한 프로젝트 중에 이 id가 없어요.' : '이 폴더에서 먼저 setup을 실행해 주세요.');
518
638
  // Never send file-provided publication settings or arbitrary fields.
519
639
  const keys = [
520
640
  'version',
@@ -537,8 +657,9 @@ async function draftCmd(o) {
537
657
  'result2Goal',
538
658
  ];
539
659
  const safe = Object.fromEntries(keys.filter((k) => k in data).map((k) => [k, data[k]]));
660
+ if (importOf !== undefined) safe.importOf = importOf;
540
661
  await request(siteOf(o), savedToken(o), `/api/me/projects/${p.id}`, 'PATCH', safe);
541
- console.log(`비공개 검토 초안을 보냈어요. ${siteOf(o)}/?p=${p.id}`);
662
+ console.log(`비공개 검토 초안을 보냈어요 (id ${p.id}). ${siteOf(o)}/?p=${p.id}`);
542
663
  }
543
664
  async function collectorCmd(o) {
544
665
  if (o._[1] === 'remove') {
@@ -546,15 +667,19 @@ async function collectorCmd(o) {
546
667
  console.log('성과 자동 갱신 연결을 해제했어요.');
547
668
  return;
548
669
  }
549
- if (o._[1] !== 'add') throw new Error('collector add 또는 remove를 지정해 주세요.');
670
+ if (o._[1] === 'test') {
671
+ await testCollector(o);
672
+ return;
673
+ }
674
+ if (o._[1] !== 'add') throw new Error('collector add, test 또는 remove를 지정해 주세요.');
675
+ if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
550
676
  const p = selectedProjects(o).find((p) => p.id === o.project);
551
677
  if (!p) throw new Error('먼저 프로젝트를 연결해 주세요.');
678
+ collectorCwd(p.cwd, o.cwd);
552
679
  const info = await request(siteOf(o), savedToken(o), `/api/projects/${p.id}`);
553
- if (
554
- !['result', 'result2'].includes(o.slot) ||
555
- !(info[o.slot]?.name || info.pendingDraft?.[o.slot === 'result' ? 'resultName' : 'result2Name'])
556
- )
557
- throw new Error('사이트에서 성과를 먼저 저장해 주세요.');
680
+ const nameKey = o.slot === 'result' ? 'resultName' : 'result2Name';
681
+ if (!(info[o.slot]?.name || info.pendingDraft?.[nameKey]))
682
+ throw new Error(`성과 이름이 아직 없어요. project.json에 ${nameKey}을 넣고 draft를 먼저 실행해 주세요.`);
558
683
  const previous = process.cwd();
559
684
  try {
560
685
  process.chdir(p.cwd);
@@ -567,10 +692,12 @@ async function collectorCmd(o) {
567
692
  }
568
693
  async function workerCmd(o) {
569
694
  let failed = false;
695
+ let recordsOk = true;
570
696
  try {
571
697
  await syncCmd(o);
572
698
  } catch {
573
699
  failed = true;
700
+ recordsOk = false;
574
701
  console.error(`${new Date().toISOString()} 작업 기록 갱신 실패`);
575
702
  for (const p of selectedProjects(o)) await sendStatus(siteOf(o), savedToken(o), p.id, 'records', false);
576
703
  }
@@ -581,6 +708,14 @@ async function workerCmd(o) {
581
708
  console.error(`${new Date().toISOString()} 성과 갱신 실패 · 이전 값 유지`);
582
709
  }
583
710
  console.log(`${new Date().toISOString()} 자동 갱신 ${failed ? '일부 실패' : '완료'}`);
711
+ try {
712
+ const file = path.join(homeDir(o), 'last-sync.json');
713
+ const at = new Date().toISOString();
714
+ // lastOkAt: the last time records reached the site.
715
+ saveJson(file, { at, ok: !failed, lastOkAt: recordsOk ? at : readJson(file)?.lastOkAt || null });
716
+ } catch {
717
+ /* status file only */
718
+ }
584
719
  if (failed) process.exitCode = 1;
585
720
  }
586
721
 
@@ -601,26 +736,12 @@ try {
601
736
  else if (cmd === 'collector') await collectorCmd(o);
602
737
  else if (cmd === 'worker') await workerCmd(o);
603
738
  else if (cmd === 'hooks') {
604
- if (o._[1] === 'install')
605
- console.log(
606
- `설치됨: ${installHooks(o).join(', ')}. Codex는 /hooks에서 신뢰 검토가 필요해요. 실제 이벤트 검증 전에는 연결 완료로 보지 마세요.`,
607
- );
739
+ if (o._[1] === 'install') for (const line of installLines(o, installHooks(o))) console.log(line);
608
740
  else if (o._[1] === 'remove') {
609
741
  removeHooks(o);
610
- console.log('Lostvibe 훅만 제거했어요.');
611
- } else if (o._[1] === 'status') {
612
- const entries = hookStatus(o);
613
- console.log(
614
- entries.length
615
- ? entries
616
- .map(
617
- (e) =>
618
- `${e.client} ${e.event}: ${e.installed ? '설정 있음 (클라이언트 신뢰·실행 확인 필요)' : '설정 없음'}`,
619
- )
620
- .join('\n')
621
- : '설치된 훅 없음',
622
- );
623
- } else throw new Error('hooks install / status / remove');
742
+ console.log('Lostvibe 훅만 제거했어요. 다른 훅은 그대로예요.');
743
+ } else if (o._[1] === 'status') for (const line of statusLines(hookStatus(o))) console.log(line);
744
+ else throw new Error('hooks install / status / remove');
624
745
  } else {
625
746
  console.log(HELP);
626
747
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lostvibe",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "프로젝트의 객관 지표를 로컬 로그와 git에서 읽는 CLI. 코드와 대화 내용은 보내지 않는다.",
5
5
  "keywords": [
6
6
  "lostvibe",
@@ -37,6 +37,23 @@ export function parseCollected(source) {
37
37
  `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`,
38
38
  };
39
39
  }
40
+ // A collector cwd is stored relative to the project folder and must stay inside it.
41
+ export function collectorCwd(projectDir, dir) {
42
+ if (!projectDir) throw new Error('폴더가 없는 프로젝트에는 성과 명령을 연결할 수 없어요.');
43
+ if (!dir) return '.';
44
+ let base, target;
45
+ try {
46
+ base = fs.realpathSync(projectDir);
47
+ target = fs.realpathSync(path.resolve(dir));
48
+ } catch {
49
+ throw new Error('--cwd 폴더를 찾지 못했어요.');
50
+ }
51
+ if (!fs.statSync(target).isDirectory()) throw new Error('--cwd 에는 폴더를 지정해 주세요.');
52
+ const rel = path.relative(base, target);
53
+ if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error('--cwd 는 프로젝트 폴더 안이어야 해요.');
54
+ return rel || '.';
55
+ }
56
+ const cwdOf = (p, c) => (c.cwd && path.isAbsolute(c.cwd) ? c.cwd : path.join(p.cwd, c.cwd || '.'));
40
57
  export function addCollector(o) {
41
58
  if (!['result', 'result2'].includes(o.slot)) throw new Error('--slot result 또는 result2가 필요해요.');
42
59
  let raw;
@@ -51,7 +68,7 @@ export function addCollector(o) {
51
68
  if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
52
69
  // Store only an explicitly registered command in user state. Never execute imported/repository config.
53
70
  p.collectors ||= {};
54
- p.collectors[o.slot] = { command, cwd: p.cwd };
71
+ p.collectors[o.slot] = { command, cwd: collectorCwd(p.cwd, o.cwd) };
55
72
  saveJson(path.join(dir, 'connections.json'), state);
56
73
  }
57
74
  export function removeCollector(o) {
@@ -61,6 +78,27 @@ export function removeCollector(o) {
61
78
  delete p.collectors?.[o.slot];
62
79
  saveJson(path.join(dir, 'connections.json'), state);
63
80
  }
81
+ // Failure categories only. stderr and stdout are never printed: they may hold secrets.
82
+ export function classifyFailure({ code, signal, killed, stderr = '' } = {}) {
83
+ const err = String(stderr);
84
+ if (code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return 'JSON 형식 오류';
85
+ if (killed || signal === 'SIGTERM' || code === 'ETIMEDOUT') return '시간 초과';
86
+ if (code === 'ENOENT' || /cannot find module|err_module_not_found|modulenotfounderror|no module named|command not found|cannot find package/i.test(err))
87
+ return '모듈을 찾지 못함';
88
+ if (/no such table|relation "?[^"\s]*"? does not exist|table '?[^'\s]*'? doesn't exist|undefined_table|42P01|PGRST205|could not find the table/i.test(err))
89
+ return '테이블 없음';
90
+ if (/environment variable|env(ironment)? var|\bis not set\b|is not defined in env|missing required env|keyerror: ['"][A-Z][A-Z0-9_]*['"]|process\.env\.[A-Z_]+ is (undefined|missing)|\b[A-Z][A-Z0-9]*_(URL|KEY|TOKEN|SECRET|DSN)\b.*(missing|required|undefined|not (set|found|defined))/i.test(err))
91
+ return '환경 변수 없음';
92
+ if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|getaddrinfo|could not connect|connection (refused|terminated|failed|error)|password authentication failed|authentication failed|too many connections|SQLITE_CANTOPEN|unable to open database|database .* does not exist|fetch failed|server closed the connection/i.test(err))
93
+ return 'DB 연결 실패';
94
+ return '알 수 없는 오류';
95
+ }
96
+ class CollectorError extends Error {
97
+ constructor(kind) {
98
+ super(`성과 조회 명령이 실패했어요 (${kind}). 이전 값을 유지해요.`);
99
+ this.kind = kind;
100
+ }
101
+ }
64
102
  async function run(command, cwd) {
65
103
  const env = { ...process.env };
66
104
  for (const k of ['LOSTVIBE_TOKEN', 'LOSTVIBE_METRICS_TOKEN']) delete env[k];
@@ -69,11 +107,42 @@ async function run(command, cwd) {
69
107
  command[0],
70
108
  command.slice(1),
71
109
  { cwd, env, timeout: 20000, maxBuffer: 64_000, windowsHide: true },
72
- (e, stdout) =>
73
- e ? reject(new Error('성과 조회 명령이 실패했어요. 이전 값을 유지해요.')) : resolve(stdout),
110
+ (e, stdout, stderr) =>
111
+ e
112
+ ? reject(new CollectorError(classifyFailure({ code: e.code, signal: e.signal, killed: e.killed, stderr })))
113
+ : resolve(stdout),
74
114
  ),
75
115
  );
76
116
  }
117
+ // Runs a collector the way collect does and returns { value, date } or throws with .kind.
118
+ export async function runCollector(command, cwd) {
119
+ const out = await run(validateCommand(command), cwd);
120
+ try {
121
+ return parseCollected(out);
122
+ } catch {
123
+ throw new CollectorError('JSON 형식 오류');
124
+ }
125
+ }
126
+ export async function testCollector(o) {
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(o.commandJson);
130
+ } catch {
131
+ throw new Error('--command-json 에 명령 배열이 필요해요.');
132
+ }
133
+ const command = validateCommand(raw);
134
+ const p = o.project ? selectedProjects(o).find((x) => x.id === o.project) : null;
135
+ if (o.project && !p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
136
+ const base = p?.cwd || process.cwd();
137
+ const cwd = o.cwd ? path.join(base, collectorCwd(base, o.cwd)) : base;
138
+ try {
139
+ const sample = await runCollector(command, cwd);
140
+ console.log(`값: ${sample.value} (${sample.date})`);
141
+ } catch (e) {
142
+ console.error(`실패: ${e.kind || '알 수 없는 오류'}`);
143
+ process.exitCode = 1;
144
+ }
145
+ }
77
146
  export async function collect(o, token) {
78
147
  const site = siteOf(o),
79
148
  { dir } = activeConnection(o);
@@ -84,7 +153,7 @@ export async function collect(o, token) {
84
153
  const skipped = new Set((connection.skipped || []).map((p) => p.sourceId));
85
154
  let failed = 0;
86
155
  for (const p of selected.filter((p) => ids.has(p.id) && !skipped.has(p.sourceId))) {
87
- if (!Object.keys(p.collectors || {}).length) continue;
156
+ if (!p.cwd || !Object.keys(p.collectors || {}).length) continue;
88
157
  const current = await request(site, token, `/api/projects/${p.id}`);
89
158
  for (const [slot, c] of Object.entries(p.collectors || {})) {
90
159
  if (!current[slot]?.name) continue;
@@ -106,7 +175,7 @@ export async function collect(o, token) {
106
175
  continue;
107
176
  }
108
177
  try {
109
- const sample = parseCollected(await run(validateCommand(c.command), c.cwd));
178
+ const sample = await runCollector(c.command, cwdOf(p, c));
110
179
  const tokenFile = path.join(p.cwd, '.lostvibe', `metrics-token-${p.id}`);
111
180
  const scoped = fs.readFileSync(tokenFile, 'utf8').trim();
112
181
  const result = await request(site, scoped, `/api/me/projects/${p.id}/metrics`, 'POST', {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { resolveCwd, projectIdentity, sha } from './gitinfo.mjs';
5
- export const VERSION = '0.7.0';
5
+ export const VERSION = '0.8.0';
6
6
  export const stateDir = (o = {}) =>
7
7
  path.resolve(o.out || process.env.LOSTVIBE_DIR || path.join(os.homedir(), '.lostvibe'));
8
8
  export function readState(dir) {
@@ -24,10 +24,12 @@ export function activeConnection(o = {}) {
24
24
  const account = state.accounts?.[state.active];
25
25
  return { dir, state, account };
26
26
  }
27
- export function currentIdentity() {
28
- const r = resolveCwd(process.cwd());
29
- if (!r.repoPath) throw new Error('프로젝트의 git 폴더에서 실행해 주세요.');
30
- return { ...r, sourceId: projectIdentity(r.repoPath, r.key) };
27
+ // A folder without git is the project `folder:<name>`, the same key scan --all gives it.
28
+ export function currentIdentity(cwd = process.cwd()) {
29
+ const r = resolveCwd(cwd);
30
+ if (r.repoPath) return { ...r, cwd: r.repoPath, sourceId: projectIdentity(r.repoPath, r.key) };
31
+ if (cwd === os.homedir() || cwd === path.dirname(cwd)) throw new Error('프로젝트 폴더 안에서 실행해 주세요.');
32
+ return { ...r, cwd, sourceId: projectIdentity(null, r.key) };
31
33
  }
32
34
  export function siteOf(o = {}) {
33
35
  const { account } = activeConnection(o);
@@ -66,13 +68,17 @@ export async function request(site, token, url, method = 'GET', body) {
66
68
  return d;
67
69
  }
68
70
  export async function registerCurrent(o, token) {
71
+ const current = currentIdentity();
72
+ return registerProject(o, token, { sourceId: current.sourceId, name: current.name, cwd: current.cwd });
73
+ }
74
+ // ident: { sourceId, name, cwd (null for a project known only from logs), roots? }
75
+ export async function registerProject(o, token, current) {
69
76
  const dir = stateDir(o),
70
77
  site = siteOf(o),
71
78
  connection = await request(site, token, '/api/me/connection');
72
- const current = currentIdentity();
73
79
  const existing = connection.projects.find((p) => p.sourceId === current.sourceId);
74
80
  if (o.project && (!existing || existing.id !== o.project))
75
- throw new Error('선택한 프로젝트와 현재 git 폴더가 달라요. 올바른 폴더에서 다시 실행해 주세요.');
81
+ throw new Error('선택한 프로젝트와 현재 폴더가 달라요. 올바른 폴더에서 다시 실행해 주세요.');
76
82
  if (connection.skipped?.some((p) => p.sourceId === current.sourceId))
77
83
  throw new Error('넘긴 프로젝트예요. 사이트에서 다시 가져온 뒤 연결해 주세요.');
78
84
  const target =
@@ -86,17 +92,37 @@ export async function registerCurrent(o, token) {
86
92
  state.accounts ||= {};
87
93
  state.accounts[key] ||= { site, login: connection.login, projects: {} };
88
94
  const account = state.accounts[key];
95
+ const prev = account.projects[current.sourceId] || {};
96
+ const roots = [...new Set([...(prev.roots || []), ...(current.roots || [])])];
89
97
  account.projects[current.sourceId] = {
90
- ...account.projects[current.sourceId],
98
+ ...prev,
91
99
  sourceId: current.sourceId,
92
100
  id: target.id,
93
- cwd: current.repoPath,
101
+ cwd: current.cwd || null,
94
102
  name: current.name,
103
+ ...(roots.length ? { roots } : {}),
95
104
  };
96
105
  state.active = key;
97
106
  saveJson(path.join(dir, 'connections.json'), state);
98
107
  return account.projects[current.sourceId];
99
108
  }
109
+ // Other scan ids whose logs count as this project's. roots are workspaces that place their folders.
110
+ export function setMerge(o, sourceId, ids, roots = []) {
111
+ const { dir, state, account } = activeConnection(o);
112
+ const p = account?.projects?.[sourceId];
113
+ if (!p) throw new Error('먼저 이 프로젝트를 setup으로 연결해 주세요.');
114
+ p.merge = [...new Set([...(p.merge || []), ...ids])].filter((id) => id !== sourceId);
115
+ const all = [...new Set([...(p.roots || []), ...roots])];
116
+ if (all.length) p.roots = all;
117
+ saveJson(path.join(dir, 'connections.json'), state);
118
+ return p;
119
+ }
120
+ // other sourceId -> main sourceId, for scanAll({ merge }).
121
+ export function mergeMap(projects) {
122
+ const map = new Map();
123
+ for (const p of projects) for (const id of p.merge || []) if (id !== p.sourceId) map.set(id, p.sourceId);
124
+ return map;
125
+ }
100
126
  export function selectedProjects(o = {}) {
101
127
  const { account } = activeConnection(o);
102
128
  if (!account) return [];
package/src/hooks.mjs CHANGED
@@ -1,9 +1,18 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { fileURLToPath } from 'node:url';
5
- import { VERSION, stateDir, saveJson } from './connections.mjs';
6
+ import { stateDir, saveJson } from './connections.mjs';
7
+
6
8
  const quote = (s) => `'${s.replace(/'/g, "'\\''")}'`;
9
+ // Any hook that runs `lostvibe.mjs hook` is ours, whichever version or folder installed it.
10
+ export const OUR_HOOK = /lostvibe\.mjs'?\s+hook\b/;
11
+ const isOurs = (h) => typeof h?.command === 'string' && OUR_HOOK.test(h.command);
12
+ const EVENTS = ['Stop', 'SessionEnd'];
13
+ const TEMP = /(^|['"\s])\/(private\/)?tmp\//;
14
+ const snake = (e) => e.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
15
+
7
16
  function load(file, fallback = {}) {
8
17
  if (!fs.existsSync(file)) return fallback;
9
18
  const data = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -11,88 +20,319 @@ function load(file, fallback = {}) {
11
20
  throw new Error(`설정 형식을 읽지 못했어요: ${path.basename(file)}`);
12
21
  return data;
13
22
  }
14
- function targets() {
15
- const list = [];
23
+ function readSafe(file) {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ function clients(o = {}) {
16
31
  const claude = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
17
32
  const codex = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
18
- if (fs.existsSync(claude))
19
- list.push({ client: 'Claude Code', file: path.join(claude, 'settings.json'), event: 'SessionEnd' });
20
- if (fs.existsSync(codex))
21
- for (const event of ['Stop', 'SessionEnd'])
22
- list.push({ client: 'Codex', file: path.join(codex, 'hooks.json'), event });
23
- return list;
24
- }
25
- function ownGroups(config, event, command, remove = false) {
26
- config.hooks ||= {};
27
- const groups = config.hooks[event] || [];
28
- if (!Array.isArray(groups)) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
29
- if (remove) {
30
- config.hooks[event] = groups
31
- .map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => h.command !== command) }))
32
- .filter((g) => g.hooks.length);
33
- if (!config.hooks[event].length) delete config.hooks[event];
34
- } else if (!groups.some((g) => g.hooks?.some((h) => h.command === command))) {
35
- config.hooks[event] = [...groups, { hooks: [{ type: 'command', command, timeout: 10 }] }];
33
+ const want = o.client?.toLowerCase();
34
+ return [
35
+ { client: 'Claude Code', from: 'claude', dir: claude, file: path.join(claude, 'settings.json') },
36
+ { client: 'Codex', from: 'codex', dir: codex, file: path.join(codex, 'hooks.json') },
37
+ ].filter((c) => !want || c.from === want || c.client.toLowerCase().startsWith(want));
38
+ }
39
+
40
+ // Removes our hooks from the given events (or every event) and leaves all other hooks as they are.
41
+ function stripOurs(config, events) {
42
+ if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
43
+ if (events && config.hooks !== undefined) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
44
+ return false;
45
+ }
46
+ let changed = false;
47
+ for (const event of events || Object.keys(config.hooks)) {
48
+ const groups = config.hooks[event];
49
+ if (groups === undefined) continue;
50
+ if (!Array.isArray(groups)) {
51
+ if (events) throw new Error('기존 훅 형식을 읽지 못했어요. 변경하지 않았어요.');
52
+ continue;
53
+ }
54
+ const next = [];
55
+ for (const g of groups) {
56
+ if (!Array.isArray(g?.hooks) || !g.hooks.some(isOurs)) {
57
+ next.push(g);
58
+ continue;
59
+ }
60
+ changed = true;
61
+ const rest = g.hooks.filter((h) => !isOurs(h));
62
+ if (rest.length) next.push({ ...g, hooks: rest });
63
+ }
64
+ if (next.length) config.hooks[event] = next;
65
+ else if (groups.length) delete config.hooks[event];
36
66
  }
37
- return config;
67
+ if (changed && !Object.keys(config.hooks).length) delete config.hooks;
68
+ return changed;
38
69
  }
39
- export function installHooks(o) {
40
- const dir = stateDir(o),
41
- chosen = targets().filter((t) => !o.client || t.client.toLowerCase().startsWith(o.client.toLowerCase()));
42
- if (!chosen.length) throw new Error('연결할 Claude Code 또는 Codex 설정 폴더를 찾지 못했어요.');
70
+ function findOurs(config, event) {
71
+ const groups = config.hooks?.[event];
72
+ if (!Array.isArray(groups)) return null;
73
+ for (let gi = 0; gi < groups.length; gi++) {
74
+ const hooks = groups[gi]?.hooks;
75
+ if (!Array.isArray(hooks)) continue;
76
+ for (let hi = 0; hi < hooks.length; hi++) if (isOurs(hooks[hi])) return { gi, hi, command: hooks[hi].command };
77
+ }
78
+ return null;
79
+ }
80
+ function ourCommands(config) {
81
+ const out = [];
82
+ for (const groups of Object.values(config.hooks || {}))
83
+ if (Array.isArray(groups))
84
+ for (const g of groups) for (const h of Array.isArray(g?.hooks) ? g.hooks : []) if (isOurs(h)) out.push(h.command);
85
+ return out;
86
+ }
87
+
88
+ // A node path that survives node upgrades (e.g. /opt/homebrew/bin/node instead of the Cellar path).
89
+ function stableNode() {
90
+ let real;
91
+ try {
92
+ real = fs.realpathSync(process.execPath);
93
+ } catch {
94
+ return process.execPath;
95
+ }
96
+ for (const d of (process.env.PATH || '').split(path.delimiter)) {
97
+ if (!d || !path.isAbsolute(d) || TEMP.test(d) || d.includes('/var/folders/') || d.includes('node_modules')) continue;
98
+ const c = path.join(d, process.platform === 'win32' ? 'node.exe' : 'node');
99
+ try {
100
+ if (fs.realpathSync(c) === real) return c;
101
+ } catch {
102
+ /* not here */
103
+ }
104
+ }
105
+ return process.execPath;
106
+ }
107
+ // The hook always runs runtime/current, so its command (and Codex's trust hash) stays the same across versions.
108
+ function installRuntime(dir) {
43
109
  const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
44
- const runtime = path.join(dir, 'runtime', VERSION);
45
- fs.mkdirSync(runtime, { recursive: true });
46
- for (const part of ['src', 'bin'])
47
- if (path.resolve(packageRoot) !== path.resolve(runtime))
48
- fs.cpSync(path.join(packageRoot, part), path.join(runtime, part), { recursive: true });
49
- const command = `${quote(process.execPath)} ${quote(path.join(runtime, 'bin', 'lostvibe.mjs'))} hook --out ${quote(dir)}`;
50
- const registryFile = path.join(dir, 'hooks.json');
51
- const registry = load(registryFile, { entries: [] });
52
- // Validate all files before changing any. Keep other hooks and notify untouched.
53
- const configs = new Map();
54
- for (const t of chosen) if (!configs.has(t.file)) configs.set(t.file, load(t.file));
55
- for (const t of chosen) {
56
- const config = configs.get(t.file);
57
- for (const old of registry.entries.filter((e) => e.file === t.file && e.event === t.event))
58
- ownGroups(config, t.event, old.command, true);
59
- ownGroups(config, t.event, command);
110
+ const base = path.join(dir, 'runtime');
111
+ const current = path.join(base, 'current');
112
+ if (path.resolve(packageRoot) === path.resolve(current)) return current;
113
+ const next = path.join(base, `.next-${process.pid}`);
114
+ fs.rmSync(next, { recursive: true, force: true });
115
+ fs.mkdirSync(next, { recursive: true });
116
+ for (const part of ['src', 'bin']) fs.cpSync(path.join(packageRoot, part), path.join(next, part), { recursive: true });
117
+ fs.copyFileSync(path.join(packageRoot, 'package.json'), path.join(next, 'package.json'));
118
+ fs.rmSync(current, { recursive: true, force: true });
119
+ fs.renameSync(next, current);
120
+ return current;
121
+ }
122
+ export function hookCommand(o, from) {
123
+ const dir = stateDir(o);
124
+ return `${quote(stableNode())} ${quote(path.join(dir, 'runtime', 'current', 'bin', 'lostvibe.mjs'))} hook --from ${from} --out ${quote(dir)}`;
125
+ }
126
+
127
+ // Codex keeps approvals in config.toml as [hooks.state."<hooks.json>:<event>:<group>:<hook>"] trusted_hash = "...".
128
+ // Read only: approval is the user's step inside Codex.
129
+ export function codexTrust(codexDir) {
130
+ let text;
131
+ try {
132
+ text = fs.readFileSync(path.join(codexDir, 'config.toml'), 'utf8');
133
+ } catch {
134
+ return new Map();
135
+ }
136
+ const unq = (s) => s.replace(/\\(.)/g, '$1');
137
+ const map = new Map();
138
+ let key = null;
139
+ let section = null;
140
+ for (const line of text.split(/\r?\n/)) {
141
+ const h = line.match(/^\s*\[\s*hooks\s*\.\s*state\s*\.\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\s*\]/);
142
+ if (h) {
143
+ key = h[1] !== undefined ? unq(h[1]) : h[2];
144
+ section = null;
145
+ continue;
146
+ }
147
+ const s = line.match(/^\s*\[\s*([^\]]+?)\s*\]/);
148
+ if (s) {
149
+ key = null;
150
+ section = s[1].replace(/\s/g, '');
151
+ continue;
152
+ }
153
+ if (key) {
154
+ const t = line.match(/^\s*trusted_hash\s*=\s*["']([^"']*)["']/);
155
+ if (t) map.set(key, t[1]);
156
+ } else if (section === 'hooks.state') {
157
+ const t = line.match(/^\s*"((?:[^"\\]|\\.)*)"\s*=\s*\{[^}]*trusted_hash\s*=\s*"([^"]*)"/);
158
+ if (t) map.set(unq(t[1]), t[2]);
159
+ }
160
+ }
161
+ return map;
162
+ }
163
+ const trustKey = (file, event, pos) => `${file}:${snake(event)}:${pos.gi}:${pos.hi}`;
164
+
165
+ export function codexCli() {
166
+ try {
167
+ const found = execFileSync('/bin/sh', ['-c', 'command -v codex'], {
168
+ encoding: 'utf8',
169
+ stdio: ['ignore', 'pipe', 'ignore'],
170
+ timeout: 5000,
171
+ }).trim();
172
+ if (found) return found;
173
+ } catch {
174
+ /* not on PATH */
175
+ }
176
+ for (const p of [
177
+ '/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex',
178
+ '/Applications/Codex.app/Contents/Resources/codex',
179
+ ])
180
+ if (fs.existsSync(p)) return p;
181
+ return null;
182
+ }
183
+ export function codexHint(cli = codexCli()) {
184
+ return cli
185
+ ? `Codex 승인: 터미널에서 '${cli}' 실행 → /hooks → Lost Vibe 항목 2개만 Trust`
186
+ : 'Codex 승인: Codex CLI를 찾지 못했어요. Codex를 열고 /hooks → Lost Vibe 항목 2개만 Trust';
187
+ }
188
+
189
+ export function installHooks(o = {}) {
190
+ const dir = stateDir(o);
191
+ const chosen = clients(o).filter((c) => fs.existsSync(c.dir));
192
+ if (!chosen.length) throw new Error('연결할 Claude Code 또는 Codex 설정 폴더를 찾지 못했어요.');
193
+ // Validate all files before changing any.
194
+ const plans = chosen.map((c) => {
195
+ const config = load(c.file);
196
+ return { ...c, before: structuredClone(config), config, command: hookCommand(o, c.from) };
197
+ });
198
+ for (const p of plans) {
199
+ stripOurs(p.config, EVENTS);
200
+ p.config.hooks ||= {};
201
+ for (const event of EVENTS)
202
+ p.config.hooks[event] = [...(p.config.hooks[event] || []), { hooks: [{ type: 'command', command: p.command, timeout: 10 }] }];
60
203
  }
61
- for (const [file, config] of configs) {
62
- fs.mkdirSync(path.dirname(file), { recursive: true });
63
- if (fs.existsSync(file) && !fs.existsSync(file + '.lostvibe-backup'))
64
- fs.copyFileSync(file, file + '.lostvibe-backup');
65
- saveJson(file, config);
204
+ installRuntime(dir);
205
+ const entries = [];
206
+ for (const p of plans) {
207
+ fs.mkdirSync(path.dirname(p.file), { recursive: true });
208
+ if (fs.existsSync(p.file) && !fs.existsSync(p.file + '.lostvibe-backup'))
209
+ fs.copyFileSync(p.file, p.file + '.lostvibe-backup');
210
+ saveJson(p.file, p.config);
211
+ const trust = p.from === 'codex' ? codexTrust(p.dir) : null;
212
+ for (const event of EVENTS) {
213
+ const entry = { client: p.client, from: p.from, file: p.file, event, command: p.command };
214
+ if (trust) {
215
+ // Remembers the approval that existed at this position so a stale one is not read as approval later.
216
+ const pos = findOurs(p.config, event);
217
+ entry.trustKey = trustKey(p.file, event, pos);
218
+ entry.preHash = trust.get(entry.trustKey) || null;
219
+ entry.sameCommand = p.before.hooks?.[event]?.[pos.gi]?.hooks?.[pos.hi]?.command === p.command;
220
+ }
221
+ entries.push(entry);
222
+ }
223
+ }
224
+ const registryFile = path.join(dir, 'hooks.json');
225
+ let registry;
226
+ try {
227
+ registry = load(registryFile, { entries: [] });
228
+ } catch {
229
+ registry = { entries: [] };
66
230
  }
67
231
  registry.entries = [
68
- ...registry.entries.filter((e) => !chosen.some((t) => t.file === e.file && t.event === e.event)),
69
- ...chosen.map((t) => ({ ...t, command })),
232
+ ...(registry.entries || []).filter((e) => !plans.some((p) => p.file === e.file)),
233
+ ...entries,
70
234
  ];
71
235
  saveJson(registryFile, registry);
72
- return [...new Set(chosen.map((t) => t.client))];
236
+ return [...new Set(chosen.map((c) => c.client))];
73
237
  }
74
- export function removeHooks(o) {
238
+
239
+ export function removeHooks(o = {}) {
75
240
  const file = path.join(stateDir(o), 'hooks.json');
76
- const registry = load(file, { entries: [] });
77
- const keep = [];
78
- for (const e of registry.entries) {
79
- if (o.client && !e.client.toLowerCase().startsWith(o.client.toLowerCase())) {
80
- keep.push(e);
81
- continue;
241
+ let registry;
242
+ try {
243
+ registry = load(file, { entries: [] });
244
+ } catch {
245
+ registry = { entries: [] };
246
+ }
247
+ const chosen = clients(o);
248
+ const files = new Set(chosen.map((c) => c.file));
249
+ const matches = (e) => !o.client || chosen.some((c) => c.client === e.client);
250
+ for (const e of registry.entries || []) if (matches(e)) files.add(e.file);
251
+ for (const f of files) {
252
+ if (!fs.existsSync(f)) continue;
253
+ const config = load(f);
254
+ if (stripOurs(config)) saveJson(f, config);
255
+ }
256
+ registry.entries = (registry.entries || []).filter((e) => !matches(e));
257
+ saveJson(file, registry);
258
+ }
259
+
260
+ const when = (iso) => {
261
+ const d = new Date(iso);
262
+ if (Number.isNaN(d.getTime())) return '-';
263
+ const z = (n) => String(n).padStart(2, '0');
264
+ return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
265
+ };
266
+
267
+ // Per client: 미설치 / 설치됨·승인 전 / 승인됨·실행 기록 없음 / 실행됨.
268
+ export function hookStatus(o = {}) {
269
+ const dir = stateDir(o);
270
+ const registry = readSafe(path.join(dir, 'hooks.json')) || { entries: [] };
271
+ const last = readSafe(path.join(dir, 'hook-last.json')) || {};
272
+ const sync = readSafe(path.join(dir, 'last-sync.json'));
273
+ const lastSync = sync ? (sync.ok ? sync.at : sync.lastOkAt) || null : null;
274
+ const out = [];
275
+ for (const c of clients(o)) {
276
+ if (!fs.existsSync(c.dir)) continue;
277
+ let config = {};
278
+ try {
279
+ config = load(c.file);
280
+ } catch {
281
+ /* unreadable config reads as not installed */
82
282
  }
83
- if (fs.existsSync(e.file)) {
84
- const c = load(e.file);
85
- saveJson(e.file, ownGroups(c, e.event, e.command, true));
283
+ const positions = EVENTS.map((event) => ({ event, pos: findOurs(config, event) }));
284
+ const installed = positions.every((p) => p.pos);
285
+ let approved = installed;
286
+ if (installed && c.from === 'codex') {
287
+ const trust = codexTrust(c.dir);
288
+ approved = positions.every(({ event, pos }) => {
289
+ const key = trustKey(c.file, event, pos);
290
+ const hash = trust.get(key);
291
+ if (!hash) return false;
292
+ const snap = (registry.entries || []).find((e) => e.trustKey === key && e.command === pos.command);
293
+ return !(snap && snap.preHash && snap.preHash === hash && !snap.sameCommand);
294
+ });
86
295
  }
296
+ const lastRun = last.clients?.[c.from]?.at || (last.from === c.from ? last.at : null) || null;
297
+ const state = !installed
298
+ ? '미설치'
299
+ : !approved
300
+ ? '설치됨·승인 전'
301
+ : !lastRun
302
+ ? '승인됨·실행 기록 없음'
303
+ : '실행됨';
304
+ out.push({
305
+ client: c.client,
306
+ from: c.from,
307
+ state,
308
+ installed,
309
+ approved,
310
+ lastRun,
311
+ lastSync,
312
+ temp: ourCommands(config).some((cmd) => TEMP.test(cmd)),
313
+ });
87
314
  }
88
- registry.entries = keep;
89
- saveJson(file, registry);
315
+ return out;
316
+ }
317
+ export function statusLines(entries, cli) {
318
+ if (!entries.length) return ['Claude Code와 Codex 설정 폴더를 찾지 못했어요.'];
319
+ const lines = entries.map((e) =>
320
+ e.state === '실행됨'
321
+ ? `${e.client}: 실행됨 (마지막 실행 ${when(e.lastRun)}, 마지막 사이트 반영 ${e.lastSync ? when(e.lastSync) : '없음'})`
322
+ : `${e.client}: ${e.state}`,
323
+ );
324
+ if (entries.some((e) => e.from === 'codex' && e.installed && !e.approved)) lines.push(codexHint(cli));
325
+ if (entries.some((e) => e.temp))
326
+ lines.push('주의: 임시 폴더(/tmp)를 가리키는 Lost Vibe 훅이 있어요. hooks install을 다시 실행하면 정리돼요.');
327
+ return lines;
90
328
  }
91
- export function hookStatus(o) {
92
- const registry = load(path.join(stateDir(o), 'hooks.json'), { entries: [] });
93
- return registry.entries.map((e) => ({
94
- ...e,
95
- installed:
96
- load(e.file).hooks?.[e.event]?.some((g) => g.hooks?.some((h) => h.command === e.command)) || false,
97
- }));
329
+ // What install did and what is left for the user.
330
+ export function installLines(o, installed, cli) {
331
+ const lines = [`자동 갱신 훅을 설치했어요: ${installed.join(', ')} (Stop, SessionEnd).`];
332
+ const entries = hookStatus(o).filter((e) => installed.includes(e.client));
333
+ const pending = entries.some((e) => e.from === 'codex' && !e.approved);
334
+ if (installed.includes('Claude Code')) lines.push('Claude Code는 승인이 필요 없어요. 다음 세션부터 자동으로 갱신돼요.');
335
+ if (pending) lines.push(`남은 일: ${codexHint(cli)}`);
336
+ else if (installed.includes('Codex')) lines.push('Codex는 이미 승인돼 있어요.');
337
+ return lines;
98
338
  }
package/src/scan.mjs CHANGED
@@ -30,7 +30,7 @@ function folderProject(cwd, workspaces, repoNames) {
30
30
  // A leftover copy or worktree folder such as app-backup belongs to app.
31
31
  const base = COPY_SUFFIX.test(lower) ? lower.replace(COPY_SUFFIX, '') : null;
32
32
  if (base && repoNames.has(base)) return repoNames.get(base);
33
- return { key: `folder:${lower}`, name, repoPath: null, remote: null };
33
+ return { key: `folder:${lower}`, name, repoPath: null, remote: null, folder: path.join(w, name) };
34
34
  }
35
35
  return null;
36
36
  }
@@ -81,7 +81,25 @@ function deviceZone(dir) {
81
81
  return zone;
82
82
  }
83
83
 
84
- export async function scanAll({ extraRoots = [], onProgress = () => {}, reportedFor = () => null, cacheDir = null, only = null } = {}) {
84
+ // Groups named in merge (other id -> main id) join the main group before totals are computed.
85
+ // When the main group is missing, the first group merged into it takes its place.
86
+ export function foldGroups(groups, merge) {
87
+ if (!merge?.size) return groups;
88
+ const byId = new Map(groups.map((g) => [g.id, g]));
89
+ const out = [];
90
+ for (const g of groups) {
91
+ const mainId = merge.get(g.id);
92
+ if (!mainId || mainId === g.id || merge.has(mainId)) { out.push(g); continue; }
93
+ const main = byId.get(mainId);
94
+ if (!main || main === g) { g.id = mainId; byId.set(mainId, g); out.push(g); continue; }
95
+ main.buckets.push(...g.buckets);
96
+ main.cwds.push(...g.cwds);
97
+ if (!main.folder && g.folder) main.folder = g.folder;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ export async function scanAll({ extraRoots = [], onProgress = () => {}, reportedFor = () => null, cacheDir = null, only = null, merge = null } = {}) {
85
103
  setZone(deviceZone(cacheDir));
86
104
  const cacheFile = cacheDir ? path.join(cacheDir, 'log-cache-v2.json') : null;
87
105
  const cache = (cacheFile && readJson(cacheFile)) || { files: {} };
@@ -117,6 +135,7 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
117
135
  if (!groups.has(r.key)) groups.set(r.key, { key: r.key, remote: r.remote, name: r.name, repos: new Set(), buckets: [], cwds: [] });
118
136
  const g = groups.get(r.key);
119
137
  if (r.repoPath) g.repos.add(r.repoPath);
138
+ if (r.folder && !g.folder) g.folder = r.folder;
120
139
  g.buckets.push(buckets.get(cwd));
121
140
  g.cwds.push(cwd);
122
141
  };
@@ -152,14 +171,20 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
152
171
  groups.get(r.key).repos.add(r.repoPath);
153
172
  }
154
173
 
174
+ // Every group's id is known before merging, so merged groups fold by id and only filters by id.
175
+ for (const g of groups.values()) {
176
+ const repos = [...g.repos];
177
+ g.repo = repos.length > 1 ? repos.map((p) => [p, commitCount(p)]).sort((a, b) => b[1] - a[1])[0][0] : repos[0] || null;
178
+ g.id = projectIdentity(g.repo, g.key);
179
+ }
155
180
  const projects = [];
156
- const list = [...groups.values()].filter((g) => !only || only.has(g.name.toLowerCase()) || [...g.repos].some((p) => only.has(path.basename(p).toLowerCase())) || only.has(projectIdentity([...g.repos][0] || null,g.key)));
181
+ const list = foldGroups([...groups.values()], merge).filter((g) => !only || only.has(g.id) || only.has(g.name.toLowerCase()) || [...g.repos].some((p) => only.has(path.basename(p).toLowerCase())));
157
182
  for (let i = 0; i < list.length; i++) {
158
183
  const g = list[i];
159
184
  onProgress(`프로젝트 ${i + 1}/${list.length}: ${g.name}`);
160
- const repo = [...g.repos].map((p) => [p, commitCount(p)]).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
185
+ const repo = g.repo;
161
186
  const name = repo ? path.basename(repo) : g.name;
162
- const id = projectIdentity(repo, g.key);
187
+ const id = g.id;
163
188
  const merged = mergeBuckets(g.buckets);
164
189
  const slug = githubSlug(g.remote);
165
190
  const gh = process.env.LOSTVIBE_SKIP_REMOTE === '1' ? null : await readGithub(slug.owner, slug.repo);
@@ -189,7 +214,9 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
189
214
  usage_days: usageDays(merged),
190
215
  sessions: merged.sessions.size,
191
216
  });
192
- projects[projects.length - 1]._local = { repo, cwds: g.cwds };
217
+ const folder = g.folder && fs.existsSync(g.folder) ? g.folder : null;
218
+ const local = repo || folder || g.cwds.find((c) => fs.existsSync(c)) || null;
219
+ projects[projects.length - 1]._local = { repo, cwds: g.cwds, folder: g.folder || null, path: local, exists: !!local, git: !!repo };
193
220
  }
194
221
  projects.sort((a, b) => (b.verified.ai?.tokens.total || 0) - (a.verified.ai?.tokens.total || 0) || (b.verified.git?.commits || 0) - (a.verified.git?.commits || 0));
195
222
 
@@ -204,6 +231,23 @@ export async function scanAll({ extraRoots = [], onProgress = () => {}, reported
204
231
  };
205
232
  }
206
233
 
234
+ // One row of `scan --all --json`. The path stays on this computer (scan-all.json, stdout).
235
+ export function scanEntry(p, i) {
236
+ const l = p._local || {};
237
+ return {
238
+ n: i + 1,
239
+ id: p.project.id,
240
+ name: p.project.name,
241
+ tokens: p.verified.ai?.tokens.total || 0,
242
+ last: p.verified.ai?.last_session || p.verified.git?.last_commit || null,
243
+ path: l.path || null,
244
+ exists: !!l.exists,
245
+ git: !!l.git,
246
+ setup: l.exists ? 'folder' : 'source',
247
+ url: p.verified.deploy?.url || null,
248
+ };
249
+ }
250
+
207
251
  export function stripLocal(p) {
208
252
  const { _local, ...rest } = p;
209
253
  return rest;
package/src/table.mjs CHANGED
@@ -69,10 +69,14 @@ export function projectTable(p) {
69
69
  }
70
70
 
71
71
  export function landTable(land) {
72
- const rows = land.projects.map((p) => {
72
+ const yes = (x) => (x === undefined ? '-' : x ? 'O' : 'X');
73
+ const rows = land.projects.map((p, i) => {
73
74
  const a = p.verified.ai; const g = p.verified.git; const c = p.verified.code;
74
75
  return [
76
+ String(i + 1),
75
77
  p.project.name,
78
+ yes(p._local?.exists),
79
+ yes(p._local?.git),
76
80
  a ? compact(a.tokens.total) : '-',
77
81
  a ? compact(splitTokens(a).claude) : '-',
78
82
  a ? compact(splitTokens(a).gpt) : '-',
@@ -88,9 +92,10 @@ export function landTable(land) {
88
92
  lastActive(p) || '-',
89
93
  ];
90
94
  });
91
- const head = ['프로젝트', '토큰', 'Claude', 'GPT', '캐시제외', '비용', '세션', '커밋', '일수', '줄', '배포', '공개', '별', '마지막'];
95
+ const head = ['#', '프로젝트', '폴더', 'Git', '토큰', 'Claude', 'GPT', '캐시제외', '비용', '세션', '커밋', '일수', '줄', '배포', '공개', '별', '마지막'];
96
+ const left = new Set([1, 2, 3, 14, 16]);
92
97
  const ws = head.map((h, i) => Math.max(width(h), ...rows.map((row) => width(row[i]))));
93
- const fmt = (row) => row.map((x, i) => (i === 0 || i === 11 || i === 13 ? pad(x, ws[i]) : lpad(x, ws[i]))).join(' ');
98
+ const fmt = (row) => row.map((x, i) => (left.has(i) ? pad(x, ws[i]) : lpad(x, ws[i]))).join(' ');
94
99
  return [fmt(head), ws.map((w) => '-'.repeat(w)).join(' '), ...rows.map(fmt)].join('\n');
95
100
  }
96
101