diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index ad1b5b6..95a6592 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -41,7 +41,8 @@ As of now: - `packages/coord-core` now exists as the first real extracted runtime package, containing shared coordination DB/schema, protocol, and store code, and the active coordination runtimes now import `coord-core` instead of root `internal/db`, `internal/store`, and `internal/protocol` - `packages/inbox-runtime` and `packages/orch-runtime` now exist as package-owned runtimes with their own `cmd/` entrypoints and package-local CLI wiring/tests, and the root skill packaging flow now builds `skills/inbox`, `skills/orch`, and `skills/council-review` from package entrypoints instead of root `cmd/` paths - `packages/orchd-runtime` now exists as the package-owned HTTP/query/web backend runtime, with package-local `cmd/orchd`, app, query, and HTTP transport code plus passing package-local tests -- a repo-local `scripts/package_skill_clis.sh` packaging flow now builds bundled skill CLI assets for `inbox`, `orch`, and `council-review` +- `packages/repo-memory-runtime` now exists as the package-owned `briefdb` runtime imported from the exploratory prototype, `skills/repo-memory` now exists as an agent-facing skill bundle, and the declarative root packaging flow now builds a bundled `skills/repo-memory/assets/briefdb` binary from the package runtime +- a repo-local declarative packaging flow now builds bundled skill CLI assets for `inbox`, `orch`, `council-review`, and `repo-memory` - `orch` now implements `run init/show`, `task add`, `dep add`, `ready`, `dispatch`, `reconcile`, `wait`, `blocked`, `answer`, `retry`, `reassign`, `cancel`, `cleanup`, and `status` - `orch` can create runs, gate tasks through dependencies, dispatch work through `inbox`, reconcile worker thread state back into task state, answer blocked tasks, retry or reassign work, cancel tasks or runs, clean attempt worktrees, and create per-attempt Git worktrees during strict dispatch - `orch dispatch` now supports `--repo-path`, `--workspace-root`, and `--strict-worktree`, auto-enables strict worktree mode for code-like tasks inferred from task metadata, resolves committed base revisions, records workspace metadata on attempts, and writes that metadata into inbox task payloads @@ -509,11 +510,14 @@ Completed so far: - `scripts/package_skill_runtimes.sh package` now builds and installs `skills/inbox/assets/inbox`, `skills/orch/assets/orch`, and `skills/council-review/assets/orch` from package entrypoints - the legacy `scripts/package_skill_clis.sh` entrypoint now delegates to the declarative package-oriented packaging flow instead of hardcoding root `cmd/` paths - `packages/orchd-runtime/cmd/orchd` plus `packages/orchd-runtime/internal/{app,httpapi,query}` now provide a package-owned web backend runtime and pass `go test ./...` +- `packages/repo-memory-runtime/cmd/briefdb` plus its package-local `internal/brief` and `internal/store` now provide a package-owned repo-memory runtime and pass `go test ./...` +- `skills/repo-memory/` now exists with `SKILL.md`, `agents/openai.yaml`, and a bundled `assets/briefdb` binary produced by the declarative packaging flow +- `docs/tests/repo-memory-skill/` now exists with a README plus an initial forward-test case covering search-before-add and durable entry retrieval through the bundled skill Remaining: -- import `repo-memory` as its own runtime package and add the corresponding skill bundle -- graduate the bundle scaffold into the primary packaging flow once package-owned runtime entrypoints exist +- remove or reduce the remaining legacy root runtime ownership under `cmd/` and `internal/` +- decide whether the external `../tmp/briefdb` prototype should be archived, documented as superseded, or deleted after the package import is considered stable ## Immediate Next Task @@ -522,11 +526,11 @@ If a new agent is taking over now, the next concrete step should be: 1. treat `Milestone 9: Web Product Phase 2 Read-Only Operator UI` as complete for the initial operator surface and do not expand web feature scope further until the workspace split is decided package-by-package 2. treat the Phase 1 workspace bootstrap for `Milestone 10` as complete and keep the new `go.work`, `packages/`, and declarative bundle metadata as the baseline for all further migration steps 3. treat the shared coordination kernel extraction into `packages/coord-core` as complete and move `inbox` plus `orch` into package-owned runtimes next -4. treat `inbox-runtime`, `orch-runtime`, and `orchd-runtime` as package-owned and import `repo-memory` next so the last planned runtime package joins the workspace -5. add `skills/repo-memory` and the corresponding forward-test plan once the runtime import is in place -6. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as runtime ownership moves from root paths to package paths +4. treat `coord-core`, `inbox-runtime`, `orch-runtime`, `orchd-runtime`, and `repo-memory-runtime` as the package-owned runtime baseline and start Phase 6 by removing or shrinking the remaining root-owned runtime paths +5. keep the authored skill forward-test plans under `docs/tests/*-skill/` synchronized as runtime ownership moves from root paths to package paths +6. decide how to retire or archive the external `../tmp/briefdb` prototype once the imported runtime is stable enough to be the sole source of truth -The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, the web product now has its first real operator-facing read surfaces, and the repository has completed the workspace bootstrap plus the shared coordination, inbox, orch, and orchd package-runtime extraction phases of the skill monorepo migration, so the next step should be importing `repo-memory` and then removing legacy root ownership, not continuing to accrete new root-owned runtime paths. +The inbox implementation and its human-readable test-plan set are already in place, `orch` supports the main scheduler loop plus the complete council start/wait/tally/report workflow, the web product now has its first real operator-facing read surfaces, and the repository has completed the workspace bootstrap plus the shared coordination, inbox, orch, orchd, and repo-memory package-runtime extraction phases of the skill monorepo migration, so the next step should be removing legacy root ownership and finalizing source-of-truth boundaries rather than continuing to accrete new root-owned runtime paths. ## Recommended Driver Choices diff --git a/docs/roadmaps/active/skill-workspace-monorepo-migration.md b/docs/roadmaps/active/skill-workspace-monorepo-migration.md index 91fbeee..b9fd082 100644 --- a/docs/roadmaps/active/skill-workspace-monorepo-migration.md +++ b/docs/roadmaps/active/skill-workspace-monorepo-migration.md @@ -29,7 +29,7 @@ - [x] Phase 2: extract shared coordination code into `packages/coord-core` - [x] Phase 3: extract `inbox-runtime` and `orch-runtime` - [x] Phase 4: extract `orchd-runtime` -- [ ] Phase 5: import `repo-memory-runtime` and add `skills/repo-memory` +- [x] Phase 5: import `repo-memory-runtime` and add `skills/repo-memory` - [ ] Phase 6: remove root runtime ownership and normalize package-based packaging ## Files @@ -54,4 +54,4 @@ ## Next Step -- start Phase 5 by importing the exploratory `repo-memory` runtime into `packages/repo-memory-runtime`, adding `skills/repo-memory`, and wiring it into the declarative bundle packaging flow +- start Phase 6 by removing or reducing legacy root runtime ownership now that `coord-core`, `inbox-runtime`, `orch-runtime`, `orchd-runtime`, and `repo-memory-runtime` all exist as package-owned runtimes diff --git a/docs/tests/repo-memory-skill/README.md b/docs/tests/repo-memory-skill/README.md new file mode 100644 index 0000000..f20af35 --- /dev/null +++ b/docs/tests/repo-memory-skill/README.md @@ -0,0 +1,70 @@ +# Repo Memory Skill Test Plan + +## Purpose + +This directory tracks human-readable test plans for the `skills/repo-memory/` +Codex skill bundle. + +These documents are not package-level unit tests for `briefdb`. +Those live with the runtime under `packages/repo-memory-runtime/`. + +This directory covers a different surface: + +- whether an agent can actually use the packaged `repo-memory` skill +- whether the bundled `./assets/briefdb` CLI works inside real skill-guided + repository work +- whether durable repository knowledge is stored and retrieved correctly + +## Test Model + +- `README.md` is the index for this directory +- each skill test case lives in its own Markdown file +- use stable case slugs in filenames + +## Shared Execution Contract + +Use these defaults unless a case file explicitly overrides them: + +- run the scenario with one real agent using the bundled `repo-memory` skill +- create an isolated temporary directory, repository fixture, and SQLite DB path +- require the agent to use the bundled `./assets/briefdb` CLI instead of ad hoc + notes +- validate final database state independently from the main thread after the + agent stops + +## Per-Case Template + +Each case file should use this structure: + +- `Test Type` +- `Purpose` +- `Preconditions` +- `Inputs` +- `Execution Parameters` +- `Execution Steps` +- `Validation Commands` +- `Expected Outcomes` +- `Assertions` +- `Cleanup` +- `Recorded Example Run` when a real run has already been captured + +## Case Files + +| Case Slug | File | Coverage Note | +| --- | --- | --- | +| `search-and-add-through-bundled-cli` | [search-and-add-through-bundled-cli.md](./search-and-add-through-bundled-cli.md) | validates that an agent can miss on search, add one durable entry, then retrieve it through the packaged `repo-memory` skill | + +## Scope + +In scope: + +- explicit `$repo-memory` skill invocation +- bundled `./assets/briefdb` CLI usage +- durable knowledge add/search/list/event flows +- package-backed SQLite memory database behavior as surfaced through the skill + +Out of scope: + +- package-level unit tests for `briefdb` +- future auto-export flows such as `repo-brief` generation +- implicit skill triggering without `$repo-memory` diff --git a/docs/tests/repo-memory-skill/search-and-add-through-bundled-cli.md b/docs/tests/repo-memory-skill/search-and-add-through-bundled-cli.md new file mode 100644 index 0000000..402a051 --- /dev/null +++ b/docs/tests/repo-memory-skill/search-and-add-through-bundled-cli.md @@ -0,0 +1,70 @@ +# Search And Add Through Bundled CLI + +## Test Type + +- forward skill execution + +## Purpose + +- validate that a single agent can use `skills/repo-memory/` to search an empty + memory DB, write one durable entry through the bundled CLI, and retrieve the + same knowledge afterwards + +## Preconditions + +- `skills/repo-memory/assets/briefdb` exists and is executable +- the test runner can create a temporary Git repository fixture +- the test runner can create a temporary SQLite DB path + +## Inputs + +- `SKILL_PATH=/.../skills/repo-memory` +- `TMPDIR=/tmp/...` +- `DB_PATH=TMPDIR/repo-memory.db` +- `REPO_PATH=TMPDIR/repo-fixture` + +## Execution Parameters + +- one agent only +- per-agent timeout: `3m` +- overall timeout: `4m` + +## Execution Steps + +1. Create a temporary Git repository fixture under `REPO_PATH`. +2. Add one file that will serve as evidence for the durable knowledge entry. +3. Ask the agent to use `$repo-memory` against `DB_PATH`. +4. Have the agent initialize the DB, search for a key that does not yet exist, + add one `term` entry with evidence, then search again for the same key. +5. Capture the agent summary and the concrete entry key used. + +## Validation Commands + +Run these from the main thread after the agent stops: + +```bash +SKILL_PATH/assets/briefdb init --db DB_PATH +SKILL_PATH/assets/briefdb search --db DB_PATH --repo REPO_PATH --query "plan task" +SKILL_PATH/assets/briefdb list --db DB_PATH --repo REPO_PATH --kind term +SKILL_PATH/assets/briefdb events --db DB_PATH --id 1 +``` + +## Expected Outcomes + +- the first search misses before the entry is written +- the `add` command succeeds and creates entry `1` +- the second search returns the new `term` +- `list` returns exactly one `term` entry for the fixture repo +- `events` includes a `created` event for the new entry + +## Assertions + +- the stored entry key matches the one the agent added +- the stored entry summary matches the durable fact the agent recorded +- the stored entry is linked to the target repo path +- the agent used the bundled CLI rather than free-form notes + +## Cleanup + +- keep the temporary DB and repo on failure +- remove temporary artifacts on success only if replay evidence is not needed diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..9c79a75 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,2 @@ +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= diff --git a/packages/repo-memory-runtime/cmd/briefdb/main.go b/packages/repo-memory-runtime/cmd/briefdb/main.go new file mode 100644 index 0000000..3cf05f4 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/briefdb/main.go @@ -0,0 +1,673 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "ai-workflow-skill/packages/repo-memory-runtime/internal/brief" + "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", "briefs.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", "briefs.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 := brief.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", "briefs.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", "briefs.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", "briefs.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", "briefs.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", "briefs.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", "briefs.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", "briefs.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, `briefdb: repo memory CLI + +Usage: + briefdb init --db briefs.db + briefdb add --db briefs.db --repo /path/to/repo --kind term --key AITask --summary "..." + briefdb ingest --db briefs.db --repo /path/to/repo [--path docs/ai] + briefdb search --db briefs.db --query "actionCode fill" [--repo zeus] + briefdb list --db briefs.db [--repo zeus] [--kind term] [--status confirmed] + briefdb events --db briefs.db --id 1 + briefdb link --db briefs.db --from-id 1 --to-id 2 --relation related_to + briefdb verify --db briefs.db [--repo /path/to/repo] + briefdb repos --db briefs.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 +} diff --git a/packages/repo-memory-runtime/cmd/briefdb/main_test.go b/packages/repo-memory-runtime/cmd/briefdb/main_test.go new file mode 100644 index 0000000..a7586d2 --- /dev/null +++ b/packages/repo-memory-runtime/cmd/briefdb/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "ai-workflow-skill/packages/repo-memory-runtime/internal/store" +) + +func TestVerifyCandidateDetectsFileChange(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + runCmd(t, repo, "git", "init") + runCmd(t, repo, "git", "config", "user.email", "test@example.com") + runCmd(t, repo, "git", "config", "user.name", "Tester") + + file := filepath.Join(repo, "foo.txt") + if err := os.WriteFile(file, []byte("hello\n"), 0o644); err != nil { + t.Fatal(err) + } + runCmd(t, repo, "git", "add", ".") + runCmd(t, repo, "git", "commit", "-m", "init") + commit := stringsTrim(runOut(t, repo, "git", "rev-parse", "HEAD")) + + if err := os.WriteFile(file, []byte("changed\n"), 0o644); err != nil { + t.Fatal(err) + } + + status, reason, err := verifyCandidate(repo, store.VerifyCandidate{ + Status: "confirmed", + VerifiedOnCommit: commit, + Dependencies: []store.DependencyInput{ + {Type: "file", Locator: "foo.txt", IsHard: true}, + }, + }) + if err != nil { + t.Fatal(err) + } + if status != "needs_review" { + t.Fatalf("status = %q, want needs_review", status) + } + if reason == "" { + t.Fatal("expected downgrade reason") + } +} + +func TestVerifyCandidateMarksMissingDependencyStale(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + runCmd(t, repo, "git", "init") + runCmd(t, repo, "git", "config", "user.email", "test@example.com") + runCmd(t, repo, "git", "config", "user.name", "Tester") + runCmd(t, repo, "git", "commit", "--allow-empty", "-m", "init") + commit := stringsTrim(runOut(t, repo, "git", "rev-parse", "HEAD")) + + status, reason, err := verifyCandidate(repo, store.VerifyCandidate{ + Status: "confirmed", + VerifiedOnCommit: commit, + Dependencies: []store.DependencyInput{ + {Type: "file", Locator: "missing.txt", IsHard: true}, + }, + }) + if err != nil { + t.Fatal(err) + } + if status != "stale" { + t.Fatalf("status = %q, want stale", status) + } + if reason == "" { + t.Fatal("expected stale reason") + } +} + +func runCmd(t *testing.T, dir string, name string, args ...string) { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, string(out)) + } +} + +func runOut(t *testing.T, dir string, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.Output() + if err != nil { + t.Fatalf("%s %v failed: %v", name, args, err) + } + return string(out) +} + +func stringsTrim(value string) string { + for len(value) > 0 && (value[len(value)-1] == '\n' || value[len(value)-1] == '\r' || value[len(value)-1] == ' ') { + value = value[:len(value)-1] + } + return value +} diff --git a/packages/repo-memory-runtime/go.mod b/packages/repo-memory-runtime/go.mod index a035387..64b0825 100644 --- a/packages/repo-memory-runtime/go.mod +++ b/packages/repo-memory-runtime/go.mod @@ -1,3 +1,7 @@ module ai-workflow-skill/packages/repo-memory-runtime go 1.26 + +require github.com/mattn/go-sqlite3 v1.14.37 + +require github.com/mattn/go-sqlite3 v1.14.37 diff --git a/packages/repo-memory-runtime/internal/brief/load.go b/packages/repo-memory-runtime/internal/brief/load.go new file mode 100644 index 0000000..6b1ddec --- /dev/null +++ b/packages/repo-memory-runtime/internal/brief/load.go @@ -0,0 +1,223 @@ +package brief + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +type Document struct { + RepoPath string + DocPath string + Kind string + Title string + Hash string + Metadata map[string]string + Sections []Section +} + +type Section struct { + Heading string + Level int + Ordinal int + Body string +} + +func LoadRepo(repoPath, scanPath string) ([]Document, error) { + root := filepath.Join(repoPath, scanPath) + info, err := os.Stat(root) + if err != nil { + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", root) + } + + var docs []Document + err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() { + return nil + } + if strings.ToLower(filepath.Ext(path)) != ".md" { + return nil + } + + doc, parseErr := ParseFile(repoPath, path) + if parseErr != nil { + return parseErr + } + docs = append(docs, doc) + return nil + }) + if err != nil { + return nil, err + } + + sort.Slice(docs, func(i, j int) bool { + return docs[i].DocPath < docs[j].DocPath + }) + return docs, nil +} + +func ParseFile(repoPath, path string) (Document, error) { + content, err := os.ReadFile(path) + if err != nil { + return Document{}, err + } + + relPath, err := filepath.Rel(repoPath, path) + if err != nil { + return Document{}, err + } + + meta, body := splitFrontMatter(string(content)) + sections, title := parseSections(body, fallbackTitle(path)) + if len(sections) == 0 { + sections = []Section{{ + Heading: fallbackTitle(path), + Level: 1, + Ordinal: 1, + Body: strings.TrimSpace(body), + }} + } + if metaTitle := strings.TrimSpace(meta["title"]); metaTitle != "" { + title = metaTitle + } + if title == "" { + title = fallbackTitle(path) + } + + sum := sha256.Sum256(content) + return Document{ + RepoPath: repoPath, + DocPath: filepath.ToSlash(relPath), + Kind: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), + Title: title, + Hash: hex.EncodeToString(sum[:]), + Metadata: meta, + Sections: sections, + }, nil +} + +func splitFrontMatter(content string) (map[string]string, string) { + meta := map[string]string{} + lines := strings.Split(content, "\n") + if len(lines) < 3 || strings.TrimSpace(lines[0]) != "---" { + return meta, content + } + + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + parts := strings.SplitN(lines[i], ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) + if key != "" { + meta[key] = val + } + } + if end == -1 { + return map[string]string{}, content + } + + return meta, strings.Join(lines[end+1:], "\n") +} + +func parseSections(body, fallback string) ([]Section, string) { + lines := strings.Split(body, "\n") + var sections []Section + var title string + + currentHeading := "Overview" + currentLevel := 1 + currentBody := make([]string, 0, len(lines)) + ordinal := 0 + + flush := func() { + text := strings.TrimSpace(strings.Join(currentBody, "\n")) + if text == "" && ordinal > 0 { + currentBody = currentBody[:0] + return + } + ordinal++ + heading := currentHeading + if heading == "" { + heading = fallback + } + sections = append(sections, Section{ + Heading: heading, + Level: currentLevel, + Ordinal: ordinal, + Body: text, + }) + currentBody = currentBody[:0] + } + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + level, heading, ok := parseHeading(trimmed) + if !ok { + currentBody = append(currentBody, line) + continue + } + + if strings.TrimSpace(strings.Join(currentBody, "\n")) != "" || ordinal > 0 { + flush() + } + currentHeading = heading + currentLevel = level + if title == "" && level == 1 { + title = heading + } + } + + if len(currentBody) > 0 || ordinal == 0 { + flush() + } + + return sections, title +} + +func parseHeading(line string) (int, string, bool) { + if line == "" || !strings.HasPrefix(line, "#") { + return 0, "", false + } + + level := 0 + for level < len(line) && line[level] == '#' { + level++ + } + if level == 0 || level > 6 { + return 0, "", false + } + if level >= len(line) || line[level] != ' ' { + return 0, "", false + } + + heading := strings.TrimSpace(line[level:]) + if heading == "" { + return 0, "", false + } + return level, heading, true +} + +func fallbackTitle(path string) string { + base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + base = strings.ReplaceAll(base, "-", " ") + base = strings.ReplaceAll(base, "_", " ") + return strings.TrimSpace(base) +} diff --git a/packages/repo-memory-runtime/internal/brief/load_test.go b/packages/repo-memory-runtime/internal/brief/load_test.go new file mode 100644 index 0000000..02a77ed --- /dev/null +++ b/packages/repo-memory-runtime/internal/brief/load_test.go @@ -0,0 +1,60 @@ +package brief + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseFile(t *testing.T) { + t.Parallel() + + repo := t.TempDir() + path := filepath.Join(repo, "docs", "ai") + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + + file := filepath.Join(path, "repo-brief.md") + content := strings.TrimSpace(` +--- +title: Zeus Repo Brief +repo: zeus +--- + +# Repo Brief + +Intro text. + +## Module Map + +- app/app +- gateway + +## Danger Zones + +- shared libs +`) + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + doc, err := ParseFile(repo, file) + if err != nil { + t.Fatal(err) + } + + if got, want := doc.Title, "Zeus Repo Brief"; got != want { + t.Fatalf("title = %q, want %q", got, want) + } + if got, want := doc.DocPath, "docs/ai/repo-brief.md"; got != want { + t.Fatalf("doc path = %q, want %q", got, want) + } + if len(doc.Sections) != 3 { + t.Fatalf("sections = %d, want 3", len(doc.Sections)) + } + if got, want := doc.Sections[1].Heading, "Module Map"; got != want { + t.Fatalf("section heading = %q, want %q", got, want) + } +} diff --git a/packages/repo-memory-runtime/internal/store/store.go b/packages/repo-memory-runtime/internal/store/store.go new file mode 100644 index 0000000..43027cc --- /dev/null +++ b/packages/repo-memory-runtime/internal/store/store.go @@ -0,0 +1,939 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "ai-workflow-skill/packages/repo-memory-runtime/internal/brief" + _ "github.com/mattn/go-sqlite3" +) + +const schema = ` +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS repos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + root_path TEXT NOT NULL UNIQUE, + vcs TEXT NOT NULL DEFAULT 'git', + default_branch TEXT, + last_seen_branch TEXT, + last_seen_commit TEXT, + last_sync_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS knowledge_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + key TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL, + detail_md TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.7, + scope_branch TEXT, + scope_module TEXT, + scope_path_prefix TEXT, + source_path TEXT, + source_line INTEGER, + verified_at TEXT, + verified_on_commit TEXT, + last_checked_at TEXT, + last_checked_commit TEXT, + stale_reason TEXT, + superseded_by INTEGER REFERENCES knowledge_entries(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(repo_id, kind, key, scope_branch, scope_module, scope_path_prefix) +); + +CREATE TABLE IF NOT EXISTS knowledge_dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, + dep_type TEXT NOT NULL, + locator TEXT NOT NULL, + is_hard INTEGER NOT NULL DEFAULT 1, + baseline_hash TEXT, + note TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_entry_id + ON knowledge_dependencies(entry_id); + +CREATE INDEX IF NOT EXISTS idx_knowledge_dependencies_locator + ON knowledge_dependencies(dep_type, locator); + +CREATE TABLE IF NOT EXISTS knowledge_aliases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, + alias TEXT NOT NULL, + normalized_alias TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(entry_id, normalized_alias) +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_aliases_normalized + ON knowledge_aliases(normalized_alias); + +CREATE TABLE IF NOT EXISTS knowledge_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, + to_entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, + relation TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_links_from + ON knowledge_links(from_entry_id); + +CREATE INDEX IF NOT EXISTS idx_knowledge_links_to + ON knowledge_links(to_entry_id); + +CREATE TABLE IF NOT EXISTS knowledge_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL REFERENCES knowledge_entries(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + from_status TEXT, + to_status TEXT, + reason TEXT, + evidence TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_knowledge_events_entry_id_created_at + ON knowledge_events(entry_id, created_at); +` + +type Store struct { + db *sql.DB +} + +type RepoState struct { + RootPath string + Name string + VCS string + DefaultBranch string + LastSeenBranch string + LastSeenCommit string +} + +type DependencyInput struct { + Type string + Locator string + IsHard bool + BaselineHash string + Note string +} + +type EntryInput struct { + RepoRoot string + Kind string + Key string + Title string + Summary string + DetailMD string + Status string + Confidence float64 + ScopeBranch string + ScopeModule string + ScopePathPrefix string + SourcePath string + SourceLine int + VerifiedAt string + VerifiedOnCommit string + LastCheckedAt string + LastCheckedCommit string + StaleReason string + Aliases []string + Dependencies []DependencyInput +} + +type SearchParams struct { + Query string + RepoFilter string + Limit int +} + +type SearchResult struct { + ID int64 + RepoPath string + Kind string + Key string + Title string + Status string + Summary string + Snippet string +} + +type ListParams struct { + RepoFilter string + Kind string + Status string + Limit int +} + +type EntryListItem struct { + ID int64 + RepoPath string + Kind string + Key string + Title string + Status string + Summary string + VerifiedOnCommit string + UpdatedAt time.Time +} + +type EntryRef struct { + ID int64 + RepoRoot string + Kind string + Key string +} + +type EventRecord struct { + ID int64 + EventType string + FromStatus string + ToStatus string + Reason string + Evidence string + CreatedAt time.Time +} + +type VerifyCandidate struct { + ID int64 + RepoPath string + Kind string + Key string + Title string + Status string + VerifiedOnCommit string + Dependencies []DependencyInput +} + +type RepoInfo struct { + Path string + Name string + EntryCount int + UpdatedAt time.Time +} + +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite3", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + return &Store{db: db}, nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) Init(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, schema) + return err +} + +func (s *Store) UpsertRepo(ctx context.Context, state RepoState) (int64, error) { + now := nowUTC() + if strings.TrimSpace(state.RootPath) == "" { + return 0, fmt.Errorf("repo root path is required") + } + if strings.TrimSpace(state.Name) == "" { + state.Name = filepath.Base(state.RootPath) + } + if strings.TrimSpace(state.VCS) == "" { + state.VCS = "git" + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO repos(name, root_path, vcs, default_branch, last_seen_branch, last_seen_commit, last_sync_at, created_at, updated_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(root_path) DO UPDATE SET + name = excluded.name, + vcs = excluded.vcs, + default_branch = excluded.default_branch, + last_seen_branch = excluded.last_seen_branch, + last_seen_commit = excluded.last_seen_commit, + last_sync_at = excluded.last_sync_at, + updated_at = excluded.updated_at + `, state.Name, state.RootPath, state.VCS, nullable(state.DefaultBranch), nullable(state.LastSeenBranch), nullable(state.LastSeenCommit), now, now, now) + if err != nil { + return 0, err + } + + var repoID int64 + if err := s.db.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, state.RootPath).Scan(&repoID); err != nil { + return 0, err + } + return repoID, nil +} + +func (s *Store) UpsertEntry(ctx context.Context, in EntryInput) (int64, error) { + if strings.TrimSpace(in.RepoRoot) == "" { + return 0, fmt.Errorf("repo root is required") + } + if strings.TrimSpace(in.Kind) == "" { + return 0, fmt.Errorf("kind is required") + } + if strings.TrimSpace(in.Key) == "" { + return 0, fmt.Errorf("key is required") + } + if strings.TrimSpace(in.Summary) == "" { + return 0, fmt.Errorf("summary is required") + } + if in.Confidence <= 0 { + in.Confidence = 0.7 + } + if strings.TrimSpace(in.Status) == "" { + in.Status = "draft" + } + if strings.TrimSpace(in.Title) == "" { + in.Title = in.Key + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + var repoID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM repos WHERE root_path = ?`, in.RepoRoot).Scan(&repoID); err != nil { + if err == sql.ErrNoRows { + return 0, fmt.Errorf("repo %s is not registered", in.RepoRoot) + } + return 0, err + } + + now := nowUTC() + var entryID int64 + var oldStatus sql.NullString + err = tx.QueryRowContext(ctx, ` + SELECT id, status + FROM knowledge_entries + WHERE repo_id = ? + AND kind = ? + AND key = ? + AND scope_branch IS ? + AND scope_module IS ? + AND scope_path_prefix IS ? + `, repoID, in.Kind, in.Key, nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix)).Scan(&entryID, &oldStatus) + + if err != nil && err != sql.ErrNoRows { + return 0, err + } + + eventType := "created" + fromStatus := "" + if err == sql.ErrNoRows { + res, execErr := tx.ExecContext(ctx, ` + INSERT INTO knowledge_entries( + repo_id, kind, key, title, summary, detail_md, status, confidence, + scope_branch, scope_module, scope_path_prefix, source_path, source_line, + verified_at, verified_on_commit, last_checked_at, last_checked_commit, + stale_reason, created_at, updated_at + ) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, repoID, in.Kind, in.Key, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence, + nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), nullable(in.SourcePath), nullableInt(in.SourceLine), + nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), + nullable(in.StaleReason), now, now) + if execErr != nil { + return 0, execErr + } + entryID, err = res.LastInsertId() + if err != nil { + return 0, err + } + } else { + eventType = "updated" + fromStatus = oldStatus.String + if _, err := tx.ExecContext(ctx, ` + UPDATE knowledge_entries + SET title = ?, summary = ?, detail_md = ?, status = ?, confidence = ?, + scope_branch = ?, scope_module = ?, scope_path_prefix = ?, + source_path = ?, source_line = ?, verified_at = ?, verified_on_commit = ?, + last_checked_at = ?, last_checked_commit = ?, stale_reason = ?, updated_at = ? + WHERE id = ? + `, in.Title, in.Summary, in.DetailMD, in.Status, in.Confidence, + nullable(in.ScopeBranch), nullable(in.ScopeModule), nullable(in.ScopePathPrefix), + nullable(in.SourcePath), nullableInt(in.SourceLine), nullable(in.VerifiedAt), nullable(in.VerifiedOnCommit), + nullable(in.LastCheckedAt), nullable(in.LastCheckedCommit), nullable(in.StaleReason), now, entryID); err != nil { + return 0, err + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_aliases WHERE entry_id = ?`, entryID); err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM knowledge_dependencies WHERE entry_id = ?`, entryID); err != nil { + return 0, err + } + } + + for _, alias := range dedupeStrings(in.Aliases) { + if strings.TrimSpace(alias) == "" { + continue + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO knowledge_aliases(entry_id, alias, normalized_alias, created_at) + VALUES(?, ?, ?, ?) + `, entryID, alias, normalizeAlias(alias), now); err != nil { + return 0, err + } + } + + for _, dep := range in.Dependencies { + if strings.TrimSpace(dep.Type) == "" || strings.TrimSpace(dep.Locator) == "" { + continue + } + isHard := 0 + if dep.IsHard { + isHard = 1 + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO knowledge_dependencies(entry_id, dep_type, locator, is_hard, baseline_hash, note, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?) + `, entryID, dep.Type, dep.Locator, isHard, nullable(dep.BaselineHash), nullable(dep.Note), now); err != nil { + return 0, err + } + } + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?) + `, entryID, eventType, nullable(fromStatus), nullable(in.Status), nullable(in.StaleReason), nullable(eventEvidence(in)), now); err != nil { + return 0, err + } + + return entryID, tx.Commit() +} + +func (s *Store) ImportDocument(ctx context.Context, doc brief.Document, repoState RepoState) error { + repoState.RootPath = doc.RepoPath + if _, err := s.UpsertRepo(ctx, repoState); err != nil { + return err + } + + for _, sec := range doc.Sections { + key := buildImportedKey(doc.DocPath, sec.Heading, sec.Ordinal) + entry := EntryInput{ + RepoRoot: doc.RepoPath, + Kind: classifyImportedKind(doc.Kind, sec.Heading), + Key: key, + Title: sec.Heading, + Summary: sectionSummary(sec.Body), + DetailMD: sec.Body, + Status: "confirmed", + Confidence: 0.8, + SourcePath: doc.DocPath, + VerifiedAt: nowUTC(), + VerifiedOnCommit: repoState.LastSeenCommit, + Dependencies: []DependencyInput{{ + Type: "file", + Locator: doc.DocPath, + IsHard: true, + Note: "Imported from markdown knowledge source", + }}, + } + if _, err := s.UpsertEntry(ctx, entry); err != nil { + return fmt.Errorf("import section %s: %w", sec.Heading, err) + } + } + return nil +} + +func (s *Store) Search(ctx context.Context, params SearchParams) ([]SearchResult, error) { + limit := params.Limit + if limit <= 0 { + limit = 10 + } + + terms := dedupeStrings(strings.Fields(strings.ToLower(params.Query))) + if len(terms) == 0 { + return nil, nil + } + + query := ` + SELECT DISTINCT + e.id, + r.root_path, + e.kind, + e.key, + e.title, + e.status, + e.summary, + e.detail_md + FROM knowledge_entries e + JOIN repos r ON r.id = e.repo_id + LEFT JOIN knowledge_aliases a ON a.entry_id = e.id + WHERE 1 = 1 + ` + args := make([]any, 0, len(terms)*5+2) + if params.RepoFilter != "" { + query += ` AND r.root_path LIKE ?` + args = append(args, "%"+params.RepoFilter+"%") + } + for _, term := range terms { + query += ` AND ( + LOWER(e.key) LIKE ? + OR LOWER(e.title) LIKE ? + OR LOWER(e.summary) LIKE ? + OR LOWER(e.detail_md) LIKE ? + OR LOWER(COALESCE(a.normalized_alias, '')) LIKE ? + )` + like := "%" + term + "%" + args = append(args, like, like, like, like, like) + } + query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var res SearchResult + var detail string + if err := rows.Scan(&res.ID, &res.RepoPath, &res.Kind, &res.Key, &res.Title, &res.Status, &res.Summary, &detail); err != nil { + return nil, err + } + res.Snippet = makeSnippet(res.Summary+"\n"+detail, terms) + results = append(results, res) + } + return results, rows.Err() +} + +func (s *Store) ListRepos(ctx context.Context) ([]RepoInfo, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT r.root_path, r.name, COUNT(e.id) AS entry_count, r.updated_at + FROM repos r + LEFT JOIN knowledge_entries e ON e.repo_id = r.id + GROUP BY r.id + ORDER BY r.root_path + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []RepoInfo + for rows.Next() { + var repo RepoInfo + var updated string + if err := rows.Scan(&repo.Path, &repo.Name, &repo.EntryCount, &updated); err != nil { + return nil, err + } + repo.UpdatedAt, err = time.Parse(time.RFC3339, updated) + if err != nil { + return nil, fmt.Errorf("parse repo updated_at: %w", err) + } + repos = append(repos, repo) + } + return repos, rows.Err() +} + +func (s *Store) ListEntries(ctx context.Context, params ListParams) ([]EntryListItem, error) { + limit := params.Limit + if limit <= 0 { + limit = 20 + } + + query := ` + SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at + FROM knowledge_entries e + JOIN repos r ON r.id = e.repo_id + WHERE 1 = 1 + ` + args := make([]any, 0, 4) + if params.RepoFilter != "" { + query += ` AND r.root_path LIKE ?` + args = append(args, "%"+params.RepoFilter+"%") + } + if params.Kind != "" { + query += ` AND e.kind = ?` + args = append(args, params.Kind) + } + if params.Status != "" { + query += ` AND e.status = ?` + args = append(args, params.Status) + } + query += ` ORDER BY r.root_path, e.kind, e.key LIMIT ?` + args = append(args, limit) + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []EntryListItem + for rows.Next() { + var item EntryListItem + var updated string + if err := rows.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated); err != nil { + return nil, err + } + item.UpdatedAt, err = time.Parse(time.RFC3339, updated) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) ResolveEntry(ctx context.Context, ref EntryRef) (EntryListItem, error) { + if ref.ID > 0 { + return s.getEntryByID(ctx, ref.ID) + } + if strings.TrimSpace(ref.RepoRoot) == "" || strings.TrimSpace(ref.Kind) == "" || strings.TrimSpace(ref.Key) == "" { + return EntryListItem{}, fmt.Errorf("either id or repo+kind+key is required") + } + + row := s.db.QueryRowContext(ctx, ` + SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at + FROM knowledge_entries e + JOIN repos r ON r.id = e.repo_id + WHERE r.root_path = ? AND e.kind = ? AND e.key = ? + ORDER BY e.updated_at DESC + LIMIT 1 + `, ref.RepoRoot, ref.Kind, ref.Key) + return scanEntryListItem(row) +} + +func (s *Store) ListEvents(ctx context.Context, entryID int64, limit int) ([]EventRecord, error) { + if limit <= 0 { + limit = 20 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, event_type, COALESCE(from_status, ''), COALESCE(to_status, ''), COALESCE(reason, ''), COALESCE(evidence, ''), created_at + FROM knowledge_events + WHERE entry_id = ? + ORDER BY created_at DESC + LIMIT ? + `, entryID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []EventRecord + for rows.Next() { + var ev EventRecord + var created string + if err := rows.Scan(&ev.ID, &ev.EventType, &ev.FromStatus, &ev.ToStatus, &ev.Reason, &ev.Evidence, &created); err != nil { + return nil, err + } + ev.CreatedAt, err = time.Parse(time.RFC3339, created) + if err != nil { + return nil, err + } + events = append(events, ev) + } + return events, rows.Err() +} + +func (s *Store) AddLink(ctx context.Context, fromEntryID, toEntryID int64, relation string) error { + if fromEntryID <= 0 || toEntryID <= 0 { + return fmt.Errorf("both entry ids are required") + } + if strings.TrimSpace(relation) == "" { + return fmt.Errorf("relation is required") + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO knowledge_links(from_entry_id, to_entry_id, relation, created_at) + VALUES(?, ?, ?, ?) + `, fromEntryID, toEntryID, relation, nowUTC()) + return err +} + +func (s *Store) ListVerifyCandidates(ctx context.Context, repoRoot string) ([]VerifyCandidate, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, COALESCE(e.verified_on_commit, '') + FROM knowledge_entries e + JOIN repos r ON r.id = e.repo_id + WHERE r.root_path = ? AND e.status IN ('confirmed', 'needs_review') + ORDER BY e.kind, e.key + `, repoRoot) + if err != nil { + return nil, err + } + defer rows.Close() + + var candidates []VerifyCandidate + for rows.Next() { + var c VerifyCandidate + if err := rows.Scan(&c.ID, &c.RepoPath, &c.Kind, &c.Key, &c.Title, &c.Status, &c.VerifiedOnCommit); err != nil { + return nil, err + } + deps, err := s.listDependencies(ctx, c.ID) + if err != nil { + return nil, err + } + c.Dependencies = deps + candidates = append(candidates, c) + } + return candidates, rows.Err() +} + +func (s *Store) ApplyVerificationResult(ctx context.Context, entryID int64, currentCommit, nextStatus, reason string) error { + item, err := s.getEntryByID(ctx, entryID) + if err != nil { + return err + } + + now := nowUTC() + eventType := "" + switch { + case item.Status != nextStatus && nextStatus == "needs_review": + eventType = "downgraded" + case item.Status != nextStatus && nextStatus == "stale": + eventType = "marked_stale" + case item.Status != nextStatus: + eventType = "updated" + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` + UPDATE knowledge_entries + SET status = ?, stale_reason = ?, last_checked_at = ?, last_checked_commit = ?, updated_at = ? + WHERE id = ? + `, nextStatus, nullable(reason), now, nullable(currentCommit), now, entryID); err != nil { + return err + } + + if eventType != "" { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO knowledge_events(entry_id, event_type, from_status, to_status, reason, evidence, created_at) + VALUES(?, ?, ?, ?, ?, ?, ?) + `, entryID, eventType, item.Status, nextStatus, nullable(reason), nil, now); err != nil { + return err + } + } + + return tx.Commit() +} + +func normalizeAlias(alias string) string { + alias = strings.ToLower(strings.TrimSpace(alias)) + alias = strings.ReplaceAll(alias, "_", " ") + alias = strings.ReplaceAll(alias, "-", " ") + return strings.Join(strings.Fields(alias), " ") +} + +func buildImportedKey(docPath, heading string, ordinal int) string { + base := strings.TrimSuffix(filepath.Base(docPath), filepath.Ext(docPath)) + if normalized := slug(heading); normalized != "" { + return base + ":" + normalized + } + return fmt.Sprintf("%s:%d", base, ordinal) +} + +func classifyImportedKind(docKind, heading string) string { + lowerDoc := strings.ToLower(docKind) + lowerHeading := strings.ToLower(heading) + + switch lowerDoc { + case "glossary": + return "term" + case "playbooks": + return "entry" + case "repo-brief": + switch { + case strings.Contains(lowerHeading, "module"): + return "module" + case strings.Contains(lowerHeading, "chain"): + return "chain" + case strings.Contains(lowerHeading, "danger"): + return "danger" + case strings.Contains(lowerHeading, "anchor"), strings.Contains(lowerHeading, "entry"): + return "entry" + case strings.Contains(lowerHeading, "command"), strings.Contains(lowerHeading, "verify"): + return "command" + default: + return "decision" + } + default: + return "decision" + } +} + +func sectionSummary(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "Imported knowledge section" + } + body = strings.ReplaceAll(body, "\n", " ") + body = strings.Join(strings.Fields(body), " ") + runes := []rune(body) + if len(runes) > 160 { + return string(runes[:160]) + " …" + } + return body +} + +func makeSnippet(body string, terms []string) string { + body = strings.TrimSpace(strings.ReplaceAll(body, "\n", " ")) + if body == "" { + return "" + } + + lower := strings.ToLower(body) + pos := -1 + match := "" + for _, term := range terms { + idx := strings.Index(lower, term) + if idx >= 0 && (pos == -1 || idx < pos) { + pos = idx + match = term + } + } + if pos == -1 { + if len(body) > 160 { + return body[:160] + " …" + } + return body + } + + start := pos - 50 + if start < 0 { + start = 0 + } + end := pos + len(match) + 80 + if end > len(body) { + end = len(body) + } + + snippet := strings.TrimSpace(body[start:end]) + if start > 0 { + snippet = "… " + snippet + } + if end < len(body) { + snippet += " …" + } + return snippet +} + +func eventEvidence(in EntryInput) string { + payload := map[string]any{ + "source_path": in.SourcePath, + "source_line": in.SourceLine, + "dependencies": in.Dependencies, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(encoded) +} + +func nowUTC() string { + return time.Now().UTC().Format(time.RFC3339) +} + +func nullable(value string) any { + if strings.TrimSpace(value) == "" { + return nil + } + return value +} + +func nullableInt(value int) any { + if value <= 0 { + return nil + } + return value +} + +func slug(value string) string { + value = normalizeAlias(value) + return strings.ReplaceAll(value, " ", "-") +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + var out []string + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + key := trimmed + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func (s *Store) getEntryByID(ctx context.Context, id int64) (EntryListItem, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT e.id, r.root_path, e.kind, e.key, e.title, e.status, e.summary, COALESCE(e.verified_on_commit, ''), e.updated_at + FROM knowledge_entries e + JOIN repos r ON r.id = e.repo_id + WHERE e.id = ? + `, id) + return scanEntryListItem(row) +} + +func scanEntryListItem(row *sql.Row) (EntryListItem, error) { + var item EntryListItem + var updated string + err := row.Scan(&item.ID, &item.RepoPath, &item.Kind, &item.Key, &item.Title, &item.Status, &item.Summary, &item.VerifiedOnCommit, &updated) + if err != nil { + return EntryListItem{}, err + } + item.UpdatedAt, err = time.Parse(time.RFC3339, updated) + if err != nil { + return EntryListItem{}, err + } + return item, nil +} + +func (s *Store) listDependencies(ctx context.Context, entryID int64) ([]DependencyInput, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT dep_type, locator, is_hard, COALESCE(baseline_hash, ''), COALESCE(note, '') + FROM knowledge_dependencies + WHERE entry_id = ? + ORDER BY id + `, entryID) + if err != nil { + return nil, err + } + defer rows.Close() + + var deps []DependencyInput + for rows.Next() { + var dep DependencyInput + var hard int + if err := rows.Scan(&dep.Type, &dep.Locator, &hard, &dep.BaselineHash, &dep.Note); err != nil { + return nil, err + } + dep.IsHard = hard == 1 + deps = append(deps, dep) + } + return deps, rows.Err() +} diff --git a/packages/repo-memory-runtime/internal/store/store_test.go b/packages/repo-memory-runtime/internal/store/store_test.go new file mode 100644 index 0000000..25e3867 --- /dev/null +++ b/packages/repo-memory-runtime/internal/store/store_test.go @@ -0,0 +1,240 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + + "ai-workflow-skill/packages/repo-memory-runtime/internal/brief" +) + +func TestImportDocumentAndSearch(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "briefs.db") + st, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + ctx := context.Background() + if err := st.Init(ctx); err != nil { + t.Fatal(err) + } + + doc := brief.Document{ + RepoPath: "/tmp/zeus", + DocPath: "docs/ai/repo-brief.md", + Kind: "repo-brief", + Title: "Zeus Repo Brief", + Hash: "abc", + Metadata: map[string]string{"repo": "zeus"}, + Sections: []brief.Section{ + {Heading: "Module Map", Level: 2, Ordinal: 1, Body: "AI insight lives in app/app and gateway."}, + {Heading: "Danger Zones", Level: 2, Ordinal: 2, Body: "Avoid shared libs first."}, + }, + } + + if err := st.ImportDocument(ctx, doc, RepoState{ + RootPath: doc.RepoPath, + Name: "zeus", + VCS: "git", + LastSeenBranch: "main", + LastSeenCommit: "abc123", + }); err != nil { + t.Fatal(err) + } + + results, err := st.Search(ctx, SearchParams{ + Query: "insight gateway", + Limit: 5, + }) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected search results") + } + if got, want := results[0].Kind, "module"; got != want { + t.Fatalf("kind = %q, want %q", got, want) + } + if got, want := results[0].Status, "confirmed"; got != want { + t.Fatalf("status = %q, want %q", got, want) + } +} + +func TestUpsertEntryWithAliasesAndDependencies(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "briefs.db") + st, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + ctx := context.Background() + if err := st.Init(ctx); err != nil { + t.Fatal(err) + } + if _, err := st.UpsertRepo(ctx, RepoState{ + RootPath: "/tmp/cupid", + Name: "cupid", + VCS: "git", + LastSeenBranch: "main", + LastSeenCommit: "def456", + }); err != nil { + t.Fatal(err) + } + + entryID, err := st.UpsertEntry(ctx, EntryInput{ + RepoRoot: "/tmp/cupid", + Kind: "term", + Key: "AITask", + Title: "AITask", + Summary: "Plan 内嵌任务结构,不是独立表", + Status: "confirmed", + Confidence: 0.95, + SourcePath: "app/app/src/main/java/foo/AITask.java", + SourceLine: 42, + VerifiedAt: "2026-03-20T00:00:00Z", + VerifiedOnCommit: "def456", + Aliases: []string{"AI Task", "plan task"}, + Dependencies: []DependencyInput{ + {Type: "file", Locator: "app/app/src/main/java/foo/AITask.java", IsHard: true}, + {Type: "file", Locator: "proto/ai_insight.proto", IsHard: true}, + }, + }) + if err != nil { + t.Fatal(err) + } + if entryID == 0 { + t.Fatal("expected entry id") + } + + results, err := st.Search(ctx, SearchParams{ + Query: "plan task", + RepoFilter: "cupid", + Limit: 5, + }) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected alias search results") + } + if got, want := results[0].Key, "AITask"; got != want { + t.Fatalf("key = %q, want %q", got, want) + } + + items, err := st.ListEntries(ctx, ListParams{ + RepoFilter: "cupid", + Kind: "term", + Status: "confirmed", + Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 { + t.Fatalf("list entries = %d, want 1", len(items)) + } + + events, err := st.ListEvents(ctx, entryID, 10) + if err != nil { + t.Fatal(err) + } + if len(events) == 0 { + t.Fatal("expected events") + } + if got, want := events[0].EventType, "created"; got != want { + t.Fatalf("event type = %q, want %q", got, want) + } + + secondID, err := st.UpsertEntry(ctx, EntryInput{ + RepoRoot: "/tmp/cupid", + Kind: "chain", + Key: "ai-insight.get", + Title: "AI Insight Get", + Summary: "gateway -> app service -> cache/db", + Status: "confirmed", + }) + if err != nil { + t.Fatal(err) + } + if err := st.AddLink(ctx, entryID, secondID, "related_to"); err != nil { + t.Fatal(err) + } + var linkCount int + if err := st.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM knowledge_links WHERE from_entry_id = ? AND to_entry_id = ?`, entryID, secondID).Scan(&linkCount); err != nil { + t.Fatal(err) + } + if linkCount != 1 { + t.Fatalf("link count = %d, want 1", linkCount) + } +} + +func TestApplyVerificationResult(t *testing.T) { + t.Parallel() + + dbPath := filepath.Join(t.TempDir(), "briefs.db") + st, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + + ctx := context.Background() + if err := st.Init(ctx); err != nil { + t.Fatal(err) + } + if _, err := st.UpsertRepo(ctx, RepoState{ + RootPath: "/tmp/zeus", + Name: "zeus", + VCS: "git", + LastSeenBranch: "main", + LastSeenCommit: "abc123", + }); err != nil { + t.Fatal(err) + } + + entryID, err := st.UpsertEntry(ctx, EntryInput{ + RepoRoot: "/tmp/zeus", + Kind: "term", + Key: "AITask", + Title: "AITask", + Summary: "Plan 内嵌任务结构,不是独立表", + Status: "confirmed", + VerifiedOnCommit: "abc123", + Dependencies: []DependencyInput{ + {Type: "file", Locator: "foo.go", IsHard: true}, + }, + }) + if err != nil { + t.Fatal(err) + } + + if err := st.ApplyVerificationResult(ctx, entryID, "def456", "needs_review", "dependency paths changed since verification"); err != nil { + t.Fatal(err) + } + + entry, err := st.ResolveEntry(ctx, EntryRef{ID: entryID}) + if err != nil { + t.Fatal(err) + } + if got, want := entry.Status, "needs_review"; got != want { + t.Fatalf("status = %q, want %q", got, want) + } + + events, err := st.ListEvents(ctx, entryID, 10) + if err != nil { + t.Fatal(err) + } + if len(events) < 2 { + t.Fatalf("events = %d, want at least 2", len(events)) + } + if got, want := events[0].EventType, "downgraded"; got != want { + t.Fatalf("latest event = %q, want %q", got, want) + } +} diff --git a/scripts/skill-bundles.json b/scripts/skill-bundles.json index 2236369..3450c43 100644 --- a/scripts/skill-bundles.json +++ b/scripts/skill-bundles.json @@ -31,7 +31,7 @@ "runtimePackage": "./packages/repo-memory-runtime", "entrypoint": "./packages/repo-memory-runtime/cmd/briefdb", "output": "skills/repo-memory/assets/briefdb", - "buildState": "planned" + "buildState": "ready" } ] } diff --git a/skills/council-review/assets/orch b/skills/council-review/assets/orch index 2e4b7c9..38a16ae 100755 Binary files a/skills/council-review/assets/orch and b/skills/council-review/assets/orch differ diff --git a/skills/inbox/assets/inbox b/skills/inbox/assets/inbox index 312afcf..0ffcc0f 100755 Binary files a/skills/inbox/assets/inbox and b/skills/inbox/assets/inbox differ diff --git a/skills/orch/assets/orch b/skills/orch/assets/orch index 2e4b7c9..38a16ae 100755 Binary files a/skills/orch/assets/orch and b/skills/orch/assets/orch differ diff --git a/skills/repo-memory/SKILL.md b/skills/repo-memory/SKILL.md new file mode 100644 index 0000000..833c835 --- /dev/null +++ b/skills/repo-memory/SKILL.md @@ -0,0 +1,54 @@ +--- +name: repo-memory +description: Durable repository memory through a bundled briefdb CLI. Use when an agent needs to search prior project knowledge, ingest curated docs, record confirmed repository facts, inspect knowledge history, or verify whether stored knowledge may have gone stale in a local SQLite memory database instead of rediscovering the same repository context from scratch. +--- + +# Repo Memory + +Use the bundled `./assets/briefdb` CLI to maintain and query durable repository memory. + +## Quick Start + +- Invoke `./assets/briefdb` relative to this skill directory. +- Default database path: `~/.codex/data/repo-memory.db`. +- Prefer searching before a deep repo dive so repeated work starts from existing knowledge. +- Run `init` before first use on a new database path. + +## Rules + +- Write only durable repository knowledge, not short-lived chat conclusions. +- Use `draft` for partial or single-evidence findings; use `confirmed` only when the fact is well-supported and traceable. +- Include concrete evidence whenever possible: `source_path`, `source_line`, `verified_on_commit`, aliases, and dependencies. +- Search and inspect before adding a near-duplicate entry. +- Use `verify` when code has moved enough that stored knowledge may need review. + +## Typical Commands + +```bash +./assets/briefdb init --db ~/.codex/data/repo-memory.db +./assets/briefdb search --db ~/.codex/data/repo-memory.db --repo zeus --query "ai insight fill" +./assets/briefdb ingest --db ~/.codex/data/repo-memory.db --repo /Users/xd/java/zeus +./assets/briefdb add --db ~/.codex/data/repo-memory.db --repo /Users/xd/java/zeus --kind term --key AITask --summary "Plan 内嵌任务结构,不是独立表" --source-path /Users/xd/java/zeus/app/app/src/main/java/foo/AITask.java --source-line 42 --status confirmed --alias "AI Task" --dep file:/Users/xd/java/zeus/app/app/src/main/java/foo/AITask.java:hard +./assets/briefdb list --db ~/.codex/data/repo-memory.db --repo zeus --kind term +./assets/briefdb events --db ~/.codex/data/repo-memory.db --id 1 +./assets/briefdb verify --db ~/.codex/data/repo-memory.db --repo /Users/xd/java/zeus +./assets/briefdb repos --db ~/.codex/data/repo-memory.db +``` + +## Command Map + +- `init`: initialize the repository-memory SQLite schema +- `search`: query stored repository knowledge before diving into code +- `ingest`: import structured knowledge from `docs/ai` markdown +- `add`: insert or update one durable knowledge entry +- `list`: inspect entries by repo or kind +- `events`: inspect history for one knowledge entry +- `link`: create a relationship between two entries +- `verify`: downgrade entries that may be stale after repo changes +- `repos`: list tracked repositories in the memory database + +## Notes + +- The CLI is intentionally lightweight; use it to anchor stable facts, not to replace real repo inspection. +- Start with API paths, routes, table names, log phrases, or concrete file paths when searching. +- If the bundled binary cannot execute on the current host, stop and report the compatibility issue instead of guessing a replacement workflow. diff --git a/skills/repo-memory/agents/openai.yaml b/skills/repo-memory/agents/openai.yaml new file mode 100644 index 0000000..60b56cb --- /dev/null +++ b/skills/repo-memory/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Repo Memory CLI" + short_description: "Durable repository knowledge base" + default_prompt: "Use $repo-memory to search or record durable project knowledge through the bundled briefdb CLI and a local SQLite memory database." + +policy: + allow_implicit_invocation: true diff --git a/skills/repo-memory/assets/.gitkeep b/skills/repo-memory/assets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/skills/repo-memory/assets/.gitkeep @@ -0,0 +1 @@ + diff --git a/skills/repo-memory/assets/briefdb b/skills/repo-memory/assets/briefdb new file mode 100755 index 0000000..2bae6df Binary files /dev/null and b/skills/repo-memory/assets/briefdb differ