Architectural overview of workspace discovery, ticket management, document parsing, and frontmatter handling in docmgr.
docmgr's architecture centers on a workspace abstraction that discovers documentation roots, builds in-memory SQLite indexes for fast queries, and provides a unified API for document operations. The system separates concerns cleanly: workspace discovery handles root resolution, document parsing extracts metadata from YAML frontmatter, and the query system enables efficient filtering and searching. This design allows commands to focus on business logic while the workspace handles the complexity of file system traversal, path normalization, and indexing.
This guide covers: Workspace discovery and indexing, ticket workspace structure, document model and frontmatter parsing, and how these components integrate to support docmgr's commands.
Intended audience: Developers extending docmgr or implementing new features that interact with the workspace, documents, or tickets.
Workspace discovery resolves the documentation root directory through a six-level fallback chain, ensuring commands work consistently whether run from the repository root, a subdirectory, or with explicit configuration. The discovery process also identifies the configuration directory and repository root, creating a complete context for path resolution and vocabulary loading.
Why this matters: When you run docmgr doc list from anywhere in your repository, it needs to find where your documentation lives. Instead of requiring you to always specify --root ttmp, docmgr tries multiple strategies automatically. This makes commands work intuitively whether you're in the repo root, a subdirectory, or even a nested project folder.
Resolution order:
The system tries each method in order until it finds a valid docs root:
--root flag: Explicit command-line argument (highest priority)
docmgr doc list --root /path/to/docs.ttmp.yaml in current directory: Configuration file with root: field
.ttmp.yaml contains root: custom-docs.ttmp.yaml in parent directories: Walks up the directory tree
project/src/ finds .ttmp.yaml in project/DOCMGR_ROOT environment variable: System-wide or session setting
export DOCMGR_ROOT=/shared/docsGit repository root: Automatically finds <git-root>/ttmp
ttmp/ at repo rootDefault fallback: ttmp in current directory
ttmp/ if it doesn't existVisual flow:
Command Execution
│
├─→ Try --root flag
│ └─→ Found? Use it ✓
│
├─→ Try .ttmp.yaml (current dir)
│ └─→ Found? Use root: field ✓
│
├─→ Walk up tree for .ttmp.yaml
│ └─→ Found? Use root: field ✓
│
├─→ Check DOCMGR_ROOT env var
│ └─→ Set? Use it ✓
│
├─→ Find git root, check for ttmp/
│ └─→ Found? Use it ✓
│
└─→ Default to ./ttmp
└─→ Use current directory ✓
Implementation:
The DiscoverWorkspace() function encapsulates this logic:
// internal/workspace/workspace.go
func DiscoverWorkspace(ctx context.Context, opts DiscoverOptions) (*Workspace, error) {
root := opts.RootOverride
if root == "" {
root = "ttmp"
}
root = ResolveRoot(root) // Applies fallback chain
// Best-effort config load
cfg, _ := LoadWorkspaceConfig()
// Resolve config directory and repository root
configDir := resolveConfigDir(cfg)
repoRoot, _ := FindRepositoryRoot()
return NewWorkspaceFromContext(WorkspaceContext{
Root: root,
ConfigDir: configDir,
RepoRoot: repoRoot,
Config: cfg,
})
}
WorkspaceContext captures the resolved environment:
The WorkspaceContext struct holds all the information needed to work with documents:
Root: Absolute path to docs root (typically ttmp/)
/home/user/project/ttmpConfigDir: Directory containing .ttmp.yaml (usually repo root)
/home/user/projectRepoRoot: Git repository root (for path normalization)
/home/user/projectConfig: Parsed workspace configuration (may be nil)
Usage in commands:
Every command that needs to access documents follows this pattern:
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return fmt.Errorf("failed to discover workspace: %w", err)
}
// ws.Context().Root now contains the resolved absolute path
Key points for developers:
DiscoverWorkspace() instead of manually resolving pathsRoot is always an absolute path, making it safe to usedocmgr builds an in-memory SQLite index on each CLI invocation to enable fast queries across all documents. The index stores document metadata, topics, and related files with normalized paths, allowing efficient filtering by ticket, status, doc-type, topics, and file paths. This design trades a small startup cost for query performance and eliminates the need for persistent index files.
Why this matters: Searching through hundreds of markdown files on every command would be slow. Instead, docmgr builds a fast SQLite database in memory that lets you query documents instantly. Think of it like a library catalog: instead of walking through every shelf, you look up books in a card catalog.
Index lifecycle:
The index is created fresh on every command invocation. Here's what happens:
docmgr doc listws.InitIndex() is calledWhy rebuild every time?
// Initialize index (rebuilds from scratch)
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: false, // Set true for full-text search
}); err != nil {
return fmt.Errorf("failed to initialize workspace index: %w", err)
}
Index schema (internal/workspace/sqlite_schema.go):
The SQLite database has three main tables that work together:
1. docs table - Core document metadata:
2. doc_topics table - Many-to-many relationship:
[api, architecture, backend]3. related_files table - File references with normalized paths:
Visual schema:
┌─────────────────┐
│ docs │
├─────────────────┤
│ doc_id (PK) │
│ path │
│ ticket │
│ doc_type │
│ status │
│ title │
│ last_updated │
└────────┬────────┘
│
│ 1:N
├─────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────────┐
│ doc_topics │ │ related_files │
├──────────────┤ ├──────────────────┤
│ doc_id (FK) │ │ doc_id (FK) │
│ topic │ │ norm_repo_rel │
└──────────────┘ │ norm_docs_rel │
│ norm_abs │
│ note │
└──────────────────┘
Indexing process:
When InitIndex() is called, here's the step-by-step process:
Walk documents: documents.WalkDocuments() traverses the docs root
internal/ignore) and canonical hard skips before parsing.md file_ (like _templates/)Parse frontmatter: Each .md file is parsed via ReadDocumentWithFrontmatter()
Document structExtract metadata: Ticket, doc-type, topics, related files are extracted
Normalize paths: Related files are normalized to multiple representations
Insert into SQLite: Documents, topics, and related files are inserted
Performance considerations:
Path normalization (internal/paths/normalization.go):
One of the trickiest parts of indexing is handling file paths. The same file might be referenced in different ways:
/home/user/project/backend/api/user.gobackend/api/user.go../../backend/api/user.goSolution: Store multiple normalized representations for each file:
norm_repo_rel: Repository-relative path (preferred canonical key)
backend/api/user.gonorm_docs_rel: Docs-root relative path
../../backend/api/user.go (if doc is in ttmp/2025/12/19/)norm_abs: Absolute path
/home/user/project/backend/api/user.gonorm_canonical: Best-effort canonical key (prefers repo_rel)
backend/api/user.goWhy multiple representations?
This allows queries to match files regardless of how they were referenced in frontmatter. When a user searches for backend/api/user.go, the query can match:
Example:
# Document A (in ttmp/2025/12/19/TICKET-001/)
RelatedFiles:
- Path: /home/user/project/backend/api/user.go # Absolute
# Document B (in ttmp/2025/12/20/TICKET-002/)
RelatedFiles:
- Path: ../../backend/api/user.go # Relative
# Document C (in ttmp/2025/12/21/TICKET-003/)
RelatedFiles:
- Path: backend/api/user.go # Repo-relative
All three documents will match a query for backend/api/user.go because the normalization stores all representations.
Ticket workspaces are date-organized directories containing an index document, standard subdirectories for document types, and metadata files. The structure provides a consistent layout while remaining flexible enough to accommodate different documentation needs.
Why this matters: Every ticket gets its own workspace directory with a predictable structure. This makes it easy to find related documents, keeps things organized chronologically, and provides a consistent experience across all tickets. Think of it like a filing cabinet: each ticket is a folder with labeled sections.
Directory structure:
Tickets are organized by date (YYYY/MM/DD) and then by ticket ID:
ttmp/
YYYY/ # Year (e.g., 2025)
MM/ # Month (e.g., 12)
DD/ # Day (e.g., 19)
<TICKET>--<slug>/ # Ticket directory
index.md # Ticket overview (required)
tasks.md # Task list
changelog.md # Change history
design/ # Design documents
reference/ # Reference docs
playbooks/ # Operational procedures
scripts/ # Utility scripts
sources/ # Source code references
various/ # Miscellaneous docs
archive/ # Archived documents
.meta/ # Metadata files
Why date-based organization?
Directory purposes:
index.md: Required ticket overview document
docmgr ticket list to find ticketstasks.md: Task tracking
docmgr task commandschangelog.md: Change history
design/: Design documents
reference/: Reference documentation
playbooks/: Operational procedures
scripts/: Utility scripts
sources/: Source code references
various/: Miscellaneous documents
archive/: Archived documents
.meta/: Metadata files
Ticket discovery:
Tickets are discovered by querying for documents with DocType == "index". This is efficient because:
index.md) is required, so every ticket has oneres, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeTicket, TicketID: ticketID},
Filters: workspace.DocFilters{DocType: "index"},
Options: workspace.DocQueryOptions{
IncludeErrors: false,
IncludeArchivedPath: true,
IncludeScriptsPath: true,
IncludeControlDocs: true,
},
})
How ticket discovery works:
DocType == "index"index.md frontmatter (Ticket: MEN-3475)ScopeTicket is used, filter to specific ticketPath tags (internal/workspace/index_builder.go):
During indexing, documents are automatically tagged based on their location. These tags enable filtering and special handling:
IsIndex: Document is a ticket index (index.md)
ttmp/2025/12/19/MEN-3475--feature/index.mdIsArchivedPath: Document is in archive/ subdirectory
ttmp/2025/12/19/MEN-3475--feature/archive/old-design.mdIsScriptsPath: Document is in scripts/ subdirectory
ttmp/2025/12/19/MEN-3475--feature/scripts/setup.shIsSourcesPath: Document is in sources/ subdirectory
ttmp/2025/12/19/MEN-3475--feature/sources/example.goIsControlDoc: Document is a control file (tasks.md, changelog.md, etc.)
ttmp/2025/12/19/MEN-3475--feature/tasks.mdWhy tags matter:
Tags allow queries to include or exclude specific document types:
Options: workspace.DocQueryOptions{
IncludeArchivedPath: false, // Don't show archived docs
IncludeScriptsPath: false, // Don't show scripts
IncludeControlDocs: true, // Include tasks.md, changelog.md
}
This gives fine-grained control over what documents appear in results.
Documents are markdown files with YAML frontmatter containing structured metadata. The frontmatter parsing system handles both legacy and current formats, provides error diagnostics, and supports preprocessing to reduce parse failures.
Why this matters: Every document needs metadata (ticket, topics, status) to be searchable and organized. The frontmatter system extracts this metadata automatically, validates it, and makes it queryable. Think of frontmatter like a library card catalog entry: it tells you what the document is about without reading the whole thing.
Document model (pkg/models/document.go):
The Document struct represents all the metadata that can be stored in a document's frontmatter:
type Document struct {
Title string // Document title (required)
Ticket string // Ticket ID (required)
Status string // Workflow status (draft, active, review, etc.)
Topics []string // List of topics (api, backend, etc.)
DocType string // Document type (required: design-doc, reference, etc.)
Intent string // Longevity intent (long-term, short-term, throwaway)
Owners []string // List of owner usernames
RelatedFiles RelatedFiles // Code files related to this document
ExternalSources []string // External references (URLs, etc.)
Summary string // Brief summary of the document
LastUpdated time.Time // When the document was last modified
}
Field purposes:
Required fields (validation fails if missing):
Title: Human-readable document nameTicket: Which ticket this document belongs toDocType: What kind of document this isOptional fields (can be empty):
Status: Current workflow stateTopics: Categories for filtering and discoveryOwners: Who is responsible for this documentRelatedFiles: Links to code filesSummary: Brief descriptionLastUpdated: Modification timestampFrontmatter format:
Frontmatter appears at the top of every markdown file, between --- delimiters:
---
Title: API Design for User Service
Ticket: MEN-3475
DocType: design-doc
Topics: [api, architecture]
Owners: [alice, bob]
Status: active
Intent: long-term
RelatedFiles:
- Path: backend/api/user.go
Note: Main API implementation
Summary: Design document for user service API
LastUpdated: 2025-12-19T10:00:00Z
---
# Document Content
Markdown body content here...
Visual structure:
┌─────────────────────────────────────┐
│ --- │
│ Title: API Design │ ← YAML Frontmatter
│ Ticket: MEN-3475 │ (Metadata)
│ Topics: [api, architecture] │
│ ... │
│ --- │
├─────────────────────────────────────┤
│ │
│ # Document Content │ ← Markdown Body
│ │ (Content)
│ This is the actual content... │
│ │
└─────────────────────────────────────┘
Frontmatter parsing (internal/documents/frontmatter.go):
The parsing pipeline handles three stages, each with error handling:
Stage 1: Extraction - Manual scanning for --- delimiters
The parser scans the file line-by-line to find the frontmatter block:
--- (trimmed)--- delimiterWhy manual scanning? Faster than regex, gives precise line numbers for error reporting.
Stage 2: Preprocessing - Quote risky scalars
Before parsing YAML, the system quotes values that might cause parse errors:
@, `, #, &, *, !, |, >: or trailing : #Example:
# Before preprocessing
Title: API: Design & Implementation
# After preprocessing
Title: 'API: Design & Implementation'
Why preprocessing? Reduces parse failures by 80-90% for common edge cases.
Stage 3: Decoding - YAML decoder parses into Document struct
The preprocessed YAML is decoded into a Document struct:
yaml.NewDecoder() for parsingfunc ReadDocumentWithFrontmatter(path string) (*models.Document, string, error) {
raw, _ := os.ReadFile(path)
// Extract frontmatter block
fm, body, fmStartLine, _ := extractFrontmatter(raw)
// Preprocess to quote risky values
fm = frontmatter.PreprocessYAML(fm)
// Decode YAML
var node yaml.Node
dec := yaml.NewDecoder(bytes.NewReader(fm))
if err := dec.Decode(&node); err != nil {
// Extract line/col, build snippet, wrap in taxonomy
return nil, "", wrapParseError(err, path, fmStartLine)
}
// Decode into Document struct
var doc models.Document
node.Decode(&doc)
return &doc, string(body), nil
}
Error handling:
When parsing fails, errors are wrapped in a diagnostics taxonomy that includes:
Example error output:
YAML/frontmatter syntax error
File: ttmp/2025/12/19/MEN-3475/index.md
Line: 5, Column: 12
3 | Topics: [api, backend]
4 | DocType: design-doc
> 5 | Status: active: review
| ^
6 | Summary: Design document
Problem: Unexpected colon in scalar value
Suggestion: Quote the value: Status: 'active: review'
RelatedFiles format:
The RelatedFiles field supports both legacy and current formats for backward compatibility:
Legacy format (still supported):
RelatedFiles:
- backend/api/user.go
- frontend/components/User.tsx
Current format (preferred):
RelatedFiles:
- Path: backend/api/user.go
Note: Main API implementation
- Path: frontend/components/User.tsx
Note: Frontend component consuming the API
Why both formats?
The RelatedFiles type (pkg/models/document.go) handles both formats automatically via custom UnmarshalYAML() that detects the format and converts appropriately.
Document walking provides a callback-based API for traversing the documentation tree. It's used by indexing, validation, and discovery operations that need to process all markdown files.
Why this matters: Many operations need to visit every document in the workspace. Instead of each command implementing its own file traversal logic, WalkDocuments() provides a consistent, tested way to iterate through all markdown files. Think of it like a for loop over all documents.
Workspace discovery constructs a shared ignore matcher from internal/ignore. The matcher is backed by github.com/denormal/go-gitignore, includes built-in dependency/build excludes, and reads .docmgrignore files from the repository/docs hierarchy, including nested ticket or scripts/ directories. Workspace.InitIndex uses this matcher through documents.WithSkipDir, so ignored paths are pruned before ReadDocumentWithFrontmatter runs. Commands such as doctor, list, search, status, and SQLite export therefore share one source of ignore truth. Use docmgr ignore explain <path> to inspect the final decision for a path.
WalkDocuments function (internal/documents/walk.go):
The function takes a root directory and a callback function that gets called for each .md file:
func WalkDocuments(root string, fn WalkDocumentFunc, opts ...WalkOption) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
// Skip directories starting with "_"
if d.IsDir() && strings.HasPrefix(d.Name(), "_") {
return fs.SkipDir
}
// Process .md files
if strings.ToLower(filepath.Ext(d.Name())) == ".md" {
doc, body, readErr := ReadDocumentWithFrontmatter(path)
return fn(path, doc, body, readErr)
}
return nil
})
}
How it works:
filepath.WalkDir() to visit every file_ (like _templates/).md extensionReadDocumentWithFrontmatter() automaticallyCallback signature:
type WalkDocumentFunc func(
path string, // Full path to the file
doc *models.Document, // Parsed document (nil if parse failed)
body string, // Markdown body content
readErr error, // Parse error (nil if successful)
) error
Usage:
Here's how you'd use it to process all documents:
err := documents.WalkDocuments(root, func(path string, doc *models.Document, body string, readErr error) error {
if readErr != nil {
// Handle parse errors (skip, log, etc.)
fmt.Printf("Failed to parse %s: %v\n", path, readErr)
return nil // Continue walking
}
// Process valid document
fmt.Printf("Found document: %s (ticket: %s)\n", doc.Title, doc.Ticket)
processDocument(doc, body)
return nil
}, documents.WithSkipDir(func(path string, d fs.DirEntry) bool {
// Custom skip logic
return strings.Contains(path, "node_modules")
}))
Custom skip logic:
You can customize which directories to skip:
documents.WithSkipDir(func(path string, d fs.DirEntry) bool {
// Skip node_modules, .git, vendor, etc.
skipDirs := []string{"node_modules", ".git", "vendor", "build"}
for _, skip := range skipDirs {
if strings.Contains(path, skip) {
return true
}
}
return false
})
Common use cases:
Performance:
filepath.WalkDir()The query system provides a flexible API for filtering documents by ticket, status, doc-type, topics, related files, and (when available) full-text content. Queries use the SQLite index for performance and support scoping to repository-wide or ticket-specific searches.
Why this matters: Once documents are indexed, you need a way to find them. The query system lets you filter by any combination of metadata fields, making it easy to find exactly what you're looking for. Think of it like a database query: you specify what you want, and it returns matching documents instantly.
Query structure (internal/workspace/query_docs.go):
A query consists of three parts: scope, filters, and options:
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{
Kind: workspace.ScopeRepo, // or ScopeTicket with TicketID
},
Filters: workspace.DocFilters{
Ticket: "MEN-3475",
Status: "active",
DocType: "design-doc",
TopicsAny: []string{"api", "backend"},
RelatedFile: []string{"backend/api/user.go"},
TextQuery: "websocket", // SQLite FTS5 MATCH query string (when available)
},
Options: workspace.DocQueryOptions{
IncludeBody: false,
IncludeErrors: false,
IncludeArchivedPath: true,
IncludeScriptsPath: true,
IncludeControlDocs: true,
OrderBy: workspace.OrderByRank, // bm25(docs_fts), requires TextQuery
Reverse: true,
},
})
```
**Query components:**
**1. Scope** - Where to search:
- **`ScopeRepo`**: Search entire repository (all tickets)
- Use when: Finding documents across all tickets
- Example: "List all design docs"
- **`ScopeTicket`**: Search within a specific ticket
- Use when: Finding documents for one ticket
- Example: "List all docs for MEN-3475"
**2. Filters** - What to match:
- **`Ticket`**: Exact ticket ID match
- **`Status`**: Exact status match (draft, active, review, etc.)
- **`DocType`**: Exact doc type match (design-doc, reference, etc.)
- **`TopicsAny`**: Match if document has ANY of these topics (OR logic)
- **`RelatedFile`**: Match if document references this file
- **`TextQuery`**: Full-text query (SQLite FTS5 `MATCH` syntax; no substring/contains compatibility guarantees)
**3. Options** - How to return results:
- **`IncludeBody`**: Include full markdown body (increases memory)
- **`IncludeErrors`**: Include documents that failed to parse
- **`IncludeArchivedPath`**: Include documents in `archive/` directories
- **`IncludeScriptsPath`**: Include documents in `scripts/` directories
- **`IncludeControlDocs`**: Include control files (tasks.md, changelog.md)
- **`OrderBy`**: Sort by path, last_updated, rank, etc.
- **`Reverse`**: Reverse sort order (newest first, etc.)
### Full-text search (FTS5)
Full-text search is backed by an FTS5 virtual table (`docs_fts`) created alongside the in-memory SQLite index.
- Implementation: `internal/workspace/sqlite_schema.go` (`ensureDocsFTS5`) and `internal/workspace/index_builder.go` (populate `docs_fts` with `rowid = doc_id`).
- Querying: `internal/workspace/query_docs_sql.go` uses `docs_fts MATCH ?` and `bm25(docs_fts)` for ranking (`workspace.OrderByRank`).
- Availability: building without `-tags sqlite_fts5` typically yields `workspace.ErrFTSNotAvailable` and `Workspace.FTSAvailable() == false`; in that mode `TextQuery` queries error, but metadata-only queries still work.
docmgr also exposes a higher-level search engine in `internal/searchsvc` (`SearchDocs`) which converts CLI-like inputs into a `workspace.DocQuery` and handles snippets, date parsing, and file suggestion heuristics.
**Query result:**
The query returns a `DocQueryResult` containing a list of `DocHandle` objects:
```go
type DocQueryResult struct {
Docs []DocHandle // Documents matching query
}
type DocHandle struct {
Path string // File path
Doc *models.Document // Parsed document (nil if parse failed)
Body string // Markdown body (only if IncludeBody=true)
Error error // Parse error (if any)
}
Important: Always check h.Doc == nil before accessing document fields, as parse errors can occur.
Common query patterns:
1. List all tickets:
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeRepo},
Filters: workspace.DocFilters{DocType: "index"},
Options: workspace.DocQueryOptions{
OrderBy: workspace.OrderByLastUpdated,
Reverse: true, // Newest first
},
})
2. Find ticket documents:
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{
Kind: workspace.ScopeTicket,
TicketID: "MEN-3475",
},
Filters: workspace.DocFilters{},
})
3. Search by file:
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeRepo},
Filters: workspace.DocFilters{
RelatedFile: []string{"backend/api/user.go"},
},
})
4. Filter by topics (OR logic):
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeRepo},
Filters: workspace.DocFilters{
TopicsAny: []string{"api", "backend"}, // Matches if doc has api OR backend
},
})
Query performance:
Commands integrate with the workspace system through a consistent pattern:
workspace.DiscoverWorkspace() resolves root and contextws.InitIndex() builds SQLite index (if querying needed)QueryDocs() for filtered access or WalkDocuments() for discoveryWhy this pattern matters: Every command that works with documents follows the same steps. This consistency makes commands predictable, testable, and easy to understand. Once you learn the pattern, you can read any command's code and understand what it does.
Example command pattern:
Here's a complete example showing how a list command integrates with the workspace:
func (c *ListDocsCommand) RunIntoGlazeProcessor(
ctx context.Context,
parsedLayers *layers.ParsedLayers,
gp middlewares.Processor,
) error {
// Step 1: Parse command settings
settings := &ListDocsSettings{}
parsedLayers.InitializeStruct(layers.DefaultSlug, settings)
// Step 2: Discover workspace
ws, err := workspace.DiscoverWorkspace(ctx, workspace.DiscoverOptions{
RootOverride: settings.Root,
})
if err != nil {
return err
}
// Step 3: Initialize index (needed for queries)
if err := ws.InitIndex(ctx, workspace.BuildIndexOptions{
IncludeBody: false, // Don't need body for listing
}); err != nil {
return err
}
// Step 4: Query documents
res, err := ws.QueryDocs(ctx, workspace.DocQuery{
Scope: workspace.Scope{Kind: workspace.ScopeRepo},
Filters: workspace.DocFilters{
Ticket: settings.Ticket,
DocType: settings.DocType,
TopicsAny: settings.Topics,
},
})
if err != nil {
return err
}
// Step 5: Output results
for _, h := range res.Docs {
if h.Doc == nil {
continue // Skip parse errors
}
row := types.NewRow(
types.MRP("ticket", h.Doc.Ticket),
types.MRP("title", h.Doc.Title),
// ... more fields
)
gp.AddRow(ctx, row)
}
return nil
}
Visual flow:
Command Execution
│
├─→ Parse Settings
│ └─→ Extract flags/args
│
├─→ Discover Workspace
│ └─→ Resolve docs root
│
├─→ Initialize Index
│ └─→ Build SQLite index
│
├─→ Query Documents
│ └─→ Filter by criteria
│
└─→ Process Results
├─→ Handle errors
├─→ Format output
└─→ Return
When to use QueryDocs vs WalkDocuments:
Use QueryDocs() when:
Use WalkDocuments() when:
Error handling best practices:
h.Doc == nil: Parse errors can occurfmt.Errorf() with contextThis section explains the "why" behind major architectural choices. Understanding these decisions helps you work with the codebase effectively and make informed choices when extending it.
In-memory SQLite index:
Decision: Rebuild the index from scratch on every CLI invocation instead of using a persistent index file.
Rationale:
Trade-offs:
When this matters: For most commands, the startup cost is negligible compared to the query performance benefits. For very large repositories (10,000+ documents), consider caching strategies.
Path normalization:
Decision: Store multiple normalized path representations for each related file instead of a single canonical path.
Rationale:
Trade-offs:
When this matters: The storage overhead is minimal (~few KB per document), but the flexibility is crucial for user experience. Without this, users would constantly struggle with path format mismatches.
Best-effort parsing:
Decision: Continue indexing documents even when frontmatter parsing fails, storing error metadata instead of skipping them.
Rationale:
Trade-offs:
doc == nil cases everywhereWhen this matters: This enables the docmgr doctor command to report all issues at once, making it much easier to fix documentation problems in bulk.
Vocabulary-guided validation:
Decision: Validate topics, doc-types, intent, and status against a vocabulary file, but only warn (not error) for unknown values.
Rationale:
Trade-offs:
When this matters: This balance between guidance and flexibility allows teams to adopt docmgr gradually while still encouraging best practices. Strict validation would be too rigid for real-world use.