Files
ai-workflow-skill/packages/repo-memory-runtime/cmd/repo-memory/main.go
T

633 lines
17 KiB
Go

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() {
os.Exit(Execute(os.Args[1:], os.Stdout, os.Stderr))
}
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)
fs.SetOutput(commandStderr)
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.Fprintf(commandStdout, "initialized %s\n", *dbPath)
return nil
}
func runIngest(args []string) error {
fs := flag.NewFlagSet("ingest", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintf(commandStdout, "ingested %d docs from %s\n", len(docs), absRepo)
return nil
}
func runAdd(args []string) error {
fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintf(commandStdout, "upserted entry %d (%s:%s)\n", entryID, input.Kind, input.Key)
return nil
}
func runSearch(args []string) error {
fs := flag.NewFlagSet("search", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintln(commandStdout, "no results")
return nil
}
for i, r := range results {
_, _ = fmt.Fprintf(commandStdout, "%d. [%s] %s:%s [%s]\n", i+1, filepath.Base(r.RepoPath), r.Kind, r.Key, r.Status)
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Title)
_, _ = fmt.Fprintf(commandStdout, " %s\n", r.Snippet)
}
return nil
}
func runRepos(args []string) error {
fs := flag.NewFlagSet("repos", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintln(commandStdout, "no repos")
return nil
}
for _, repo := range repos {
_, _ = fmt.Fprintf(commandStdout, "- %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)
fs.SetOutput(commandStderr)
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.Fprintln(commandStdout, "no entries")
return nil
}
for _, item := range items {
_, _ = fmt.Fprintf(commandStdout, "- #%d [%s] %s:%s [%s]\n", item.ID, filepath.Base(item.RepoPath), item.Kind, item.Key, item.Status)
_, _ = fmt.Fprintf(commandStdout, " %s\n", item.Summary)
}
return nil
}
func runEvents(args []string) error {
fs := flag.NewFlagSet("events", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintln(commandStdout, "no events")
return nil
}
_, _ = fmt.Fprintf(commandStdout, "%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.Fprintln(commandStdout, line)
if ev.Reason != "" {
_, _ = fmt.Fprintf(commandStdout, " reason: %s\n", ev.Reason)
}
}
return nil
}
func runLink(args []string) error {
fs := flag.NewFlagSet("link", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintf(commandStdout, "linked #%d -[%s]-> #%d\n", *fromID, *relation, *toID)
return nil
}
func runVerify(args []string) error {
fs := flag.NewFlagSet("verify", flag.ContinueOnError)
fs.SetOutput(commandStderr)
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.Fprintln(commandStdout, "no repos")
return nil
}
for _, repoRoot := range repoRoots {
gitState := detectGitState(repoRoot)
if gitState.commit == "" {
_, _ = fmt.Fprintf(commandStdout, "%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.Fprintf(commandStdout, "%s: verified %d entries, %d downgraded, %d stale\n", repoRoot, len(candidates), changedCount, staleCount)
}
return nil
}
func usage() {
_, _ = fmt.Fprintf(commandStderr, `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
`)
}
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
}