kedovi 0.76.0 → 0.77.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  `kedovi upgrade` 가 올린 버전 사이의 항목을 표로 보여 준다. 배포할 때 이번 버전 항목을 맨 위에 더한다 (`## 버전` 아래 `| 구분 | 내용 |` 표, 한 줄씩 짧게).
4
4
 
5
+ ## 0.77.1
6
+
7
+ | 구분 | 내용 |
8
+ |---|---|
9
+ | 수정 | 임시 폴더(시스템 임시 폴더 · `/private/tmp` · `/var/folders` · `~/.claude/jobs`)의 워크스페이스를 이 PC 목록에 넣지 않음. 이미 있으면 `kedovi list` · `upgrade --all` 때 정리 |
10
+ | 수정 | 공개판 CI 의 GitHub action 을 Node 24 판으로 (Node 20 지원 종료 경고), setup-node 자동 캐시 끔 |
11
+
12
+ ## 0.77.0
13
+
14
+ | 구분 | 내용 |
15
+ |---|---|
16
+ | 기능 | 팀장 · 팀원 · 담당 repo: `workspace.json` `team.leads` `[{ username, nickname }]` (모든 repo · 설정) · `team.members[].repos` (담당 repo 만, 한 repo 에 여러 명). 담당 없는 repo 는 누구나. 옛 `team.admins` 는 팀장으로 읽음 |
17
+ | 기능 | 담당 아닌 repo 수정 막기: Claude 편집 · Codex `apply_patch` · 셸 쓰기 명령(훅 `repo-guard`), repo 커밋(pre-commit). 읽기 · `git pull` 은 된다. 막을 때 담당자 · 팀장 이름으로 안내 |
18
+ | 기능 | 이 PC 사용자를 저장소 계정으로 확인 (ssh · gh · glab · git credential, 못 읽으면 한 번 묻기). `kedovi team whoami [--refresh]` |
19
+ | 기능 | 명령 `kedovi team lead add\|remove` · `leads` · `add <username> --nick 이름 --repos a,b` · `repos` · `nick` |
20
+ | 규칙 | 사람을 nickname 으로 부름 (개인 지침 「팀과 담당」). 관리자 → 팀장으로 용어 통일 |
21
+ | 기능 | 설정 안내: "왜 이래 · 이런 거 안 되나 · 매번 …" 같은 불만이 킷 설정으로 풀리면 설정 · 범위(이 PC · 팀) · 명령을 알리고 바꿀지 물음 (훅 `settings-hint`, 설정 지도 `packs/core/settings-map.json`) |
22
+ | 문서 | README 「플러그인 · MCP · 도구」 (필수 · 권장 · 선택), `kedovi doctor` 가 권장 플러그인 · MCP 점검 |
23
+ | 적용 | Codex 는 훅이 바뀌어 `/hooks` 에서 다시 신뢰해야 한다. 팀장은 `kedovi team lead add <계정> --nick 이름` 뒤 팀원 담당을 `kedovi team add <계정> --nick 이름 --repos …` 로 |
24
+
25
+ ## 0.76.1
26
+
27
+ | 구분 | 내용 |
28
+ |---|---|
29
+ | 모델 | Claude `haiku` = Haiku 5.5 (2026-10-07 출시, API $0.10·$0.50, effort 지원). 설명 · 가격 · 벤치마크 갱신 |
30
+ | 모델 | 표준 · 절약의 Claude 테스트 담당: GPT-6 Luna(Codex 위임) → Haiku 5.5 xhigh (LiveBench 같은 점수, 더 쌈, 위임 없음). 게이트웨이에서 haiku 가 4.5 면 GPT-6 Luna max |
31
+ | 모델 | Codex 선택지에 GPT-6.1 Terra (글쓰기 · 화면 이해 · 컴퓨터 사용, 추천에는 안 씀). GPT-6 Sol 은 Codex 목록에서 빠졌다고 표시 |
32
+ | 수정 | `npm run models:livebench`: LiveBench 데이터가 `/v1/` 로 옮겨 404 나던 것 |
33
+ | 적용 | 별칭을 쓰므로 `kedovi upgrade` 뒤 sync 만 하면 새 추천이 들어간다 (직접 고른 모델은 그대로) |
34
+
5
35
  ## 0.76.0
6
36
 
7
37
  | 구분 | 내용 |
package/bin/kedovi.js CHANGED
@@ -19,11 +19,14 @@
19
19
  * kedovi repo guides [<이름> follow|kit|ignore] [--rescan [<이름>]] repo 안에 원래 있던 AI 지침(CLAUDE.md · AGENTS.md · 룰 · 스킬 등) 보기 · 따르는 방식 정하기 (follow = repo 지침 우선, 기본 · kit = 킷 규칙 우선 · ignore = 무시) · 다시 훑기
20
20
  * kedovi team [id] [--take 1,2|all] 팀 현황 (모두의 핸드오프) · 한 사람 자세히 · 그 사람 TODO 항목을 내 TODO 로 인수
21
21
  * kedovi team add|remove <id…> · members · restrict on|off 팀 명단 (workspace.json team) · 명단 밖 사람의 kedovi start 막기
22
- * kedovi team edit allow|deny · admins [id…] 팀원 설정 수정 허용·불가 (불가면 관리자만 workspace.json·팩을 바꾼다)
22
+ * kedovi team edit allow|deny · admins [id…] 팀원 설정 수정 허용·불가 (불가면 팀장만 workspace.json·팩을 바꾼다)
23
+ * kedovi team lead add|remove <username> [--nick 이름] · leads 팀장 (모든 repo · 설정을 고친다)
24
+ * kedovi team add <username> [--nick 이름] [--repos a,b] · repos <username> <a,b|-> · nick <username> <이름> 팀원 · 담당 repo (담당 밖 repo 는 훅 · 커밋 검사가 막는다) · 부를 이름
25
+ * kedovi team whoami [--refresh] 이 PC 사용자 (저장소 계정 username · nickname · 팀장/팀원 · 담당 repo)
23
26
  * kedovi detect --hint <스택> --contains <정규식> 사내 라이브러리를 거쳐 쓰는 스택의 감지 힌트를 더한다
24
27
  * kedovi models [--migrate | --json] 모델 프로필·역할별·교차 검토 모델을 바꾼다 (화면 없이 실행하면 표, --json 은 JSON) / 퇴역 모델을 대체 모델로 일괄 교체
25
28
  * kedovi models profile <이름> [--team] · set <역할> <도구> <모델> [--team] [--effort e] 화면 없이 (AI 가 대신 실행). 내 프로필은 팀보다 낮은 것만
26
- * kedovi models allow-higher on|off 팀원이 팀보다 높은 프로필을 고를 수 있게 (팀, 관리자)
29
+ * kedovi models allow-higher on|off 팀원이 팀보다 높은 프로필을 고를 수 있게 (팀, 팀장)
27
30
  * kedovi agent --claude|--codex [--args "<인자>"] 화면 없이 메인 에이전트
28
31
  * kedovi list 이 PC 에서 만들거나 쓴 워크스페이스 목록
29
32
  * kedovi reset [항목,… | --all] [--yes] 이 PC 의 개인 설정을 처음 상태로 (이 PC 설정 · 텔레그램 채널 · 임시 기록 · 개인 지침 · 토큰 중 고른다). 팀 설정 · repos 는 그대로
@@ -45,13 +48,13 @@
45
48
  * kedovi board [--no-open] [--no-fetch] 워크스페이스 현황판: 로드맵 · 팀원별 마지막 핸드오프 · 미완 작업 · 공지 + 기록 쪽 (.agents/board/, 이 PC 만 · 보기 전용)
46
49
  * kedovi screens check [repo|폴더] 화면설계서 검사: 근거 ID 가 색인에 있는지 · 화면 ID 중복 · 기능 ID 누락 (screens 팩)
47
50
  * kedovi datamodel check [repo|폴더] 데이터 설계 검사: 참조 · 기본키 · 중복 · 표 형식 · 컨벤션 · 기능 ID 대조 (datamodel 팩)
48
- * kedovi secret set <이름> [--global] · list [--all] · test <이름> · rm <이름> [--global] · import [--global|--project] · exec [--only A,B] -- <명령> 비밀값(토큰): OS 저장소(키체인 · 자격 증명 관리자 · libsecret)에 프로젝트별 · 전역으로. 값은 숨김 입력, 어디에도 출력하지 않는다
51
+ * kedovi secret set <이름> [--global] · list [--all] · test <이름> · rm <이름> [--global] · import [--global|--project] · exec [--only A,B] -- <명령> 비밀값(토큰): OS 저장소(키체인 · 자격 증명 팀장 · libsecret)에 프로젝트별 · 전역으로. 값은 숨김 입력, 어디에도 출력하지 않는다
49
52
  * kedovi gitlab pipeline [--repo R] [--ref 브랜치 | --mr 번호 | --id ID] [--limit N] [--log [줄 수]] [--json] GitLab 파이프라인 조회 (gitlab 팩, 읽기만): 최근 목록 · 단계별 작업 상태 · 실패한 작업의 로그 끝부분. MR 은 머지 커밋의 파이프라인도 찾는다
50
53
  * kedovi prefs [language ko|en|team · style learn|normal|expert|team] [--team] · mermaid [install] · setup 응답 언어 · 대화 스타일(학습 · 일반 · 전문가) · 그림 미리보기(claude-mermaid). 이 PC 값이 팀 값보다 먼저. 인자 없이 = 지금 값
51
54
  * kedovi timestamp [on|off|team] [--team] 답변 첫 줄에 요청 받은 시각(yyyy-MM-dd HH:mm:ss) 표시 (Claude Code, 기본 켜짐). 이 PC 값이 팀 값보다 먼저, team = 이 PC 값 지우기. 인자 없이 = 지금 값
52
- * kedovi type [유형] [--approach a] [--code 이름,…] 유형 바꾸기: 분석·설계 → 구축 · 고도화 등. 유형 팩 · 현행 repo 중 고칠 것(개발 repo 로) · 진행 방식 · 로드맵 새 뼈대 (관리자)
53
- * kedovi approach [sprint|full|issue] 진행 방식: MVP · 스프린트(최소 기능부터, 스프린트마다 데모하며 하나씩) · 워터폴 · 전체(전체 설계 뒤 구축) · 이슈형(운영 · 유지보수, 이슈 보드). 바꾸면 로드맵도 맞춘다 (관리자)
54
- * kedovi team-mode [auto|fixed] · prefer <역할> <claude|codex>:<모델>|- · [--team] 팀 구성: 자동(리더가 작업마다 역할·모델, 요금제 여유도 반영) · 고정(프로필대로), 역할별 선호 모델. --team 은 팀 기본값(관리자)
55
+ * kedovi type [유형] [--approach a] [--code 이름,…] 유형 바꾸기: 분석·설계 → 구축 · 고도화 등. 유형 팩 · 현행 repo 중 고칠 것(개발 repo 로) · 진행 방식 · 로드맵 새 뼈대 (팀장)
56
+ * kedovi approach [sprint|full|issue] 진행 방식: MVP · 스프린트(최소 기능부터, 스프린트마다 데모하며 하나씩) · 워터폴 · 전체(전체 설계 뒤 구축) · 이슈형(운영 · 유지보수, 이슈 보드). 바꾸면 로드맵도 맞춘다 (팀장)
57
+ * kedovi team-mode [auto|fixed] · prefer <역할> <claude|codex>:<모델>|- · [--team] 팀 구성: 자동(리더가 작업마다 역할·모델, 요금제 여유도 반영) · 고정(프로필대로), 역할별 선호 모델. --team 은 팀 기본값(팀장)
55
58
  * kedovi review status · mark [--repo R] [--by "<누가>"] · skip --reason "<사유>" [--repo R] 리뷰 확인(commit · push 전에 리뷰를 거쳤나): 대상 repo 마다 리뷰 안 된 줄 수 · 지금 상태를 리뷰했다고 기록 · 한 번 생략(사용자 승인 뒤). 켜기는 team-mode enforce review
56
59
  * kedovi team-mode enforce review off|warn|block [--repos a,b|*] [--min-lines N] [--paths p1,p2] [--team] commit · push 전 리뷰 확인 (기본 꺼짐. Claude Code). 대상 · 기준 줄 수 · 경로는 팀 값(--team)
57
60
  * kedovi import <폴더> [--json] 기존 AI 작업 폴더 훑기 (읽기만): 지침 · 룰 · 스킬 · 메모 · 핸드오프 · 스크립트 · 큰 폴더 분류, 문서 속 절대경로, 비밀값 의심 줄(값은 가림). 옮기기는 채팅에서 import 스킬로 (계획 → 승인 → 반영)
@@ -65,7 +68,7 @@
65
68
  * kedovi team-mode enforce name off|warn|block · roster off|warn [--team] 훅 강제 수준. name: 팀 모드(Claude Code agent teams)에서 역할 에이전트를 이름 없이 띄우면 막기(기본) · 경고 · 끄기. roster: 한 요청의 첫 역할 에이전트 때 실행 방식 · 구성표를 알렸는지 한 줄(기본) · 끄기. 이 PC 값은 팀 값보다 세게만
66
69
  * kedovi sync | check | doctor | packs 생성물 다시 만들기 · 생성물 검사 · 환경 진단 · 팩 목록
67
70
  * kedovi upgrade [--add 팩] 이 킷 버전으로 워크스페이스의 bin·팩을 갱신
68
- * kedovi upgrade --all [--scan <폴더>] [--yes] 이 PC 의 워크스페이스(kedovi list)를 설치된 킷 버전으로 한꺼번에 올린다. 관리자 아님 · 원격에 받지 않은 커밋 · 커밋 안 한 변경이 있으면 건너뛴다. --scan 은 폴더 아래 워크스페이스를 찾아 목록에 더한다
71
+ * kedovi upgrade --all [--scan <폴더>] [--yes] 이 PC 의 워크스페이스(kedovi list)를 설치된 킷 버전으로 한꺼번에 올린다. 팀장 아님 · 원격에 받지 않은 커밋 · 커밋 안 한 변경이 있으면 건너뛴다. --scan 은 폴더 아래 워크스페이스를 찾아 목록에 더한다
69
72
  * kedovi help [번호|명령|--all] 명령 도움말: 인자 없이 = 자주 쓰는 명령 · 분류 목록 (터미널이면 분류를 골라 보기) · 번호 = 그 분류의 명령 · --all = 전체
70
73
  */
71
74
  'use strict';
@@ -393,8 +396,21 @@ function writeRegistry(list) {
393
396
  function workspaceName(root) {
394
397
  try { return JSON.parse(read(path.join(root, 'workspace.json'))).project.name || path.basename(root); } catch { return path.basename(root); }
395
398
  }
396
- /** 목록에 올린다 (이미 있으면 이름만 갱신) — 받은 경로를 그대로 돌려준다 */
399
+ /**
400
+ * 임시 폴더의 워크스페이스인가 — 시험 · 에이전트 작업이 만든 것. 이 PC 목록(kedovi list · upgrade --all)에 올리지 않는다.
401
+ * 시스템 임시 폴더(os.tmpdir · /tmp · /private/tmp · /var/folders · Windows TEMP)와 ~/.claude/jobs
402
+ */
403
+ function isTempWorkspace(dir) {
404
+ const os = require('os');
405
+ const real = (x) => { try { return fs.realpathSync(x); } catch { return path.resolve(x); } };
406
+ const p = real(dir);
407
+ const roots = [os.tmpdir(), '/tmp', '/private/tmp', '/var/folders', '/private/var/folders', process.env.TEMP, process.env.TMP, path.join(os.homedir(), '.claude', 'jobs')]
408
+ .filter(Boolean).map(real);
409
+ return roots.some((r) => p === r || p.startsWith(r + path.sep));
410
+ }
411
+ /** 목록에 올린다 (이미 있으면 이름만 갱신) — 받은 경로를 그대로 돌려준다. 임시 폴더는 올리지 않는다 */
397
412
  function remember(root) {
413
+ if (isTempWorkspace(root) && !process.env.KEDOVI_HOME) { writeShim(root); return root; }
398
414
  const all = readRegistry();
399
415
  const name = workspaceName(root);
400
416
  const old = all.find((w) => w.path === root);
@@ -422,10 +438,10 @@ function writeShim(root) {
422
438
  }
423
439
  } catch { /* 편의 기능 — 못 만들어도 명령은 계속 */ }
424
440
  }
425
- /** 아직 워크스페이스로 남아 있는 것만 — 지웠거나 옮긴 폴더는 목록에서 뺀다 */
441
+ /** 아직 워크스페이스로 남아 있는 것만 — 지웠거나 옮긴 폴더 · 임시 폴더는 목록에서 뺀다 */
426
442
  function liveRegistry() {
427
443
  const all = readRegistry();
428
- const live = all.filter((w) => fs.existsSync(path.join(w.path, 'workspace.json')));
444
+ const live = all.filter((w) => fs.existsSync(path.join(w.path, 'workspace.json')) && (process.env.KEDOVI_HOME || !isTempWorkspace(w.path)));
429
445
  if (live.length !== all.length) writeRegistry(live);
430
446
  return live;
431
447
  }
@@ -435,9 +451,9 @@ function wsVersion(root) {
435
451
  try { return (JSON.parse(read(path.join(root, 'workspace.json'))).kit || {}).version || null; } catch { return null; }
436
452
  }
437
453
 
438
- /** 지금 올리면 안 되는 이유 (없으면 null): 관리자 아님 · 원격에 받지 않은 커밋(팀원이 먼저 올렸을 수 있다) · 커밋 안 한 변경 */
454
+ /** 지금 올리면 안 되는 이유 (없으면 null): 팀장 아님 · 원격에 받지 않은 커밋(팀원이 먼저 올렸을 수 있다) · 커밋 안 한 변경 */
439
455
  function upgradeBlocker(root) {
440
- try { if (!kit.lockState(root, kit.loadWorkspace(root)).admin) return '관리자만 올릴 수 있음 (팀원 설정 수정이 막혀 있음)'; } catch { /* 옛 킷 */ }
456
+ try { if (!kit.lockState(root, kit.loadWorkspace(root)).admin) return '팀장만 올릴 수 있음 (팀원 설정 수정이 막혀 있음)'; } catch { /* 옛 킷 */ }
441
457
  const git = (...a) => spawnSync('git', ['-C', root, ...a], { encoding: 'utf8', timeout: 6000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
442
458
  if (git('rev-parse', '--is-inside-work-tree').status !== 0) return null;
443
459
  if (git('rev-parse', '--abbrev-ref', '@{u}').status === 0) {
@@ -1413,10 +1429,10 @@ async function cmdInit(argv) {
1413
1429
  if (leadId) {
1414
1430
  const f = path.join(dir, 'handoffs', leadId, 'TODO.md');
1415
1431
  if (!fs.existsSync(f)) { fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, `# TODO (${leadId})\n`); }
1416
- // 만든 사람이 관리자 — 팀원 설정 수정을 막을 때(kedovi team edit deny) 이 사람만 설정을 바꾼다
1432
+ // 만든 사람이 팀장 — 팀원 설정 수정을 막을 때(kedovi team edit deny) 이 사람만 설정을 바꾼다
1417
1433
  const wsFile = path.join(dir, 'workspace.json');
1418
1434
  const raw = JSON.parse(read(wsFile));
1419
- if (!(raw.team && (raw.team.admins || []).length)) { raw.team = { ...(raw.team || {}), admins: [leadId] }; fs.writeFileSync(wsFile, JSON.stringify(raw, null, 2) + '\n'); }
1435
+ if (!(raw.team && ((raw.team.admins || []).length || (raw.team.leads || []).length))) { const acct = detectAccount(dir); raw.team = { ...(raw.team || {}), leads: [acct ? { username: acct.username } : { username: leadId, source: 'git' }] }; if (acct) { writeLocal(dir, { me: { ...acct, checked: new Date().toISOString().slice(0, 10) } }); fs.writeFileSync(path.join(dir, 'handoffs', leadId, '.account'), `${acct.username}\n`); } fs.writeFileSync(wsFile, JSON.stringify(raw, null, 2) + '\n'); }
1420
1436
  }
1421
1437
  // repo 별 상태: clone 실패 · 빠진 repo 는 질문 화면들 뒤로 밀려 안 보이므로 끝에 다시 보인다 (다시 받기 = kedovi install, sync 는 받지 않는다)
1422
1438
  const repoNote = repoStatus(dir, res.repos || []);
@@ -1590,49 +1606,128 @@ function teamOf(root) {
1590
1606
  try { return JSON.parse(read(path.join(root, 'workspace.json'))).team || {}; } catch { return {}; }
1591
1607
  }
1592
1608
 
1593
- /** 설정 잠금(팀원 설정 수정 불가)일 때 관리자만. 아니면 멈춘다. soft 면 경고하고 false */
1609
+ /** 설정 잠금(팀원 설정 수정 불가)일 때 팀장만. 아니면 멈춘다. soft 면 경고하고 false */
1594
1610
  function requireAdmin(root, what, { soft = false } = {}) {
1595
1611
  let st = { admin: true };
1596
1612
  try { st = kit.lockState(root, kit.loadWorkspace(root)); } catch { /* 옛 킷 */ }
1597
1613
  if (st.admin) return true;
1598
- const msg = `팀원 설정 수정이 막혀 있습니다 (관리자: ${(teamOf(root).admins || []).join(', ')}). ${what} 은(는) 관리자에게 요청하세요`;
1614
+ const t = teamOf(root);
1615
+ const leads = [...(t.leads || []), ...(t.admins || [])].map((x) => (typeof x === 'string' ? x : x.nickname || x.username));
1616
+ const msg = `팀원 설정 수정이 막혀 있습니다 (팀장: ${leads.join(', ')}). ${what} 은(는) 팀장에게 요청하세요`;
1599
1617
  if (soft) { p.log.warn(msg); return false; }
1600
1618
  fail(msg);
1601
1619
  }
1602
1620
 
1621
+ // 이 PC 사용자 확인 — 워크스페이스 repo 원격의 저장소 계정 username. ssh 면 ssh -T 인사말, https 면 gh · glab · git credential 의 사용자 이름
1622
+ const NOT_USER = new Set(['oauth2', 'x-access-token', 'gitlab-ci-token', 'personalaccesstoken', 'token', 'git']);
1623
+ function detectAccount(root) {
1624
+ const url = (spawnSync('git', ['-C', root, 'remote', 'get-url', 'origin'], { encoding: 'utf8' }).stdout || '').trim();
1625
+ if (!url) return null;
1626
+ const ssh = url.match(/^ssh:\/\/([\w.-]+)@([^:/]+)(?::(\d+))?\//) || url.match(/^([\w.-]+)@([^:/]+):/);
1627
+ // 로그인 창 · 브라우저가 뜨지 않게 (Git Credential Manager · askpass)
1628
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
1629
+ delete env.GIT_ASKPASS; delete env.SSH_ASKPASS;
1630
+ const run = (cmd, args, input) => { try { const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: 8000, input, env }); return `${r.stdout || ''}\n${r.stderr || ''}`; } catch { return ''; } };
1631
+ if (ssh && !/^https?:/.test(url)) {
1632
+ const [, user, host, port] = ssh;
1633
+ const out = run('ssh', ['-T', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', ...(port ? ['-p', port] : []), `${user}@${host}`]);
1634
+ const m = out.match(/Hi ([\w.-]+)!/) || out.match(/Welcome to GitLab, @([\w.-]+)!/) || out.match(/logged in as ([\w.-]+)/i);
1635
+ return m ? { username: m[1], host, source: 'ssh' } : null;
1636
+ }
1637
+ let host = '';
1638
+ try { host = new URL(url).hostname; } catch { return null; }
1639
+ if (/github/.test(host)) {
1640
+ const u = run('gh', ['api', 'user', '--hostname', host, '--jq', '.login']).trim().split('\n')[0];
1641
+ if (/^[\w.-]+$/.test(u)) return { username: u, host, source: 'gh' };
1642
+ } else {
1643
+ const j = run('glab', ['api', 'user', '--hostname', host]);
1644
+ const m = j.match(/"username"\s*:\s*"([\w.-]+)"/);
1645
+ if (m) return { username: m[1], host, source: 'glab' };
1646
+ }
1647
+ const cred = run('git', ['-C', root, 'credential', 'fill'], `protocol=https\nhost=${host}\n\n`).match(/^username=(.+)$/m);
1648
+ if (cred && !NOT_USER.has(cred[1].trim().toLowerCase())) return { username: cred[1].trim(), host, source: 'git credential' };
1649
+ return null;
1650
+ }
1651
+
1652
+ /** 이 PC 사용자 저장 (.agents/local.json me). 이미 있고 refresh 가 아니면 그대로. 못 찾으면 화면에서 한 번 묻는다 */
1653
+ async function ensureAccount(root, { refresh = false, ask = process.stdin.isTTY } = {}) {
1654
+ const cur = readLocal(root).me;
1655
+ const today = new Date().toISOString().slice(0, 10);
1656
+ if (cur && cur.username && !refresh) return cur;
1657
+ // 못 찾은 기록이 있으면 7일 동안 다시 묻지 않는다 (start 마다 느려지지 않게. 다시: kedovi team whoami --refresh)
1658
+ if (cur && cur.source === 'none' && !refresh && (Date.now() - Date.parse(cur.checked || 0)) < 7 * 864e5) return null;
1659
+ const found = detectAccount(root);
1660
+ let me = found ? { ...found, checked: today } : null;
1661
+ if (!me && ask) {
1662
+ p.log.info('워크스페이스 원격에서 저장소 계정을 읽지 못했습니다. 팀장 · 담당 repo 판정에 쓰니 직접 적어 주세요 (나중에 다시 확인: kedovi team whoami --refresh)');
1663
+ const v = await clack().text({ message: '저장소 계정 username', placeholder: '예: hong01' });
1664
+ if (!clack().isCancel(v) && String(v || '').trim()) me = { username: String(v).trim(), host: '', source: 'input', checked: today };
1665
+ }
1666
+ if (!me) { if (ask) writeLocal(root, { me: { source: 'none', checked: today } }); return null; } // 물을 수 없던 실행(화면 없음)은 기록하지 않는다
1667
+ writeLocal(root, { me });
1668
+ return me;
1669
+ }
1670
+
1671
+ /**
1672
+ * 계정을 찾으면: (1) 핸드오프 폴더에 계정을 적는다 (팀 현황이 git user.name 폴더 ↔ username 을 잇는다. 합류 기록 · 다음 핸드오프와 함께 커밋된다)
1673
+ * (2) 팀 설정에 이 PC 가 git user.name 으로 적혀 있으면 계정으로 바꾸는 명령을 알린다 (자동으로 고치지 않는다: 다른 사람이 이름만 바꿔 그 자리를 가져가지 못하게)
1674
+ */
1675
+ function adoptAccount(root, username) {
1676
+ const gitName = memberId(root);
1677
+ if (!gitName || !username) return;
1678
+ const dir = path.join(root, 'handoffs', gitName);
1679
+ try { if (fs.existsSync(dir) && (fs.existsSync(path.join(dir, '.account')) ? read(path.join(dir, '.account')).trim() : '') !== username) fs.writeFileSync(path.join(dir, '.account'), `${username}\n`); } catch { /* 편의 */ }
1680
+ if (gitName === username) return;
1681
+ const t = teamOf(root);
1682
+ const all = [...(t.leads || []), ...(t.admins || []), ...(t.members || [])];
1683
+ if (all.some((x) => sameUser(x, username)) || !all.some((x) => sameUser(x, gitName))) return;
1684
+ const lead = [...(t.leads || []), ...(t.admins || [])].some((x) => sameUser(x, gitName));
1685
+ p.log.info(lead
1686
+ ? `팀 설정의 팀장 ${gitName} 이(가) git user.name 으로 적혀 있습니다. 저장소 계정으로 바꾸세요: kedovi team lead add ${username} --nick <이름> 뒤 kedovi team lead remove ${gitName}`
1687
+ : `팀 설정에 ${gitName} 이(가) git user.name 으로 적혀 있습니다. 팀장에게 저장소 계정 ${username} 으로 바꿔 달라고 요청하세요 (kedovi team add ${username} --nick <이름> --repos …)`);
1688
+ }
1689
+
1690
+ /** 사람 항목 다루기 — 문자열(옛 형식)도 객체로 맞춘다 */
1691
+ const personOf = (x) => (typeof x === 'string' ? { username: x } : { ...x });
1692
+ const sameUser = (x, u) => personOf(x).username === u;
1693
+ function optVal(args, name) { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }
1694
+
1603
1695
  /** 리더용 — 명단 추가·삭제·보기, 명단 제한 켜기·끄기. workspace.json 을 고치고 commit · push 를 안내한다 */
1604
1696
  function cmdTeamAdmin(root, [sub, ...ids]) {
1697
+ if (sub === 'whoami') return cmdWhoami(root, ids);
1698
+ if (['lead', 'leads', 'repos', 'nick'].includes(sub) || (sub === 'add' && (ids.includes('--nick') || ids.includes('--repos')))) return cmdTeamPeople(root, [sub, ...ids]);
1605
1699
  const file = path.join(root, 'workspace.json');
1606
1700
  const raw = JSON.parse(read(file));
1607
1701
  const team = raw.team || {};
1608
1702
  team.members = team.members || [];
1609
1703
  if (sub !== 'members' && !(sub === 'admins' && !ids.length)) requireAdmin(root, `kedovi team ${sub}`);
1610
1704
  if (sub === 'edit') {
1611
- // 팀원 설정 수정 허용·불가. deny 면 관리자만 workspace.json·팩·생성물을 바꾸고, 팀원은 자기 handoffs/ 와 로드맵(handoffs/PLAN.md)만 커밋한다
1705
+ // 팀원 설정 수정 허용·불가. deny 면 팀장만 workspace.json·팩·생성물을 바꾸고, 팀원은 자기 handoffs/ 와 로드맵(handoffs/PLAN.md)만 커밋한다
1612
1706
  const v = ids[0];
1613
1707
  if (!['allow', 'deny'].includes(v)) fail('사용법: kedovi team edit allow|deny');
1614
- const me = memberId(root);
1615
- team.admins = team.admins && team.admins.length ? team.admins : me ? [me] : [];
1616
- if (v === 'deny' && !team.admins.length) fail('관리자가 없습니다. 먼저 git config user.name 을 설정하거나 kedovi team admins <id…>');
1708
+ const me = (readLocal(root).me || {}).username || memberId(root);
1709
+ if (!(team.leads || []).length && !(team.admins || []).length && me) team.leads = [{ username: me, ...((readLocal(root).me || {}).username ? {} : { source: 'git' }) }];
1710
+ const leadNames = [...(team.leads || []), ...(team.admins || [])].map((x) => personOf(x).nickname || personOf(x).username);
1711
+ if (v === 'deny' && !leadNames.length) fail('팀장이 없습니다. 먼저 kedovi team lead add <username>');
1617
1712
  team.lock = v === 'deny';
1618
1713
  raw.team = team;
1619
1714
  fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
1620
1715
  runWorkspaceKit(root, ['sync', '--quiet']);
1621
- console.log(v === 'deny' ? `✔ 팀원 설정 수정: 불가. 관리자(${team.admins.join(', ')})만 workspace.json · 팩 · 생성물을 바꾸고, 팀원은 자기 handoffs/ 와 로드맵(handoffs/PLAN.md)만 커밋한다` : '✔ 팀원 설정 수정: 허용 (누구나)');
1716
+ console.log(v === 'deny' ? `✔ 팀원 설정 수정: 불가. 팀장(${leadNames.join(', ')})만 workspace.json · 팩 · 생성물을 바꾸고, 팀원은 자기 handoffs/ 와 로드맵(handoffs/PLAN.md)만 커밋한다` : '✔ 팀원 설정 수정: 허용 (누구나)');
1622
1717
  console.log(' workspace.json 이 바뀌었습니다. commit · push 하면 팀에 적용됩니다. 서버에서 확실히 막으려면 README 의 GitLab 설정 참고');
1623
1718
  return;
1624
1719
  }
1625
1720
  if (sub === 'admins') {
1626
- if (!ids.length) { console.log(`관리자: ${(team.admins || []).join(', ') || '(없음)'} · 팀원 설정 수정: ${team.lock ? '불가' : '허용'}`); return; }
1721
+ if (!ids.length) { console.log(`팀장: ${[...(team.leads || []), ...(team.admins || [])].map((x) => personOf(x).nickname || personOf(x).username).join(', ') || '(없음)'} · 팀원 설정 수정: ${team.lock ? '불가' : '허용'}`); return; }
1627
1722
  team.admins = [...new Set(ids)];
1628
1723
  raw.team = team;
1629
1724
  fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
1630
1725
  runWorkspaceKit(root, ['sync', '--quiet']);
1631
- console.log(`✔ 관리자: ${team.admins.join(', ')}. workspace.json 을 commit · push 하면 팀에 적용됩니다`);
1726
+ console.log(`✔ 팀장: ${team.admins.join(', ')}. workspace.json 을 commit · push 하면 팀에 적용됩니다`);
1632
1727
  return;
1633
1728
  }
1634
1729
  if (sub === 'members') {
1635
- console.log(team.members.length ? `팀 명단 (${team.members.length}명): ${team.members.join(', ')}${team.restrict ? '\n명단 제한: 켜짐. 명단 밖 사람은 kedovi start 가 멈춘다' : ''}` : '팀 명단이 비어 있습니다. kedovi team add <id> (id = 그 사람 워크스페이스의 git user.name)');
1730
+ console.log(team.members.length ? `팀 명단 (${team.members.length}명): ${team.members.map((x) => { const q = personOf(x); return `${q.nickname ? `${q.nickname}(${q.username})` : q.username}${q.repos ? ` · 담당 ${q.repos.join(', ')}` : ''}`; }).join(', ')}${team.restrict ? '\n명단 제한: 켜짐. 명단 밖 사람은 kedovi start 가 멈춘다' : ''}` : '팀 명단이 비어 있습니다. kedovi team add <id> (id = 그 사람 워크스페이스의 git user.name)');
1636
1731
  return;
1637
1732
  }
1638
1733
  if (sub === 'restrict') {
@@ -1643,24 +1738,100 @@ function cmdTeamAdmin(root, [sub, ...ids]) {
1643
1738
  } else {
1644
1739
  if (!ids.length) fail(`사용법: kedovi team ${sub} <id…>`);
1645
1740
  for (const id of ids) {
1646
- if (sub === 'add' && !team.members.includes(id)) team.members.push(id);
1647
- if (sub === 'remove') team.members = team.members.filter((x) => x !== id);
1741
+ if (sub === 'add' && !team.members.some((x) => sameUser(x, id))) team.members.push({ username: id }); // 새로 넣는 사람은 계정 username 항목 (문자열은 옛 git user.name 형식)
1742
+ if (sub === 'remove') team.members = team.members.filter((x) => !sameUser(x, id));
1648
1743
  }
1649
1744
  if (!team.members.length) delete team.restrict;
1650
1745
  }
1651
1746
  raw.team = team;
1652
1747
  fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n');
1653
- console.log(`✔ 팀 명단: ${team.members.join(', ') || '(없음)'}${team.restrict ? ' · 명단 제한 켜짐' : ''}`);
1748
+ console.log(`✔ 팀 명단: ${team.members.map((x) => personOf(x).nickname || personOf(x).username).join(', ') || '(없음)'}${team.restrict ? ' · 명단 제한 켜짐' : ''}`);
1654
1749
  console.log(' workspace.json 이 바뀌었습니다. commit · push 하면 팀에 적용됩니다');
1655
1750
  }
1656
1751
 
1752
+ /** 팀 판정 모듈: 워크스페이스 것, 옛 워크스페이스면 킷 것 */
1753
+ function teamMod(root) {
1754
+ const own = path.join(root, 'packs', 'core', 'lib', 'team.js');
1755
+ return require(fs.existsSync(own) ? own : path.join(KIT_ROOT, 'packs', 'core', 'lib', 'team.js'));
1756
+ }
1757
+
1758
+ async function cmdWhoami(root, args) {
1759
+ if (args.includes('--refresh')) {
1760
+ const before = (readLocal(root).me || {}).username;
1761
+ const me = await ensureAccount(root, { refresh: true });
1762
+ if (me && me.username) adoptAccount(root, me.username);
1763
+ if ((me || {}).username !== before) runWorkspaceKit(root, ['sync', '--quiet']);
1764
+ }
1765
+ const ws = kit.loadWorkspace(root);
1766
+ const T = teamMod(root);
1767
+ const w = T.whoAmI(root, ws);
1768
+ const blocked = T.blockedRepos(root, ws);
1769
+ console.log(`이 PC 사용자: ${w.nickname ? `${w.nickname} · ` : ''}${w.username || '(모름)'} (확인: ${w.source})`);
1770
+ console.log(`역할: ${w.lead ? '팀장 (모든 repo · 설정 수정)' : w.person ? `팀원 · 담당 ${(w.repos || []).join(', ') || '없음'}` : '명단에 없음'}`);
1771
+ if (blocked.length) console.log(`고칠 수 없는 repo: ${blocked.join(', ')} (담당자 · 팀장에게 요청)`);
1772
+ if (w.source === 'git user.name') console.log(' 저장소 계정을 아직 확인하지 못했습니다: kedovi team whoami --refresh');
1773
+ }
1774
+
1775
+ /** 팀장 · 팀원 · 담당 repo · 부를 이름 (workspace.json team.leads · members) */
1776
+ function cmdTeamPeople(root, [sub, ...args]) {
1777
+ const file = path.join(root, 'workspace.json');
1778
+ const raw = JSON.parse(read(file));
1779
+ const team = raw.team || {};
1780
+ const ws = kit.loadWorkspace(root);
1781
+ const repoNames = new Set(ws.repos.map((r) => r.name));
1782
+ const nick = optVal(args, '--nick');
1783
+ const reposArg = optVal(args, '--repos');
1784
+ const pos = args.filter((a, i) => !a.startsWith('--') && !['--nick', '--repos'].includes(args[i - 1]));
1785
+ const show = () => {
1786
+ const T = teamMod(root);
1787
+ const { leads, members } = T.roster({ team });
1788
+ console.log(`팀장: ${leads.map((x) => `${T.callName(x)}(${x.username})`).join(', ') || '(없음)'}`);
1789
+ for (const m of members) console.log(` 팀원 ${T.callName(m)}(${m.username}) · 담당 ${(m.repos || []).join(', ') || '-'}`);
1790
+ };
1791
+ if (sub === 'leads') return show();
1792
+ requireAdmin(root, `kedovi team ${sub}`);
1793
+ const save = (msg) => { raw.team = team; fs.writeFileSync(file, JSON.stringify(raw, null, 2) + '\n'); runWorkspaceKit(root, ['sync', '--quiet']); console.log(`✔ ${msg}`); show(); console.log(' workspace.json 이 바뀌었습니다. commit · push 하면 팀에 적용됩니다'); };
1794
+ const checkRepos = (list) => { const bad = list.filter((x) => !repoNames.has(x)); if (bad.length) fail(`repos 에 없는 이름: ${bad.join(', ')} (있는 repo: ${[...repoNames].join(', ')})`); return list; };
1795
+ if (sub === 'lead') {
1796
+ const [act, user] = pos;
1797
+ if (!['add', 'remove'].includes(act) || !user) fail('사용법: kedovi team lead add|remove <username> [--nick 이름]');
1798
+ team.leads = (team.leads || []).map(personOf).filter((x) => x.username !== user);
1799
+ team.admins = (team.admins || []).filter((x) => x !== user);
1800
+ if (!team.admins.length) delete team.admins;
1801
+ if (act === 'add') team.leads.push({ username: user, ...(nick ? { nickname: nick } : {}) });
1802
+ if (act === 'remove' && !team.leads.length && !(team.admins || []).length && team.lock) fail('마지막 팀장은 뺄 수 없습니다 (설정 잠금이 켜져 있음). 다른 팀장을 먼저 넣으세요');
1803
+ return save(act === 'add' ? `팀장 추가: ${nick || user}` : `팀장에서 뺌: ${user}`);
1804
+ }
1805
+ const [user, val] = pos;
1806
+ if (!user) fail(`사용법: kedovi team ${sub === 'nick' ? 'nick <username> <이름>' : sub === 'repos' ? 'repos <username> <a,b|->' : 'add <username> [--nick 이름] [--repos a,b]'}`);
1807
+ team.members = (team.members || []).map(personOf);
1808
+ let m = team.members.find((x) => x.username === user);
1809
+ if (!m && sub !== 'nick') { m = { username: user }; team.members.push(m); }
1810
+ if (sub === 'nick') {
1811
+ if (!val) fail('사용법: kedovi team nick <username> <이름>');
1812
+ if ((team.admins || []).includes(user)) { team.admins = team.admins.filter((x) => x !== user); if (!team.admins.length) delete team.admins; team.leads = [...(team.leads || []), { username: user }]; } // 옛 팀장 → 팀장 항목으로
1813
+ const l = (team.leads || []).map(personOf).find((x) => x.username === user);
1814
+ if (l) { team.leads = team.leads.map((x) => (sameUser(x, user) ? { ...personOf(x), nickname: val } : x)); return save(`부를 이름: ${user} → ${val}`); }
1815
+ if (!m) fail(`${user} 은(는) 팀장 · 팀원 명단에 없습니다. 먼저 kedovi team add ${user} --nick ${val}`);
1816
+ m.nickname = val;
1817
+ return save(`부를 이름: ${user} → ${val}`);
1818
+ }
1819
+ if (nick) m.nickname = nick;
1820
+ const rv = sub === 'repos' ? val : reposArg;
1821
+ if (rv !== undefined) {
1822
+ if (rv === '-' || rv === '') delete m.repos; else m.repos = checkRepos(String(rv).split(',').map((x) => x.trim()).filter(Boolean));
1823
+ }
1824
+ return save(sub === 'repos' ? `${m.nickname || user} 담당: ${(m.repos || []).join(', ') || '없음'}` : `팀원: ${m.nickname || user}${m.repos ? ` · 담당 ${m.repos.join(', ')}` : ''}`);
1825
+ }
1826
+
1657
1827
  /** 합류 기록 — install 때 묻고, 예면 handoffs/<id>/TODO.md 를 만들어 워크스페이스 repo 에 push. 팀 현황에 바로 보인다 */
1658
1828
  async function joinTeam(root) {
1659
1829
  const me = memberId(root);
1660
1830
  if (!me) { p.log.warn('워크스페이스의 git user.name 이 없어 팀 합류 기록을 남기지 못했습니다. git config user.name <id> 후 kedovi install'); return; }
1661
1831
  const git = (...a) => spawnSync('git', ['-C', root, ...a], { encoding: 'utf8' });
1662
1832
  const team = teamOf(root);
1663
- if ((team.members || []).length && !team.members.includes(me)) {
1833
+ const meAcct = (readLocal(root).me || {}).username;
1834
+ if ((team.members || []).length && !team.members.some((x) => sameUser(x, me) || (meAcct && sameUser(x, meAcct))) && ![...(team.leads || []), ...(team.admins || [])].some((x) => sameUser(x, me) || (meAcct && sameUser(x, meAcct)))) {
1664
1835
  p.log.warn(`${me} 은(는) 팀 명단에 없습니다. 리더에게 명단 추가(kedovi team add ${me})를 요청하세요${team.restrict ? ' (명단 제한이 켜져 있어 그 전에는 kedovi start 가 멈춥니다)' : ''}`);
1665
1836
  }
1666
1837
  const rel = `handoffs/${me}/TODO.md`;
@@ -1674,7 +1845,12 @@ async function joinTeam(root) {
1674
1845
  const file = path.join(root, rel);
1675
1846
  fs.mkdirSync(path.dirname(file), { recursive: true });
1676
1847
  if (!fs.existsSync(file)) fs.writeFileSync(file, `# TODO (${me})\n`);
1677
- const steps = [['add', '--', rel], ['commit', '-q', '-m', `docs: ${me} 팀 합류`, '--', rel], ['pull', '-q', '--rebase', '--autostash'], ['push', '-q']];
1848
+ // 저장소 계정도 함께 (팀 현황이 git user.name 폴더 ↔ 명단 username 을 잇는다)
1849
+ const acct = (readLocal(root).me || {}).username;
1850
+ const accRel = `handoffs/${me}/.account`;
1851
+ if (acct) fs.writeFileSync(path.join(root, accRel), `${acct}\n`);
1852
+ const paths = acct ? [rel, accRel] : [rel];
1853
+ const steps = [['add', '--', ...paths], ['commit', '-q', '-m', `docs: ${me} 팀 합류`, '--', ...paths], ['pull', '-q', '--rebase', '--autostash'], ['push', '-q']];
1678
1854
  for (const st of steps) {
1679
1855
  const r = git(...st);
1680
1856
  if (r.status !== 0) { p.log.warn(`합류 기록을 올리지 못했습니다 (git ${st[0]}): ${(r.stderr || '').trim().split('\n').pop()}. 나중에 첫 핸드오프 때 함께 올라갑니다`); return; }
@@ -2112,7 +2288,7 @@ function migrateModels(root) {
2112
2288
  * 화면 없이 모델 바꾸기 (AI 가 대신 실행할 때) — 반환: 처리했으면 true
2113
2289
  * kedovi models profile [rich|standard|lean|team] [--team] 프로필 (team = 이 PC 값 지우고 팀 기본 따름, 호환: - · 팀). 이 PC 값은 팀보다 낮은 것만. 인자 없이 실행하면 화면에서 고른다
2114
2290
  * kedovi models set <main|crossReview|역할> <claude|codex> <모델|-> [--team] [--effort <e>] 모델 (- = 직접 지정 지우기, 위임은 codex:<모델>)
2115
- * kedovi models allow-higher on|off 팀원이 팀보다 높은 프로필을 둘 수 있게 (workspace.json agents.allowHigherProfile, 관리자)
2291
+ * kedovi models allow-higher on|off 팀원이 팀보다 높은 프로필을 둘 수 있게 (workspace.json agents.allowHigherProfile, 팀장)
2116
2292
  */
2117
2293
  async function modelsCommand(root, argv) {
2118
2294
  const [sub, ...rest] = argv;
@@ -2162,7 +2338,7 @@ async function modelsCommand(root, argv) {
2162
2338
  const clear = CLEAR.includes(v);
2163
2339
  if (!team && !clear && !profileAllowed(root, cat, v)) {
2164
2340
  const { profile } = kit.resolveModels(root, kit.loadWorkspace(root));
2165
- fail(`이 PC 프로필은 팀 프로필(${kit.profileLabel(cat, profile.team)})과 같거나 낮은 것만 됩니다. 팀 값을 올리려면 kedovi models profile ${v} --team, 팀원에게 높은 프로필을 허용하려면 kedovi models allow-higher on (관리자)`);
2341
+ fail(`이 PC 프로필은 팀 프로필(${kit.profileLabel(cat, profile.team)})과 같거나 낮은 것만 됩니다. 팀 값을 올리려면 kedovi models profile ${v} --team, 팀원에게 높은 프로필을 허용하려면 kedovi models allow-higher on (팀장)`);
2166
2342
  }
2167
2343
  saveProfile(root, team ? 'team' : 'local', clear ? '' : v);
2168
2344
  } else {
@@ -2329,7 +2505,7 @@ function cmdApproach(argv) {
2329
2505
  }
2330
2506
 
2331
2507
  /**
2332
- * 유형 바꾸기 (관리자): 분석·설계를 끝내고 같은 워크스페이스에서 구축 · 고도화로 넘어갈 때 등.
2508
+ * 유형 바꾸기 (팀장): 분석·설계를 끝내고 같은 워크스페이스에서 구축 · 고도화로 넘어갈 때 등.
2333
2509
  * 유형 팩 교체 · 필수 repo(현행 repo 중 고칠 것을 개발 repo 로) · 진행 방식 · 로드맵(새 뼈대, 지난 로드맵은 아래에 남김) → sync
2334
2510
  */
2335
2511
  async function cmdType(argv) {
@@ -2422,7 +2598,7 @@ function cmdTimestamp(argv) {
2422
2598
  const m = val(readLocal(root));
2423
2599
  const now = m !== undefined ? m : t !== undefined ? t : true;
2424
2600
  console.log(`답변 첫 줄 시각 표시 (Claude Code): ${now ? '켜짐' : '꺼짐'} (이 PC ${word(m)} · 팀 ${word(t)} · 기본 켜짐)`);
2425
- console.log(' 바꾸기: kedovi timestamp on|off · 팀 기본값: --team (관리자) · 이 PC 값 지우기: kedovi timestamp team');
2601
+ console.log(' 바꾸기: kedovi timestamp on|off · 팀 기본값: --team (팀장) · 이 PC 값 지우기: kedovi timestamp team');
2426
2602
  };
2427
2603
  if (!want) return show();
2428
2604
  if (!['on', 'off', 'team'].includes(want) || (want === 'team' && team)) fail('사용법: kedovi timestamp [on|off] [--team] · team (이 PC 값을 지워 팀 값을 따름)');
@@ -2475,7 +2651,7 @@ function showPrefs(root) {
2475
2651
  console.log(`대화 스타일: ${S(r.style)} (${where[r.from.style]}) · 이 PC ${S(r.local.style)} · 팀 ${S(r.team.style)}`);
2476
2652
  const m = mermaidInstalled();
2477
2653
  console.log(`그림 미리보기(claude-mermaid): ${m === null ? '확인 못 함 (Claude Code 없음)' : m ? '설치됨: 세션에 mermaid_preview 가 있으면 브라우저로 띄움' : '없음: 텍스트 그림으로 (설치: kedovi prefs mermaid install)'}`);
2478
- console.log(' 바꾸기: kedovi prefs language ko|en · kedovi prefs style learn|normal|expert · 이 PC 값 지우기: team · 팀 기본값: --team (관리자)');
2654
+ console.log(' 바꾸기: kedovi prefs language ko|en · kedovi prefs style learn|normal|expert · 이 PC 값 지우기: team · 팀 기본값: --team (팀장)');
2479
2655
  }
2480
2656
 
2481
2657
  /** 저장 → sync. key = language | style, value = 값 | 'team'(이 PC 값 지우기) */
@@ -2594,7 +2770,7 @@ function cmdTeamMode(argv) {
2594
2770
  const lv = (v) => kit.ENFORCE_LEVELS.indexOf(v);
2595
2771
  if (!key || lv(a2) < 0 || lv(a2) > lv(kit.ENFORCE_DEFAULTS[key].max)) fail('사용법: kedovi team-mode enforce name off|warn|block · roster off|warn · review off|warn|block [--repos a,b|*] [--min-lines N] [--paths p1,p2] · idle off|warn|block [--after 분] [--remind 분] · status off|block · ask off|block · run off|warn|block [--after 분] · check off|warn|block · plan off|warn|block [--team] (name: 팀 모드에서 이름 없는 역할 에이전트 · roster: 첫 역할 에이전트 때 구성표 알림 · review: commit · push 전 리뷰 확인 · idle: 대기로 오래 남은 팀원 알림 · status: 남은 것이 있는 채 멈출 때 상태 줄 · ask: 결정 질문을 글로 끝낼 때 · run: 오래 일하는 팀원 · 위임 · check: 리뷰 전 검사 · plan: 코드 수정 전 착수 전 확인)');
2596
2772
  const teamNow = kit.resolveModels(root, JSON.parse(read(wsFile))).teamEnforce[key];
2597
- if (!team && lv(a2) < lv(teamNow)) fail(`이 PC 값은 팀 값(${teamNow})보다 세게만 둘 수 있습니다. 팀 값을 바꾸려면 관리자가 kedovi team-mode enforce ${a1} ${a2} --team`);
2773
+ if (!team && lv(a2) < lv(teamNow)) fail(`이 PC 값은 팀 값(${teamNow})보다 세게만 둘 수 있습니다. 팀 값을 바꾸려면 팀장이 kedovi team-mode enforce ${a1} ${a2} --team`);
2598
2774
  // 리뷰 확인의 대상 · 기준은 팀 값만
2599
2775
  const extra = {};
2600
2776
  if (a1 === 'review') {
@@ -2602,7 +2778,7 @@ function cmdTeamMode(argv) {
2602
2778
  if (optAt('--repos') !== undefined) extra.reviewRepos = list(optAt('--repos'));
2603
2779
  if (optAt('--min-lines') !== undefined) { const n = Number(optAt('--min-lines')); if (!Number.isInteger(n) || n < 0) fail('--min-lines 는 0 이상의 정수'); extra.reviewMinLines = n; }
2604
2780
  if (optAt('--paths') !== undefined) extra.reviewPaths = optAt('--paths') === '-' ? [] : list(optAt('--paths'));
2605
- if (Object.keys(extra).length && !team) fail('리뷰 확인의 대상(--repos) · 기준 줄 수(--min-lines) · 경로(--paths)는 팀 값입니다. --team 을 붙이세요 (관리자)');
2781
+ if (Object.keys(extra).length && !team) fail('리뷰 확인의 대상(--repos) · 기준 줄 수(--min-lines) · 경로(--paths)는 팀 값입니다. --team 을 붙이세요 (팀장)');
2606
2782
  const known = (JSON.parse(read(wsFile)).repos || []).map((r) => r.name);
2607
2783
  const bad = (extra.reviewRepos || []).filter((n) => n !== '*' && !known.includes(n));
2608
2784
  if (bad.length) fail(`workspace.json repos 에 없는 이름: ${bad.join(', ')} (있는 것: ${known.join(', ') || '없음'})`);
@@ -2614,12 +2790,12 @@ function cmdTeamMode(argv) {
2614
2790
  if (!Number.isInteger(n) || n < 1) fail(`${opt} 는 1 이상의 정수(분)`);
2615
2791
  extra[field] = n;
2616
2792
  }
2617
- if (Object.keys(extra).length && !team) fail('대기 상한(--after) · 다시 알리는 간격(--remind)은 팀 값입니다. --team 을 붙이세요 (관리자)');
2793
+ if (Object.keys(extra).length && !team) fail('대기 상한(--after) · 다시 알리는 간격(--remind)은 팀 값입니다. --team 을 붙이세요 (팀장)');
2618
2794
  }
2619
2795
  if (a1 === 'run' && optAt('--after') !== undefined) {
2620
2796
  const n = Number(optAt('--after'));
2621
2797
  if (!Number.isInteger(n) || n < 1) fail('--after 는 1 이상의 정수(분)');
2622
- if (!team) fail('실행 상한(--after)은 팀 값입니다. --team 을 붙이세요 (관리자)');
2798
+ if (!team) fail('실행 상한(--after)은 팀 값입니다. --team 을 붙이세요 (팀장)');
2623
2799
  extra.runMinutes = n;
2624
2800
  }
2625
2801
  edit((x) => ({ ...x, enforce: { ...(x.enforce || {}), [key]: a2, ...extra } }));
@@ -2832,6 +3008,7 @@ async function cmdInstall() {
2832
3008
  clack();
2833
3009
  ask(await chooseAgent(root, { force: !readLocal(root).agent ? true : false, progress: true }));
2834
3010
  await choosePrefs(root, { onlyMissing: true });
3011
+ await ensureAccount(root);
2835
3012
  await joinTeam(root);
2836
3013
  const cur = readLocal(root);
2837
3014
  p.outro(`메인 에이전트: ${AGENTS[cur.agent].label}${cur.args && cur.args.length ? ` (${cur.args.join(' ')})` : ''}: 바꾸려면 kedovi agent`);
@@ -2843,10 +3020,21 @@ async function cmdStart(argv) {
2843
3020
  const root = locateWorkspace();
2844
3021
  // 리더가 명단 제한을 켰으면 명단에 없는 사람은 시작하지 않는다 (안전장치 — 권한 통제는 GitLab·GitHub 멤버로)
2845
3022
  const team = teamOf(root);
3023
+ // 팀이 있으면 이 PC 사용자(저장소 계정)를 한 번 확인해 둔다 — 팀장 · 담당 repo 판정과 nickname 부르기에 쓴다
3024
+ if ((team.leads || []).length || (team.members || []).length || (team.admins || []).length) {
3025
+ const before = (readLocal(root).me || {}).username;
3026
+ const acct = await ensureAccount(root);
3027
+ if (acct && acct.username) adoptAccount(root, acct.username); // 핸드오프 폴더의 .account 가 없으면 만든다
3028
+ // 계정이 새로 잡혔거나 팀 설정(담당 repo 등)이 git pull 로 바뀌었으면 이 PC 의 편집 금지 · 지침을 다시 맞춘다
3029
+ const hash = require('crypto').createHash('sha1').update(JSON.stringify(team)).digest('hex').slice(0, 12);
3030
+ if ((acct && acct.username !== before) || readLocal(root).teamHash !== hash) { runWorkspaceKit(root, ['sync', '--quiet']); writeLocal(root, { teamHash: hash }); }
3031
+ }
2846
3032
  if (team.restrict && (team.members || []).length) {
2847
3033
  const me = memberId(root);
2848
- if (!me) fail('이 워크스페이스는 팀 명단에 있는 사람만 시작합니다. 먼저 git config user.name <id> 를 설정하세요');
2849
- if (!team.members.includes(me)) fail(`${me} 은(는) 이 워크스페이스 팀 명단에 없습니다. 리더에게 명단 추가(kedovi team add ${me})를 요청하세요 (명단: ${team.members.join(', ')})`);
3034
+ const acct = (readLocal(root).me || {}).username;
3035
+ if (!me && !acct) fail('이 워크스페이스는 팀 명단에 있는 사람만 시작합니다. 먼저 kedovi team whoami --refresh 로 저장소 계정을 확인하세요');
3036
+ const listed = [...team.members, ...(team.leads || []), ...(team.admins || [])].some((x) => sameUser(x, me) || (acct && sameUser(x, acct)));
3037
+ if (!listed) fail(`${acct || me} 은(는) 이 워크스페이스 팀 명단에 없습니다. 팀장에게 명단 추가(kedovi team add ${acct || me} --nick 이름)를 요청하세요 (명단: ${team.members.map((x) => personOf(x).nickname || personOf(x).username).join(', ')})`);
2850
3038
  }
2851
3039
  const dash = argv.indexOf('--');
2852
3040
  const extra = dash >= 0 ? argv.slice(dash + 1) : [];
@@ -3179,7 +3367,7 @@ const NOTIFY_USAGE = `사용법
3179
3367
  kedovi notify my channel on [--new]|off|status 양방향 대화 (기본 꺼짐. 이 프로젝트 전용 봇 → Claude Code 세션. 봇 토큰은 숨김 입력)
3180
3368
  kedovi notify my channel ask dialog|text 질문 방식: 선택지 창(기본) · 텔레그램 버튼 질문(= 자리 비움)
3181
3369
  kedovi notify my channel progress <분|off> 입력 없이 오래 작업하면 진행 현황 (기본 5분)
3182
- kedovi notify team setup [--new] 팀 봇 연결 (관리자에게 받은 토큰을 숨김 입력에)
3370
+ kedovi notify team setup [--new] 팀 봇 연결 (팀장에게 받은 토큰을 숨김 입력에)
3183
3371
  kedovi notify team chat <그룹방 ID|off> 팀 방 지정 (팀 설정: commit · push)
3184
3372
  kedovi notify team announce "<내용>" · test`;
3185
3373
 
@@ -3277,7 +3465,7 @@ async function cmdNotifySetup() {
3277
3465
  }
3278
3466
  } else if (pick === 'team') {
3279
3467
  const tm = await choose({ message: '팀 방 알림', hint: '핸드오프 push · 설정 변경 push · 공지를 팀 그룹방으로.', initialValue: team.token ? 'chat' : 'bot', options: [
3280
- { value: 'bot', label: '팀 봇 연결', hint: '관리자에게 받은 팀 봇 토큰' },
3468
+ { value: 'bot', label: '팀 봇 연결', hint: '팀장에게 받은 팀 봇 토큰' },
3281
3469
  { value: 'chat', label: '팀 방 지정', hint: '팀 설정 (workspace.json, commit · push)' },
3282
3470
  { value: 'off', label: '팀 방 알림 끄기', hint: '팀 설정' },
3283
3471
  ...(team.token ? [{ value: 'remove', label: '팀 봇 제거', hint: '이 PC 의 팀 봇 토큰을 지운다' }] : [])] });
@@ -3286,7 +3474,7 @@ async function cmdNotifySetup() {
3286
3474
  else if (tm === 'remove') runTool(['remove', '--team']);
3287
3475
  else {
3288
3476
  const v = await input(tm === 'bot'
3289
- ? { message: '팀 봇 토큰', hint: '관리자에게 받은 토큰. 봇을 팀 방에 초대하고 방에서 아무 말이나 해 두면 방 ID 를 보여 준다. 입력은 가려집니다.', placeholder: '123456789:AA…', secret: true }
3477
+ ? { message: '팀 봇 토큰', hint: '팀장에게 받은 토큰. 봇을 팀 방에 초대하고 방에서 아무 말이나 해 두면 방 ID 를 보여 준다. 입력은 가려집니다.', placeholder: '123456789:AA…', secret: true }
3290
3478
  : { message: '팀 방 ID', hint: '팀 봇 연결 때 보여 준 그룹방 ID (-100…).', placeholder: '-100…' });
3291
3479
  if (v !== BACK && String(v || '').trim()) { if (tm === 'bot') runTool(['setup', '--team', '--new'], String(v).trim()); else runTool(['team-chat', String(v).trim()]); }
3292
3480
  }
@@ -3528,7 +3716,7 @@ async function main() {
3528
3716
  const root = locateWorkspace();
3529
3717
  const tool = path.join(root, 'packs', 'core', 'bin', 'team-status.js');
3530
3718
  if (!fs.existsSync(tool)) fail('이 워크스페이스에는 팀 현황 도구가 없습니다. kedovi upgrade 후 다시 하세요');
3531
- if (['add', 'remove', 'restrict', 'members', 'edit', 'admins'].includes(rest[0])) return cmdTeamAdmin(root, rest);
3719
+ if (['add', 'remove', 'restrict', 'members', 'edit', 'admins', 'lead', 'leads', 'repos', 'nick', 'whoami'].includes(rest[0])) return cmdTeamAdmin(root, rest);
3532
3720
  const [id, ...more] = rest;
3533
3721
  const toolArgs = !id || id.startsWith('--') ? rest : more.includes('--take') ? ['--take', id, '--items', more[more.indexOf('--take') + 1] || ''] : ['--id', id, ...more];
3534
3722
  const r = spawnSync(process.execPath, [tool, ...toolArgs], { cwd: root, stdio: 'inherit' });