Spek is built around two Go interfaces. Implement either one, or both,
and you have a new backend. The interfaces live in
github.com/hivecommons/spektacular/internal/store and
github.com/hivecommons/spektacular/internal/agent.
The Store interface
A Store is a uniform read/write surface over a data directory. The same
interface is used for spec storage, plan storage, and knowledge storage:
each section of config.yaml independently names a Store provider.
type Store interface { // Root returns the absolute path to the store root directory. Root() string // Read returns the contents of the file at path. Read(path string) ([]byte, error) // Write creates or overwrites the file at path with content. // Parent directories are created automatically. Write(path string, content []byte) error // Delete removes the file at path. Returns nil if the file does not exist. Delete(path string) error // List returns the direct children of the directory at path. Each entry // reports whether it is a directory, so a caller can recurse the tree. List(path string) ([]DirEntry, error) // Exists reports whether a file or directory exists at path. Exists(path string) bool // Search returns hits for a pre-tokenized keyword query, scanning only // this store. Terms arrive lower-cased, in the order the caller indexes // evidence by. Hits are left unattributed and unscored for the caller. Search(terms []string, opts SearchOptions) ([]Hit, error)}Three helper types travel with Store:
type DirEntry struct { Name string // child name, not a full path IsDir bool // true for a subdirectory, recurse into it via List}
type SearchOptions struct { // Tags restricts results to entries carrying every listed tag, matched // exactly. Empty means no restriction. Applying it is an optimisation, // not a guarantee: the knowledge layer enforces it regardless, so a // store that ignores this field is slower, never wrong. Tags []string}
type Hit struct { // Filled in by the store Path string `json:"path"` // locator, relative to the store root Title string `json:"title"` // the document's first heading, or the locator Excerpts []string `json:"excerpts"` // compact excerpts, each capped at the budget Checksum string `json:"checksum"` // hex SHA-256 over the entry's exact raw bytes Tags []string `json:"tags"` // the entry's declared tags, empty rather than absent BodyCounts []int `json:"-"` // occurrences per query term, in term order
// Left zero by the store, stamped by the knowledge layer Tier string `json:"tier"` Name string `json:"name"` Category string `json:"category"` Score float64 `json:"score"`}The two groups are the whole contract in miniature. A store fills in what it observed while reading a document. It leaves the second group alone, because every field in it is a statement about the whole result set, which a single store cannot see.
Method contracts in brief:
Rootreturns an absolute path so callers can resolve relative paths themselves when needed.Read,Write, andDeleteoperate on paths relative to the store root.Writecreates parent directories;Deleteis idempotent.Listreturns direct children only, callers recurse explicitly when they need to walk a tree.Existsdoes not distinguish files from directories; callers that need to know useListon the parent.Searchis the only contract with semantics beyond simple file IO: hits must be compact (excerpts, not full bodies). A store leavesTierandNameempty, because it has no notion of the caller’s addressing scheme; the knowledge layer stamps them after merging so every result says which store it came from.- For the same reason, a store leaves
Scoreat zero. A store cannot see the other stores its results will be merged and ranked against, so it cannot put a score on a scale those results share. Report what you found, per term, and the knowledge layer scores it. A backend that computed its own score would rank on a scale unrelated to every other backend’s, and the merged ordering would be quietly wrong rather than visibly broken. Matching is a ranked OR: return a document that carries evidence for any term, not only one that carries evidence for all of them, and let the ranking decide what is worth showing. - This means a backend has to be able to report how often each query term
occurs, in
BodyCounts, indexed like thetermsit was given. A purely semantic backend that can only return its own opaque relevance score cannot take part in this ranking, and is out of scope for the current design. Meet that limit in the design rather than in a debugging session. SearchOptions.Tagsmay be applied while walking, to avoid reporting documents that would be discarded anyway. Correctness does not depend on it: the knowledge layer applies the same filter after the merge.
The Agent interface
An Agent represents a coding agent Spek can drive during the
implement workflow. The interface is intentionally narrow: the agent
declares its name and installs the workflow artefacts it needs.
type Agent interface { Name() string Install(projectPath string, cfg config.Config, out io.Writer) error}Namereturns the canonical identifier used on the CLI (spektacular init <name>) and persisted toconfig.yamlunder theagent:key.Installwrites the agent’s skills and (optionally) command wrappers intoprojectPath. It emits one human-readable line per artefact tooutso the CLI can surface progress.
Registering a backend
Each plugin lives in its own file under the relevant package and registers
itself from an init() function. The pattern is the same for both
interfaces.
Agents register against the agent package’s private registry:
type claudeAgent struct{}
func (claudeAgent) Name() string { return "claude" }
func (claudeAgent) Install(projectPath string, cfg config.Config, out io.Writer) error { // ... install workflow artefacts ... return nil}
func init() { register(claudeAgent{})}Store providers plug into the section that consumes them. For knowledge
stores, that’s the provider switch in
internal/knowledge/set.go, a new arm names the provider and constructs
the Store for each configured source. The shipping case config.ProviderFile: arm is the worked example.
The file provider is the reference implementation for both reading the
spec/plan/knowledge sections of config.yaml and for satisfying the Store
contract end-to-end. Use it as the template for new Store providers.
Next steps
This page covers the shape of a plugin. A full walkthrough of building
and shipping one (module layout, registration ordering, error handling,
tests) is intentionally out of scope here; read the Go source under
internal/store and internal/agent for the lived-in version.