Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
369a37a
feat(engine): 공식 구독 훅 useEngineStore / useEngineSelector
Apr 19, 2026
eda557c
refactor(useAria): 렌더 트리거를 useSyncExternalStore 경로로 교체
Apr 19, 2026
2b180ce
refactor(useAriaZone): store prop 선택적 — engine 구독 기본 경로로
Apr 19, 2026
f48c9a5
chore(routes): 실험 홀딩 라우트 정리 — /incident-legacy·/kanban 제거, orphan 폴더 삭제
Apr 20, 2026
2258901
refactor(ui): TreeGrid를 Simple/Row/Cell/Columns로 분할
Apr 20, 2026
2c2ea6e
feat(json-editor): FlatLayout 기반 재구성 + schema 생성기
Apr 20, 2026
b9d6b28
feat(ui): KeyHintBar + Kbd/Tooltip/SplitPane 다듬기
Apr 20, 2026
ca40247
feat(mockup): gmail fidelity ladder 실험 (low/mid/hi) + MockupBar + gua…
Apr 20, 2026
c3f8b80
docs(prd): studio + cmux 통합 PRD
Apr 20, 2026
2570914
feat(studio): /a2ui + /playground → /studio 통합
Apr 20, 2026
fb88727
feat(cmux): /chat + /chat/entities + /cmux/preview → /cmux 통합
Apr 20, 2026
f1f0514
fix(appshell): useTheme의 callback 불안정성으로 인한 전체 앱 remount 차단
Apr 20, 2026
98b9e0e
feat(os): defineFeature + defineApp 마켓플레이스 조립 뼈대
Apr 20, 2026
f0778bd
feat(os): defineFeature + defineApp 마켓플레이스 조립 뼈대
Apr 20, 2026
71a0802
feat(features): BookFeature/MillerFeature tsx 전환, Public ax 축 준수
Apr 20, 2026
d61313d
feat(feature): featureRegistryToPlugin 어댑터 — Feature keymap을 engine P…
Apr 20, 2026
2e4fc92
feat(baseline): BaselineFinderApp + /feature-finder 라우트 — defineFeatu…
Apr 20, 2026
ab91e69
feat(book): onKeyDown 대신 Prev/Next 버튼 + initialFocus 제거로 TabList 전환 작동
Apr 20, 2026
1933f53
feat(baseline): Settings 버튼 + Feature install/uninstall 체크박스
Apr 20, 2026
a10253a
feat(sidebar): FavoritesFeature 추가 — RECENT + FAVORITES 사이드바 기여
Apr 20, 2026
31786fb
fix(lint): BookFeature/MillerFeature eslint-disable + BaselineFinderA…
Apr 20, 2026
96c200e
feat(os): persist plugin + localStorage 소급 흡수 (9/10)
Apr 20, 2026
fbe8a9a
docs(handoff): defineFeature 마켓플레이스 세션 핸드오프
Apr 20, 2026
eca7fcd
fix(vite-plugin-fs): handleFile에서 디렉토리 경로는 400 반환
Apr 20, 2026
8e45365
Merge remote-tracking branch 'origin/feature/define-feature-runtime'
Apr 20, 2026
de855b2
refactor(layout): FlatLayout 노드 타입별 분산 + defineLayoutNode registry
Apr 20, 2026
9fc1fc2
refactor(slides): slidesWidgets 653→307 LOC, 도메인별 4파일로 분산
Apr 20, 2026
adf609f
merge: FlatLayout OCP+SRP + slides SRP 분리
Apr 20, 2026
72d2fa6
chore(wip): 타 세션 uncommitted 변경 + 2026-04-21 docs 포함
Apr 20, 2026
206b5f0
fix(ax): KeyHintBar item role 추가 + faker 타입 캐시 재빌드로 typecheck 통과
Apr 20, 2026
28ade5d
fix(regressions): handoff push 대응 — CI baseline 회복
Apr 20, 2026
f1ce140
chore(lint): autofix 9건 (unused directives + 보정)
Apr 20, 2026
67b57b3
feat(finder): .claude favorite + symlink traversal
Apr 20, 2026
5c3f0c4
feat(finder): SymlinkIndicator + FileTreeTable 뱃지
Apr 20, 2026
865b8b4
feat(infra): 병렬 세션 worktree 인프라 — hook 4종 + registry + wt:list
Apr 20, 2026
5972e3a
feat(infra): worktree 게이트웨이 + 대시보드 — 병렬 세션 가시성
Apr 20, 2026
b73efc2
feat(infra): wt:start/wt:stop — 게이트웨이+대시보드 원커맨드
Apr 20, 2026
2d3916c
chore(lint): ax.ts 불필요 eslint-disable 제거
Apr 20, 2026
c4b7e11
Merge remote-tracking branch 'origin/main' into feat/wt-gateway-clean
Apr 20, 2026
21e745a
chore(wip): parallel worktree infra local edits
Apr 21, 2026
e4c581f
merge: feat/wt-gateway-clean — Caddy 게이트웨이 + 대시보드
Apr 21, 2026
c6e309f
merge: feat/parallel-wip — 로컬 병렬 인프라 WIP
Apr 21, 2026
d154b82
chore(hook): main = PR-only 강제 — merge/rebase/cherry-pick 차단, ALLOW_M…
Apr 21, 2026
a858349
fix(hooks): guardBash 맥락 인식 — main에서만 차단
Apr 20, 2026
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
25 changes: 25 additions & 0 deletions .claude/hooks/allocWorktreePort.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env node
import { readRegistry } from './worktreeRegistry.mjs'

const BASE = 5174
const RANGE = 30

function hashBranch(branch) {
let h = 2166136261
for (let i = 0; i < branch.length; i++) {
h ^= branch.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return Math.abs(h) % RANGE
}

export function allocPort(branch) {
const used = new Set(readRegistry().map((e) => e.port))
if (branch) {
const preferred = BASE + hashBranch(branch)
if (!used.has(preferred)) return preferred
}
let p = BASE
while (used.has(p)) p++
return p
}
42 changes: 39 additions & 3 deletions .claude/hooks/guardBash.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,40 @@ import { readFileSync } from 'fs'
import { execSync } from 'child_process'

const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
const cmd = (input.tool_input?.command ?? '').trim()
const rawCmd = (input.tool_input?.command ?? '').trim()

/**
* Strip string literals and heredoc bodies so that pattern matching only sees
* actual shell tokens (commands/args/operators), not text inside quotes/heredocs.
*
* Motivation: `gh pr create --body "... git stash ..."` should NOT trigger the
* git stash guard because the string is a PR description, not a command. The
* previous implementation substring-matched the whole command line.
*
* This is intentionally conservative: if someone obfuscates by concatenating
* ('gi' + 't stash') we risk a false negative, which is acceptable for a
* defense-in-depth guard (other layers still exist).
*/
function stripLiterals(src) {
let s = src
// heredocs: <<EOF ... EOF, <<'EOF' ... EOF, <<-EOF ... EOF etc.
s = s.replace(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?\n\2\b/g, '<<HEREDOC_STRIPPED>>')
// single-quoted strings (no escapes in POSIX single quotes)
s = s.replace(/'[^']*'/g, "''")
// double-quoted strings (allow backslash escapes)
s = s.replace(/"(?:[^"\\]|\\.)*"/g, '""')
return s
}

const cmd = stripLiterals(rawCmd)

const isMainBranch = () => {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim()
// 명령 앞의 `cd <path>`를 감지해 실제 실행 위치 기준으로 브랜치 판정
// (Claude 프로세스 cwd는 main repo로 고정이라, cd 없이 판정하면 worktree 작업도 main으로 오판)
const cdMatch = rawCmd.match(/(?:^|[;&])\s*cd\s+([^\s;&|]+)/)
const args = cdMatch ? ['-C', cdMatch[1], 'rev-parse', '--abbrev-ref', 'HEAD'] : ['rev-parse', '--abbrev-ref', 'HEAD']
const branch = execSync(`git ${args.map((a) => JSON.stringify(a)).join(' ')}`, { encoding: 'utf8' }).trim()
return branch === 'main' || branch === 'master'
} catch { return false }
}
Expand All @@ -31,10 +60,17 @@ const BLOCKED = [
{ pattern: /\bgit\s+checkout\s+(\.|--\s*\.\s*$)/, reason: 'git checkout . 금지 — git checkout -- [파일명]으로 개별 파일만 원복하세요' },
{ pattern: /\bgit\s+restore\s+\.\s*$/, reason: 'git restore . 금지 — git restore [파일명]으로 개별 파일만 원복하세요' },
{ pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'git clean -f 금지 — rm [파일명]으로 개별 삭제하세요' },
{ pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard 금지 (main 브랜치) — feature branch에서는 허용', onlyMain: true },
// 파괴적 명령은 main에서만 차단, feature branch/worktree에서는 허용 (복구용)
{ pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard 금지 (main 브랜치) — feature branch/worktree에서는 허용', onlyMain: true },
{ pattern: /\bgit\s+push\s+[^|]*--force(?!-with-lease)\b/, reason: 'git push --force 금지 — git push --force-with-lease를 사용하세요' },
{ pattern: /\bgit\s+push\s+[^|]*\s-f\b/, reason: 'git push -f 금지 — git push --force-with-lease를 사용하세요' },
{ pattern: /\bgit\s+branch\s+-D\b/, reason: 'git branch -D 금지 (main 브랜치) — feature branch에서는 허용', onlyMain: true },
// main = PR-only. 쓰기 연산 전부 worktree에서 수행 후 PR 경로로만 main에 도달.
{ pattern: /\bgit\s+commit\b/, reason: 'main 브랜치 commit 금지 — worktree에서 작업 (git worktree add .claude/worktrees/<slug> -b feat/<slug>)', onlyMain: true },
{ pattern: /\bgit\s+push\b/, reason: 'main 브랜치 push 금지 — PR 경로만 허용', onlyMain: true },
{ pattern: /\bgit\s+merge\b/, reason: 'main 브랜치 로컬 merge 금지 — worktree에서 머지 후 PR로 main 갱신', onlyMain: true },
{ pattern: /\bgit\s+rebase\b/, reason: 'main 브랜치 rebase 금지 — main은 PR-only', onlyMain: true },
{ pattern: /\bgit\s+cherry-pick\b/, reason: 'main 브랜치 cherry-pick 금지 — worktree에서 작업 후 PR', onlyMain: true },
]

for (const { pattern, reason, onlyMain } of BLOCKED) {
Expand Down
143 changes: 143 additions & 0 deletions .claude/hooks/guardMockupFidelity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
#!/usr/bin/env node

/**
* PreToolUse:Write|Edit hook — mockup fidelity-tier 규칙 강제
*
* Phase 3 Low-fi / Phase 4 Mid-fi / Phase 5 Hi-fi 각 tier의 경계를 정적 검사로 차단.
* "fidelity leak" (low-fi에 hi-fi 요소가 들어오거나 반대)을 막는다.
*
* 대상 파일 경로:
* src/pages/__mockup__/{slug}/PageLow.tsx
* src/pages/__mockup__/{slug}/WireframeWidgets.tsx
* src/pages/__mockup__/{slug}/PageMid.tsx
* src/pages/__mockup__/{slug}/MidfiWidgets.tsx
* src/pages/__mockup__/{slug}/PageHi.tsx
* src/pages/__mockup__/{slug}/HifiWidgets.tsx
* src/pages/__mockup__/{slug}/layout.ts
*/

import { readFileSync } from 'fs'

function getFullContent(file_path, ti) {
// Write: full content provided.
if (ti.content != null) return ti.content
// Edit: simulate old_string → new_string on current file content,
// so presence/absence checks see the final state, not just the diff fragment.
if (ti.new_string != null && ti.old_string != null) {
try {
const current = readFileSync(file_path, 'utf8')
return ti.replace_all
? current.split(ti.old_string).join(ti.new_string)
: current.replace(ti.old_string, ti.new_string)
} catch {
return ti.new_string
}
}
return ''
}

let input = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (c) => { input += c })
process.stdin.on('end', () => {
let payload
try { payload = JSON.parse(input) } catch { process.exit(0) }
const ti = payload?.tool_input ?? {}
const file_path = ti.file_path ?? ''
const body = getFullContent(file_path, ti)
if (!file_path || !body) process.exit(0)

const violations = []

const isMockupPath = /\/src\/pages\/__mockup__\/[^/]+\//.test(file_path)
if (!isMockupPath) process.exit(0)

const isLowWidgets = /\/WireframeWidgets\.tsx$/.test(file_path)
const isPageLow = /\/PageLow\.tsx$/.test(file_path)
const isMidWidgets = /\/MidfiWidgets\.tsx$/.test(file_path)
const isPageMid = /\/PageMid\.tsx$/.test(file_path)
const isHiWidgets = /\/HifiWidgets\.tsx$/.test(file_path)
const isPageHi = /\/PageHi\.tsx$/.test(file_path)
const isLayout = /\/layout\.ts$/.test(file_path)
const isPageAny = isPageLow || isPageMid || isPageHi

// ── Shared: all mockup pages ──
if (isPageAny) {
if (/from ['"].*AppShell/.test(body) || /import\s+AppShell/.test(body)) {
violations.push('AppShell import 금지 — mockup 라우트는 shell-clean (router에서 AppShell 바깥에 등록)')
}
if (!/FlatLayout/.test(body)) {
violations.push('FlatLayout import 필수 — mockup은 defineLayout 엔진 기반 렌더')
}
if (!/createWidgetRegistry/.test(body)) {
violations.push('createWidgetRegistry 필수 — widget registry 없이 FlatLayout 비정상 동작')
}
if (/data-theme=['"]light['"]/.test(body)) {
violations.push('data-theme="light" 금지 — 프로젝트 dark 기본 유지 (fidelity와 theme은 독립 축)')
}
}

// ── Layout (shared across Phase 3-5) ──
if (isLayout) {
if (!/defineLayout/.test(body)) {
violations.push('layout.ts는 defineLayout 사용 필수')
}
}

// ── Phase 3 Low-fi rules ──
if (isLowWidgets) {
if (!/MockupBar/.test(body)) {
violations.push('MockupBar import 필수 — wireframe은 ax-based MockupBar (role:item + surface:display)만 사용. badge+nbsp 꼼수 금지')
}
if (/tone:\s*['"](accent|danger|success|warning)(?!-)/.test(body)) {
violations.push("tone 강조 금지 — low-fi는 중립. 'accent'/'danger'/'success'/'warning' 사용 시 Phase 5 Hi-fi로 이동")
}
if (/textStyle:\s*['"](page|section|hero)['"]/.test(body)) {
violations.push("textStyle hierarchy 금지 — 'page'/'section'/'hero'는 Phase 5 Hi-fi용. Low-fi는 label/body/caption만")
}
if (/Array\.from\(\{\s*length:/.test(body)) {
violations.push('Array.from({length:N}) 금지 — Phase 1 fixtures(MAILS/THREAD/FOLDERS)를 그대로 순회. Low-fi는 실제 데이터 분포를 반영해야')
}
if (/(starred|unread|important|selected|active|checked|hasAttachment)\s*\?\s*[^:]+:/.test(body)) {
violations.push('상태 분기 (ternary) 금지 — low-fi는 state-uniform. unread/read 같은 조건 분기는 Phase 5 Hi-fi에서')
}
if (/surface:\s*['"]action['"]/.test(body)) {
violations.push("surface:'action' 금지 — action surface는 tone 강조와 쌍 (Phase 5 Hi-fi). Low-fi는 base/ghost/sunken/display만")
}
if (/role:\s*['"]control['"].*?tone:/s.test(body) && /surface:\s*['"]action['"]/.test(body)) {
// already caught above but reinforced
}
}

// ── Phase 4 Mid-fi rules ──
if (isMidWidgets) {
if (/MockupBar/.test(body)) {
violations.push('MockupBar 금지 — Mid-fi는 실 데이터 텍스트 렌더. MockupBar는 Low-fi 전용')
}
if (/textStyle:\s*['"](page|section|hero)['"]/.test(body)) {
violations.push("strong hierarchy 금지 — textStyle 'page'/'section'/'hero'는 Phase 5 Hi-fi용")
}
if (/(unread|starred)\s*\?\s*['"]display['"]\s*:\s*['"]ghost['"]/.test(body)) {
violations.push('unread/starred 기반 surface 분기 금지 — Phase 5 Hi-fi에서 Hierarchy 결정 후 분기')
}
}

// ── Phase 5 Hi-fi rules ──
if (isHiWidgets) {
if (/MockupBar/.test(body)) {
violations.push('MockupBar 금지 — Hi-fi는 실 ui/ 부품 사용 (Avatar/Badge/Button 등)')
}
}

if (violations.length) {
const header = isLowWidgets || isPageLow ? 'Low-fi 규칙'
: isMidWidgets || isPageMid ? 'Mid-fi 규칙'
: isHiWidgets || isPageHi ? 'Hi-fi 규칙'
: isLayout ? 'Layout 규칙' : 'Mockup 규칙'
process.stderr.write(`mockup fidelity ${header} 위반 ${violations.length}건:\n`)
violations.forEach((v, i) => process.stderr.write(` ${i + 1}. ${v}\n`))
process.stderr.write('\n참고: .claude/skills/mockup/SKILL.md · docs/.../gmailMockup.md\n')
process.exit(2)
}
process.exit(0)
})
35 changes: 35 additions & 0 deletions .claude/hooks/requireWorktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env node
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { findCurrent } from './worktreeRegistry.mjs'

const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
const filePath = input.tool_input?.file_path ?? ''

function isMainWorktree() {
try {
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim()
return !gitDir.includes('/worktrees/')
} catch { return true }
}

function slugFromPath(p) {
const base = p.split('/').filter(Boolean).slice(-2).join('-') || `wt-${Date.now()}`
return base.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().slice(0, 40)
}

if (findCurrent()) process.exit(0)
if (!isMainWorktree()) process.exit(0)

const slug = slugFromPath(filePath)
const reason = [
'main worktree에서 직접 수정 금지. 병렬 세션 규약 위반.',
'',
'새 worktree 생성:',
` git worktree add .claude/worktrees/${slug} -b feat/${slug}`,
` cd .claude/worktrees/${slug}`,
'',
'또는 기존 worktree로 이동 후 다시 시도하세요. (pnpm wt:list)',
].join('\n')

process.stdout.write(JSON.stringify({ decision: 'block', reason }))
39 changes: 39 additions & 0 deletions .claude/hooks/sessionStartWorktree.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { readFileSync } from 'fs'
import { execSync } from 'child_process'
import { spawn } from 'child_process'
import path from 'path'
import { findCurrent, upsert, cleanStale } from './worktreeRegistry.mjs'
import { allocPort } from './allocWorktreePort.mjs'

const input = JSON.parse(readFileSync('/dev/stdin', 'utf8'))
const sessionId = input.session_id ?? null

cleanStale()
let entry = findCurrent()

const branch = (() => {
try { return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim() } catch { return 'unknown' }
})()

if (!entry && branch !== 'main' && branch !== 'master') {
const toplevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
const name = toplevel.split('/').pop()
entry = { name, branch, path: toplevel, port: allocPort(branch), session_id: sessionId, dev_pid: null, started_at: new Date().toISOString() }
upsert(entry)
} else if (entry) {
upsert({ ...entry, session_id: sessionId })
}

try {
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim()
const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim()
const mainRoot = path.resolve(path.dirname(gitCommonDir) || repoRoot)
spawn('node', [path.join(mainRoot, 'scripts/wtStart.mjs')], { cwd: mainRoot, detached: true, stdio: 'ignore' }).unref()
} catch {}

const msg = entry
? `worktree: ${entry.name} | branch: ${entry.branch} | dev port: ${entry.port}`
: `worktree 밖(main 추정). Edit 시 hook이 worktree 생성을 요구합니다. pnpm wt:list로 현황 확인.`

process.stdout.write(JSON.stringify({ systemMessage: msg }))
18 changes: 18 additions & 0 deletions .claude/hooks/stopRequireCommit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env node
import { execSync } from 'child_process'
import { findCurrent } from './worktreeRegistry.mjs'

const entry = findCurrent()
if (!entry) process.exit(0)

const dirty = (() => {
try { return execSync('git status --porcelain', { encoding: 'utf8' }).trim().length > 0 } catch { return false }
})()
const unpushed = (() => {
try { return execSync(`git log @{u}..HEAD --oneline 2>/dev/null || git log HEAD --oneline --not --remotes`, { encoding: 'utf8' }).trim().length > 0 } catch { return false }
})()

if (!dirty && !unpushed) process.exit(0)

const reason = `worktree ${entry.name}에 미커밋(${dirty ? 'dirty' : 'clean'}) 또는 미푸시(${unpushed ? 'yes' : 'no'}) 상태. /handoff로 마무리하세요.`
process.stdout.write(JSON.stringify({ systemMessage: reason }))
46 changes: 46 additions & 0 deletions .claude/hooks/worktreeRegistry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { execSync } from 'child_process'
import path from 'path'

const gitCommonDir = execSync('git rev-parse --git-common-dir', { encoding: 'utf8' }).trim()
const absCommonDir = path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(gitCommonDir)
const repoRoot = path.dirname(absCommonDir)
export const REGISTRY_PATH = path.join(repoRoot, '.claude', 'worktrees.json')

export function readRegistry() {
if (!existsSync(REGISTRY_PATH)) return []
try { return JSON.parse(readFileSync(REGISTRY_PATH, 'utf8')) } catch { return [] }
}

export function writeRegistry(entries) {
writeFileSync(REGISTRY_PATH, JSON.stringify(entries, null, 2))
}

export function cleanStale() {
const entries = readRegistry()
const alive = entries.filter((e) => existsSync(e.path)).map((e) => {
if (e.dev_pid == null) return e
try { process.kill(e.dev_pid, 0); return e } catch { return { ...e, dev_pid: null } }
})
writeRegistry(alive)
return alive
}

export function findCurrent() {
const cwd = process.cwd()
const toplevel = (() => {
try { return execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim() } catch { return cwd }
})()
return readRegistry().find((e) => path.resolve(e.path) === path.resolve(toplevel)) ?? null
}

export function upsert(entry) {
const entries = readRegistry().filter((e) => e.name !== entry.name)
entries.push(entry)
writeRegistry(entries)
}

export function remove(name) {
writeRegistry(readRegistry().filter((e) => e.name !== name))
}
Loading