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 +30 -0
- package/bin/kedovi.js +232 -44
- package/bin/kit.js +142 -22
- package/bin/models.js +19 -6
- package/docs/commands.md +11 -9
- package/docs/model-catalog.md +11 -6
- package/docs/notify.md +2 -2
- package/docs/pack-making.md +2 -2
- package/docs/packs.md +3 -3
- package/docs/quickstart.md +2 -0
- package/docs/rules.md +2 -0
- package/docs/team-models.md +8 -5
- package/docs/workflow.md +19 -18
- package/docs/workspace.md +40 -9
- package/package.json +1 -1
- package/packs/core/bin/board.js +3 -2
- package/packs/core/bin/doctor.js +36 -7
- package/packs/core/bin/repo-check.js +36 -0
- package/packs/core/bin/team-status.js +11 -3
- package/packs/core/hooks/repo-guard.js +155 -0
- package/packs/core/hooks/settings-hint.js +49 -0
- package/packs/core/hooks.json +2 -0
- package/packs/core/lib/team.js +100 -0
- package/packs/core/models.json +95 -48
- package/packs/core/pack.json +1 -1
- package/packs/core/rules/10-communication.md +2 -1
- package/packs/core/settings-map.json +202 -0
- package/packs/core/skills/kedovi/SKILL.md +14 -12
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…] 팀원 설정 수정 허용·불가 (불가면
|
|
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 저장소(키체인 · 자격 증명
|
|
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)를 설치된 킷 버전으로 한꺼번에 올린다.
|
|
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 '
|
|
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
|
-
// 만든 사람이
|
|
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 || {}),
|
|
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
|
-
/** 설정 잠금(팀원 설정 수정 불가)일 때
|
|
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
|
|
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 면
|
|
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.
|
|
1616
|
-
|
|
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' ? `✔ 팀원 설정 수정: 불가.
|
|
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(
|
|
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(`✔
|
|
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.
|
|
1647
|
-
if (sub === 'remove') team.members = team.members.filter((x) => x
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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})보다 세게만 둘 수 있습니다. 팀 값을 바꾸려면
|
|
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
|
-
|
|
2849
|
-
if (!
|
|
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: '
|
|
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' });
|