From 87c40fe36439e34b732598103f544c66e86b9406 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 20:34:52 +0200 Subject: [PATCH] fix(api,watcher): name the queue, list parked non-markdown, reserved wings, top-level-only frontmatter key; document the inbox (review of #211) - brain_promote/brain_reject take an optional queue (inbox | needs-review); a name present in both is refused instead of silently acting on the top-level note; brain_pending labels every entry with its queue - parked pdf/txt files are listed (they were invisible); only markdown can be promoted, the others are moved or rejected - promote refuses sources/concepts/entities/wiki as a wing - frontmatterWing reads only a top-level key inside a properly fenced block - docs/raw-inbox.md: the contract, the three env vars, the known limits Each behaviour has a test that fails without it (mutation-checked). Co-Authored-By: Claude Sonnet 5.5 --- docs/raw-inbox.md | 49 +++++++ ingestion/internal/api/handler.go | 3 +- ingestion/internal/api/pending_review_test.go | 2 +- ingestion/internal/api/promote.go | 103 ++++++++++---- ingestion/internal/api/promote_queue_test.go | 126 ++++++++++++++++++ ingestion/internal/api/promote_review_test.go | 13 -- ingestion/internal/mcp/handlers.go | 8 +- ingestion/internal/mcp/handlers_test.go | 25 ++++ ingestion/internal/watcher/watcher.go | 13 +- .../internal/watcher/watcher_wing_test.go | 21 +++ 10 files changed, 316 insertions(+), 47 deletions(-) create mode 100644 docs/raw-inbox.md create mode 100644 ingestion/internal/api/promote_queue_test.go diff --git a/docs/raw-inbox.md b/docs/raw-inbox.md new file mode 100644 index 0000000..bb2e374 --- /dev/null +++ b/docs/raw-inbox.md @@ -0,0 +1,49 @@ +# The raw/ inbox + +`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it +with the machines you drop files from. (hyperguild#205, #208, #209) + +## What happens to a file you drop + +| You drop | Result | +|---|---| +| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed//`, so `raw/` is empty again | +| `raw//clip.md` or `wing: ` in the frontmatter | the same, with the source in `wiki//sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed | +| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted | +| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` | +| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes | + +Concepts and entities are shared across wings because their slug is the graph's +identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`). + +## Parked notes + +`brain_pending` lists them with `queue: needs-review`, a proposed wing and the +reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the +same name also sits in the inbox), or move the file into `raw//` to have it +extracted. Parked pdf/txt files are listed but can only be moved or rejected. + +## Switching it on + +Routing is off by default (the retired layout is used). Set on the ingestion +deployment: + +| Variable | Meaning | +|---|---| +| `INGEST_WING_ROUTING=1` | enable the rules above | +| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) | +| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. It catches what it names and nothing else | + +A bad default or pattern stops the server at startup. A wing created while the +server runs is usable without a restart. + +## Known limits + +- A deliberate folder into a sensitive wing places the note there, and the shared + concept/entity pages then cite that source by title. Client material does not + belong in the brain at all; the pattern only guards the unsignalled lane. +- A merge into a shared page refreshes `last_updated` and `origin` to the latest + ingest; the first wing's attribution is not kept. +- A marker file (`.processed`) is only written when a processed original + cannot be removed. One at least as new as its note holds the note (and says + so once); an older or future-dated one is ignored and removed. diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index 313fba4..4daba38 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -617,6 +617,7 @@ type promoteRequest struct { Wing string `json:"wing"` Hall string `json:"hall"` Slug string `json:"slug,omitempty"` + Queue string `json:"queue,omitempty"` } // Promote handles POST /promote — move a raw/ note into the wiki. A bad @@ -630,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) { // Field by field: this door carries no principal, so the promoted note is // stamped origin: unknown and the body cannot set provenance (#145). rel, err := PromoteNote(h.brainDir, PromoteOptions{ - Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, + Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue, }) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) diff --git a/ingestion/internal/api/pending_review_test.go b/ingestion/internal/api/pending_review_test.go index 0283c24..86f0bfa 100644 --- a/ingestion/internal/api/pending_review_test.go +++ b/ingestion/internal/api/pending_review_test.go @@ -34,7 +34,7 @@ func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) { assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter") assert.Equal(t, "fresh.md", got[1].Filename) - assert.Empty(t, got[1].Queue, "a top-level note has no queue label") + assert.Equal(t, QueueInbox, got[1].Queue) assert.Empty(t, got[1].ProposedWing) } diff --git a/ingestion/internal/api/promote.go b/ingestion/internal/api/promote.go index 6a1a966..a0a86cb 100644 --- a/ingestion/internal/api/promote.go +++ b/ingestion/internal/api/promote.go @@ -26,7 +26,7 @@ type PendingNote struct { // (hyperguild#205): it was not placed automatically, and the proposal says // what the resolver would have done and why it did not. Approve by moving // the note into raw// and the watcher ingests it there. - Queue string `json:"queue,omitempty"` + Queue string `json:"queue"` ProposedWing string `json:"proposed_wing,omitempty"` ProposalReason string `json:"proposal_reason,omitempty"` ProposalConfidence float64 `json:"proposal_confidence,omitempty"` @@ -49,7 +49,7 @@ func ListPending(brainDir string) ([]PendingNote, error) { } defer func() { _ = root.Close() }() - out, err := listNotes(root, "") + out, err := listNotes(root, QueueInbox) if err != nil { return nil, err } @@ -57,7 +57,7 @@ func ListPending(brainDir string) ([]PendingNote, error) { // or note there is refused exactly like at the top level (#153). if sub, serr := root.OpenRoot("needs-review"); serr == nil { defer func() { _ = sub.Close() }() - parked, perr := listNotes(sub, "needs-review") + parked, perr := listNotes(sub, QueueNeedsReview) if perr != nil { return nil, perr } @@ -76,9 +76,25 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) { } out := make([]PendingNote, 0, len(entries)) for _, e := range entries { - if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") { + isMD := strings.HasSuffix(e.Name(), ".md") + // A parked pdf or txt is listed too (the watcher parks any supported + // file); at the top level only markdown ever was. + parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf")) + if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") { continue // directories, and symlinks whose target may be anywhere (#153) } + if !isMD { + info, ierr := root.Lstat(e.Name()) + if ierr != nil { + continue + } + out = append(out, PendingNote{ + Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(), + Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw// to ingest it.)", + Queue: queue, + }) + continue + } raw, info, readErr := readRawNote(root, e.Name()) if readErr != nil { continue @@ -95,7 +111,7 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) { Excerpt: excerpt(body, 200), Queue: queue, } - if queue != "" { + if queue == QueueNeedsReview { n.ProposedWing = fm.get("proposed_wing") n.ProposalReason = unquoteScalar(fm.get("proposal_reason")) n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64) @@ -111,6 +127,9 @@ type PromoteOptions struct { Wing string Hall string Slug string // optional; defaults to Filename minus date prefix + .md + // Queue names which listing the note is in: QueueInbox or QueueNeedsReview. + // Empty is accepted only when the name exists in exactly one of them. + Queue string // Origin/WrittenBy re-attribute the promoted note to the promoter: the raw // note's own frontmatter claims are not provenance (hyperguild#145). Origin string @@ -133,17 +152,26 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) { return "", fmt.Errorf("invalid filename %q", opts.Filename) } + if !strings.HasSuffix(base, ".md") { + return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw// to ingest it", base) + } slug := opts.Slug if slug == "" { slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "") } + // The retired layout's directory names are not wings: a note promoted into + // "sources" would land in wiki/sources//, inside the retired dir. + switch brain.Sanitise(opts.Wing) { + case "sources", "concepts", "entities", "wiki": + return "", fmt.Errorf("%q is not a wing", opts.Wing) + } // NotePath validates hall + wing + slug; do this before reading anything. dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug) if err != nil { return "", err } - raw, srcRel, err := readRawSource(brainDir, base) + raw, srcRel, err := readRawSource(brainDir, base, opts.Queue) if err != nil { return "", err } @@ -198,6 +226,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) { type RejectOptions struct { Filename string // basename in brain/raw/ Reason string // optional + Queue string // as PromoteOptions.Queue } // RejectNote records a human decision not to promote a brain/raw/ note, @@ -220,7 +249,7 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) { return "", fmt.Errorf("invalid filename %q", opts.Filename) } - raw, srcRel, err := readRawSource(brainDir, base) + raw, srcRel, err := readRawSource(brainDir, base, opts.Queue) if err != nil { return "", err } @@ -263,7 +292,10 @@ func excerpt(s string, n int) string { // readRawSource reads a pending note for promote/reject through the raw root, // refusing symlinks and a symlinked raw/ (hyperguild#153). -func readRawSource(brainDir, base string) ([]byte, string, error) { +func readRawSource(brainDir, base, queue string) ([]byte, string, error) { + if queue != "" && queue != QueueInbox && queue != QueueNeedsReview { + return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview) + } root, _, err := OpenRawRoot(brainDir) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -272,34 +304,49 @@ func readRawSource(brainDir, base string) ([]byte, string, error) { return nil, "", fmt.Errorf("read source: %w", err) } defer func() { _ = root.Close() }() - raw, _, err := readRawNote(root, base) - if err == nil { - return raw, "raw/" + base, nil + + var topRaw, parkedRaw []byte + var topErr, parkedErr error + if queue != QueueNeedsReview { + topRaw, _, topErr = readRawNote(root, base) } - if !errors.Is(err, os.ErrNotExist) { - return nil, "", fmt.Errorf("read source: %w", err) - } - // brain_pending also lists notes parked in raw/needs-review/ under their - // bare filename (hyperguild#208); the top level wins on a name clash. The - // sub-root refuses a symlinked directory like OpenRawRoot does. - sub, serr := root.OpenRoot(parkedDir) - if serr != nil { - return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base) - } - defer func() { _ = sub.Close() }() - raw, _, err = readRawNote(sub, base) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base) + if queue != QueueInbox { + // The sub-root refuses a symlinked directory like OpenRawRoot does. + if sub, serr := root.OpenRoot(parkedDir); serr != nil { + parkedErr = serr + } else { + parkedRaw, _, parkedErr = readRawNote(sub, base) + _ = sub.Close() } - return nil, "", fmt.Errorf("read source: %w", err) } - return raw, "raw/" + parkedDir + "/" + base, nil + haveTop := queue != QueueNeedsReview && topErr == nil + haveParked := queue != QueueInbox && parkedErr == nil + switch { + case haveTop && haveParked: + return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview) + case haveTop: + return topRaw, "raw/" + base, nil + case haveParked: + return parkedRaw, "raw/" + parkedDir + "/" + base, nil + } + for _, e := range []error{topErr, parkedErr} { + if e != nil && !errors.Is(e, os.ErrNotExist) { + return nil, "", fmt.Errorf("read source: %w", e) + } + } + return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base) } // parkedDir is where the watcher parks notes it could not place. const parkedDir = "needs-review" +// The two review queues brain_pending lists. promote and reject take one of +// them (or neither, when the name is unambiguous). +const ( + QueueInbox = "inbox" // raw/ + QueueNeedsReview = "needs-review" // raw/needs-review/ +) + // unquoteScalar undoes the Go-style double quoting the watcher writes around a // free-text value (strconv.Quote keeps it on one line); a bare value is // returned as is. diff --git a/ingestion/internal/api/promote_queue_test.go b/ingestion/internal/api/promote_queue_test.go new file mode 100644 index 0000000..7e58e95 --- /dev/null +++ b/ingestion/internal/api/promote_queue_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// brain_pending lists a top-level note and a parked note of the same name as two +// entries with the same filename. Acting on "a.md" must not silently pick one. +func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n") + writeParked(t, dir, "a.md", parkedNote) + + _, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "both") + assert.Contains(t, err.Error(), "queue") + _, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md")) + assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted") + + rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview}) + require.NoError(t, err) + got, _ := os.ReadFile(filepath.Join(dir, rel)) + assert.Contains(t, string(got), "Body of the parked note.") + _, statErr = os.Stat(filepath.Join(dir, "raw", "a.md")) + assert.NoError(t, statErr, "the top-level note of the same name is untouched") +} + +func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n") + writeParked(t, dir, "a.md", parkedNote) + + rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox}) + require.NoError(t, err) + got, _ := os.ReadFile(filepath.Join(dir, rel)) + assert.Contains(t, string(got), "Top-level body.") + _, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md")) + assert.NoError(t, statErr, "the parked note is untouched") +} + +func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "a.md", "top") + _, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview}) + assert.Error(t, err) + _, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"}) + assert.Error(t, err, "an unknown queue name is refused") +} + +func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "a.md", "top") + writeParked(t, dir, "a.md", parkedNote) + + _, err := RejectNote(dir, RejectOptions{Filename: "a.md"}) + require.Error(t, err) + _, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview}) + require.NoError(t, err) + _, statErr := os.Stat(filepath.Join(dir, "raw", "a.md")) + assert.NoError(t, statErr, "the top-level note is untouched") +} + +func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) { + for _, w := range []string{"sources", "concepts", "entities", "wiki"} { + t.Run(w, func(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "a.md", "top") + _, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"}) + assert.Error(t, err) + _, statErr := os.Stat(filepath.Join(dir, "wiki", w)) + assert.ErrorIs(t, statErr, os.ErrNotExist) + }) + } +} + +func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n") + writeParked(t, dir, "p.md", parkedNote) + got, err := ListPending(dir) + require.NoError(t, err) + require.Len(t, got, 2) + queues := map[string]string{} + for _, n := range got { + queues[n.Filename] = n.Queue + } + assert.Equal(t, QueueInbox, queues["fresh.md"]) + assert.Equal(t, QueueNeedsReview, queues["p.md"]) +} + +// Parked notes that are not markdown (a pdf, a txt) were invisible: the only +// trace was a log.md line. +func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644)) + + got, err := ListPending(dir) + require.NoError(t, err) + names := map[string]PendingNote{} + for _, n := range got { + names[n.Filename] = n + } + require.Contains(t, names, "paper.pdf") + require.Contains(t, names, "plain.txt") + assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue) + assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is") +} + +func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644)) + _, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "markdown") + _, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf")) + assert.NoError(t, statErr, "refused, untouched") +} diff --git a/ingestion/internal/api/promote_review_test.go b/ingestion/internal/api/promote_review_test.go index 37e7df5..66490de 100644 --- a/ingestion/internal/api/promote_review_test.go +++ b/ingestion/internal/api/promote_review_test.go @@ -48,19 +48,6 @@ func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) { assert.Empty(t, pending, "a rejected note is not re-listed") } -func TestPromoteNote_ATopLevelNoteWinsOverAParkedOneOfTheSameName(t *testing.T) { - dir := t.TempDir() - writeRaw(t, dir, "same.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n") - writeParked(t, dir, "same.md", parkedNote) - - _, err := PromoteNote(dir, PromoteOptions{Filename: "same.md", Wing: "homelab", Hall: "facts"}) - require.NoError(t, err) - got, _ := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/same.md")) - assert.Contains(t, string(got), "Top-level body.") - _, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "same.md")) - assert.NoError(t, statErr, "the parked note of the same name is left alone") -} - func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) { dir := t.TempDir() outside := t.TempDir() diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 62872e1..7556086 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any { "wing": str("target wing, e.g. jepa-fx"), "hall": enum("target hall", halls...), "slug": str("optional target slug; defaults to filename minus date prefix"), + "queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"), }), }, { @@ -102,6 +103,7 @@ func (s *Server) tools() []map[string]any { "inputSchema": schema([]string{"filename"}, map[string]any{ "filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"), "reason": str("optional; why this isn't worth promoting"), + "queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"), }), }, noteToolDescriptors()[0], @@ -383,6 +385,7 @@ type brainPromoteArgs struct { Wing string `json:"wing"` Hall string `json:"hall"` Slug string `json:"slug,omitempty"` + Queue string `json:"queue,omitempty"` } // brainPromote moves a raw/ note into the structured wiki (frontmatter @@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R } origin, writtenBy := provenanceFrom(ctx) relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{ - Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, + Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue, Origin: origin, WrittenBy: writtenBy, }) if err != nil { @@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R type brainRejectArgs struct { Filename string `json:"filename"` Reason string `json:"reason,omitempty"` + Queue string `json:"queue,omitempty"` } // brainReject records a decision not to promote a raw/ note (api.RejectNote: @@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } - relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason}) + relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue}) if err != nil { return nil, err } diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go index 84b5114..d03b9ac 100644 --- a/ingestion/internal/mcp/handlers_test.go +++ b/ingestion/internal/mcp/handlers_test.go @@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) { resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"}) require.NotNil(t, resp["error"]) } + +// brain_pending lists two entries for one name when a top-level note and a +// parked one share it; the tool must let the caller say which it means. +func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) { + brainDir := t.TempDir() + raw := filepath.Join(brainDir, "raw") + require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644)) + srv := mcp.NewServer(brainDir, nil, nil, nil) + + resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"}) + require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed") + + resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"}) + require.Nil(t, resp["error"], "got: %v", resp["error"]) + got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md")) + require.NoError(t, err) + assert.Contains(t, string(got), "PARKED") + + resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"}) + require.Nil(t, resp["error"], "got: %v", resp["error"]) + _, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md")) + assert.NoError(t, err) +} diff --git a/ingestion/internal/watcher/watcher.go b/ingestion/internal/watcher/watcher.go index 9956315..94b941c 100644 --- a/ingestion/internal/watcher/watcher.go +++ b/ingestion/internal/watcher/watcher.go @@ -392,14 +392,23 @@ func topDir(rel string) string { return "" } -// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none. +// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter, +// "" if there is none. The block counts only if it has a real closing fence (a +// line that is exactly "---"), and an indented `wing:` belongs to some other +// mapping, not to the note. func frontmatterWing(content string) string { content = strings.ReplaceAll(content, "\r\n", "\n") if !strings.HasPrefix(content, "---\n") { return "" } - block, _, _ := strings.Cut(content[4:], "\n---") + block, _, found := cutFence(content[4:]) + if !found { + return "" + } for _, line := range strings.Split(block, "\n") { + if line == "" || line[0] == ' ' || line[0] == '\t' { + continue + } if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" { return strings.Trim(strings.TrimSpace(val), `"'`) } diff --git a/ingestion/internal/watcher/watcher_wing_test.go b/ingestion/internal/watcher/watcher_wing_test.go index 3d6f717..ff7a68b 100644 --- a/ingestion/internal/watcher/watcher_wing_test.go +++ b/ingestion/internal/watcher/watcher_wing_test.go @@ -314,3 +314,24 @@ func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) { assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up") } + +func TestFrontmatterWing(t *testing.T) { + tests := []struct { + name, in, want string + }{ + {"plain key", "---\nwing: radar\n---\nbody", "radar"}, + {"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"}, + {"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"}, + {"no frontmatter", "wing: radar\nbody", ""}, + {"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""}, + {"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""}, + {"no closing fence", "---\nwing: radar\nbody", ""}, + {"closing fence at end of input", "---\nwing: radar\n---", "radar"}, + {"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, frontmatterWing(tt.in)) + }) + } +}