diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..9000b74 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "local-plugins", + "interface": { + "displayName": "Local Plugins" + }, + "plugins": [ + { + "name": "subagent-orchestration-kit", + "source": { + "source": "local", + "path": "./plugins/subagent-orchestration-kit" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/.env.example b/.env.example index 46464bc..7446f0f 100644 --- a/.env.example +++ b/.env.example @@ -4,14 +4,15 @@ # .env は絶対にコミットしないこと # === Slack === -SLACK_BOT_TOKEN= -SLACK_SIGNING_SECRET= -SLACK_APP_ID= +SLACK_BOT_TOKEN= # xoxb-... (OAuth & Permissions) +SLACK_APP_TOKEN= # xapp-... (Basic Information → App-Level Tokens → connections:write) +SLACK_APP_ID= # A0XXXXXXX (setup.sh で使用) +SLACK_SIGNING_SECRET= # 不要になりましたが念のため残す SLACK_MENTOR_CHANNEL_ID= SLACK_PROGRESS_CHANNEL_ID= # === Database === -DATABASE_URL=postgres://postgres:postgres@localhost:5432/kcl_support_hub +DATABASE_URL=postgres://postgres:postgres@localhost:5432/kcl_support_hub?sslmode=disable # === AI (Bonsai / Ollama) === # ollama serve && ollama pull qwen2.5:7b を実行してから設定 @@ -34,7 +35,7 @@ ONYX_API_URL= ONYX_API_KEY= # === AWS === -AWS_REGION=ap-northeast-1 +AWS_REGION=us-east-1 # ローカル開発 (LocalStack) 用 AWS_ENDPOINT_URL=http://localhost:4566 AWS_ACCESS_KEY_ID=test diff --git a/Makefile b/Makefile index 5227b12..e788b27 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,12 @@ export .PHONY: help \ up down logs \ + init-sqs \ migrate migrate-down migrate-status \ dev-api dev-worker dev-web \ - docs docs-build \ + docs \ build \ - slack-setup slack-manifest \ + slack-manifest \ lint test \ setup @@ -22,6 +23,7 @@ help: @echo " make up Docker インフラ起動 (postgres + localstack)" @echo " make down Docker インフラ停止" @echo " make logs Docker ログ表示" + @echo " make init-sqs LocalStack SQS キュー作成 (up後に必要)" @echo "" @echo " DB" @echo " make migrate マイグレーション実行" @@ -33,12 +35,10 @@ help: @echo " make dev-worker TypeScript Worker 起動" @echo " make dev-web Next.js ダッシュボード起動 (port 3000)" @echo " make docs ドキュメントサーバー起動 (port 4000)" - @echo " make docs-build ドキュメントを静的ファイルにビルド" @echo "" @echo " Slack" - @echo " make slack-setup URL=https://xxxx.ngrok-free.app" - @echo " Slash Commands + Interactivity URL を一括更新" @echo " make slack-manifest manifest.json の内容を表示" + @echo " ※ Socket Mode 使用中のため ngrok / URL 設定不要" @echo "" @echo " ビルド / テスト" @echo " make build 全サービスをビルド" @@ -46,7 +46,7 @@ help: @echo " make test 全サービスのテスト" @echo "" @echo " 初回セットアップ一括" - @echo " make setup up + migrate をまとめて実行" + @echo " make setup up + init-sqs + migrate をまとめて実行" @echo "" # ----------------------------------------------- @@ -55,6 +55,9 @@ help: up: docker compose -f infra/docker/compose.yml up -d +init-sqs: + docker exec docker-localstack-1 bash /etc/localstack/init/ready.d/init-localstack.sh + down: docker compose -f infra/docker/compose.yml down @@ -83,26 +86,17 @@ dev-worker: cd apps/worker && pnpm dev dev-web: - cd apps/web && pnpm dev + cd apps/web && PORT=3000 pnpm dev # ----------------------------------------------- # ドキュメント # ----------------------------------------------- docs: - cd docs-host && pnpm dev - -docs-build: - cd docs-host && pnpm build + node docs-host/server.js # ----------------------------------------------- # Slack # ----------------------------------------------- -slack-setup: -ifndef URL - $(error URLを指定してください: make slack-setup URL=https://xxxx.ngrok-free.app) -endif - ./infra/slack/setup.sh "$(URL)" - slack-manifest: @cat infra/slack/manifest.json @@ -132,11 +126,10 @@ test: setup: up @echo "インフラ起動を待機中..." @sleep 5 + $(MAKE) init-sqs $(MAKE) migrate @echo "" @echo "セットアップ完了。次のステップ:" @echo " 1. make dev-api (別ターミナル)" @echo " 2. make dev-worker (別ターミナル)" @echo " 3. make dev-web (別ターミナル)" - @echo " 4. ngrok http 8080" - @echo " 5. make slack-setup URL=" diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index 8c19a79..f46fcad 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -36,6 +36,10 @@ func main() { log.Fatalf("sqs init: %v", err) } + // Start Slack Socket Mode in a background goroutine. + // Connects to Slack via WebSocket — no public URL required. + go slackhandler.RunSocketMode(cfg, sqlDB, sqsClient) + if !cfg.IsDev() { gin.SetMode(gin.ReleaseMode) } @@ -46,10 +50,6 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - // Slack webhook routes - r.POST("/slack/commands", slackhandler.HandleSlashCommand(cfg, sqlDB)) - r.POST("/slack/interactions", slackhandler.HandleInteraction(cfg, sqlDB, sqsClient)) - // REST API routes for the dashboard api := r.Group("/api") { diff --git a/apps/api/go.mod b/apps/api/go.mod index 14f967a..d621dff 100644 --- a/apps/api/go.mod +++ b/apps/api/go.mod @@ -1,8 +1,6 @@ module github.com/Asheze1127/HackHub/apps/api -go 1.24.0 - -toolchain go1.24.5 +go 1.25 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -39,6 +37,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -46,6 +45,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/slack-go/slack v0.21.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/apps/api/go.sum b/apps/api/go.sum index c2da3bb..853f378 100644 --- a/apps/api/go.sum +++ b/apps/api/go.sum @@ -62,6 +62,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -86,6 +88,8 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6 github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 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/slack-go/slack v0.21.0 h1:TAGnZYFp79LAG/oqFzYhFJ9LwEwXJ93heCkPvwjxc7o= +github.com/slack-go/slack v0.21.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/sqlc-dev/pqtype v0.3.0 h1:b09TewZ3cSnO5+M1Kqq05y0+OjqIptxELaSayg7bmqk= github.com/sqlc-dev/pqtype v0.3.0/go.mod h1:oyUjp5981ctiL9UYvj1bVvCKi8OXkCa0u645hce7CAs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/apps/api/internal/api/handler.go b/apps/api/internal/api/handler.go index 49ad09f..3b858c2 100644 --- a/apps/api/internal/api/handler.go +++ b/apps/api/internal/api/handler.go @@ -34,7 +34,11 @@ func HandleListQuestions(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, questions) + resp := make([]QuestionResponse, len(questions)) + for i, question := range questions { + resp[i] = toQuestionResponse(question) + } + c.JSON(http.StatusOK, resp) } } @@ -56,7 +60,7 @@ func HandleGetQuestion(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, question) + c.JSON(http.StatusOK, toQuestionResponse(question)) } } @@ -87,7 +91,11 @@ func HandleListProgress(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, logs) + resp := make([]ProgressLogResponse, len(logs)) + for i, log := range logs { + resp[i] = toProgressLogResponse(log) + } + c.JSON(http.StatusOK, resp) } } @@ -100,7 +108,11 @@ func HandleListTeams(db *sql.DB) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) return } - c.JSON(http.StatusOK, teams) + resp := make([]TeamResponse, len(teams)) + for i, team := range teams { + resp[i] = toTeamResponse(team) + } + c.JSON(http.StatusOK, resp) } } diff --git a/apps/api/internal/api/response.go b/apps/api/internal/api/response.go new file mode 100644 index 0000000..77deb09 --- /dev/null +++ b/apps/api/internal/api/response.go @@ -0,0 +1,144 @@ +package api + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" + + "github.com/Asheze1127/HackHub/apps/api/db/sqlcgen" +) + +type QuestionResponse struct { + ID uuid.UUID `json:"id"` + TeamID *string `json:"team_id"` + SlackUserID string `json:"slack_user_id"` + SlackThreadTs *string `json:"slack_thread_ts"` + SlackChannelID string `json:"slack_channel_id"` + Title string `json:"title"` + Body *string `json:"body"` + ErrorMessage *string `json:"error_message"` + Tried *string `json:"tried"` + TriageResult *json.RawMessage `json:"triage_result"` + Category *string `json:"category"` + Confidence *float64 `json:"confidence"` + Status string `json:"status"` + EscalatedToMentor bool `json:"escalated_to_mentor"` + MentorChannelTs *string `json:"mentor_channel_ts"` + SlackEventID *string `json:"slack_event_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProgressLogResponse struct { + ID uuid.UUID `json:"id"` + TeamID *string `json:"team_id"` + SlackUserID string `json:"slack_user_id"` + Phase string `json:"phase"` + StatusText *string `json:"status_text"` + Blockers *string `json:"blockers"` + IsSos bool `json:"is_sos"` + SlackMessageTs *string `json:"slack_message_ts"` + SlackEventID *string `json:"slack_event_id"` + CreatedAt time.Time `json:"created_at"` +} + +type TeamResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + SlackChannelID *string `json:"slack_channel_id"` + TechStack []string `json:"tech_stack"` + Phase string `json:"phase"` + IsSos bool `json:"is_sos"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toQuestionResponse(q sqlcgen.Question) QuestionResponse { + r := QuestionResponse{ + ID: q.ID, + SlackUserID: q.SlackUserID, + SlackChannelID: q.SlackChannelID, + Title: q.Title, + Status: q.Status, + EscalatedToMentor: q.EscalatedToMentor, + CreatedAt: q.CreatedAt, + UpdatedAt: q.UpdatedAt, + } + if q.TeamID.Valid { + s := q.TeamID.UUID.String() + r.TeamID = &s + } + if q.SlackThreadTs.Valid { + r.SlackThreadTs = &q.SlackThreadTs.String + } + if q.Body.Valid { + r.Body = &q.Body.String + } + if q.ErrorMessage.Valid { + r.ErrorMessage = &q.ErrorMessage.String + } + if q.Tried.Valid { + r.Tried = &q.Tried.String + } + if q.TriageResult.Valid { + raw := json.RawMessage(q.TriageResult.RawMessage) + r.TriageResult = &raw + } + if q.Category.Valid { + r.Category = &q.Category.String + } + if q.Confidence.Valid { + r.Confidence = &q.Confidence.Float64 + } + if q.MentorChannelTs.Valid { + r.MentorChannelTs = &q.MentorChannelTs.String + } + if q.SlackEventID.Valid { + r.SlackEventID = &q.SlackEventID.String + } + return r +} + +func toProgressLogResponse(p sqlcgen.ProgressLog) ProgressLogResponse { + r := ProgressLogResponse{ + ID: p.ID, + SlackUserID: p.SlackUserID, + Phase: p.Phase, + IsSos: p.IsSos, + CreatedAt: p.CreatedAt, + } + if p.TeamID.Valid { + s := p.TeamID.UUID.String() + r.TeamID = &s + } + if p.StatusText.Valid { + r.StatusText = &p.StatusText.String + } + if p.Blockers.Valid { + r.Blockers = &p.Blockers.String + } + if p.SlackMessageTs.Valid { + r.SlackMessageTs = &p.SlackMessageTs.String + } + if p.SlackEventID.Valid { + r.SlackEventID = &p.SlackEventID.String + } + return r +} + +func toTeamResponse(t sqlcgen.Team) TeamResponse { + r := TeamResponse{ + ID: t.ID, + Name: t.Name, + TechStack: t.TechStack, + Phase: t.Phase, + IsSos: t.IsSos, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + } + if t.SlackChannelID.Valid { + r.SlackChannelID = &t.SlackChannelID.String + } + return r +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 93209ad..63dbec2 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -6,18 +6,19 @@ import ( ) type Config struct { - Port string - Env string - DatabaseURL string - SlackBotToken string - SlackSigningSecret string - SlackMentorChannel string + Port string + Env string + DatabaseURL string + SlackBotToken string + SlackAppToken string + SlackSigningSecret string + SlackMentorChannel string SlackProgressChannel string - AWSRegion string - AWSEndpointURL string - SQSQuestionNewURL string - SQSFollowupURL string - SQSProgressURL string + AWSRegion string + AWSEndpointURL string + SQSQuestionNewURL string + SQSFollowupURL string + SQSProgressURL string } func Load() (*Config, error) { @@ -26,10 +27,11 @@ func Load() (*Config, error) { Env: getEnv("ENV", "development"), DatabaseURL: os.Getenv("DATABASE_URL"), SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), + SlackAppToken: os.Getenv("SLACK_APP_TOKEN"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), SlackMentorChannel: os.Getenv("SLACK_MENTOR_CHANNEL_ID"), SlackProgressChannel: os.Getenv("SLACK_PROGRESS_CHANNEL_ID"), - AWSRegion: getEnv("AWS_REGION", "ap-northeast-1"), + AWSRegion: getEnv("AWS_REGION", "us-east-1"), AWSEndpointURL: os.Getenv("AWS_ENDPOINT_URL"), SQSQuestionNewURL: os.Getenv("SQS_QUESTION_NEW_URL"), SQSFollowupURL: os.Getenv("SQS_QUESTION_FOLLOWUP_URL"), @@ -38,8 +40,11 @@ func Load() (*Config, error) { if cfg.DatabaseURL == "" { return nil, fmt.Errorf("DATABASE_URL is required") } - if cfg.SlackSigningSecret == "" { - return nil, fmt.Errorf("SLACK_SIGNING_SECRET is required") + if cfg.SlackBotToken == "" { + return nil, fmt.Errorf("SLACK_BOT_TOKEN is required") + } + if cfg.SlackAppToken == "" { + return nil, fmt.Errorf("SLACK_APP_TOKEN is required (xapp-... Socket Mode token)") } return cfg, nil } @@ -54,4 +59,3 @@ func getEnv(key, fallback string) string { } return fallback } - diff --git a/apps/api/internal/slack/modal.go b/apps/api/internal/slack/modal.go index 6b4b01c..fd04ca7 100644 --- a/apps/api/internal/slack/modal.go +++ b/apps/api/internal/slack/modal.go @@ -85,6 +85,23 @@ func OpenProgressModal(botToken, triggerID, channelID string) error { return callViewsOpen(botToken, triggerID, modal) } +// OpenMentorReplyModal opens the mentor reply modal via views.open. +// metadata is stored in private_metadata (format: channelId|threadTs|userId). +func OpenMentorReplyModal(botToken, triggerID, metadata string) error { + modal := map[string]any{ + "type": "modal", + "callback_id": "mentor_reply_modal", + "private_metadata": metadata, + "title": plainText("回答を入力"), + "submit": plainText("送信(匿名)"), + "close": plainText("キャンセル"), + "blocks": []map[string]any{ + textareaBlock("reply_block", "reply", "回答内容", "参加者への回答を入力してください", false), + }, + } + return callViewsOpen(botToken, triggerID, modal) +} + func callViewsOpen(botToken, triggerID string, view map[string]any) error { payload := map[string]any{ "trigger_id": triggerID, diff --git a/apps/api/internal/slack/socketmode.go b/apps/api/internal/slack/socketmode.go new file mode 100644 index 0000000..62e5b33 --- /dev/null +++ b/apps/api/internal/slack/socketmode.go @@ -0,0 +1,477 @@ +package slack + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + + goslack "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + + "github.com/Asheze1127/HackHub/apps/api/internal/config" + "github.com/Asheze1127/HackHub/apps/api/internal/sqs" +) + +// RunSocketMode connects to Slack via WebSocket and handles slash commands +// and view submissions. It blocks until the connection is closed. +func RunSocketMode(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender) { + api := goslack.New( + cfg.SlackBotToken, + goslack.OptionAppLevelToken(cfg.SlackAppToken), + ) + client := socketmode.New(api, + socketmode.OptionLog(log.New(log.Writer(), "[socketmode] ", log.LstdFlags)), + ) + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeSlashCommand: + cmd, ok := evt.Data.(goslack.SlashCommand) + if !ok { + client.Ack(*evt.Request) + continue + } + onSlashCommand(cfg, client, &evt, &cmd) + + case socketmode.EventTypeInteractive: + callback, ok := evt.Data.(goslack.InteractionCallback) + if !ok { + client.Ack(*evt.Request) + continue + } + onInteraction(cfg, db, sqsClient, client, &evt, &callback) + + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + client.Ack(*evt.Request) + if !ok { + continue + } + onEventsAPI(cfg, db, sqsClient, &eventsAPIEvent) + + case socketmode.EventTypeConnecting: + log.Println("[socketmode] connecting to Slack...") + case socketmode.EventTypeConnected: + log.Println("[socketmode] connected") + } + } + }() + + if err := client.Run(); err != nil { + log.Printf("[socketmode] disconnected: %v", err) + } +} + +// onSlashCommand opens the appropriate modal. Must complete within 3 seconds. +func onSlashCommand(cfg *config.Config, client *socketmode.Client, evt *socketmode.Event, cmd *goslack.SlashCommand) { + var err error + switch cmd.Command { + case "/question": + err = OpenQuestionModal(cfg.SlackBotToken, cmd.TriggerID, cmd.ChannelID) + case "/progress": + err = OpenProgressModal(cfg.SlackBotToken, cmd.TriggerID, cmd.ChannelID) + default: + client.Ack(*evt.Request) + return + } + + if err != nil { + log.Printf("[socketmode] open modal failed (%s): %v", cmd.Command, err) + client.Ack(*evt.Request, map[string]interface{}{ + "response_type": "ephemeral", + "text": "モーダルを開けませんでした。しばらく待ってから再試行してください。", + }) + return + } + client.Ack(*evt.Request) +} + +// onInteraction handles interactive callbacks (block_actions and view_submission). +func onInteraction(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender, client *socketmode.Client, evt *socketmode.Event, cb *goslack.InteractionCallback) { + // Handle block_actions (button clicks) + if cb.Type == goslack.InteractionTypeBlockActions { + for _, action := range cb.ActionCallback.BlockActions { + switch action.ActionID { + case "mentor_reply": + if err := OpenMentorReplyModal(cfg.SlackBotToken, cb.TriggerID, action.Value); err != nil { + log.Printf("[socketmode] open mentor reply modal failed: %v", err) + } + case "question_resolved": + onQuestionResolved(cfg, db, action.Value) + case "question_not_resolved": + onQuestionNotResolved(cfg, db, action.Value) + } + } + client.Ack(*evt.Request) + return + } + + if cb.Type != goslack.InteractionTypeViewSubmission { + client.Ack(*evt.Request) + return + } + + // Handle mentor_reply_modal submission + if cb.View.CallbackID == "mentor_reply_modal" { + parts := strings.SplitN(cb.View.PrivateMetadata, "|", 3) + if len(parts) != 3 { + log.Printf("[socketmode] invalid mentor_reply_modal metadata: %s", cb.View.PrivateMetadata) + client.Ack(*evt.Request) + return + } + channelID, threadTs, userID := parts[0], parts[1], parts[2] + reply := blockVal(cb.View.State.Values, "reply_block", "reply") + text := fmt.Sprintf("<@%s> メンターより:\n%s", userID, reply) + if err := callChatPostMessage(cfg.SlackBotToken, channelID, threadTs, text); err != nil { + log.Printf("[socketmode] mentor reply post failed: %v", err) + } + client.Ack(*evt.Request, map[string]interface{}{"response_action": "clear"}) + return + } + + // Idempotency: view.id is unique per modal open. + inserted, err := insertEventIfNew(context.Background(), db, cb.View.ID, "view_submission") + if err != nil { + log.Printf("[socketmode] idempotency db error: %v", err) + client.Ack(*evt.Request) + return + } + if !inserted { + client.Ack(*evt.Request) + return + } + + var enqueueErr error + switch cb.View.CallbackID { + case "question_modal": + enqueueErr = enqueueQuestion(sqsClient, cb) + case "progress_modal": + enqueueErr = enqueueProgress(sqsClient, cb) + default: + log.Printf("[socketmode] unknown callback_id: %s", cb.View.CallbackID) + client.Ack(*evt.Request) + return + } + + if enqueueErr != nil { + log.Printf("[socketmode] enqueue failed for %s: %v", cb.View.CallbackID, enqueueErr) + client.Ack(*evt.Request, map[string]interface{}{ + "response_action": "errors", + "errors": map[string]string{ + "title_block": "送信に失敗しました。しばらく待ってから再試行してください。", + }, + }) + return + } + + client.Ack(*evt.Request, map[string]interface{}{"response_action": "clear"}) +} + +// onQuestionResolved handles the "✅ 解決した" button. +// value format: "questionId|channelId|threadTs|userId" +func onQuestionResolved(cfg *config.Config, db *sql.DB, value string) { + parts := strings.SplitN(value, "|", 4) + if len(parts) != 4 { + log.Printf("[socketmode] invalid question_resolved value: %s", value) + return + } + questionID, channelID, threadTs, userID := parts[0], parts[1], parts[2], parts[3] + + if _, err := db.ExecContext(context.Background(), + `UPDATE questions SET status = 'resolved', updated_at = NOW() WHERE id = $1`, + questionID, + ); err != nil { + log.Printf("[socketmode] mark resolved failed: %v", err) + } + + text := fmt.Sprintf("<@%s> ✅ 解決済みとしてマークしました。お役に立てて良かったです!", userID) + if err := callChatPostMessage(cfg.SlackBotToken, channelID, threadTs, text); err != nil { + log.Printf("[socketmode] post resolved message failed: %v", err) + } +} + +// onQuestionNotResolved handles the "🆘 まだ解決していない" button. +// value format: "questionId|channelId|threadTs|userId" +func onQuestionNotResolved(cfg *config.Config, db *sql.DB, value string) { + parts := strings.SplitN(value, "|", 4) + if len(parts) != 4 { + log.Printf("[socketmode] invalid question_not_resolved value: %s", value) + return + } + questionID, channelID, threadTs, userID := parts[0], parts[1], parts[2], parts[3] + + // Fetch question title for escalation message. + var title string + if err := db.QueryRowContext(context.Background(), + `SELECT title FROM questions WHERE id = $1`, questionID, + ).Scan(&title); err != nil { + log.Printf("[socketmode] fetch question title failed: %v", err) + title = "(タイトル不明)" + } + + // Update status to escalated. + if _, err := db.ExecContext(context.Background(), + `UPDATE questions SET status = 'escalated', escalated_to_mentor = TRUE, updated_at = NOW() WHERE id = $1`, + questionID, + ); err != nil { + log.Printf("[socketmode] mark escalated failed: %v", err) + } + + // Notify user in thread. + userText := fmt.Sprintf("<@%s> メンターに転送しました。しばらくお待ちください 🙏", userID) + if err := callChatPostMessage(cfg.SlackBotToken, channelID, threadTs, userText); err != nil { + log.Printf("[socketmode] post not-resolved message failed: %v", err) + } + + // Post to mentor channel with reply button. + mentorChannel := cfg.SlackMentorChannel + if mentorChannel == "" { + log.Printf("[socketmode] SLACK_MENTOR_CHANNEL_ID not set; skipping mentor escalation") + return + } + threadURL := fmt.Sprintf("https://slack.com/archives/%s/p%s", channelID, strings.ReplaceAll(threadTs, ".", "")) + mentorText := fmt.Sprintf("[ユーザー未解決] %s", title) + mentorBlocks := buildMentorEscalationBlocks(title, channelID, threadTs, userID, threadURL) + if err := callChatPostMessageWithBlocks(cfg.SlackBotToken, mentorChannel, "", mentorText, mentorBlocks); err != nil { + log.Printf("[socketmode] post mentor escalation failed: %v", err) + } +} + +// buildMentorEscalationBlocks builds the Block Kit payload for mentor channel escalation. +func buildMentorEscalationBlocks(title, channelID, threadTs, userID, threadURL string) []map[string]any { + return []map[string]any{ + { + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": fmt.Sprintf("*[ユーザー未解決] %s*\n元スレッド: %s", title, threadURL), + }, + }, + { + "type": "actions", + "elements": []map[string]any{ + { + "type": "button", + "text": map[string]any{"type": "plain_text", "text": "回答する", "emoji": true}, + "action_id": "mentor_reply", + "value": fmt.Sprintf("%s|%s|%s", channelID, threadTs, userID), + }, + }, + }, + } +} + +// callChatPostMessageWithBlocks posts a Block Kit message to a Slack channel. +func callChatPostMessageWithBlocks(botToken, channelID, threadTs, text string, blocks []map[string]any) error { + payload := map[string]any{ + "channel": channelID, + "text": text, + "blocks": blocks, + } + if threadTs != "" { + payload["thread_ts"] = threadTs + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal chat.postMessage payload: %w", err) + } + req, err := http.NewRequest(http.MethodPost, slackAPIBase+"/chat.postMessage", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat.postMessage request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer "+botToken) + + resp, err := slackHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("chat.postMessage http call: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode chat.postMessage response: %w", err) + } + if !result.OK { + return fmt.Errorf("chat.postMessage error: %s", result.Error) + } + return nil +} + +// callChatPostMessage posts a message to a Slack channel/thread via the Web API. +func callChatPostMessage(botToken, channelID, threadTs, text string) error { + payload := map[string]any{ + "channel": channelID, + "thread_ts": threadTs, + "text": text, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal chat.postMessage payload: %w", err) + } + req, err := http.NewRequest(http.MethodPost, slackAPIBase+"/chat.postMessage", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create chat.postMessage request: %w", err) + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Authorization", "Bearer "+botToken) + + resp, err := slackHTTPClient.Do(req) + if err != nil { + return fmt.Errorf("chat.postMessage http call: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("decode chat.postMessage response: %w", err) + } + if !result.OK { + return fmt.Errorf("chat.postMessage error: %s", result.Error) + } + return nil +} + +// onEventsAPI handles Events API callbacks (e.g. message events). +func onEventsAPI(cfg *config.Config, db *sql.DB, sqsClient sqs.Sender, event *slackevents.EventsAPIEvent) { + if event.InnerEvent.Type != "message" { + return + } + msg, ok := event.InnerEvent.Data.(*slackevents.MessageEvent) + if !ok { + return + } + // Skip bot messages, edited messages, etc. + if msg.BotID != "" || msg.SubType != "" { + return + } + // Only process thread replies (not top-level messages) + if msg.ThreadTimeStamp == "" { + return + } + // Skip the thread-parent itself + if msg.ThreadTimeStamp == msg.TimeStamp { + return + } + + questionID, userID, title, found, err := findQuestionByThread(context.Background(), db, msg.Channel, msg.ThreadTimeStamp) + if err != nil { + log.Printf("[socketmode] findQuestionByThread error: %v", err) + return + } + if !found { + return + } + + followup := questionFollowupMessage{ + QuestionID: questionID, + ChannelID: msg.Channel, + ThreadTs: msg.ThreadTimeStamp, + UserID: userID, + ReplyText: msg.Text, + OriginalTitle: title, + } + body, err := json.Marshal(followup) + if err != nil { + log.Printf("[socketmode] marshal followup message: %v", err) + return + } + if err := sqsClient.Enqueue(context.Background(), sqsClient.QuestionFollowupURL(), string(body)); err != nil { + log.Printf("[socketmode] enqueue followup failed: %v", err) + } +} + +// findQuestionByThread looks up a question by channel + thread_ts. +func findQuestionByThread(ctx context.Context, db *sql.DB, channelID, threadTs string) (questionID, userID, title string, found bool, err error) { + row := db.QueryRowContext(ctx, + `SELECT id, slack_user_id, title FROM questions WHERE slack_channel_id = $1 AND slack_thread_ts = $2 LIMIT 1`, + channelID, threadTs, + ) + if err = row.Scan(&questionID, &userID, &title); err != nil { + if err == sql.ErrNoRows { + return "", "", "", false, nil + } + return "", "", "", false, err + } + return questionID, userID, title, true, nil +} + +// questionFollowupMessage is the SQS payload for question followups. +type questionFollowupMessage struct { + QuestionID string `json:"question_id"` + ChannelID string `json:"channel_id"` + ThreadTs string `json:"thread_ts"` + UserID string `json:"user_id"` + ReplyText string `json:"reply_text"` + OriginalTitle string `json:"original_title"` +} + +func enqueueQuestion(sqsClient sqs.Sender, cb *goslack.InteractionCallback) error { + vals := cb.View.State.Values + msg := questionNewMessage{ + UserID: cb.User.ID, + TeamID: cb.Team.ID, + ChannelID: cb.View.PrivateMetadata, + Title: blockVal(vals, "title_block", "title"), + Body: blockVal(vals, "body_block", "body"), + ErrorMessage: blockVal(vals, "error_block", "error_message"), + Tried: blockVal(vals, "tried_block", "tried"), + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + return sqsClient.Enqueue(context.Background(), sqsClient.QuestionNewURL(), string(body)) +} + +func enqueueProgress(sqsClient sqs.Sender, cb *goslack.InteractionCallback) error { + vals := cb.View.State.Values + sosChecked := len(vals["sos_block"]["sos_flag"].SelectedOptions) > 0 + msg := progressMessage{ + UserID: cb.User.ID, + TeamID: cb.Team.ID, + ChannelID: cb.View.PrivateMetadata, + Phase: blockSelectedVal(vals, "phase_block", "phase"), + CurrentStatus: blockVal(vals, "status_block", "current_status"), + Blockers: blockVal(vals, "blockers_block", "blockers"), + SOSFlag: sosChecked, + } + body, err := json.Marshal(msg) + if err != nil { + return err + } + return sqsClient.Enqueue(context.Background(), sqsClient.ProgressURL(), string(body)) +} + +// blockVal extracts a plain_text_input value from slack-go's ViewState. +func blockVal(vals map[string]map[string]goslack.BlockAction, blockID, actionID string) string { + if b, ok := vals[blockID]; ok { + if a, ok := b[actionID]; ok { + return a.Value + } + } + return "" +} + +// blockSelectedVal extracts a static_select value from slack-go's ViewState. +func blockSelectedVal(vals map[string]map[string]goslack.BlockAction, blockID, actionID string) string { + if b, ok := vals[blockID]; ok { + if a, ok := b[actionID]; ok && a.SelectedOption.Value != "" { + return a.SelectedOption.Value + } + } + return "" +} diff --git a/apps/worker/src/ai/triage.ts b/apps/worker/src/ai/triage.ts index a008af2..2965e61 100644 --- a/apps/worker/src/ai/triage.ts +++ b/apps/worker/src/ai/triage.ts @@ -22,11 +22,52 @@ interface KnowledgeContext { const TRIAGE_PROMPT_TEMPLATE = `あなたは KCL ハッカソンの質問受付担当です。 参加者の質問を受け取り、指定された JSON 形式で分析結果を返してください。 -【重要な制約】 -- 断定的な技術回答をしないこと。あなたは「受付係」です -- 不確かな場合は needs_more_info を true にして情報を求めること -- short_answer_for_user は 500 文字以内、日本語で -- AI による誤答の可能性がある旨を必要に応じて伝えること +【役割】 +- あなたは「問題切り分けを補助する受付係」です +- 解決に必要な情報が十分なら、まず短く一次回答してください +- 情報が不足しているなら、診断に本当に必要な情報だけを求めてください + +【needs_more_info の判断基準 — 最重要】 +needs_more_info は「次の回答や切り分けに進むために不可欠な事実が足りない場合のみ」true にすること。 +以下の場合は needs_more_info を false にして回答すること: +- 概念・仕組みの説明系の質問(例:「Next.js と React の違いは?」「JWT とは何か?」) +- ベストプラクティス・設計方針・責務分担の質問 +- 一般的な how-to、比較、用語説明 +- 与えられた情報だけで一次回答できる質問 +needs_more_info を true にするのは、たとえば以下のような場合だけです: +- 実際のエラーメッセージがない +- 実行したコマンドや再現手順がない +- 問題が起きているファイル、設定、コード箇所が分からない +- バージョンや実行環境が不明で切り分けできない + +【missing_info の書き方 — 最重要】 +- missing_info は「ユーザーが次の 1 通で返せる具体的な事実・資料名」だけを書くこと +- missing_info に書くのは「何を送ってほしいか」であり、「何を説明してほしいか」ではない +- 抽象的な説明要求は禁止 +- 各項目は短い名詞句にすること +- 1項目につき1情報だけにすること +- 最大 3 件までにすること +- すでに質問文に含まれている情報は要求しないこと +- needs_more_info が false のとき missing_info は必ず空配列 [] にすること + +良い missing_info の例: +- 実際のエラーメッセージ全文 +- 実行したコマンド +- 問題が起きているコードの該当関数 +- package.json の scripts 部分 +- 使用している Node.js のバージョン + +悪い missing_info の例: +- DDD の Utility 設計に関する詳細な説明 +- クリーンアーキテクチャにおける Utility の役割 +- もう少し詳しく +- 関連情報 +- 背景知識の説明 + +【short_answer_for_user の書き方】 +あなたは「受付の一次回答」として、知っている範囲で回答を提供してからフォローアップを聞く形にすること。 +例:「〜です。(知識を簡潔に回答) 追加で〜を教えてもらえると、さらに詳しいアドバイスができます。」 +500 文字以内、日本語で。AI による誤答の可能性がある旨を必要に応じて伝えること。 【チーム情報】 チーム名: {team_name} @@ -80,12 +121,13 @@ async function triageWithProvider( apiKey: string, model: string, ): Promise { - const provider = createOpenAI({ baseURL, apiKey }) + const provider = createOpenAI({ baseURL, apiKey, compatibility: "compatible" }) const { object } = await generateObject({ model: provider(model), schema: QuestionTriageResultSchema, prompt, maxTokens: 1024, + mode: "json", }) return object } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1ea531a..3e60935 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,7 +1,11 @@ -import "dotenv/config" +import dotenv from "dotenv" +import path from "path" +// モノレポルートの .env を優先して読む(apps/worker/.env があればそちらも読む) +dotenv.config({ path: path.resolve(__dirname, "../../../.env") }) +dotenv.config() import { checkConnection, closePool } from "./db/client" import { startConsumer } from "./queue/consumer" -import { handleQuestionNew, type QuestionNewMessage } from "./workers/question" +import { handleQuestionNew, handleQuestionFollowup, type QuestionNewMessage, type QuestionFollowupMessage } from "./workers/question" import { handleProgress, type ProgressMessage } from "./workers/progress" function requireEnv(name: string): string { @@ -36,8 +40,8 @@ async function main(): Promise { }, signal), startConsumer(QUEUES.questionFollowup, async (body) => { - console.log("[worker] question:followup", JSON.stringify(body)) - // TODO: gh-19 Phase 3 — FollowupWorker + console.log("[worker] question:followup received") + await handleQuestionFollowup(body as QuestionFollowupMessage) }, signal), startConsumer(QUEUES.progress, async (body) => { diff --git a/apps/worker/src/slack/client.ts b/apps/worker/src/slack/client.ts index e62bb2f..21e6b87 100644 --- a/apps/worker/src/slack/client.ts +++ b/apps/worker/src/slack/client.ts @@ -46,6 +46,22 @@ export async function postMessage(channelId: string, text: string): Promise { + const data = await callSlack("chat.postMessage", { + channel: channelId, + text, + blocks, + }) + if (!data.ts) throw new Error("chat.postMessage (blocks) returned no ts") + return data.ts +} + // postThreadReply sends a threaded reply to an existing message. export async function postThreadReply( channelId: string, @@ -60,3 +76,20 @@ export async function postThreadReply( if (!data.ts) throw new Error("chat.postMessage (thread) returned no ts") return data.ts } + +// postThreadReplyWithBlocks sends a threaded reply with Block Kit blocks. +export async function postThreadReplyWithBlocks( + channelId: string, + threadTs: string, + text: string, + blocks: unknown[], +): Promise { + const data = await callSlack("chat.postMessage", { + channel: channelId, + thread_ts: threadTs, + text, + blocks, + }) + if (!data.ts) throw new Error("chat.postMessage (thread blocks) returned no ts") + return data.ts +} diff --git a/apps/worker/src/workers/question.ts b/apps/worker/src/workers/question.ts index a204653..cf0956f 100644 --- a/apps/worker/src/workers/question.ts +++ b/apps/worker/src/workers/question.ts @@ -1,6 +1,6 @@ import { triageQuestion, type QuestionInput } from "../ai/triage" import { insertQuestion, updateQuestionTriage, markEscalated } from "../db/questions" -import { postMessage, postThreadReply } from "../slack/client" +import { postMessage, postMessageWithBlocks, postThreadReply, postThreadReplyWithBlocks } from "../slack/client" export interface QuestionNewMessage { user_id: string @@ -59,19 +59,17 @@ export async function handleQuestionNew(msg: QuestionNewMessage): Promise status, }) - // Post Slack thread reply. - if (triage.needs_more_info) { - await postThreadReply(msg.channel_id, threadTs, buildAskMoreInfoReply(triage.missing_info)) - } else { - await postThreadReply(msg.channel_id, threadTs, buildAnswerReply(triage)) - } + // Post Slack thread reply with resolution buttons. + const replyText = buildReply(triage) + const replyBlocks = buildReplyBlocks(replyText, row.id, msg.channel_id, threadTs, msg.user_id) + await postThreadReplyWithBlocks(msg.channel_id, threadTs, replyText, replyBlocks) // Escalate to mentor channel if flagged. if (triage.should_escalate_to_mentor) { const mentorChannel = getMentorChannel() if (mentorChannel) { - const mentorText = buildMentorEscalationText(msg, triage, msg.channel_id, threadTs) - const mentorTs = await postMessage(mentorChannel, mentorText) + const { text, blocks } = buildMentorEscalationBlocks(msg, triage, msg.channel_id, threadTs, msg.user_id) + const mentorTs = await postMessageWithBlocks(mentorChannel, text, blocks) await markEscalated(row.id, mentorTs) } else { // Mentor channel not configured — log and skip escalation. @@ -81,6 +79,37 @@ export async function handleQuestionNew(msg: QuestionNewMessage): Promise } } +export interface QuestionFollowupMessage { + question_id: string + channel_id: string + thread_ts: string + user_id: string + reply_text: string + original_title: string +} + +// handleQuestionFollowup processes a follow-up reply in a question thread. +export async function handleQuestionFollowup(msg: QuestionFollowupMessage): Promise { + const input: QuestionInput = { + title: msg.original_title, + body: msg.reply_text, + } + const triage = await triageQuestion(input) + + const status = triage.should_escalate_to_mentor ? "escalated" : "answered" + await updateQuestionTriage({ + id: msg.question_id, + triage_result: triage, + category: triage.category, + confidence: triage.confidence, + status, + }) + + const replyText = buildReply(triage) + const replyBlocks = buildReplyBlocks(replyText, msg.question_id, msg.channel_id, msg.thread_ts, msg.user_id) + await postThreadReplyWithBlocks(msg.channel_id, msg.thread_ts, replyText, replyBlocks) +} + // buildQuestionText formats the initial channel message. function buildQuestionText(msg: QuestionNewMessage): string { const lines = [`*[質問] ${msg.title}*`, `<@${msg.user_id}> からの質問です。`] @@ -90,41 +119,85 @@ function buildQuestionText(msg: QuestionNewMessage): string { return lines.join("\n") } -// buildAskMoreInfoReply formats the "please provide more information" reply. -function buildAskMoreInfoReply(missingInfo: string[]): string { - const items = missingInfo.length > 0 - ? missingInfo.map((i) => `• ${i}`).join("\n") - : "• もう少し詳しい情報" - return [ - "ありがとうございます!質問を受け取りました 🔍", - "", - "確認させてください:", - items, - "", - "上記を教えていただけると、より正確にサポートできます。", - "このスレッドに返信してください!", - ].join("\n") -} - -// buildAnswerReply formats the AI first-response reply. -function buildAnswerReply(triage: Awaited>): string { +// buildReply formats the AI reply — 常に回答を先に出し、追加情報が必要なら末尾に添える。 +function buildReply(triage: Awaited>): string { const note = triage.confidence >= 0.8 ? "" : "\n\n_※ AIによる一次回答です。内容に誤りがある可能性があります。_" - return `${triage.short_answer_for_user}${note}` + const followup = triage.needs_more_info && triage.missing_info.length > 0 + ? "\n\n追加で教えてもらえると助かります:\n" + triage.missing_info.map((i) => `• ${i}`).join("\n") + : "" + return `${triage.short_answer_for_user}${note}${followup}` } -// buildMentorEscalationText formats the mentor channel escalation message. -function buildMentorEscalationText( +// buildReplyBlocks wraps the AI reply text with resolution buttons. +// button value: "questionId|channelId|threadTs|userId" +function buildReplyBlocks( + text: string, + questionId: string, + channelId: string, + threadTs: string, + userId: string, +): unknown[] { + const value = `${questionId}|${channelId}|${threadTs}|${userId}` + return [ + { type: "section", text: { type: "mrkdwn", text } }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "✅ 解決した", emoji: true }, + style: "primary", + action_id: "question_resolved", + value, + }, + { + type: "button", + text: { type: "plain_text", text: "🆘 まだ解決していない", emoji: true }, + style: "danger", + action_id: "question_not_resolved", + value, + }, + ], + }, + ] +} + +// buildMentorEscalationBlocks formats the mentor channel escalation message as Block Kit. +function buildMentorEscalationBlocks( msg: QuestionNewMessage, triage: Awaited>, channelId: string, threadTs: string, -): string { - return [ - `*[メンター転送] ${msg.title}*`, - `カテゴリ: \`${triage.category}\``, - `AI 判定理由: ${triage.escalation_reason ?? "信頼度が低いため"}`, - `元スレッド: https://slack.com/archives/${channelId}/p${threadTs.replace(".", "")}`, - ].join("\n") + userId: string, +): { text: string; blocks: unknown[] } { + const threadUrl = `https://slack.com/archives/${channelId}/p${threadTs.replace(".", "")}` + const text = `[メンター転送] ${msg.title}` + const blocks: unknown[] = [ + { + type: "section", + text: { + type: "mrkdwn", + text: [ + `*[メンター転送] ${msg.title}*`, + `カテゴリ: \`${triage.category}\``, + `AI 判定理由: ${triage.escalation_reason ?? "信頼度が低いため"}`, + `元スレッド: ${threadUrl}`, + ].join("\n"), + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "回答する", emoji: true }, + action_id: "mentor_reply", + value: `${channelId}|${threadTs}|${userId}`, + }, + ], + }, + ] + return { text, blocks } } diff --git a/docs-host/.vitepress/config.mts b/docs-host/.vitepress/config.mts deleted file mode 100644 index 628feb9..0000000 --- a/docs-host/.vitepress/config.mts +++ /dev/null @@ -1,107 +0,0 @@ -import { defineConfig } from "vitepress" -import { withMermaid } from "vitepress-plugin-mermaid" - -export default withMermaid( - defineConfig({ - srcDir: "../docs", - title: "KCL Support Hub", - description: "ハッカソン参加者向け AI サポートシステム", - lang: "ja", - - themeConfig: { - logo: "/logo.svg", - siteTitle: "KCL Support Hub", - - nav: [ - { text: "はじめに", link: "/specification" }, - { text: "セットアップ", link: "/setup/" }, - { text: "詳細設計", link: "/detail/01_product-definition" }, - ], - - sidebar: { - "/setup/": [ - { - text: "セットアップ", - items: [ - { text: "概要", link: "/setup/" }, - { text: "Slack App", link: "/setup/slack" }, - { text: "ローカル開発", link: "/setup/local" }, - { text: "自前サーバー (VPS)", link: "/setup/own" }, - { text: "クラウド (AWS)", link: "/setup/remote" }, - ], - }, - ], - "/detail/": [ - { - text: "詳細設計", - items: [ - { text: "01 プロダクト定義", link: "/detail/01_product-definition" }, - { text: "02 アーキテクチャ", link: "/detail/02_architecture" }, - { text: "03 AI フロー", link: "/detail/03_ai-flow" }, - { text: "04 データモデル", link: "/detail/04_data-models" }, - { text: "05 実装フェーズ", link: "/detail/05_implementation-phases" }, - { text: "06 ディレクトリ構成", link: "/detail/06_directory" }, - { text: "07 環境変数", link: "/detail/07_env-vars" }, - { text: "08 実装タスク", link: "/detail/08_implementation-tasks" }, - { text: "09 最初の PR", link: "/detail/09_first-pr" }, - ], - }, - ], - "/": [ - { - text: "概要", - items: [ - { text: "設計仕様", link: "/specification" }, - { text: "AI セットアップ", link: "/ai-setup" }, - { text: "Slack セットアップ", link: "/slack-setup" }, - ], - }, - { - text: "セットアップ", - items: [ - { text: "概要", link: "/setup/" }, - { text: "Slack App", link: "/setup/slack" }, - { text: "ローカル開発", link: "/setup/local" }, - { text: "自前サーバー (VPS)", link: "/setup/own" }, - { text: "クラウド (AWS)", link: "/setup/remote" }, - ], - }, - { - text: "詳細設計", - items: [ - { text: "01 プロダクト定義", link: "/detail/01_product-definition" }, - { text: "02 アーキテクチャ", link: "/detail/02_architecture" }, - { text: "03 AI フロー", link: "/detail/03_ai-flow" }, - { text: "04 データモデル", link: "/detail/04_data-models" }, - { text: "05 実装フェーズ", link: "/detail/05_implementation-phases" }, - { text: "06 ディレクトリ構成", link: "/detail/06_directory" }, - { text: "07 環境変数", link: "/detail/07_env-vars" }, - { text: "08 実装タスク", link: "/detail/08_implementation-tasks" }, - { text: "09 最初の PR", link: "/detail/09_first-pr" }, - ], - }, - ], - }, - - socialLinks: [ - { icon: "github", link: "https://github.com/Asheze1127/HackHub" }, - ], - - search: { - provider: "local", - }, - - footer: { - message: "KCL Support Hub", - }, - }, - - markdown: { - lineNumbers: true, - }, - - mermaid: { - theme: "default", - }, - }) -) diff --git a/docs-host/index.html b/docs-host/index.html new file mode 100644 index 0000000..411d76e --- /dev/null +++ b/docs-host/index.html @@ -0,0 +1,63 @@ + + + + + + KCL Support Hub Docs + + + + +
+ + + + + + + + + + + + + + + + + + + diff --git a/docs-host/package.json b/docs-host/package.json index d6a4c06..b6b92db 100644 --- a/docs-host/package.json +++ b/docs-host/package.json @@ -2,13 +2,6 @@ "name": "@kcl/docs", "private": true, "scripts": { - "dev": "vitepress dev --port 4000", - "build": "vitepress build", - "preview": "vitepress preview --port 4000" - }, - "dependencies": { - "mermaid": "^11.4.1", - "vitepress": "^1.6.3", - "vitepress-plugin-mermaid": "^2.0.17" + "dev": "node server.js" } } diff --git a/docs-host/server.js b/docs-host/server.js new file mode 100644 index 0000000..c271554 --- /dev/null +++ b/docs-host/server.js @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// docs-host/server.js — KCL Support Hub ドキュメントサーバー +// プロジェクトルートを静的ファイルとして配信します +// docsify は /docs/*.md を AJAX で取得するため、ルートからの配信が必要です + +const http = require("http") +const fs = require("fs") +const path = require("path") + +const PORT = process.env.PORT || 4000 +const PROJECT_ROOT = path.join(__dirname, "..") + +const MIME = { + ".html": "text/html; charset=utf-8", + ".md": "text/plain; charset=utf-8", + ".js": "application/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", +} + +const server = http.createServer((req, res) => { + let urlPath = req.url.split("?")[0] + + // / → docs-host/index.html + if (urlPath === "/" || urlPath === "") { + urlPath = "/docs-host/index.html" + } + + const filePath = path.join(PROJECT_ROOT, urlPath) + + // パストラバーサル防止 + if (!filePath.startsWith(PROJECT_ROOT)) { + res.writeHead(403) + res.end("Forbidden") + return + } + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404) + res.end(`Not found: ${urlPath}`) + return + } + + const ext = path.extname(filePath) + res.writeHead(200, { + "Content-Type": MIME[ext] || "application/octet-stream", + "Cache-Control": "no-cache", + }) + res.end(data) + }) +}) + +server.listen(PORT, () => { + console.log("") + console.log(" KCL Support Hub — ドキュメントサーバー") + console.log("") + console.log(` http://localhost:${PORT}`) + console.log("") + console.log(" Ctrl+C で停止") +}) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..baf7f46 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,56 @@ +# KCL Support Hub + +> ハッカソン参加者向け AI サポートシステム + +Slack 中心・AI 一次対応・進捗可視化システムのドキュメントです。 + +--- + +## はじめに + +| ドキュメント | 説明 | +|---|---| +| [設計仕様](/specification) | システム全体の仕様と要件定義 | +| [AI セットアップ](/ai-setup) | Bonsai / Ollama の設定方法 | + +## セットアップ + +| ガイド | 対象 | +|---|---| +| [Slack App](/setup/slack) | Slack App の作成・設定(全パターン共通) | +| [ローカル開発](/setup/local) | 開発・動作確認 | +| [自前サーバー (VPS)](/setup/own) | ハッカソン当日・社内 VPS | +| [クラウド (AWS)](/setup/remote) | 本番・長期運用 | + +## クイックスタート + +```bash +# 1. .env を設定 +cp .env.example .env && nano .env + +# 2. インフラ + DB +make setup + +# 3. 各サービス起動 +make dev-api # ターミナル 1 +make dev-worker # ターミナル 2 +make dev-web # ターミナル 3 + +# 4. Slack に公開 +ngrok http 8080 +make slack-setup URL=https://xxxx.ngrok-free.app +``` + +## システム構成 + +```mermaid +graph TB + User["参加者 Slack"] -->|/question /progress| API["API サーバー (Go)"] + API -->|ACK| User + API --> SQS["SQS Queues"] + SQS --> Worker["Worker (Node.js)"] + Worker --> DB[("PostgreSQL")] + Worker -->|AI トリアージ| AI["Bonsai / Ollama"] + Worker -->|スレッド返信| User + Dashboard["Web ダッシュボード"] -->|REST| API +``` diff --git a/docs/_sidebar.md b/docs/_sidebar.md new file mode 100644 index 0000000..a666a7a --- /dev/null +++ b/docs/_sidebar.md @@ -0,0 +1,22 @@ +- **概要** + - [トップ](/) + - [設計仕様](/specification) + - [AI セットアップ](/ai-setup) + +- **セットアップ** + - [概要](/setup/) + - [Slack App](/setup/slack) + - [ローカル開発](/setup/local) + - [自前サーバー (VPS)](/setup/own) + - [クラウド (AWS)](/setup/remote) + +- **詳細設計** + - [01 プロダクト定義](/detail/01_product-definition) + - [02 アーキテクチャ](/detail/02_architecture) + - [03 AI フロー](/detail/03_ai-flow) + - [04 データモデル](/detail/04_data-models) + - [05 実装フェーズ](/detail/05_implementation-phases) + - [06 ディレクトリ構成](/detail/06_directory) + - [07 環境変数](/detail/07_env-vars) + - [08 実装タスク](/detail/08_implementation-tasks) + - [09 最初の PR](/detail/09_first-pr) diff --git a/docs/detail/06_directory.md b/docs/detail/06_directory.md index 2c12360..202c40c 100644 --- a/docs/detail/06_directory.md +++ b/docs/detail/06_directory.md @@ -192,7 +192,7 @@ services: # 1️⃣ 全体構成(Monorepo想定) -```id="o8d9si" +``` root/ ├── apps/ # 実行可能アプリ │ ├── web/ @@ -213,7 +213,7 @@ root/ # 2️⃣ フロントエンド構成テンプレ -```id="tq93md" +``` apps/web/ ├── src/ │ ├── app/ # ルーティング層 @@ -231,7 +231,7 @@ apps/web/ ## Featureベース構成(推奨) -```id="t6lm52" +``` features/ ├── auth/ │ ├── components/ @@ -249,7 +249,7 @@ features/ # 3️⃣ バックエンド構成テンプレ(Clean Architecture) -```id="9wh13c" +``` apps/api/ ├── cmd/ # エントリポイント ├── internal/ @@ -267,7 +267,7 @@ apps/api/ # 4️⃣ DDDベース構成テンプレ -```id="kl2m91" +``` src/ ├── modules/ │ ├── user/ @@ -283,7 +283,7 @@ src/ # 5️⃣ マイクロサービス構成 -```id="0b5zvx" +``` services/ ├── auth-service/ ├── core-service/ @@ -295,7 +295,7 @@ services/ # 6️⃣ インフラ構成 -```id="v9k0mz" +``` infra/ ├── terraform/ │ ├── modules/ @@ -311,7 +311,7 @@ infra/ # 7️⃣ ドキュメント構成 -```id="az1k93" +``` docs/ ├── 01_feature-list.md ├── 02_db-design.md @@ -325,7 +325,7 @@ docs/ # 8️⃣ テスト構成テンプレ -```id="c32po1" +``` tests/ ├── unit/ ├── integration/ @@ -337,7 +337,7 @@ tests/ # 9️⃣ ベクトルDB / AI機能がある場合 -```id="q91dte" +``` packages/ ├── embeddings/ │ ├── generator.ts @@ -352,7 +352,7 @@ packages/ # 🔟 状態管理分離パターン(FE) -```id="8t1k4d" +``` stores/ ├── auth.store.ts ├── entity.store.ts @@ -363,7 +363,7 @@ stores/ # 11️⃣ API設計分離パターン -```id="nb29df" +``` api/ ├── client.ts ├── endpoints/ diff --git a/docs/detail/07_env-vars.md b/docs/detail/07_env-vars.md index ff981c9..a0795af 100644 --- a/docs/detail/07_env-vars.md +++ b/docs/detail/07_env-vars.md @@ -15,7 +15,7 @@ SLACK_PROGRESS_CHANNEL_ID=C0XXXXXXXX # #progress-board チャンネル DATABASE_URL=postgres://postgres:postgres@localhost:5432/kcl_support_hub # AWS SQS -AWS_REGION=ap-northeast-1 +AWS_REGION=us-east-1 SQS_QUESTION_NEW_URL=https://sqs.ap-northeast-1.amazonaws.com/xxx/question-new SQS_QUESTION_FOLLOWUP_URL=https://sqs.ap-northeast-1.amazonaws.com/xxx/question-followup SQS_PROGRESS_URL=https://sqs.ap-northeast-1.amazonaws.com/xxx/progress @@ -56,7 +56,7 @@ ONYX_API_URL=http://localhost:3000 # Onyx サーバーの URL(な ONYX_API_KEY=xxxx # optional # AWS SQS(ポーリング用) -AWS_REGION=ap-northeast-1 +AWS_REGION=us-east-1 SQS_QUESTION_NEW_URL=https://sqs... SQS_QUESTION_FOLLOWUP_URL=https://sqs... SQS_PROGRESS_URL=https://sqs... @@ -114,7 +114,7 @@ ONYX_API_URL= ONYX_API_KEY= # === AWS === -AWS_REGION=ap-northeast-1 +AWS_REGION=us-east-1 SQS_QUESTION_NEW_URL= SQS_QUESTION_FOLLOWUP_URL= SQS_PROGRESS_URL= diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index f429065..0000000 --- a/docs/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -layout: home - -hero: - name: "KCL Support Hub" - text: "ハッカソン参加者向け AI サポートシステム" - tagline: Slack 中心・AI 一次対応・進捗可視化 - actions: - - theme: brand - text: セットアップ - link: /setup/ - - theme: alt - text: 設計仕様 - link: /specification - -features: - - icon: 🤖 - title: AI トリアージ - details: 参加者の質問を Bonsai (Ollama) が自動分類し、スレッドに回答または追加質問を返します。 - - icon: 📊 - title: 進捗可視化 - details: /progress コマンドで投稿された進捗を #progress-board に自動集約。SOS はメンターに即エスカレーション。 - - icon: ⚡ - title: Slack 3 秒制約対応 - details: 受信 → SQS 投入 → ACK を 3 秒以内に完了。重い処理はすべて非同期 Worker で処理します。 ---- diff --git a/docs/setup/local.md b/docs/setup/local.md index 5e131a2..80ffb6e 100644 --- a/docs/setup/local.md +++ b/docs/setup/local.md @@ -127,11 +127,23 @@ BONSAI_MODEL=qwen2.5:7b docker compose -f infra/docker/compose.yml ps # healthy になっているか確認 ``` -**Worker が SQS を受け取らない** -→ LocalStack のキューが作成されているか確認。 +**Worker が `QueueDoesNotExist` エラーを出す** + +原因: LocalStack v3 はコンテナ再作成時に init スクリプトを自動実行するが、macOS の Docker bind mount は execute ビットを落とすため Permission denied で失敗する。 + +```bash +# キューが存在するか確認 +docker exec docker-localstack-1 awslocal sqs list-queues + +# キューがなければ手動作成 +make init-sqs +``` + +もし `make init-sqs` 後もエラーが続く場合は `.env` の `AWS_REGION` を確認する。 +LocalStack は常に `us-east-1` でキューを作成するため、`us-east-1` でなければならない。 ```bash -aws --endpoint-url http://localhost:4566 sqs list-queues +grep AWS_REGION .env # → AWS_REGION=us-east-1 であること ``` **ngrok URL が毎回変わって不便** diff --git a/docs/setup/slack.md b/docs/setup/slack.md index 130faf7..223acf7 100644 --- a/docs/setup/slack.md +++ b/docs/setup/slack.md @@ -2,6 +2,8 @@ local / own / remote のどのパターンでも最初に一度だけ実施します。 +Socket Mode を使うため **ngrok や公開 URL は不要**です。 + --- ## 1. App 作成(manifest を使う・推奨) @@ -15,13 +17,24 @@ local / own / remote のどのパターンでも最初に一度だけ実施し --- -## 2. 値を `.env` に設定 +## 2. App-Level Token を発行 + +Socket Mode には `xapp-...` トークンが必要です。 + +1. **Basic Information → App-Level Tokens** → **Generate Token and Scopes** +2. Token Name: `socket-mode`(任意) +3. Scope: `connections:write` を追加 +4. **Generate** → 表示された `xapp-...` をコピー + +--- + +## 3. 値を `.env` に設定 | 取得場所 | 変数名 | |---|---| | Basic Information → **App ID** | `SLACK_APP_ID` | | OAuth & Permissions → **Bot User OAuth Token** (`xoxb-...`) | `SLACK_BOT_TOKEN` | -| Basic Information → App Credentials → **Signing Secret** | `SLACK_SIGNING_SECRET` | +| Basic Information → App-Level Tokens (`xapp-...`) | `SLACK_APP_TOKEN` | | メンター通知チャンネル → 詳細の `C0XXXXXXXX` | `SLACK_MENTOR_CHANNEL_ID` | | 進捗投稿チャンネル → 詳細の `C0XXXXXXXX` | `SLACK_PROGRESS_CHANNEL_ID` | @@ -29,29 +42,15 @@ local / own / remote のどのパターンでも最初に一度だけ実施し --- -## 3. Request URL の設定 +## 4. 動作確認 -サーバーが起動してから以下を実行(`` は ngrok URL や本番ドメイン): +API サーバーを起動して Slack で `/question` を実行 → モーダルが開けば OK。 ```bash -make slack-setup URL= -# 例: make slack-setup URL=https://xxxx.ngrok-free.app +make dev-api +# → [socketmode] connected と表示されれば接続成功 ``` -スクリプトが以下を自動更新します: - -| 設定箇所 | URL | -|---|---| -| Slash Commands `/question` | `/slack/commands` | -| Slash Commands `/progress` | `/slack/commands` | -| Interactivity & Shortcuts | `/slack/interactions` | - ---- - -## 4. 動作確認 - -Slack で `/question` を実行 → モーダルが開けば OK。 - --- ## 手動設定 @@ -64,6 +63,6 @@ manifest を使わない場合: 3. **Slash Commands** → Create New Command: - `/question`: Description `質問を投稿する` - `/progress`: Description `進捗を共有する` -4. **Interactivity & Shortcuts** → 有効化 -5. **Install App** -6. Request URL は `make slack-setup URL=...` で後から一括設定 +4. **Interactivity & Shortcuts** → 有効化(URL 不要) +5. **Settings → Socket Mode** → Enable Socket Mode +6. **Install App** diff --git a/infra/docker/compose.yml b/infra/docker/compose.yml index b759a39..418bd96 100644 --- a/infra/docker/compose.yml +++ b/infra/docker/compose.yml @@ -19,7 +19,6 @@ services: image: localstack/localstack:3 environment: SERVICES: sqs - DEFAULT_REGION: ap-northeast-1 LOCALSTACK_HOST: localstack ports: - "4566:4566" diff --git a/infra/docker/init-localstack.sh b/infra/docker/init-localstack.sh old mode 100644 new mode 100755 diff --git a/infra/slack/manifest.json b/infra/slack/manifest.json index 156c8ae..4257584 100644 --- a/infra/slack/manifest.json +++ b/infra/slack/manifest.json @@ -34,17 +34,21 @@ "bot": [ "commands", "chat:write", - "channels:read" + "channels:read", + "channels:history", + "groups:history" ] } }, "settings": { "interactivity": { - "is_enabled": true, - "request_url": "https://your-domain.example.com/slack/interactions" + "is_enabled": true + }, + "event_subscriptions": { + "bot_events": ["message.channels", "message.groups"] }, "org_deploy_enabled": false, - "socket_mode_enabled": false, + "socket_mode_enabled": true, "token_rotation_enabled": false } } diff --git a/plugins/subagent-orchestration-kit/.codex-plugin/plugin.json b/plugins/subagent-orchestration-kit/.codex-plugin/plugin.json new file mode 100644 index 0000000..f0c4001 --- /dev/null +++ b/plugins/subagent-orchestration-kit/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "subagent-orchestration-kit", + "version": "0.1.0", + "description": "Reusable role prompts and orchestration rules for idea generation, implementation, evaluation, and error checking.", + "author": { + "name": "Project maintainer" + }, + "license": "MIT", + "keywords": [ + "subagent", + "orchestration", + "evaluation", + "review", + "planning" + ], + "skills": "./skills/", + "interface": { + "displayName": "Subagent Orchestration Kit", + "shortDescription": "Reusable sub-agent playbooks for product work and implementation loops", + "longDescription": "Packages reusable role definitions, project-context handling, and an implementation loop for idea consulting, coding, evaluation, error checking, and orchestration across repos.", + "developerName": "Project maintainer", + "category": "Productivity", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "defaultPrompt": [ + "Use the orchestrator cycle to plan and ship the next slice.", + "Use idea-consultant to propose feature options from project docs after reading contexts/project-overview.md.", + "Use error-checker to compare implementation against specs, and replace contexts/project-overview.md when reusing the plugin in another repo." + ], + "brandColor": "#0F766E", + "composerIcon": "./assets/subagent-kit.svg", + "logo": "./assets/subagent-kit.svg", + "screenshots": [] + } +} diff --git a/plugins/subagent-orchestration-kit/LICENSE b/plugins/subagent-orchestration-kit/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/plugins/subagent-orchestration-kit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/subagent-orchestration-kit/README.md b/plugins/subagent-orchestration-kit/README.md new file mode 100644 index 0000000..f4fae62 --- /dev/null +++ b/plugins/subagent-orchestration-kit/README.md @@ -0,0 +1,65 @@ +# Subagent Orchestration Kit + +This plugin packages a reusable improvement loop for product work and implementation work. + +It does **not** add new first-class agent types to Codex. Instead, it gives you a portable set of: + +- role definitions +- project-context files +- orchestration rules +- usage guidance you can reuse in other repositories + +## Included roles + +- `idea-consultant` + - proposes feature options and product slices from source docs +- `coding-worker` + - implements one bounded slice with clear file ownership +- `implementation-evaluator` + - verifies acceptance criteria and test coverage for the slice +- `error-checker` + - compares code against specs, product direction, and architectural constraints +- `reviewer` + - reviews the concrete diff for regressions, maintainability, and missed tests +- `orchestrator-cycle` + - coordinates the loop and keeps scope under control + +## Current repo context + +This copy includes two context files: + +- [contexts/project-overview.md](./contexts/project-overview.md) +- [contexts/hackhub-product-overview.md](./contexts/hackhub-product-overview.md) + +Use `contexts/project-overview.md` as the canonical file that the skills read. +In this repo it currently mirrors the HackHub direction. In another repo, replace +that file before you use the skills. + +That context merges: + +- `changeHack` +- `progress-checker` +- `hack-evaluater` +- the user override that the product must support multiple hackathons and serve participants, sponsors, and organizers + +## How to use in this repo + +1. Start with `orchestrator-cycle`. + Use [contexts/project-overview.md](./contexts/project-overview.md) as the project overview file. +2. Use `idea-consultant` to decide the smallest valuable slice. +3. Give `coding-worker` exclusive ownership of the write scope. +4. Run `error-checker` against docs and code. +5. Run `implementation-evaluator` against the agreed acceptance criteria. +6. Repeat in small slices. + +## How to reuse in another repo + +1. Copy `plugins/subagent-orchestration-kit` into the target repo. +2. Merge the plugin entry into the target repo's `.agents/plugins/marketplace.json`. +3. Replace `contexts/project-overview.md` with a project-specific overview based on [contexts/project-overview-template.md](./contexts/project-overview-template.md). +4. Update `contexts/project-overview.md` so it points to the target repo's real source-of-truth specs and architecture docs. +5. Keep the same role split unless the target repo needs fewer roles. + +## Portability note + +If the plugin is not installed in a target environment, the files still work as prompt packs and operating docs. You can open the relevant `SKILL.md` files and use them directly when spawning helper agents. diff --git a/plugins/subagent-orchestration-kit/agents/openai.yaml b/plugins/subagent-orchestration-kit/agents/openai.yaml new file mode 100644 index 0000000..fca76d7 --- /dev/null +++ b/plugins/subagent-orchestration-kit/agents/openai.yaml @@ -0,0 +1,6 @@ +interface: + display_name: "Subagent Orchestration Kit" + short_description: "Reusable role prompts for idea consulting, coding, evaluation, and implementation review" + icon_small: "./assets/subagent-kit.svg" + icon_large: "./assets/subagent-kit.svg" + default_prompt: "Use the subagent orchestration kit to plan the next slice, implement it with a bounded worker, and verify it with evaluator and error-checker roles." diff --git a/plugins/subagent-orchestration-kit/assets/subagent-kit.svg b/plugins/subagent-orchestration-kit/assets/subagent-kit.svg new file mode 100644 index 0000000..c0b0cd7 --- /dev/null +++ b/plugins/subagent-orchestration-kit/assets/subagent-kit.svg @@ -0,0 +1,10 @@ + + Subagent Orchestration Kit + Three coordinated blocks around a central hub. + + + + + + + diff --git a/plugins/subagent-orchestration-kit/contexts/hackhub-product-overview.md b/plugins/subagent-orchestration-kit/contexts/hackhub-product-overview.md new file mode 100644 index 0000000..32d59bb --- /dev/null +++ b/plugins/subagent-orchestration-kit/contexts/hackhub-product-overview.md @@ -0,0 +1,85 @@ +# HackHub Product Overview + +## Source of truth order + +Use this order when the repository is inconsistent: + +1. Direct user instructions in the current thread +2. This file +3. The current repo product and architecture docs +4. Any sibling-repo specs that this product explicitly inherits from +5. Current implementation + +## Current workspace path mapping + +This context file is written for the current HackHub workspace layout. + +From the `changeHack/` repo root, the intended doc locations are: + +- `docs/` +- `../progress-checker/docs/` +- `../hack-evaluater/docs/` + +If those sibling paths do not exist in another repo, do not follow them. Replace this file with a repo-specific overview derived from [project-overview-template.md](./project-overview-template.md) first. + +## Product direction + +The intended product is not just the current `changeHack` MVP. + +It is a combined product that should merge: + +- the Slack-driven progress and question workflow from `progress-checker` +- the multi-hackathon and role-aware product model from `hack-evaluater` + +The product must support multiple hackathons and serve: + +- participants / hackers +- mentors / judges +- organizers +- corporate sponsors +- tenant or platform admins when needed + +## Problem the product solves + +- Participants need a low-friction way to ask questions and share progress. +- Mentors need structured, triaged questions instead of unformatted Slack messages. +- Organizers need a web view of progress, blockers, and unanswered questions across hackathons. +- Sponsors need scoped access to the hackathons and teams they are allowed to see. + +## Combined product shape + +### From progress-checker + +- `/progress` modal and progress board flow +- `/question` modal and AI first response flow +- async queue architecture for Slack's 3-second constraint +- web dashboard for progress and questions +- future Slack discussion to GitHub issue flow + +### From hack-evaluater + +- multi-hackathon structure +- role separation and scoped permissions +- web architecture patterns for feature/container based UI +- backend layering and clearer domain boundaries +- sponsor and organizer facing workflows + +## Known current drift + +The current `changeHack` implementation is still closer to a KCL-specific support hub MVP: + +- single-event assumptions remain in docs and code +- sponsor and organizer flows are not implemented +- multi-hackathon modeling is not implemented +- mentor-only operational views are incomplete +- Slack discussion to Issue flow is still missing + +Treat these as implementation gaps, not final product decisions. + +## Planning constraints + +- Preserve Slack-first entry points. +- Keep heavy work asynchronous. +- Do not break the existing `changeHack` MVP while expanding scope. +- Favor small vertical slices over broad rewrites. +- When docs conflict, note the conflict explicitly before choosing a path. diff --git a/plugins/subagent-orchestration-kit/contexts/project-overview-template.md b/plugins/subagent-orchestration-kit/contexts/project-overview-template.md new file mode 100644 index 0000000..9d8ac4a --- /dev/null +++ b/plugins/subagent-orchestration-kit/contexts/project-overview-template.md @@ -0,0 +1,40 @@ +# Project Overview Template + +## Source of truth order + +1. Current user instructions +2. This file +3. Primary product specs +4. Architecture and permission docs +5. Current implementation + +## Product direction + +- What the product is +- Which older repos or docs it merges +- Which user roles matter most +- Which scope assumptions are critical + +## Target users + +- user group 1 +- user group 2 +- user group 3 + +## Core workflows + +- workflow 1 +- workflow 2 +- workflow 3 + +## Non-negotiable constraints + +- architecture constraint +- security constraint +- product constraint +- delivery constraint + +## Known drift + +- docs vs implementation mismatch 1 +- docs vs implementation mismatch 2 diff --git a/plugins/subagent-orchestration-kit/contexts/project-overview.md b/plugins/subagent-orchestration-kit/contexts/project-overview.md new file mode 100644 index 0000000..9876f56 --- /dev/null +++ b/plugins/subagent-orchestration-kit/contexts/project-overview.md @@ -0,0 +1,12 @@ +# Project Overview + +This file is the canonical project context that the bundled skills read. + +Replace it before reusing this plugin in another repository. + +In the current HackHub workspace, the active project context is: + +- [hackhub-product-overview.md](./hackhub-product-overview.md) + +Use that file as the current repo-specific source of truth until this file is +replaced with a repo-specific version. diff --git a/plugins/subagent-orchestration-kit/skills/coding-worker/SKILL.md b/plugins/subagent-orchestration-kit/skills/coding-worker/SKILL.md new file mode 100644 index 0000000..7c92f6d --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/coding-worker/SKILL.md @@ -0,0 +1,39 @@ +--- +name: coding-worker +description: Implement one bounded slice with clear ownership, tests, and minimal scope creep. +--- + +# Coding Worker + +## Overview + +Use this skill for implementation once the orchestrator has already chosen a slice. + +This role owns one bounded write scope and should avoid expanding the task. + +## Required inputs + +- exact acceptance criteria +- the files or modules this role owns +- known constraints from the project overview + - canonical path: `../../contexts/project-overview.md` +- any test requirements for the slice + +## Required behavior + +- Implement only the assigned slice. +- Preserve existing behavior outside the slice. +- Add or update tests for the touched area. +- Surface blockers instead of silently changing scope. + +## Deliverables + +- code changes +- tests or verification updates +- short note on any unresolved risk + +## Guardrails + +- Do not redefine the product while implementing. +- Do not take ownership of files assigned to another worker. +- Prefer small diffs over broad refactors unless the slice explicitly requires one. diff --git a/plugins/subagent-orchestration-kit/skills/error-checker/SKILL.md b/plugins/subagent-orchestration-kit/skills/error-checker/SKILL.md new file mode 100644 index 0000000..6b52fbc --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/error-checker/SKILL.md @@ -0,0 +1,69 @@ +--- +name: error-checker +description: Compare the repository's code and plans against the actual product direction, specs, and architectural constraints. +--- + +# Error Checker + +## Overview + +Use this skill to find implementation drift. + +This role is not only a bug checker. It checks whether the repository is moving in the wrong direction. + +## Required inputs + +- the project overview file + - canonical path: `../../contexts/project-overview.md` + - replace that file when reusing the plugin in another repo +- relevant specs and architecture docs +- the code or plan under review + +## What to look for + +### Product drift + +- current implementation assumes a smaller product than the docs require +- single-event assumptions where multi-hackathon support is required +- mentor-only assumptions where sponsor or organizer workflows are required + +### Architectural drift + +- synchronous handling where async design is required +- permission shortcuts that violate the intended role model +- hard-coded assumptions that block future role or tenant expansion + +### Implementation gaps + +- documented flows with no code path +- partial flows with no persistence or no UI +- UI routes that exist without backend support +- backend support that exists without user-facing flow + +### Quality problems + +- missing tests in modified areas +- broken or stale docs after implementation +- hidden coupling and poor ownership boundaries + +## Output format + +List findings in severity order: + +1. `critical` +2. `high` +3. `medium` +4. `low` + +Each finding should include: + +- what is wrong +- why it matters +- where it shows up +- the narrowest fix that would reduce the problem + +## Guardrails + +- Do not treat every future feature as a bug. +- Focus on real mismatches between product direction, docs, and implementation. +- Flag contradictions explicitly when the source docs disagree. diff --git a/plugins/subagent-orchestration-kit/skills/idea-consultant/SKILL.md b/plugins/subagent-orchestration-kit/skills/idea-consultant/SKILL.md new file mode 100644 index 0000000..b3c02de --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/idea-consultant/SKILL.md @@ -0,0 +1,49 @@ +--- +name: idea-consultant +description: Propose the next useful product or implementation slice without losing sight of the actual product direction. +--- + +# Idea Consultant + +## Overview + +Use this skill to decide **what to build next**. + +This role turns product docs, architecture docs, and implementation gaps into a concrete next slice. + +## Required inputs + +- the project overview file + - canonical path: `../../contexts/project-overview.md` + - replace that file when reusing the plugin in another repo +- the main product spec +- current gap analysis or known missing features +- any direct user overrides from the current thread + +## Required behavior + +- Start from the real product, not just the current codebase. +- Prefer the smallest slice that unlocks the next important workflow. +- Call out when docs and implementation disagree. +- Separate: + - product requirement + - implementation constraint + - optional enhancement + +## Good output + +Return a short decision memo with: + +1. `next slice` +2. `user value` +3. `why now` +4. `minimal implementation` +5. `acceptance criteria` +6. `follow-up slices` + +## Guardrails + +- Do not invent features that contradict the project overview. +- Do not prioritize polish over broken or missing core flows. +- When the product is multi-tenant or multi-hackathon, explicitly check whether the proposed slice preserves that direction. +- Treat role and permission mismatches as product problems, not just technical debt. diff --git a/plugins/subagent-orchestration-kit/skills/implementation-evaluator/SKILL.md b/plugins/subagent-orchestration-kit/skills/implementation-evaluator/SKILL.md new file mode 100644 index 0000000..791c2f4 --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/implementation-evaluator/SKILL.md @@ -0,0 +1,37 @@ +--- +name: implementation-evaluator +description: Verify that an implemented slice meets acceptance criteria, tests, and basic quality expectations. +--- + +# Implementation Evaluator + +## Overview + +Use this skill after implementation to decide whether the slice is actually done. + +This role does not redesign the feature. It checks whether the agreed slice now works. + +## Evaluation checklist + +- Does the implementation still match the active project overview? +- Does the change satisfy the written acceptance criteria? +- Are the tests appropriate for the changed behavior? +- Is the implementation smaller and more direct than alternatives? +- Did the change accidentally widen scope? +- Are there obvious regressions or missing edge cases? + +## Output format + +Return: + +1. `pass` or `fail` +2. acceptance criteria status +3. test status +4. remaining risks +5. exact follow-up if it fails + +## Guardrails + +- Keep the judgment tied to the agreed slice. +- Distinguish hard failures from future improvements. +- If the slice passes but the larger product is still incomplete, say that clearly. diff --git a/plugins/subagent-orchestration-kit/skills/orchestrator-cycle/SKILL.md b/plugins/subagent-orchestration-kit/skills/orchestrator-cycle/SKILL.md new file mode 100644 index 0000000..812729f --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/orchestrator-cycle/SKILL.md @@ -0,0 +1,67 @@ +--- +name: orchestrator-cycle +description: Coordinate a reusable sub-agent improvement loop across ideation, implementation, evaluation, and drift checking. +--- + +# Orchestrator Cycle + +## Overview + +Use this skill when a repository needs a repeatable improvement loop instead of a single isolated edit. + +This role is the coordinator. It does not own every edit itself. It owns: + +- choosing the next slice +- assigning clear write ownership +- keeping the loop small +- deciding when a slice is ready to merge + +## Required startup + +Before proposing work: + +1. Read the project overview under `../../contexts/`. + The canonical file is `../../contexts/project-overview.md`. + Replace that file before reusing the plugin in another repo. +2. Read the product and architecture docs that the overview points to. +3. Identify the smallest vertical slice that reduces the most important gap. +4. Write down acceptance criteria before implementation starts. + +## Recommended role split + +- `idea-consultant` + - decides what should be built next and why +- `coding-worker` + - implements one bounded slice +- `error-checker` + - finds drift against docs, product direction, and architecture +- `implementation-evaluator` + - verifies the slice against acceptance criteria and tests +- `reviewer` + - reviews the concrete diff for regressions and maintainability + +## Coordination rules + +- Give each write task a single owner. +- Run read-only exploration in parallel whenever possible. +- Do not let `idea-consultant` implement code. +- Do not let `coding-worker` redefine product direction while coding. +- Run `error-checker` after each meaningful implementation step. +- Run `implementation-evaluator` before merge or PR creation. +- Run `reviewer` on the final diff before merge or PR creation. + +## Output format + +For each loop, produce: + +1. the chosen slice +2. why it is the next slice +3. acceptance criteria +4. write owner +5. verification owner +6. remaining risks + +## Reuse guidance + +When reused in another repo, replace `../../contexts/project-overview.md` first. +The orchestration rules can stay mostly unchanged. diff --git a/plugins/subagent-orchestration-kit/skills/reviewer/SKILL.md b/plugins/subagent-orchestration-kit/skills/reviewer/SKILL.md new file mode 100644 index 0000000..4d9d1b5 --- /dev/null +++ b/plugins/subagent-orchestration-kit/skills/reviewer/SKILL.md @@ -0,0 +1,41 @@ +--- +name: reviewer +description: Review a concrete change for regressions, maintainability issues, and missing tests after the slice is implemented. +--- + +# Reviewer + +## Overview + +Use this skill after a concrete diff exists. + +This role focuses on the implemented change itself, not broad product ideation. + +## What to review + +- consistency with the active project overview +- behavioral regressions +- missing edge cases +- poor naming or unclear control flow +- hidden coupling +- missing or weak tests +- security or validation gaps in the touched area + +## Output format + +Return findings first, ordered by severity. + +Each finding should include: + +- path +- why it is a problem +- the user-visible or maintenance impact +- the narrowest practical fix + +If there are no findings, say so explicitly and mention residual risks or test gaps. + +## Guardrails + +- Do not restate the whole implementation unless needed. +- Prefer concrete findings over generic advice. +- Keep focus on the actual diff, not the entire repo. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1b6e158..b8e2f70 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,3 @@ packages: - "apps/*" - "packages/*" - "tools/*" - - "docs-host"