From d318ecf7fca39746321cbbe5aaffced2d6af040b Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 27 Mar 2026 19:33:18 +0000 Subject: [PATCH] Add workspace onboarding: CLI hints, web checklist, codebase detection, and relation field fix - Print suggested /pad prompts after `pad init` creates a new workspace - Add `pad onboard` command that detects project tooling (language, build system, test runner, CI, linter) and suggests matching conventions from the library - Replace empty workspace welcome box with OnboardingChecklist component showing a 4-step guided setup with progress bar and /pad prompt hints - Add contextual tips with /pad prompts to empty collection states - Add onboarding workflow to /pad skill for agent-driven codebase analysis - Fix relation fields storing slugs instead of UUIDs: server now resolves slugs/refs to UUIDs for relation-type fields on both create and update --- cmd/pad/main.go | 152 +++++++++++ embed.go | 2 +- internal/cli/detect.go | 182 +++++++++++++ internal/server/handlers_items.go | 64 +++++ skills/pad/SKILL.md | 23 ++ .../lib/components/OnboardingChecklist.svelte | 256 ++++++++++++++++++ web/src/routes/[workspace]/+page.svelte | 31 +-- .../[workspace]/[collection]/+page.svelte | 27 ++ 8 files changed, 707 insertions(+), 30 deletions(-) create mode 100644 internal/cli/detect.go create mode 100644 web/src/lib/components/OnboardingChecklist.svelte diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 9769a6c7..81e3060b 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -45,6 +45,7 @@ func main() { serveCmd(), stopCmd(), initCmd(), + onboardCmd(), workspacesCmd(), switchCmd(), skillsCmd(), @@ -285,6 +286,7 @@ Use --list-templates to see available templates.`, } } + newlyCreated := false if ws != nil { if err := cli.WriteWorkspaceLink(cwd, ws.Slug); err != nil { return fmt.Errorf("write .pad.toml: %w", err) @@ -309,9 +311,14 @@ Use --list-templates to see available templates.`, } fmt.Printf("Created workspace %q (slug: %s)%s\n", ws.Name, ws.Slug, tmplMsg) fmt.Printf("Linked to %s\n", cwd) + newlyCreated = true } offerSkillInstall() + + if newlyCreated { + printOnboardingHints() + } return nil }, } @@ -386,6 +393,151 @@ func readChoice() string { return strings.TrimSpace(input) } +func printOnboardingHints() { + fmt.Println() + fmt.Println("Get started:") + fmt.Println(" /pad scan this codebase and set up my workspace") + fmt.Println(" /pad what conventions should this project follow?") + fmt.Println(" /pad create a phase for what I'm working on") + fmt.Println() + fmt.Println("Or open the web UI at http://localhost:7777") +} + +// --- onboard --- + +func onboardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "onboard", + Short: "Analyze the project and suggest items to populate the workspace", + Long: `Analyze the current project directory to detect tooling and suggest +conventions, then optionally create them in the workspace. + +This scans for build config, CI setup, linters, and project structure to +recommend conventions from the built-in library.`, + RunE: func(cmd *cobra.Command, args []string) error { + client, _ := getClient() + ws := getWorkspace() + + cwd, _ := os.Getwd() + info := cli.DetectProject(cwd) + + // Print detection results + fmt.Println("Scanning project...") + if info.Language != "" { + fmt.Printf(" Language: %s\n", info.Language) + } + if info.BuildTool != "" { + fmt.Printf(" Build: %s\n", info.BuildTool) + } + if info.TestCmd != "" { + fmt.Printf(" Tests: %s\n", info.TestCmd) + } + if info.HasCI { + fmt.Printf(" CI: %s\n", info.CIProvider) + } + if info.HasLinter { + fmt.Println(" Linter: detected") + } + if info.Language == "" && info.BuildTool == "" { + fmt.Println(" Could not detect project type.") + fmt.Println() + fmt.Println("Try using /pad to set up your workspace conversationally:") + fmt.Println(" /pad scan this codebase and set up my workspace") + return nil + } + + fmt.Println() + + // Get suggested conventions + suggestions := cli.SuggestedConventions(info) + + // Check which are already active + existingConventions, _ := client.ListCollectionItems(ws, "conventions", nil) + existingTitles := make(map[string]bool) + for _, item := range existingConventions { + existingTitles[item.Title] = true + } + + // Filter to new suggestions only + type suggestion struct { + title string + content string + } + var newSuggestions []suggestion + for title, content := range suggestions { + if !existingTitles[title] { + newSuggestions = append(newSuggestions, suggestion{title, content}) + } + } + + if len(newSuggestions) == 0 { + fmt.Println("All suggested conventions are already active.") + return nil + } + + fmt.Printf("Suggested conventions (%d new):\n", len(newSuggestions)) + for i, s := range newSuggestions { + fmt.Printf(" %d. %s\n", i+1, s.title) + } + + if !cli.IsTerminal() { + // Non-interactive: just print suggestions + fmt.Println() + fmt.Println("Run 'pad onboard' in a terminal to activate, or use:") + fmt.Println(" /pad what conventions should this project follow?") + return nil + } + + fmt.Print("\nCreate these conventions? (y/N): ") + choice := readChoice() + if choice != "y" && choice != "Y" { + fmt.Println("Skipped. You can activate conventions from the library:") + fmt.Printf(" http://localhost:7777/%s/library\n", ws) + return nil + } + + // Look up library conventions to get proper trigger/scope/priority + libraryConventions := collections.ConventionLibrary() + libraryMap := make(map[string]collections.LibraryConvention) + for _, cat := range libraryConventions { + for _, conv := range cat.Conventions { + libraryMap[conv.Title] = conv + } + } + + created := 0 + for _, s := range newSuggestions { + // Use library metadata if available, otherwise use sensible defaults + trigger := "on-implement" + scope := "all" + priority := "should" + if lc, ok := libraryMap[s.title]; ok { + trigger = lc.Trigger + scope = lc.Scope + priority = lc.Priority + } + + fieldsJSON := fmt.Sprintf(`{"status":"active","trigger":"%s","scope":"%s","priority":"%s"}`, trigger, scope, priority) + _, err := client.CreateItem(ws, "conventions", models.ItemCreate{ + Title: s.title, + Content: s.content, + Fields: fieldsJSON, + }) + if err != nil { + fmt.Fprintf(os.Stderr, " Failed to create %q: %v\n", s.title, err) + continue + } + fmt.Printf(" Created: %s\n", s.title) + created++ + } + + fmt.Printf("\n%d conventions created.\n", created) + return nil + }, + } + return cmd +} + // --- skills --- func skillsCmd() *cobra.Command { diff --git a/embed.go b/embed.go index ef627d24..81aaa78d 100644 --- a/embed.go +++ b/embed.go @@ -8,4 +8,4 @@ var WebUI embed.FS //go:embed skills/pad/SKILL.md var PadSkill []byte -// embed cache bust: 1774577313 +// embed cache bust: 1774639009 diff --git a/internal/cli/detect.go b/internal/cli/detect.go new file mode 100644 index 00000000..d1cb5eec --- /dev/null +++ b/internal/cli/detect.go @@ -0,0 +1,182 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" +) + +// ProjectInfo holds detected project metadata. +type ProjectInfo struct { + Language string // go, node, rust, python, java, etc. + BuildTool string // make, npm, cargo, pip, maven, etc. + TestCmd string // detected test command + HasCI bool // CI config detected + CIProvider string // github-actions, gitlab, circleci, etc. + HasLinter bool // linter config detected + Frameworks []string // detected frameworks (e.g., svelte, react, chi) +} + +// DetectProject analyzes the current directory to determine project type, +// build system, test runner, CI, and other metadata. +func DetectProject(dir string) ProjectInfo { + info := ProjectInfo{} + + // Detect language and build tool + if fileExists(dir, "go.mod") { + info.Language = "go" + if fileExists(dir, "Makefile") { + info.BuildTool = "make" + info.TestCmd = detectMakeTestCmd(dir) + } else { + info.BuildTool = "go" + info.TestCmd = "go test ./..." + } + } else if fileExists(dir, "package.json") { + info.Language = "node" + info.BuildTool = "npm" + info.TestCmd = "npm test" + if fileExists(dir, "yarn.lock") { + info.BuildTool = "yarn" + info.TestCmd = "yarn test" + } else if fileExists(dir, "pnpm-lock.yaml") { + info.BuildTool = "pnpm" + info.TestCmd = "pnpm test" + } + // Check for TypeScript + if fileExists(dir, "tsconfig.json") { + info.Language = "typescript" + } + } else if fileExists(dir, "Cargo.toml") { + info.Language = "rust" + info.BuildTool = "cargo" + info.TestCmd = "cargo test" + } else if fileExists(dir, "pyproject.toml") || fileExists(dir, "setup.py") || fileExists(dir, "requirements.txt") { + info.Language = "python" + if fileExists(dir, "pyproject.toml") { + info.BuildTool = "pip" + info.TestCmd = "pytest" + } else { + info.BuildTool = "pip" + info.TestCmd = "python -m pytest" + } + } else if fileExists(dir, "pom.xml") { + info.Language = "java" + info.BuildTool = "maven" + info.TestCmd = "mvn test" + } else if fileExists(dir, "build.gradle") || fileExists(dir, "build.gradle.kts") { + info.Language = "java" + info.BuildTool = "gradle" + info.TestCmd = "gradle test" + } else if fileExists(dir, "Makefile") { + info.BuildTool = "make" + info.TestCmd = detectMakeTestCmd(dir) + } + + // Detect CI + if dirExists(dir, ".github", "workflows") { + info.HasCI = true + info.CIProvider = "github-actions" + } else if fileExists(dir, ".gitlab-ci.yml") { + info.HasCI = true + info.CIProvider = "gitlab" + } else if dirExists(dir, ".circleci") { + info.HasCI = true + info.CIProvider = "circleci" + } + + // Detect linter + if fileExists(dir, ".eslintrc.json") || fileExists(dir, ".eslintrc.js") || fileExists(dir, "eslint.config.js") || fileExists(dir, "eslint.config.mjs") { + info.HasLinter = true + } else if fileExists(dir, ".golangci.yml") || fileExists(dir, ".golangci.yaml") { + info.HasLinter = true + } else if fileExists(dir, ".prettierrc") || fileExists(dir, ".prettierrc.json") { + info.HasLinter = true + } else if fileExists(dir, "rustfmt.toml") || fileExists(dir, ".rustfmt.toml") { + info.HasLinter = true + } else if fileExists(dir, ".flake8") || fileExists(dir, "ruff.toml") || fileExists(dir, ".ruff.toml") { + info.HasLinter = true + } + + return info +} + +// SuggestedConventions returns convention titles from the library that match +// the detected project info. Returns a map of title → customized content. +func SuggestedConventions(info ProjectInfo) map[string]string { + suggestions := make(map[string]string) + + // Build convention + if info.BuildTool != "" { + var buildCmd string + switch info.BuildTool { + case "make": + buildCmd = "make build" + case "npm": + buildCmd = "npm run build" + case "yarn": + buildCmd = "yarn build" + case "pnpm": + buildCmd = "pnpm build" + case "cargo": + buildCmd = "cargo build" + case "go": + buildCmd = "go build ./..." + case "maven": + buildCmd = "mvn compile" + case "gradle": + buildCmd = "gradle build" + default: + buildCmd = info.BuildTool + " build" + } + suggestions["Rebuild after code changes"] = "After modifying source code, run `" + buildCmd + "` to verify everything compiles and builds successfully." + } + + // Test convention + if info.TestCmd != "" { + suggestions["Run tests before completing tasks"] = "Run the project's test suite (`" + info.TestCmd + "`) before marking any task as done. If tests fail, fix them before completing the task." + } + + // Linter convention + if info.HasLinter { + suggestions["Run linter before committing"] = "Run the project's linter/formatter before committing code to ensure consistent code style." + } + + // CI convention + if info.HasCI { + suggestions["Verify locally before PR"] = "Before creating a PR, verify the changes work locally: build succeeds, tests pass, and the feature works as expected." + } + + // Always suggest these general ones + suggestions["Commit after task completion"] = "Create a git commit with a descriptive message after completing each discrete unit of work. Reference the task slug or item number in the commit message." + suggestions["Update task status when starting work"] = "When starting work on a task, update its status to in-progress: `pad update --status in-progress`" + + return suggestions +} + +func fileExists(dir, name string) bool { + _, err := os.Stat(filepath.Join(dir, name)) + return err == nil +} + +func dirExists(parts ...string) bool { + info, err := os.Stat(filepath.Join(parts...)) + return err == nil && info.IsDir() +} + +func detectMakeTestCmd(dir string) string { + data, err := os.ReadFile(filepath.Join(dir, "Makefile")) + if err != nil { + return "make test" + } + content := string(data) + // Check if Makefile has a test target + if strings.Contains(content, "test:") || strings.Contains(content, "test :") { + return "make test" + } + // Fall back to language-specific if no test target + if strings.Contains(content, "go test") { + return "go test ./..." + } + return "make test" +} diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index eddc2d00..7961ed9c 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -3,6 +3,7 @@ package server import ( "database/sql" "encoding/json" + "fmt" "net/http" "strconv" "strings" @@ -113,6 +114,12 @@ func (s *Server) handleCreateItem(w http.ResponseWriter, r *http.Request) { } } + // Resolve relation fields (slugs/refs → UUIDs) before validation + if err := s.resolveRelationFields(workspaceID, fieldMap, schema); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if err := items.ValidateFields(fieldMap, schema); err != nil { writeError(w, http.StatusBadRequest, "validation_error", err.Error()) return @@ -207,6 +214,12 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { return } + // Resolve relation fields (slugs/refs → UUIDs) before validation + if err := s.resolveRelationFields(workspaceID, fieldMap, schema); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + if err := items.ValidateFields(fieldMap, schema); err != nil { writeError(w, http.StatusBadRequest, "validation_error", err.Error()) return @@ -365,6 +378,57 @@ func (s *Server) handleGetItemTasks(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, tasks) } +// resolveRelationFields resolves slugs, PREFIX-NUMBER refs, and other identifiers +// in relation fields to their canonical UUIDs. This allows clients to send +// human-readable identifiers (e.g. --field phase=workspace-onboarding) and have +// them stored as UUIDs that the dashboard and queries expect. +func (s *Server) resolveRelationFields(workspaceID string, fields map[string]any, schema models.CollectionSchema) error { + for _, def := range schema.Fields { + if def.Type != "relation" { + continue + } + val, exists := fields[def.Key] + if !exists || val == nil { + continue + } + strVal, ok := val.(string) + if !ok || strVal == "" { + continue + } + // Already a UUID — nothing to resolve + if isUUID(strVal) { + continue + } + // Resolve the identifier (slug, PREFIX-NUMBER, etc.) to an item + item, err := s.store.ResolveItem(workspaceID, strVal) + if err != nil { + return fmt.Errorf("field %q: failed to resolve %q: %w", def.Key, strVal, err) + } + if item == nil { + return fmt.Errorf("field %q: item %q not found", def.Key, strVal) + } + fields[def.Key] = item.ID + } + return nil +} + +// isUUID checks if a string looks like a UUID (8-4-4-4-12 hex). +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + // autoPopulateDates auto-fills start_date/end_date when status changes to active/completed. // Only sets dates if the schema defines those date fields and the field is currently empty. func autoPopulateDates(newFields map[string]any, existingFieldsJSON string, schema models.CollectionSchema) { diff --git a/skills/pad/SKILL.md b/skills/pad/SKILL.md index 4e59feb5..c6285a32 100644 --- a/skills/pad/SKILL.md +++ b/skills/pad/SKILL.md @@ -69,6 +69,10 @@ Interpret the user's intent and route to the appropriate action. Here are common **Retrospective:** - "phase 2 is done, let's retro" → Review completed work, save retrospective +**Onboarding:** +- "scan this codebase" / "set up my workspace" → Codebase analysis + onboarding workflow (see below) +- "what conventions should this project follow?" → Analyze tooling, suggest conventions from the library + ## Before Performing Work When you are about to take action (implement code, complete a task, create a PR, etc.), load the relevant conventions and playbooks FIRST: @@ -195,6 +199,25 @@ All commands support `--format json` (for parsing) or `--format table` (default, 3. Run `pad status --format json` for blockers/attention items 4. Present as: Yesterday / Today / Blockers format +### Onboarding: "Scan this codebase" / "Set up my workspace" + +1. **Check workspace state:** `pad status --format json` — if the workspace already has items, ask if they want to add more or start fresh sections. +2. **Analyze the codebase:** Read key project files to understand the project: + - `README.md` or `README` — project overview, setup instructions + - `CLAUDE.md` — existing AI/agent instructions + - Build config: `Makefile`, `package.json`, `go.mod`, `Cargo.toml`, `pyproject.toml`, `pom.xml` + - CI config: `.github/workflows/`, `.gitlab-ci.yml`, `.circleci/` + - Directory structure: `ls` the top-level directories to understand the layout +3. **Detect project type and tooling:** + - Language: Go, Node/TypeScript, Rust, Python, Java, etc. + - Build system: make, npm, cargo, pip, maven, etc. + - Test runner: what command runs the tests? + - Linter/formatter: what tools enforce code style? +4. **Suggest conventions:** Based on the detected tooling, suggest conventions from the library. Customize the content with the actual commands found in the project (e.g., "Run `make test`" not just "Run the test suite"). Present as a checklist and ask which to activate. +5. **Draft an architecture doc:** Summarize the project structure, tech stack, key directories, and how the pieces fit together. Offer to save as a Doc item. +6. **Propose an initial phase:** Based on recent git activity (`git log --oneline -20`) and any open TODOs, suggest a phase name and a few starter tasks. Ask before creating. +7. **Always confirm before creating each item.** Show what will be created, get approval, then create. + ### Retrospective: "Phase X is done, let's retro" 1. Load the phase: `pad show --format markdown` diff --git a/web/src/lib/components/OnboardingChecklist.svelte b/web/src/lib/components/OnboardingChecklist.svelte new file mode 100644 index 00000000..0cfde372 --- /dev/null +++ b/web/src/lib/components/OnboardingChecklist.svelte @@ -0,0 +1,256 @@ + + +
+
+

Set up your workspace

+

Complete these steps to get the most out of Pad.

+
+ +
+ {completedCount} of {steps.length} complete +
+
+
+
+ +
    + {#each steps as step (step.title)} +
  1. +
    + {#if step.done} + + + + + {:else} + + + + {/if} +
    +
    + {step.title} + {#if !step.done} + Try: {step.hint} + {/if} +
    +
  2. + {/each} +
+ + +
+ + diff --git a/web/src/routes/[workspace]/+page.svelte b/web/src/routes/[workspace]/+page.svelte index 3100b2bb..98462bad 100644 --- a/web/src/routes/[workspace]/+page.svelte +++ b/web/src/routes/[workspace]/+page.svelte @@ -4,6 +4,7 @@ import { api } from '$lib/api/client'; import { workspaceStore } from '$lib/stores/workspace.svelte'; import { relativeTime } from '$lib/utils/markdown'; + import OnboardingChecklist from '$lib/components/OnboardingChecklist.svelte'; import type { DashboardResponse, Collection } from '$lib/types'; let wsSlug = $derived(page.params.workspace ?? ''); @@ -78,15 +79,7 @@ {#if totalItems === 0} -
-
🚀
-

Welcome to {workspaceStore.current?.name ?? 'your workspace'}

-

Your project is ready. Start by creating items or use /pad in Claude Code to manage your project conversationally.

- -
+ {/if} @@ -212,26 +205,6 @@ margin: 0 auto; padding: var(--space-8) var(--space-6); } - .welcome-box { - text-align: center; - padding: var(--space-10) var(--space-6); - } - .welcome-icon { font-size: 3em; margin-bottom: var(--space-4); } - .welcome-box h2 { font-size: 1.3em; font-weight: 600; margin: 0 0 var(--space-2) 0; } - .welcome-box p { font-size: 0.9em; color: var(--text-muted); margin: 0 0 var(--space-6) 0; max-width: 480px; margin-left: auto; margin-right: auto; } - .welcome-box kbd { - background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 3px; - padding: 1px 5px; font-size: 0.9em; font-family: inherit; - } - .welcome-actions { display: flex; gap: var(--space-3); justify-content: center; flex-wrap: wrap; } - .welcome-btn { - padding: var(--space-2) var(--space-5); border-radius: var(--radius); font-weight: 600; - font-size: 0.9em; text-decoration: none; transition: opacity 0.1s; - } - .welcome-btn.primary { background: var(--accent-blue); color: #fff; } - .welcome-btn.secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); } - .welcome-btn:hover { opacity: 0.85; } - .loading { text-align: center; padding-top: 20vh; diff --git a/web/src/routes/[workspace]/[collection]/+page.svelte b/web/src/routes/[workspace]/[collection]/+page.svelte index 76ade358..8875f9a7 100644 --- a/web/src/routes/[workspace]/[collection]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/+page.svelte @@ -236,6 +236,18 @@ return counts; }); + const emptyHintMap: Record = { + tasks: '/pad break down my current work into tasks', + ideas: "/pad I have an idea for...", + phases: '/pad create a phase for what I\'m working on', + docs: '/pad document the architecture of this project', + conventions: '/pad what conventions should this project follow?', + playbooks: '/pad set up playbooks for our workflow', + bugs: '/pad triage open issues in this project', + }; + + let emptyHint = $derived(emptyHintMap[collSlug] ?? null); + function singularName(): string { if (!collection) return 'item'; const name = collection.name; @@ -447,6 +459,9 @@

No {collection.name.toLowerCase()} yet

Create your first {singularName().toLowerCase()} to get started.

+ Create {singularName()} + {#if emptyHint} +

Or try: {emptyHint}

+ {/if} {:else if filteredItems.length === 0 && (searchQuery || Object.keys(activeFilters).length > 0)}
@@ -524,6 +539,18 @@ color: var(--text-muted); margin: 0 0 var(--space-5) 0; } + .empty-hint { + font-size: 0.82em !important; + color: var(--text-muted) !important; + margin-top: var(--space-3) !important; + } + .empty-hint code { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + font-size: 0.95em; + } .empty-cta { display: inline-block; background: var(--accent-blue);