Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
25a64f8
feat(onboarding): folder-candidacy heuristic (git or markers, minus d…
bborn Jun 5, 2026
7b27352
fix(onboarding): exact /tmp match, drop redundant .git marker, add no…
bborn Jun 5, 2026
3a1607b
feat(onboarding): infer project name/alias/description via claude -p
bborn Jun 5, 2026
fbe1b55
fix(onboarding): capture claude -p diagnostics, WaitDelay, robust JSO…
bborn Jun 5, 2026
982523f
feat(onboarding): merge inferred metadata without erasing defaults
bborn Jun 5, 2026
6a91d47
feat(onboarding): fuzzy folder picker (type-to-search, git-tagged)
bborn Jun 5, 2026
6985a60
fix(onboarding): folder picker cursor clamp, drop dead descend return…
bborn Jun 5, 2026
eecc355
feat(onboarding): first-run Welcome fork model
bborn Jun 5, 2026
ef740a8
feat(onboarding): launch decision tree, welcome fork, picker→enriched…
bborn Jun 5, 2026
d35efa4
fix(onboarding): offer suggestion card for non-git candidates; drop d…
bborn Jun 5, 2026
6862879
fix(onboarding): preserve project-form input on validation errors
bborn Jun 5, 2026
5179fbc
test(onboarding): scripted first-run QA scenario + nested-repo note
bborn Jun 5, 2026
0f79559
style: gofmt app.go after onboarding field removal
bborn Jun 5, 2026
ec0c411
fix(onboarding): picker dup-path guard, correct dismiss key, resize n…
bborn Jun 5, 2026
fa09c13
feat(onboarding): run project inference async so the card shows insta…
bborn Jun 5, 2026
34991e5
fix(onboarding): let projectInferredMsg reach its handler past the fo…
bborn Jun 5, 2026
daa9034
test(qa): VHS screenshot + R2 publish helpers for one-command PR evid…
bborn Jun 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions internal/ai/project_infer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package ai

import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"

"github.com/bborn/workflow/internal/executor"
)

// ProjectMetadata is the structured result of inferring a project's identity.
type ProjectMetadata struct {
Name string `json:"name"`
Alias string `json:"alias"`
Description string `json:"description"`
}

// inferenceTimeout caps how long we wait on `claude -p`.
const inferenceTimeout = 12 * time.Second

// InferProjectMetadata shells out to `claude -p` (print mode) to infer a clean
// project name, short alias, and one-line description from the folder. It reuses
// the user's Claude CLI auth via CLAUDE_CONFIG_DIR (no API key), mirroring
// executor.RenameClaudeSession. Returns an error when claude is unavailable,
// times out, or returns unparseable output; callers MUST degrade gracefully.
func InferProjectMetadata(dir, configDir string) (ProjectMetadata, error) {
ctx, cancel := context.WithTimeout(context.Background(), inferenceTimeout)
defer cancel()

prompt := buildInferencePrompt(dir)
cmd := exec.CommandContext(ctx, "claude", "-p", prompt)
cmd.Dir = dir
cmd.Env = append(os.Environ(), fmt.Sprintf("CLAUDE_CONFIG_DIR=%s", executor.ResolveClaudeConfigDir(configDir)))
cmd.WaitDelay = 2 * time.Second
out, err := cmd.CombinedOutput()
if err != nil {
return ProjectMetadata{}, fmt.Errorf("claude -p inference failed: %w\noutput: %s", err, strings.TrimSpace(string(out)))
}
return parseInferenceJSON(string(out))
}

func buildInferencePrompt(dir string) string {
var sb strings.Builder
sb.WriteString("You are naming a software project for a task manager. ")
sb.WriteString("Given a folder, respond with ONLY a JSON object: ")
sb.WriteString(`{"name": "...", "alias": "...", "description": "..."}`)
sb.WriteString(".\n- name: a clean human project name (kebab or title case), not a file path.\n")
sb.WriteString("- alias: a short lowercase handle (3-12 chars), no spaces.\n")
sb.WriteString("- description: one sentence (<= 12 words) describing what the project is.\n")
sb.WriteString("Output JSON only, no prose, no code fences.\n\n")
sb.WriteString("Folder name: " + filepath.Base(filepath.Clean(dir)) + "\n\n")
sb.WriteString("Files:\n" + shallowFileListing(dir) + "\n")
if snippet := readmeSnippet(dir); snippet != "" {
sb.WriteString("\nREADME/AGENTS excerpt:\n" + snippet + "\n")
}
return sb.String()
}

// shallowFileListing returns up to 40 top-level entry names, dirs marked with /.
func shallowFileListing(dir string) string {
entries, err := os.ReadDir(dir)
if err != nil {
return "(unreadable)"
}
var names []string
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue
}
if e.IsDir() {
names = append(names, e.Name()+"/")
} else {
names = append(names, e.Name())
}
}
sort.Strings(names)
if len(names) > 40 {
names = names[:40]
}
return strings.Join(names, "\n")
}

// readmeSnippet returns the first ~1200 chars of the best available doc file.
func readmeSnippet(dir string) string {
for _, name := range []string{"AGENTS.md", "CLAUDE.md", "README.md"} {
data, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
s := strings.TrimSpace(string(data))
if s == "" {
continue
}
if len(s) > 1200 {
s = s[:1200]
}
return s
}
return ""
}

// parseInferenceJSON extracts a ProjectMetadata from claude's output, tolerating
// surrounding prose or fences by scanning forward from each '{' up to the last '}'.
func parseInferenceJSON(raw string) (ProjectMetadata, error) {
s := strings.TrimSpace(raw)
end := strings.LastIndex(s, "}")
if end < 0 {
return ProjectMetadata{}, fmt.Errorf("no JSON object found in inference output")
}
for i := 0; i < end; i++ {
if s[i] != '{' {
continue
}
var meta ProjectMetadata
if err := json.Unmarshal([]byte(s[i:end+1]), &meta); err == nil && strings.TrimSpace(meta.Name) != "" {
return meta, nil
}
}
return ProjectMetadata{}, fmt.Errorf("no parseable JSON object with a name in inference output")
}
36 changes: 36 additions & 0 deletions internal/ai/project_infer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package ai

import "testing"

func TestParseInferenceJSON(t *testing.T) {
meta, err := parseInferenceJSON(`{"name":"acme-rocket","alias":"acme","description":"Rust CLI for rockets"}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta.Name != "acme-rocket" || meta.Alias != "acme" || meta.Description != "Rust CLI for rockets" {
t.Errorf("got %+v", meta)
}

meta, err = parseInferenceJSON("Here you go:\n```json\n{\"name\":\"foo\",\"alias\":\"f\",\"description\":\"d\"}\n```")
if err != nil || meta.Name != "foo" {
t.Errorf("fenced parse failed: %+v err=%v", meta, err)
}

if _, err := parseInferenceJSON("not json at all"); err == nil {
t.Error("expected error on non-JSON")
}

// Prose containing a stray brace before the real JSON object.
meta, err = parseInferenceJSON(`note: use {curly} then {"name":"bar","alias":"b","description":"d"}`)
if err != nil || meta.Name != "bar" {
t.Errorf("stray-brace parse failed: %+v err=%v", meta, err)
}
}

func TestInferProjectMetadata_DegradesWhenClaudeMissing(t *testing.T) {
t.Setenv("PATH", t.TempDir()) // empty PATH dir => claude not found
_, err := InferProjectMetadata(t.TempDir(), "")
if err == nil {
t.Error("expected error when claude binary is unavailable")
}
}
Loading
Loading