Go 151 lines
package manifest
// Package manifest writes per-directory .ai.json manifests: a compact,
// deterministic enumeration of a knowledge directory's immediate entries for
// human audit and AI orientation. The deterministic walk fills paths and kinds
// only; descriptions are left empty and an opt-in AI pass (the Slice-D
// `eeco refresh-manifest` verb, using the ManifestSummary prompt) fills them.
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
)
// FileName is the per-directory manifest file name.
const FileName = ".ai.json"
// vcsDir is the version-control directory the manifest walk never descends
// into or enumerates: the private workspace-history repo (and any nested VCS
// dir) is engine plumbing, not knowledge. Pairs with the engine-workspace
// exclusion so a refresh after `init` never writes into ajhahnde/.git.
const vcsDir = ".git"
// Item is one entry in a directory manifest. Desc and FindWhen are populated by
// the opt-in AI enrichment pass, never by the deterministic walk.
type Item struct {
Path string `json:"path"`
Kind string `json:"kind"` // "file" or "dir"
Desc string `json:"desc,omitempty"`
FindWhen string `json:"find_when,omitempty"`
}
// Manifest is the .ai.json document for one directory.
type Manifest struct {
Dir string `json:"dir"`
Purpose string `json:"purpose,omitempty"`
Items []Item `json:"items"`
}
// Build walks the immediate children of <root>/<dir> and returns a
// deterministic skeleton manifest (paths + kinds; descriptions empty), sorted
// by path. The manifest file itself is never listed, so re-running over a
// directory that already holds an .ai.json is idempotent.
func Build(root, dir string) (Manifest, error) {
target := filepath.Join(root, dir)
entries, err := os.ReadDir(target)
if err != nil {
return Manifest{}, err
}
items := make([]Item, 0, len(entries))
for _, e := range entries {
name := e.Name()
if name == FileName || name == vcsDir {
continue
}
if e.IsDir() {
items = append(items, Item{Path: name + "/", Kind: "dir"})
continue
}
items = append(items, Item{Path: name, Kind: "file"})
}
sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path })
return Manifest{Dir: dir, Items: items}, nil
}
// KnowledgeDirs walks the whole tree under userDir and returns every directory
// in it — top-level knowledge dirs and their nested subdirectories alike — as
// paths relative to userDir, sorted. userDir itself is never returned, and the
// engine workspace (engineName, e.g. ".eeco") is excluded along with its entire
// subtree. Separators in the returned paths are OS-native. A userDir that does
// not exist yet yields an empty list and no error, so a refresh on an un-inited
// repo is a clean no-op.
func KnowledgeDirs(userDir, engineName string) ([]string, error) {
var out []string
err := filepath.WalkDir(userDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if path == userDir {
return nil
}
if d.Name() == engineName || d.Name() == vcsDir {
return fs.SkipDir
}
rel, err := filepath.Rel(userDir, path)
if err != nil {
return err
}
out = append(out, rel)
return nil
})
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
sort.Strings(out)
return out, nil
}
// Subtree walks <userDir>/<dir> and returns dir plus all of its nested
// subdirectories as paths relative to userDir, sorted — so each is directly
// usable with Build(userDir, x) and Write(userDir, x, m). No engine exclusion
// applies, as the engine workspace is never passed as dir.
func Subtree(userDir, dir string) ([]string, error) {
root := filepath.Join(userDir, dir)
var out []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if d.Name() == vcsDir {
return fs.SkipDir
}
rel, err := filepath.Rel(userDir, path)
if err != nil {
return err
}
out = append(out, rel)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(out)
return out, nil
}
// Write marshals m to <root>/<dir>/.ai.json with stable indentation and a
// trailing newline.
func Write(root, dir string, m Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(filepath.Join(root, dir, FileName), data, 0o644)
}