Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/vibe-sunsang-codex/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -42,4 +42,4 @@
"logo": "./assets/vibe-sunsang-codex.svg",
"screenshots": []
}
}
}
162 changes: 162 additions & 0 deletions plugins/vibe-sunsang-codex/scripts/analysis_scope.py
Original file line number Diff line number Diff line change
@@ -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())
123 changes: 123 additions & 0 deletions plugins/vibe-sunsang-codex/scripts/ensure_workspace.py
Original file line number Diff line number Diff line change
@@ -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 <fresh|v2|v1_migrated> CONFIG <present|absent>
호출부(스킬)는 이 한 줄만 보면 초기설정 여부를 판단할 수 있다.
"""

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())
14 changes: 14 additions & 0 deletions plugins/vibe-sunsang-codex/skills/vibe-sunsang-growth/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: 서브에이전트 위임

진행 메시지를 먼저 출력한다:
Expand Down Expand Up @@ -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축 데이터가 없을 수 있다. 이 경우 레벨과 요청 품질만 비교한다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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개 세션
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`로 저장한다.
Expand Down
8 changes: 8 additions & 0 deletions plugins/vibe-sunsang-codex/skills/vibe-sunsang-retro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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로 작업한 뒤 다시 와주세요." → 종료

Expand Down
Loading