diff --git a/.dal/.gitattributes b/.dal/.gitattributes new file mode 100644 index 00000000..43f8472d --- /dev/null +++ b/.dal/.gitattributes @@ -0,0 +1,4 @@ +.dal/decisions.md merge=union +.dal/decisions-archive.md merge=union +.dal/*/history.md merge=union +.dal/wisdom.md merge=union diff --git a/.dal/template/dal.spec.cue b/.dal/template/dal.spec.cue new file mode 100644 index 00000000..a3c20d19 --- /dev/null +++ b/.dal/template/dal.spec.cue @@ -0,0 +1,38 @@ +// dal.spec.cue — localdal schema + +#Player: "claude" | "codex" | "gemini" +#Role: "leader" | "member" | "ops" + +#BranchConfig: { + base?: string | *"main" +} + +#SetupConfig: { + packages?: [...string] + commands?: [...string] + timeout?: string | *"5m" +} + +#DalProfile: { + uuid!: string & != "" + name!: string & != "" + version!: string + player!: #Player + fallback_player?: #Player + role!: #Role + channel_only?: bool + skills?: [...string] + hooks?: [...string] + model?: string + player_version?: string + auto_task?: string + auto_interval?: string + workspace?: string + branch?: #BranchConfig + setup?: #SetupConfig + git?: { + user?: string + email?: string + github_token?: string + } +} diff --git a/.dal/template/dal/charter.md b/.dal/template/dal/charter.md new file mode 100644 index 00000000..8e7f3d51 --- /dev/null +++ b/.dal/template/dal/charter.md @@ -0,0 +1,25 @@ +# dal — 문서 관리자 + +## Role +팀 공유 기억의 유일한 writer + committer. 백그라운드 자동 실행. + +## Responsibilities +1. decisions/inbox/ → decisions.md 병합 (중복 제거: By + What 기준) +2. wisdom-inbox/ → wisdom.md 병합 +3. history-buffer/{name}.md → .dal/{name}/history.md 병합 +4. history.md 12KB 초과 시 Core Context 압축 +5. decisions.md 50KB 초과 시 30일+ 항목 → decisions-archive.md +6. README.md / CLAUDE.md 갱신 (ccw tool update_module_claude) +7. 변경 시 git add + commit + push + +## Tools +- ccw tool update_module_claude — 모듈 문서 자동 생성 +- ccw tool detect_changed_modules — 변경 모듈 탐지 +- ccw memory — 컨텍스트 메모리 관리 +- dalcli status / report + +## Rules +- push 실패 시 재시도만. force push, reset 금지. +- 병합 후 inbox 파일 삭제. +- history에는 최종 결과만. 중간 상태 금지. +- 코드, 리뷰, 테스트 금지 — 문서만 담당. diff --git a/.dal/template/dal/dal.cue b/.dal/template/dal/dal.cue new file mode 100644 index 00000000..715102eb --- /dev/null +++ b/.dal/template/dal/dal.cue @@ -0,0 +1,15 @@ +uuid: "99ab0934-07fb-423f-84e6-1ce8aef91919" +name: "dal" +version: "1.0.0" +player: "claude" +model: "haiku" +role: "member" +skills: ["skills/inbox-protocol", "skills/history-hygiene"] +hooks: [] +auto_task: "1. /workspace/decisions/inbox/ → decisions.md 병합 (중복 제거 후 삭제). 2. /workspace/history-buffer/ → .dal/{name}/history.md 병합. 3. /workspace/wisdom-inbox/ → wisdom.md 병합. 4. history.md 12KB 초과 시 압축. 5. decisions.md 50KB 초과 시 30일+ 아카이브. 6. README.md, CLAUDE.md 갱신 필요 시 ccw tool update_module_claude로 자동 생성. 7. 변경 시 git add + commit + push." +auto_interval: "30m" +git: { + user: "dal-docs" + email: "dal-docs@dalcenter.local" + github_token: "env:GITHUB_TOKEN" +} diff --git a/.dal/template/dalops/charter.md b/.dal/template/dalops/charter.md new file mode 100644 index 00000000..ca01569f --- /dev/null +++ b/.dal/template/dalops/charter.md @@ -0,0 +1,28 @@ +# dalops — 운영자 + +## Role +CCW 기반 오케스트레이터. 코드 구현, 리뷰, 테스트를 워크플로우로 실행한다. + +## Tools +- ccw cli --tool codex --mode review — Codex 코드 리뷰 +- ccw cli --tool codex --mode analysis — Codex 분석 +- ccw cli --tool gemini — Gemini 분석 +- dalcli status / ps / report + +## Workflows +- workflow-lite-plan — 단일 모듈 기능 구현 +- workflow-tdd-plan — 테스트 주도 개발 +- workflow-multi-cli-plan — 멀티 CLI 협업 분석/리뷰 +- workflow-test-fix — 테스트 생성 및 수정 루프 + +## Process +1. 이슈/작업 수신 +2. CCW 워크플로우 선택 및 실행 +3. codex 리뷰 통과 확인 +5. 브랜치 → PR 생성 +6. dalcli report로 결과 보고 + +## Rules +- main 직접 커밋 금지 +- PR 생성 전 반드시 테스트 통과 +- ccw session으로 작업 컨텍스트 유지 diff --git a/.dal/template/dalops/dal.cue b/.dal/template/dalops/dal.cue new file mode 100644 index 00000000..380dcc8b --- /dev/null +++ b/.dal/template/dalops/dal.cue @@ -0,0 +1,13 @@ +uuid: "c9380496-8203-44e4-9025-6e624e95cb67" +name: "dalops" +version: "1.0.0" +player: "claude" +role: "ops" +channel_only: true +skills: ["skills/git-workflow", "skills/pre-flight"] +hooks: [] +git: { + user: "dal-ops" + email: "dal-ops@dalcenter.local" + github_token: "env:GITHUB_TOKEN" +} diff --git a/.dal/template/decisions-archive.md b/.dal/template/decisions-archive.md new file mode 100644 index 00000000..b64bff7f --- /dev/null +++ b/.dal/template/decisions-archive.md @@ -0,0 +1,3 @@ +# Decisions Archive + +아카이브된 결정. 읽기 전용 참조. diff --git a/.dal/template/decisions.md b/.dal/template/decisions.md new file mode 100644 index 00000000..655bba5b --- /dev/null +++ b/.dal/template/decisions.md @@ -0,0 +1,13 @@ +# Decisions + +팀 아키텍처 결정 로그. 모든 dal은 작업 전에 이 파일을 읽는다. +scribe dal이 inbox에서 승인된 제안을 병합한다. 직접 수정 금지. + +## 포맷 + +### {날짜}: {주제} +**By:** {dal name} +**What:** {결정 내용} +**Why:** {이유} + +--- diff --git a/.dal/template/reviewer/dal.cue b/.dal/template/reviewer/dal.cue new file mode 100644 index 00000000..62215a57 --- /dev/null +++ b/.dal/template/reviewer/dal.cue @@ -0,0 +1,15 @@ +uuid: "a1b2c3d4-e5f6-7890-abcd-reviewer00001" +name: "reviewer" +version: "1.0.0" +player: "claude" +role: "member" +channel_only: false +skills: ["skills/git-workflow", "skills/pre-flight"] +hooks: [] +auto_task: "1. gh pr list --state open 으로 리뷰 대기 PR 확인. 2. 각 PR에 대해: checkout → go build → go test → 코드 리뷰 (보안, 로직, 테스트 커버리지). 3. 문제 없으면 gh pr review --approve. 4. 문제 있으면 gh pr review --request-changes + 코멘트. 5. 리뷰 결과를 /workspace/review-log.md에 기록." +auto_interval: "15m" +git: { + user: "dal-reviewer" + email: "dal-reviewer@dalcenter.local" + github_token: "env:GITHUB_TOKEN" +} diff --git a/.dal/template/skills/escalation/SKILL.md b/.dal/template/skills/escalation/SKILL.md new file mode 100644 index 00000000..684aa7fd --- /dev/null +++ b/.dal/template/skills/escalation/SKILL.md @@ -0,0 +1,3 @@ +# Escalation + +report: 완료 보고. claim: 진행 불가 에스컬레이션. diff --git a/.dal/template/skills/git-workflow/SKILL.md b/.dal/template/skills/git-workflow/SKILL.md new file mode 100644 index 00000000..fdadd70d --- /dev/null +++ b/.dal/template/skills/git-workflow/SKILL.md @@ -0,0 +1,3 @@ +# Git Workflow + +main 직접 커밋 금지. 브랜치 → PR → 리뷰 → 머지. diff --git a/.dal/template/skills/history-hygiene/SKILL.md b/.dal/template/skills/history-hygiene/SKILL.md new file mode 100644 index 00000000..d94e5a49 --- /dev/null +++ b/.dal/template/skills/history-hygiene/SKILL.md @@ -0,0 +1,3 @@ +# History Hygiene + +최종 결과만 기록. 중간 시도 금지. 12KB 제한. diff --git a/.dal/template/skills/inbox-protocol/SKILL.md b/.dal/template/skills/inbox-protocol/SKILL.md new file mode 100644 index 00000000..a4da95c7 --- /dev/null +++ b/.dal/template/skills/inbox-protocol/SKILL.md @@ -0,0 +1,3 @@ +# Inbox Protocol + +decisions.md, wisdom.md 직접 수정 금지. inbox에 드롭. diff --git a/.dal/template/skills/leader-protocol/SKILL.md b/.dal/template/skills/leader-protocol/SKILL.md new file mode 100644 index 00000000..bf586268 --- /dev/null +++ b/.dal/template/skills/leader-protocol/SKILL.md @@ -0,0 +1,4 @@ +# Leader Protocol + +나는 중개자. 직접 수정 안 함. 소환+읽기+판단+라우팅만. +Write/Edit/commit 금지. dalcli-leader assign으로 멤버에게 위임. diff --git a/.dal/template/skills/pre-flight/SKILL.md b/.dal/template/skills/pre-flight/SKILL.md new file mode 100644 index 00000000..1736b61f --- /dev/null +++ b/.dal/template/skills/pre-flight/SKILL.md @@ -0,0 +1,3 @@ +# Pre-Flight + +작업 전 필수: now.md → decisions.md → wisdom.md → ps. diff --git a/.dal/template/skills/reviewer-protocol/SKILL.md b/.dal/template/skills/reviewer-protocol/SKILL.md new file mode 100644 index 00000000..9772493c --- /dev/null +++ b/.dal/template/skills/reviewer-protocol/SKILL.md @@ -0,0 +1,3 @@ +# Reviewer Protocol + +작성자 ≠ 리뷰어. 리뷰어 본인 수정 금지. diff --git a/.dal/template/wisdom.md b/.dal/template/wisdom.md new file mode 100644 index 00000000..44793068 --- /dev/null +++ b/.dal/template/wisdom.md @@ -0,0 +1,11 @@ +# Wisdom + +팀 공유 교훈. 모든 dal은 작업 전에 이 파일을 읽는다. + +## Patterns + +검증된 접근 방식. + +## Anti-Patterns + +피해야 할 것. diff --git a/cmd/dalcenter/cmd_localdal.go b/cmd/dalcenter/cmd_localdal.go index bde22ef2..e0fecc2b 100644 --- a/cmd/dalcenter/cmd_localdal.go +++ b/cmd/dalcenter/cmd_localdal.go @@ -27,7 +27,7 @@ func localdalRoot() string { // --- serve --- func newServeCmd() *cobra.Command { - var addr, serviceRepo, bridgeURL, bridgeConf string + var addr, serviceRepo, bridgeURL, bridgeConf, githubRepo string cmd := &cobra.Command{ Use: "serve", Short: "Run dalcenter daemon (HTTP API + Docker management)", @@ -39,7 +39,7 @@ func newServeCmd() *cobra.Command { root = repoRoot } } - d := daemon.New(addr, root, serviceRepo, bridgeURL, bridgeConf) + d := daemon.New(addr, root, serviceRepo, bridgeURL, bridgeConf, githubRepo) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() @@ -51,6 +51,7 @@ func newServeCmd() *cobra.Command { cmd.Flags().StringVar(&serviceRepo, "repo", os.Getenv("DALCENTER_REPO"), "Service repository path") cmd.Flags().StringVar(&bridgeURL, "bridge-url", os.Getenv("DALCENTER_BRIDGE_URL"), "Matterbridge API URL (auto-set if --bridge-conf provided)") cmd.Flags().StringVar(&bridgeConf, "bridge-conf", os.Getenv("DALCENTER_BRIDGE_CONF"), "Matterbridge config path (starts as child process)") + cmd.Flags().StringVar(&githubRepo, "github-repo", os.Getenv("DALCENTER_GITHUB_REPO"), "GitHub repo for issue polling (e.g., owner/repo)") return cmd } diff --git a/internal/daemon/claim_test.go b/internal/daemon/claim_test.go index bb416731..a90efc2b 100644 --- a/internal/daemon/claim_test.go +++ b/internal/daemon/claim_test.go @@ -77,7 +77,7 @@ func TestClaimStore_FilterByStatus(t *testing.T) { } func TestHandleClaim_Post(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") body := `{"dal":"dev","type":"bug","title":"cargo missing","detail":"command not found"}` req := httptest.NewRequest("POST", "/api/claim", strings.NewReader(body)) w := httptest.NewRecorder() @@ -93,7 +93,7 @@ func TestHandleClaim_Post(t *testing.T) { } func TestHandleClaims_Empty(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") req := httptest.NewRequest("GET", "/api/claims", nil) w := httptest.NewRecorder() d.handleClaims(w, req) @@ -103,7 +103,7 @@ func TestHandleClaims_Empty(t *testing.T) { } func TestHandleClaim_MissingFields(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") body := `{"dal":"","title":""}` req := httptest.NewRequest("POST", "/api/claim", strings.NewReader(body)) w := httptest.NewRecorder() diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 86aea750..f6a1b057 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -33,6 +33,7 @@ type Daemon struct { serviceRepo string // service repo path to mount as /workspace bridgeURL string // matterbridge API URL bridgeConf string // matterbridge config path (child process) + githubRepo string // GitHub repo for issue polling (e.g., "owner/repo") apiToken string // Bearer token for write endpoints (empty = no auth) containers map[string]*Container // dal name -> container mu sync.RWMutex @@ -44,6 +45,7 @@ type Daemon struct { tasks *taskStore feedback *feedbackStore costs *costStore + issues *issueStore registry *Registry startTime time.Time } @@ -63,7 +65,7 @@ type Container struct { } // New creates a daemon. -func New(addr, localdalRoot, serviceRepo, bridgeURL, bridgeConf string) *Daemon { +func New(addr, localdalRoot, serviceRepo, bridgeURL, bridgeConf, githubRepo string) *Daemon { token := os.Getenv("DALCENTER_TOKEN") if token != "" { log.Println("[daemon] API token auth enabled for write endpoints") @@ -77,6 +79,7 @@ func New(addr, localdalRoot, serviceRepo, bridgeURL, bridgeConf string) *Daemon serviceRepo: serviceRepo, bridgeURL: strings.TrimRight(bridgeURL, "/"), bridgeConf: bridgeConf, + githubRepo: githubRepo, apiToken: token, containers: make(map[string]*Container), escalations: newEscalationStoreWithFile(filepath.Join(dataDir(serviceRepo), "escalations.json")), @@ -84,6 +87,7 @@ func New(addr, localdalRoot, serviceRepo, bridgeURL, bridgeConf string) *Daemon tasks: newTaskStoreWithFile(filepath.Join(dataDir(serviceRepo), "tasks.json")), feedback: newFeedbackStoreWithFile(filepath.Join(dataDir(serviceRepo), "feedback.json")), costs: newCostStoreWithFile(filepath.Join(dataDir(serviceRepo), "costs.json"), orchestrationLogDir(serviceRepo)), + issues: newIssueStore(filepath.Join(dataDir(serviceRepo), "issues_seen.json")), registry: newRegistry(serviceRepo), credSyncLast: newCredentialSyncMap(), startTime: time.Now(), @@ -171,6 +175,9 @@ func (d *Daemon) Run(ctx context.Context) error { // Start leader health watcher (auto-recovery) go d.startLeaderWatcher(ctx) + // Start GitHub issue watcher + go d.startIssueWatcher(ctx, d.githubRepo, defaultIssuePollInterval) + if d.bridgeURL != "" { log.Printf("[daemon] matterbridge URL: %s", d.bridgeURL) } @@ -222,6 +229,8 @@ func (d *Daemon) Run(ctx context.Context) error { mux.HandleFunc("POST /api/escalate", d.requireAuth(d.handleEscalate)) mux.HandleFunc("GET /api/escalations", d.handleEscalations) mux.HandleFunc("POST /api/escalations/{id}/resolve", d.requireAuth(d.handleResolveEscalation)) + // GitHub issue tracking + mux.HandleFunc("GET /api/issues", d.handleIssues) // A2A protocol endpoints mux.HandleFunc("GET /api/provider-status", d.handleProviderStatus) mux.HandleFunc("POST /api/provider-trip", d.handleProviderTrip) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 3cdb3d2f..73696bc0 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -43,7 +43,7 @@ hooks: [] `), 0644) os.WriteFile(filepath.Join(devDir, "charter.md"), []byte("# Dev\n"), 0644) - d := New(":0", root, "", "", "") + d := New(":0", root, "", "", "", "") return d, root } diff --git a/internal/daemon/issue_watcher.go b/internal/daemon/issue_watcher.go new file mode 100644 index 00000000..bbbc40d7 --- /dev/null +++ b/internal/daemon/issue_watcher.go @@ -0,0 +1,102 @@ +package daemon + +import ( + "context" + "encoding/json" + "log" + "net/http" + "os" + "sync" + "time" +) + +const defaultIssuePollInterval = 5 * time.Minute + +// issueStore tracks seen GitHub issues to avoid duplicate dispatch. +type issueStore struct { + mu sync.Mutex + seen map[int]time.Time + path string +} + +func newIssueStore(path string) *issueStore { + s := &issueStore{ + seen: make(map[int]time.Time), + path: path, + } + s.load() + return s +} + +func (s *issueStore) load() { + data, err := os.ReadFile(s.path) + if err != nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + json.Unmarshal(data, &s.seen) +} + +func (s *issueStore) save() { + s.mu.Lock() + defer s.mu.Unlock() + data, err := json.Marshal(s.seen) + if err != nil { + return + } + os.WriteFile(s.path, data, 0644) +} + +func (s *issueStore) hasSeen(num int) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.seen[num] + return ok +} + +func (s *issueStore) markSeen(num int) { + s.mu.Lock() + s.seen[num] = time.Now() + s.mu.Unlock() + s.save() +} + +func (s *issueStore) list() map[int]time.Time { + s.mu.Lock() + defer s.mu.Unlock() + cp := make(map[int]time.Time, len(s.seen)) + for k, v := range s.seen { + cp[k] = v + } + return cp +} + +// startIssueWatcher polls GitHub for new issues and dispatches them to the leader. +// This is a stub — full implementation will arrive with #526. +func (d *Daemon) startIssueWatcher(ctx context.Context, repo string, interval time.Duration) { + if repo == "" { + log.Println("[issue-watcher] disabled (no --github-repo configured)") + return + } + log.Printf("[issue-watcher] watching %s (interval=%s)", repo, interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // Stub: poll loop placeholder for #526. + } + } +} + +// handleIssues returns the list of tracked issues. +func (d *Daemon) handleIssues(w http.ResponseWriter, r *http.Request) { + seen := d.issues.list() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(seen) +} diff --git a/internal/daemon/task_test.go b/internal/daemon/task_test.go index 2b22afee..3b0b96ee 100644 --- a/internal/daemon/task_test.go +++ b/internal/daemon/task_test.go @@ -83,7 +83,7 @@ func TestTaskStore_Eviction(t *testing.T) { } func TestHandleTask_NoDal(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") body := `{"dal":"nonexistent","task":"hello"}` req := httptest.NewRequest("POST", "/api/task", strings.NewReader(body)) w := httptest.NewRecorder() @@ -94,7 +94,7 @@ func TestHandleTask_NoDal(t *testing.T) { } func TestHandleTask_MissingFields(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") body := `{"dal":"","task":""}` req := httptest.NewRequest("POST", "/api/task", strings.NewReader(body)) w := httptest.NewRecorder() @@ -105,7 +105,7 @@ func TestHandleTask_MissingFields(t *testing.T) { } func TestHandleTaskList_Empty(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") req := httptest.NewRequest("GET", "/api/tasks", nil) w := httptest.NewRecorder() d.handleTaskList(w, req) @@ -118,7 +118,7 @@ func TestHandleTaskList_Empty(t *testing.T) { } func TestHandleTaskStatus_NotFound(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") req := httptest.NewRequest("GET", "/api/task/task-9999", nil) req.SetPathValue("id", "task-9999") w := httptest.NewRecorder() @@ -129,7 +129,7 @@ func TestHandleTaskStatus_NotFound(t *testing.T) { } func TestHandleTaskStartAndFinish(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") startReq := httptest.NewRequest("POST", "/api/task/start", strings.NewReader(`{"dal":"leader","task":"triage issue"}`)) startW := httptest.NewRecorder() @@ -175,7 +175,7 @@ func TestHandleTaskStartAndFinish(t *testing.T) { } func TestHandleTaskEvent(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") tr := d.tasks.New("leader", "triage issue") req := httptest.NewRequest("POST", "/api/task/"+tr.ID+"/event", strings.NewReader(`{"kind":"self_repair","message":"Retrying after fix"}`)) @@ -196,7 +196,7 @@ func TestHandleTaskEvent(t *testing.T) { } func TestHandleTaskMetadata(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") tr := d.tasks.New("leader", "triage issue") req := httptest.NewRequest("POST", "/api/task/"+tr.ID+"/metadata", strings.NewReader(`{"git_diff":"M README.md","git_changes":1,"verified":"yes","completion":{"build_ok":true,"test_ok":false,"duration":"1.2s"}}`)) @@ -276,7 +276,7 @@ func TestTaskResult_WithChanges(t *testing.T) { } func TestMessageFallback_NoMM(t *testing.T) { - d := New(":0", "/tmp/test", t.TempDir(), "", "") + d := New(":0", "/tmp/test", t.TempDir(), "", "", "") // No MM configured, no running dals → should return 503 body := `{"from":"host","message":"test"}` req := httptest.NewRequest("POST", "/api/message", strings.NewReader(body))