diff --git a/plugins/vibe-sunsang-codex/.codex-plugin/plugin.json b/plugins/vibe-sunsang-codex/.codex-plugin/plugin.json index bd92e5a..899a63a 100644 --- a/plugins/vibe-sunsang-codex/.codex-plugin/plugin.json +++ b/plugins/vibe-sunsang-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe-sunsang-codex", - "version": "2.1.4", + "version": "2.2.0", "description": "Codex-native AI collaboration mentor using local Codex session history.", "author": { "name": "chulrolee" @@ -42,4 +42,4 @@ "logo": "./assets/vibe-sunsang-codex.svg", "screenshots": [] } -} \ No newline at end of file +} diff --git a/plugins/vibe-sunsang-codex/scripts/analysis_scope.py b/plugins/vibe-sunsang-codex/scripts/analysis_scope.py new file mode 100644 index 0000000..cf983df --- /dev/null +++ b/plugins/vibe-sunsang-codex/scripts/analysis_scope.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +""" +바선생 분석 범위 계산기 (증분 워터마크 + 마지막-활동 기준 필터). + +문제: 기존 스킬은 파일명(세션 *시작일*) 기준으로 범위를 잡아, 오래전 시작해 +이번 주에 이어간 세션을 놓쳤고, "지난 분석 이후 새로 생긴 것만" 개념이 없었다. + +해결: 각 변환 md의 frontmatter `end`(마지막 활동)를 활동 시각으로 삼는다. +- `--new` : analysis_state.json의 last_analyzed_at 이후로 활동한 세션. + (start > watermark = 신규, start <= watermark < end = 이어짐 → 둘 다 포착) +- `--window-days N` : 최근 N일 내 활동한 세션. +- `--mark` : 대상 세션들의 최대 end로 워터마크를 갱신한다. + +출력: 매칭 세션의 md 경로 목록 + 마지막 줄 SUMMARY. +""" + +import argparse +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def parse_frontmatter(md_path: Path) -> dict: + """md 파일 상단 --- ... --- 프론트매터를 얕게 파싱.""" + fm: dict = {} + try: + with open(md_path, "r", encoding="utf-8") as f: + first = f.readline() + if first.strip() != "---": + return fm + for line in f: + if line.strip() == "---": + break + if ":" in line and not line.startswith((" ", "-", "\t")): + key, _, val = line.partition(":") + fm[key.strip()] = val.strip() + except OSError: + pass + return fm + + +def to_dt(s: str): + """'YYYY-MM-DD HH:MM' 또는 'YYYY-MM-DD'를 aware datetime(UTC)으로.""" + if not s: + return None + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + return None + + +def collect_sessions(conv_dir: Path) -> list: + """conversations/*/*.md 세션들의 (path, start, end, project, messages) 수집.""" + out = [] + for md in sorted(conv_dir.rglob("*.md")): + if md.name == "INDEX.md": + continue + fm = parse_frontmatter(md) + start = to_dt(fm.get("start") or fm.get("date", "")) + end = to_dt(fm.get("end") or fm.get("start") or fm.get("date", "")) + out.append({ + "path": str(md), + "project": fm.get("project", md.parent.name), + "start": start, + "end": end, + "messages": int(fm.get("messages", "0") or 0), + }) + return out + + +def load_state(state_file: Path) -> dict: + if state_file.exists(): + try: + return json.load(open(state_file, encoding="utf-8")) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def main() -> int: + ap = argparse.ArgumentParser(description="바선생 분석 범위 계산기") + ap.add_argument("--conversations-dir", type=Path, + default=Path.home() / "vibe-sunsang" / "conversations") + ap.add_argument("--state-file", type=Path, + default=Path.home() / "vibe-sunsang" / "config" / "analysis_state.json") + group = ap.add_mutually_exclusive_group() + group.add_argument("--new", action="store_true", + help="지난 분석 이후 활동한 세션만") + group.add_argument("--window-days", type=int, default=None, + help="최근 N일 내 활동한 세션만") + ap.add_argument("--now", type=str, default=None, + help="기준 시각(테스트용, 'YYYY-MM-DD HH:MM'). 기본: 현재 UTC") + ap.add_argument("--mark", action="store_true", + help="대상 세션의 최대 end로 워터마크 갱신") + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + conv_dir = args.conversations_dir.expanduser() + if not conv_dir.exists(): + print("SUMMARY total=0 matched=0 new=0 continued=0 watermark=none") + return 0 + + now = (to_dt(args.now) if args.now else None) or datetime.now(timezone.utc) + sessions = collect_sessions(conv_dir) + state = load_state(args.state_file.expanduser()) + watermark = to_dt(state.get("last_analyzed_at", "")) + + matched = [] + new_count = continued_count = 0 + for s in sessions: + act = s["end"] or s["start"] + if act is None: + continue + keep = False + if args.window_days is not None: + keep = act >= now - timedelta(days=args.window_days) + elif args.new: + keep = watermark is None or act > watermark + else: + keep = True # 범위 미지정 → 전체 + if keep: + matched.append(s) + if args.new and watermark is not None and s["start"] and s["start"] <= watermark: + continued_count += 1 + else: + new_count += 1 + + for s in matched: + print(s["path"]) + + # 워터마크 갱신 + new_watermark = state.get("last_analyzed_at", "") + if args.mark: + ends = [s["end"] for s in sessions if s["end"]] + if ends: + mx = max(ends) + new_watermark = mx.strftime("%Y-%m-%d %H:%M") + state["last_analyzed_at"] = new_watermark + args.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(args.state_file, "w", encoding="utf-8") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + + wm = watermark.strftime("%Y-%m-%d %H:%M") if watermark else "none" + if args.json: + print(json.dumps({ + "total": len(sessions), + "matched": len(matched), + "new": new_count, + "continued": continued_count, + "watermark_before": wm, + "watermark_after": new_watermark or "none", + }, ensure_ascii=False)) + print(f"SUMMARY total={len(sessions)} matched={len(matched)} " + f"new={new_count} continued={continued_count} watermark={wm}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/vibe-sunsang-codex/scripts/ensure_workspace.py b/plugins/vibe-sunsang-codex/scripts/ensure_workspace.py new file mode 100644 index 0000000..c56dd7d --- /dev/null +++ b/plugins/vibe-sunsang-codex/scripts/ensure_workspace.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +바선생 워크스페이스 상태 보증기 (self-healing). + +모든 스킬(retro/mentor/growth/onboard)의 Step 0에서 호출한다. +- v2 레이아웃(config/ conversations/ exports/ growth-log/)을 보장한다. +- v1 번호 접두사 레이아웃(10-scripts/ 30-growth-log/ 40-conversations/ 90-exports/)이 + 감지되면 데이터를 비파괴적으로 이관한다. **기존 파일은 절대 덮어쓰지 않는다.** +- v1 config(10-scripts/*.json)를 config/로 이관한다 — 구 onboard 마이그레이션이 + 누락하던 유실 결함을 여기서 수정한다. + +출력(마지막 줄, `--json` 시 JSON): + STATUS CONFIG +호출부(스킬)는 이 한 줄만 보면 초기설정 여부를 판단할 수 있다. +""" + +import argparse +import json +import shutil +import sys +from pathlib import Path + +V1_DATA_DIRS = { + "40-conversations": "conversations", + "90-exports": "exports", + "30-growth-log": "growth-log", +} +V1_CONFIG_DIR = "10-scripts" +CONFIG_FILES = ("project_names.json", "workspace_types.json") + + +def merge_move(src: Path, dst: Path, actions: list) -> None: + """src의 모든 파일을 dst로 이동한다. dst에 이미 있는 파일은 건드리지 않는다.""" + if not src.exists(): + return + for item in sorted(src.rglob("*")): + if item.is_dir(): + continue + rel = item.relative_to(src) + target = dst / rel + if target.exists(): + continue # 비파괴: 기존 데이터 보존 + target.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(item), str(target)) + actions.append(f"move {item} -> {target}") + + +def ensure(base: Path) -> dict: + base = base.expanduser() + actions: list = [] + + # v2 표준 디렉토리 보장 + for d in ("config", "conversations", "exports", "growth-log/weekly"): + p = base / d + if not p.exists(): + p.mkdir(parents=True, exist_ok=True) + actions.append(f"mkdir {p}") + + # v1 데이터 디렉토리 이관 (비파괴 병합) + for old, new in V1_DATA_DIRS.items(): + old_p = base / old + if old_p.exists(): + merge_move(old_p, base / new, actions) + + # v1 config 이관 — 복사(원본은 백업으로 보존) + for name in CONFIG_FILES: + src = base / V1_CONFIG_DIR / name + dst = base / "config" / name + if src.exists() and not dst.exists(): + shutil.copy2(str(src), str(dst)) + actions.append(f"copy {src} -> {dst}") + + # 데이터를 옮기고 비어버린 v1 디렉토리 정리 (비어있을 때만 rmdir) + for old in V1_DATA_DIRS: + old_p = base / old + if old_p.exists(): + for sub in sorted(old_p.rglob("*"), reverse=True): + if sub.is_dir() and not any(sub.iterdir()): + sub.rmdir() + if not any(old_p.iterdir()): + old_p.rmdir() + actions.append(f"rmdir empty {old_p}") + + migrated = any(a.startswith(("move ", "copy ")) for a in actions) + config_present = any((base / "config" / n).exists() for n in CONFIG_FILES) + + if migrated: + status = "v1_migrated" + elif config_present: + status = "v2" + else: + status = "fresh" + + return { + "base": str(base), + "status": status, + "config_present": config_present, + "actions": actions, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description="바선생 워크스페이스 상태 보증기") + ap.add_argument("--base", type=Path, default=Path.home() / "vibe-sunsang", + help="워크스페이스 루트 (기본: ~/vibe-sunsang)") + ap.add_argument("--json", action="store_true", help="JSON으로 상세 출력") + args = ap.parse_args() + + result = ensure(args.base) + + if args.json: + print(json.dumps(result, ensure_ascii=False, indent=2)) + else: + for a in result["actions"]: + print(f" {a}") + # 스킬이 파싱하는 상태 라인 (항상 마지막) + print(f"STATUS {result['status']} CONFIG " + f"{'present' if result['config_present'] else 'absent'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-growth/SKILL.md b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-growth/SKILL.md index 77aafe9..8a1a418 100644 --- a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-growth/SKILL.md +++ b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-growth/SKILL.md @@ -27,11 +27,21 @@ Codex는 skill-first다 (`commands/` 없음). 객관식은 Codex CLI에 카드 U ## Step 0: 사전 확인 +`python3 "$PLUGIN_ROOT/scripts/ensure_workspace.py"`로 워크스페이스를 자동 치유한다. `CONFIG absent`면 온보딩으로 넘기고, `v1_migrated`면 비파괴 이관 사실을 안내한 뒤 계속한다. + `~/vibe-sunsang/config/workspace_types.json`이 있는지 확인한다. 없으면: > "아직 바선생 초기 설정이 안 됐어요. '바선생 시작'(vibe-sunsang-onboard)을 먼저 실행해주세요." → 종료 ## Step 1: 범위 선택 +기본 범위는 지난 리뷰 이후다. 먼저 다음을 실행해 신규/이어진 세션을 계산한다: + +```bash +python3 "$PLUGIN_ROOT/scripts/analysis_scope.py" --new +``` + +첫 리뷰이거나 사용자가 다른 범위를 지정한 경우에만 아래 범위 선택을 사용한다. + 사용자가 범위를 명시했으면 그대로 사용한다. 없으면 `shared/questioning-policy.md §A` 번호 블록: ```text @@ -61,6 +71,8 @@ Codex는 skill-first다 (`commands/` 없음). 객관식은 Codex CLI에 카드 U python3 "$PLUGIN_ROOT/skills/vibe-sunsang-retro/scripts/convert_sessions.py" --output-dir "$HOME/vibe-sunsang/conversations" 2>/dev/null || python "$PLUGIN_ROOT/skills/vibe-sunsang-retro/scripts/convert_sessions.py" --output-dir "$HOME/vibe-sunsang/conversations" ``` +변환 후 `analysis_scope.py --new`를 다시 실행해 최종 대상을 확정한다. + ## Step 4: 서브에이전트 위임 진행 메시지를 먼저 출력한다: @@ -123,6 +135,8 @@ Codex는 skill-first다 (`commands/` 없음). 객관식은 Codex CLI에 카드 U ## Gotchas +리포트가 정상 저장된 뒤에만 `python3 "$PLUGIN_ROOT/scripts/analysis_scope.py" --mark`로 워터마크를 전진시킨다. 저장 실패 시에는 갱신하지 않는다. + - 서브에이전트가 TIMELINE.md 업데이트에 실패할 수 있다. Step 6에서 반드시 확인하고 보완한다. - 첫 리포트면 종단 요약 대신 "첫 리포트가 생성되었습니다. 다음 리포트부터 6축 성장 추이를 비교할 수 있어요."라고 안내한다. - v1.x 이전 리포트와 종단 비교 시 6축 데이터가 없을 수 있다. 이 경우 레벨과 요청 품질만 비교한다. diff --git a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-mentor/SKILL.md b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-mentor/SKILL.md index e684baa..432100c 100644 --- a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-mentor/SKILL.md +++ b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-mentor/SKILL.md @@ -148,6 +148,8 @@ Codex는 skill-first다 (`commands/` 없음). 본진 `/vibe-sunsang` 커맨드 ### Step 3: 세션 데이터 수집 +먼저 `python3 "$PLUGIN_ROOT/scripts/ensure_workspace.py"`로 워크스페이스를 자동 치유하고, `python3 "$PLUGIN_ROOT/scripts/analysis_scope.py" --new`로 지난 리뷰 이후 활동 세션을 우선 계산한다. 본문 전체를 먼저 읽지 말고 frontmatter의 `start`/`end`/`messages`로 1차 선별한 뒤 필요한 구간만 읽는다. + 1. `~/vibe-sunsang/conversations/INDEX.md`를 읽어 최신 상태 확인. 변환된 대화가 없으면 vibe-sunsang-retro의 변환기를 먼저 돌린다. 2. 모드에 따라 범위 선택: - 모드 A, B: 최근 3~5개 세션 diff --git a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-onboard/SKILL.md b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-onboard/SKILL.md index 010b7e8..da4d607 100644 --- a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-onboard/SKILL.md +++ b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-onboard/SKILL.md @@ -44,6 +44,8 @@ mkdir -p "$HOME/vibe-sunsang/config" "$HOME/vibe-sunsang/conversations" "$HOME/v ## Step 0.5: 워크스페이스 환경 구성 +먼저 `python3 "$PLUGIN_ROOT/scripts/ensure_workspace.py"`를 실행한다. 이 스크립트는 v2 디렉토리를 보장하고 v1의 대화·리포트·성장로그·설정 JSON을 기존 파일을 덮어쓰지 않고 이관한다. `STATUS v1_migrated`면 이관 사실을 안내하며, 나머지 v1 잔여물은 자동 삭제하지 않는다. + **CLAUDE.md 생성** — `~/vibe-sunsang/CLAUDE.md`가 없으면: 1. `$PLUGIN_ROOT/skills/vibe-sunsang-onboard/references/CLAUDE-MD-TEMPLATE.md`를 읽는다 (인라인 하드코딩 금지). 2. `~/vibe-sunsang/CLAUDE.md`로 저장한다. diff --git a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/SKILL.md b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/SKILL.md index 3d2bd09..82e4990 100644 --- a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/SKILL.md +++ b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/SKILL.md @@ -20,6 +20,14 @@ Codex는 skill-first다 (`commands/` 없음). 객관식은 Codex CLI에 카드 U ## Step 0: 사전 확인 +먼저 워크스페이스를 비파괴적으로 자동 치유한다: + +```bash +python3 "$PLUGIN_ROOT/scripts/ensure_workspace.py" +``` + +`STATUS v1_migrated`면 이관 사실을 한 줄 안내하고 계속한다. `CONFIG absent`면 온보딩으로 넘기되, 단순히 설정 오류로 종료하지 않는다. + `~/.codex/sessions/`에 JSONL이 있는지 확인한다. 없으면: > "아직 Codex 대화 기록이 없어요. Codex로 작업한 뒤 다시 와주세요." → 종료 diff --git a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/scripts/convert_sessions.py b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/scripts/convert_sessions.py index 2a7d371..6b6701f 100755 --- a/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/scripts/convert_sessions.py +++ b/plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/scripts/convert_sessions.py @@ -26,7 +26,7 @@ def text_from_content(content: Any) -> str: def parse_session(path: Path) -> dict[str, Any]: - meta: dict[str, Any] = {"id": path.stem, "timestamp": "", "cwd": ""} + meta: dict[str, Any] = {"id": path.stem, "timestamp": "", "end": "", "cwd": ""} messages: list[dict[str, str]] = [] for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): @@ -37,6 +37,9 @@ def parse_session(path: Path) -> dict[str, Any]: except json.JSONDecodeError: continue + event_time = event.get("timestamp") or "" + if event_time and event_time > (meta.get("end") or ""): + meta["end"] = event_time event_type = event.get("type") payload = event.get("payload") or {} @@ -76,7 +79,16 @@ def write_session(session: dict[str, Any], output_dir: Path) -> Path: safe_id = str(meta.get("id") or session["path"].stem).replace("/", "-") out = output_dir / f"{stamp}-{safe_id}.md" + start = (meta.get("timestamp") or "")[:16].replace("T", " ") + end = (meta.get("end") or meta.get("timestamp") or "")[:16].replace("T", " ") lines = [ + "---", + f"project: {Path(meta.get('cwd') or 'unknown-workspace').name}", + f"start: {start}", + f"end: {end}", + f"messages: {len(session['messages'])}", + "---", + "", f"# {title_for(session)}", "", f"- Source: `{session['path']}`",