feat(brain): add brain_reject — the missing third option for raw/ notes
hyperguild#81. brain_pending lists brain/raw/ notes awaiting review; brain_promote moves one into the wiki. There was no way to say "reviewed, not worth promoting" — the only options were promote it or let it sit forever. Live consequence, confirmed this session: the pending queue has grown to 11 items, the oldest 152 days old, several of which are generic web scrapes nobody is ever going to promote but nobody could clear either except an un-audited rm bypassing the tool surface entirely. RejectNote (internal/api/promote.go) mirrors PromoteNote's exact safety shape: same basename-only filename validation, same write-then-delete ordering (the source survives any write failure), same collision check (never silently overwrite an existing rejected note under the same name). Stamps rejected_at (+ reject_reason if given) into frontmatter, moves brain/raw/<filename> -> brain/raw/rejected/<filename>. No index rebuild or auto-tunnel -- a rejected note never entered the wiki, so there's nothing to index. raw/rejected/ is a subdirectory ListPending's non-recursive os.ReadDir never walks into, so a rejected note is never re-surfaced as pending -- the human decision, once made, stays made. brain_reject wired into the MCP tool list and dispatch, mirroring brain_promote's schema shape (filename required, reason optional). Human-invoked only, same as promote -- never called automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,6 +145,68 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
||||
return relSlash, nil
|
||||
}
|
||||
|
||||
// RejectOptions identifies a raw note to reject and why.
|
||||
type RejectOptions struct {
|
||||
Filename string // basename in brain/raw/
|
||||
Reason string // optional
|
||||
}
|
||||
|
||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||
// without silently losing it: it stamps rejected_at (+ reject_reason when
|
||||
// given) into frontmatter and moves the file to brain/raw/rejected/, using
|
||||
// the same write-then-delete safety PromoteNote uses — the source survives
|
||||
// any write failure — and the same basename-only filename validation.
|
||||
//
|
||||
// A rejected note never entered the wiki, so no index rebuild or
|
||||
// auto-tunnel run: unlike PromoteNote, this is the end of the note's
|
||||
// journey, not the start of a new one. It stays out of ListPending's view
|
||||
// (raw/rejected/ is a subdirectory, and ListPending's os.ReadDir on raw/
|
||||
// does not recurse), so a rejected note is never re-surfaced as pending —
|
||||
// the human decision, once made, stays made, the same principle behind
|
||||
// this estate's "issue status is surfaced, not auto-closed, but once a
|
||||
// human decides, the decision is recorded" rule applied one level earlier.
|
||||
func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
||||
base := filepath.Base(opts.Filename)
|
||||
if base != opts.Filename || base == "." || base == ".." || strings.ContainsAny(opts.Filename, `/\`) {
|
||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||
}
|
||||
|
||||
src := filepath.Join(brainDir, "raw", base)
|
||||
raw, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||
}
|
||||
return "", fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
|
||||
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||
if _, statErr := os.Stat(dest); statErr == nil {
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
fm, body := parseFrontmatter(string(raw))
|
||||
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||
if opts.Reason != "" {
|
||||
fm.set("reject_reason", opts.Reason)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create rejected dir: %w", err)
|
||||
}
|
||||
// Write-then-delete: the source survives any write failure.
|
||||
if err := os.WriteFile(dest, []byte(fm.render()+body), 0o644); err != nil {
|
||||
return "", fmt.Errorf("write rejected note: %w", err)
|
||||
}
|
||||
if err := os.Remove(src); err != nil {
|
||||
return "", fmt.Errorf("rejected note written but source removal failed: %w", err)
|
||||
}
|
||||
|
||||
rel, _ := filepath.Rel(brainDir, dest)
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
// excerpt returns the first n runes of s, trimmed, single-spaced.
|
||||
func excerpt(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
Reference in New Issue
Block a user