From 81d5b5bd92a279127bfd0f554504b895a2e22eb6 Mon Sep 17 00:00:00 2001 From: dal-ops Date: Mon, 30 Mar 2026 19:40:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20test-dal=20dal=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .dal/.gitattributes | 4 + .dal/template/dal.spec.cue | 38 ++++++ .dal/template/dal/charter.md | 25 ++++ .dal/template/dal/dal.cue | 15 +++ .dal/template/dalops/charter.md | 28 +++++ .dal/template/dalops/dal.cue | 13 ++ .dal/template/decisions-archive.md | 3 + .dal/template/decisions.md | 13 ++ .dal/template/skills/escalation/SKILL.md | 3 + .dal/template/skills/git-workflow/SKILL.md | 3 + .dal/template/skills/history-hygiene/SKILL.md | 3 + .dal/template/skills/inbox-protocol/SKILL.md | 3 + .dal/template/skills/leader-protocol/SKILL.md | 4 + .dal/template/skills/pre-flight/SKILL.md | 3 + .dal/template/skills/reviewer-protocol/SKILL.md | 3 + .dal/template/wisdom.md | 11 ++ internal/daemon/notify.go | 126 +++++++++++++++++++ internal/daemon/notify_test.go | 155 ++++++++++++++++++++++++ internal/daemon/task.go | 25 ++-- 19 files changed, 461 insertions(+), 17 deletions(-) Co-Authored-By: dal-test-dal --- .dal/.gitattributes | 4 + .dal/template/dal.spec.cue | 38 +++++ .dal/template/dal/charter.md | 25 +++ .dal/template/dal/dal.cue | 15 ++ .dal/template/dalops/charter.md | 28 ++++ .dal/template/dalops/dal.cue | 13 ++ .dal/template/decisions-archive.md | 3 + .dal/template/decisions.md | 13 ++ .dal/template/skills/escalation/SKILL.md | 3 + .dal/template/skills/git-workflow/SKILL.md | 3 + .dal/template/skills/history-hygiene/SKILL.md | 3 + .dal/template/skills/inbox-protocol/SKILL.md | 3 + .dal/template/skills/leader-protocol/SKILL.md | 4 + .dal/template/skills/pre-flight/SKILL.md | 3 + .../skills/reviewer-protocol/SKILL.md | 3 + .dal/template/wisdom.md | 11 ++ internal/daemon/notify.go | 126 ++++++++++++++ internal/daemon/notify_test.go | 155 ++++++++++++++++++ internal/daemon/task.go | 25 +-- 19 files changed, 461 insertions(+), 17 deletions(-) create mode 100644 .dal/.gitattributes create mode 100644 .dal/template/dal.spec.cue create mode 100644 .dal/template/dal/charter.md create mode 100644 .dal/template/dal/dal.cue create mode 100644 .dal/template/dalops/charter.md create mode 100644 .dal/template/dalops/dal.cue create mode 100644 .dal/template/decisions-archive.md create mode 100644 .dal/template/decisions.md create mode 100644 .dal/template/skills/escalation/SKILL.md create mode 100644 .dal/template/skills/git-workflow/SKILL.md create mode 100644 .dal/template/skills/history-hygiene/SKILL.md create mode 100644 .dal/template/skills/inbox-protocol/SKILL.md create mode 100644 .dal/template/skills/leader-protocol/SKILL.md create mode 100644 .dal/template/skills/pre-flight/SKILL.md create mode 100644 .dal/template/skills/reviewer-protocol/SKILL.md create mode 100644 .dal/template/wisdom.md create mode 100644 internal/daemon/notify.go create mode 100644 internal/daemon/notify_test.go 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/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/internal/daemon/notify.go b/internal/daemon/notify.go new file mode 100644 index 00000000..13f2eda9 --- /dev/null +++ b/internal/daemon/notify.go @@ -0,0 +1,126 @@ +package daemon + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "strings" + "time" +) + +// NotifyPayload is the JSON body sent to DALCENTER_NOTIFY_URL. +type NotifyPayload struct { + Event string `json:"event"` // "task_done", "task_failed" + Dal string `json:"dal"` + TaskID string `json:"task_id"` + Task string `json:"task"` + Status string `json:"status"` + PRUrl string `json:"pr_url,omitempty"` + Error string `json:"error,omitempty"` + Output string `json:"output,omitempty"` + Changes int `json:"git_changes"` + Verified string `json:"verified,omitempty"` + Timestamp string `json:"timestamp"` +} + +// notifyTaskComplete sends a notification when a task finishes. +// It tries three channels in order: +// 1. DALCENTER_NOTIFY_URL — HTTP POST with JSON payload +// 2. notify-dalroot CLI — if CallbackPane is set +// 3. Neither — log only +func notifyTaskComplete(dalName string, tr *taskResult, repo string) { + payload := buildNotifyPayload(dalName, tr) + + // 1. HTTP notification via DALCENTER_NOTIFY_URL + if url := os.Getenv("DALCENTER_NOTIFY_URL"); url != "" { + go sendNotifyHTTP(url, payload) + } + + // 2. CLI notification via notify-dalroot (backward compat) + if tr.CallbackPane != "" { + go sendNotifyCLI(dalName, tr, repo) + } +} + +// buildNotifyPayload constructs the notification payload from a task result. +func buildNotifyPayload(dalName string, tr *taskResult) NotifyPayload { + event := "task_done" + if tr.Status == "failed" || tr.Status == "blocked" { + event = "task_failed" + } + + p := NotifyPayload{ + Event: event, + Dal: dalName, + TaskID: tr.ID, + Task: truncateStr(tr.Task, 200), + Status: tr.Status, + Changes: tr.GitChanges, + Verified: tr.Verified, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + if tr.Status == "failed" || tr.Status == "blocked" { + p.Error = truncateStr(tr.Error, 500) + } + + // Extract PR URL from output if present + if prURL := extractPRUrl(tr.Output); prURL != "" { + p.PRUrl = prURL + } + + return p +} + +// sendNotifyHTTP posts the payload to the given URL. +func sendNotifyHTTP(url string, payload NotifyPayload) { + data, err := json.Marshal(payload) + if err != nil { + log.Printf("[notify] marshal error: %v", err) + return + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(url, "application/json", bytes.NewReader(data)) + if err != nil { + log.Printf("[notify] HTTP POST to %s failed: %v", url, err) + return + } + resp.Body.Close() + log.Printf("[notify] HTTP POST %s → %d (%s %s)", url, resp.StatusCode, payload.Event, payload.Dal) +} + +// sendNotifyCLI calls the notify-dalroot CLI tool for pane-based notification. +func sendNotifyCLI(dalName string, tr *taskResult, repo string) { + msg := fmt.Sprintf("[%s] task %s: %s", dalName, tr.Status, truncateStr(tr.Task, 80)) + if prURL := extractPRUrl(tr.Output); prURL != "" { + msg += " → " + prURL + } + if tr.Status == "failed" && tr.Error != "" { + msg += " | error: " + truncateStr(tr.Error, 100) + } + cmd := exec.Command("notify-dalroot", repo, msg, tr.CallbackPane) + if err := cmd.Run(); err != nil { + log.Printf("[notify] dalroot CLI failed: %v", err) + } +} + +// extractPRUrl scans output for a GitHub PR URL. +func extractPRUrl(output string) string { + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.Contains(line, "github.com/") && strings.Contains(line, "/pull/") { + // Find the URL within the line + for _, word := range strings.Fields(line) { + if strings.Contains(word, "github.com/") && strings.Contains(word, "/pull/") { + return strings.TrimRight(word, ".,;:!?\"'`)") + } + } + } + } + return "" +} diff --git a/internal/daemon/notify_test.go b/internal/daemon/notify_test.go new file mode 100644 index 00000000..96b0dfa5 --- /dev/null +++ b/internal/daemon/notify_test.go @@ -0,0 +1,155 @@ +package daemon + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestBuildNotifyPayload_Done(t *testing.T) { + tr := &taskResult{ + ID: "task-001", + Dal: "leader", + Task: "go test ./...", + Status: "done", + Output: "all tests passed\nhttps://github.com/dalsoop/dalcenter/pull/42\n", + GitChanges: 3, + Verified: "yes", + } + p := buildNotifyPayload("leader", tr) + if p.Event != "task_done" { + t.Errorf("expected event=task_done, got %s", p.Event) + } + if p.PRUrl != "https://github.com/dalsoop/dalcenter/pull/42" { + t.Errorf("expected PR URL extracted, got %q", p.PRUrl) + } + if p.Error != "" { + t.Errorf("expected no error for done task, got %q", p.Error) + } + if p.Changes != 3 { + t.Errorf("expected 3 changes, got %d", p.Changes) + } +} + +func TestBuildNotifyPayload_Failed(t *testing.T) { + tr := &taskResult{ + ID: "task-002", + Dal: "dev", + Task: "implement feature X", + Status: "failed", + Error: "compilation error: undefined variable", + } + p := buildNotifyPayload("dev", tr) + if p.Event != "task_failed" { + t.Errorf("expected event=task_failed, got %s", p.Event) + } + if p.Error == "" { + t.Error("expected error content in payload") + } +} + +func TestBuildNotifyPayload_Blocked(t *testing.T) { + tr := &taskResult{ + ID: "task-003", + Status: "blocked", + Error: "need approval", + } + p := buildNotifyPayload("dev", tr) + if p.Event != "task_failed" { + t.Errorf("blocked should map to task_failed event, got %s", p.Event) + } +} + +func TestExtractPRUrl(t *testing.T) { + tests := []struct { + name string + output string + want string + }{ + { + name: "github PR URL in output", + output: "Created PR: https://github.com/dalsoop/dalcenter/pull/42", + want: "https://github.com/dalsoop/dalcenter/pull/42", + }, + { + name: "no PR URL", + output: "all tests passed\nno changes", + want: "", + }, + { + name: "PR URL with trailing punctuation", + output: "see https://github.com/dalsoop/dalcenter/pull/99.", + want: "https://github.com/dalsoop/dalcenter/pull/99", + }, + { + name: "multiple lines with PR", + output: "line1\nline2\nhttps://github.com/org/repo/pull/123\nline4", + want: "https://github.com/org/repo/pull/123", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractPRUrl(tt.output) + if got != tt.want { + t.Errorf("extractPRUrl() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSendNotifyHTTP(t *testing.T) { + var received NotifyPayload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected application/json, got %s", ct) + } + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + payload := NotifyPayload{ + Event: "task_done", + Dal: "leader", + TaskID: "task-001", + Status: "done", + PRUrl: "https://github.com/dalsoop/dalcenter/pull/42", + } + sendNotifyHTTP(srv.URL, payload) + + if received.Event != "task_done" { + t.Errorf("expected task_done, got %s", received.Event) + } + if received.PRUrl != "https://github.com/dalsoop/dalcenter/pull/42" { + t.Errorf("expected PR URL, got %s", received.PRUrl) + } +} + +func TestNotifyPayload_JSONSerialization(t *testing.T) { + p := NotifyPayload{ + Event: "task_done", + Dal: "leader", + TaskID: "task-001", + Task: "run tests", + Status: "done", + PRUrl: "https://github.com/org/repo/pull/1", + } + data, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + var decoded NotifyPayload + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + if decoded.PRUrl != p.PRUrl { + t.Errorf("PR URL lost in round-trip: got %q", decoded.PRUrl) + } + if decoded.Event != "task_done" { + t.Errorf("event lost in round-trip: got %q", decoded.Event) + } +} diff --git a/internal/daemon/task.go b/internal/daemon/task.go index ab8472ea..ae53f3ba 100644 --- a/internal/daemon/task.go +++ b/internal/daemon/task.go @@ -250,6 +250,10 @@ func (d *Daemon) handleTaskFinish(w http.ResponseWriter, r *http.Request) { http.Error(w, "task not found", http.StatusNotFound) return } + + // Notify dalroot on external task finish + notifyTaskComplete(tr.Dal, tr, d.serviceRepo) + respondJSON(w, http.StatusOK, tr) } @@ -449,10 +453,8 @@ func (d *Daemon) execTaskInContainer(c *Container, tr *taskResult) { Timestamp: now.Format(time.RFC3339), }) - // Notify dalroot if callback pane was specified - if tr.CallbackPane != "" { - notifyDalroot(c.DalName, tr, d.serviceRepo) - } + // Notify dalroot on failure + notifyTaskComplete(c.DalName, tr, d.serviceRepo) } else { tr.Status = "done" tr.Output = stdout.String() @@ -484,10 +486,8 @@ func (d *Daemon) execTaskInContainer(c *Container, tr *taskResult) { Timestamp: now.Format(time.RFC3339), }) - // Notify dalroot if callback pane was specified - if tr.CallbackPane != "" { - notifyDalroot(c.DalName, tr, d.serviceRepo) - } + // Notify dalroot on completion + notifyTaskComplete(c.DalName, tr, d.serviceRepo) } } @@ -519,15 +519,6 @@ func verifyTaskChanges(containerID string, tr *taskResult) { } } -// notifyDalroot calls notify-dalroot to send a notification to the requesting pane. -func notifyDalroot(dalName string, tr *taskResult, repo string) { - msg := fmt.Sprintf("[%s] task %s: %s", dalName, tr.Status, truncateStr(tr.Task, 80)) - cmd := exec.Command("notify-dalroot", repo, msg, tr.CallbackPane) - if err := cmd.Run(); err != nil { - log.Printf("[notify] dalroot notification failed: %v", err) - } -} - func truncateStr(s string, n int) string { if len(s) <= n { return s