feat(monorepo): import repo-memory runtime

This commit is contained in:
2026-03-20 13:20:28 +08:00
parent b6e524de41
commit 388c25b1b0
20 changed files with 2461 additions and 10 deletions
+11 -7
View File
@@ -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
@@ -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
+70
View File
@@ -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`
@@ -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
+2
View File
@@ -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=
@@ -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
}
@@ -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
}
+4
View File
@@ -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
@@ -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)
}
@@ -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)
}
}
@@ -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()
}
@@ -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)
}
}
+1 -1
View File
@@ -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"
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+54
View File
@@ -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.
+7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
BIN
View File
Binary file not shown.