From e0d2b5b616fdcf7705bc872f9f9d5f9a6f8411c0 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 07:32:31 +0900 Subject: [PATCH 01/12] Initial nomadcode-core project --- .gitignore | 3 + Dockerfile | 22 ++++ Makefile | 29 +++++ README.md | 135 ++++++++++++++++++++ bin/build | 10 ++ bin/docker-down | 7 ++ bin/docker-up | 7 ++ bin/migrate-up | 17 +++ bin/run | 11 ++ bin/sqlc | 13 ++ bin/test | 7 ++ cmd/server/main.go | 98 +++++++++++++++ docker-compose.yml | 35 ++++++ go.mod | 30 +++++ go.sum | 63 ++++++++++ goose.env.example | 2 + internal/adapters/mattermost/client.go | 54 ++++++++ internal/adapters/plane/client.go | 60 +++++++++ internal/config/config.go | 33 +++++ internal/db/db.go | 30 +++++ internal/db/queries.go | 164 +++++++++++++++++++++++++ internal/http/handlers.go | 137 +++++++++++++++++++++ internal/http/middleware.go | 37 ++++++ internal/http/router.go | 29 +++++ internal/notification/model.go | 8 ++ internal/notification/service.go | 36 ++++++ internal/scheduler/jobs.go | 86 +++++++++++++ internal/scheduler/river.go | 81 ++++++++++++ internal/storage/store.go | 56 +++++++++ internal/workflow/model.go | 20 +++ internal/workflow/service.go | 102 +++++++++++++++ migrations/00001_create_tasks.sql | 23 ++++ queries/tasks.sql | 33 +++++ sqlc.yaml | 18 +++ 34 files changed, 1496 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100755 bin/build create mode 100755 bin/docker-down create mode 100755 bin/docker-up create mode 100755 bin/migrate-up create mode 100755 bin/run create mode 100755 bin/sqlc create mode 100755 bin/test create mode 100644 cmd/server/main.go create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 goose.env.example create mode 100644 internal/adapters/mattermost/client.go create mode 100644 internal/adapters/plane/client.go create mode 100644 internal/config/config.go create mode 100644 internal/db/db.go create mode 100644 internal/db/queries.go create mode 100644 internal/http/handlers.go create mode 100644 internal/http/middleware.go create mode 100644 internal/http/router.go create mode 100644 internal/notification/model.go create mode 100644 internal/notification/service.go create mode 100644 internal/scheduler/jobs.go create mode 100644 internal/scheduler/river.go create mode 100644 internal/storage/store.go create mode 100644 internal/workflow/model.go create mode 100644 internal/workflow/service.go create mode 100644 migrations/00001_create_tasks.sql create mode 100644 queries/tasks.sql create mode 100644 sqlc.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0394978 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.build/ +/bin/nomadcode-core +coverage.out diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..77631b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS build +WORKDIR /src + +RUN apk add --no-cache ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/nomadcode-core ./cmd/server + +FROM alpine:3.21 +RUN adduser -D -H appuser +WORKDIR /app + +COPY --from=build /bin/nomadcode-core /usr/local/bin/nomadcode-core + +USER appuser +EXPOSE 8080 +ENTRYPOINT ["nomadcode-core"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..196e438 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +export APP_ENV ?= local +export HTTP_ADDR ?= :8080 +export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable +export GOOSE_DRIVER ?= postgres +export GOOSE_DBSTRING ?= $(DATABASE_URL) +export OUTPUT ?= .build/nomadcode-core + +.PHONY: run test build docker-up docker-down migrate-up sqlc + +run: + ./bin/run + +test: + ./bin/test + +build: + ./bin/build + +docker-up: + ./bin/docker-up + +docker-down: + ./bin/docker-down + +migrate-up: + ./bin/migrate-up + +sqlc: + ./bin/sqlc diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd653c2 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# NomadCode Core + +NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 저장하며, 비동기 Agent 작업 흐름을 관리하기 위한 서버입니다. + +초기 목표는 작업 생성과 조회, PostgreSQL 기반 작업 상태 저장, River 기반 비동기 작업 실행, Plane/Mattermost Adapter stub 구성, 이후 IOP / Agent Integrator와 연결 가능한 구조 확보입니다. + +## 현재 구현 범위 + +- Go HTTP Server +- `GET /healthz`, `GET /readyz` +- task 생성 / 조회 / 목록 / enqueue API +- PostgreSQL 연결 +- goose migration +- sqlc 기반 query 구조 +- River dummy job +- Plane Adapter stub +- Mattermost Adapter stub +- 선택적 Docker Compose 실행 환경 + +## 현재 구현하지 않은 범위 + +- 실제 Plane API 호출 +- 실제 Mattermost 메시지 발송 +- IOP 연동 +- Agent Integrator 연동 +- Outline / Forgejo / Nextcloud 연동 +- MCP 서버 +- Web Agent UI +- Flutter 앱 +- 복잡한 권한 정책 +- 복잡한 workflow DSL + +## 단계별 다음 작업 + +### Phase 1. Server Skeleton + +현재 스캐폴드 단계입니다. + +목표: +- 서버 실행 골격 구성 +- task 저장 구조 구성 +- dummy 비동기 job 실행 +- Adapter stub 생성 + +### Phase 2. Workflow Core + +목표: +- task 상태 전이 정리 +- enqueue / running / completed / failed 흐름 안정화 +- retry / timeout 기본 구조 추가 +- notification event 구조 정리 + +### Phase 3. External Integration + +목표: +- Plane issue 생성 / comment / status update 구현 +- Mattermost 메시지 발송 구현 +- Agent Integrator 호출 구조 추가 +- IOP 호출 구조 추가 + +## 실행 방법 + +로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. + +PostgreSQL 사용자와 DB 생성 예시: + +```bash +psql postgres -c "CREATE USER nomadcode WITH PASSWORD 'nomadcode';" +psql postgres -c "CREATE DATABASE nomadcode OWNER nomadcode;" +``` + +Migration 실행: + +```bash +./bin/migrate-up +``` + +서버 실행: + +```bash +./bin/run +``` + +다른 DB 주소를 사용할 경우: + +```bash +DATABASE_URL="postgres://user:password@localhost:5432/dbname?sslmode=disable" ./bin/migrate-up +DATABASE_URL="postgres://user:password@localhost:5432/dbname?sslmode=disable" ./bin/run +``` + +테스트 실행: + +```bash +./bin/test +``` + +sqlc 실행: + +```bash +./bin/sqlc +``` + +Docker Compose 실행은 통합 확인이 필요할 때 선택적으로 사용합니다. + +```bash +./bin/docker-up +``` + +Makefile은 같은 명령을 감싸는 얇은 alias입니다. + +```bash +make migrate-up +make run +make test +make sqlc +``` + +API 테스트: + +```bash +curl localhost:8080/healthz +curl localhost:8080/readyz +``` + +```bash +curl -X POST localhost:8080/api/tasks \ + -H 'Content-Type: application/json' \ + -d '{"title":"README 수정 작업","source":"manual","payload":{"message":"README 초안을 정리해줘"}}' +``` + +```bash +curl localhost:8080/api/tasks +curl localhost:8080/api/tasks/{id} +curl -X POST localhost:8080/api/tasks/{id}/enqueue +``` diff --git a/bin/build b/bin/build new file mode 100755 index 0000000..6346104 --- /dev/null +++ b/bin/build @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +OUTPUT="${OUTPUT:-.build/nomadcode-core}" +mkdir -p "$(dirname "$OUTPUT")" + +exec go build -o "$OUTPUT" ./cmd/server diff --git a/bin/docker-down b/bin/docker-down new file mode 100755 index 0000000..2ce9574 --- /dev/null +++ b/bin/docker-down @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +exec docker compose down "$@" diff --git a/bin/docker-up b/bin/docker-up new file mode 100755 index 0000000..180fa17 --- /dev/null +++ b/bin/docker-up @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +exec docker compose up --build "$@" diff --git a/bin/migrate-up b/bin/migrate-up new file mode 100755 index 0000000..a7a6ad8 --- /dev/null +++ b/bin/migrate-up @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}" +DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" +GOOSE_DBSTRING="${GOOSE_DBSTRING:-$DATABASE_URL}" + +if [[ -n "${GOOSE_BIN:-}" ]]; then + goose_cmd=("$GOOSE_BIN") +else + goose_cmd=(go run github.com/pressly/goose/v3/cmd/goose@v3.27.1) +fi + +exec "${goose_cmd[@]}" -dir migrations "$GOOSE_DRIVER" "$GOOSE_DBSTRING" up diff --git a/bin/run b/bin/run new file mode 100755 index 0000000..d024278 --- /dev/null +++ b/bin/run @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +export APP_ENV="${APP_ENV:-local}" +export HTTP_ADDR="${HTTP_ADDR:-:8080}" +export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" + +exec go run ./cmd/server "$@" diff --git a/bin/sqlc b/bin/sqlc new file mode 100755 index 0000000..6c62921 --- /dev/null +++ b/bin/sqlc @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ -n "${SQLC_BIN:-}" ]]; then + sqlc_cmd=("$SQLC_BIN") +else + sqlc_cmd=(go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1) +fi + +exec "${sqlc_cmd[@]}" generate "$@" diff --git a/bin/test b/bin/test new file mode 100755 index 0000000..38a515f --- /dev/null +++ b/bin/test @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +exec go test ./... "$@" diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..8c102f1 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" + "github.com/nomadcode/nomadcode-core/internal/config" + "github.com/nomadcode/nomadcode-core/internal/db" + apphttp "github.com/nomadcode/nomadcode-core/internal/http" + "github.com/nomadcode/nomadcode-core/internal/notification" + "github.com/nomadcode/nomadcode-core/internal/scheduler" + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workflow" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + if err := run(logger); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + cfg := config.Load() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + pool, err := db.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + return err + } + defer pool.Close() + + store := storage.NewStore(pool) + mattermostClient := mattermost.NewClient(mattermost.Config{ + BaseURL: cfg.MattermostBaseURL, + Token: cfg.MattermostToken, + }, logger) + notificationService := notification.NewService(mattermostClient, logger) + + taskScheduler, err := scheduler.New(pool, store, notificationService, logger) + if err != nil { + return err + } + if err := taskScheduler.Migrate(ctx); err != nil { + return err + } + if err := taskScheduler.Start(ctx); err != nil { + return err + } + + workflowService := workflow.NewService(store, taskScheduler, logger) + handler := apphttp.NewHandler(pool, workflowService, logger) + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: apphttp.NewRouter(handler, logger), + ReadHeaderTimeout: 5 * time.Second, + } + + serverErr := make(chan error, 1) + go func() { + logger.Info("server listening", "addr", cfg.HTTPAddr, "env", cfg.AppEnv) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serverErr <- err + return + } + serverErr <- nil + }() + + select { + case <-ctx.Done(): + case err := <-serverErr: + if err != nil { + return err + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + if err := taskScheduler.Stop(shutdownCtx); err != nil { + return err + } + + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..094239b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: nomadcode + POSTGRES_USER: nomadcode + POSTGRES_PASSWORD: nomadcode + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U nomadcode -d nomadcode"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - postgres-data:/var/lib/postgresql/data + + nomadcode-core: + build: . + environment: + APP_ENV: local + HTTP_ADDR: :8080 + DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable + MATTERMOST_BASE_URL: "" + MATTERMOST_TOKEN: "" + PLANE_BASE_URL: "" + PLANE_TOKEN: "" + depends_on: + postgres: + condition: service_healthy + ports: + - "8080:8080" + +volumes: + postgres-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..20f7431 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module github.com/nomadcode/nomadcode-core + +go 1.25.0 + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/jackc/pgx/v5 v5.9.2 + github.com/riverqueue/river v0.37.1 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.37.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/riverqueue/river/riverdriver v0.37.1 // indirect + github.com/riverqueue/river/rivershared v0.37.1 // indirect + github.com/riverqueue/river/rivertype v0.37.1 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.uber.org/goleak v1.3.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9d7375f --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/riverqueue/river v0.37.1 h1:lZgXooqtGulMrItWFzhEDLeSBWoiJ3ZmKofKkHgBOwo= +github.com/riverqueue/river v0.37.1/go.mod h1:+5tefuBbaBRWiIEWOIyQexwQwhPVX23tsWgomIAcP88= +github.com/riverqueue/river/riverdriver v0.37.1 h1:IXV2xdS+LvsBvrJK+/GOy9s/a8P+9JW41RYIV/Vdya0= +github.com/riverqueue/river/riverdriver v0.37.1/go.mod h1:e7x1Q9gUFpbtKJ6/K4qpGxPT1ayc9NGlknZbwl0Re14= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.37.1 h1:dja9aBIEZTRk+gX+38t6/h+hLdWex7TmJdxft0Y0EXU= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.37.1/go.mod h1:48ij+FKRcarxG2fDscCreFLUiDXs+u6Wxjx5/TntKKU= +github.com/riverqueue/river/rivershared v0.37.1 h1:lvsxy9RAU+f1gRQ8+B4lVylyb184z4/82ilyU0DrsnY= +github.com/riverqueue/river/rivershared v0.37.1/go.mod h1:NAJdSXrjUjqdudLGCEm5heD2YRccClI9I9Yq+F3fyQM= +github.com/riverqueue/river/rivertype v0.37.1 h1:XZlhRR+c4RIgTTR//mJUZGAR26FNAHlMaNERpgUjb0c= +github.com/riverqueue/river/rivertype v0.37.1/go.mod h1:D1Ad+EaZiaXbQbJcJcfeicXJMBKno0n6UcfKI5Q7DIQ= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/goose.env.example b/goose.env.example new file mode 100644 index 0000000..b55e3db --- /dev/null +++ b/goose.env.example @@ -0,0 +1,2 @@ +GOOSE_DRIVER=postgres +GOOSE_DBSTRING=postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable diff --git a/internal/adapters/mattermost/client.go b/internal/adapters/mattermost/client.go new file mode 100644 index 0000000..7005a1b --- /dev/null +++ b/internal/adapters/mattermost/client.go @@ -0,0 +1,54 @@ +package mattermost + +import ( + "context" + "log/slog" +) + +type Config struct { + BaseURL string + Token string +} + +type Client struct { + cfg Config + logger *slog.Logger +} + +type MessageInput struct { + ChannelID string + Text string +} + +type TaskNotificationInput struct { + TaskID string + Title string + Status string + Message string +} + +func NewClient(cfg Config, logger *slog.Logger) *Client { + return &Client{cfg: cfg, logger: logger} +} + +func (c *Client) SendMessage(ctx context.Context, input MessageInput) error { + _ = ctx + if c.logger != nil { + c.logger.Info("mattermost send message skipped", "channel_id", input.ChannelID, "text", input.Text) + } + return nil +} + +func (c *Client) SendTaskNotification(ctx context.Context, input TaskNotificationInput) error { + _ = ctx + if c.logger != nil { + c.logger.Info( + "mattermost task notification skipped", + "task_id", input.TaskID, + "title", input.Title, + "status", input.Status, + "message", input.Message, + ) + } + return nil +} diff --git a/internal/adapters/plane/client.go b/internal/adapters/plane/client.go new file mode 100644 index 0000000..cb66721 --- /dev/null +++ b/internal/adapters/plane/client.go @@ -0,0 +1,60 @@ +package plane + +import ( + "context" + "errors" + "log/slog" +) + +var ErrNotImplemented = errors.New("plane adapter not implemented") + +type Config struct { + BaseURL string + Token string +} + +type Client struct { + cfg Config + logger *slog.Logger +} + +type CreateIssueInput struct { + Title string + Description string +} + +type AddCommentInput struct { + IssueID string + Body string +} + +type UpdateIssueStatusInput struct { + IssueID string + Status string +} + +func NewClient(cfg Config, logger *slog.Logger) *Client { + return &Client{cfg: cfg, logger: logger} +} + +func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) error { + c.log(ctx, "plane create issue skipped", "title", input.Title) + return ErrNotImplemented +} + +func (c *Client) AddComment(ctx context.Context, input AddCommentInput) error { + c.log(ctx, "plane add comment skipped", "issue_id", input.IssueID) + return ErrNotImplemented +} + +func (c *Client) UpdateIssueStatus(ctx context.Context, input UpdateIssueStatusInput) error { + c.log(ctx, "plane update issue status skipped", "issue_id", input.IssueID, "status", input.Status) + return ErrNotImplemented +} + +func (c *Client) log(ctx context.Context, message string, args ...any) { + _ = ctx + if c.logger != nil { + c.logger.Info(message, args...) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..5961d76 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,33 @@ +package config + +import "os" + +type Config struct { + AppEnv string + HTTPAddr string + DatabaseURL string + MattermostBaseURL string + MattermostToken string + PlaneBaseURL string + PlaneToken string +} + +func Load() Config { + return Config{ + AppEnv: getEnv("APP_ENV", "local"), + HTTPAddr: getEnv("HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"), + MattermostToken: os.Getenv("MATTERMOST_TOKEN"), + PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), + PlaneToken: os.Getenv("PLANE_TOKEN"), + } +} + +func getEnv(key, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..1a90c27 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,30 @@ +package db + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + if databaseURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return pool, nil +} diff --git a/internal/db/queries.go b/internal/db/queries.go new file mode 100644 index 0000000..0f4b954 --- /dev/null +++ b/internal/db/queries.go @@ -0,0 +1,164 @@ +// Code generated placeholder for sqlc-style queries. DO NOT EDIT lightly. +package db + +import ( + "context" + "encoding/json" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...any) (pgconn.CommandTag, error) + Query(context.Context, string, ...any) (pgx.Rows, error) + QueryRow(context.Context, string, ...any) pgx.Row +} + +type Queries struct { + db DBTX +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Status string `json:"status"` + Payload json.RawMessage `json:"payload"` + Result json.RawMessage `json:"result"` + Error *string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateTaskParams struct { + Title string `json:"title"` + Source string `json:"source"` + Payload json.RawMessage `json:"payload"` +} + +type UpdateTaskStatusParams struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type CompleteTaskParams struct { + ID string `json:"id"` + Result json.RawMessage `json:"result"` +} + +type FailTaskParams struct { + ID string `json:"id"` + Error string `json:"error"` +} + +const taskColumns = ` + id::text, + title, + source, + status, + payload, + result, + error, + created_at, + updated_at +` + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, ` + INSERT INTO tasks (title, source, payload) + VALUES ($1, $2, $3) + RETURNING `+taskColumns, arg.Title, arg.Source, arg.Payload) + return scanTask(row) +} + +func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) { + row := q.db.QueryRow(ctx, ` + SELECT `+taskColumns+` + FROM tasks + WHERE id::text = $1 + `, id) + return scanTask(row) +} + +func (q *Queries) ListTasks(ctx context.Context, limit int32) ([]Task, error) { + rows, err := q.db.Query(ctx, ` + SELECT `+taskColumns+` + FROM tasks + ORDER BY created_at DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []Task + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return tasks, nil +} + +func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusParams) (Task, error) { + row := q.db.QueryRow(ctx, ` + UPDATE tasks + SET status = $2, updated_at = now() + WHERE id::text = $1 + RETURNING `+taskColumns, arg.ID, arg.Status) + return scanTask(row) +} + +func (q *Queries) CompleteTask(ctx context.Context, arg CompleteTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, ` + UPDATE tasks + SET status = 'completed', result = $2, error = NULL, updated_at = now() + WHERE id::text = $1 + RETURNING `+taskColumns, arg.ID, arg.Result) + return scanTask(row) +} + +func (q *Queries) FailTask(ctx context.Context, arg FailTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, ` + UPDATE tasks + SET status = 'failed', error = $2, updated_at = now() + WHERE id::text = $1 + RETURNING `+taskColumns, arg.ID, arg.Error) + return scanTask(row) +} + +type taskScanner interface { + Scan(dest ...any) error +} + +func scanTask(row taskScanner) (Task, error) { + var task Task + err := row.Scan( + &task.ID, + &task.Title, + &task.Source, + &task.Status, + &task.Payload, + &task.Result, + &task.Error, + &task.CreatedAt, + &task.UpdatedAt, + ) + if err != nil { + return Task{}, err + } + return task, nil +} diff --git a/internal/http/handlers.go b/internal/http/handlers.go new file mode 100644 index 0000000..d098fbc --- /dev/null +++ b/internal/http/handlers.go @@ -0,0 +1,137 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/nomadcode/nomadcode-core/internal/workflow" +) + +type Handler struct { + db *pgxpool.Pool + workflow *workflow.Service + logger *slog.Logger +} + +func NewHandler(pool *pgxpool.Pool, workflowService *workflow.Service, logger *slog.Logger) *Handler { + return &Handler{ + db: pool, + workflow: workflowService, + logger: logger, + } +} + +func (h *Handler) Healthz(w stdhttp.ResponseWriter, r *stdhttp.Request) { + writeJSON(w, stdhttp.StatusOK, map[string]string{"status": "ok"}) +} + +func (h *Handler) Readyz(w stdhttp.ResponseWriter, r *stdhttp.Request) { + ctx, cancel := context.WithTimeout(r.Context(), readyTimeout) + defer cancel() + + if err := h.db.Ping(ctx); err != nil { + writeError(w, stdhttp.StatusServiceUnavailable, "database is not ready") + return + } + + writeJSON(w, stdhttp.StatusOK, map[string]string{"status": "ready"}) +} + +func (h *Handler) CreateTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { + var input workflow.CreateTaskInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, stdhttp.StatusBadRequest, "invalid JSON body") + return + } + + task, err := h.workflow.CreateTask(r.Context(), input) + if err != nil { + h.writeServiceError(w, err) + return + } + + writeJSON(w, stdhttp.StatusCreated, map[string]string{ + "id": task.ID, + "status": task.Status, + }) +} + +func (h *Handler) GetTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { + task, err := h.workflow.GetTask(r.Context(), chi.URLParam(r, "id")) + if err != nil { + h.writeServiceError(w, err) + return + } + + writeJSON(w, stdhttp.StatusOK, task) +} + +func (h *Handler) ListTasks(w stdhttp.ResponseWriter, r *stdhttp.Request) { + limit := int32(20) + if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" { + parsed, err := strconv.Atoi(rawLimit) + if err != nil || parsed < 1 { + writeError(w, stdhttp.StatusBadRequest, "limit must be a positive integer") + return + } + limit = int32(parsed) + } + + tasks, err := h.workflow.ListTasks(r.Context(), limit) + if err != nil { + h.writeServiceError(w, err) + return + } + + writeJSON(w, stdhttp.StatusOK, tasks) +} + +func (h *Handler) EnqueueTask(w stdhttp.ResponseWriter, r *stdhttp.Request) { + task, err := h.workflow.EnqueueTask(r.Context(), chi.URLParam(r, "id")) + if err != nil { + h.writeServiceError(w, err) + return + } + + writeJSON(w, stdhttp.StatusOK, map[string]string{ + "id": task.ID, + "status": task.Status, + }) +} + +func (h *Handler) writeServiceError(w stdhttp.ResponseWriter, err error) { + switch { + case errors.Is(err, workflow.ErrInvalidTaskInput): + writeError(w, stdhttp.StatusBadRequest, err.Error()) + case errors.Is(err, workflow.ErrTaskCannotBeEnqueued): + writeError(w, stdhttp.StatusConflict, err.Error()) + case errors.Is(err, pgx.ErrNoRows): + writeError(w, stdhttp.StatusNotFound, "task not found") + default: + if h.logger != nil { + h.logger.Error("request failed", "error", err) + } + writeError(w, stdhttp.StatusInternalServerError, "internal server error") + } +} + +const readyTimeout = 2 * time.Second + +func writeJSON(w stdhttp.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeError(w stdhttp.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/http/middleware.go b/internal/http/middleware.go new file mode 100644 index 0000000..d08d153 --- /dev/null +++ b/internal/http/middleware.go @@ -0,0 +1,37 @@ +package http + +import ( + "log/slog" + stdhttp "net/http" + "time" +) + +type statusRecorder struct { + stdhttp.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +func loggingMiddleware(logger *slog.Logger) func(stdhttp.Handler) stdhttp.Handler { + return func(next stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + start := time.Now() + recorder := &statusRecorder{ResponseWriter: w, status: stdhttp.StatusOK} + next.ServeHTTP(recorder, r) + + if logger != nil { + logger.Info( + "http request", + "method", r.Method, + "path", r.URL.Path, + "status", recorder.status, + "duration", time.Since(start).String(), + ) + } + }) + } +} diff --git a/internal/http/router.go b/internal/http/router.go new file mode 100644 index 0000000..8de899c --- /dev/null +++ b/internal/http/router.go @@ -0,0 +1,29 @@ +package http + +import ( + "log/slog" + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler { + r := chi.NewRouter() + r.Use(chimiddleware.RequestID) + r.Use(chimiddleware.RealIP) + r.Use(loggingMiddleware(logger)) + r.Use(chimiddleware.Recoverer) + + r.Get("/healthz", handler.Healthz) + r.Get("/readyz", handler.Readyz) + + r.Route("/api", func(r chi.Router) { + r.Post("/tasks", handler.CreateTask) + r.Get("/tasks", handler.ListTasks) + r.Get("/tasks/{id}", handler.GetTask) + r.Post("/tasks/{id}/enqueue", handler.EnqueueTask) + }) + + return r +} diff --git a/internal/notification/model.go b/internal/notification/model.go new file mode 100644 index 0000000..13051e7 --- /dev/null +++ b/internal/notification/model.go @@ -0,0 +1,8 @@ +package notification + +type TaskNotification struct { + TaskID string + Title string + Status string + Message string +} diff --git a/internal/notification/service.go b/internal/notification/service.go new file mode 100644 index 0000000..690d39a --- /dev/null +++ b/internal/notification/service.go @@ -0,0 +1,36 @@ +package notification + +import ( + "context" + "log/slog" + + "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" +) + +type Service struct { + mattermost *mattermost.Client + logger *slog.Logger +} + +func NewService(mattermostClient *mattermost.Client, logger *slog.Logger) *Service { + return &Service{ + mattermost: mattermostClient, + logger: logger, + } +} + +func (s *Service) NotifyTaskCompleted(ctx context.Context, input TaskNotification) error { + if s.logger != nil { + s.logger.Info("task completed notification requested", "task_id", input.TaskID) + } + if s.mattermost == nil { + return nil + } + + return s.mattermost.SendTaskNotification(ctx, mattermost.TaskNotificationInput{ + TaskID: input.TaskID, + Title: input.Title, + Status: input.Status, + Message: input.Message, + }) +} diff --git a/internal/scheduler/jobs.go b/internal/scheduler/jobs.go new file mode 100644 index 0000000..747be9c --- /dev/null +++ b/internal/scheduler/jobs.go @@ -0,0 +1,86 @@ +package scheduler + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/riverqueue/river" + + "github.com/nomadcode/nomadcode-core/internal/notification" + "github.com/nomadcode/nomadcode-core/internal/storage" + "github.com/nomadcode/nomadcode-core/internal/workflow" +) + +type TaskJobArgs struct { + TaskID string `json:"task_id"` +} + +func (TaskJobArgs) Kind() string { + return "task_run" +} + +type TaskWorker struct { + river.WorkerDefaults[TaskJobArgs] + + Store *storage.Store + Notifications *notification.Service + Logger *slog.Logger +} + +func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) error { + taskID := job.Args.TaskID + if w.Logger != nil { + w.Logger.Info("task job started", "task_id", taskID) + } + + task, err := w.Store.UpdateStatus(ctx, taskID, string(workflow.StatusRunning)) + if err != nil { + return err + } + + select { + case <-time.After(500 * time.Millisecond): + case <-ctx.Done(): + w.markFailed(taskID, ctx.Err()) + return ctx.Err() + } + + result := json.RawMessage(`{"message":"dummy task completed"}`) + task, err = w.Store.CompleteTask(ctx, taskID, result) + if err != nil { + w.markFailed(taskID, err) + return err + } + + if w.Notifications != nil { + err = w.Notifications.NotifyTaskCompleted(ctx, notification.TaskNotification{ + TaskID: task.ID, + Title: task.Title, + Status: task.Status, + Message: "dummy task completed", + }) + if err != nil && w.Logger != nil { + w.Logger.Warn("task notification failed", "task_id", taskID, "error", err) + } + } + + if w.Logger != nil { + w.Logger.Info("task job completed", "task_id", taskID) + } + return nil +} + +func (w *TaskWorker) markFailed(taskID string, err error) { + if err == nil || w.Store == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, failErr := w.Store.FailTask(ctx, taskID, err.Error()); failErr != nil && w.Logger != nil { + w.Logger.Error("failed to mark task failed", "task_id", taskID, "error", failErr) + } +} diff --git a/internal/scheduler/river.go b/internal/scheduler/river.go new file mode 100644 index 0000000..c2d5d83 --- /dev/null +++ b/internal/scheduler/river.go @@ -0,0 +1,81 @@ +package scheduler + +import ( + "context" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/riverqueue/river" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" + + "github.com/nomadcode/nomadcode-core/internal/notification" + "github.com/nomadcode/nomadcode-core/internal/storage" +) + +type Client struct { + river *river.Client[pgx.Tx] + driver *riverpgxv5.Driver + logger *slog.Logger +} + +func New(pool *pgxpool.Pool, store *storage.Store, notifications *notification.Service, logger *slog.Logger) (*Client, error) { + workers := river.NewWorkers() + river.AddWorker(workers, &TaskWorker{ + Store: store, + Notifications: notifications, + Logger: logger, + }) + + driver := riverpgxv5.New(pool) + riverClient, err := river.NewClient[pgx.Tx](driver, &river.Config{ + Logger: logger, + Queues: map[string]river.QueueConfig{ + river.QueueDefault: {MaxWorkers: 2}, + }, + Workers: workers, + MaxAttempts: 3, + }) + if err != nil { + return nil, err + } + + return &Client{ + river: riverClient, + driver: driver, + logger: logger, + }, nil +} + +func (c *Client) Migrate(ctx context.Context) error { + migrator, err := rivermigrate.New[pgx.Tx](c.driver, &rivermigrate.Config{ + Logger: c.logger, + }) + if err != nil { + return err + } + + result, err := migrator.Migrate(ctx, rivermigrate.DirectionUp, nil) + if err != nil { + return err + } + if c.logger != nil && len(result.Versions) > 0 { + c.logger.Info("river migrations applied", "count", len(result.Versions)) + } + + return nil +} + +func (c *Client) Start(ctx context.Context) error { + return c.river.Start(ctx) +} + +func (c *Client) Stop(ctx context.Context) error { + return c.river.Stop(ctx) +} + +func (c *Client) EnqueueTask(ctx context.Context, taskID string) error { + _, err := c.river.Insert(ctx, TaskJobArgs{TaskID: taskID}, nil) + return err +} diff --git a/internal/storage/store.go b/internal/storage/store.go new file mode 100644 index 0000000..65453c6 --- /dev/null +++ b/internal/storage/store.go @@ -0,0 +1,56 @@ +package storage + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nomadcode/nomadcode-core/internal/db" +) + +type Task = db.Task + +type Store struct { + queries *db.Queries +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{queries: db.New(pool)} +} + +func (s *Store) CreateTask(ctx context.Context, title, source string, payload json.RawMessage) (db.Task, error) { + return s.queries.CreateTask(ctx, db.CreateTaskParams{ + Title: title, + Source: source, + Payload: payload, + }) +} + +func (s *Store) GetTask(ctx context.Context, id string) (db.Task, error) { + return s.queries.GetTask(ctx, id) +} + +func (s *Store) ListTasks(ctx context.Context, limit int32) ([]db.Task, error) { + return s.queries.ListTasks(ctx, limit) +} + +func (s *Store) UpdateStatus(ctx context.Context, id, status string) (db.Task, error) { + return s.queries.UpdateTaskStatus(ctx, db.UpdateTaskStatusParams{ + ID: id, + Status: status, + }) +} + +func (s *Store) CompleteTask(ctx context.Context, id string, result json.RawMessage) (db.Task, error) { + return s.queries.CompleteTask(ctx, db.CompleteTaskParams{ + ID: id, + Result: result, + }) +} + +func (s *Store) FailTask(ctx context.Context, id, message string) (db.Task, error) { + return s.queries.FailTask(ctx, db.FailTaskParams{ + ID: id, + Error: message, + }) +} diff --git a/internal/workflow/model.go b/internal/workflow/model.go new file mode 100644 index 0000000..759f4f1 --- /dev/null +++ b/internal/workflow/model.go @@ -0,0 +1,20 @@ +package workflow + +import "encoding/json" + +type TaskStatus string + +const ( + StatusPending TaskStatus = "pending" + StatusQueued TaskStatus = "queued" + StatusRunning TaskStatus = "running" + StatusCompleted TaskStatus = "completed" + StatusFailed TaskStatus = "failed" + StatusCanceled TaskStatus = "canceled" +) + +type CreateTaskInput struct { + Title string `json:"title"` + Source string `json:"source"` + Payload json.RawMessage `json:"payload"` +} diff --git a/internal/workflow/service.go b/internal/workflow/service.go new file mode 100644 index 0000000..5ae3133 --- /dev/null +++ b/internal/workflow/service.go @@ -0,0 +1,102 @@ +package workflow + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "strings" + + "github.com/nomadcode/nomadcode-core/internal/storage" +) + +var ( + ErrInvalidTaskInput = errors.New("invalid task input") + ErrTaskCannotBeEnqueued = errors.New("task cannot be enqueued in current status") +) + +type TaskEnqueuer interface { + EnqueueTask(ctx context.Context, taskID string) error +} + +type Service struct { + store *storage.Store + enqueuer TaskEnqueuer + logger *slog.Logger +} + +func NewService(store *storage.Store, enqueuer TaskEnqueuer, logger *slog.Logger) *Service { + return &Service{ + store: store, + enqueuer: enqueuer, + logger: logger, + } +} + +func (s *Service) CreateTask(ctx context.Context, input CreateTaskInput) (storage.Task, error) { + title := strings.TrimSpace(input.Title) + source := strings.TrimSpace(input.Source) + if title == "" || source == "" { + return storage.Task{}, ErrInvalidTaskInput + } + + payload := input.Payload + if len(payload) == 0 || string(payload) == "null" { + payload = json.RawMessage(`{}`) + } + if !json.Valid(payload) { + return storage.Task{}, ErrInvalidTaskInput + } + + return s.store.CreateTask(ctx, title, source, payload) +} + +func (s *Service) GetTask(ctx context.Context, id string) (storage.Task, error) { + return s.store.GetTask(ctx, id) +} + +func (s *Service) ListTasks(ctx context.Context, limit int32) ([]storage.Task, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + return s.store.ListTasks(ctx, limit) +} + +func (s *Service) EnqueueTask(ctx context.Context, id string) (storage.Task, error) { + task, err := s.store.GetTask(ctx, id) + if err != nil { + return storage.Task{}, err + } + if !canEnqueue(task.Status) { + return storage.Task{}, ErrTaskCannotBeEnqueued + } + + queuedTask, err := s.store.UpdateStatus(ctx, id, string(StatusQueued)) + if err != nil { + return storage.Task{}, err + } + + if s.enqueuer == nil { + return queuedTask, errors.New("task enqueuer is not configured") + } + if err := s.enqueuer.EnqueueTask(ctx, id); err != nil { + if _, failErr := s.store.FailTask(ctx, id, err.Error()); failErr != nil && s.logger != nil { + s.logger.Error("failed to mark task enqueue error", "task_id", id, "error", failErr) + } + return queuedTask, err + } + + return queuedTask, nil +} + +func canEnqueue(status string) bool { + switch TaskStatus(status) { + case StatusPending, StatusFailed: + return true + default: + return false + } +} diff --git a/migrations/00001_create_tasks.sql b/migrations/00001_create_tasks.sql new file mode 100644 index 0000000..4bd9dbb --- /dev/null +++ b/migrations/00001_create_tasks.sql @@ -0,0 +1,23 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + source text NOT NULL, + status text NOT NULL DEFAULT 'pending', + payload jsonb NOT NULL DEFAULT '{}', + result jsonb NOT NULL DEFAULT '{}', + error text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT tasks_status_check CHECK ( + status IN ('pending', 'queued', 'running', 'completed', 'failed', 'canceled') + ) +); + +CREATE INDEX IF NOT EXISTS tasks_status_idx ON tasks (status); +CREATE INDEX IF NOT EXISTS tasks_created_at_idx ON tasks (created_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS tasks; diff --git a/queries/tasks.sql b/queries/tasks.sql new file mode 100644 index 0000000..1265254 --- /dev/null +++ b/queries/tasks.sql @@ -0,0 +1,33 @@ +-- name: CreateTask :one +INSERT INTO tasks (title, source, payload) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetTask :one +SELECT * +FROM tasks +WHERE id::text = $1; + +-- name: ListTasks :many +SELECT * +FROM tasks +ORDER BY created_at DESC +LIMIT $1; + +-- name: UpdateTaskStatus :one +UPDATE tasks +SET status = $2, updated_at = now() +WHERE id::text = $1 +RETURNING *; + +-- name: CompleteTask :one +UPDATE tasks +SET status = 'completed', result = $2, error = NULL, updated_at = now() +WHERE id::text = $1 +RETURNING *; + +-- name: FailTask :one +UPDATE tasks +SET status = 'failed', error = $2, updated_at = now() +WHERE id::text = $1 +RETURNING *; diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..8c2acf0 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,18 @@ +version: "2" +sql: + - engine: postgresql + schema: migrations + queries: queries + gen: + go: + package: generated + out: internal/db/generated + sql_package: pgx/v5 + emit_json_tags: true + overrides: + - db_type: uuid + go_type: string + - db_type: jsonb + go_type: + import: encoding/json + type: RawMessage From 5e58770c8a00220595e5ff95b0833c2c6258fa8c Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 09:31:50 +0900 Subject: [PATCH 02/12] refactor: db layer separation and updates --- README.md | 4 +- internal/db/db.go | 46 +++++----- internal/db/models.go | 22 +++++ internal/db/pool.go | 30 ++++++ internal/db/queries.go | 164 --------------------------------- internal/db/tasks.sql.go | 187 ++++++++++++++++++++++++++++++++++++++ internal/storage/store.go | 2 +- queries/tasks.sql | 12 +-- sqlc.yaml | 9 +- 9 files changed, 280 insertions(+), 196 deletions(-) create mode 100644 internal/db/models.go create mode 100644 internal/db/pool.go delete mode 100644 internal/db/queries.go create mode 100644 internal/db/tasks.sql.go diff --git a/README.md b/README.md index fd653c2..8eb10f7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - task 생성 / 조회 / 목록 / enqueue API - PostgreSQL 연결 - goose migration -- sqlc 기반 query 구조 +- sqlc 기반 DB query 생성 구조 - River dummy job - Plane Adapter stub - Mattermost Adapter stub @@ -100,6 +100,8 @@ sqlc 실행: ./bin/sqlc ``` +DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으로 `internal/db/`에 생성합니다. `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 생성 파일이므로 직접 수정하지 않습니다. + Docker Compose 실행은 통합 확인이 필요할 때 선택적으로 사용합니다. ```bash diff --git a/internal/db/db.go b/internal/db/db.go index 1a90c27..468d1fa 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,30 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + package db import ( "context" - "errors" - "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) -func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { - if databaseURL == "" { - return nil, errors.New("DATABASE_URL is required") - } - - cfg, err := pgxpool.ParseConfig(databaseURL) - if err != nil { - return nil, err - } - - pool, err := pgxpool.NewWithConfig(ctx, cfg) - if err != nil { - return nil, err - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, err - } - - return pool, nil +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } } diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..777c6aa --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package db + +import ( + "encoding/json" + "time" +) + +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Status string `json:"status"` + Payload json.RawMessage `json:"payload"` + Result json.RawMessage `json:"result"` + Error *string `json:"error"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/db/pool.go b/internal/db/pool.go new file mode 100644 index 0000000..1a90c27 --- /dev/null +++ b/internal/db/pool.go @@ -0,0 +1,30 @@ +package db + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + if databaseURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + cfg, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, err + } + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + + return pool, nil +} diff --git a/internal/db/queries.go b/internal/db/queries.go deleted file mode 100644 index 0f4b954..0000000 --- a/internal/db/queries.go +++ /dev/null @@ -1,164 +0,0 @@ -// Code generated placeholder for sqlc-style queries. DO NOT EDIT lightly. -package db - -import ( - "context" - "encoding/json" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" -) - -type DBTX interface { - Exec(context.Context, string, ...any) (pgconn.CommandTag, error) - Query(context.Context, string, ...any) (pgx.Rows, error) - QueryRow(context.Context, string, ...any) pgx.Row -} - -type Queries struct { - db DBTX -} - -func New(db DBTX) *Queries { - return &Queries{db: db} -} - -type Task struct { - ID string `json:"id"` - Title string `json:"title"` - Source string `json:"source"` - Status string `json:"status"` - Payload json.RawMessage `json:"payload"` - Result json.RawMessage `json:"result"` - Error *string `json:"error,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CreateTaskParams struct { - Title string `json:"title"` - Source string `json:"source"` - Payload json.RawMessage `json:"payload"` -} - -type UpdateTaskStatusParams struct { - ID string `json:"id"` - Status string `json:"status"` -} - -type CompleteTaskParams struct { - ID string `json:"id"` - Result json.RawMessage `json:"result"` -} - -type FailTaskParams struct { - ID string `json:"id"` - Error string `json:"error"` -} - -const taskColumns = ` - id::text, - title, - source, - status, - payload, - result, - error, - created_at, - updated_at -` - -func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { - row := q.db.QueryRow(ctx, ` - INSERT INTO tasks (title, source, payload) - VALUES ($1, $2, $3) - RETURNING `+taskColumns, arg.Title, arg.Source, arg.Payload) - return scanTask(row) -} - -func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) { - row := q.db.QueryRow(ctx, ` - SELECT `+taskColumns+` - FROM tasks - WHERE id::text = $1 - `, id) - return scanTask(row) -} - -func (q *Queries) ListTasks(ctx context.Context, limit int32) ([]Task, error) { - rows, err := q.db.Query(ctx, ` - SELECT `+taskColumns+` - FROM tasks - ORDER BY created_at DESC - LIMIT $1 - `, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var tasks []Task - for rows.Next() { - task, err := scanTask(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, task) - } - if err := rows.Err(); err != nil { - return nil, err - } - - return tasks, nil -} - -func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusParams) (Task, error) { - row := q.db.QueryRow(ctx, ` - UPDATE tasks - SET status = $2, updated_at = now() - WHERE id::text = $1 - RETURNING `+taskColumns, arg.ID, arg.Status) - return scanTask(row) -} - -func (q *Queries) CompleteTask(ctx context.Context, arg CompleteTaskParams) (Task, error) { - row := q.db.QueryRow(ctx, ` - UPDATE tasks - SET status = 'completed', result = $2, error = NULL, updated_at = now() - WHERE id::text = $1 - RETURNING `+taskColumns, arg.ID, arg.Result) - return scanTask(row) -} - -func (q *Queries) FailTask(ctx context.Context, arg FailTaskParams) (Task, error) { - row := q.db.QueryRow(ctx, ` - UPDATE tasks - SET status = 'failed', error = $2, updated_at = now() - WHERE id::text = $1 - RETURNING `+taskColumns, arg.ID, arg.Error) - return scanTask(row) -} - -type taskScanner interface { - Scan(dest ...any) error -} - -func scanTask(row taskScanner) (Task, error) { - var task Task - err := row.Scan( - &task.ID, - &task.Title, - &task.Source, - &task.Status, - &task.Payload, - &task.Result, - &task.Error, - &task.CreatedAt, - &task.UpdatedAt, - ) - if err != nil { - return Task{}, err - } - return task, nil -} diff --git a/internal/db/tasks.sql.go b/internal/db/tasks.sql.go new file mode 100644 index 0000000..4c8728d --- /dev/null +++ b/internal/db/tasks.sql.go @@ -0,0 +1,187 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tasks.sql + +package db + +import ( + "context" + "encoding/json" +) + +const completeTask = `-- name: CompleteTask :one +UPDATE tasks +SET status = 'completed', result = $2, error = NULL, updated_at = now() +WHERE id::text = $1 +RETURNING id, title, source, status, payload, result, error, created_at, updated_at +` + +type CompleteTaskParams struct { + ID string `json:"id"` + Result json.RawMessage `json:"result"` +} + +func (q *Queries) CompleteTask(ctx context.Context, arg CompleteTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, completeTask, arg.ID, arg.Result) + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createTask = `-- name: CreateTask :one +INSERT INTO tasks (title, source, payload) +VALUES ($1, $2, $3) +RETURNING id, title, source, status, payload, result, error, created_at, updated_at +` + +type CreateTaskParams struct { + Title string `json:"title"` + Source string `json:"source"` + Payload json.RawMessage `json:"payload"` +} + +func (q *Queries) CreateTask(ctx context.Context, arg CreateTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, createTask, arg.Title, arg.Source, arg.Payload) + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const failTask = `-- name: FailTask :one +UPDATE tasks +SET status = 'failed', error = $2, updated_at = now() +WHERE id::text = $1 +RETURNING id, title, source, status, payload, result, error, created_at, updated_at +` + +type FailTaskParams struct { + ID string `json:"id"` + Error *string `json:"error"` +} + +func (q *Queries) FailTask(ctx context.Context, arg FailTaskParams) (Task, error) { + row := q.db.QueryRow(ctx, failTask, arg.ID, arg.Error) + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTask = `-- name: GetTask :one +SELECT id, title, source, status, payload, result, error, created_at, updated_at +FROM tasks +WHERE id::text = $1 +` + +func (q *Queries) GetTask(ctx context.Context, id string) (Task, error) { + row := q.db.QueryRow(ctx, getTask, id) + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listTasks = `-- name: ListTasks :many +SELECT id, title, source, status, payload, result, error, created_at, updated_at +FROM tasks +ORDER BY created_at DESC +LIMIT $1 +` + +func (q *Queries) ListTasks(ctx context.Context, limit int32) ([]Task, error) { + rows, err := q.db.Query(ctx, listTasks, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Task + for rows.Next() { + var i Task + if err := rows.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateTaskStatus = `-- name: UpdateTaskStatus :one +UPDATE tasks +SET status = $2, updated_at = now() +WHERE id::text = $1 +RETURNING id, title, source, status, payload, result, error, created_at, updated_at +` + +type UpdateTaskStatusParams struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateTaskStatus(ctx context.Context, arg UpdateTaskStatusParams) (Task, error) { + row := q.db.QueryRow(ctx, updateTaskStatus, arg.ID, arg.Status) + var i Task + err := row.Scan( + &i.ID, + &i.Title, + &i.Source, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/storage/store.go b/internal/storage/store.go index 65453c6..857edc3 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -51,6 +51,6 @@ func (s *Store) CompleteTask(ctx context.Context, id string, result json.RawMess func (s *Store) FailTask(ctx context.Context, id, message string) (db.Task, error) { return s.queries.FailTask(ctx, db.FailTaskParams{ ID: id, - Error: message, + Error: &message, }) } diff --git a/queries/tasks.sql b/queries/tasks.sql index 1265254..2ef3f11 100644 --- a/queries/tasks.sql +++ b/queries/tasks.sql @@ -1,15 +1,15 @@ -- name: CreateTask :one INSERT INTO tasks (title, source, payload) VALUES ($1, $2, $3) -RETURNING *; +RETURNING id, title, source, status, payload, result, error, created_at, updated_at; -- name: GetTask :one -SELECT * +SELECT id, title, source, status, payload, result, error, created_at, updated_at FROM tasks WHERE id::text = $1; -- name: ListTasks :many -SELECT * +SELECT id, title, source, status, payload, result, error, created_at, updated_at FROM tasks ORDER BY created_at DESC LIMIT $1; @@ -18,16 +18,16 @@ LIMIT $1; UPDATE tasks SET status = $2, updated_at = now() WHERE id::text = $1 -RETURNING *; +RETURNING id, title, source, status, payload, result, error, created_at, updated_at; -- name: CompleteTask :one UPDATE tasks SET status = 'completed', result = $2, error = NULL, updated_at = now() WHERE id::text = $1 -RETURNING *; +RETURNING id, title, source, status, payload, result, error, created_at, updated_at; -- name: FailTask :one UPDATE tasks SET status = 'failed', error = $2, updated_at = now() WHERE id::text = $1 -RETURNING *; +RETURNING id, title, source, status, payload, result, error, created_at, updated_at; diff --git a/sqlc.yaml b/sqlc.yaml index 8c2acf0..72bffa9 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -5,10 +5,11 @@ sql: queries: queries gen: go: - package: generated - out: internal/db/generated + package: db + out: internal/db sql_package: pgx/v5 emit_json_tags: true + emit_pointers_for_null_types: true overrides: - db_type: uuid go_type: string @@ -16,3 +17,7 @@ sql: go_type: import: encoding/json type: RawMessage + - db_type: timestamptz + go_type: + import: time + type: Time From b0345b644ac33c860655a0765fcca62a4d54bc75 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 10:27:13 +0900 Subject: [PATCH 03/12] chore: initialize agent-ops --- .aiexclude | 1 + .claude/settings.json | 8 + .clineignore | 1 + .clinerules | 16 + .cursorignore | 1 + .cursorrules | 16 + .geminiignore | 1 + .gitignore | 3 + AGENTS.md | 16 + CLAUDE.md | 16 + GEMINI.md | 16 + agent-ops/.version | 1 + agent-ops/bin/bump-version.sh | 28 ++ agent-ops/bin/entry-files.sh | 24 ++ agent-ops/bin/init-agent-ops.sh | 104 ++++++ agent-ops/bin/sync.sh | 182 ++++++++++ .../common/_templates/domain-rule-template.md | 29 ++ agent-ops/rules/common/rules.md | 16 + .../rules/project/domain/adapters/rules.md | 35 ++ .../rules/project/domain/http-api/rules.md | 38 ++ .../rules/project/domain/persistence/rules.md | 41 +++ .../rules/project/domain/scheduler/rules.md | 39 +++ .../rules/project/domain/workflow/rules.md | 38 ++ agent-ops/rules/project/rules.md | 38 ++ .../common/_templates/skill-template.md | 57 +++ agent-ops/skills/common/code-review/SKILL.md | 324 ++++++++++++++++++ .../common/code-review/agents/openai.yaml | 4 + .../templates/complete-log-template.md | 31 ++ agent-ops/skills/common/commit-push/SKILL.md | 124 +++++++ .../skills/common/create-domain-rule/SKILL.md | 113 ++++++ agent-ops/skills/common/create-skill/SKILL.md | 95 +++++ .../skills/common/init-agent-ops/SKILL.md | 243 +++++++++++++ agent-ops/skills/common/plan/SKILL.md | 317 +++++++++++++++++ .../skills/common/plan/agents/openai.yaml | 4 + agent-ops/skills/common/router.md | 13 + agent-ops/skills/common/sync-pull/SKILL.md | 36 ++ agent-ops/skills/common/sync-push/SKILL.md | 60 ++++ .../skills/common/update-domain-rule/SKILL.md | 100 ++++++ opencode.json | 16 + 39 files changed, 2245 insertions(+) create mode 100644 .aiexclude create mode 100644 .claude/settings.json create mode 100644 .clineignore create mode 100644 .clinerules create mode 100644 .cursorignore create mode 100644 .cursorrules create mode 100644 .geminiignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 agent-ops/.version create mode 100755 agent-ops/bin/bump-version.sh create mode 100644 agent-ops/bin/entry-files.sh create mode 100755 agent-ops/bin/init-agent-ops.sh create mode 100755 agent-ops/bin/sync.sh create mode 100644 agent-ops/rules/common/_templates/domain-rule-template.md create mode 100644 agent-ops/rules/common/rules.md create mode 100644 agent-ops/rules/project/domain/adapters/rules.md create mode 100644 agent-ops/rules/project/domain/http-api/rules.md create mode 100644 agent-ops/rules/project/domain/persistence/rules.md create mode 100644 agent-ops/rules/project/domain/scheduler/rules.md create mode 100644 agent-ops/rules/project/domain/workflow/rules.md create mode 100644 agent-ops/rules/project/rules.md create mode 100644 agent-ops/skills/common/_templates/skill-template.md create mode 100644 agent-ops/skills/common/code-review/SKILL.md create mode 100644 agent-ops/skills/common/code-review/agents/openai.yaml create mode 100644 agent-ops/skills/common/code-review/templates/complete-log-template.md create mode 100644 agent-ops/skills/common/commit-push/SKILL.md create mode 100644 agent-ops/skills/common/create-domain-rule/SKILL.md create mode 100644 agent-ops/skills/common/create-skill/SKILL.md create mode 100644 agent-ops/skills/common/init-agent-ops/SKILL.md create mode 100644 agent-ops/skills/common/plan/SKILL.md create mode 100644 agent-ops/skills/common/plan/agents/openai.yaml create mode 100644 agent-ops/skills/common/router.md create mode 100644 agent-ops/skills/common/sync-pull/SKILL.md create mode 100644 agent-ops/skills/common/sync-push/SKILL.md create mode 100644 agent-ops/skills/common/update-domain-rule/SKILL.md create mode 100644 opencode.json diff --git a/.aiexclude b/.aiexclude new file mode 100644 index 0000000..477be46 --- /dev/null +++ b/.aiexclude @@ -0,0 +1 @@ +agent-task/archive/** diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0c2baa3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "deny": [ + "Read(./agent-task/archive/**)" + ] + } +} diff --git a/.clineignore b/.clineignore new file mode 100644 index 0000000..477be46 --- /dev/null +++ b/.clineignore @@ -0,0 +1 @@ +agent-task/archive/** diff --git a/.clinerules b/.clinerules new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/.clinerules @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..477be46 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +agent-task/archive/** diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/.cursorrules @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000..477be46 --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +agent-task/archive/** diff --git a/.gitignore b/.gitignore index 0394978..d008d13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /.build/ /bin/nomadcode-core coverage.out + +# Agent-Ops Private Rules +agent-ops/rules/private/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/agent-ops/.version b/agent-ops/.version new file mode 100644 index 0000000..e9bc149 --- /dev/null +++ b/agent-ops/.version @@ -0,0 +1 @@ +1.1.14 diff --git a/agent-ops/bin/bump-version.sh b/agent-ops/bin/bump-version.sh new file mode 100755 index 0000000..4cc50ef --- /dev/null +++ b/agent-ops/bin/bump-version.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# bump-version.sh +# patch +1, 999 초과 시 minor 올림. major는 수동 관리. +# 결과 버전을 stdout으로 출력. + +set -euo pipefail + +YELLOW='\033[1;33m'; RESET='\033[0m' + +VERSION="${1:-}" +if [[ -z "$VERSION" ]]; then + echo "사용법: $0 " >&2 + exit 1 +fi + +IFS='.' read -r major minor patch <<< "$VERSION" + +patch=$((patch + 1)) +if [[ $patch -gt 999 ]]; then + patch=0 + minor=$((minor + 1)) +fi +if [[ $minor -gt 999 ]]; then + minor=0 + echo -e "${YELLOW}⚠ minor 버전이 999를 초과했습니다. major 버전을 수동으로 올려주세요.${RESET}" >&2 +fi + +echo "$major.$minor.$patch" diff --git a/agent-ops/bin/entry-files.sh b/agent-ops/bin/entry-files.sh new file mode 100644 index 0000000..dfb6f90 --- /dev/null +++ b/agent-ops/bin/entry-files.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +# Shared entry-point file list for init/sync scripts. +AGENT_OPS_ENTRY_FILES=("GEMINI.md" "CLAUDE.md" "AGENTS.md" ".cursorrules" ".clinerules") + +apply_agent_ops_entry_files() { + local rules_md="$1" + local target_root="$2" + + if [[ ! -f "$rules_md" ]]; then + if [[ -n "${YELLOW:-}" && -n "${RESET:-}" ]]; then + echo -e "${YELLOW} rules.md 없음, 진입 파일 재적용 건너뜀${RESET}" + else + echo " rules.md 없음, 진입 파일 재적용 건너뜀" + fi + return + fi + + local f + for f in "${AGENT_OPS_ENTRY_FILES[@]}"; do + cp "$rules_md" "$target_root/$f" + echo " 진입 파일 적용: $f" + done +} diff --git a/agent-ops/bin/init-agent-ops.sh b/agent-ops/bin/init-agent-ops.sh new file mode 100755 index 0000000..449c410 --- /dev/null +++ b/agent-ops/bin/init-agent-ops.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# agent-ops/bin/init-agent-ops.sh +# 프로젝트에 agent-ops 스캐폴드를 초기화하는 스크립트 + +set -e + +SCRIPT_DIR=$(realpath "$(dirname "$0")") +SOURCE_DIR=$(realpath "$SCRIPT_DIR/..") +source "$SCRIPT_DIR/entry-files.sh" + +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +TARGET_DIR=$(realpath "$1") +ARCHIVE_IGNORE_PATTERN="agent-task/archive/**" + +append_unique_line() { + local file="$1" + local line="$2" + + touch "$file" + if ! grep -qxF "$line" "$file"; then + printf "%s\n" "$line" >> "$file" + fi +} + +echo "Initializing agent-ops in: $TARGET_DIR" +echo "Source agent-ops: $SOURCE_DIR" + +# 1. 대상 폴더 생성 +mkdir -p "$TARGET_DIR/agent-ops/rules/project/domain" +mkdir -p "$TARGET_DIR/agent-ops/rules/private" +mkdir -p "$TARGET_DIR/agent-ops/skills/project" + +# 2. 공통 요소 복사 (프로젝트 전용 설정은 제외) +cp "$SOURCE_DIR/.version" "$TARGET_DIR/agent-ops/" +rm -rf "$TARGET_DIR/agent-ops/bin" +cp -r "$SOURCE_DIR/bin" "$TARGET_DIR/agent-ops/" +cp -r "$SOURCE_DIR/rules/common" "$TARGET_DIR/agent-ops/rules/" +cp -r "$SOURCE_DIR/skills/common" "$TARGET_DIR/agent-ops/skills/" + +# 3. 에이전트 진입 파일 생성 (common/rules.md 복사) +COMMON_RULES="$SOURCE_DIR/rules/common/rules.md" +apply_agent_ops_entry_files "$COMMON_RULES" "$TARGET_DIR" + +# 4. AI ignore / permission 설정 +append_unique_line "$TARGET_DIR/.geminiignore" "$ARCHIVE_IGNORE_PATTERN" +append_unique_line "$TARGET_DIR/.aiexclude" "$ARCHIVE_IGNORE_PATTERN" +append_unique_line "$TARGET_DIR/.cursorignore" "$ARCHIVE_IGNORE_PATTERN" +append_unique_line "$TARGET_DIR/.clineignore" "$ARCHIVE_IGNORE_PATTERN" + +mkdir -p "$TARGET_DIR/.claude" +if [ ! -f "$TARGET_DIR/.claude/settings.json" ]; then + cat > "$TARGET_DIR/.claude/settings.json" <<'EOF' +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "deny": [ + "Read(./agent-task/archive/**)" + ] + } +} +EOF +elif ! grep -q "agent-task/archive" "$TARGET_DIR/.claude/settings.json"; then + echo "Note: .claude/settings.json exists; add Read(./agent-task/archive/**) manually." +fi + +if [ ! -f "$TARGET_DIR/opencode.json" ]; then + cat > "$TARGET_DIR/opencode.json" <<'EOF' +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": { + "agent-task/archive/**": "deny" + }, + "glob": { + "agent-task/archive/**": "deny" + } + }, + "watcher": { + "ignore": [ + "agent-task/archive/**" + ] + } +} +EOF +elif ! grep -q "agent-task/archive" "$TARGET_DIR/opencode.json"; then + echo "Note: opencode.json exists; add agent-task/archive/** read/glob deny and watcher ignore manually." +fi + +# 5. .gitignore 설정 +TOUCH_GITIGNORE="$TARGET_DIR/.gitignore" +touch "$TOUCH_GITIGNORE" +if ! grep -q "agent-ops/rules/private/" "$TOUCH_GITIGNORE"; then + echo "" >> "$TOUCH_GITIGNORE" + echo "# Agent-Ops Private Rules" >> "$TOUCH_GITIGNORE" + echo "agent-ops/rules/private/" >> "$TOUCH_GITIGNORE" +fi + +echo "Successfully initialized agent-ops in $TARGET_DIR" +echo "Note: agent-ops/rules/project and agent-ops/skills/project are initialized as empty." diff --git a/agent-ops/bin/sync.sh b/agent-ops/bin/sync.sh new file mode 100755 index 0000000..632853d --- /dev/null +++ b/agent-ops/bin/sync.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── 경로 설정 ──────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGENT_OPS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_ROOT="$(cd "$AGENT_OPS_DIR/.." && pwd)" + +# ── 색상 ───────────────────────────────────────────────────────────────────── +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; RESET='\033[0m' + +# ── 진입 파일 공통 관리 ────────────────────────────────────────────────────── +source "$SCRIPT_DIR/entry-files.sh" + +# ── 대상 경로 해석 (폴더명 / 상대경로 / 절대경로) ──────────────────────────── +resolve_target() { + local input="$1" + if [[ "$input" == /* ]]; then + [[ -d "$input" ]] && echo "$input" || echo "" + return + fi + if [[ "$input" == */* ]]; then + local resolved + resolved="$(cd "$input" 2>/dev/null && pwd)" || { echo ""; return; } + echo "$resolved" + return + fi + local sibling="$(dirname "$PROJECT_ROOT")/$input" + [[ -d "$sibling" ]] && echo "$sibling" || echo "" +} + +# ── 버전 비교: v1 > v2 이면 0 반환 ─────────────────────────────────────────── +version_gt() { + local v1="$1" v2="$2" + [[ "$v1" == "$v2" ]] && return 1 + [[ "$(printf '%s\n%s' "$v1" "$v2" | sort -V | head -1)" == "$v2" ]] +} + +# ── 버전 +1 ────────────────────────────────────────────────────────────────── +bump_version() { + bash "$SCRIPT_DIR/bump-version.sh" "$1" +} + +# ── 폴더 동기화 (rules.md 제외, 삭제된 파일도 반영) ───────────────────────── +sync_folder() { + local src="$1" dst="$2" exclude="${3:-}" + mkdir -p "$dst" + # dst에서 src에 없는 항목 제거 (exclude 파일 보존) + find "$dst" -mindepth 1 -maxdepth 1 | while IFS= read -r item; do + local name + name="$(basename "$item")" + [[ -n "$exclude" && "$name" == "$exclude" ]] && continue + if [[ ! -e "$src/$name" ]]; then + rm -rf "$item" + fi + done + # src에서 dst로 복사 (exclude 파일 제외) + find "$src" -mindepth 1 -maxdepth 1 | while IFS= read -r item; do + local name + name="$(basename "$item")" + [[ -n "$exclude" && "$name" == "$exclude" ]] && continue + rm -rf "$dst/$name" + cp -r "$item" "$dst/" + done +} + +sync_common() { + local src="$1" dst="$2" + sync_folder "$src/rules/common" "$dst/rules/common" "rules.md" + sync_folder "$src/skills/common" "$dst/skills/common" + sync_folder "$src/bin" "$dst/bin" +} + +# ── 사용법 ──────────────────────────────────────────────────────────────────── +usage() { + echo "사용법: $0 [--pull] " + echo "" + echo " (기본) 현재 프로젝트 → target(agentic-framework) 으로 push" + echo " --pull target(agentic-framework) → 현재 프로젝트 로 pull" + echo "" + echo " target: 폴더명 (동일 레벨 탐색) | 상대경로 | 절대경로" +} + +# ───────────────────────────────────────────────────────────────────────────── +PULL_MODE="0" +if [[ "${1:-}" == "--pull" ]]; then + PULL_MODE="1" + shift +fi + +TARGET_INPUT="${1:-}" +if [[ -z "$TARGET_INPUT" ]]; then + usage; exit 1 +fi + +TARGET="$(resolve_target "$TARGET_INPUT")" +if [[ -z "$TARGET" ]]; then + echo -e "${RED}Error: 대상을 찾을 수 없습니다: $TARGET_INPUT${RESET}" + exit 1 +fi + +SRC_AO="$AGENT_OPS_DIR" +DST_AO="$TARGET/agent-ops" +IS_FRAMEWORK="$([[ -f "$PROJECT_ROOT/.agent-ops-source" ]] && echo "1" || echo "0")" + +# ── pull 모드: agentic-framework → 현재 프로젝트 ───────────────────────────── +if [[ "$PULL_MODE" == "1" ]]; then + if [[ "$IS_FRAMEWORK" == "1" ]]; then + echo -e "${RED}Error: agentic-framework에서는 --pull을 사용할 수 없습니다.${RESET}" + exit 1 + fi + if [[ ! -f "$TARGET/.agent-ops-source" ]]; then + echo -e "${RED}Error: target이 agentic-framework가 아닙니다 (.agent-ops-source 없음)${RESET}" + exit 1 + fi + SRC_VER="$(cat "$SRC_AO/.version" 2>/dev/null || echo "0.0.0")" + DST_VER="$(cat "$DST_AO/.version" 2>/dev/null || echo "0.0.0")" + echo "▶ $(basename "$TARGET") → $(basename "$PROJECT_ROOT") (pull)" + echo " 현재 버전: $SRC_VER | framework 버전: $DST_VER" + sync_common "$DST_AO" "$SRC_AO" + cp "$DST_AO/.version" "$SRC_AO/.version" + apply_agent_ops_entry_files "$DST_AO/rules/common/rules.md" "$PROJECT_ROOT" + cd "$PROJECT_ROOT" + git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version \ + "${AGENT_OPS_ENTRY_FILES[@]}" + git commit -m "sync: pull from agentic-framework v$DST_VER" + git push + echo -e "${GREEN}✓ 완료 (pull v$DST_VER)${RESET}" + exit 0 +fi + +echo "▶ $(basename "$PROJECT_ROOT") → $(basename "$TARGET")" + +# ── agentic-framework에서 다른 프로젝트로 ──────────────────────────────────── +if [[ "$IS_FRAMEWORK" == "1" ]]; then + if [[ ! -d "$DST_AO" ]]; then + echo " 최초 진행: agent-ops 전체 복사" + cp -r "$SRC_AO" "$TARGET/" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" + echo -e "${YELLOW} init-agent-ops 스킬로 초기화를 진행하세요.${RESET}" + else + echo " 이후 진행: common/ 동기화" + sync_common "$SRC_AO" "$DST_AO" + cp "$SRC_AO/.version" "$DST_AO/.version" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" + fi + echo -e "${GREEN}✓ 완료 → $(basename "$TARGET")${RESET}" + exit 0 +fi + +# ── 일반 프로젝트에서 agentic-framework로 (push) ───────────────────────────── +if [[ ! -f "$TARGET/.agent-ops-source" ]]; then + echo -e "${RED}Error: target이 agentic-framework가 아닙니다 (.agent-ops-source 없음)${RESET}" + exit 1 +fi + +SRC_VER="$(cat "$SRC_AO/.version" 2>/dev/null || echo "0.0.0")" +DST_VER="$(cat "$DST_AO/.version" 2>/dev/null || echo "0.0.0")" +echo " 현재 버전: $SRC_VER | framework 버전: $DST_VER" + +if version_gt "$DST_VER" "$SRC_VER"; then + echo -e "${RED}⚠ 버전 충돌: framework($DST_VER) > current($SRC_VER)${RESET}" + echo -e "${YELLOW} 먼저 sync-pull로 내려받은 뒤 재시도하세요.${RESET}" + exit 1 +fi + +NEW_VER="$(bump_version "$SRC_VER")" +sync_common "$SRC_AO" "$DST_AO" +echo "$NEW_VER" > "$SRC_AO/.version" +echo "$NEW_VER" > "$DST_AO/.version" + +cd "$TARGET" +git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version +git commit -m "sync: from $(basename "$PROJECT_ROOT") v$NEW_VER" +git push +cd "$PROJECT_ROOT" + +git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version +git commit -m "sync: to agentic-framework v$NEW_VER" +git push + +echo -e "${GREEN}✓ 완료 (v$SRC_VER → v$NEW_VER)${RESET}" diff --git a/agent-ops/rules/common/_templates/domain-rule-template.md b/agent-ops/rules/common/_templates/domain-rule-template.md new file mode 100644 index 0000000..529e10d --- /dev/null +++ b/agent-ops/rules/common/_templates/domain-rule-template.md @@ -0,0 +1,29 @@ +# + +## 목적 / 책임 + +<이 도메인이 담당하는 책임을 1~2문장으로> + +## 포함 경로 + +- `/` — <이 경로가 이 도메인에 속하는 이유> + +## 제외 경로 + +- `/` — <왜 이 도메인이 아닌지> + +## 주요 구성 요소 + +- `` — <역할> + +## 유지할 패턴 + +- <네이밍 규칙 또는 아키텍처 패턴> + +## 다른 도메인과의 경계 + +- **<인접 domain>**: <어디까지가 이 도메인이고 어디서부터 저 도메인인지> + +## 금지 사항 + +- <이 도메인 코드에서 하면 안 되는 것> diff --git a/agent-ops/rules/common/rules.md b/agent-ops/rules/common/rules.md new file mode 100644 index 0000000..ae124ac --- /dev/null +++ b/agent-ops/rules/common/rules.md @@ -0,0 +1,16 @@ +# 공통 규칙 + +- 기존 구조를 우선한다. 새 파일 생성보다 기존 파일 수정을 우선한다. +- 코드 변경 전 관련 domain rule을 먼저 확인한다. +- 요청 범위를 넘는 변경을 하지 않는다. +- 불확실하면 단정하지 말고 후보를 제시한다. +- `agent-task/archive/**`는 사용자가 명시적으로 요청한 경우에만 읽는다. + +`agent-ops/rules/project/rules.md`와 `agent-ops/rules/private/rules.md`는 세션 최초 1회 읽는다. 파일이 없으면 무시한다. + +아래 성격의 요청은 사용자가 명시적으로 요청한 경우에만 `agent-ops/skills/common/router.md`를 작업 최초 1회 읽고 수행한다. 자동으로 수행하지 않는다. +- agent-ops 초기화 +- domain rule 생성 +- skill 생성 +- git commit / push +- agent-ops 업데이트 / 진입 파일 재적용 diff --git a/agent-ops/rules/project/domain/adapters/rules.md b/agent-ops/rules/project/domain/adapters/rules.md new file mode 100644 index 0000000..048694b --- /dev/null +++ b/agent-ops/rules/project/domain/adapters/rules.md @@ -0,0 +1,35 @@ +# adapters + +## 목적 / 책임 + +Plane, Mattermost 등 외부 서비스와의 통합 경계를 담당한다. 현재는 stub 단계다. + +## 포함 경로 + +- `internal/adapters/` — 외부 시스템별 client/stub + +## 제외 경로 + +- `internal/workflow/` — task 업무 흐름 +- `internal/notification/` — 내부 알림 서비스 +- `internal/http/` — 외부 API가 아닌 서버 API + +## 주요 구성 요소 + +- `plane.Client` — Plane issue/comment/status 연동 stub +- `mattermost.Client` — Mattermost message/task notification 연동 stub + +## 유지할 패턴 + +- 외부 서비스별 config와 client를 하위 패키지에 격리한다. +- 실제 API 호출을 추가할 때 context, timeout, 인증 실패, 재시도 정책을 명시적으로 다룬다. + +## 다른 도메인과의 경계 + +- **workflow**: 외부 시스템 세부사항을 workflow 모델에 직접 섞지 않는다. +- **scheduler/notification**: 알림 발생 시점은 scheduler/notification에서 결정하고, 전송 구현은 adapters가 맡는다. + +## 금지 사항 + +- token, URL 같은 민감 설정을 코드에 하드코딩하지 않는다. +- stub에서 실제 API 호출로 전환할 때 조용히 에러를 무시하지 않는다. diff --git a/agent-ops/rules/project/domain/http-api/rules.md b/agent-ops/rules/project/domain/http-api/rules.md new file mode 100644 index 0000000..720f653 --- /dev/null +++ b/agent-ops/rules/project/domain/http-api/rules.md @@ -0,0 +1,38 @@ +# http-api + +## 목적 / 책임 + +HTTP routing, middleware, handler, server entrypoint를 담당한다. + +## 포함 경로 + +- `internal/http/` — router, middleware, handler +- `cmd/server/` — 서버 실행 진입점 + +## 제외 경로 + +- `internal/workflow/` — 업무 유스케이스 +- `internal/storage/` — DB 접근 +- `internal/scheduler/` — job queue 실행 + +## 주요 구성 요소 + +- `NewRouter` — chi router 구성 +- `Handler` — HTTP 요청을 workflow 호출로 변환 +- `loggingMiddleware` — 요청 로그 처리 +- `cmd/server/main.go` — 의존성 조립과 서버 실행 + +## 유지할 패턴 + +- handler는 입력 파싱, 응답 변환, 에러 매핑에 집중한다. +- 업무 규칙은 workflow service에 위임한다. + +## 다른 도메인과의 경계 + +- **workflow**: API endpoint는 workflow service를 통해 유스케이스를 실행한다. +- **persistence/scheduler**: HTTP 계층에서 DB나 River client를 직접 조작하지 않는다. + +## 금지 사항 + +- HTTP handler에 상태 전이 규칙을 중복 구현하지 않는다. +- router에 외부 adapter 호출 로직을 직접 넣지 않는다. diff --git a/agent-ops/rules/project/domain/persistence/rules.md b/agent-ops/rules/project/domain/persistence/rules.md new file mode 100644 index 0000000..3ecc074 --- /dev/null +++ b/agent-ops/rules/project/domain/persistence/rules.md @@ -0,0 +1,41 @@ +# persistence + +## 목적 / 책임 + +PostgreSQL schema, SQL query, sqlc 생성 코드, storage wrapper를 담당한다. + +## 포함 경로 + +- `internal/storage/` — workflow가 사용하는 저장소 wrapper +- `internal/db/` — sqlc 생성 코드와 DB pool +- `migrations/` — goose migration +- `queries/` — sqlc query 정의 +- `sqlc.yaml` — sqlc 설정 + +## 제외 경로 + +- `internal/workflow/` — 업무 상태 전이와 유스케이스 +- `internal/http/` — HTTP handler/router +- `internal/scheduler/` — River job 처리 + +## 주요 구성 요소 + +- `Store` — workflow용 persistence facade +- `db.Queries` — sqlc query 집합 +- `tasks.sql` — task 관련 query 정의 +- `00001_create_tasks.sql` — 초기 task schema + +## 유지할 패턴 + +- schema 변경은 migration, query, generated code 순서로 반영한다. +- 생성 파일은 직접 수정하지 않고 `./bin/sqlc`로 갱신한다. + +## 다른 도메인과의 경계 + +- **workflow**: 저장소 메서드는 workflow 유스케이스가 필요한 단위로 제공한다. +- **scheduler**: worker가 상태를 갱신할 때도 Store 경계를 우선 사용한다. + +## 금지 사항 + +- `internal/db/*` 생성 파일을 직접 편집하지 않는다. +- workflow 상태 전이 규칙을 SQL query에 숨기지 않는다. diff --git a/agent-ops/rules/project/domain/scheduler/rules.md b/agent-ops/rules/project/domain/scheduler/rules.md new file mode 100644 index 0000000..6c9a569 --- /dev/null +++ b/agent-ops/rules/project/domain/scheduler/rules.md @@ -0,0 +1,39 @@ +# scheduler + +## 목적 / 책임 + +River 기반 task enqueue, worker 실행, task 완료/실패 처리, notification 흐름을 담당한다. + +## 포함 경로 + +- `internal/scheduler/` — River client, worker, job args +- `internal/notification/` — task notification 모델과 서비스 + +## 제외 경로 + +- `internal/workflow/` — enqueue 가능 상태 판단 +- `internal/storage/` — DB query wrapper +- `internal/http/` — API 요청 처리 + +## 주요 구성 요소 + +- `Client` — River client wrapper +- `TaskWorker` — task job 실행 worker +- `TaskJobArgs` — River job payload +- `notification.Service` — task 상태 알림 처리 + +## 유지할 패턴 + +- workflow는 `TaskEnqueuer` 인터페이스로 scheduler를 호출한다. +- River migration/start/stop 책임은 scheduler client 안에 둔다. + +## 다른 도메인과의 경계 + +- **workflow**: enqueue 요청과 상태 전이 정책은 workflow가 결정한다. +- **persistence**: task 상태 저장은 Store 경계를 통해 수행한다. +- **adapters**: 실제 외부 알림 연동은 adapters 도메인과 분리한다. + +## 금지 사항 + +- worker에 HTTP handler 로직을 넣지 않는다. +- scheduler에서 SQL query를 직접 호출해 Store 경계를 우회하지 않는다. diff --git a/agent-ops/rules/project/domain/workflow/rules.md b/agent-ops/rules/project/domain/workflow/rules.md new file mode 100644 index 0000000..608d186 --- /dev/null +++ b/agent-ops/rules/project/domain/workflow/rules.md @@ -0,0 +1,38 @@ +# workflow + +## 목적 / 책임 + +Task 생성, 조회, 목록, enqueue 가능 상태 판단 등 핵심 작업 흐름의 유스케이스를 담당한다. + +## 포함 경로 + +- `internal/workflow/` — task 상태 모델과 workflow service + +## 제외 경로 + +- `internal/storage/`, `internal/db/` — 영속성 구현 +- `internal/http/` — HTTP 요청/응답 어댑터 +- `internal/scheduler/` — River job 실행 + +## 주요 구성 요소 + +- `TaskStatus` — task 상태 enum +- `CreateTaskInput` — task 생성 입력 +- `Service` — workflow 유스케이스 구현 +- `TaskEnqueuer` — scheduler와의 경계 인터페이스 + +## 유지할 패턴 + +- workflow는 storage와 scheduler를 인터페이스/서비스 경계로 호출한다. +- 상태 전이 규칙은 `canEnqueue`처럼 workflow 도메인 안에서 명확하게 유지한다. + +## 다른 도메인과의 경계 + +- **persistence**: DB query와 트랜잭션 세부사항은 persistence에 둔다. +- **scheduler**: 실제 River enqueue와 worker 실행은 scheduler에 둔다. +- **http-api**: HTTP status code와 JSON 응답 형식은 http-api에 둔다. + +## 금지 사항 + +- workflow 서비스에 HTTP request/response 타입을 직접 의존시키지 않는다. +- sqlc 생성 타입을 외부 API 계약으로 노출하지 않는다. diff --git a/agent-ops/rules/project/rules.md b/agent-ops/rules/project/rules.md new file mode 100644 index 0000000..6b4db9a --- /dev/null +++ b/agent-ops/rules/project/rules.md @@ -0,0 +1,38 @@ +# nomadcode-core project rules + +## 응답 언어 + +- 기본 응답은 한국어로 작성한다. + +## 프로젝트 개요 + +`nomadcode-core`는 사용자 요청을 task로 저장하고, 비동기 agent 작업 흐름을 관리하기 위한 Go 서버다. HTTP API, PostgreSQL 저장소, sqlc 생성 코드, River job 실행, Plane/Mattermost adapter stub을 포함한다. + +## 기술 스택 + +- Go `1.25.0` +- `chi` HTTP router +- PostgreSQL, `pgx`, `sqlc`, `goose` +- `riverqueue/river` 기반 비동기 job +- Docker Compose 선택 실행 환경 + +## 프로젝트 특화 컨벤션 + +- `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 sqlc 생성 파일이므로 직접 수정하지 않는다. +- DB 스키마 변경은 `migrations/`와 `queries/`를 먼저 수정하고 `./bin/sqlc`로 생성 코드를 갱신한다. +- 실행/검증 명령은 가능한 한 `bin/` 스크립트 또는 Makefile alias를 사용한다. +- 외부 연동은 현재 adapter stub 단계이므로 실제 API 호출 추가 시 설정, 에러 처리, 테스트 경계를 함께 정한다. + +## 도메인 매핑 + +| 경로 패턴 | 도메인 | rules.md | +|----------|--------|----------| +| `internal/workflow/**` | workflow | `agent-ops/rules/project/domain/workflow/rules.md` | +| `internal/storage/**`, `internal/db/**`, `migrations/**`, `queries/**`, `sqlc.yaml` | persistence | `agent-ops/rules/project/domain/persistence/rules.md` | +| `internal/http/**`, `cmd/server/**` | http-api | `agent-ops/rules/project/domain/http-api/rules.md` | +| `internal/scheduler/**`, `internal/notification/**` | scheduler | `agent-ops/rules/project/domain/scheduler/rules.md` | +| `internal/adapters/**` | adapters | `agent-ops/rules/project/domain/adapters/rules.md` | + +## 스킬 라우팅 + +현재 프로젝트 전용 스킬은 없다. 반복 작업이 안정적으로 드러난 뒤 `agent-ops/skills/project/`에 추가한다. diff --git a/agent-ops/skills/common/_templates/skill-template.md b/agent-ops/skills/common/_templates/skill-template.md new file mode 100644 index 0000000..2372acb --- /dev/null +++ b/agent-ops/skills/common/_templates/skill-template.md @@ -0,0 +1,57 @@ +--- +name: +version: 1.0.0 +description: <이 skill이 하는 일을 한 줄로 설명. 트리거 키워드 포함 권장> +depends: [] # 선택 — 의존 skill이 없으면 이 줄 삭제 +--- + +# + +## 목적 + +<이 skill이 해결하는 문제를 1~2문장으로 설명> + +## 언제 호출할지 + +- <이 skill을 호출해야 하는 상황 1> +- <이 skill을 호출해야 하는 상황 2> +- <이 skill을 호출해야 하는 상황 3> + +## 입력 + +- ``: <설명> (필수) +- ``: <설명> (선택) + +## 먼저 확인할 것 + +- [ ] <실행 전 반드시 확인해야 할 조건 1> +- [ ] <실행 전 반드시 확인해야 할 조건 2> + +## 실행 절차 + +1. **<단계명>** + - <세부 행동> + - <세부 행동> + +2. **<단계명>** + - <세부 행동> + +3. **결과 보고** + - <출력할 내용> + +## 실행 결과 검증 + +- [ ] <실행 후 확인해야 할 성공 조건 1> +- [ ] <실행 후 확인해야 할 성공 조건 2> +- 검증 실패 시: <실패 시 취할 행동 — 롤백, 사용자 알림, 재시도 등> + +## 출력 형식 + +``` +<출력 예시> +``` + +## 금지 사항 + +- <절대 하면 안 되는 것> +- <절대 하면 안 되는 것> diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md new file mode 100644 index 0000000..f2429db --- /dev/null +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -0,0 +1,324 @@ +--- +name: code-review +description: Review completed implementation work in the current repository. Reads PLAN-{build_lane}-GNN.md and CODE_REVIEW-{review_lane}-GNN.md from the active task, reviews the actual changed source files, appends a verdict to the review file, then archives both files as .log. On PASS, writes complete.log summarising the full loop history and moves the completed task under agent-task/archive/YYYY/MM/. If Required or Suggested issues are found (FAIL or WARN), writes new routed plan/review files so the loop continues immediately. Nit-only findings may still PASS. +--- + +# Code Review + +## Purpose + +Review the implementation phase of the plan-code-review loop: + +```text +plan skill -> implementation -> code-review skill + ^ | + +----- issues found: new routed plan/review files +``` + +## Workflow Contract + +Active work must live under `agent-task/{task_name}/` using routed filenames. This is the state protocol shared with the plan skill. + +Filename rules: + +- Plan file: `PLAN-{build_lane}-GNN.md` +- Review file: `CODE_REVIEW-{review_lane}-GNN.md` +- `{lane}` is only `local` or `cloud`; never put model names in filenames. +- `GNN` is a two-digit capability grade from `G01` to `G10`; runtime maps lane+grade to current models externally. + +Multi-plan runtime contract: + +- Multi-plan work is represented as multiple task directories. Each directory owns exactly one normal active plan file and one normal active review file. +- Directory names may encode runtime scheduling metadata. Preserve them verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. +- If the user/runtime names a task directory, review that directory even when other active review files exist. + +Review routing rules: + +- `local`: narrow, low-risk, or first-pass review where tests and scope are clear. +- `cloud`: multi-file, API/call-site impact, meaningful test judgment, plan deviation, weak verification, security/auth, storage/migration, concurrency, protocol/schema, cross-domain, or repeated Required issues. +- `cloud-G07` or higher is mandatory for terminal-agent follow-up work: shell/CLI workflow implementation, bin script orchestration, process control, stdout/stderr parsing, exit-status contracts, long-running command diagnosis, or terminal benchmark-style tasks. Merely running deterministic tests such as `go test` does not make a task terminal-agent work. +- `cloud-G07` or higher is mandatory for follow-up plans when a local implementation failed a real bin/smoke/integration command after unit tests passed, when the success condition depends on an interactive TUI/PTY/browser/external CLI or screen repaint/cursor stream, or when verification trust is Fail because recorded stdout/stderr does not match a rerun. + +Directory states: + +| State | Meaning | +|-------|---------| +| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` stub or placeholders | Implementation is pending/incomplete; review should fail completeness if invoked | +| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` | Ready for code-review skill | +| `complete.log` + `*.log` files | Task complete (PASS), before final task-directory archive move | +| `agent-task/archive/YYYY/MM/{task_name}/complete.log` + `*.log` files | Archived completed task (PASS); not active | +| Only `*.log` files (no `complete.log`) | Task terminated mid-loop or abandoned | + +The implementing agent never archives or deletes active files; archiving is this skill's responsibility. + +Finalization invariant: + +- Every review attempt that selects an active review file must end by archiving the active `CODE_REVIEW-*-G??.md` and `PLAN-*-G??.md` files. +- After archiving, exactly one next state is allowed: + - `PASS`: write `complete.log`, move `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/`, then complete the review-only checklist in the final archive path. + - `WARN` or `FAIL`: write the next active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md`; do not write `complete.log`. +- Never stop after appending a verdict. Do not report to the user until archive, required next-state file writes, review-only checklist updates, and PASS task-directory archive moves are complete. +- If the review result feels ambiguous, choose `WARN` or `FAIL` according to the severity rules, archive the current files, and write the next plan/review pair. Ambiguity is not a reason to leave active files unarchived. + +## Step 1 - Find Active Task + +Glob `agent-task/*/CODE_REVIEW-*-G??.md`: + +| Result | Action | +|--------|--------| +| Exactly one | Review that task; exactly one `PLAN-*-G??.md` is expected beside it | +| None | Nothing to review; stop and report | +| Multiple | If the user/runtime named a task directory, review that directory. Otherwise list paths and ask which task to review; do not choose by agent judgment. | + +## Step 2 - Load Context + +Count `agent-task/{task_name}/code_review_*.log`: + +- `0`: first review. Read the active review file, active plan file, every planned source file, related tests, and files importing/imported by changed files up to 2 levels deep. +- `>=1`: follow-up review. Start with `git diff`, `git diff --cached`, and `git log --oneline -5`, then expand to related callers, implementers, tests, and any planned files missing from the diff. + +The diff is the starting point, not the boundary. Follow behavior and API connections far enough to judge correctness. + +## Step 3 - Pre-Review Checklist + +Before writing the verdict: + +- Compare actual source files against every planned checklist item. +- Compare the plan `구현 체크리스트` and review stub `구현 체크리스트`; item text/order must match. +- Confirm the implementation marked the matching checklist items in the active review file, including the final mandatory `CODE_REVIEW-*-G??.md` completion item. +- Treat blank placeholder sections, missing actual implementation notes, missing checklist completion, or missing actual stdout/stderr in the active review file as a completeness or verification-trust failure. +- Grep renamed/removed symbols for stale references. +- Confirm every required test exists, name matches, and assertions are meaningful. +- Cross-check claimed verification output in the active review file against actual code and project commands. +- For follow-up reviews, compare diff against the plan and scan for unplanned changes, debug prints, dead code, TODOs, formatting-only noise, and unrelated edits. + +## Step 4 - Append Verdict + +Append `코드리뷰 결과` to the active `CODE_REVIEW-*-G??.md`. + +Required fields: + +- `종합 판정`: exactly `PASS`, `WARN`, or `FAIL`. +- `차원별 평가`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, plan deviation, verification trust. +- `발견된 문제`: `없음`, or bullets using `Required`, `Suggested`, or `Nit` with `file:line` and a concrete fix. +- `다음 단계`: keep only the matching PASS/WARN/FAIL line. + +Do not check archive/next-state items in `코드리뷰 전용 체크리스트` during Step 4. Complete the applicable dedicated checklist items in the archived `code_review_*.log` during Step 7, after archive, next-state writes, and PASS task-directory moves are done. + +Severity semantics: + +| Verdict | Meaning | Follow-up plan | +|---------|---------|----------------| +| `PASS` | No Required/Suggested issues. Nit-only findings may still PASS. | No | +| `WARN` | One or more Suggested issues, zero Required. | Yes | +| `FAIL` | One or more Required issues. | Yes | + +Issue severity: + +- `Required`: correctness, API contract, missing required test, plan-completeness issue, or incomplete/placeholder `CODE_REVIEW-*-G??.md` content required from the implementing agent. +- `Suggested`: useful improvement that should enter the loop but does not block correctness. +- `Nit`: tiny cleanup; may be recorded without forcing WARN. + +Verdict consistency: + +- `PASS` requires all dimensions to be Pass and no Required/Suggested issues. Nit-only findings may still PASS only when every dimension remains Pass. +- Any Fail dimension or any Required issue forces `FAIL`. +- Any Warn dimension or any Suggested issue forces `WARN`, unless the only findings are explicitly Nit and every dimension remains Pass. + +## Step 5 - Archive Active Files + +Archive is mandatory for `PASS`, `WARN`, and `FAIL`. Archive order is fixed: + +1. Count existing `code_review_*.log` as `N`; rename `CODE_REVIEW-{review_lane}-GNN.md` to `code_review_{review_lane}_GNN_N.log`. +2. Count existing `plan_*.log` as `M`; rename `PLAN-{build_lane}-GNN.md` to `plan_{build_lane}_GNN_M.log`. + +After archiving, neither active `.md` file remains unless Step 6 writes a follow-up. + +## Step 6 - Post-Review Actions + +For `PASS`, write `agent-task/{task_name}/complete.log` before reporting. + +Complete log template: + +- Template path: `agent-ops/skills/common/code-review/templates/complete-log-template.md` +- Copy the template's section order and fill every placeholder from the archived plan/review logs and final verdict. +- Do not leave placeholders in `complete.log`. +- Use `없음` for empty `잔여 Nit` or `후속 작업`. +- A PASS `complete.log` must not contain unresolved Required or Suggested issues. Nit-only leftovers may be recorded under `잔여 Nit`. + +After Step 7, report: + +- Verdict. +- Archive filenames. +- Final task archive path under `agent-task/archive/YYYY/MM/`. + +For `WARN` or `FAIL`, write new routed plan/review files using the plan skill format: + +- New plan number is the count of `plan_*.log` after archive. +- Header tag is `REVIEW_`. +- Choose lane/grade again; preserve the prior route only when it was adequate, otherwise raise `GNN` and/or move `local -> cloud`. +- Before choosing the follow-up route, apply this escalation gate: + - If the archived plan was `local-*` and the verdict is `FAIL` for correctness, completeness, test coverage, or verification trust, move the follow-up build lane to `cloud` unless the issue is trivially deterministic and review-detectable without live environment behavior. + - If the follow-up work is terminal-agent work (shell/CLI workflow implementation, bin script orchestration, process control, stdout/stderr parsing, exit-status contracts, long-running command diagnosis, or terminal benchmark-style tasks), use `cloud-G07` or higher. + - If unit tests passed but a real bin/smoke/integration command failed, use `cloud-G07` or higher. + - If the task depends on interactive TUI/PTY/browser/external CLI behavior, screen repaint/cursor stream parsing, or live command-palette state, use `cloud-G07` or higher. + - If recorded verification output was reconstructed, stale, or mismatched on rerun, use `cloud-G07` or higher and make verification trust recovery a plan item. +- `FAIL`: one plan item per Required issue. +- `WARN`: one grouped plan item for Suggested issues, plus related Nit issues if useful. +- Each plan item needs problem, solution with before/after when non-trivial, checklist, test decision, intermediate verification. +- The follow-up plan and review stub must contain matching `구현 체크리스트` item text/order, including the final mandatory `CODE_REVIEW-*-G??.md` completion item. + +Routed review stub template (fill `{…}` placeholders; everything else is fixed and must not be changed by the implementing agent): + +```markdown + + +# Code Review Reference - {TAG} + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Do not modify or check the `코드리뷰 전용 체크리스트`; it is owned by the review agent only. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date={YYYY-MM-DD} +task={task_name}, plan={N}, tag={TAG} + +## 이 파일을 읽는 리뷰 에이전트에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +Review 완료 후 반드시 아래 순서로 아카이브하세요. + +1. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log` (N = 기존 code_review_*.log 수) +2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) +3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. + +어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. +PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. +WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [{TAG}-1] {item description} | [ ] | +| [{TAG}-2] {item description} | [ ] | + +## 구현 체크리스트 + +{copy the follow-up plan's 구현 체크리스트 items exactly, preserving order and checkbox text} + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] WARN/FAIL이면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 리뷰어를 위한 체크포인트 + +{pre-filled from plan — one bullet per review focus area} + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. + +### {TAG}-1 중간 검증 +``` +$ {verification command from plan} +(output) +``` + +### 최종 검증 +``` +$ {final verification command from plan} +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave the review-agent-only checklist unchanged. +``` + +Sections and their ownership: + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 | +| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` → `[x]` 체크만 구현 에이전트가 수행 | +| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` → `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 | +| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 | +| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 | + +## Step 7 - Complete Review-Only Checklist, Move PASS Task, And Report + +After Step 6: + +- If verdict is `PASS`, determine archive month from the current completion date as `YYYY/MM`, create `agent-task/archive/YYYY/MM/` if missing, then move `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/`. +- Do not overwrite an existing archive directory. If `agent-task/archive/YYYY/MM/{task_name}/` already exists, append the next numeric suffix: `agent-task/archive/YYYY/MM/{task_name}_1/`, `agent-task/archive/YYYY/MM/{task_name}_2/`, and so on. +- For `PASS`, open the moved `agent-task/archive/YYYY/MM/{final_task_dir}/code_review_{review_lane}_GNN_N.log`. +- For `WARN` or `FAIL`, open `agent-task/{task_name}/code_review_{review_lane}_GNN_N.log`. +- Check every applicable item in `코드리뷰 전용 체크리스트`; leave mutually exclusive verdict items unchecked. +- If any applicable item cannot be checked, finish the missing archive, `complete.log`, task-directory move, or follow-up plan/review write first. +- Do not recreate an active review file just to update this checklist; update the archived `code_review_*.log`. +- Only report after the archived review log has the verdict, applicable checked review-only checklist, required next-state files, and for `PASS` the final task archive move. + +Report Required/Suggested counts, archive names, and the final task archive path for `PASS` or new plan path for `WARN`/`FAIL`. + +## Review Dimensions + +| Dimension | Check | +|-----------|-------| +| Correctness | Logic, edge cases, concurrency, errors | +| Completeness | All planned checklist items done, including matching plan/review `구현 체크리스트` completion | +| Test coverage | Required tests present and meaningful | +| API contract | Call sites, compatibility, docs | +| Code quality | No debug prints, dead code, leftover TODOs | +| Plan deviation | Deviations justified, no unrelated risk | +| Verification trust | Reported output matches actual code | + +## Quality Rules + +- Lead with findings; use specific `file:line`. +- Provide a concrete fix for every Required issue. +- Name exact stale symbols or missing tests. +- Do not write vague praise or style opinions without a rule. +- Every dimension gets Pass/Warn/Fail. +- For follow-up plans about verification trust, specify deterministic commands, for example `rg --sort path`, and forbid repo-local tool artifacts. + +## Final Checklist + +- `code_review_{review_lane}_GNN_N.log` exists with verdict appended. +- `plan_{build_lane}_GNN_M.log` exists. +- No active `.md` files remain after PASS. +- PASS: `complete.log` written from `agent-ops/skills/common/code-review/templates/complete-log-template.md`, then task directory moved under `agent-task/archive/YYYY/MM/`. +- WARN/FAIL: new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` created with matching headers and matching `구현 체크리스트`; no `complete.log`. +- The applicable review-agent-only finalization checklist was completed before reporting. diff --git a/agent-ops/skills/common/code-review/agents/openai.yaml b/agent-ops/skills/common/code-review/agents/openai.yaml new file mode 100644 index 0000000..d45d468 --- /dev/null +++ b/agent-ops/skills/common/code-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Code Review" + short_description: "Review task implementation" + default_prompt: "Use $code-review to review the active repository task and archive or continue the loop." diff --git a/agent-ops/skills/common/code-review/templates/complete-log-template.md b/agent-ops/skills/common/code-review/templates/complete-log-template.md new file mode 100644 index 0000000..a4e8fa0 --- /dev/null +++ b/agent-ops/skills/common/code-review/templates/complete-log-template.md @@ -0,0 +1,31 @@ +# Complete - {task_name} + +## 완료 일시 + +{YYYY-MM-DD or ISO-8601} + +## 요약 + +{one-line task summary, loop count, and final verdict} + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_{build_lane}_GNN_N.log` | `code_review_{review_lane}_GNN_N.log` | PASS/WARN/FAIL | {main outcome or follow-up reason} | + +## 구현/정리 내용 + +- {implemented or cleaned-up change} + +## 최종 검증 + +- `{command}` - {PASS/FAIL/BLOCKED}; {actual output summary or saved output path} + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-ops/skills/common/commit-push/SKILL.md b/agent-ops/skills/common/commit-push/SKILL.md new file mode 100644 index 0000000..09e8ace --- /dev/null +++ b/agent-ops/skills/common/commit-push/SKILL.md @@ -0,0 +1,124 @@ +--- +name: commit-push +version: 1.0.0 +description: 변경 사항을 커밋하고 원격에 푸시한다. "커밋해줘", "푸시해줘", "커밋하고 푸시" 요청 시 사용한다. +--- + +# commit-push + +## 목적 + +현재 변경 내용을 분석하여 한국어 커밋 메시지를 작성하고, 커밋 및 푸시를 수행한다. + +## 언제 호출할지 + +- "커밋해줘", "커밋하고 푸시해줘" 요청 시 +- "변경 사항 올려줘", "푸시해줘" 요청 시 +- 코드 작업 완료 후 "반영해줘", "올려줘" 요청 시 + +## 입력 + +- `scope`: 커밋 범위 — all(전체) 또는 특정 파일/경로 (선택, 기본값: all) +- `push`: 푸시 여부 (선택, 기본값: true) + +## 먼저 확인할 것 + +- [ ] 커밋할 변경 사항이 존재하는가 (`git status`로 확인) +- [ ] 현재 브랜치가 올바른 브랜치인가 +- [ ] `.env`, 시크릿 파일 등 민감한 파일이 스테이징에 포함되어 있지 않은가 + +## 실행 절차 + +1. **변경 사항 파악** + - `git status`로 변경/추가/삭제된 파일 목록을 확인한다 + - `git diff`로 구체적인 변경 내용을 확인한다 + - 변경 사항이 없으면 사용자에게 알리고 종료한다 + +2. **민감 파일 검사** + - 스테이징 대상에 아래 패턴이 포함되면 해당 파일을 제외하고 사용자에게 경고한다 + - `.env`, `.env.*` + - `*secret*`, `*credential*`, `*password*` + - `*.pem`, `*.key`, `*.p12` + - `.gitignore`에 이미 등록된 파일은 무시한다 + +3. **커밋 메시지 작성** + - 변경 내용을 분석하여 한국어로 커밋 메시지를 작성한다 + - 아래 메시지 규칙을 따른다 + +4. **사용자 확인** + - 커밋 메시지와 대상 파일 목록을 보여주고 승인을 받는다 + - 사용자가 메시지 수정을 요청하면 반영한다 + +5. **커밋 실행** + - scope에 따라 파일을 스테이징한다 + - all: 변경된 전체 파일을 개별적으로 `git add` + - 특정 경로: 해당 파일만 `git add` + - 승인된 메시지로 `git commit`을 실행한다 + +6. **푸시 실행** + - push가 true인 경우에만 실행한다 + - 현재 브랜치를 원격에 푸시한다 + - 원격 브랜치가 없으면 `-u origin {branch}`로 설정한다 + - 충돌 발생 시 사용자에게 알리고 중단한다 + +7. **결과 보고** + - 커밋 해시, 메시지, 변경 파일 수, 푸시 결과를 출력한다 + +## 커밋 메시지 규칙 + +### 형식 + +``` +<타입>: <변경 요약> + +<본문> (선택) +``` + +### 타입 목록 + +| 타입 | 용도 | +|------|------| +| 기능 | 새로운 기능 추가 | +| 수정 | 버그 수정 | +| 개선 | 기존 기능 개선, 리팩토링 | +| 문서 | 문서 추가/수정 | +| 설정 | 빌드, CI/CD, 설정 파일 변경 | +| 테스트 | 테스트 추가/수정 | +| 정리 | 코드 정리, 불필요 코드 제거 | + +### 작성 원칙 + +- **한국어**로 작성한다 +- 요약은 50자 이내로 간결하게 작성한다 +- 요약은 "~한다" 형태의 서술형으로 끝낸다 (예: "로그인 검증 로직을 추가한다") +- 본문은 **무엇을** 변경했는지가 아니라 **왜** 변경했는지를 쓴다 +- 변경 파일이 3개 이하이면 본문을 생략할 수 있다 +- 여러 성격의 변경이 섞여 있으면 가장 핵심적인 변경을 기준으로 타입을 정한다 + +## 출력 형식 + +``` +## 커밋 완료 + +- 브랜치: {브랜치명} +- 커밋: {해시} — {커밋 메시지 요약} +- 변경 파일: {n}개 + - {파일 경로}: {변경 유형} +- 푸시: {성공/생략/실패} +``` + +## 실행 결과 검증 + +- [ ] `git log -1`로 방금 생성한 커밋이 존재하고, 메시지가 승인된 내용과 일치하는가 +- [ ] 커밋에 포함된 파일 목록이 의도한 scope와 일치하는가 (`git diff-tree --no-commit-id --name-only -r HEAD`) +- [ ] push가 true인 경우, `git status`에서 "Your branch is up to date" 또는 원격과 동기화 상태인가 +- 검증 실패 시: 실패 원인(커밋 누락, 푸시 실패 등)을 사용자에게 알리고 재시도 여부를 확인한다 + +## 금지 사항 + +- `git add -A` 또는 `git add .`을 사용하지 않는다 (민감 파일 혼입 방지) +- `git push --force`를 사용하지 않는다 +- 사용자 확인 없이 커밋하지 않는다 +- 영어로 커밋 메시지를 작성하지 않는다 +- 변경 내용과 무관한 커밋 메시지를 작성하지 않는다 +- `--no-verify` 옵션을 사용하지 않는다 diff --git a/agent-ops/skills/common/create-domain-rule/SKILL.md b/agent-ops/skills/common/create-domain-rule/SKILL.md new file mode 100644 index 0000000..dedb8e2 --- /dev/null +++ b/agent-ops/skills/common/create-domain-rule/SKILL.md @@ -0,0 +1,113 @@ +--- +name: create-domain-rule +version: 1.0.0 +description: 새로운 도메인 rules.md 파일을 생성하기 위한 범용 스킬 +--- + +# Create Domain Rule + +## 목적 + +`agent-ops/rules/project/domain//rules.md` 파일을 올바른 형식으로 생성한다. +실제 프로젝트 폴더 구조를 분석하여 경로와 구성 요소를 자동으로 채운다. +생성 후 `rules/project/rules.md`의 도메인 룰 로딩 안내와 도메인 매핑 테이블을 갱신한다. + +## 언제 호출할지 + +- 새로운 도메인 영역의 코드를 처음 변경하기 전에 +- 기존 domain rule이 없는 경로에 대해 규칙이 필요할 때 +- 사용자가 특정 도메인의 rule 파일을 만들어 달라고 요청할 때 +- agent-ops 초기 scaffold 이후 도메인을 추가 확장할 때 + +## 입력 + +- `domain-name`: 생성할 도메인 이름, kebab-case (필수) +- `domain-type`: `core` / `supporting` / `generic` 중 하나 (필수) +- `path-hints`: 이 도메인에 해당하는 경로 힌트 목록 (선택, 없으면 자동 탐색) + +## 먼저 확인할 것 + +- [ ] `agent-ops/rules/project/domain//rules.md` 가 이미 존재하는지 확인 +- [ ] `agent-ops/rules/common/_templates/domain-rule-template.md` 를 읽어 최신 템플릿 형식 파악 +- [ ] `agent-ops/rules/project/domain/` 하위 기존 domain rule 목록을 확인하여 중복·유사 도메인 여부 판단 + +## 실행 절차 + +1. **중복 확인** + - `agent-ops/rules/project/domain//` 폴더 존재 여부 확인 + - 책임이 겹치는 기존 domain rule 이 있으면 사용자에게 알리고 중단한다 + +2. **경로 탐색** + - `path-hints` 가 제공된 경우: 해당 경로를 기준으로 폴더 구조 확인 + - `path-hints` 가 없는 경우: `domain-name` 과 연관된 폴더명·파일명으로 프로젝트를 탐색 + - 실제로 존재하는 경로만 포함 경로에 기재한다 + - 존재하지 않는 경로는 기재하지 않는다 + +3. **도메인 분석** + - 탐색된 경로의 주요 파일·모듈을 읽어 아래를 도출한다 + - 도메인의 목적 / 책임 한 줄 요약 + - 포함 경로 목록 + - 제외 경로 (인접 도메인과 겹칠 수 있는 경로) + - 주요 구성 요소 (파일, 모듈, 클래스, 함수 등) + - 유지할 패턴 (네이밍 규칙, 아키텍처 패턴 등) + - 다른 도메인과의 경계 + - 금지 사항 + +4. **도메인 rules.md 생성** + - 경로: `agent-ops/rules/project/domain//rules.md` + - `domain-rule-template.md` 형식을 따른다 + - 실제 코드에서 확인된 내용만 기재한다 + - 불확실한 항목은 `` 주석으로 남긴다 + +5. **rules/project/rules.md 업데이트** + - `agent-ops/rules/project/rules.md` 를 읽는다 + - `## 도메인 룰 로딩` 섹션이 없으면 `## 도메인 매핑` 바로 위에 아래 형식으로 추가한다 + ``` + ## 도메인 룰 로딩 + + - 아래 도메인 매핑에 해당하는 작업에서 해당 domain 최초 진입 시 domain rule을 1회 읽는다. + - 이미 읽은 domain rule은 같은 세션에서 반복해서 읽지 않는다. + ``` + - 도메인 매핑 테이블에 아래 형식으로 추가한다 + ``` + | `` | | `agent-ops/rules/project/domain//rules.md` | + ``` + - 경로 패턴은 실제 포함 경로를 기준으로 작성한다 + - 기존 로딩 섹션과 테이블 항목을 삭제하거나 재정렬하지 않는다 + +6. **결과 보고** + - 생성한 파일 경로 + - rules/project/rules.md 에 추가한 항목 + - 불확실하여 TODO로 남긴 항목 (해당 시) + +## 출력 형식 + +``` +## 생성 완료 + +- Domain Rule 경로: agent-ops/rules/project/domain//rules.md +- 도메인 유형: +- 도메인 룰 로딩 섹션: <추가함 | 이미 존재함> +- rules/project/rules.md 추가 경로 패턴: + +## TODO 항목 (확인 필요) +- <불확실하여 직접 확인이 필요한 항목> (해당 시) +``` + +## 실행 결과 검증 + +- [ ] `agent-ops/rules/project/domain//rules.md` 파일이 생성되었는가 +- [ ] 생성된 파일이 `domain-rule-template.md`의 모든 섹션(목적, 포함 경로, 제외 경로, 주요 구성 요소, 유지할 패턴, 경계, 금지 사항)을 포함하는가 +- [ ] `rules/project/rules.md`에 도메인 룰 로딩 섹션이 존재하는가 +- [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 해당 도메인 항목이 추가되었는가 +- [ ] 포함 경로에 기재된 경로가 실제로 프로젝트에 존재하는가 +- 검증 실패 시: 누락된 섹션 또는 잘못된 경로를 사용자에게 알리고 해당 부분만 보완한다 + +## 금지 사항 + +- 이미 존재하는 rules.md 를 덮어쓰지 않는다 +- 실제 존재하지 않는 경로를 포함 경로에 넣지 않는다 +- 추측으로 패턴이나 금지 사항을 채우지 않는다 — 확인된 내용만 기재한다 +- rules/project/rules.md 의 기존 로딩 섹션과 테이블 항목을 삭제하거나 재정렬하지 않는다 +- 하나의 domain rule 에 여러 독립 도메인의 책임을 묶지 않는다 +- 코드 변경을 수행하지 않는다 — 이 skill 은 rule 파일 생성만 담당한다 diff --git a/agent-ops/skills/common/create-skill/SKILL.md b/agent-ops/skills/common/create-skill/SKILL.md new file mode 100644 index 0000000..07bb36c --- /dev/null +++ b/agent-ops/skills/common/create-skill/SKILL.md @@ -0,0 +1,95 @@ +--- +name: create-skill +version: 1.0.0 +description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬 +--- + +# Create Skill + +## 목적 + +`agent-ops/skills/` 하위에 올바른 형식의 SKILL.md 파일을 생성한다. +기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다. +생성 후 라우팅 항목을 추가한다. + +### 생성 위치 결정 +- `.agent-ops-source` 파일이 **있으면** (공통 관리 레포): `agent-ops/skills/common//SKILL.md` +- `.agent-ops-source` 파일이 **없으면** (타겟 프로젝트): `agent-ops/skills/project//SKILL.md` + +## 언제 호출할지 + +- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때 +- 기존 skill이 없는 작업 유형을 처음 수행하기 전에 +- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때 + +## 입력 + +- `skill-name`: 생성할 skill 이름, kebab-case (필수) +- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수) +- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택) + +## 먼저 확인할 것 + +- [ ] `agent-ops/skills/common/` 및 `agent-ops/skills/project/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인 +- [ ] `agent-ops/rules/common/rules.md` 및 `agent-ops/rules/project/rules.md` 에 이미 유사한 라우팅 항목이 있는지 확인 +- [ ] `agent-ops/skills/common/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악 + +## 실행 절차 + +1. **중복 확인** + - `agent-ops/skills/common//` 및 `agent-ops/skills/project//` 폴더 존재 여부 확인 + - 기능이 겹치는 기존 skill이 있으면 사용자에게 알리고 중단한다 + +2. **목적 분석** + - `purpose` 와 `trigger-cases` 를 바탕으로 아래 항목을 도출한다 + - 언제 호출할지 (2~4개) + - 필요한 입력 파라미터 + - 사전 확인 항목 + - 실행 절차 (3~7단계) + - 출력 형식 + - 금지 사항 + +3. **SKILL.md 생성** + - 경로: 생성 위치 결정 규칙에 따라 `common/` 또는 `project/` 하위에 생성 + - `skill-template.md` 형식을 따른다 + - 프로젝트 특화 내용보다 범용 절차를 우선한다 + - 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다 + +4. **라우팅 업데이트** + - `.agent-ops-source` 마커가 **있으면** (공통 관리 레포): `agent-ops/rules/common/rules.md`에 라우팅 항목 추가 + - `.agent-ops-source` 마커가 **없으면** (타겟 프로젝트): `agent-ops/rules/project/rules.md`의 프로젝트 스킬 라우터 섹션에 라우팅 항목 추가 + - 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다 + - 기존 라우팅 구조를 깨지 않는다 + +5. **결과 보고** + - 생성한 파일 경로 + - 라우팅 항목을 추가한 파일과 내용 + - 이 skill이 다루지 않는 범위(필요 시) + +## 출력 형식 + +``` +## 생성 완료 + +- SKILL 경로: agent-ops/skills/{common|project}//SKILL.md +- 라우팅 추가: <대상 파일> → <라우팅 축> → + +## 주의사항 (해당 시) +- <이 skill이 다루지 않는 범위 또는 주의할 점> +``` + +## 실행 결과 검증 + +- [ ] `agent-ops/skills/{common|project}//SKILL.md` 파일이 생성되었는가 +- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가 +- [ ] frontmatter에 name, version, description이 올바르게 기재되었는가 +- [ ] 라우팅 대상 파일에 해당 스킬의 라우팅 항목이 추가되었는가 +- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다 + +## 금지 사항 + +- 이미 존재하는 skill 을 덮어쓰지 않는다 +- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다 +- skill 생성과 무관한 코드 파일을 수정하지 않는다 +- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다 +- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다 diff --git a/agent-ops/skills/common/init-agent-ops/SKILL.md b/agent-ops/skills/common/init-agent-ops/SKILL.md new file mode 100644 index 0000000..af2b836 --- /dev/null +++ b/agent-ops/skills/common/init-agent-ops/SKILL.md @@ -0,0 +1,243 @@ +--- +name: init-agent-ops +version: 1.1.1 +description: 프로젝트 상태를 판별하고 agent-ops 기본 스캐폴드를 생성하기 위한 초기 규칙 +--- + +# init-agent-ops + +## 목적 + +프로젝트에 Agent-Ops 구조가 없거나 불완전할 때, +현재 프로젝트 상태를 분석하여 다음을 세팅한다. + +1. 에이전트 진입 파일 +2. AI ignore / permission 기본 설정 +3. agent-ops 기본 폴더 구조 +4. rules/project/rules.md (프로젝트 특화 규칙, 분석 후 생성) +5. 초기 domain rule 초안 +6. 초기 skill (필요 시) + +"현재 프로젝트에 맞는 최소 스캐폴드" 생성을 우선한다. + +## 언제 호출할지 + +- 프로젝트에 agent-ops 구조가 없을 때 +- agent-ops 구조가 불완전하여 재설정이 필요할 때 +- 사용자가 "agent-ops 초기화해줘", "에이전트 설정해줘" 요청 시 + +## 입력 + +- `project-type`: 신규 / 운영중 (선택, 미지정 시 자동 판별) + +## 먼저 확인할 것 + +- [ ] 프로젝트 루트에 기존 agent-ops 관련 파일(`CLAUDE.md`, `agent-ops/` 등)이 있는지 확인 +- [ ] `.agent-ops-source` 파일이 있는지 확인 (공통 관리 레포 여부) +- [ ] 기존 진입 파일(`CLAUDE.md`, `GEMINI.md` 등)이 있는지 확인 +- [ ] 기존 AI ignore / permission 파일(`.geminiignore`, `.aiexclude`, `.claude/settings.json`, `opencode.json` 등)이 있는지 확인 + +## 핵심 원칙 + +- 기존 구조를 우선한다. +- 새 파일 생성은 꼭 필요한 최소 범위로 제한한다. +- 도메인은 발명하지 말고 현재 구조에서 발견한다. +- 처음에는 핵심 도메인만 생성한다. +- 반복되는 작업만 초기 skill로 만든다. +- 불확실한 내용은 후보로 제시한다. + +## 상태 판별 + +### 신규 프로젝트 +- 코드/폴더 구조가 단순하다 +- 도메인 경계가 아직 약하다 +- Agent-Ops 관련 파일이 없다 + +### 운영중 프로젝트 +- 모듈/폴더/패키지 경계가 보인다 +- 반복 작업이 드러난다 +- 핵심 책임 경계가 식별된다 + +## 생성 대상 + +| 파일 | 방법 | +|------|------| +| `GEMINI.md`, `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, `.clinerules` 등 진입 파일 | `rules/common/rules.md`를 `agent-ops/bin/entry-files.sh`의 파일 목록으로 프로젝트 루트에 복사 | +| `agent-ops/.version` | 공통 관리 레포의 VERSION 파일을 그대로 복사 (프레임워크 버전 추적용) | +| `agent-ops/bin/` | 공통 스크립트 전체 복사 (진입 파일 목록의 단일 기준인 `entry-files.sh` 포함) | +| `agent-ops/rules/common/` | 공통 폴더 전체 복사 (수정 금지) | +| `agent-ops/skills/common/` | 공통 폴더 전체 복사 (수정 금지) | +| `agent-ops/rules/project/rules.md` | 프로젝트 분석 후 생성 | +| `agent-ops/rules/project/domain//rules.md` | 도메인 분석 후 생성 | +| `agent-ops/rules/private/` | 폴더만 생성 (내용은 개인이 작성) | +| `.gitignore`에 `agent-ops/rules/private/` 추가 | git 추적 제외 | +| `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore` | `agent-task/archive/**` 한 줄 추가 | +| `.claude/settings.json`, `opencode.json` | 파일이 없으면 `agent-task/archive/**` 읽기/검색 제외 설정 생성, 있으면 덮어쓰지 않고 수동 병합 안내 | + +에이전트별 파일명: + +실제 생성/동기화 대상 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. + +| 에이전트 | 파일명 | +|---------|--------| +| Gemini | `GEMINI.md` | +| Claude | `CLAUDE.md` | +| Kilo Code / OpenCode | `AGENTS.md` | +| Cursor | `.cursorrules` | +| Cline | `.clinerules` | + +## 에이전트 진입 파일 원칙 + +진입 파일은 `rules/common/rules.md` 내용 그대로를 에이전트별 파일명으로 복사한다. +별도 내용을 추가하거나 수정하지 않는다. + +## Rule 구조 원칙 + +### rules/common/rules.md +- 공통 관리 레포에서 제공. 프로젝트에서 직접 수정하지 않는다. +- 실제 사용은 진입점 파일들에서 사용된다. + +포함 항목: +- 기본 원칙 +- 공통 스킬 라우팅 (router.md 참조) +- project/rules.md 로드 지시 + +### rules/project/rules.md +init-agent-ops가 프로젝트를 분석하여 생성한다. + +포함 항목: +- 응답 언어 +- 프로젝트 개요 / 주요 구조 +- 기술 스택 +- 프로젝트 특화 컨벤션 +- 도메인 매핑 테이블 (경로 패턴 → domain rules.md) +- 프로젝트 스킬 라우팅 (해당 시) + +common/rules.md와 내용이 중복되지 않도록 한다. + +#### 도메인 매핑 테이블 형식 + +```markdown +## 도메인 매핑 + +| 경로 패턴 | 도메인 | rules.md | +|----------|--------|----------| +| `src/order/**` | order | `agent-ops/rules/project/domain/order/rules.md` | +| `src/payment/**` | payment | `agent-ops/rules/project/domain/payment/rules.md` | +| `src/common/**` | common | `agent-ops/rules/project/domain/common/rules.md` | +``` + +#### 프로젝트 스킬 라우팅 형식 + +```markdown +## 스킬 라우팅 + +| 요청 키워드 | SKILL.md | +|------------|----------| +| 테스트 실행해줘 | `agent-ops/skills/project/run-test/SKILL.md` | +``` + +### domain rule +각 도메인 rules.md에는 아래만 둔다. + +- 목적 / 책임 +- 포함 경로 +- 제외 경로 +- 주요 구성 요소 +- 유지할 패턴 +- 다른 도메인과의 경계 +- 금지 사항 + +## DDD 기준 + +- **Core Domain**: 핵심 가치와 주요 유즈케이스 +- **Supporting Domain**: 핵심 도메인을 지원 +- **Generic / Common**: 여러 도메인이 공통으로 사용 + +도메인은 실제 폴더, 모듈, 패키지, 책임 경계와 연결되어야 한다. + +## 실행 (Execution) + +지정한 대상 디렉토리에 agent-ops 스캐폴드를 생성한다. + +```bash +./agent-ops/bin/init-agent-ops.sh +``` + +## 실행 절차 + +1. **상태 판별** + - 프로젝트 구조, 모듈 경계, agent-ops 파일 유무를 분석하여 신규/운영중을 판별한다 + +2. **에이전트 진입 파일 생성** + - `rules/common/rules.md`를 `agent-ops/bin/entry-files.sh`의 파일 목록으로 프로젝트 루트에 복사한다 + +3. **AI ignore / permission 기본 설정** + - `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 `agent-task/archive/**`를 추가한다 + - `.claude/settings.json`, `opencode.json`이 없으면 archive 읽기/검색 제외 설정을 생성한다 + - `agent-task/archive/**` 제외는 `.gitignore`에 추가하지 않는다 + +4. **agent-ops 폴더 구조 복사** + - 공통 관리 레포의 `agent-ops/` 공통 폴더(bin, rules/common, skills/common)를 복사한다 + - `agent-ops/.version` 파일을 복사한다 + +5. **rules/project/rules.md 생성** + - 프로젝트를 분석하여 응답 언어, 프로젝트 개요, 기술 스택 등을 채운다 + - common/rules.md와 내용이 중복되지 않도록 한다 + +6. **도메인 분석 및 domain rule 생성** + - 신규 프로젝트: 핵심 domain placeholder 2~4개만 제안한다. domain rules를 과도하게 채우지 않는다 + - 운영중 프로젝트: Core/Supporting/Generic 도메인을 식별하고 실제 경로를 반영한 초안을 생성한다 + +7. **초기 skill 제안** + - 신규 프로젝트: 최소 2개만 제안한다 + - 운영중 프로젝트: 반복 작업 기반으로 필요한 skill을 제안한다 + +8. **결과 보고** + - 상태 판별 결과, 생성된 파일 목록, 도메인 제안, skill 제안, 주의사항을 출력한다 + +## 출력 형식 + +### 상태 판별 +- Agent-Ops 상태: 없음 / 부분 적용 / 운영중 +- 프로젝트 상태: 신규 / 운영중 +- 근거: 짧게 요약 + +### 스캐폴드 계획 +- 생성할 파일 +- 바로 채울 파일 +- placeholder로 둘 파일 + +### 도메인 제안 +- Core Domain +- Supporting Domain +- Generic / Common + +### 초기 skill 제안 +- skill 이름 +- 필요한 이유 + +### 주의사항 +- 지금 만들지 말아야 할 것 +- 아직 확정하면 안 되는 것 + +## 실행 결과 검증 + +- [ ] `agent-ops/bin/entry-files.sh`의 모든 진입 파일이 프로젝트 루트에 존재하고, 내용이 초기화에 사용한 `rules/common/rules.md`와 일치하는가 +- [ ] `agent-ops/rules/project/rules.md`가 생성되었고, 필수 항목(응답 언어, 프로젝트 개요, 기술 스택)이 포함되어 있는가 +- [ ] 생성된 domain rules.md가 `domain-rule-template.md` 형식을 따르는가 +- [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 생성된 도메인이 모두 등록되어 있는가 +- [ ] `.gitignore`에 `agent-ops/rules/private/` 항목이 추가되어 있는가 +- [ ] `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 `agent-task/archive/**`가 포함되어 있는가 +- [ ] `.claude/settings.json`, `opencode.json`에 `agent-task/archive/**` 제외 설정이 있거나, 기존 파일 수동 병합 안내를 출력했는가 +- [ ] `.gitignore`에 `agent-task/archive/**`를 추가하지 않았는가 +- 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다 + +## 금지 사항 + +- 진입 파일에 rules/common/rules.md 외 내용을 추가하지 않는다. +- `agent-task/archive/**` 제외를 `.gitignore`에 추가하지 않는다. +- 실제 구조보다 앞선 추상 구조를 강요하지 않는다. +- 처음부터 많은 domain / skill을 만들지 않는다. +- rules/common/rules.md를 프로젝트에서 직접 수정하지 않는다. +- rules/project/rules.md에 rules/common/rules.md와 중복되는 내용을 넣지 않는다. diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md new file mode 100644 index 0000000..897de37 --- /dev/null +++ b/agent-ops/skills/common/plan/SKILL.md @@ -0,0 +1,317 @@ +--- +name: plan +description: Analyze the current repository and write a detailed PLAN-{build_lane}-GNN.md for implementation work. Also writes the CODE_REVIEW-{review_lane}-GNN.md stub that the implementing agent will fill in after coding. Use for any feature, refactor, bug fix, or follow-up fix that should enter the plan-code-review loop. A separate implementing agent, or the same agent in an implementation pass, reads the plan file and does the coding. The code-review skill archives both files after review and moves PASS tasks under agent-task/archive/YYYY/MM/. +--- + +# Plan + +## Purpose + +Create the planning artifacts for the implementation loop: + +```text +plan skill -> PLAN-{build_lane}-GNN.md + CODE_REVIEW-{review_lane}-GNN.md stub +implementation -> code changes + filled CODE_REVIEW-{review_lane}-GNN.md +code-review skill -> verdict + archive, complete.log, and PASS task-directory archive move or new follow-up plan/review files +``` + +## Workflow Contract + +This skill intentionally uses routed active files under `agent-task/{task_name}/` as the state protocol. Do not change this filename contract unless the paired code-review skill is updated together. + +Filename rules: + +- Plan file: `PLAN-{build_lane}-GNN.md` +- Review stub: `CODE_REVIEW-{review_lane}-GNN.md` +- `{lane}` is only `local` or `cloud`; never put model names in filenames. +- `GNN` is a two-digit capability grade from `G01` to `G10`; the runtime maps lane+grade to current models externally. + +Task directory naming rules: + +- A single-plan task uses `agent-task/{task_name}/` with a short snake_case task name. +- If one plan is not enough, split the work into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. +- Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across multiple folders, not multiple plan files inside one folder. +- Multi-plan task directory names must start with an execution-order index: `01_{task_name}`, `02_{task_name}`, `03_{task_name}`, and so on. +- If a later task cannot start until a specific current task is complete, mark that dependency immediately after the current task's index with `+`, not `_`: `01+{dependent_task_name}` means this task depends on completion of the `01_...` task. +- When multiple dependent tasks share the same predecessor, order them after the dependency marker: `01+01_{task_name}`, `01+02_{task_name}`. +- Directory names are runtime scheduling metadata. Preserve them verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. +- Every split plan must include `의존 관계 및 구현 순서` and list predecessor task directories that must reach `complete.log` before implementation begins. + +Routing rules: + +- Build defaults to `local` when the plan is explicit, tests are runnable, and failure is review-detectable. +- Use `cloud` for weak tests, broad API/call-site impact, ambiguity, storage/concurrency/protocol/auth risk, or prior local failure. +- Use `cloud-G07` or higher for terminal-agent work: shell/CLI workflow implementation, bin script orchestration, process control, stdout/stderr parsing, exit-status contracts, long-running command diagnosis, or terminal benchmark-style tasks. Merely running deterministic tests such as `go test` does not make a task terminal-agent work. +- Use `cloud-G07` or higher when acceptance depends on a real interactive external tool, TUI, PTY, browser, screen repaint/cursor stream, or bin-level smoke output. This is mandatory when unit tests pass but the real smoke/integration command fails. +- Use `cloud-G07` or higher when a prior review found verification trust failure, reconstructed command output, or claimed stdout/stderr that does not match a rerun. +- Keep high-risk design judgment on `cloud` with a higher grade; the runtime may map `cloud-GNN` to frontier-class models. +- Review may be `local` for narrow/low-risk checks and `cloud` for multi-file/API/test-meaning reviews, security/auth, storage/migration, concurrency, protocol/schema, cross-domain, or repeated Required issues. +- Raise `GNN` with scope, ambiguity, missing tests, irreversible behavior, and blast radius; keep grade model-independent. + +Directory states: + +| State | Meaning | +|-------|---------| +| `PLAN-*-G??.md` only | Invalid; plan skill always writes both active files | +| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` stub | Implementation is pending/in progress | +| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` | Ready for code-review skill | +| `complete.log` + `*.log` files | Task complete (PASS), before final task-directory archive move | +| `agent-task/archive/YYYY/MM/{task_name}/complete.log` + `*.log` files | Archived completed task (PASS); not active | +| Only `*.log` files (no `complete.log`) | Task terminated mid-loop or abandoned | + +## Step 1 - Determine Task + +If the user names the task explicitly, use that task name. + +Otherwise, glob `agent-task/*/PLAN-*-G??.md`: + +| Result | Action | +|--------|--------| +| Exactly one | Continue that task | +| None | Create a new task only for a feature/refactor/fix/follow-up that belongs in this workflow | +| Multiple | If the user/runtime named a task directory, use that directory. Otherwise list paths and ask which task to use; do not choose by agent judgment. | + +The routed plan file is the loop entry point. A missing active plan normally means only that no plan has been started for a new task; do not create task files for casual analysis, status, or review requests unless the user explicitly asks for a plan. + +Use short snake_case task names, e.g. `api_refactor`. + +When the work must be split into multiple plans, choose directory names using the task directory naming rules above. Do not put multiple active plan files in one task directory. + +## Step 2 - Analyze Before Writing + +Complete all items below before creating any files. Work through them in order; do not proceed to the next step until every checkbox is done. + +- [ ] **Read all source files in full** — read every source file the change will touch, whole file. No partial reads. +- [ ] **Read all test files in full** — read every test file that exercises the changed behavior. +- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it. +- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain. +- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest. +- [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports. +- [ ] **Verify verification commands** — confirm that the final verification commands actually run in this repository layout. +- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or `-count=1` is required. + +## Step 3 - Determine GXX Grade + +GXX is an output of analysis, not an input. Determine lane and grade only after Step 2 is fully complete. + +- [ ] **Assess change scope** — count affected files, interface impact, and call-site count. +- [ ] **Check risk factors** — mark any that apply: concurrency, storage/migration, protocol/schema, auth, irreversible behavior. +- [ ] **Evaluate test confidence** — judge whether existing tests sufficiently verify the changed behavior. +- [ ] **Decide lane** — use `local` if the plan is explicit and tests are sufficient; use `cloud` if any of the following apply: + - Tests are weak or do not cover the changed behavior + - Broad API/call-site impact + - Concurrency, storage, protocol, or auth risk + - Prior local build failure on this task + - Terminal-agent work: shell/CLI workflow implementation, bin script orchestration, process control, stdout/stderr parsing, exit-status contracts, long-running command diagnosis, or terminal benchmark-style tasks + - Real bin/smoke/integration verification failed after unit tests passed + - Interactive TUI/PTY/browser/external CLI automation or screen-rendered output is part of the success condition + - A prior review marked verification trust Fail/Warn because recorded command output did not match a rerun +- [ ] **Decide GNN** — assign a higher grade as scope, ambiguity, irreversibility, and blast radius increase. Grade is complexity-based, not model-name-based. + +## Step 4 - Archive Existing Active Files + +Before writing new active files for the chosen task: + +- Count existing `agent-task/{task_name}/plan_*.log`; call it `N`. If `PLAN-*-G??.md` exists, rename `PLAN-{build_lane}-GNN.md` to `plan_{build_lane}_GNN_N.log`. +- Count existing `agent-task/{task_name}/code_review_*.log`; call it `M`. If `CODE_REVIEW-*-G??.md` exists, rename `CODE_REVIEW-{review_lane}-GNN.md` to `code_review_{review_lane}_GNN_M.log`. + +The new plan number is the count of `plan_*.log` after archiving. + +## Step 5 - Write Plan File + +Header line must be exactly: + +```markdown + +``` + +Required sections: + +- Title. +- `이 파일을 읽는 구현 에이전트에게`: open with a bold warning that filling in implementation-owned sections of `CODE_REVIEW-*-G??.md` is a mandatory final step — the task is NOT complete until every implementation-owned section of that file is filled. Tell the implementer to work from the implementation checklist, complete every checklist item in both the plan and review stub, run intermediate/final verification, and fill implementation-owned `CODE_REVIEW-*-G??.md` sections with actual implementation notes and command output. Explicitly state that the implementing agent must NOT execute the archiving instructions in the review file's `이 파일을 읽는 리뷰 에이전트에게` section and must NOT modify or check the `코드리뷰 전용 체크리스트` — those instructions/checklists are for the code-review skill only. +- `배경`: 2-4 sentences explaining why the work is needed. +- `분석 결과`: record the findings from Step 2 and Step 3. This section is the written output of the analysis — not a summary, but the actual findings that justify the plan's scope and decisions. Must include all of the following subsections: + - `읽은 파일`: list every source and test file read during analysis, with path. + - `테스트 커버리지 공백`: list each behavior change and whether existing tests cover it; explicitly note gaps. + - `심볼 참조`: list renamed/removed symbols and every call site found, or state "none" if no symbols were changed. + - `범위 결정 근거`: state which files or areas were explicitly excluded from this change and why. This is the boundary justification — the implementing agent must not silently expand scope beyond what is recorded here. + - `빌드 등급`: state the decided lane and GNN grade with a one-line rationale. +- `구현 체크리스트`: a top-level checklist the implementing agent must follow while coding. Include one item per plan item, one item for all intermediate/final verification, and make the final item exactly: `- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.` Copy this checklist into the review stub's `구현 체크리스트` section with the same item text and order. +- One item per change: `### [TAG-1] Title`, `TAG-2`, etc. +- `수정 파일 요약`: table mapping files to item ids. +- `최종 검증`: runnable commands and expected outcome. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. The final line of this section must read exactly — **"모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다."** + +Each plan item must include: + +- `문제`: concrete problem with file:line references. +- `해결 방법`: exact approach and before/after code block for non-trivial changes. +- `수정 파일 및 체크리스트`: exhaustive file-level checklist. +- `테스트 작성`: explicit write/skip decision. If writing tests, include path, test name, assertion goal, and fixtures. If skipping, justify. +- `중간 검증`: runnable commands and expected result. + +Include `의존 관계 및 구현 순서` only when order matters. + +For split multi-plan work, `의존 관계 및 구현 순서` is mandatory in every generated plan. If a plan has a `NN+...` directory name, state which `NN_...` predecessor must produce `complete.log` before implementation starts. + +Quality rules: + +- Exact line numbers in every Before snippet. +- Use the language's required override annotation/keyword wherever an abstract method is implemented. +- Include full import statements for new packages. +- List all call sites for renamed/removed symbols. +- Never write "add tests as needed"; decide up front. +- Do not cite files you did not read. +- Be concise. Write the minimum words needed to convey the decision or fact. No preamble, no restatement of context already in the plan, no closing summaries. + +Test policy: + +| Change | Test requirement | +|--------|------------------| +| Bug fix | Regression test required | +| New public API | Normal + boundary tests required | +| API rename | Existing test call-site updates usually enough | +| Internal refactor | Existing tests may be enough | +| Concurrency logic | Race/ordering test recommended | + +Verification fidelity rules: + +- Plan verification commands are a contract. The implementing agent must run them exactly as written. +- If a command must be changed, the implementing agent must record the replacement command and reason in `계획 대비 변경 사항`, then paste the replacement command's actual stdout/stderr. +- Before claiming a tool is unavailable, run and record `command -v ` or the project-equivalent check. +- Do not download, generate, or leave verification tools inside the repository. Temporary tools belong outside the repo, such as under `/tmp`, and must not become task artifacts. +- For search commands whose output order may vary, specify deterministic options in the plan, for example `rg --sort path`. +- `검증 결과` must contain actual stdout/stderr, not summarized or reconstructed output. If output is too long, record the saved output file path and the exact command used to create it. +- If the plan's pass condition says all leftovers must be intentional exceptions, any `변경 필요` item forces FAIL until resolved or explicitly reclassified with evidence. +- Decide in the plan whether Go test cache output is acceptable. If fresh execution matters, use `go test -count=1 ...`. + +## Step 6 - Write Review Stub + +Use the template below exactly. Fill `{…}` placeholders from the plan; everything else is fixed and must not be changed by the implementing agent. + +```markdown + + +# Code Review Reference - {TAG} + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Do not modify or check the `코드리뷰 전용 체크리스트`; it is owned by the review agent only. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date={YYYY-MM-DD} +task={task_name}, plan={N}, tag={TAG} + +## 이 파일을 읽는 리뷰 에이전트에게 + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +review 완료 후 반드시 아래 순서로 아카이브하세요. + +1. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log` (N = 기존 code_review_*.log 수) +2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) +3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. + +어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. +PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. +WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [{TAG}-1] {item description} | [ ] | +| [{TAG}-2] {item description} | [ ] | + +## 구현 체크리스트 + +{copy the plan's 구현 체크리스트 items exactly, preserving order and checkbox text} + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 `agent-task/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] WARN/FAIL이면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 리뷰어를 위한 체크포인트 + +{pre-filled from plan — one bullet per review focus area} + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. + +### {TAG}-1 중간 검증 +``` +$ {verification command from plan} +(output) +``` + +### 최종 검증 +``` +$ {final verification command from plan} +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave the review-agent-only checklist unchanged. +``` + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) | +| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only | +| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only; final checkbox is mandatory before saving | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry | +| 코드리뷰 결과 | Review agent appends | Not included in stub | + +## Naming + +| Tag | Use for | +|-----|---------| +| `API` | Public API changes | +| `REFACTOR` | Internal refactoring | +| `TEST` | Test additions/fixes | +| `REVIEW_` | Follow-up fixes after review | + +## Final Checklist + +- `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. +- Split work, if any, uses one task directory per plan/review pair with `01_...`, `02_...`, or dependency-marked `NN+...` names. +- Both first lines match ``. +- Previous active files, if any, were archived with correct numeric suffixes. +- Every plan item has problem, solution, checklist, test decision, and intermediate verification. +- The plan and review stub have matching `구현 체크리스트` item text/order, and the final checkbox is the mandatory `CODE_REVIEW-*-G??.md` completion item. +- The review stub has a clearly marked `코드리뷰 전용 체크리스트` owned only by the review agent. +- Routed review file completion table lists every plan item. diff --git a/agent-ops/skills/common/plan/agents/openai.yaml b/agent-ops/skills/common/plan/agents/openai.yaml new file mode 100644 index 0000000..751c35e --- /dev/null +++ b/agent-ops/skills/common/plan/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Plan" + short_description: "Write implementation plans" + default_prompt: "Use $plan to create a task PLAN.md and CODE_REVIEW.md stub for this repository change." diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md new file mode 100644 index 0000000..49ce8d2 --- /dev/null +++ b/agent-ops/skills/common/router.md @@ -0,0 +1,13 @@ +# 공통 스킬 라우터 + +| 요청 키워드 | SKILL.md | +|------------|----------| +| agent-ops 세팅해줘, scaffold 만들어줘, 초기화해줘 | `agent-ops/skills/common/init-agent-ops/SKILL.md` | +| domain rule 만들어줘, rules.md 생성, 새 도메인 규칙 | `agent-ops/skills/common/create-domain-rule/SKILL.md` | +| skill 만들어줘, SKILL.md 생성, 새 스킬 추가 | `agent-ops/skills/common/create-skill/SKILL.md` | +| 계획 세워줘, 구현 계획, PLAN.md, plan | `agent-ops/skills/common/plan/SKILL.md` | +| 코드 리뷰해줘, code review, CODE_REVIEW.md, 리뷰 루프 | `agent-ops/skills/common/code-review/SKILL.md` | +| 커밋해줘, 푸시해줘, commit, push, 반영해줘 | `agent-ops/skills/common/commit-push/SKILL.md` | +| agent-ops 싱크해, agent-ops 동기화해, agentic-framework에 올려줘, agent-ops를 [프로젝트]로 싱크해 | `agent-ops/skills/common/sync-push/SKILL.md` | +| agent-ops pull해, agent-ops 가져와, agentic-framework에서 가져와, agent-ops 내려받아 | `agent-ops/skills/common/sync-pull/SKILL.md` | +| 도메인 업데이트, domain rule 갱신, 도메인 검토, domain 스캔 | `agent-ops/skills/common/update-domain-rule/SKILL.md` | diff --git a/agent-ops/skills/common/sync-pull/SKILL.md b/agent-ops/skills/common/sync-pull/SKILL.md new file mode 100644 index 0000000..adc2c42 --- /dev/null +++ b/agent-ops/skills/common/sync-pull/SKILL.md @@ -0,0 +1,36 @@ +--- +name: sync-pull +description: agentic-framework에서 현재 프로젝트로 agent-ops를 내려받는다. "agent-ops pull해", "agentic-framework에서 가져와" 요청 시 사용한다. +--- + +# sync-pull + +## 목적 + +`agent-ops/bin/sync.sh --pull`을 호출해 agentic-framework → 현재 프로젝트 방향으로 agent-ops를 내려받는다. + +## 언제 호출할지 + +- "agent-ops pull해", "agent-ops 가져와" 요청 시 +- "agentic-framework에서 가져와" 요청 시 +- "agent-ops 내려받아" 요청 시 + +## 실행 절차 + +1. 현재 프로젝트의 **상위 폴더(`../`)** 에 `agentic-framework` 폴더가 있는지 확인한다 (현재 폴더 내부가 아님) +2. **있으면**: `agent-ops/bin/sync.sh --pull agentic-framework` 실행 +3. **없으면**: 사용자에게 agentic-framework 경로 입력을 안내하고, 입력받은 경로로 실행 + +```bash +agent-ops/bin/sync.sh --pull +``` + +## 실행 결과 검증 + +- [ ] sync.sh 가 오류 없이 완료됐는가 +- [ ] 현재 프로젝트 버전이 framework 버전과 일치하는가 +- [ ] `agent-ops/bin/entry-files.sh`의 모든 진입 파일이 갱신됐고, 내용이 framework의 `agent-ops/rules/common/rules.md`와 일치하는가 + +## 금지 사항 + +- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다 diff --git a/agent-ops/skills/common/sync-push/SKILL.md b/agent-ops/skills/common/sync-push/SKILL.md new file mode 100644 index 0000000..04884a1 --- /dev/null +++ b/agent-ops/skills/common/sync-push/SKILL.md @@ -0,0 +1,60 @@ +--- +name: sync-push +description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거나, agentic-framework의 공통 agent-ops를 대상 프로젝트로 push한다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다. +--- + +# sync-push + +## 목적 + +`agent-ops/bin/sync.sh`를 호출해 agent-ops 공통 파일을 push 방향으로 동기화한다. + +- 일반 프로젝트에서는 현재 프로젝트 → agentic-framework 방향으로 올린다. +- agentic-framework 원본 프로젝트에서는 agentic-framework → 대상 프로젝트 방향으로 보낸다. + +## 언제 호출할지 + +- "agent-ops 싱크해", "agent-ops 동기화해" 요청 시 +- "agentic-framework에 올려줘" 요청 시 +- "agent-ops를 [프로젝트]로 싱크해" 요청 시 + +## 실행 절차 + +### 현 프로젝트가 일반 프로젝트인 경우 (`.agent-ops-source` 없음) + +1. 현재 프로젝트의 **상위 폴더(`../`)** 에 `agentic-framework` 폴더가 있는지 확인한다 (현재 폴더 내부가 아님) +2. **있으면**: `agent-ops/bin/sync.sh agentic-framework` 실행 +3. **없으면**: 사용자에게 agentic-framework 경로 입력을 안내하고, 입력받은 경로로 실행 + +### 현 프로젝트가 agentic-framework인 경우 (`.agent-ops-source` 있음) + +1. 사용자 요청에서 target 프로젝트명 또는 경로를 추출한다 +2. **명시된 경우**: `agent-ops/bin/sync.sh ` 실행 +3. **명시되지 않은 경우**: 사용자에게 대상 프로젝트 입력을 요청한다 +4. `sync.sh`는 현재 agentic-framework의 `agent-ops/rules/common/rules.md` 내용을 대상 프로젝트 루트의 진입 파일에 덮어쓴다 + +덮어쓰기 대상은 `init-agent-ops` 초기 세팅과 동일하며, 실제 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. + +| 에이전트 | 파일명 | +|---------|--------| +| Gemini | `GEMINI.md` | +| Claude | `CLAUDE.md` | +| Kilo Code / OpenCode | `AGENTS.md` | +| Cursor | `.cursorrules` | +| Cline | `.clinerules` | + +대상 프로젝트에 기존 진입 파일이 있어도 보존하거나 병합하지 않고 `agent-ops/rules/common/rules.md` 내용으로 교체한다. + +```bash +agent-ops/bin/sync.sh +``` + +## 실행 결과 검증 + +- [ ] sync.sh 가 오류 없이 완료됐는가 +- [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다 +- [ ] agentic-framework에서 대상 프로젝트로 push한 경우, `agent-ops/bin/entry-files.sh`의 모든 진입 파일 내용이 현재 agentic-framework의 `agent-ops/rules/common/rules.md`와 일치하는가 + +## 금지 사항 + +- sync.sh 를 거치지 않고 직접 파일을 복사하지 않는다 diff --git a/agent-ops/skills/common/update-domain-rule/SKILL.md b/agent-ops/skills/common/update-domain-rule/SKILL.md new file mode 100644 index 0000000..b5beb3e --- /dev/null +++ b/agent-ops/skills/common/update-domain-rule/SKILL.md @@ -0,0 +1,100 @@ +--- +name: update-domain-rule +version: 1.0.0 +description: 기존 도메인 rules.md를 코드 현황에 맞게 갱신. 전체 스캔(full) 또는 지정 도메인(targeted) 두 모드 지원 +--- + +# update-domain-rule + +## 목적 + +프로젝트 코드가 변경되면서 기존 domain rule이 실제 구조와 어긋날 수 있다. +이 스킬은 실제 파일 구조를 탐색하여 기존 `rules.md`와 비교하고, 누락·오류·구식 항목을 수정한다. + +## 언제 호출할지 + +- 대규모 리팩터링 또는 외부 패키지 내재화 후 domain rule 동기화가 필요할 때 +- 특정 도메인 파일 구조가 바뀌어 기존 rule이 맞지 않을 때 +- 사용자가 "도메인 업데이트", "domain rule 갱신", "domain 검토" 등을 요청할 때 +- agent-ops 초기 scaffold 이후 코드가 많이 달라진 경우 + +## 입력 + +- `mode`: `full`(전체 도메인 스캔) | `targeted`(지정 도메인만) (필수) +- `domain-name`: 업데이트할 도메인 이름 — `targeted` 모드에서만 필수 + +## 먼저 확인할 것 + +- [ ] `agent-ops/rules/project/domain/` 하위 기존 도메인 목록 확인 +- [ ] `agent-ops/rules/common/_templates/domain-rule-template.md` 읽어 최신 템플릿 형식 파악 +- [ ] `targeted` 모드이면 `agent-ops/rules/project/domain//rules.md` 존재 여부 확인 + - 존재하지 않으면 `create-domain-rule` 스킬을 사용하도록 안내하고 중단 + +## 실행 절차 + +1. **대상 목록 결정** + - `full`: `agent-ops/rules/project/domain/` 하위 모든 도메인 디렉터리를 대상으로 한다 + - `targeted`: 지정된 `domain-name` 하나만 대상으로 한다 + +2. **도메인별 코드 탐색** + - 기존 `rules.md`의 **포함 경로** 목록을 기준으로 실제 파일 구조 탐색 + - 포함 경로에 없지만 도메인 이름과 연관된 경로도 함께 탐색 + - 탐색 시 실제로 존재하는 경로만 수집한다 + +3. **비교 및 변경 항목 식별** + 다음 항목 각각을 현재 `rules.md`와 비교한다: + - **포함 경로**: 실제로 존재하지 않는 경로 제거, 새로 생긴 경로 추가 + - **주요 구성 요소**: 파일/클래스 삭제·이름 변경·신규 추가 반영 + - **유지할 패턴**: 코드에서 더 이상 사용되지 않는 패턴 제거, 새 패턴 추가 + - **다른 도메인과의 경계**: 도메인 간 import 관계가 바뀐 경우 반영 + - **목적/책임**: 도메인 책임이 실질적으로 변경된 경우에만 수정 + +4. **rules.md 업데이트** + - 변경이 필요한 항목만 수정한다 — 변경 불필요한 섹션은 그대로 둔다 + - 확인된 사실만 기재한다; 불확실하면 `` 주석 처리 + - `domain-rule-template.md`의 섹션 구조를 유지한다 + +5. **도메인 매핑 테이블 검토** (`full` 모드 시) + - `agent-ops/rules/project/rules.md`의 도메인 매핑 테이블과 실제 포함 경로 비교 + - 누락된 경로 패턴은 추가, 존재하지 않는 경로 패턴은 제거 + - 기존 항목 순서는 변경하지 않는다 + +6. **결과 보고** + - 도메인별로 수정한 항목 목록 + - `TODO`로 남긴 항목 (해당 시) + - 도메인 매핑 테이블 변경 내용 (full 모드 시) + +## 실행 결과 검증 + +- [ ] 수정된 `rules.md`의 포함 경로가 모두 실제 프로젝트에 존재하는가 +- [ ] 섹션 구조가 `domain-rule-template.md` 형식을 유지하는가 +- [ ] 변경하지 않아도 되는 섹션이 의도치 않게 바뀌지 않았는가 +- [ ] `full` 모드에서 도메인 매핑 테이블이 실제 포함 경로와 일치하는가 +- 검증 실패 시: 실제 존재하지 않는 경로나 누락된 섹션을 사용자에게 알리고 해당 항목만 보완한다 + +## 출력 형식 + +``` +## 업데이트 완료 + +### +- 변경: <수정된 항목 요약> +- 추가: <새로 추가된 경로/구성 요소> +- 제거: <삭제된 경로/구성 요소> +- 유지: 변경 없음 (해당 섹션) + +### 도메인 매핑 테이블 (full 모드 시) +- 추가: <새 경로 패턴> +- 제거: <삭제된 경로 패턴> + +## TODO 항목 (확인 필요) +- <불확실하여 직접 확인이 필요한 항목> (해당 시) +``` + +## 금지 사항 + +- 실제 존재하지 않는 경로를 포함 경로에 기재하지 않는다 +- 추측으로 패턴·금지 사항을 추가하지 않는다 — 코드에서 확인된 내용만 기재한다 +- 신규 도메인 생성이 필요한 경우 직접 생성하지 않고 `create-domain-rule` 스킬 사용을 안내한다 +- `rules/project/rules.md`의 기존 항목을 삭제하거나 재정렬하지 않는다 +- 코드 파일을 수정하지 않는다 — 이 스킬은 rule 파일 갱신만 담당한다 diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..e8b12b6 --- /dev/null +++ b/opencode.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "read": { + "agent-task/archive/**": "deny" + }, + "glob": { + "agent-task/archive/**": "deny" + } + }, + "watcher": { + "ignore": [ + "agent-task/archive/**" + ] + } +} From cb35ec5a1a47942dd8bebd83ee171e697b73d8d7 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 13:43:19 +0900 Subject: [PATCH 04/12] update project configuration and add middleware tests --- Makefile | 2 + README.md | 15 +++++-- bin/run | 2 + cmd/server/main.go | 2 +- docker-compose.yml | 19 +++++++++ internal/config/config.go | 6 +++ internal/http/middleware.go | 29 +++++++++++++ internal/http/middleware_test.go | 73 ++++++++++++++++++++++++++++++++ internal/http/router.go | 15 +++++-- 9 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 internal/http/middleware_test.go diff --git a/Makefile b/Makefile index 196e438..fdeb191 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ export APP_ENV ?= local export HTTP_ADDR ?= :8080 export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable +export REDIS_URL ?= redis://localhost:6379/0 +export AUTH_USERNAME ?= nomadcode export GOOSE_DRIVER ?= postgres export GOOSE_DBSTRING ?= $(DATABASE_URL) export OUTPUT ?= .build/nomadcode-core diff --git a/README.md b/README.md index 8eb10f7..b1d2587 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - River dummy job - Plane Adapter stub - Mattermost Adapter stub -- 선택적 Docker Compose 실행 환경 +- 선택적 Docker Compose 실행 환경(PostgreSQL, Redis) ## 현재 구현하지 않은 범위 @@ -60,7 +60,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 ## 실행 방법 -로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. +로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. Redis는 이후 event fan-out / realtime push 경로를 위한 선택 구성으로, Docker Compose에서는 `redis://redis:6379/0`로 노출합니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. PostgreSQL 사용자와 DB 생성 예시: @@ -81,6 +81,13 @@ Migration 실행: ./bin/run ``` +간단한 암호를 걸고 실행: + +```bash +AUTH_PASSWORD="change-me" ./bin/run +curl -u nomadcode:change-me localhost:8080/api/tasks +``` + 다른 DB 주소를 사용할 경우: ```bash @@ -102,10 +109,10 @@ sqlc 실행: DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으로 `internal/db/`에 생성합니다. `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 생성 파일이므로 직접 수정하지 않습니다. -Docker Compose 실행은 통합 확인이 필요할 때 선택적으로 사용합니다. +Docker Compose 실행은 PostgreSQL과 Redis를 함께 띄워 통합 확인이 필요할 때 선택적으로 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다. ```bash -./bin/docker-up +AUTH_PASSWORD="change-me" ./bin/docker-up ``` Makefile은 같은 명령을 감싸는 얇은 alias입니다. diff --git a/bin/run b/bin/run index d024278..a02d319 100755 --- a/bin/run +++ b/bin/run @@ -7,5 +7,7 @@ cd "$ROOT_DIR" export APP_ENV="${APP_ENV:-local}" export HTTP_ADDR="${HTTP_ADDR:-:8080}" export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" +export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" +export AUTH_USERNAME="${AUTH_USERNAME:-nomadcode}" exec go run ./cmd/server "$@" diff --git a/cmd/server/main.go b/cmd/server/main.go index 8c102f1..54bf5f6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -62,7 +62,7 @@ func run(logger *slog.Logger) error { handler := apphttp.NewHandler(pool, workflowService, logger) server := &http.Server{ Addr: cfg.HTTPAddr, - Handler: apphttp.NewRouter(handler, logger), + Handler: apphttp.NewRouter(handler, logger, apphttp.AuthConfig{Username: cfg.AuthUsername, Password: cfg.AuthPassword}), ReadHeaderTimeout: 5 * time.Second, } diff --git a/docker-compose.yml b/docker-compose.yml index 094239b..f44a4c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,12 +15,28 @@ services: volumes: - postgres-data:/var/lib/postgresql/data + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 10 + volumes: + - redis-data:/data + nomadcode-core: build: . environment: APP_ENV: local HTTP_ADDR: :8080 DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable + REDIS_URL: redis://redis:6379/0 + AUTH_USERNAME: ${AUTH_USERNAME:-nomadcode} + AUTH_PASSWORD: ${AUTH_PASSWORD:-} MATTERMOST_BASE_URL: "" MATTERMOST_TOKEN: "" PLANE_BASE_URL: "" @@ -28,8 +44,11 @@ services: depends_on: postgres: condition: service_healthy + redis: + condition: service_healthy ports: - "8080:8080" volumes: postgres-data: + redis-data: diff --git a/internal/config/config.go b/internal/config/config.go index 5961d76..8d9a6fa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,9 @@ type Config struct { AppEnv string HTTPAddr string DatabaseURL string + RedisURL string + AuthUsername string + AuthPassword string MattermostBaseURL string MattermostToken string PlaneBaseURL string @@ -17,6 +20,9 @@ func Load() Config { AppEnv: getEnv("APP_ENV", "local"), HTTPAddr: getEnv("HTTP_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), + RedisURL: os.Getenv("REDIS_URL"), + AuthUsername: getEnv("AUTH_USERNAME", "nomadcode"), + AuthPassword: os.Getenv("AUTH_PASSWORD"), MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"), MattermostToken: os.Getenv("MATTERMOST_TOKEN"), PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), diff --git a/internal/http/middleware.go b/internal/http/middleware.go index d08d153..fe6b8b1 100644 --- a/internal/http/middleware.go +++ b/internal/http/middleware.go @@ -1,6 +1,7 @@ package http import ( + "crypto/subtle" "log/slog" stdhttp "net/http" "time" @@ -35,3 +36,31 @@ func loggingMiddleware(logger *slog.Logger) func(stdhttp.Handler) stdhttp.Handle }) } } + +func basicAuthMiddleware(auth AuthConfig) func(stdhttp.Handler) stdhttp.Handler { + if auth.Password == "" { + return func(next stdhttp.Handler) stdhttp.Handler { + return next + } + } + if auth.Username == "" { + auth.Username = "nomadcode" + } + + return func(next stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + username, password, ok := r.BasicAuth() + if !ok || !secureCompare(username, auth.Username) || !secureCompare(password, auth.Password) { + w.Header().Set("WWW-Authenticate", `Basic realm="nomadcode-core", charset="UTF-8"`) + writeError(w, stdhttp.StatusUnauthorized, "authentication required") + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func secureCompare(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} diff --git a/internal/http/middleware_test.go b/internal/http/middleware_test.go new file mode 100644 index 0000000..c93a6de --- /dev/null +++ b/internal/http/middleware_test.go @@ -0,0 +1,73 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestBasicAuthMiddlewareDisabledWithoutPassword(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestBasicAuthMiddlewareRejectsMissingCredentials(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got == "" { + t.Fatal("expected WWW-Authenticate header") + } +} + +func TestBasicAuthMiddlewareAcceptsValidCredentials(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + req.SetBasicAuth("nomadcode", "secret") + rec := httptest.NewRecorder() + + basicAuthMiddleware(AuthConfig{Username: "nomadcode", Password: "secret"})(okHandler()).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} + +func TestRouterKeepsHealthzPublicAndProtectsAPI(t *testing.T) { + router := NewRouter(NewHandler(nil, nil, nil), nil, AuthConfig{ + Username: "nomadcode", + Password: "secret", + }) + + healthReq := httptest.NewRequest(http.MethodGet, "/healthz", nil) + healthRec := httptest.NewRecorder() + router.ServeHTTP(healthRec, healthReq) + + if healthRec.Code != http.StatusOK { + t.Fatalf("expected /healthz status %d, got %d", http.StatusOK, healthRec.Code) + } + + apiReq := httptest.NewRequest(http.MethodGet, "/api/tasks", nil) + apiRec := httptest.NewRecorder() + router.ServeHTTP(apiRec, apiReq) + + if apiRec.Code != http.StatusUnauthorized { + t.Fatalf("expected /api/tasks status %d, got %d", http.StatusUnauthorized, apiRec.Code) + } +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) +} diff --git a/internal/http/router.go b/internal/http/router.go index 8de899c..bf1c2ca 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -8,7 +8,12 @@ import ( chimiddleware "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler { +type AuthConfig struct { + Username string + Password string +} + +func NewRouter(handler *Handler, logger *slog.Logger, auth AuthConfig) stdhttp.Handler { r := chi.NewRouter() r.Use(chimiddleware.RequestID) r.Use(chimiddleware.RealIP) @@ -16,14 +21,18 @@ func NewRouter(handler *Handler, logger *slog.Logger) stdhttp.Handler { r.Use(chimiddleware.Recoverer) r.Get("/healthz", handler.Healthz) - r.Get("/readyz", handler.Readyz) - r.Route("/api", func(r chi.Router) { + protected := chi.NewRouter() + protected.Use(basicAuthMiddleware(auth)) + protected.Get("/readyz", handler.Readyz) + + protected.Route("/api", func(r chi.Router) { r.Post("/tasks", handler.CreateTask) r.Get("/tasks", handler.ListTasks) r.Get("/tasks/{id}", handler.GetTask) r.Post("/tasks/{id}/enqueue", handler.EnqueueTask) }) + r.Mount("/", protected) return r } From 787f579c1cb820aa204f366f5ae209e5d9c4d9b8 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 22:12:50 +0900 Subject: [PATCH 05/12] update project configuration and build scripts --- Makefile | 7 ++++-- README.md | 10 ++++---- bin/migrate-up | 12 ++++++++- bin/run | 20 +++++++++++++-- docker-compose.yml | 51 +++++++++------------------------------ goose.env.example | 2 +- internal/config/config.go | 2 ++ 7 files changed, 53 insertions(+), 51 deletions(-) diff --git a/Makefile b/Makefile index fdeb191..b776e46 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,10 @@ export APP_ENV ?= local export HTTP_ADDR ?= :8080 -export DATABASE_URL ?= postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable -export REDIS_URL ?= redis://localhost:6379/0 +export DATABASE_URL ?= postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable +export REDIS_URL ?= redis://code-server-redis:6379/3 +export REDIS_KEY_PREFIX ?= nomadcode-core:local +export DEV_REDIS_URL ?= redis://code-server-redis:6379/4 +export DEV_REDIS_KEY_PREFIX ?= nomadcode-core:dev export AUTH_USERNAME ?= nomadcode export GOOSE_DRIVER ?= postgres export GOOSE_DBSTRING ?= $(DATABASE_URL) diff --git a/README.md b/README.md index b1d2587..b7b2e5d 100644 --- a/README.md +++ b/README.md @@ -60,13 +60,13 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 ## 실행 방법 -로컬 실행은 호스트에 Go와 PostgreSQL이 설치되어 있다는 전제로 진행합니다. 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable` 입니다. Redis는 이후 event fan-out / realtime push 경로를 위한 선택 구성으로, Docker Compose에서는 `redis://redis:6379/0`로 노출합니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. +로컬 실행은 현재 개발 호스트의 Go와 `code-server` compose에 붙은 PostgreSQL/Redis가 있다는 전제로 진행합니다. local 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable` 이고, local 기본 `REDIS_URL`은 `redis://code-server-redis:6379/3`, `REDIS_KEY_PREFIX`는 `nomadcode-core:local` 입니다. dev 배포는 Docker Compose로 실행하며, 같은 `code-server-postgres` Postgres와 `code-server-redis` Redis를 사용합니다. dev DB명은 `nomad-core-dev` 이고, dev 기본 `REDIS_URL`은 `redis://code-server-redis:6379/4`, `REDIS_KEY_PREFIX`는 `nomadcode-core:dev` 입니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. -PostgreSQL 사용자와 DB 생성 예시: +`code-server` PostgreSQL 컨테이너에 DB를 생성하는 예시: ```bash -psql postgres -c "CREATE USER nomadcode WITH PASSWORD 'nomadcode';" -psql postgres -c "CREATE DATABASE nomadcode OWNER nomadcode;" +docker exec code-server-postgres psql -U nomadcode -d postgres -c 'CREATE DATABASE "nomadcode-core-local" OWNER nomadcode;' +docker exec code-server-postgres psql -U nomadcode -d postgres -c 'CREATE DATABASE "nomad-core-dev" OWNER nomadcode;' ``` Migration 실행: @@ -109,7 +109,7 @@ sqlc 실행: DB 접근 코드는 `migrations/`의 스키마와 `queries/`의 SQL을 기준으로 `internal/db/`에 생성합니다. `internal/db/db.go`, `internal/db/models.go`, `internal/db/tasks.sql.go`는 생성 파일이므로 직접 수정하지 않습니다. -Docker Compose 실행은 PostgreSQL과 Redis를 함께 띄워 통합 확인이 필요할 때 선택적으로 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다. +Docker Compose 실행은 dev 배포용입니다. compose는 외부 Docker 네트워크 `net_nginx`에 붙고, 같은 네트워크의 `code-server-postgres`에 `nomad-core-dev` DB로, `code-server-redis`의 Redis DB 4번과 `nomadcode-core:dev` key prefix로 접속합니다. dev Redis 주소나 prefix를 바꿀 때는 local 실행용 `REDIS_URL`, `REDIS_KEY_PREFIX`와 분리된 `DEV_REDIS_URL`, `DEV_REDIS_KEY_PREFIX`를 사용합니다. 외부 노출 테스트에서는 `AUTH_PASSWORD`를 함께 지정합니다. ```bash AUTH_PASSWORD="change-me" ./bin/docker-up diff --git a/bin/migrate-up b/bin/migrate-up index a7a6ad8..4e8ce12 100755 --- a/bin/migrate-up +++ b/bin/migrate-up @@ -5,8 +5,18 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" GOOSE_DRIVER="${GOOSE_DRIVER:-postgres}" -DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" +default_database_url="postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable" +legacy_code_server_database_url="postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode?sslmode=disable" +# code-server exports a shared DATABASE_URL; remap that default to this project's local DB. +if [[ "${DATABASE_URL:-}" == "$legacy_code_server_database_url" ]]; then + DATABASE_URL="$default_database_url" +else + DATABASE_URL="${DATABASE_URL:-$default_database_url}" +fi GOOSE_DBSTRING="${GOOSE_DBSTRING:-$DATABASE_URL}" +if [[ "$GOOSE_DBSTRING" == "$legacy_code_server_database_url" ]]; then + GOOSE_DBSTRING="$DATABASE_URL" +fi if [[ -n "${GOOSE_BIN:-}" ]]; then goose_cmd=("$GOOSE_BIN") diff --git a/bin/run b/bin/run index a02d319..d7e8248 100755 --- a/bin/run +++ b/bin/run @@ -6,8 +6,24 @@ cd "$ROOT_DIR" export APP_ENV="${APP_ENV:-local}" export HTTP_ADDR="${HTTP_ADDR:-:8080}" -export DATABASE_URL="${DATABASE_URL:-postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable}" -export REDIS_URL="${REDIS_URL:-redis://localhost:6379/0}" +default_database_url="postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable" +legacy_code_server_database_url="postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode?sslmode=disable" +default_redis_url="redis://code-server-redis:6379/3" +legacy_code_server_redis_url="redis://code-server-redis:6379/0" +default_redis_key_prefix="nomadcode-core:local" +# code-server exports a shared DATABASE_URL; remap that default to this project's local DB. +if [[ "${DATABASE_URL:-}" == "$legacy_code_server_database_url" ]]; then + export DATABASE_URL="$default_database_url" +else + export DATABASE_URL="${DATABASE_URL:-$default_database_url}" +fi +# code-server exports a shared REDIS_URL; remap that default to this project's local Redis DB. +if [[ "${REDIS_URL:-}" == "$legacy_code_server_redis_url" ]]; then + export REDIS_URL="$default_redis_url" +else + export REDIS_URL="${REDIS_URL:-$default_redis_url}" +fi +export REDIS_KEY_PREFIX="${REDIS_KEY_PREFIX:-$default_redis_key_prefix}" export AUTH_USERNAME="${AUTH_USERNAME:-nomadcode}" exec go run ./cmd/server "$@" diff --git a/docker-compose.yml b/docker-compose.yml index f44a4c1..515e597 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,54 +1,25 @@ +name: nomadcode-core + services: - postgres: - image: postgres:17-alpine - environment: - POSTGRES_DB: nomadcode - POSTGRES_USER: nomadcode - POSTGRES_PASSWORD: nomadcode - ports: - - "5432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U nomadcode -d nomadcode"] - interval: 5s - timeout: 5s - retries: 10 - volumes: - - postgres-data:/var/lib/postgresql/data - - redis: - image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] - ports: - - "6379:6379" - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 5s - retries: 10 - volumes: - - redis-data:/data - nomadcode-core: build: . environment: - APP_ENV: local + APP_ENV: dev HTTP_ADDR: :8080 - DATABASE_URL: postgres://nomadcode:nomadcode@postgres:5432/nomadcode?sslmode=disable - REDIS_URL: redis://redis:6379/0 + DATABASE_URL: ${DATABASE_URL:-postgres://nomadcode:nomadcode@code-server-postgres:5432/nomad-core-dev?sslmode=disable} + REDIS_URL: ${DEV_REDIS_URL:-redis://code-server-redis:6379/4} + REDIS_KEY_PREFIX: ${DEV_REDIS_KEY_PREFIX:-nomadcode-core:dev} AUTH_USERNAME: ${AUTH_USERNAME:-nomadcode} AUTH_PASSWORD: ${AUTH_PASSWORD:-} MATTERMOST_BASE_URL: "" MATTERMOST_TOKEN: "" PLANE_BASE_URL: "" PLANE_TOKEN: "" - depends_on: - postgres: - condition: service_healthy - redis: - condition: service_healthy ports: - "8080:8080" + networks: + - net_nginx -volumes: - postgres-data: - redis-data: +networks: + net_nginx: + external: true diff --git a/goose.env.example b/goose.env.example index b55e3db..977ee46 100644 --- a/goose.env.example +++ b/goose.env.example @@ -1,2 +1,2 @@ GOOSE_DRIVER=postgres -GOOSE_DBSTRING=postgres://nomadcode:nomadcode@localhost:5432/nomadcode?sslmode=disable +GOOSE_DBSTRING=postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable diff --git a/internal/config/config.go b/internal/config/config.go index 8d9a6fa..fb6e14c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ type Config struct { HTTPAddr string DatabaseURL string RedisURL string + RedisKeyPrefix string AuthUsername string AuthPassword string MattermostBaseURL string @@ -21,6 +22,7 @@ func Load() Config { HTTPAddr: getEnv("HTTP_ADDR", ":8080"), DatabaseURL: os.Getenv("DATABASE_URL"), RedisURL: os.Getenv("REDIS_URL"), + RedisKeyPrefix: os.Getenv("REDIS_KEY_PREFIX"), AuthUsername: getEnv("AUTH_USERNAME", "nomadcode"), AuthPassword: os.Getenv("AUTH_PASSWORD"), MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"), From 28dd9d5941b24fe8a3617d60ec8c6d907ee79f9d Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 10:09:43 +0900 Subject: [PATCH 06/12] sync: agent-ops from agentic-framework v1.1.15 --- agent-ops/.version | 2 +- agent-ops/bin/init-agent-ops.sh | 36 +++- agent-ops/bin/sync.sh | 178 +++++++++++++++---- agent-ops/skills/common/code-review/SKILL.md | 3 + agent-ops/skills/common/plan/SKILL.md | 14 +- agent-ops/skills/common/sync-push/SKILL.md | 18 +- 6 files changed, 197 insertions(+), 54 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index e9bc149..645377e 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.14 +1.1.15 diff --git a/agent-ops/bin/init-agent-ops.sh b/agent-ops/bin/init-agent-ops.sh index 449c410..142a9f6 100755 --- a/agent-ops/bin/init-agent-ops.sh +++ b/agent-ops/bin/init-agent-ops.sh @@ -27,20 +27,38 @@ append_unique_line() { fi } +create_project_agent_ops_dirs() { + local agent_ops_dir="$1" + + mkdir -p "$agent_ops_dir/rules/project/domain" + mkdir -p "$agent_ops_dir/rules/private" + mkdir -p "$agent_ops_dir/skills/project" +} + +copy_common_agent_ops() { + local source_dir="$1" + local target_agent_ops_dir="$2" + + mkdir -p "$target_agent_ops_dir/rules" + mkdir -p "$target_agent_ops_dir/skills" + + cp "$source_dir/.version" "$target_agent_ops_dir/" + rm -rf "$target_agent_ops_dir/bin" + rm -rf "$target_agent_ops_dir/rules/common" + rm -rf "$target_agent_ops_dir/skills/common" + cp -r "$source_dir/bin" "$target_agent_ops_dir/" + cp -r "$source_dir/rules/common" "$target_agent_ops_dir/rules/" + cp -r "$source_dir/skills/common" "$target_agent_ops_dir/skills/" +} + echo "Initializing agent-ops in: $TARGET_DIR" echo "Source agent-ops: $SOURCE_DIR" -# 1. 대상 폴더 생성 -mkdir -p "$TARGET_DIR/agent-ops/rules/project/domain" -mkdir -p "$TARGET_DIR/agent-ops/rules/private" -mkdir -p "$TARGET_DIR/agent-ops/skills/project" +# 1. 대상 폴더 생성 (프로젝트 전용 폴더는 내용 복사 없이 폴더만 생성) +create_project_agent_ops_dirs "$TARGET_DIR/agent-ops" # 2. 공통 요소 복사 (프로젝트 전용 설정은 제외) -cp "$SOURCE_DIR/.version" "$TARGET_DIR/agent-ops/" -rm -rf "$TARGET_DIR/agent-ops/bin" -cp -r "$SOURCE_DIR/bin" "$TARGET_DIR/agent-ops/" -cp -r "$SOURCE_DIR/rules/common" "$TARGET_DIR/agent-ops/rules/" -cp -r "$SOURCE_DIR/skills/common" "$TARGET_DIR/agent-ops/skills/" +copy_common_agent_ops "$SOURCE_DIR" "$TARGET_DIR/agent-ops" # 3. 에이전트 진입 파일 생성 (common/rules.md 복사) COMMON_RULES="$SOURCE_DIR/rules/common/rules.md" diff --git a/agent-ops/bin/sync.sh b/agent-ops/bin/sync.sh index 632853d..dbde37f 100755 --- a/agent-ops/bin/sync.sh +++ b/agent-ops/bin/sync.sh @@ -71,14 +71,112 @@ sync_common() { sync_folder "$src/bin" "$dst/bin" } +create_project_agent_ops_dirs() { + local agent_ops_dir="$1" + mkdir -p "$agent_ops_dir/rules/project/domain" + mkdir -p "$agent_ops_dir/rules/private" + mkdir -p "$agent_ops_dir/skills/project" +} + +copy_common_scaffold() { + local src="$1" dst="$2" + mkdir -p "$dst/rules" "$dst/skills" + cp "$src/.version" "$dst/" + rm -rf "$dst/bin" "$dst/rules/common" "$dst/skills/common" + cp -r "$src/bin" "$dst/" + cp -r "$src/rules/common" "$dst/rules/" + cp -r "$src/skills/common" "$dst/skills/" + create_project_agent_ops_dirs "$dst" +} + +discover_agent_ops_targets() { + local parent + parent="$(dirname "$PROJECT_ROOT")" + + find "$parent" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r candidate; do + local resolved + resolved="$(cd "$candidate" && pwd)" + [[ "$resolved" == "$PROJECT_ROOT" ]] && continue + [[ -d "$resolved/agent-ops" ]] || continue + echo "$resolved" + done +} + +agent_ops_git_paths() { + printf '%s\n' "agent-ops/.version" + printf '%s\n' "agent-ops/bin" + printf '%s\n' "agent-ops/rules/common" + printf '%s\n' "agent-ops/skills/common" + + local f + for f in "${AGENT_OPS_ENTRY_FILES[@]}"; do + if [[ -e "$f" ]] || git ls-files --error-unmatch "$f" >/dev/null 2>&1; then + printf '%s\n' "$f" + fi + done +} + +commit_and_push_agent_ops_scope() { + local repo="$1" message="$2" + + if ! git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo -e "${RED}Error: git 저장소가 아닙니다: $repo${RESET}" + return 1 + fi + + ( + cd "$repo" + mapfile -t paths < <(agent_ops_git_paths) + git add -- "${paths[@]}" + + if git diff --cached --quiet -- "${paths[@]}"; then + echo " agent-ops 변경 없음: commit 건너뜀" + else + git commit -m "$message" -- "${paths[@]}" + fi + + git push + ) +} + +sync_framework_to_target() { + local target="$1" + local dst_ao="$target/agent-ops" + local src_ver + src_ver="$(cat "$SRC_AO/.version" 2>/dev/null || echo "0.0.0")" + + echo "▶ $(basename "$PROJECT_ROOT") → $(basename "$target")" + + if ! git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo -e "${RED}Error: git 저장소가 아닙니다: $target${RESET}" + return 1 + fi + + if [[ ! -d "$dst_ao" ]]; then + echo " 최초 진행: agent-ops 공통 scaffold 복사" + copy_common_scaffold "$SRC_AO" "$dst_ao" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$target" + echo -e "${YELLOW} init-agent-ops 스킬로 초기화를 진행하세요.${RESET}" + else + echo " 이후 진행: common/ 동기화" + sync_common "$SRC_AO" "$dst_ao" + cp "$SRC_AO/.version" "$dst_ao/.version" + apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$target" + fi + + commit_and_push_agent_ops_scope "$target" "sync: agent-ops from $(basename "$PROJECT_ROOT") v$src_ver" + echo -e "${GREEN}✓ 완료 → $(basename "$target")${RESET}" +} + # ── 사용법 ──────────────────────────────────────────────────────────────────── usage() { - echo "사용법: $0 [--pull] " + echo "사용법: $0 [--pull] [target]" echo "" - echo " (기본) 현재 프로젝트 → target(agentic-framework) 으로 push" + echo " (기본) push 동기화" echo " --pull target(agentic-framework) → 현재 프로젝트 로 pull" echo "" echo " target: 폴더명 (동일 레벨 탐색) | 상대경로 | 절대경로" + echo " agentic-framework에서 target 생략 시 상위 폴더의 agent-ops 적용 프로젝트 전체 동기화" } # ───────────────────────────────────────────────────────────────────────────── @@ -89,22 +187,22 @@ if [[ "${1:-}" == "--pull" ]]; then fi TARGET_INPUT="${1:-}" -if [[ -z "$TARGET_INPUT" ]]; then - usage; exit 1 -fi - -TARGET="$(resolve_target "$TARGET_INPUT")" -if [[ -z "$TARGET" ]]; then - echo -e "${RED}Error: 대상을 찾을 수 없습니다: $TARGET_INPUT${RESET}" - exit 1 -fi SRC_AO="$AGENT_OPS_DIR" -DST_AO="$TARGET/agent-ops" IS_FRAMEWORK="$([[ -f "$PROJECT_ROOT/.agent-ops-source" ]] && echo "1" || echo "0")" # ── pull 모드: agentic-framework → 현재 프로젝트 ───────────────────────────── if [[ "$PULL_MODE" == "1" ]]; then + if [[ -z "$TARGET_INPUT" ]]; then + usage; exit 1 + fi + TARGET="$(resolve_target "$TARGET_INPUT")" + if [[ -z "$TARGET" ]]; then + echo -e "${RED}Error: 대상을 찾을 수 없습니다: $TARGET_INPUT${RESET}" + exit 1 + fi + DST_AO="$TARGET/agent-ops" + if [[ "$IS_FRAMEWORK" == "1" ]]; then echo -e "${RED}Error: agentic-framework에서는 --pull을 사용할 수 없습니다.${RESET}" exit 1 @@ -129,26 +227,47 @@ if [[ "$PULL_MODE" == "1" ]]; then exit 0 fi -echo "▶ $(basename "$PROJECT_ROOT") → $(basename "$TARGET")" - # ── agentic-framework에서 다른 프로젝트로 ──────────────────────────────────── if [[ "$IS_FRAMEWORK" == "1" ]]; then - if [[ ! -d "$DST_AO" ]]; then - echo " 최초 진행: agent-ops 전체 복사" - cp -r "$SRC_AO" "$TARGET/" - apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" - echo -e "${YELLOW} init-agent-ops 스킬로 초기화를 진행하세요.${RESET}" + if [[ -n "$TARGET_INPUT" ]]; then + TARGET="$(resolve_target "$TARGET_INPUT")" + if [[ -z "$TARGET" ]]; then + echo -e "${RED}Error: 대상을 찾을 수 없습니다: $TARGET_INPUT${RESET}" + exit 1 + fi + sync_framework_to_target "$TARGET" else - echo " 이후 진행: common/ 동기화" - sync_common "$SRC_AO" "$DST_AO" - cp "$SRC_AO/.version" "$DST_AO/.version" - apply_agent_ops_entry_files "$SRC_AO/rules/common/rules.md" "$TARGET" + mapfile -t TARGETS < <(discover_agent_ops_targets) + if [[ "${#TARGETS[@]}" -eq 0 ]]; then + echo -e "${YELLOW}agent-ops가 적용된 sibling 프로젝트가 없습니다.${RESET}" + exit 0 + fi + + STATUS=0 + for TARGET in "${TARGETS[@]}"; do + if ! sync_framework_to_target "$TARGET"; then + STATUS=1 + fi + done + exit "$STATUS" fi - echo -e "${GREEN}✓ 완료 → $(basename "$TARGET")${RESET}" exit 0 fi # ── 일반 프로젝트에서 agentic-framework로 (push) ───────────────────────────── +if [[ -z "$TARGET_INPUT" ]]; then + TARGET_INPUT="agentic-framework" +fi + +TARGET="$(resolve_target "$TARGET_INPUT")" +if [[ -z "$TARGET" ]]; then + echo -e "${RED}Error: 대상을 찾을 수 없습니다: $TARGET_INPUT${RESET}" + exit 1 +fi +DST_AO="$TARGET/agent-ops" + +echo "▶ $(basename "$PROJECT_ROOT") → $(basename "$TARGET")" + if [[ ! -f "$TARGET/.agent-ops-source" ]]; then echo -e "${RED}Error: target이 agentic-framework가 아닙니다 (.agent-ops-source 없음)${RESET}" exit 1 @@ -169,14 +288,7 @@ sync_common "$SRC_AO" "$DST_AO" echo "$NEW_VER" > "$SRC_AO/.version" echo "$NEW_VER" > "$DST_AO/.version" -cd "$TARGET" -git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version -git commit -m "sync: from $(basename "$PROJECT_ROOT") v$NEW_VER" -git push -cd "$PROJECT_ROOT" - -git add agent-ops/rules/common agent-ops/skills/common agent-ops/bin agent-ops/.version -git commit -m "sync: to agentic-framework v$NEW_VER" -git push +commit_and_push_agent_ops_scope "$TARGET" "sync: from $(basename "$PROJECT_ROOT") v$NEW_VER" +commit_and_push_agent_ops_scope "$PROJECT_ROOT" "sync: to agentic-framework v$NEW_VER" echo -e "${GREEN}✓ 완료 (v$SRC_VER → v$NEW_VER)${RESET}" diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md index f2429db..006174c 100644 --- a/agent-ops/skills/common/code-review/SKILL.md +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -54,11 +54,13 @@ The implementing agent never archives or deletes active files; archiving is this Finalization invariant: - Every review attempt that selects an active review file must end by archiving the active `CODE_REVIEW-*-G??.md` and `PLAN-*-G??.md` files. +- A review is not complete when the verdict is appended. It is complete only after the required archive files and next-state files exist. - After archiving, exactly one next state is allowed: - `PASS`: write `complete.log`, move `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/`, then complete the review-only checklist in the final archive path. - `WARN` or `FAIL`: write the next active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md`; do not write `complete.log`. - Never stop after appending a verdict. Do not report to the user until archive, required next-state file writes, review-only checklist updates, and PASS task-directory archive moves are complete. - If the review result feels ambiguous, choose `WARN` or `FAIL` according to the severity rules, archive the current files, and write the next plan/review pair. Ambiguity is not a reason to leave active files unarchived. +- If you notice that you already reported after only appending `PASS`, `WARN`, or `FAIL`, resume immediately: archive the active files, write `complete.log` or the follow-up plan/review pair, update the archived review checklist, then correct the user-facing report. ## Step 1 - Find Active Task @@ -195,6 +197,7 @@ Review 완료 후 반드시 아래 순서로 아카이브하세요. 2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) 3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. +판정 append만으로 review를 끝내면 안 됩니다. `PASS`, `WARN`, `FAIL` 모두 active plan/review 파일을 `.log`로 전환한 뒤 다음 상태 파일까지 만든 후에만 보고하세요. 어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index 897de37..7874d89 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -31,9 +31,12 @@ Task directory naming rules: - A single-plan task uses `agent-task/{task_name}/` with a short snake_case task name. - If one plan is not enough, split the work into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. - Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across multiple folders, not multiple plan files inside one folder. -- Multi-plan task directory names must start with an execution-order index: `01_{task_name}`, `02_{task_name}`, `03_{task_name}`, and so on. -- If a later task cannot start until a specific current task is complete, mark that dependency immediately after the current task's index with `+`, not `_`: `01+{dependent_task_name}` means this task depends on completion of the `01_...` task. -- When multiple dependent tasks share the same predecessor, order them after the dependency marker: `01+01_{task_name}`, `01+02_{task_name}`. +- Multi-plan task directory names must start with the current task's execution-order index: `01_{task_name}`, `02_{task_name}`, `03_{task_name}`, and so on. The leading number is always the current task index and must increase across sibling task directories. +- Use `_` after the index for a task that can start independently at that point: `01_{task_name}`, `03_{task_name}`. +- Use `+` instead of `_` after the index for a task that depends on an earlier task: `02+{task_name}`. Do not put predecessor numbers in the directory name. +- Record the exact predecessor task directory or directories in the plan's `의존 관계 및 구현 순서` section. +- Example: split a common core plus two app integrations as `01_core`, `02+edge_integration`, `03+node_integration`. In both `02+...` and `03+...`, write that they depend on `agent-task/01_core`. The `+` marker does not mean `03+...` depends on `02+...`; if `03+...` also depends on `02+...`, say so explicitly in `의존 관계 및 구현 순서`. +- Example: split three sequential tasks as `01_schema`, `02+migration`, `03+api`. In `02+migration`, list `agent-task/01_schema` as predecessor. In `03+api`, list `agent-task/02+migration` as predecessor. - Directory names are runtime scheduling metadata. Preserve them verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. - Every split plan must include `의존 관계 및 구현 순서` and list predecessor task directories that must reach `complete.log` before implementation begins. @@ -151,7 +154,7 @@ Each plan item must include: Include `의존 관계 및 구현 순서` only when order matters. -For split multi-plan work, `의존 관계 및 구현 순서` is mandatory in every generated plan. If a plan has a `NN+...` directory name, state which `NN_...` predecessor must produce `complete.log` before implementation starts. +For split multi-plan work, `의존 관계 및 구현 순서` is mandatory in every generated plan. If a plan has a `NN+...` directory name, state which predecessor task directory or directories must produce `complete.log` before implementation starts. Quality rules: @@ -213,6 +216,7 @@ review 완료 후 반드시 아래 순서로 아카이브하세요. 2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) 3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. +판정 append만으로 review를 끝내면 안 됩니다. `PASS`, `WARN`, `FAIL` 모두 active plan/review 파일을 `.log`로 전환한 뒤 다음 상태 파일까지 만든 후에만 보고하세요. 어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. @@ -308,7 +312,7 @@ Sections and their ownership: ## Final Checklist - `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. -- Split work, if any, uses one task directory per plan/review pair with `01_...`, `02_...`, or dependency-marked `NN+...` names. +- Split work, if any, uses one task directory per plan/review pair with names like `01_core`, `02+edge_integration`, `03+node_integration`; predecessor details live in `의존 관계 및 구현 순서`, not in the directory name. - Both first lines match ``. - Previous active files, if any, were archived with correct numeric suffixes. - Every plan item has problem, solution, checklist, test decision, and intermediate verification. diff --git a/agent-ops/skills/common/sync-push/SKILL.md b/agent-ops/skills/common/sync-push/SKILL.md index 04884a1..12c9c5b 100644 --- a/agent-ops/skills/common/sync-push/SKILL.md +++ b/agent-ops/skills/common/sync-push/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-push -description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거나, agentic-framework의 공통 agent-ops를 대상 프로젝트로 push한다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다. +description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거나, agentic-framework의 공통 agent-ops를 대상 프로젝트 또는 상위 폴더의 agent-ops 적용 프로젝트 전체로 push하고 푸시한다. "agent-ops 싱크해", "agent-ops 동기화해", "agentic-framework에 올려줘" 요청 시 사용한다. --- # sync-push @@ -10,7 +10,8 @@ description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거 `agent-ops/bin/sync.sh`를 호출해 agent-ops 공통 파일을 push 방향으로 동기화한다. - 일반 프로젝트에서는 현재 프로젝트 → agentic-framework 방향으로 올린다. -- agentic-framework 원본 프로젝트에서는 agentic-framework → 대상 프로젝트 방향으로 보낸다. +- agentic-framework 원본 프로젝트에서는 agentic-framework → 대상 프로젝트 방향으로 보내고, 대상 repo를 commit/push 한다. +- agentic-framework 원본 프로젝트에서 대상이 명시되지 않으면 현재 프로젝트의 상위 폴더에 있는 agent-ops 적용 프로젝트 전체로 보낸다. ## 언제 호출할지 @@ -23,15 +24,17 @@ description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거 ### 현 프로젝트가 일반 프로젝트인 경우 (`.agent-ops-source` 없음) 1. 현재 프로젝트의 **상위 폴더(`../`)** 에 `agentic-framework` 폴더가 있는지 확인한다 (현재 폴더 내부가 아님) -2. **있으면**: `agent-ops/bin/sync.sh agentic-framework` 실행 +2. **있으면**: `agent-ops/bin/sync.sh` 실행 또는 `agent-ops/bin/sync.sh agentic-framework` 실행 3. **없으면**: 사용자에게 agentic-framework 경로 입력을 안내하고, 입력받은 경로로 실행 ### 현 프로젝트가 agentic-framework인 경우 (`.agent-ops-source` 있음) 1. 사용자 요청에서 target 프로젝트명 또는 경로를 추출한다 2. **명시된 경우**: `agent-ops/bin/sync.sh ` 실행 -3. **명시되지 않은 경우**: 사용자에게 대상 프로젝트 입력을 요청한다 -4. `sync.sh`는 현재 agentic-framework의 `agent-ops/rules/common/rules.md` 내용을 대상 프로젝트 루트의 진입 파일에 덮어쓴다 +3. **명시되지 않은 경우**: `agent-ops/bin/sync.sh` 실행 +4. target이 없으면 `sync.sh`가 현재 프로젝트 기준 상위 폴더(`../`)의 하위 디렉터리 중 `agent-ops/` 폴더가 있는 프로젝트를 모두 대상으로 삼는다 +5. `sync.sh`는 현재 agentic-framework의 `agent-ops/rules/common/rules.md` 내용을 대상 프로젝트 루트의 진입 파일에 덮어쓴다 +6. 적용 후 각 대상 repo에서 agent-ops 공통 관리 경로와 진입 파일만 stage 하여 commit/push 한다 덮어쓰기 대상은 `init-agent-ops` 초기 세팅과 동일하며, 실제 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. @@ -46,14 +49,17 @@ description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거 대상 프로젝트에 기존 진입 파일이 있어도 보존하거나 병합하지 않고 `agent-ops/rules/common/rules.md` 내용으로 교체한다. ```bash -agent-ops/bin/sync.sh +agent-ops/bin/sync.sh [target] ``` +푸시 대상 path는 항상 `agent-ops/.version`, `agent-ops/bin`, `agent-ops/rules/common`, `agent-ops/skills/common`과 `AGENT_OPS_ENTRY_FILES`의 진입 파일로 제한한다. + ## 실행 결과 검증 - [ ] sync.sh 가 오류 없이 완료됐는가 - [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다 - [ ] agentic-framework에서 대상 프로젝트로 push한 경우, `agent-ops/bin/entry-files.sh`의 모든 진입 파일 내용이 현재 agentic-framework의 `agent-ops/rules/common/rules.md`와 일치하는가 +- [ ] 대상 repo에 commit/push 된 path가 agent-ops 공통 관리 경로와 진입 파일로 제한되었는가 ## 금지 사항 From 4b887bd1d3889649012ccf130c0cb82a98fdbea7 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 11:32:54 +0900 Subject: [PATCH 07/12] sync: agent-ops from agentic-framework v1.1.15 --- agent-ops/skills/common/code-review/SKILL.md | 5 +++- agent-ops/skills/common/plan/SKILL.md | 25 ++++++++++++-------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md index 006174c..45add84 100644 --- a/agent-ops/skills/common/code-review/SKILL.md +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -29,7 +29,10 @@ Filename rules: Multi-plan runtime contract: - Multi-plan work is represented as multiple task directories. Each directory owns exactly one normal active plan file and one normal active review file. -- Directory names may encode runtime scheduling metadata. Preserve them verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. +- Multi-plan task directory names encode runtime scheduling metadata: + - `NN_{task_name}` has no runtime dependencies. + - `NN+PP[,QQ...]_{task_name}` depends on the listed earlier task indices. +- Directory names are the runtime dependency source of truth. Preserve them verbatim; do not normalize, reinterpret, infer extra dependencies from numeric order, or choose execution order by agent judgment. - If the user/runtime names a task directory, review that directory even when other active review files exist. Review routing rules: diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index 7874d89..7406350 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -31,14 +31,19 @@ Task directory naming rules: - A single-plan task uses `agent-task/{task_name}/` with a short snake_case task name. - If one plan is not enough, split the work into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. - Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across multiple folders, not multiple plan files inside one folder. -- Multi-plan task directory names must start with the current task's execution-order index: `01_{task_name}`, `02_{task_name}`, `03_{task_name}`, and so on. The leading number is always the current task index and must increase across sibling task directories. -- Use `_` after the index for a task that can start independently at that point: `01_{task_name}`, `03_{task_name}`. -- Use `+` instead of `_` after the index for a task that depends on an earlier task: `02+{task_name}`. Do not put predecessor numbers in the directory name. -- Record the exact predecessor task directory or directories in the plan's `의존 관계 및 구현 순서` section. -- Example: split a common core plus two app integrations as `01_core`, `02+edge_integration`, `03+node_integration`. In both `02+...` and `03+...`, write that they depend on `agent-task/01_core`. The `+` marker does not mean `03+...` depends on `02+...`; if `03+...` also depends on `02+...`, say so explicitly in `의존 관계 및 구현 순서`. -- Example: split three sequential tasks as `01_schema`, `02+migration`, `03+api`. In `02+migration`, list `agent-task/01_schema` as predecessor. In `03+api`, list `agent-task/02+migration` as predecessor. -- Directory names are runtime scheduling metadata. Preserve them verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. -- Every split plan must include `의존 관계 및 구현 순서` and list predecessor task directories that must reach `complete.log` before implementation begins. +- Multi-plan task directory names must start with a stable two-digit task index. The index must increase across sibling task directories for sorting, but it is not a serial execution dependency. +- Use `NN_{task_name}` for a task with no runtime dependencies, e.g. `01_core`, `04_docs`, `05_ui`. +- Use `NN+PP[,QQ...]_{task_name}` for a task that depends on earlier task indices, e.g. `02+01_db`, `03+01,02_api`, `06+05_integration`. +- Valid independent pattern: `^[0-9]{2}_[a-z0-9_]+$`. +- Valid dependent pattern: `^[0-9]{2}\+[0-9]{2}(,[0-9]{2})*_[a-z0-9_]+$`. +- `NN`, `PP`, and `QQ` are two-digit indices. Every predecessor index after `+` must be lower than `NN` and must refer to a sibling multi-plan task directory. +- The first `_` after the index or dependency list starts `{task_name}`. `{task_name}` stays short snake_case and may contain additional underscores. +- Runtime scheduling reads only the directory name: `_` means `depends_on=[]`; `+` means `depends_on` is the comma-separated index list between `+` and the first `_`. +- Directory names are the source of truth for runtime dependencies. Do not hide extra dependencies only in the plan body, and do not create a bare `NN+{task_name}` without predecessor indices. +- Example: split a common core plus two app integrations as `01_core`, `02+01_edge_integration`, `03+01_node_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`. +- Example: split three sequential tasks as `01_schema`, `02+01_migration`, `03+02_api`. +- Example: split independent docs/UI plus an integration as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`. +- Preserve task directory names verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. Routing rules: @@ -154,7 +159,7 @@ Each plan item must include: Include `의존 관계 및 구현 순서` only when order matters. -For split multi-plan work, `의존 관계 및 구현 순서` is mandatory in every generated plan. If a plan has a `NN+...` directory name, state which predecessor task directory or directories must produce `complete.log` before implementation starts. +For split multi-plan work, the directory name is the runtime source of truth. If a plan has a `NN+PP[,QQ...]_...` directory name, `의존 관계 및 구현 순서` must echo the decoded predecessor task directories that must produce `complete.log` before implementation starts, and it must not add dependencies that are absent from the directory name. Quality rules: @@ -312,7 +317,7 @@ Sections and their ownership: ## Final Checklist - `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. -- Split work, if any, uses one task directory per plan/review pair with names like `01_core`, `02+edge_integration`, `03+node_integration`; predecessor details live in `의존 관계 및 구현 순서`, not in the directory name. +- Split work, if any, uses one task directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the directory name as `NN+PP[,QQ...]_task_name`. - Both first lines match ``. - Previous active files, if any, were archived with correct numeric suffixes. - Every plan item has problem, solution, checklist, test decision, and intermediate verification. From fa65adcb39ed69d9c819307f912755f2bc6d086b Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 12:11:09 +0900 Subject: [PATCH 08/12] sync: agent-ops from agentic-framework v1.1.16 --- agent-ops/.version | 2 +- agent-ops/skills/common/code-review/SKILL.md | 42 +++++++------------ .../common/code-review/agents/openai.yaml | 4 +- agent-ops/skills/common/plan/SKILL.md | 14 +++---- agent-ops/skills/common/router.md | 2 +- 5 files changed, 24 insertions(+), 40 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 645377e..63b283b 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.15 +1.1.16 diff --git a/agent-ops/skills/common/code-review/SKILL.md b/agent-ops/skills/common/code-review/SKILL.md index 45add84..559239f 100644 --- a/agent-ops/skills/common/code-review/SKILL.md +++ b/agent-ops/skills/common/code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: code-review -description: Review completed implementation work in the current repository. Reads PLAN-{build_lane}-GNN.md and CODE_REVIEW-{review_lane}-GNN.md from the active task, reviews the actual changed source files, appends a verdict to the review file, then archives both files as .log. On PASS, writes complete.log summarising the full loop history and moves the completed task under agent-task/archive/YYYY/MM/. If Required or Suggested issues are found (FAIL or WARN), writes new routed plan/review files so the loop continues immediately. Nit-only findings may still PASS. +description: Use for active task review requests such as 리뷰 진행해, 리뷰해줘, 코드 리뷰해줘, code review, CODE_REVIEW.md, or 리뷰 루프. Review the active PLAN/CODE_REVIEW pair, append PASS/WARN/FAIL, archive both active files, and create the required next state: PASS writes complete.log and moves the task to archive; WARN/FAIL immediately writes follow-up PLAN/CODE_REVIEW files. Never stop after verdict append. --- # Code Review @@ -15,6 +15,14 @@ plan skill -> implementation -> code-review skill +----- issues found: new routed plan/review files ``` +## Core Loop Rules + +- Trigger: Korean or English active-task review requests, including `리뷰 진행해` and `리뷰해줘`, must use this skill when `agent-task/*/CODE_REVIEW-*-G??.md` exists. +- Finalize every selected active review: append one verdict, archive the active review and plan files, then create exactly one next state before reporting. +- Next state: `PASS` writes `complete.log` and moves the task under `agent-task/archive/YYYY/MM/`; `WARN` or `FAIL` writes the next active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md`. +- Do not ask for confirmation before WARN/FAIL follow-up files. If details are uncertain, write the smallest concrete follow-up plan with file references and verification commands. +- Recovery: if a prior turn appended a verdict without archive or next-state files, resume at Step 5 and finish finalization first. + ## Workflow Contract Active work must live under `agent-task/{task_name}/` using routed filenames. This is the state protocol shared with the plan skill. @@ -54,17 +62,6 @@ Directory states: The implementing agent never archives or deletes active files; archiving is this skill's responsibility. -Finalization invariant: - -- Every review attempt that selects an active review file must end by archiving the active `CODE_REVIEW-*-G??.md` and `PLAN-*-G??.md` files. -- A review is not complete when the verdict is appended. It is complete only after the required archive files and next-state files exist. -- After archiving, exactly one next state is allowed: - - `PASS`: write `complete.log`, move `agent-task/{task_name}/` to `agent-task/archive/YYYY/MM/{task_name}/`, then complete the review-only checklist in the final archive path. - - `WARN` or `FAIL`: write the next active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md`; do not write `complete.log`. -- Never stop after appending a verdict. Do not report to the user until archive, required next-state file writes, review-only checklist updates, and PASS task-directory archive moves are complete. -- If the review result feels ambiguous, choose `WARN` or `FAIL` according to the severity rules, archive the current files, and write the next plan/review pair. Ambiguity is not a reason to leave active files unarchived. -- If you notice that you already reported after only appending `PASS`, `WARN`, or `FAIL`, resume immediately: archive the active files, write `complete.log` or the follow-up plan/review pair, update the archived review checklist, then correct the user-facing report. - ## Step 1 - Find Active Task Glob `agent-task/*/CODE_REVIEW-*-G??.md`: @@ -151,16 +148,11 @@ Complete log template: - Use `없음` for empty `잔여 Nit` or `후속 작업`. - A PASS `complete.log` must not contain unresolved Required or Suggested issues. Nit-only leftovers may be recorded under `잔여 Nit`. -After Step 7, report: - -- Verdict. -- Archive filenames. -- Final task archive path under `agent-task/archive/YYYY/MM/`. - For `WARN` or `FAIL`, write new routed plan/review files using the plan skill format: - New plan number is the count of `plan_*.log` after archive. - Header tag is `REVIEW_`. +- Base the follow-up scope directly on the archived review findings. Keep it narrow and actionable. - Choose lane/grade again; preserve the prior route only when it was adequate, otherwise raise `GNN` and/or move `local -> cloud`. - Before choosing the follow-up route, apply this escalation gate: - If the archived plan was `local-*` and the verdict is `FAIL` for correctness, completeness, test coverage, or verification trust, move the follow-up build lane to `cloud` unless the issue is trivially deterministic and review-detectable without live environment behavior. @@ -194,16 +186,12 @@ task={task_name}, plan={N}, tag={TAG} ## 이 파일을 읽는 리뷰 에이전트에게 각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -Review 완료 후 반드시 아래 순서로 아카이브하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. -1. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log` (N = 기존 code_review_*.log 수) -2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) -3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. - -판정 append만으로 review를 끝내면 안 됩니다. `PASS`, `WARN`, `FAIL` 모두 active plan/review 파일을 `.log`로 전환한 뒤 다음 상태 파일까지 만든 후에만 보고하세요. -어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. -PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. -WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 다음 active plan/review 파일을 즉시 작성한다. +4. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. --- diff --git a/agent-ops/skills/common/code-review/agents/openai.yaml b/agent-ops/skills/common/code-review/agents/openai.yaml index d45d468..23c51f4 100644 --- a/agent-ops/skills/common/code-review/agents/openai.yaml +++ b/agent-ops/skills/common/code-review/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Code Review" - short_description: "Review task implementation" - default_prompt: "Use $code-review to review the active repository task and archive or continue the loop." + short_description: "Review and continue task loops" + default_prompt: "Use $code-review to review the active repository task, archive active files, and create complete.log or follow-up plan/review files." diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index 7406350..9568111 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -215,16 +215,12 @@ task={task_name}, plan={N}, tag={TAG} ## 이 파일을 읽는 리뷰 에이전트에게 각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. -review 완료 후 반드시 아래 순서로 아카이브하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. -1. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log` (N = 기존 code_review_*.log 수) -2. `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log` (M = 기존 plan_*.log 수) -3. PASS인 경우 `complete.log` 작성 후 `agent-task/archive/YYYY/MM/{task_name}/`로 task 디렉터리 이동. WARN/FAIL인 경우 새 routed plan + review 스텁 작성. - -판정 append만으로 review를 끝내면 안 됩니다. `PASS`, `WARN`, `FAIL` 모두 active plan/review 파일을 `.log`로 전환한 뒤 다음 상태 파일까지 만든 후에만 보고하세요. -어떤 판정에서도 아카이브를 건너뛰지 마세요. PASS/WARN/FAIL 모두 `코드리뷰 결과` append 후 active plan/review 파일을 먼저 아카이브하고, 그 다음 `complete.log` 또는 다음 plan/review 파일을 작성해야 합니다. -PASS에서는 `agent-ops/skills/common/code-review/templates/complete-log-template.md`의 섹션 순서와 필수 항목을 기준으로 `complete.log`를 작성하세요. 작성 후 현재 날짜의 `YYYY/MM` 기준으로 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고, 최종 archive 경로의 `code_review_*.log`에서 `코드리뷰 전용 체크리스트`를 갱신한 다음 보고하세요. -WARN/FAIL에서는 다음 상태 파일 작성 후 현재 task 경로의 archived `code_review_*.log`에서 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 체크한 다음 보고하세요. +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. WARN/FAIL이면 다음 active plan/review 파일을 즉시 작성한다. +4. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. --- diff --git a/agent-ops/skills/common/router.md b/agent-ops/skills/common/router.md index 49ce8d2..daa9cd0 100644 --- a/agent-ops/skills/common/router.md +++ b/agent-ops/skills/common/router.md @@ -6,7 +6,7 @@ | domain rule 만들어줘, rules.md 생성, 새 도메인 규칙 | `agent-ops/skills/common/create-domain-rule/SKILL.md` | | skill 만들어줘, SKILL.md 생성, 새 스킬 추가 | `agent-ops/skills/common/create-skill/SKILL.md` | | 계획 세워줘, 구현 계획, PLAN.md, plan | `agent-ops/skills/common/plan/SKILL.md` | -| 코드 리뷰해줘, code review, CODE_REVIEW.md, 리뷰 루프 | `agent-ops/skills/common/code-review/SKILL.md` | +| 코드 리뷰해줘, 리뷰 진행해, 리뷰해줘, code review, CODE_REVIEW.md, 리뷰 루프 | `agent-ops/skills/common/code-review/SKILL.md` | | 커밋해줘, 푸시해줘, commit, push, 반영해줘 | `agent-ops/skills/common/commit-push/SKILL.md` | | agent-ops 싱크해, agent-ops 동기화해, agentic-framework에 올려줘, agent-ops를 [프로젝트]로 싱크해 | `agent-ops/skills/common/sync-push/SKILL.md` | | agent-ops pull해, agent-ops 가져와, agentic-framework에서 가져와, agent-ops 내려받아 | `agent-ops/skills/common/sync-pull/SKILL.md` | From b585b5d13484e4edd8b4c921088bde9196234f9e Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 21:09:23 +0900 Subject: [PATCH 09/12] update: add a2a/openai adapters, agent module, model, config tests, and scheduler updates --- .antigravityignore | 1 + .gitignore | 3 + Makefile | 8 + README.md | 26 ++- bin/run | 11 + cmd/server/main.go | 22 +- docker-compose.yml | 9 + internal/adapters/a2a/client.go | 299 ++++++++++++++++++++++++ internal/adapters/a2a/client_test.go | 162 +++++++++++++ internal/adapters/openai/client.go | 266 +++++++++++++++++++++ internal/adapters/openai/client_test.go | 140 +++++++++++ internal/agent/agent.go | 104 +++++++++ internal/config/config.go | 44 +++- internal/config/config_test.go | 53 +++++ internal/model/model.go | 34 +++ internal/scheduler/jobs.go | 233 +++++++++++++++++- internal/scheduler/jobs_test.go | 108 +++++++++ internal/scheduler/river.go | 6 +- 18 files changed, 1515 insertions(+), 14 deletions(-) create mode 100644 .antigravityignore create mode 100644 internal/adapters/a2a/client.go create mode 100644 internal/adapters/a2a/client_test.go create mode 100644 internal/adapters/openai/client.go create mode 100644 internal/adapters/openai/client_test.go create mode 100644 internal/agent/agent.go create mode 100644 internal/config/config_test.go create mode 100644 internal/model/model.go create mode 100644 internal/scheduler/jobs_test.go diff --git a/.antigravityignore b/.antigravityignore new file mode 100644 index 0000000..477be46 --- /dev/null +++ b/.antigravityignore @@ -0,0 +1 @@ +agent-task/archive/** diff --git a/.gitignore b/.gitignore index d008d13..583fe3e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ coverage.out # Agent-Ops Private Rules agent-ops/rules/private/ + +# AI/IDE Cache +/.antigravitycli/ diff --git a/Makefile b/Makefile index b776e46..ae0de94 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,14 @@ export REDIS_KEY_PREFIX ?= nomadcode-core:local export DEV_REDIS_URL ?= redis://code-server-redis:6379/4 export DEV_REDIS_KEY_PREFIX ?= nomadcode-core:dev export AUTH_USERNAME ?= nomadcode +export MODEL_BASE_URL ?= http://192.168.0.91:11434 +export MODEL_API_KEY ?= ollama +export MODEL_NAME ?= qwen3.6:35b-a3b-bf16 +export MODEL_CONTEXT_SIZE ?= 262144 +export MODEL_TIMEOUT_SEC ?= 300 +export A2A_EDGE_URL ?= +export A2A_AGENT_URL ?= +export A2A_TIMEOUT_SEC ?= 300 export GOOSE_DRIVER ?= postgres export GOOSE_DBSTRING ?= $(DATABASE_URL) export OUTPUT ?= .build/nomadcode-core diff --git a/README.md b/README.md index b7b2e5d..acdd333 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - PostgreSQL 연결 - goose migration - sqlc 기반 DB query 생성 구조 -- River dummy job +- River task job +- IOP Edge/OpenAI-compatible 모델 호출 경로(Ollama direct fallback 가능) +- IOP Edge/A2A-compatible agent 호출 인터페이스 - Plane Adapter stub - Mattermost Adapter stub - 선택적 Docker Compose 실행 환경(PostgreSQL, Redis) @@ -21,7 +23,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - 실제 Plane API 호출 - 실제 Mattermost 메시지 발송 -- IOP 연동 +- IOP native protocol 연동 - Agent Integrator 연동 - Outline / Forgejo / Nextcloud 연동 - MCP 서버 @@ -39,7 +41,7 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 목표: - 서버 실행 골격 구성 - task 저장 구조 구성 -- dummy 비동기 job 실행 +- 로컬 모델 호출 기반 비동기 job 실행 - Adapter stub 생성 ### Phase 2. Workflow Core @@ -57,10 +59,12 @@ NomadCode Core는 사용자 요청을 작업 단위로 받고, 작업 상태를 - Mattermost 메시지 발송 구현 - Agent Integrator 호출 구조 추가 - IOP 호출 구조 추가 +- IOP의 OpenAI-compatible API 경로는 단순 모델/chat completion 호환 호출에 사용 +- IOP의 A2A JSON-RPC 경로는 Core가 Edge 실행 그룹이나 외부 agent에게 작업을 위임하고 task 상태/artifact/cancel 흐름을 공유할 때 사용 ## 실행 방법 -로컬 실행은 현재 개발 호스트의 Go와 `code-server` compose에 붙은 PostgreSQL/Redis가 있다는 전제로 진행합니다. local 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable` 이고, local 기본 `REDIS_URL`은 `redis://code-server-redis:6379/3`, `REDIS_KEY_PREFIX`는 `nomadcode-core:local` 입니다. dev 배포는 Docker Compose로 실행하며, 같은 `code-server-postgres` Postgres와 `code-server-redis` Redis를 사용합니다. dev DB명은 `nomad-core-dev` 이고, dev 기본 `REDIS_URL`은 `redis://code-server-redis:6379/4`, `REDIS_KEY_PREFIX`는 `nomadcode-core:dev` 입니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. +로컬 실행은 현재 개발 호스트의 Go와 `code-server` compose에 붙은 PostgreSQL/Redis가 있다는 전제로 진행합니다. local 기본 `DATABASE_URL`은 `postgres://nomadcode:nomadcode@code-server-postgres:5432/nomadcode-core-local?sslmode=disable` 이고, local 기본 `REDIS_URL`은 `redis://code-server-redis:6379/3`, `REDIS_KEY_PREFIX`는 `nomadcode-core:local` 입니다. dev 배포는 Docker Compose로 실행하며, 같은 `code-server-postgres` Postgres와 `code-server-redis` Redis를 사용합니다. dev DB명은 `nomad-core-dev` 이고, dev 기본 `REDIS_URL`은 `redis://code-server-redis:6379/4`, `REDIS_KEY_PREFIX`는 `nomadcode-core:dev` 입니다. `AUTH_PASSWORD`를 설정하면 `/readyz`와 `/api/*`에 HTTP Basic Auth가 적용됩니다. 모델 호출의 장기 대상은 IOP Edge의 OpenAI-compatible input surface이며, 현재 core model client는 Responses API 형식을 사용합니다. fallback 기본값은 direct Ollama `MODEL_BASE_URL=http://192.168.0.91:11434`, `MODEL_NAME=qwen3.6:35b-a3b-bf16`, `MODEL_CONTEXT_SIZE=262144`, `MODEL_TIMEOUT_SEC=300` 입니다. A2A agent 호출 endpoint는 `A2A_EDGE_URL`로 설정하고, 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. bearer token은 `A2A_TOKEN`, timeout은 `A2A_TIMEOUT_SEC`로 설정합니다. `code-server` PostgreSQL 컨테이너에 DB를 생성하는 예시: @@ -95,6 +99,20 @@ DATABASE_URL="postgres://user:password@localhost:5432/dbname?sslmode=disable" ./ DATABASE_URL="postgres://user:password@localhost:5432/dbname?sslmode=disable" ./bin/run ``` +다른 모델 endpoint를 사용할 경우: + +```bash +MODEL_BASE_URL="http://localhost:11434" \ +MODEL_NAME="qwen3.6:35b-a3b-bf16" \ +MODEL_CONTEXT_SIZE="262144" \ +MODEL_TIMEOUT_SEC="300" \ +./bin/run +``` + +모델 호출은 OpenAI-compatible Responses API의 non-streaming `POST /v1/responses` 형식을 사용합니다. IOP Edge OpenAI-compatible listener를 `MODEL_BASE_URL`로 쓰려면 해당 listener가 `/v1/responses`를 제공해야 합니다. direct Ollama fallback에서는 `MODEL_CONTEXT_SIZE`를 Ollama 전용 option인 `options.num_ctx`로 전달합니다. + +A2A agent 호출 인터페이스는 JSON-RPC 2.0 `message/send`, `tasks/get`, `tasks/cancel`을 우선 지원합니다. `A2A_EDGE_URL`을 설정하면 worker는 A2A `message/send`를 blocking 호출하고, 완료된 task/message 응답만 local task completion으로 반영합니다. 기존 `A2A_AGENT_URL`도 fallback alias로 받습니다. 설정하지 않으면 기존 OpenAI-compatible 모델 호출 경로를 사용합니다. + 테스트 실행: ```bash diff --git a/bin/run b/bin/run index d7e8248..d5638de 100755 --- a/bin/run +++ b/bin/run @@ -11,6 +11,11 @@ legacy_code_server_database_url="postgres://nomadcode:nomadcode@code-server-post default_redis_url="redis://code-server-redis:6379/3" legacy_code_server_redis_url="redis://code-server-redis:6379/0" default_redis_key_prefix="nomadcode-core:local" +default_model_base_url="http://192.168.0.91:11434" +default_model_name="qwen3.6:35b-a3b-bf16" +default_model_context_size="262144" +default_model_timeout_sec="300" +default_a2a_timeout_sec="300" # code-server exports a shared DATABASE_URL; remap that default to this project's local DB. if [[ "${DATABASE_URL:-}" == "$legacy_code_server_database_url" ]]; then export DATABASE_URL="$default_database_url" @@ -25,5 +30,11 @@ else fi export REDIS_KEY_PREFIX="${REDIS_KEY_PREFIX:-$default_redis_key_prefix}" export AUTH_USERNAME="${AUTH_USERNAME:-nomadcode}" +export MODEL_BASE_URL="${MODEL_BASE_URL:-$default_model_base_url}" +export MODEL_API_KEY="${MODEL_API_KEY:-ollama}" +export MODEL_NAME="${MODEL_NAME:-$default_model_name}" +export MODEL_CONTEXT_SIZE="${MODEL_CONTEXT_SIZE:-$default_model_context_size}" +export MODEL_TIMEOUT_SEC="${MODEL_TIMEOUT_SEC:-$default_model_timeout_sec}" +export A2A_TIMEOUT_SEC="${A2A_TIMEOUT_SEC:-$default_a2a_timeout_sec}" exec go run ./cmd/server "$@" diff --git a/cmd/server/main.go b/cmd/server/main.go index 54bf5f6..bae1e1f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,7 +10,10 @@ import ( "syscall" "time" + agenta2a "github.com/nomadcode/nomadcode-core/internal/adapters/a2a" "github.com/nomadcode/nomadcode-core/internal/adapters/mattermost" + modelopenai "github.com/nomadcode/nomadcode-core/internal/adapters/openai" + "github.com/nomadcode/nomadcode-core/internal/agent" "github.com/nomadcode/nomadcode-core/internal/config" "github.com/nomadcode/nomadcode-core/internal/db" apphttp "github.com/nomadcode/nomadcode-core/internal/http" @@ -46,8 +49,25 @@ func run(logger *slog.Logger) error { Token: cfg.MattermostToken, }, logger) notificationService := notification.NewService(mattermostClient, logger) + modelClient := modelopenai.NewClient(modelopenai.Config{ + BaseURL: cfg.ModelBaseURL, + APIKey: cfg.ModelAPIKey, + Model: cfg.ModelName, + ContextSize: cfg.ModelContextSize, + TimeoutSec: cfg.ModelTimeoutSec, + }, logger) - taskScheduler, err := scheduler.New(pool, store, notificationService, logger) + var agentClient agent.Client + if cfg.A2AEdgeURL != "" { + agentClient = agenta2a.NewClient(agenta2a.Config{ + URL: cfg.A2AEdgeURL, + Token: cfg.A2AToken, + TimeoutSec: cfg.A2ATimeoutSec, + }, logger) + logger.Info("a2a edge input enabled", "url", cfg.A2AEdgeURL) + } + + taskScheduler, err := scheduler.New(pool, store, notificationService, agentClient, modelClient, logger) if err != nil { return err } diff --git a/docker-compose.yml b/docker-compose.yml index 515e597..6e27682 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,15 @@ services: REDIS_KEY_PREFIX: ${DEV_REDIS_KEY_PREFIX:-nomadcode-core:dev} AUTH_USERNAME: ${AUTH_USERNAME:-nomadcode} AUTH_PASSWORD: ${AUTH_PASSWORD:-} + MODEL_BASE_URL: ${MODEL_BASE_URL:-http://192.168.0.91:11434} + MODEL_API_KEY: ${MODEL_API_KEY:-ollama} + MODEL_NAME: ${MODEL_NAME:-qwen3.6:35b-a3b-bf16} + MODEL_CONTEXT_SIZE: ${MODEL_CONTEXT_SIZE:-262144} + MODEL_TIMEOUT_SEC: ${MODEL_TIMEOUT_SEC:-300} + A2A_EDGE_URL: ${A2A_EDGE_URL:-} + A2A_AGENT_URL: ${A2A_AGENT_URL:-} + A2A_TOKEN: ${A2A_TOKEN:-} + A2A_TIMEOUT_SEC: ${A2A_TIMEOUT_SEC:-300} MATTERMOST_BASE_URL: "" MATTERMOST_TOKEN: "" PLANE_BASE_URL: "" diff --git a/internal/adapters/a2a/client.go b/internal/adapters/a2a/client.go new file mode 100644 index 0000000..5749a12 --- /dev/null +++ b/internal/adapters/a2a/client.go @@ -0,0 +1,299 @@ +package a2a + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + "github.com/nomadcode/nomadcode-core/internal/agent" +) + +const ( + defaultTimeoutSec = 300 + jsonRPCVersion = "2.0" +) + +type Config struct { + URL string + Token string + TimeoutSec int +} + +type Client struct { + cfg Config + httpClient *http.Client + logger *slog.Logger + nextID atomic.Uint64 +} + +func NewClient(cfg Config, logger *slog.Logger) *Client { + if cfg.TimeoutSec <= 0 { + cfg.TimeoutSec = defaultTimeoutSec + } + return &Client{ + cfg: cfg, + httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}, + logger: logger, + } +} + +func (c *Client) SendMessage(ctx context.Context, input agent.SendMessageInput) (agent.SendMessageResult, error) { + if strings.TrimSpace(input.Text) == "" { + return agent.SendMessageResult{}, fmt.Errorf("a2a message text is required") + } + + messageID := newMessageID() + msg := agent.Message{ + Kind: "message", + Role: "user", + MessageID: messageID, + ContextID: input.ContextID, + TaskID: input.TaskID, + Parts: []agent.Part{{ + Kind: "text", + Text: input.Text, + }}, + } + + params := messageSendParams{ + Message: msg, + Configuration: &messageSendConfiguration{ + AcceptedOutputModes: input.AcceptedOutputModes, + HistoryLength: input.HistoryLength, + Blocking: input.Blocking, + }, + Metadata: input.Metadata, + } + if params.Configuration.empty() { + params.Configuration = nil + } + + raw, err := c.call(ctx, "message/send", params) + if err != nil { + return agent.SendMessageResult{}, err + } + result, err := decodeMessageSendResult(raw) + if err != nil { + return agent.SendMessageResult{}, err + } + result.Raw = raw + return result, nil +} + +func (c *Client) GetTask(ctx context.Context, input agent.GetTaskInput) (agent.Task, error) { + if strings.TrimSpace(input.TaskID) == "" { + return agent.Task{}, fmt.Errorf("a2a task id is required") + } + raw, err := c.call(ctx, "tasks/get", taskIDParams{ + ID: input.TaskID, + HistoryLength: input.HistoryLength, + Metadata: input.Metadata, + }) + if err != nil { + return agent.Task{}, err + } + var task agent.Task + if err := json.Unmarshal(raw, &task); err != nil { + return agent.Task{}, fmt.Errorf("decode a2a task: %w", err) + } + return task, nil +} + +func (c *Client) CancelTask(ctx context.Context, input agent.CancelTaskInput) (agent.Task, error) { + if strings.TrimSpace(input.TaskID) == "" { + return agent.Task{}, fmt.Errorf("a2a task id is required") + } + raw, err := c.call(ctx, "tasks/cancel", taskIDParams{ + ID: input.TaskID, + Metadata: input.Metadata, + }) + if err != nil { + return agent.Task{}, err + } + var task agent.Task + if err := json.Unmarshal(raw, &task); err != nil { + return agent.Task{}, fmt.Errorf("decode canceled a2a task: %w", err) + } + return task, nil +} + +type messageSendParams struct { + Message agent.Message `json:"message"` + Configuration *messageSendConfiguration `json:"configuration,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type messageSendConfiguration struct { + AcceptedOutputModes []string `json:"acceptedOutputModes,omitempty"` + HistoryLength int `json:"historyLength,omitempty"` + Blocking bool `json:"blocking,omitempty"` +} + +func (c *messageSendConfiguration) empty() bool { + return c == nil || (len(c.AcceptedOutputModes) == 0 && c.HistoryLength == 0 && !c.Blocking) +} + +type taskIDParams struct { + ID string `json:"id"` + HistoryLength int `json:"historyLength,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID string `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) { + endpoint, err := endpointURL(c.cfg.URL) + if err != nil { + return nil, err + } + + requestID := c.requestID() + payload, err := json.Marshal(jsonRPCRequest{ + JSONRPC: jsonRPCVersion, + ID: requestID, + Method: method, + Params: params, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if c.cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + } + + if c.logger != nil { + c.logger.Info("a2a json-rpc request", "method", method, "endpoint", endpoint) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("a2a request failed: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var rpcResp jsonRPCResponse + if err := json.Unmarshal(body, &rpcResp); err != nil { + return nil, fmt.Errorf("decode a2a json-rpc response: %w", err) + } + if rpcResp.Error != nil { + return nil, fmt.Errorf("a2a json-rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + if rpcResp.ID != "" && rpcResp.ID != requestID { + return nil, fmt.Errorf("a2a response id mismatch: got %q want %q", rpcResp.ID, requestID) + } + if len(rpcResp.Result) == 0 { + return nil, fmt.Errorf("a2a response result is empty") + } + return rpcResp.Result, nil +} + +func (c *Client) requestID() string { + return fmt.Sprintf("nomadcode-%d", c.nextID.Add(1)) +} + +func decodeMessageSendResult(raw json.RawMessage) (agent.SendMessageResult, error) { + var envelope struct { + Kind string `json:"kind"` + } + _ = json.Unmarshal(raw, &envelope) + + switch envelope.Kind { + case "message": + var msg agent.Message + if err := json.Unmarshal(raw, &msg); err != nil { + return agent.SendMessageResult{}, fmt.Errorf("decode a2a message: %w", err) + } + return agent.SendMessageResult{Message: &msg}, nil + case "task": + var task agent.Task + if err := json.Unmarshal(raw, &task); err != nil { + return agent.SendMessageResult{}, fmt.Errorf("decode a2a task: %w", err) + } + return agent.SendMessageResult{Task: &task}, nil + } + + var probe map[string]json.RawMessage + if err := json.Unmarshal(raw, &probe); err != nil { + return agent.SendMessageResult{}, fmt.Errorf("decode a2a result: %w", err) + } + if _, ok := probe["status"]; ok { + var task agent.Task + if err := json.Unmarshal(raw, &task); err != nil { + return agent.SendMessageResult{}, fmt.Errorf("decode a2a task: %w", err) + } + return agent.SendMessageResult{Task: &task}, nil + } + if _, ok := probe["role"]; ok { + var msg agent.Message + if err := json.Unmarshal(raw, &msg); err != nil { + return agent.SendMessageResult{}, fmt.Errorf("decode a2a message: %w", err) + } + return agent.SendMessageResult{Message: &msg}, nil + } + return agent.SendMessageResult{}, fmt.Errorf("a2a result is neither message nor task") +} + +func endpointURL(rawURL string) (string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "", fmt.Errorf("a2a endpoint URL is required") + } + parsed, err := url.Parse(rawURL) + if err != nil { + return "", err + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("a2a endpoint URL must include scheme and host") + } + return parsed.String(), nil +} + +func newMessageID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err == nil { + return "msg-" + hex.EncodeToString(b[:]) + } + return fmt.Sprintf("msg-%d", time.Now().UnixNano()) +} diff --git a/internal/adapters/a2a/client_test.go b/internal/adapters/a2a/client_test.go new file mode 100644 index 0000000..c298761 --- /dev/null +++ b/internal/adapters/a2a/client_test.go @@ -0,0 +1,162 @@ +package a2a + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/agent" +) + +func TestSendMessagePostsJSONRPCMessageSend(t *testing.T) { + var gotAuth string + var gotReq jsonRPCRequest + var gotParams map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("decode request: %v", err) + } + paramsBytes, _ := json.Marshal(gotReq.Params) + if err := json.Unmarshal(paramsBytes, &gotParams); err != nil { + t.Fatalf("decode params: %v", err) + } + _, _ = w.Write([]byte(`{ + "jsonrpc": "2.0", + "id": "nomadcode-1", + "result": { + "kind": "task", + "id": "task-1", + "contextId": "ctx-1", + "status": {"state": "working"} + } + }`)) + })) + defer server.Close() + + client := NewClient(Config{URL: server.URL, Token: "secret"}, nil) + result, err := client.SendMessage(context.Background(), agent.SendMessageInput{ + Text: "fix issue", + ContextID: "ctx-1", + AcceptedOutputModes: []string{"text/plain"}, + Blocking: true, + Metadata: map[string]any{ + "nomad_task_id": "task-local", + }, + }) + if err != nil { + t.Fatalf("SendMessage returned error: %v", err) + } + + if gotAuth != "Bearer secret" { + t.Fatalf("unexpected auth header: %q", gotAuth) + } + if gotReq.JSONRPC != "2.0" || gotReq.Method != "message/send" || gotReq.ID != "nomadcode-1" { + t.Fatalf("unexpected json-rpc request: %#v", gotReq) + } + message := gotParams["message"].(map[string]any) + if message["role"] != "user" || message["kind"] != "message" || message["contextId"] != "ctx-1" { + t.Fatalf("unexpected message: %#v", message) + } + parts := message["parts"].([]any) + part := parts[0].(map[string]any) + if part["kind"] != "text" || part["text"] != "fix issue" { + t.Fatalf("unexpected part: %#v", part) + } + config := gotParams["configuration"].(map[string]any) + if config["blocking"] != true { + t.Fatalf("expected blocking=true, got %#v", config["blocking"]) + } + metadata := gotParams["metadata"].(map[string]any) + if metadata["nomad_task_id"] != "task-local" { + t.Fatalf("unexpected metadata: %#v", metadata) + } + if result.Task == nil || result.Task.ID != "task-1" || result.Task.Status.State != agent.TaskStateWorking { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestSendMessageReturnsDirectMessage(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{ + "jsonrpc": "2.0", + "id": "nomadcode-1", + "result": { + "kind": "message", + "role": "agent", + "messageId": "msg-2", + "parts": [{"kind": "text", "text": "done"}] + } + }`)) + })) + defer server.Close() + + client := NewClient(Config{URL: server.URL}, nil) + result, err := client.SendMessage(context.Background(), agent.SendMessageInput{Text: "status"}) + if err != nil { + t.Fatalf("SendMessage returned error: %v", err) + } + if result.Message == nil || result.Message.Role != "agent" || result.Message.Parts[0].Text != "done" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestGetAndCancelTask(t *testing.T) { + var methods []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req jsonRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + methods = append(methods, req.Method) + _, _ = w.Write([]byte(`{ + "jsonrpc": "2.0", + "id": "` + req.ID + `", + "result": { + "kind": "task", + "id": "task-1", + "status": {"state": "completed"} + } + }`)) + })) + defer server.Close() + + client := NewClient(Config{URL: server.URL}, nil) + task, err := client.GetTask(context.Background(), agent.GetTaskInput{TaskID: "task-1"}) + if err != nil { + t.Fatalf("GetTask returned error: %v", err) + } + if task.Status.State != agent.TaskStateCompleted { + t.Fatalf("unexpected get task: %#v", task) + } + + task, err = client.CancelTask(context.Background(), agent.CancelTaskInput{TaskID: "task-1"}) + if err != nil { + t.Fatalf("CancelTask returned error: %v", err) + } + if task.ID != "task-1" { + t.Fatalf("unexpected cancel task: %#v", task) + } + if len(methods) != 2 || methods[0] != "tasks/get" || methods[1] != "tasks/cancel" { + t.Fatalf("unexpected methods: %#v", methods) + } +} + +func TestJSONRPCError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{ + "jsonrpc": "2.0", + "id": "nomadcode-1", + "error": {"code": -32602, "message": "invalid params"} + }`)) + })) + defer server.Close() + + client := NewClient(Config{URL: server.URL}, nil) + if _, err := client.SendMessage(context.Background(), agent.SendMessageInput{Text: "hello"}); err == nil { + t.Fatal("expected JSON-RPC error") + } +} diff --git a/internal/adapters/openai/client.go b/internal/adapters/openai/client.go new file mode 100644 index 0000000..e2f0d0e --- /dev/null +++ b/internal/adapters/openai/client.go @@ -0,0 +1,266 @@ +package openai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/nomadcode/nomadcode-core/internal/model" +) + +const ( + defaultAPIKey = "ollama" + defaultTimeoutSec = 300 + responsesPath = "/v1/responses" +) + +type Config struct { + BaseURL string + APIKey string + Model string + ContextSize int + TimeoutSec int +} + +type Client struct { + cfg Config + httpClient *http.Client + logger *slog.Logger +} + +func NewClient(cfg Config, logger *slog.Logger) *Client { + if cfg.APIKey == "" { + cfg.APIKey = defaultAPIKey + } + if cfg.TimeoutSec <= 0 { + cfg.TimeoutSec = defaultTimeoutSec + } + return &Client{ + cfg: cfg, + httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}, + logger: logger, + } +} + +func (c *Client) Generate(ctx context.Context, input model.GenerateInput) (model.GenerateResult, error) { + endpoint, err := responsesURL(c.cfg.BaseURL) + if err != nil { + return model.GenerateResult{}, err + } + + modelName := strings.TrimSpace(input.Model) + if modelName == "" { + modelName = c.cfg.Model + } + if strings.TrimSpace(modelName) == "" { + return model.GenerateResult{}, fmt.Errorf("model name is required") + } + if strings.TrimSpace(input.Input) == "" { + return model.GenerateResult{}, fmt.Errorf("model input is required") + } + + reqBody := responsesRequest{ + Model: modelName, + Input: input.Input, + Instructions: input.Instructions, + Stream: false, + Temperature: input.Temperature, + TopP: input.TopP, + MaxOutputTokens: input.MaxOutputTokens, + } + if c.cfg.ContextSize > 0 { + reqBody.Options = &responsesOptions{NumCtx: c.cfg.ContextSize} + } + body, err := json.Marshal(reqBody) + if err != nil { + return model.GenerateResult{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return model.GenerateResult{}, err + } + req.Header.Set("Content-Type", "application/json") + if c.cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + } + + if c.logger != nil { + c.logger.Info( + "model responses request", + "endpoint", endpoint, + "model", modelName, + "num_ctx", c.cfg.ContextSize, + ) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return model.GenerateResult{}, err + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return model.GenerateResult{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return model.GenerateResult{}, responseError(resp.StatusCode, raw) + } + + var parsed responsesResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return model.GenerateResult{}, err + } + + usage := model.Usage{} + if parsed.Usage != nil { + usage.InputTokens = firstNonZero(parsed.Usage.InputTokens, parsed.Usage.PromptTokens) + usage.OutputTokens = firstNonZero(parsed.Usage.OutputTokens, parsed.Usage.CompletionTokens) + usage.TotalTokens = parsed.Usage.TotalTokens + if usage.TotalTokens == 0 { + usage.TotalTokens = usage.InputTokens + usage.OutputTokens + } + } + + return model.GenerateResult{ + ID: parsed.ID, + Model: firstNonEmpty(parsed.Model, modelName), + Text: parsed.text(), + Usage: usage, + Raw: json.RawMessage(raw), + }, nil +} + +type responsesRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Instructions string `json:"instructions,omitempty"` + Stream bool `json:"stream"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Options *responsesOptions `json:"options,omitempty"` +} + +type responsesOptions struct { + NumCtx int `json:"num_ctx,omitempty"` +} + +type responsesResponse struct { + ID string `json:"id"` + Model string `json:"model"` + OutputText string `json:"output_text"` + Output []responsesOutput `json:"output"` + Usage *responsesUsage `json:"usage"` +} + +type responsesOutput struct { + Type string `json:"type"` + Text string `json:"text"` + Content []responsesContent `json:"content"` +} + +type responsesContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type responsesUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` +} + +func (r responsesResponse) text() string { + if r.OutputText != "" { + return r.OutputText + } + + var b strings.Builder + for _, output := range r.Output { + if output.Text != "" { + b.WriteString(output.Text) + } + for _, content := range output.Content { + if content.Text != "" { + b.WriteString(content.Text) + } + } + } + return b.String() +} + +type errorResponse struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` +} + +func responseError(status int, raw []byte) error { + var parsed errorResponse + if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" { + return fmt.Errorf("responses API request failed: status %d: %s", status, parsed.Error.Message) + } + msg := strings.TrimSpace(string(raw)) + if msg == "" { + msg = http.StatusText(status) + } + return fmt.Errorf("responses API request failed: status %d: %s", status, msg) +} + +func responsesURL(base string) (string, error) { + base = strings.TrimSpace(base) + if base == "" { + return "", fmt.Errorf("model base URL is required") + } + + parsed, err := url.Parse(base) + if err != nil { + return "", err + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("model base URL must include scheme and host") + } + + cleanPath := strings.TrimRight(parsed.Path, "/") + switch { + case strings.HasSuffix(cleanPath, "/v1/responses"): + parsed.Path = cleanPath + case strings.HasSuffix(cleanPath, "/v1"): + parsed.Path = path.Join(cleanPath, "responses") + default: + parsed.Path = path.Join(cleanPath, responsesPath) + } + return parsed.String(), nil +} + +func firstNonZero(values ...int) int { + for _, value := range values { + if value != 0 { + return value + } + } + return 0 +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/adapters/openai/client_test.go b/internal/adapters/openai/client_test.go new file mode 100644 index 0000000..2b42e7b --- /dev/null +++ b/internal/adapters/openai/client_test.go @@ -0,0 +1,140 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/model" +) + +func TestGenerateCallsResponsesAPI(t *testing.T) { + var gotPath string + var gotAuth string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": "resp_test", + "model": "qwen3.6:35b-a3b-bf16", + "output_text": "hello from model", + "usage": { + "input_tokens": 3, + "output_tokens": 4, + "total_tokens": 7 + } + }`)) + })) + defer server.Close() + + client := NewClient(Config{ + BaseURL: server.URL, + APIKey: "test-key", + Model: "qwen3.6:35b-a3b-bf16", + ContextSize: 262144, + }, nil) + + result, err := client.Generate(context.Background(), model.GenerateInput{ + Input: "say hello", + Instructions: "be brief", + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + if gotPath != "/v1/responses" { + t.Fatalf("expected /v1/responses, got %q", gotPath) + } + if gotAuth != "Bearer test-key" { + t.Fatalf("expected auth header, got %q", gotAuth) + } + if gotBody["model"] != "qwen3.6:35b-a3b-bf16" { + t.Fatalf("unexpected model: %#v", gotBody["model"]) + } + if gotBody["input"] != "say hello" { + t.Fatalf("unexpected input: %#v", gotBody["input"]) + } + if gotBody["instructions"] != "be brief" { + t.Fatalf("unexpected instructions: %#v", gotBody["instructions"]) + } + if _, ok := gotBody["context_size"]; ok { + t.Fatal("Responses API request must not include unsupported context_size") + } + options, ok := gotBody["options"].(map[string]any) + if !ok { + t.Fatalf("expected options object, got %#v", gotBody["options"]) + } + if options["num_ctx"] != float64(262144) { + t.Fatalf("unexpected options.num_ctx: %#v", options["num_ctx"]) + } + if result.Text != "hello from model" { + t.Fatalf("unexpected result text: %q", result.Text) + } + if result.Usage.TotalTokens != 7 { + t.Fatalf("unexpected usage: %#v", result.Usage) + } +} + +func TestGenerateExtractsOutputContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id": "resp_test", + "output": [ + { + "type": "message", + "content": [ + {"type": "output_text", "text": "hello "}, + {"type": "output_text", "text": "again"} + ] + } + ], + "usage": { + "prompt_tokens": 2, + "completion_tokens": 5 + } + }`)) + })) + defer server.Close() + + client := NewClient(Config{BaseURL: server.URL, Model: "model-a"}, nil) + result, err := client.Generate(context.Background(), model.GenerateInput{Input: "say hello"}) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + if result.Text != "hello again" { + t.Fatalf("unexpected result text: %q", result.Text) + } + if result.Usage.InputTokens != 2 || result.Usage.OutputTokens != 5 || result.Usage.TotalTokens != 7 { + t.Fatalf("unexpected usage: %#v", result.Usage) + } +} + +func TestResponsesURL(t *testing.T) { + tests := map[string]string{ + "http://localhost:11434": "http://localhost:11434/v1/responses", + "http://localhost:11434/": "http://localhost:11434/v1/responses", + "http://localhost:11434/v1": "http://localhost:11434/v1/responses", + "http://localhost:11434/v1/": "http://localhost:11434/v1/responses", + "http://localhost:11434/v1/responses": "http://localhost:11434/v1/responses", + } + + for input, want := range tests { + got, err := responsesURL(input) + if err != nil { + t.Fatalf("responsesURL(%q) returned error: %v", input, err) + } + if got != want { + t.Fatalf("responsesURL(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..ee28be7 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,104 @@ +package agent + +import ( + "context" + "encoding/json" +) + +type Client interface { + SendMessage(ctx context.Context, input SendMessageInput) (SendMessageResult, error) + GetTask(ctx context.Context, input GetTaskInput) (Task, error) + CancelTask(ctx context.Context, input CancelTaskInput) (Task, error) +} + +type SendMessageInput struct { + Text string + ContextID string + TaskID string + Metadata map[string]any + AcceptedOutputModes []string + HistoryLength int + Blocking bool +} + +type SendMessageResult struct { + Message *Message + Task *Task + Raw json.RawMessage +} + +type GetTaskInput struct { + TaskID string + HistoryLength int + Metadata map[string]any +} + +type CancelTaskInput struct { + TaskID string + Metadata map[string]any +} + +type TaskState string + +const ( + TaskStateSubmitted TaskState = "submitted" + TaskStateWorking TaskState = "working" + TaskStateInputNeeded TaskState = "input-required" + TaskStateCompleted TaskState = "completed" + TaskStateCanceled TaskState = "canceled" + TaskStateFailed TaskState = "failed" + TaskStateRejected TaskState = "rejected" + TaskStateAuthRequired TaskState = "auth-required" + TaskStateUnknown TaskState = "unknown" +) + +type Task struct { + Kind string `json:"kind,omitempty"` + ID string `json:"id"` + ContextID string `json:"contextId,omitempty"` + Status TaskStatus `json:"status"` + Artifacts []Artifact `json:"artifacts,omitempty"` + History []Message `json:"history,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type TaskStatus struct { + State TaskState `json:"state"` + Message *Message `json:"message,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +type Message struct { + Kind string `json:"kind,omitempty"` + Role string `json:"role"` + Parts []Part `json:"parts"` + MessageID string `json:"messageId"` + TaskID string `json:"taskId,omitempty"` + ContextID string `json:"contextId,omitempty"` + ReferenceTaskIDs []string `json:"referenceTaskIds,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Extensions []string `json:"extensions,omitempty"` +} + +type Artifact struct { + ArtifactID string `json:"artifactId,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parts []Part `json:"parts,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type Part struct { + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + Data map[string]any `json:"data,omitempty"` + File *FilePart `json:"file,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type FilePart struct { + Name string `json:"name,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Bytes string `json:"bytes,omitempty"` + URI string `json:"uri,omitempty"` +} diff --git a/internal/config/config.go b/internal/config/config.go index fb6e14c..626d3a2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,9 @@ package config -import "os" +import ( + "os" + "strconv" +) type Config struct { AppEnv string @@ -10,6 +13,15 @@ type Config struct { RedisKeyPrefix string AuthUsername string AuthPassword string + ModelBaseURL string + ModelAPIKey string + ModelName string + ModelContextSize int + ModelTimeoutSec int + A2AEdgeURL string + A2AAgentURL string + A2AToken string + A2ATimeoutSec int MattermostBaseURL string MattermostToken string PlaneBaseURL string @@ -25,6 +37,15 @@ func Load() Config { RedisKeyPrefix: os.Getenv("REDIS_KEY_PREFIX"), AuthUsername: getEnv("AUTH_USERNAME", "nomadcode"), AuthPassword: os.Getenv("AUTH_PASSWORD"), + ModelBaseURL: getEnv("MODEL_BASE_URL", "http://192.168.0.91:11434"), + ModelAPIKey: getEnv("MODEL_API_KEY", "ollama"), + ModelName: getEnv("MODEL_NAME", "qwen3.6:35b-a3b-bf16"), + ModelContextSize: getEnvInt("MODEL_CONTEXT_SIZE", 262144), + ModelTimeoutSec: getEnvInt("MODEL_TIMEOUT_SEC", 300), + A2AEdgeURL: firstEnv("A2A_EDGE_URL", "A2A_AGENT_URL"), + A2AAgentURL: firstEnv("A2A_AGENT_URL", "A2A_EDGE_URL"), + A2AToken: os.Getenv("A2A_TOKEN"), + A2ATimeoutSec: getEnvInt("A2A_TIMEOUT_SEC", 300), MattermostBaseURL: os.Getenv("MATTERMOST_BASE_URL"), MattermostToken: os.Getenv("MATTERMOST_TOKEN"), PlaneBaseURL: os.Getenv("PLANE_BASE_URL"), @@ -39,3 +60,24 @@ func getEnv(key, fallback string) string { } return value } + +func firstEnv(keys ...string) string { + for _, key := range keys { + if value := os.Getenv(key); value != "" { + return value + } + } + return "" +} + +func getEnvInt(key string, fallback int) int { + value := os.Getenv(key) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3983073 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,53 @@ +package config + +import "testing" + +func TestLoadA2AEdgeURLPrefersEdgeURL(t *testing.T) { + t.Setenv("A2A_EDGE_URL", "http://edge.example/a2a") + t.Setenv("A2A_AGENT_URL", "http://legacy.example/a2a") + + cfg := Load() + + if cfg.A2AEdgeURL != "http://edge.example/a2a" { + t.Fatalf("A2AEdgeURL: got %q", cfg.A2AEdgeURL) + } + if cfg.A2AAgentURL != "http://legacy.example/a2a" { + t.Fatalf("A2AAgentURL: got %q", cfg.A2AAgentURL) + } +} + +func TestLoadA2AEdgeURLFallsBackToLegacyAgentURL(t *testing.T) { + t.Setenv("A2A_AGENT_URL", "http://legacy.example/a2a") + + cfg := Load() + + if cfg.A2AEdgeURL != "http://legacy.example/a2a" { + t.Fatalf("A2AEdgeURL fallback: got %q", cfg.A2AEdgeURL) + } + if cfg.A2AAgentURL != "http://legacy.example/a2a" { + t.Fatalf("A2AAgentURL: got %q", cfg.A2AAgentURL) + } +} + +func TestLoadModelAndA2ADefaults(t *testing.T) { + cfg := Load() + + if cfg.ModelBaseURL != "http://192.168.0.91:11434" { + t.Fatalf("ModelBaseURL: got %q", cfg.ModelBaseURL) + } + if cfg.ModelAPIKey != "ollama" { + t.Fatalf("ModelAPIKey: got %q", cfg.ModelAPIKey) + } + if cfg.ModelName != "qwen3.6:35b-a3b-bf16" { + t.Fatalf("ModelName: got %q", cfg.ModelName) + } + if cfg.ModelContextSize != 262144 { + t.Fatalf("ModelContextSize: got %d", cfg.ModelContextSize) + } + if cfg.ModelTimeoutSec != 300 { + t.Fatalf("ModelTimeoutSec: got %d", cfg.ModelTimeoutSec) + } + if cfg.A2ATimeoutSec != 300 { + t.Fatalf("A2ATimeoutSec: got %d", cfg.A2ATimeoutSec) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..4b0dc67 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,34 @@ +package model + +import ( + "context" + "encoding/json" +) + +type Client interface { + Generate(ctx context.Context, input GenerateInput) (GenerateResult, error) +} + +type GenerateInput struct { + Model string + Instructions string + Input string + Metadata map[string]string + MaxOutputTokens int + Temperature *float64 + TopP *float64 +} + +type GenerateResult struct { + ID string + Model string + Text string + Usage Usage + Raw json.RawMessage +} + +type Usage struct { + InputTokens int + OutputTokens int + TotalTokens int +} diff --git a/internal/scheduler/jobs.go b/internal/scheduler/jobs.go index 747be9c..3e32f7b 100644 --- a/internal/scheduler/jobs.go +++ b/internal/scheduler/jobs.go @@ -3,11 +3,15 @@ package scheduler import ( "context" "encoding/json" + "fmt" "log/slog" + "strings" "time" "github.com/riverqueue/river" + "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/storage" "github.com/nomadcode/nomadcode-core/internal/workflow" @@ -26,6 +30,8 @@ type TaskWorker struct { Store *storage.Store Notifications *notification.Service + Agent agent.Client + Model model.Client Logger *slog.Logger } @@ -40,14 +46,12 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro return err } - select { - case <-time.After(500 * time.Millisecond): - case <-ctx.Done(): - w.markFailed(taskID, ctx.Err()) - return ctx.Err() + result, message, err := w.runTask(ctx, task) + if err != nil { + w.markFailed(taskID, err) + return err } - result := json.RawMessage(`{"message":"dummy task completed"}`) task, err = w.Store.CompleteTask(ctx, taskID, result) if err != nil { w.markFailed(taskID, err) @@ -59,7 +63,7 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro TaskID: task.ID, Title: task.Title, Status: task.Status, - Message: "dummy task completed", + Message: message, }) if err != nil && w.Logger != nil { w.Logger.Warn("task notification failed", "task_id", taskID, "error", err) @@ -72,6 +76,221 @@ func (w *TaskWorker) Work(ctx context.Context, job *river.Job[TaskJobArgs]) erro return nil } +func (w *TaskWorker) runTask(ctx context.Context, task storage.Task) (json.RawMessage, string, error) { + if w.Agent != nil { + return w.runAgentTask(ctx, task) + } + + if w.Model == nil { + select { + case <-time.After(500 * time.Millisecond): + case <-ctx.Done(): + return nil, "", ctx.Err() + } + return json.RawMessage(`{"message":"dummy task completed","mode":"dummy"}`), "dummy task completed", nil + } + + generated, err := w.Model.Generate(ctx, buildGenerateInput(task)) + if err != nil { + return nil, "", err + } + + result := map[string]any{ + "message": generated.Text, + "mode": "openai_responses", + "model": generated.Model, + "response_id": generated.ID, + "usage": map[string]int{ + "input_tokens": generated.Usage.InputTokens, + "output_tokens": generated.Usage.OutputTokens, + "total_tokens": generated.Usage.TotalTokens, + }, + } + raw, err := json.Marshal(result) + if err != nil { + return nil, "", err + } + return raw, "model task completed", nil +} + +func (w *TaskWorker) runAgentTask(ctx context.Context, task storage.Task) (json.RawMessage, string, error) { + result, err := w.Agent.SendMessage(ctx, buildAgentInput(task)) + if err != nil { + return nil, "", err + } + + output := map[string]any{ + "mode": "a2a", + } + if len(result.Raw) > 0 { + output["raw"] = json.RawMessage(result.Raw) + } + + message := "a2a task completed" + if result.Message != nil { + text := messageText(*result.Message) + if text != "" { + message = text + output["message"] = text + } + output["message_id"] = result.Message.MessageID + raw, err := json.Marshal(output) + if err != nil { + return nil, "", err + } + return raw, message, nil + } + + if result.Task == nil { + return nil, "", fmt.Errorf("a2a result does not include message or task") + } + + state := result.Task.Status.State + output["a2a_task_id"] = result.Task.ID + output["a2a_state"] = state + + text := taskOutputText(*result.Task) + if text != "" { + message = text + output["message"] = text + } + + switch state { + case agent.TaskStateCompleted: + case agent.TaskStateFailed, agent.TaskStateCanceled, agent.TaskStateRejected, agent.TaskStateInputNeeded, agent.TaskStateAuthRequired: + if text == "" { + text = fmt.Sprintf("a2a task ended with state %s", state) + } + return nil, "", fmt.Errorf("%s", text) + default: + return nil, "", fmt.Errorf("a2a blocking call returned non-terminal task state %s", state) + } + + raw, err := json.Marshal(output) + if err != nil { + return nil, "", err + } + return raw, message, nil +} + +func buildGenerateInput(task storage.Task) model.GenerateInput { + input := strings.TrimSpace(task.Title) + instructions := "" + + if len(task.Payload) > 0 && string(task.Payload) != "null" { + if payloadInput, payloadInstructions, ok := parsePayloadPrompt(task.Payload); ok { + if payloadInput != "" { + input = payloadInput + } + instructions = payloadInstructions + } else if string(task.Payload) != "{}" { + input = fmt.Sprintf("Task title: %s\nPayload:\n%s", task.Title, string(task.Payload)) + } + } + + return model.GenerateInput{ + Input: input, + Instructions: instructions, + Metadata: map[string]string{ + "task_id": task.ID, + "source": task.Source, + }, + } +} + +func buildAgentInput(task storage.Task) agent.SendMessageInput { + generateInput := buildGenerateInput(task) + text := strings.TrimSpace(generateInput.Input) + if generateInput.Instructions != "" { + text = fmt.Sprintf("Instructions:\n%s\n\nTask:\n%s", generateInput.Instructions, text) + } + if text == "" { + text = task.Title + } + + return agent.SendMessageInput{ + Text: text, + AcceptedOutputModes: []string{"text/plain", "application/json"}, + Blocking: true, + Metadata: map[string]any{ + "nomadcode_task_id": task.ID, + "source": task.Source, + "title": task.Title, + }, + } +} + +func parsePayloadPrompt(payload json.RawMessage) (input string, instructions string, ok bool) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return "", "", false + } + ok = true + input = firstStringField(fields, "prompt", "message", "input") + instructions = firstStringField(fields, "instructions", "system") + return input, instructions, ok +} + +func firstStringField(fields map[string]json.RawMessage, names ...string) string { + for _, name := range names { + raw, exists := fields[name] + if !exists { + continue + } + var value string + if err := json.Unmarshal(raw, &value); err == nil { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + } + return "" +} + +func taskOutputText(task agent.Task) string { + if task.Status.Message != nil { + if text := messageText(*task.Status.Message); text != "" { + return text + } + } + + for _, artifact := range task.Artifacts { + if text := partsText(artifact.Parts); text != "" { + return text + } + } + + for i := len(task.History) - 1; i >= 0; i-- { + if task.History[i].Role == "agent" || task.History[i].Role == "assistant" { + if text := messageText(task.History[i]); text != "" { + return text + } + } + } + for i := len(task.History) - 1; i >= 0; i-- { + if text := messageText(task.History[i]); text != "" { + return text + } + } + + return "" +} + +func messageText(message agent.Message) string { + return partsText(message.Parts) +} + +func partsText(parts []agent.Part) string { + texts := make([]string, 0, len(parts)) + for _, part := range parts { + text := strings.TrimSpace(part.Text) + if text != "" { + texts = append(texts, text) + } + } + return strings.Join(texts, "\n") +} + func (w *TaskWorker) markFailed(taskID string, err error) { if err == nil || w.Store == nil { return diff --git a/internal/scheduler/jobs_test.go b/internal/scheduler/jobs_test.go new file mode 100644 index 0000000..5842087 --- /dev/null +++ b/internal/scheduler/jobs_test.go @@ -0,0 +1,108 @@ +package scheduler + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/storage" +) + +func TestRunAgentTaskCompletesFromMessage(t *testing.T) { + worker := &TaskWorker{ + Agent: fakeAgentClient{ + result: agent.SendMessageResult{ + Message: &agent.Message{ + Kind: "message", + Role: "agent", + MessageID: "msg-1", + Parts: []agent.Part{{Kind: "text", Text: "done"}}, + }, + Raw: json.RawMessage(`{"kind":"message","messageId":"msg-1"}`), + }, + }, + } + + raw, message, err := worker.runAgentTask(context.Background(), storage.Task{ + ID: "task-1", + Title: "Fix issue", + Source: "manual", + Payload: json.RawMessage(`{"prompt":"please fix it"}`), + }) + if err != nil { + t.Fatalf("runAgentTask returned error: %v", err) + } + if message != "done" { + t.Fatalf("unexpected message: %q", message) + } + + var output map[string]any + if err := json.Unmarshal(raw, &output); err != nil { + t.Fatalf("decode output: %v", err) + } + if output["mode"] != "a2a" || output["message"] != "done" || output["message_id"] != "msg-1" { + t.Fatalf("unexpected output: %#v", output) + } +} + +func TestRunAgentTaskRejectsNonTerminalTask(t *testing.T) { + worker := &TaskWorker{ + Agent: fakeAgentClient{ + result: agent.SendMessageResult{ + Task: &agent.Task{ + Kind: "task", + ID: "remote-1", + Status: agent.TaskStatus{State: agent.TaskStateWorking}, + }, + }, + }, + } + + _, _, err := worker.runAgentTask(context.Background(), storage.Task{ + ID: "task-1", + Title: "Fix issue", + Source: "manual", + Payload: json.RawMessage(`{}`), + }) + if err == nil || !strings.Contains(err.Error(), "non-terminal") { + t.Fatalf("expected non-terminal state error, got %v", err) + } +} + +func TestBuildAgentInputUsesPromptPayload(t *testing.T) { + input := buildAgentInput(storage.Task{ + ID: "task-1", + Title: "Fallback title", + Source: "manual", + Payload: json.RawMessage(`{"prompt":"do the thing","instructions":"be concise"}`), + }) + + if !input.Blocking { + t.Fatal("expected blocking A2A call") + } + if !strings.Contains(input.Text, "be concise") || !strings.Contains(input.Text, "do the thing") { + t.Fatalf("unexpected text: %q", input.Text) + } + if input.Metadata["nomadcode_task_id"] != "task-1" { + t.Fatalf("unexpected metadata: %#v", input.Metadata) + } +} + +type fakeAgentClient struct { + result agent.SendMessageResult + err error +} + +func (c fakeAgentClient) SendMessage(context.Context, agent.SendMessageInput) (agent.SendMessageResult, error) { + return c.result, c.err +} + +func (c fakeAgentClient) GetTask(context.Context, agent.GetTaskInput) (agent.Task, error) { + return agent.Task{}, nil +} + +func (c fakeAgentClient) CancelTask(context.Context, agent.CancelTaskInput) (agent.Task, error) { + return agent.Task{}, nil +} diff --git a/internal/scheduler/river.go b/internal/scheduler/river.go index c2d5d83..7b0f732 100644 --- a/internal/scheduler/river.go +++ b/internal/scheduler/river.go @@ -10,6 +10,8 @@ import ( "github.com/riverqueue/river/riverdriver/riverpgxv5" "github.com/riverqueue/river/rivermigrate" + "github.com/nomadcode/nomadcode-core/internal/agent" + "github.com/nomadcode/nomadcode-core/internal/model" "github.com/nomadcode/nomadcode-core/internal/notification" "github.com/nomadcode/nomadcode-core/internal/storage" ) @@ -20,11 +22,13 @@ type Client struct { logger *slog.Logger } -func New(pool *pgxpool.Pool, store *storage.Store, notifications *notification.Service, logger *slog.Logger) (*Client, error) { +func New(pool *pgxpool.Pool, store *storage.Store, notifications *notification.Service, agentClient agent.Client, modelClient model.Client, logger *slog.Logger) (*Client, error) { workers := river.NewWorkers() river.AddWorker(workers, &TaskWorker{ Store: store, Notifications: notifications, + Agent: agentClient, + Model: modelClient, Logger: logger, }) From 86eda3a1596e7d9aed39a67399ed7f94622ba316 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 21:31:18 +0900 Subject: [PATCH 10/12] sync: agent-ops from agentic-framework v1.1.17 --- agent-ops/.version | 2 +- agent-ops/skills/common/plan/SKILL.md | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 63b283b..cbb8cba 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.16 +1.1.17 diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index 9568111..56d3a58 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -29,7 +29,8 @@ Filename rules: Task directory naming rules: - A single-plan task uses `agent-task/{task_name}/` with a short snake_case task name. -- If one plan is not enough, split the work into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. +- Prefer splitting before writing. A single-plan task is allowed only when the work is one coherent implementation unit with one primary ownership boundary, no prerequisite subtask, and a reviewable blast radius. +- If the work can be decomposed by dependency, ownership boundary, subsystem, API-vs-call-site phase, test strategy, or independently reviewable risk, split it into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. - Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across multiple folders, not multiple plan files inside one folder. - Multi-plan task directory names must start with a stable two-digit task index. The index must increase across sibling task directories for sorting, but it is not a serial execution dependency. - Use `NN_{task_name}` for a task with no runtime dependencies, e.g. `01_core`, `04_docs`, `05_ui`. @@ -45,6 +46,15 @@ Task directory naming rules: - Example: split independent docs/UI plus an integration as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`. - Preserve task directory names verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. +Split-first gates: + +- Before choosing a single plan, explicitly ask: "Could this be split into smaller independently reviewable implementation units?" If yes, split. +- Split when a plan would require both shared API/foundation changes and broad call-site rollout. Put the API/foundation in an earlier task and the rollout in dependent task(s). +- Split when a plan touches multiple domains or ownership boundaries, unless the change is purely mechanical and trivially reviewable. +- Split when different parts can be verified with different focused tests or different risk profiles. +- Split when a likely failure in one part would force rewriting unrelated parts of the plan. +- Single-plan bias is acceptable only for narrow fixes, one-file/small-file-cluster refactors, or tightly coupled changes where splitting would create artificial coordination overhead. + Routing rules: - Build defaults to `local` when the plan is explicit, tests are runnable, and failure is review-detectable. @@ -83,7 +93,7 @@ The routed plan file is the loop entry point. A missing active plan normally mea Use short snake_case task names, e.g. `api_refactor`. -When the work must be split into multiple plans, choose directory names using the task directory naming rules above. Do not put multiple active plan files in one task directory. +Before writing a single plan, record why the split-first gates did not require multiple task directories. When the work must be split into multiple plans, choose directory names using the task directory naming rules above. Do not put multiple active plan files in one task directory. ## Step 2 - Analyze Before Writing @@ -92,6 +102,7 @@ Complete all items below before creating any files. Work through them in order; - [ ] **Read all source files in full** — read every source file the change will touch, whole file. No partial reads. - [ ] **Read all test files in full** — read every test file that exercises the changed behavior. - [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it. +- [ ] **Assess split boundaries** — identify dependency boundaries, ownership boundaries, API-vs-call-site phases, and independently verifiable subwork. If any split-first gate applies, write multiple task directories instead of one plan. - [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain. - [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest. - [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports. @@ -142,6 +153,7 @@ Required sections: - `읽은 파일`: list every source and test file read during analysis, with path. - `테스트 커버리지 공백`: list each behavior change and whether existing tests cover it; explicitly note gaps. - `심볼 참조`: list renamed/removed symbols and every call site found, or state "none" if no symbols were changed. + - `분할 판단`: state whether the split-first gates were evaluated. For a single plan, explain why the work is still one coherent reviewable unit. For multi-plan output, list each sibling task directory and dependency relationship. - `범위 결정 근거`: state which files or areas were explicitly excluded from this change and why. This is the boundary justification — the implementing agent must not silently expand scope beyond what is recorded here. - `빌드 등급`: state the decided lane and GNN grade with a one-line rationale. - `구현 체크리스트`: a top-level checklist the implementing agent must follow while coding. Include one item per plan item, one item for all intermediate/final verification, and make the final item exactly: `- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.` Copy this checklist into the review stub's `구현 체크리스트` section with the same item text and order. From 92343c6447c9c0c8f186311c6749807745295319 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 21:49:43 +0900 Subject: [PATCH 11/12] sync: agent-ops from agentic-framework v1.1.18 --- agent-ops/.version | 2 +- agent-ops/bin/init-agent-ops.sh | 16 ++++++++++++++++ agent-ops/bin/sync.sh | 4 ++-- agent-ops/skills/common/init-agent-ops/SKILL.md | 1 + agent-ops/skills/common/sync-push/SKILL.md | 3 ++- 5 files changed, 22 insertions(+), 4 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index cbb8cba..852ed67 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.17 +1.1.18 diff --git a/agent-ops/bin/init-agent-ops.sh b/agent-ops/bin/init-agent-ops.sh index 142a9f6..7a1f196 100755 --- a/agent-ops/bin/init-agent-ops.sh +++ b/agent-ops/bin/init-agent-ops.sh @@ -51,6 +51,21 @@ copy_common_agent_ops() { cp -r "$source_dir/skills/common" "$target_agent_ops_dir/skills/" } +ensure_common_rules_file() { + local source_dir="$1" + local target_agent_ops_dir="$2" + local source_rules="$source_dir/rules/common/rules.md" + local target_rules="$target_agent_ops_dir/rules/common/rules.md" + + if [ ! -f "$source_rules" ]; then + echo "Error: common rules file not found: $source_rules" >&2 + exit 1 + fi + + mkdir -p "$(dirname "$target_rules")" + cp "$source_rules" "$target_rules" +} + echo "Initializing agent-ops in: $TARGET_DIR" echo "Source agent-ops: $SOURCE_DIR" @@ -63,6 +78,7 @@ copy_common_agent_ops "$SOURCE_DIR" "$TARGET_DIR/agent-ops" # 3. 에이전트 진입 파일 생성 (common/rules.md 복사) COMMON_RULES="$SOURCE_DIR/rules/common/rules.md" apply_agent_ops_entry_files "$COMMON_RULES" "$TARGET_DIR" +ensure_common_rules_file "$SOURCE_DIR" "$TARGET_DIR/agent-ops" # 4. AI ignore / permission 설정 append_unique_line "$TARGET_DIR/.geminiignore" "$ARCHIVE_IGNORE_PATTERN" diff --git a/agent-ops/bin/sync.sh b/agent-ops/bin/sync.sh index dbde37f..790bc1f 100755 --- a/agent-ops/bin/sync.sh +++ b/agent-ops/bin/sync.sh @@ -41,7 +41,7 @@ bump_version() { bash "$SCRIPT_DIR/bump-version.sh" "$1" } -# ── 폴더 동기화 (rules.md 제외, 삭제된 파일도 반영) ───────────────────────── +# ── 폴더 동기화 (삭제된 파일도 반영) ──────────────────────────────────────── sync_folder() { local src="$1" dst="$2" exclude="${3:-}" mkdir -p "$dst" @@ -66,7 +66,7 @@ sync_folder() { sync_common() { local src="$1" dst="$2" - sync_folder "$src/rules/common" "$dst/rules/common" "rules.md" + sync_folder "$src/rules/common" "$dst/rules/common" sync_folder "$src/skills/common" "$dst/skills/common" sync_folder "$src/bin" "$dst/bin" } diff --git a/agent-ops/skills/common/init-agent-ops/SKILL.md b/agent-ops/skills/common/init-agent-ops/SKILL.md index af2b836..5a9807c 100644 --- a/agent-ops/skills/common/init-agent-ops/SKILL.md +++ b/agent-ops/skills/common/init-agent-ops/SKILL.md @@ -224,6 +224,7 @@ common/rules.md와 내용이 중복되지 않도록 한다. ## 실행 결과 검증 - [ ] `agent-ops/bin/entry-files.sh`의 모든 진입 파일이 프로젝트 루트에 존재하고, 내용이 초기화에 사용한 `rules/common/rules.md`와 일치하는가 +- [ ] `agent-ops/rules/common/rules.md`가 대상 프로젝트에 남아 있고 초기화에 사용한 `rules/common/rules.md`와 일치하는가 - [ ] `agent-ops/rules/project/rules.md`가 생성되었고, 필수 항목(응답 언어, 프로젝트 개요, 기술 스택)이 포함되어 있는가 - [ ] 생성된 domain rules.md가 `domain-rule-template.md` 형식을 따르는가 - [ ] `rules/project/rules.md`의 도메인 매핑 테이블에 생성된 도메인이 모두 등록되어 있는가 diff --git a/agent-ops/skills/common/sync-push/SKILL.md b/agent-ops/skills/common/sync-push/SKILL.md index 12c9c5b..390437b 100644 --- a/agent-ops/skills/common/sync-push/SKILL.md +++ b/agent-ops/skills/common/sync-push/SKILL.md @@ -34,7 +34,7 @@ description: 현재 프로젝트의 agent-ops를 agentic-framework로 올리거 3. **명시되지 않은 경우**: `agent-ops/bin/sync.sh` 실행 4. target이 없으면 `sync.sh`가 현재 프로젝트 기준 상위 폴더(`../`)의 하위 디렉터리 중 `agent-ops/` 폴더가 있는 프로젝트를 모두 대상으로 삼는다 5. `sync.sh`는 현재 agentic-framework의 `agent-ops/rules/common/rules.md` 내용을 대상 프로젝트 루트의 진입 파일에 덮어쓴다 -6. 적용 후 각 대상 repo에서 agent-ops 공통 관리 경로와 진입 파일만 stage 하여 commit/push 한다 +6. 적용 후 각 대상 repo에서 agent-ops 공통 관리 경로(`rules/common/rules.md` 포함)와 진입 파일만 stage 하여 commit/push 한다 덮어쓰기 대상은 `init-agent-ops` 초기 세팅과 동일하며, 실제 목록은 `agent-ops/bin/entry-files.sh`의 `AGENT_OPS_ENTRY_FILES`를 단일 기준으로 사용한다. @@ -58,6 +58,7 @@ agent-ops/bin/sync.sh [target] - [ ] sync.sh 가 오류 없이 완료됐는가 - [ ] 버전 충돌 경고가 없었는가 — 있었다면 사용자에게 수동 머지 필요함을 알린다 +- [ ] 대상 repo의 `agent-ops/rules/common/rules.md`가 현재 agentic-framework의 `agent-ops/rules/common/rules.md`와 일치하는가 - [ ] agentic-framework에서 대상 프로젝트로 push한 경우, `agent-ops/bin/entry-files.sh`의 모든 진입 파일 내용이 현재 agentic-framework의 `agent-ops/rules/common/rules.md`와 일치하는가 - [ ] 대상 repo에 commit/push 된 path가 agent-ops 공통 관리 경로와 진입 파일로 제한되었는가 From 6fdbc73753d57cedbe12d69d43f86dfa732826b9 Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 20 May 2026 22:13:46 +0900 Subject: [PATCH 12/12] sync: agent-ops from agentic-framework v1.1.19 --- agent-ops/.version | 2 +- agent-ops/skills/common/plan/SKILL.md | 34 ++++++++++++++++----------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/agent-ops/.version b/agent-ops/.version index 852ed67..4e03659 100644 --- a/agent-ops/.version +++ b/agent-ops/.version @@ -1 +1 @@ -1.1.18 +1.1.19 diff --git a/agent-ops/skills/common/plan/SKILL.md b/agent-ops/skills/common/plan/SKILL.md index 56d3a58..d998158 100644 --- a/agent-ops/skills/common/plan/SKILL.md +++ b/agent-ops/skills/common/plan/SKILL.md @@ -26,11 +26,26 @@ Filename rules: - `{lane}` is only `local` or `cloud`; never put model names in filenames. - `GNN` is a two-digit capability grade from `G01` to `G10`; the runtime maps lane+grade to current models externally. +Split decision policy: + +- Default to multiple task directories. Before writing any plan, decide whether the work has smaller independently reviewable implementation units. +- If there is any natural dependency boundary, ownership boundary, subsystem boundary, API-vs-call-site phase, test strategy split, or independently reviewable risk, write multiple task directories. +- A single plan is an exception. Use one only when all of these are true: the work is one coherent implementation unit, it has one primary ownership boundary, it has no prerequisite subtask, splitting would create artificial coordination overhead, and the whole change remains easy to review at once. +- When uncertain, split. Do not choose a single plan merely because it is shorter to write. +- Record the split decision in the plan. For a single plan, explicitly state why each applicable split boundary does not require a separate task. For multi-plan output, list each sibling task directory and its dependency relationship. + +Split gates: + +- Split when the work combines shared API/foundation changes with broad call-site rollout. Put the API/foundation in an earlier task and the rollout in dependent task(s). +- Split when the work touches multiple domains or ownership boundaries, unless the change is purely mechanical and trivially reviewable. +- Split when different parts can be verified with different focused tests or have different risk profiles. +- Split when a likely failure in one part would force rewriting unrelated parts of the plan. +- Split when one part can produce a useful `complete.log` before another part starts. + Task directory naming rules: - A single-plan task uses `agent-task/{task_name}/` with a short snake_case task name. -- Prefer splitting before writing. A single-plan task is allowed only when the work is one coherent implementation unit with one primary ownership boundary, no prerequisite subtask, and a reviewable blast radius. -- If the work can be decomposed by dependency, ownership boundary, subsystem, API-vs-call-site phase, test strategy, or independently reviewable risk, split it into multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. +- When split gates require decomposition, create multiple task directories. Each directory owns exactly one normal active plan file and one normal active review stub. - Multi-plan output is a set of independent `PLAN-{build_lane}-GNN.md` + `CODE_REVIEW-{review_lane}-GNN.md` pairs across multiple folders, not multiple plan files inside one folder. - Multi-plan task directory names must start with a stable two-digit task index. The index must increase across sibling task directories for sorting, but it is not a serial execution dependency. - Use `NN_{task_name}` for a task with no runtime dependencies, e.g. `01_core`, `04_docs`, `05_ui`. @@ -46,15 +61,6 @@ Task directory naming rules: - Example: split independent docs/UI plus an integration as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`. - Preserve task directory names verbatim; do not normalize, reinterpret, or choose execution order by agent judgment. -Split-first gates: - -- Before choosing a single plan, explicitly ask: "Could this be split into smaller independently reviewable implementation units?" If yes, split. -- Split when a plan would require both shared API/foundation changes and broad call-site rollout. Put the API/foundation in an earlier task and the rollout in dependent task(s). -- Split when a plan touches multiple domains or ownership boundaries, unless the change is purely mechanical and trivially reviewable. -- Split when different parts can be verified with different focused tests or different risk profiles. -- Split when a likely failure in one part would force rewriting unrelated parts of the plan. -- Single-plan bias is acceptable only for narrow fixes, one-file/small-file-cluster refactors, or tightly coupled changes where splitting would create artificial coordination overhead. - Routing rules: - Build defaults to `local` when the plan is explicit, tests are runnable, and failure is review-detectable. @@ -93,7 +99,7 @@ The routed plan file is the loop entry point. A missing active plan normally mea Use short snake_case task names, e.g. `api_refactor`. -Before writing a single plan, record why the split-first gates did not require multiple task directories. When the work must be split into multiple plans, choose directory names using the task directory naming rules above. Do not put multiple active plan files in one task directory. +Before choosing plan files or task directory names, apply the split decision policy above. When the policy allows a single plan, record the exception rationale. When the policy requires multiple plans, choose directory names using the task directory naming rules above. Do not put multiple active plan files in one task directory. ## Step 2 - Analyze Before Writing @@ -102,7 +108,7 @@ Complete all items below before creating any files. Work through them in order; - [ ] **Read all source files in full** — read every source file the change will touch, whole file. No partial reads. - [ ] **Read all test files in full** — read every test file that exercises the changed behavior. - [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it. -- [ ] **Assess split boundaries** — identify dependency boundaries, ownership boundaries, API-vs-call-site phases, and independently verifiable subwork. If any split-first gate applies, write multiple task directories instead of one plan. +- [ ] **Assess split boundaries first** — identify dependency boundaries, ownership boundaries, API-vs-call-site phases, test strategy splits, risk profile splits, and independently verifiable subwork before selecting plan files. If any split gate applies or the decision is uncertain, write multiple task directories instead of one plan. - [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain. - [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest. - [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports. @@ -153,7 +159,7 @@ Required sections: - `읽은 파일`: list every source and test file read during analysis, with path. - `테스트 커버리지 공백`: list each behavior change and whether existing tests cover it; explicitly note gaps. - `심볼 참조`: list renamed/removed symbols and every call site found, or state "none" if no symbols were changed. - - `분할 판단`: state whether the split-first gates were evaluated. For a single plan, explain why the work is still one coherent reviewable unit. For multi-plan output, list each sibling task directory and dependency relationship. + - `분할 판단`: state that the split decision policy was evaluated before choosing plan files. For a single plan, explain why each relevant split gate does not apply and why single-plan coordination is safer than splitting. For multi-plan output, list each sibling task directory and dependency relationship. - `범위 결정 근거`: state which files or areas were explicitly excluded from this change and why. This is the boundary justification — the implementing agent must not silently expand scope beyond what is recorded here. - `빌드 등급`: state the decided lane and GNN grade with a one-line rationale. - `구현 체크리스트`: a top-level checklist the implementing agent must follow while coding. Include one item per plan item, one item for all intermediate/final verification, and make the final item exactly: `- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.` Copy this checklist into the review stub's `구현 체크리스트` section with the same item text and order.