Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .agents/decisions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,17 @@ entries:
list_knowledge stays file-backed for projects and index-backed for orgs; with auto-refresh both are current.
source: 'issue #36 design discussion with user, 2026-10-23'
date: "2026-10-23"
- id: dec-006
summary: 'Org writes: markdown sections through write/update with a file argument'
detail: |-
Issue #40 part 2: org roots (KindOrg) accepted queries but rejected writes, so org knowledge was hand-maintained and drifted.
Chosen shape (user-approved): write_knowledge gains an optional file argument; on an org root summary becomes the ## heading and detail the section body, appended to <org>/.agents/knowledge/<file>.
file must be a bare .md filename: no separators, no traversal, no '::' (the id delimiter), non-empty stem. A missing file name returns the available files; a valid new name creates the file. No guessing.
source is folded into the section as a final 'Source: ...' line. update_knowledge on org parses id org-<file>::<heading> (the id shape query_knowledge already returns), applies only detail, keeps the heading immutable, and carries the old Source line forward.
Decisions from the issue's open questions: rule is rejected for org sections (fold constraints into detail); heading collisions are rejected with the matching update id instead of merged or duplicated; summary on org update is rejected because headings are part of the id.
category is now schema-optional and required only for project stores; org sections all index as conventions. The handler resolves the ref before validating category so org writes need not pass it.
Writes are atomic via knowledge.SaveText (temp + fsync + rename) under the existing per-path fileLocks. No idx.Add is needed: the GH-36 store fingerprint notices the new mtime and Query rebuilds.
Part 1 of the issue (only architecture.md/review.md indexed) was already fixed by GH-36/PR #39; this change only hardened OrgKnowledgeFiles to require regular files.
Tests: knowledge/org_test.go (append/replace/body/save/regular-file filtering), tools/org_test.go (write+query+list, missing file, no files, rule rejection, filename validation, collision, project category requirement, update semantics, invalid ids).
source: 'GitHub issue #40 implementation, 2026-10-26'
date: "2026-10-26"
11 changes: 7 additions & 4 deletions .agents/subsystems.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ entries:
- id: sub-003
summary: 'Knowledge store: atomic writes, per-file locks, entry lifecycle'
detail: |-
knowledge/store.go: Save writes temp file, fsyncs, renames (atomic); LoadOrCreate returns empty KnowledgeFile when missing.
knowledge/store.go: Save writes YAML via temp file, fsync, rename (atomic); LoadOrCreate returns an empty KnowledgeFile when missing; SaveText does the same for org markdown.
tools package keeps per-path RWMutexes (fileLocks) around read-modify-write cycles; write/update lock, list takes RLock.
NextID scans existing IDs for the category prefix and returns the next sequential ID.
write_knowledge validates category, summary <=100 chars, detail <=1MB, rule <=200 chars; org roots reject writes (file-based knowledge only).
update_knowledge validates supersedes references an existing ID in the same category and rejects no-op updates.
write_knowledge validates summary <=100 chars, detail <=1MB, rule <=200 chars. category is required for project stores and ignored on org roots.
Org root write: file is required (bare *.md, no directories; missing file lists available names; a new name creates the file), summary is the ## heading, detail the body, source becomes a trailing Source: line, rule is rejected, and a heading collision points at update_knowledge. knowledge.AppendOrgSection/SaveText run under fileLocks.
update_knowledge on an org root takes id org-<file>::<heading>; only detail applies, the heading is immutable, the trailing Source: line is preserved, and the file must already exist. knowledge.ReplaceOrgSection rewrites just that section.
OrgKnowledgeFiles returns sorted top-level *.md names and skips directories, broken symlinks, and other non-regular files (symlinks to regular files are followed).
update_knowledge validates supersedes references an existing ID in the same category and rejects no-op updates for project stores.
Rules surface as a constraints section in list_knowledge output, above regular entries.
source: code review, 2026-09-08
date: "2026-09-08"
date: "2026-10-26"
- id: sub-004
summary: 'Startup indexing: always-on background kick, no skip flag'
detail: |-
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,10 @@ arguments — entry bodies are replaced by sizes and never written. Set
| Tool | Description |
|------|-------------|
| `init_knowledge` | Create a project's knowledge directory structure (central store under `--store`) |
| `write_knowledge` | Add a new knowledge entry |
| `write_knowledge` | Add a knowledge entry, or an org markdown section with `file` |
| `query_knowledge` | Full-text search with category filters |
| `list_knowledge` | List all entries; entries with rules shown first as constraints |
| `update_knowledge` | Update an existing entry by ID |
| `update_knowledge` | Update an entry by ID, or an org section body |
| `list_projects` | List all discovered projects (org-wide mode only) |
| `reindex_knowledge` | Rebuild the search index from the store files (queries also refresh automatically) |

Expand All @@ -245,6 +245,23 @@ list parses as zero entries. `list_knowledge` marks entries merged from a
shared global store with a `(global)` suffix so they are never mistaken for
the project's own entries.

### Org Markdown Files

Org roots keep knowledge as markdown in `<org>/.agents/knowledge/*.md`,
indexed per `##` section.

- `write_knowledge` on an org root requires `file` (for example
`dsp-verification.md`). `summary` becomes the `##` heading, `detail` the
section body, and `source` is folded in as a final `Source:` line. Omit
`file` to get the available files; a bare new `*.md` name creates it.
- `update_knowledge` on an org root takes the id
`org-<file>::<heading>` and only applies `detail`. The heading is preserved
and the trailing `Source:` line is carried over.
- A heading collision is rejected with the matching `update_knowledge` id
rather than merged. `rule` and `supersedes` do not apply to sections.
- Writes are atomic (temp file + fsync + rename) and take the same per-file
lock as YAML stores. Queries see them through the index fingerprint.

### Rules / Constraints

Entries can include an optional `rule` field — an imperative statement of what's forbidden or required (e.g. `"Never create files outside ./tmp"`). `list_knowledge` surfaces entries with rules in a separate `## constraints` section above regular entries, so agents see hard rules immediately without needing to query detail.
Expand Down
2 changes: 2 additions & 0 deletions examples/KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ Write it back:
- `write_knowledge(project="<name>", category="subsystems", summary="...", detail="...", source="...")`
- `write_knowledge(project="<name>", category="decisions", summary="...", detail="...", source="...")`
- `write_knowledge(project="<name>", category="workflows", summary="...", detail="...", source="...")`
- Org root: `write_knowledge(project="<org>", file="topic.md", summary="<heading>", detail="...", source="...")`
(`update_knowledge` uses id `org-<file>::<heading>`; org sections take detail only.)
1 change: 1 addition & 0 deletions install/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func RenderSkillMD(registry []tools.ToolSpec) []byte {
"## Capture workflow\n" +
"\n" +
"- write_knowledge when a convention, subsystem fact, or decision is established.\n" +
"- On an org root, pass file=<name>.md: summary is the section heading and detail the body.\n" +
"- detail: 10-20 lines of query-able facts; never copy source files.\n" +
"- source: record how the knowledge was learned.\n" +
"- rule field: an imperative prohibition/requirement (surfaced first in lists).\n" +
Expand Down
148 changes: 148 additions & 0 deletions knowledge/org.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package knowledge

import (
"errors"
"fmt"
"os"
"strings"
)

// Org section errors. Append requires a fresh heading; replace requires an
// existing one; duplicate headings are ambiguous and rejected by both.
var (
ErrOrgSectionExists = errors.New("section already exists")
ErrOrgSectionNotFound = errors.New("section not found")
ErrOrgDuplicateSection = errors.New("duplicate section heading")
)

// OrgSectionBody returns the body of the "## heading" section in data,
// excluding the heading line. It returns ErrOrgSectionNotFound when the
// heading is absent and ErrOrgDuplicateSection when it appears twice.
func OrgSectionBody(data, heading string) (string, error) {
lines := splitOrgLines(data)
idx := findOrgSections(lines, heading)
if len(idx) == 0 {
return "", ErrOrgSectionNotFound
}
if len(idx) > 1 {
return "", ErrOrgDuplicateSection
}
end := orgSectionEnd(lines, idx[0])
return strings.TrimSpace(strings.Join(lines[idx[0]+1:end], "\n")), nil
}

// AppendOrgSection appends a "## heading" section to data. It refuses to
// guess when the heading already exists.
func AppendOrgSection(data, heading, body string) (string, error) {
idx := findOrgSections(splitOrgLines(data), heading)
if len(idx) > 1 {
return "", ErrOrgDuplicateSection
}
if len(idx) == 1 {
return "", ErrOrgSectionExists
}
section := renderOrgSection(heading, body)
if strings.TrimSpace(data) == "" {
return section, nil
}
return strings.TrimRight(data, "\n") + "\n\n" + section, nil
}

// ReplaceOrgSection replaces the body of an existing "## heading" section,
// preserving the heading line and all content outside the section.
func ReplaceOrgSection(data, heading, body string) (string, error) {
lines := splitOrgLines(data)
idx := findOrgSections(lines, heading)
if len(idx) == 0 {
return "", ErrOrgSectionNotFound
}
if len(idx) > 1 {
return "", ErrOrgDuplicateSection
}

start := idx[0]
end := orgSectionEnd(lines, start)

var b strings.Builder
if prefix := strings.Join(lines[:start], "\n"); prefix != "" {
b.WriteString(strings.TrimRight(prefix, "\n"))
b.WriteString("\n\n")
}
b.WriteString(renderOrgSection(heading, body))
if rest := lines[end:]; len(rest) > 0 {
b.WriteString("\n")
b.WriteString(strings.Join(rest, "\n"))
b.WriteString("\n")
}
return b.String(), nil
}

// SaveText writes a text file atomically (temp file + fsync + rename), the
// same durability model as Save for YAML stores.
func SaveText(filePath, data string) error {
tmpPath := filePath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}

if _, err := f.WriteString(data); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("write temp file: %w", err)
}

if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpPath)
return fmt.Errorf("sync temp file: %w", err)
}

if err := f.Close(); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("close temp file: %w", err)
}

if err := os.Rename(tmpPath, filePath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename temp file: %w", err)
}

return nil
}

func splitOrgLines(data string) []string {
lines := strings.Split(data, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}

func findOrgSections(lines []string, heading string) []int {
var idx []int
for i, line := range lines {
rest, ok := strings.CutPrefix(line, "## ")
if ok && strings.TrimSpace(rest) == heading {
idx = append(idx, i)
}
}
return idx
}

func orgSectionEnd(lines []string, start int) int {
for i := start + 1; i < len(lines); i++ {
if strings.HasPrefix(lines[i], "## ") {
return i
}
}
return len(lines)
}

func renderOrgSection(heading, body string) string {
body = strings.TrimSpace(body)
if body == "" {
return "## " + heading + "\n"
}
return "## " + heading + "\n\n" + body + "\n"
}
Loading
Loading