package main import ( "context" "flag" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "ai-workflow-skill/packages/repo-memory-runtime/internal/documents" "ai-workflow-skill/packages/repo-memory-runtime/internal/store" ) func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "init": if err := runInit(os.Args[2:]); err != nil { fatal(err) } case "add": if err := runAdd(os.Args[2:]); err != nil { fatal(err) } case "ingest": if err := runIngest(os.Args[2:]); err != nil { fatal(err) } case "search": if err := runSearch(os.Args[2:]); err != nil { fatal(err) } case "list": if err := runList(os.Args[2:]); err != nil { fatal(err) } case "events": if err := runEvents(os.Args[2:]); err != nil { fatal(err) } case "link": if err := runLink(os.Args[2:]); err != nil { fatal(err) } case "verify": if err := runVerify(os.Args[2:]); err != nil { fatal(err) } case "repos": if err := runRepos(os.Args[2:]); err != nil { fatal(err) } default: usage() os.Exit(2) } } type stringSliceFlag []string func (s *stringSliceFlag) String() string { return strings.Join(*s, ",") } func (s *stringSliceFlag) Set(value string) error { *s = append(*s, value) return nil } func runInit(args []string) error { fs := flag.NewFlagSet("init", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() if err := st.Init(context.Background()); err != nil { return err } fmt.Printf("initialized %s\n", *dbPath) return nil } func runIngest(args []string) error { fs := flag.NewFlagSet("ingest", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repoPath := fs.String("repo", "", "Repository root") scanPath := fs.String("path", "docs/ai", "Relative path under repo to scan for markdown") if err := fs.Parse(args); err != nil { return err } if strings.TrimSpace(*repoPath) == "" { return fmt.Errorf("--repo is required") } absRepo, err := filepath.Abs(*repoPath) if err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() if err := st.Init(context.Background()); err != nil { return err } gitState := detectGitState(absRepo) if _, err := st.UpsertRepo(context.Background(), store.RepoState{ RootPath: absRepo, Name: filepath.Base(absRepo), VCS: "git", LastSeenBranch: gitState.branch, LastSeenCommit: gitState.commit, }); err != nil { return err } docs, err := documents.LoadRepo(absRepo, *scanPath) if err != nil { return err } if len(docs) == 0 { return fmt.Errorf("no markdown files found under %s", filepath.Join(absRepo, *scanPath)) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() for _, doc := range docs { if err := st.ImportDocument(ctx, doc, store.RepoState{ RootPath: absRepo, Name: filepath.Base(absRepo), VCS: "git", LastSeenBranch: gitState.branch, LastSeenCommit: gitState.commit, }); err != nil { return fmt.Errorf("ingest %s: %w", doc.DocPath, err) } } fmt.Printf("ingested %d docs from %s\n", len(docs), absRepo) return nil } func runAdd(args []string) error { fs := flag.NewFlagSet("add", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repoPath := fs.String("repo", "", "Repository root") kind := fs.String("kind", "", "Knowledge kind, e.g. term|chain|danger") key := fs.String("key", "", "Stable entry key") title := fs.String("title", "", "Display title") summary := fs.String("summary", "", "Short summary") detail := fs.String("detail", "", "Detailed markdown text") status := fs.String("status", "draft", "Entry status") confidence := fs.Float64("confidence", 0.7, "Confidence from 0 to 1") scopeBranch := fs.String("scope-branch", "", "Optional branch scope") scopeModule := fs.String("scope-module", "", "Optional module scope") scopePath := fs.String("scope-path-prefix", "", "Optional path prefix scope") sourcePath := fs.String("source-path", "", "Main evidence path") sourceLine := fs.Int("source-line", 0, "Main evidence line") var aliases stringSliceFlag var deps stringSliceFlag fs.Var(&aliases, "alias", "Alias for this entry (repeatable)") fs.Var(&deps, "dep", "Dependency in type:locator[:hard|soft] format (repeatable)") if err := fs.Parse(args); err != nil { return err } if strings.TrimSpace(*repoPath) == "" { return fmt.Errorf("--repo is required") } absRepo, err := filepath.Abs(*repoPath) if err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() if err := st.Init(context.Background()); err != nil { return err } gitState := detectGitState(absRepo) if _, err := st.UpsertRepo(context.Background(), store.RepoState{ RootPath: absRepo, Name: filepath.Base(absRepo), VCS: "git", LastSeenBranch: gitState.branch, LastSeenCommit: gitState.commit, }); err != nil { return err } input := store.EntryInput{ RepoRoot: absRepo, Kind: *kind, Key: *key, Title: *title, Summary: *summary, DetailMD: *detail, Status: *status, Confidence: *confidence, ScopeBranch: *scopeBranch, ScopeModule: *scopeModule, ScopePathPrefix: *scopePath, SourcePath: relativize(absRepo, *sourcePath), SourceLine: *sourceLine, VerifiedAt: time.Now().UTC().Format(time.RFC3339), VerifiedOnCommit: gitState.commit, Aliases: aliases, Dependencies: parseDeps(absRepo, deps), } entryID, err := st.UpsertEntry(context.Background(), input) if err != nil { return err } fmt.Printf("upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key) return nil } func runSearch(args []string) error { fs := flag.NewFlagSet("search", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") query := fs.String("query", "", "Search query") repo := fs.String("repo", "", "Optional repo path filter (substring match)") limit := fs.Int("limit", 10, "Result limit") if err := fs.Parse(args); err != nil { return err } if strings.TrimSpace(*query) == "" { return fmt.Errorf("--query is required") } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() results, err := st.Search(context.Background(), store.SearchParams{ Query: *query, RepoFilter: *repo, Limit: *limit, }) if err != nil { return err } if len(results) == 0 { fmt.Println("no results") return nil } for i, r := range results { fmt.Printf("%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status) fmt.Printf(" %s\n", r.Title) fmt.Printf(" %s\n", r.Snippet) } return nil } func runRepos(args []string) error { fs := flag.NewFlagSet("repos", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() repos, err := st.ListRepos(context.Background()) if err != nil { return err } if len(repos) == 0 { fmt.Println("no repos") return nil } for _, repo := range repos { fmt.Printf("- %s (%d entries, updated %s)\n", repo.Path, repo.EntryCount, repo.UpdatedAt.Format(time.RFC3339)) } return nil } func runList(args []string) error { fs := flag.NewFlagSet("list", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repo := fs.String("repo", "", "Optional repo path filter (substring match)") kind := fs.String("kind", "", "Optional knowledge kind filter") status := fs.String("status", "", "Optional status filter") limit := fs.Int("limit", 20, "Result limit") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() items, err := st.ListEntries(context.Background(), store.ListParams{ RepoFilter: *repo, Kind: *kind, Status: *status, Limit: *limit, }) if err != nil { return err } if len(items) == 0 { fmt.Println("no entries") return nil } for _, item := range items { fmt.Printf("- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status) fmt.Printf(" %s\n", item.Summary) } return nil } func runEvents(args []string) error { fs := flag.NewFlagSet("events", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") id := fs.Int64("id", 0, "Entry id") repo := fs.String("repo", "", "Repo root when resolving by kind/key") kind := fs.String("kind", "", "Knowledge kind when resolving by kind/key") key := fs.String("key", "", "Knowledge key when resolving by kind/key") limit := fs.Int("limit", 20, "Result limit") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() ref, err := buildEntryRef(*id, *repo, *kind, *key) if err != nil { return err } entry, err := st.ResolveEntry(context.Background(), ref) if err != nil { return err } events, err := st.ListEvents(context.Background(), entry.ID, *limit) if err != nil { return err } if len(events) == 0 { fmt.Println("no events") return nil } fmt.Printf("%s:%s [%s] #%d\n", entry.Kind, entry.Key, entry.Status, entry.ID) for _, ev := range events { line := fmt.Sprintf("- %s %s", ev.CreatedAt.Format(time.RFC3339), ev.EventType) if ev.FromStatus != "" || ev.ToStatus != "" { line += fmt.Sprintf(" (%s -> %s)", emptyDash(ev.FromStatus), emptyDash(ev.ToStatus)) } fmt.Println(line) if ev.Reason != "" { fmt.Printf(" reason: %s\n", ev.Reason) } } return nil } func runLink(args []string) error { fs := flag.NewFlagSet("link", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") fromID := fs.Int64("from-id", 0, "From entry id") toID := fs.Int64("to-id", 0, "To entry id") relation := fs.String("relation", "", "Link relation") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() if err := st.AddLink(context.Background(), *fromID, *toID, *relation); err != nil { return err } fmt.Printf("linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID) return nil } func runVerify(args []string) error { fs := flag.NewFlagSet("verify", flag.ContinueOnError) dbPath := fs.String("db", "repo-memory.db", "SQLite database path") repo := fs.String("repo", "", "Optional repo root to verify; if omitted, verify all known repos") if err := fs.Parse(args); err != nil { return err } st, err := store.Open(*dbPath) if err != nil { return err } defer st.Close() var repoRoots []string if strings.TrimSpace(*repo) != "" { absRepo, err := filepath.Abs(*repo) if err != nil { return err } repoRoots = []string{absRepo} } else { repos, err := st.ListRepos(context.Background()) if err != nil { return err } for _, repoInfo := range repos { repoRoots = append(repoRoots, repoInfo.Path) } } if len(repoRoots) == 0 { fmt.Println("no repos") return nil } for _, repoRoot := range repoRoots { gitState := detectGitState(repoRoot) if gitState.commit == "" { fmt.Printf("%s: skipped (not a git repo or no HEAD)\n", repoRoot) continue } if _, err := st.UpsertRepo(context.Background(), store.RepoState{ RootPath: repoRoot, Name: filepath.Base(repoRoot), VCS: "git", LastSeenBranch: gitState.branch, LastSeenCommit: gitState.commit, }); err != nil { return err } candidates, err := st.ListVerifyCandidates(context.Background(), repoRoot) if err != nil { return err } changedCount := 0 staleCount := 0 for _, candidate := range candidates { nextStatus, reason, err := verifyCandidate(repoRoot, candidate) if err != nil { return fmt.Errorf("verify %s:%s: %w", candidate.Kind, candidate.Key, err) } if nextStatus != candidate.Status { switch nextStatus { case "needs_review": changedCount++ case "stale": staleCount++ } } if err := st.ApplyVerificationResult(context.Background(), candidate.ID, gitState.commit, nextStatus, reason); err != nil { return err } } fmt.Printf("%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount) } return nil } func usage() { fmt.Fprintf(os.Stderr, `repo-memory: repo memory CLI Usage: repo-memory init --db repo-memory.db repo-memory add --db repo-memory.db --repo /path/to/repo --kind term --key AITask --summary "..." repo-memory ingest --db repo-memory.db --repo /path/to/repo [--path docs/ai] repo-memory search --db repo-memory.db --query "actionCode fill" [--repo zeus] repo-memory list --db repo-memory.db [--repo zeus] [--kind term] [--status confirmed] repo-memory events --db repo-memory.db --id 1 repo-memory link --db repo-memory.db --from-id 1 --to-id 2 --relation related_to repo-memory verify --db repo-memory.db [--repo /path/to/repo] repo-memory repos --db repo-memory.db `) } func fatal(err error) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } type gitState struct { branch string commit string } func detectGitState(repo string) gitState { return gitState{ branch: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "--abbrev-ref", "HEAD")), commit: strings.TrimSpace(run(repo, "git", "-C", repo, "rev-parse", "HEAD")), } } func run(dir string, name string, args ...string) string { cmd := exec.Command(name, args...) cmd.Dir = dir out, err := cmd.Output() if err != nil { return "" } return string(out) } func parseDeps(repoRoot string, values []string) []store.DependencyInput { var deps []store.DependencyInput for _, raw := range values { parts := strings.Split(raw, ":") if len(parts) < 2 { continue } dep := store.DependencyInput{ Type: strings.TrimSpace(parts[0]), Locator: strings.TrimSpace(parts[1]), IsHard: true, } if dep.Type == "file" || dep.Type == "dir" || dep.Type == "glob" { dep.Locator = relativize(repoRoot, dep.Locator) } if len(parts) >= 3 { mode := strings.ToLower(strings.TrimSpace(parts[2])) if mode == "soft" { dep.IsHard = false } } if dep.Type != "" && dep.Locator != "" { deps = append(deps, dep) } } return deps } func relativize(repoRoot, maybePath string) string { if strings.TrimSpace(maybePath) == "" { return "" } if !filepath.IsAbs(maybePath) { return filepath.ToSlash(maybePath) } rel, err := filepath.Rel(repoRoot, maybePath) if err != nil { return filepath.ToSlash(maybePath) } if strings.HasPrefix(rel, "..") { return filepath.ToSlash(maybePath) } return filepath.ToSlash(rel) } func buildEntryRef(id int64, repo, kind, key string) (store.EntryRef, error) { if id > 0 { return store.EntryRef{ID: id}, nil } if strings.TrimSpace(repo) == "" || strings.TrimSpace(kind) == "" || strings.TrimSpace(key) == "" { return store.EntryRef{}, fmt.Errorf("either --id or --repo+--kind+--key is required") } absRepo, err := filepath.Abs(repo) if err != nil { return store.EntryRef{}, err } return store.EntryRef{RepoRoot: absRepo, Kind: kind, Key: key}, nil } func verifyCandidate(repoRoot string, candidate store.VerifyCandidate) (string, string, error) { if strings.TrimSpace(candidate.VerifiedOnCommit) == "" { return "needs_review", "missing verified_on_commit", nil } var paths []string for _, dep := range candidate.Dependencies { if !dep.IsHard { continue } switch dep.Type { case "file", "dir", "glob": paths = append(paths, dep.Locator) if dep.Type == "file" { absPath := dep.Locator if !filepath.IsAbs(absPath) { absPath = filepath.Join(repoRoot, dep.Locator) } if _, err := os.Stat(absPath); err != nil { if os.IsNotExist(err) { return "stale", "hard dependency missing: " + dep.Locator, nil } return candidate.Status, "", err } } } } if len(paths) == 0 { return candidate.Status, "", nil } changed, err := hasCommittedChanges(repoRoot, candidate.VerifiedOnCommit, paths) if err != nil { return candidate.Status, "", err } dirty, err := hasWorkingTreeChanges(repoRoot, paths) if err != nil { return candidate.Status, "", err } if changed || dirty { return "needs_review", "dependency paths changed since verification", nil } return candidate.Status, "", nil } func hasCommittedChanges(repoRoot, fromCommit string, paths []string) (bool, error) { args := append([]string{"-C", repoRoot, "diff", "--name-only", fromCommit + "..HEAD", "--"}, paths...) out, err := exec.Command("git", args...).Output() if err != nil { return false, err } return strings.TrimSpace(string(out)) != "", nil } func hasWorkingTreeChanges(repoRoot string, paths []string) (bool, error) { args := append([]string{"-C", repoRoot, "status", "--porcelain", "--"}, paths...) out, err := exec.Command("git", args...).Output() if err != nil { return false, err } return strings.TrimSpace(string(out)) != "", nil } func emptyDash(value string) string { if strings.TrimSpace(value) == "" { return "-" } return value }