Tools to read the knowledge base and the issue tracker in fewer, smaller calls (issues 1064, 1069, 1070, 1071) #354

Merged
mesh-tools merged 6 commits from feat/kb-tracker-tools into main 2026-10-17 21:34:20 +00:00
17 changed files with 907 additions and 28 deletions
+2 -2
View File
@@ -18,8 +18,8 @@ what it refuses.
| verb | takes | does |
|---|---|---|
| `open` | `title`, `report`, optionally `located-in`, `record` | checks the issue, then takes the next number and keeps it under that number; answers the number |
| `get` | `number` | the issue whole: fields, notes, links |
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor) | short rows, newest first, or best first for a `search`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match |
| `get` | `number`, optionally `brief` | the issue whole: fields, notes, links; with `brief`, a short answer with only its last note |
| `list` | optionally `status`, `owner`, `phrase`, `limit`, `severity`, `needs`, `ready`, `flag` (`delivered-not-resolved` or `abandoned`), `search` (words, ranked), `before` (a number, the cursor), `numbers` (only these issues, in the order given, naming any not an issue in `missing`), `worker` (only that worker's held issues) | short rows, newest first, or best first for a `search`, or in the order given for `numbers`: number, status, owner, title, and each flag that holds; `next`, the `before` that reads on, when more match |
| `move` | `number`, `status`, and what that status needs (`by` for `diagnosing` and `fixing`, optionally `take-over`) | changes the status under the development cycle's rules |
| `note` | `number`, `text` | appends a note; a note is never edited |
| `link` | `number`, `kind`, `target`, optionally `remove` | adds or removes a typed link |
@@ -0,0 +1,50 @@
package main
import "testing"
// A get with brief: true answers only its last note, saying how many were left out (issue 1071).
func TestGetBriefAnswersOnlyTheLastNote(t *testing.T) {
h := newHarness(t)
n := h.open(t, "the counter skips a number", "hq")
for _, text := range []string{"first", "second", "third"} {
if _, err := h.Note(n, text); err != nil {
t.Fatalf("note: %v", err)
}
}
got, err := h.call(t, "get", map[string]any{"number": float64(n), "brief": true})
if err != nil {
t.Fatalf("get brief: %v", err)
}
b, ok := got.(*Brief)
if !ok {
t.Fatalf("want *Brief, got %T", got)
}
if len(b.Notes) != 1 || b.Notes[0].Text != "third" {
t.Fatalf("want only the last note (third), got %+v", b.Notes)
}
if b.NotesLeftOut != 2 {
t.Fatalf("want notes-left-out 2, got %d", b.NotesLeftOut)
}
}
// A get without brief is unchanged (issue 1071).
func TestGetWithoutBriefIsUnchanged(t *testing.T) {
h := newHarness(t)
n := h.open(t, "the counter skips a number", "hq")
if _, err := h.Note(n, "first"); err != nil {
t.Fatalf("note: %v", err)
}
got, err := h.call(t, "get", map[string]any{"number": float64(n)})
if err != nil {
t.Fatalf("get: %v", err)
}
v, ok := got.(*View)
if !ok {
t.Fatalf("want *View, got %T", got)
}
if len(v.Notes) != 1 || v.Notes[0].Text != "first" {
t.Fatalf("want every note, got %+v", v.Notes)
}
}
@@ -0,0 +1,64 @@
package main
import (
"strings"
"testing"
)
// list numbers combines search and before as AND filters, never ignoring them (review round 1, issue 1070).
func TestListNumbersAppliesSearchAndBeforeAsFilters(t *testing.T) {
h := newHarness(t)
a := h.open(t, "alpha widget", "hq")
b := h.open(t, "beta widget", "hq")
c := h.open(t, "gamma widget", "hq")
d := h.open(t, "delta nomatch", "hq")
got, err := h.call(t, "list", map[string]any{
"numbers": []any{float64(d), float64(c), float64(b), float64(a)}, "search": "widget"})
if err != nil {
t.Fatalf("list numbers+search: %v", err)
}
l := got.(*Listed)
if len(l.Rows) != 3 || l.Rows[0].Number != c || l.Rows[1].Number != b || l.Rows[2].Number != a {
t.Fatalf("want c, b, a (widget only, in the order given), got %+v", l.Rows)
}
got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "before": float64(c)})
if err != nil {
t.Fatalf("list numbers+before: %v", err)
}
l = got.(*Listed)
if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a {
t.Fatalf("want b, a (below c, in the order given), got %+v", l.Rows)
}
}
// list numbers' limit defaults to the count of numbers asked, still capped, and said names a cut (review round
// 1, issue 1070).
func TestListNumbersLimitDefaultsAndSaysWhenCut(t *testing.T) {
h := newHarness(t)
a := h.open(t, "one", "hq")
b := h.open(t, "two", "hq")
c := h.open(t, "three", "hq")
got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}})
if err != nil {
t.Fatalf("list numbers, default limit: %v", err)
}
l := got.(*Listed)
if len(l.Rows) != 3 {
t.Fatalf("want the limit to default to len(numbers)=3, got %d rows", len(l.Rows))
}
got, err = h.call(t, "list", map[string]any{"numbers": []any{float64(c), float64(b), float64(a)}, "limit": float64(2)})
if err != nil {
t.Fatalf("list numbers, limit 2: %v", err)
}
l = got.(*Listed)
if len(l.Rows) != 2 || l.Matched != 3 {
t.Fatalf("want 2 rows of 3 matched, got rows %d matched %d", len(l.Rows), l.Matched)
}
if !strings.Contains(l.Said, "first 2") {
t.Fatalf("want said to name the cut, got %q", l.Said)
}
}
@@ -0,0 +1,52 @@
package main
import "testing"
// list numbers answers only those issues, rows in the order given, naming a number that does not exist in
// missing (issue 1070).
func TestListNumbersAnswersInTheOrderGivenNamingMissing(t *testing.T) {
h := newHarness(t)
a := h.open(t, "issue a", "hq")
b := h.open(t, "issue b", "hq")
missing := b + 1000
got, err := h.call(t, "list", map[string]any{"numbers": []any{float64(b), float64(a), float64(missing)}})
if err != nil {
t.Fatalf("list numbers: %v", err)
}
l, ok := got.(*Listed)
if !ok {
t.Fatalf("want *Listed, got %T", got)
}
if len(l.Rows) != 2 || l.Rows[0].Number != b || l.Rows[1].Number != a {
t.Fatalf("want rows b then a, got %+v", l.Rows)
}
if len(l.Missing) != 1 || l.Missing[0] != missing {
t.Fatalf("want missing [%d], got %v", missing, l.Missing)
}
}
// list worker answers only the issues that worker holds, in diagnosing or fixing (issue 1070).
func TestListWorkerAnswersOnlyThatWorkersHeldIssues(t *testing.T) {
h := newHarness(t)
held := h.open(t, "held by x", "hq")
other := h.open(t, "held by someone else", "hq")
if _, err := h.Move(MoveAsk{Number: held, Status: StatusFixing, By: "x@n"}); err != nil {
t.Fatalf("move held: %v", err)
}
if _, err := h.Move(MoveAsk{Number: other, Status: StatusFixing, By: "y@n"}); err != nil {
t.Fatalf("move other: %v", err)
}
got, err := h.call(t, "list", map[string]any{"worker": "x@n"})
if err != nil {
t.Fatalf("list worker: %v", err)
}
l, ok := got.(*Listed)
if !ok {
t.Fatalf("want *Listed, got %T", got)
}
if len(l.Rows) != 1 || l.Rows[0].Number != held {
t.Fatalf("want only x@n's held issue, got %+v", l.Rows)
}
}
+58 -6
View File
@@ -282,6 +282,30 @@ func (a args) list(k string) ([]string, error) {
return nil, fmt.Errorf("%s is a list of text", k)
}
// numbers is a list of whole numbers from 1, at most max entries (issue 1070's `numbers`).
func (a args) numbers(k string, max int) ([]uint64, error) {
v, ok := a.m[k]
if !ok || v == nil {
return nil, nil
}
t, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("%s is a list of whole numbers from 1, such as [312, 313]", k)
}
if len(t) > max {
return nil, fmt.Errorf("%s names %d entries; at most %d", k, len(t), max)
}
out := make([]uint64, 0, len(t))
for _, e := range t {
f, ok := e.(float64)
if !ok || f < 1 || f > 1e9 || f != math.Trunc(f) {
return nil, fmt.Errorf("every entry of %s is a whole number from 1, such as 312", k)
}
out = append(out, uint64(f))
}
return out, nil
}
func (a args) number(k string, required bool) (uint64, error) {
v, ok := a.m[k]
if !ok || v == nil {
@@ -387,10 +411,16 @@ func Tools(t *Tracker) []stdio.Tool {
"links; the issues it waits for, those that depend on it (needed-by), and whether it is ready; " +
"whether its fix is delivered, as the delivery last said it (merged, held and why, delivered); and, once " +
"the last of its fixes is delivered, its fix time (fix-time) from its first move to fixing, its class and " +
"fix budget, and its phases: implementing, review, checks, merge to running.",
Input: schema([]string{"number"}, map[string]any{"number": number("the issue's number")}),
"fix budget, and its phases: implementing, review, checks, merge to running. With brief: true, a short " +
"answer instead: number, title, status, severity, needs, ready, worker and since (when diagnosing or " +
"fixing), located-in, fixed-by, the delivery line, its links without who made them or when, opened, and " +
"only its last note, with notes-left-out saying how many were left out.",
Input: schema([]string{"number"}, map[string]any{
"number": number("the issue's number"),
"brief": map[string]any{"type": "boolean", "description": "a short answer with only its last note, instead of every note and the whole triage"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("get", m, "number")
a, err := readArgs("get", m, "number", "brief")
if err != nil {
return nil, err
}
@@ -398,7 +428,18 @@ func Tools(t *Tracker) []stdio.Tool {
if err != nil {
return nil, err
}
return t.View(n)
brief, err := a.flag("brief")
if err != nil {
return nil, err
}
v, err := t.View(n)
if err != nil {
return nil, err
}
if brief {
return briefOf(v), nil
}
return v, nil
}},
{Name: Seat + ".list",
Description: "The issues, newest first, one short row each — number, status, owner, title — optionally only " +
@@ -411,7 +452,9 @@ func Tools(t *Tracker) []stdio.Tool {
"pull requests linked) is delivered; flag abandoned the issues whose worker did nothing on them for a " +
"day with no pull request open, which a move takes over without a reason. Each row says both. Answers " +
"what is open in one call. search ranks the issues whose title, report or notes hold its words, best first; " +
"before pages the newest-first list: give the answer's next to read on below it.",
"before pages the newest-first list: give the answer's next to read on below it. numbers answers only " +
"those issues, rows in the order given, naming any that is not an issue in missing; worker (who@node) " +
"keeps only the issues in diagnosing or fixing whose worker is exactly that.",
Input: schema(nil, map[string]any{
"status": map[string]any{"type": "string", "enum": Statuses, "description": "only issues at this status"},
"owner": text("only issues located in this owner, such as mesh-controller"),
@@ -423,9 +466,12 @@ func Tools(t *Tracker) []stdio.Tool {
"flag": map[string]any{"type": "string", "enum": Flags, "description": "only issues delivered and not resolved, or the abandoned ones"},
"search": text("rank the issues whose title, report or notes hold these words, best first"),
"before": number("only issues numbered below this: the next of the page before, to read on"),
"numbers": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 1},
"maxItems": MostNumbers, "description": "only these issues, rows in the order given; one not an issue is named in missing"},
"worker": text("only issues in diagnosing or fixing whose worker is exactly this, who@node"),
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before")
a, err := readArgs("list", m, "status", "owner", "phrase", "limit", "severity", "needs", "ready", "flag", "search", "before", "numbers", "worker")
if err != nil {
return nil, err
}
@@ -451,9 +497,15 @@ func Tools(t *Tracker) []stdio.Tool {
if ask.Search, err = a.str("search"); err != nil {
return nil, err
}
if ask.Worker, err = a.str("worker"); err != nil {
return nil, err
}
if ask.Before, err = a.number("before", false); err != nil {
return nil, err
}
if ask.Numbers, err = a.numbers("numbers", MostNumbers); err != nil {
return nil, err
}
if v, given := m["ready"]; given && v != nil {
r, err := a.flag("ready")
if err != nil {
@@ -12,6 +12,7 @@ import (
"regexp"
"sort"
"strings"
"time"
)
// The events of ADR 0279.
@@ -320,6 +321,53 @@ func (t *Tracker) View(n uint64) (*View, error) {
DeliveredNotResolved: deliveredNotResolved(is), Abandoned: abandoned(is, now, staleAfter)}, nil
}
// BriefLink is a link as brief answers it: its kind and target, without who made it or when (issue 1071).
type BriefLink struct {
Kind string `json:"kind"`
Target string `json:"target"`
}
// Brief is `get` with brief: true — a short answer naming only what deciding what to do with an issue needs,
// with its last note alone and how many were left out (novox/hq issue 1071).
type Brief struct {
Number uint64 `json:"number"`
Title string `json:"title"`
Status string `json:"status,omitempty"`
Severity string `json:"severity,omitempty"`
Needs string `json:"needs,omitempty"`
Ready bool `json:"ready"`
Worker string `json:"worker,omitempty"`
Since *time.Time `json:"since,omitempty"`
LocatedIn []string `json:"located-in,omitempty"`
FixedBy []string `json:"fixed-by,omitempty"`
Delivery string `json:"delivery,omitempty"`
Links []BriefLink `json:"links,omitempty"`
Opened time.Time `json:"opened"`
Notes []Note `json:"notes,omitempty"`
NotesLeftOut int `json:"notes-left-out"`
}
// briefOf is a View shaped for brief: true: only its last note, with how many were left out.
func briefOf(v *View) *Brief {
b := &Brief{
Number: v.Number, Title: v.Title, Status: v.Status, Severity: v.Severity, Needs: v.Needs,
Ready: v.Ready, LocatedIn: v.LocatedIn, FixedBy: v.FixedBy, Delivery: v.Delivery, Opened: v.Opened,
}
if v.Worker != nil {
b.Worker = v.Worker.By
since := v.Worker.Since
b.Since = &since
}
for _, l := range v.Links {
b.Links = append(b.Links, BriefLink{Kind: l.Kind, Target: l.Target})
}
if n := len(v.Notes); n > 0 {
b.Notes = []Note{v.Notes[n-1]}
b.NotesLeftOut = n - 1
}
return b
}
// letGo says, for an issue that may have just let others go (its fix delivered, or wontfix), every issue it
// made ready: a note on each, once, and the event `ready`.
func (t *Tracker) letGo(dep uint64) error {
+80 -14
View File
@@ -699,8 +699,17 @@ type ListAsk struct {
Search string
// Before keeps only the issues numbered below it: the cursor that pages the newest-first list.
Before uint64
// Numbers keeps only these issues, rows in the order given, combined with the other filters as AND; a
// number that is not an issue is named in the answer's Missing (novox/hq issue 1070).
Numbers []uint64
// Worker keeps only the issues in diagnosing or fixing whose worker is exactly this, who@node (novox/hq
// issue 1070).
Worker string
}
// MostNumbers is the most `numbers` may name in one list.
const MostNumbers = 200
// Row is one issue in a list: short enough to read in a chat.
type Row struct {
Number uint64 `json:"number"`
@@ -731,7 +740,23 @@ type Listed struct {
Matched int `json:"matched"`
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
Next uint64 `json:"next,omitempty"`
Said string `json:"said"`
// Missing is each number asked for (in Numbers) that is not an issue, in the order given (novox/hq issue
// 1070).
Missing []uint64 `json:"missing,omitempty"`
Said string `json:"said"`
}
// searchHits ranks the issues whose title, report or notes hold search's words, best first, from memory once it
// is loaded on start, else from a fresh index (novox/hq ADR 0300 §5).
func (t *Tracker) searchHits(all map[uint64]*Issue, search string) []hit {
if v := t.view(); v.ready() {
return v.search(search)
}
x := newIndex()
for _, is := range all {
x.add(is)
}
return x.search(search)
}
// List is the issues that match, newest first, or ranked by a search: from memory once it is loaded on start
@@ -770,6 +795,10 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
return nil, err
}
}
usingNumbers := len(a.Numbers) > 0
if len(a.Numbers) > MostNumbers {
return nil, fmt.Errorf("numbers names %d entries; at most %d", len(a.Numbers), MostNumbers)
}
search := ""
if strings.TrimSpace(a.Search) != "" {
if search, err = singleLine("search", a.Search, MostPhrase); err != nil {
@@ -778,14 +807,24 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
if len(tokens(search)) == 0 {
return nil, fmt.Errorf("search %q holds no word of two letters or digits", search)
}
if a.Before != 0 {
if a.Before != 0 && !usingNumbers {
return nil, errors.New("before pages the newest-first list; a search is ranked best first, so give limit, " +
"or more words, instead")
}
}
worker := ""
if strings.TrimSpace(a.Worker) != "" {
if worker, err = checkWorkerField("worker", a.Worker); err != nil {
return nil, err
}
}
limit := a.Limit
if limit <= 0 {
limit = 50
if usingNumbers {
limit = len(a.Numbers)
} else {
limit = 50
}
}
limit = min(limit, MostListResult)
all, err := t.allIssues()
@@ -793,19 +832,18 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
return nil, err
}
now, staleAfter := t.now(), t.workerStaleAfter()
var numbers []uint64
if search != "" {
var hits []hit
if v := t.view(); v.ready() {
hits = v.search(search)
} else {
x := newIndex()
for _, is := range all {
x.add(is)
var numbers, missing []uint64
var searchSet map[uint64]bool
if usingNumbers {
numbers = a.Numbers
if search != "" {
searchSet = map[uint64]bool{}
for _, h := range t.searchHits(all, search) {
searchSet[h.Number] = true
}
hits = x.search(search)
}
for _, h := range hits {
} else if search != "" {
for _, h := range t.searchHits(all, search) {
if all[h.Number] != nil {
numbers = append(numbers, h.Number)
}
@@ -822,6 +860,20 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
lower := strings.ToLower(phrase)
for _, n := range numbers {
is := all[n]
if is == nil {
if usingNumbers {
missing = append(missing, n)
}
continue
}
if usingNumbers {
if a.Before != 0 && n >= a.Before {
continue
}
if searchSet != nil && !searchSet[n] {
continue
}
}
if status != "" && is.Status != status {
continue
}
@@ -841,6 +893,9 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
if owner != "" && !locatedUnder(is.LocatedIn, owner) {
continue
}
if worker != "" && (is.Worker == nil || is.Worker.By != worker) {
continue
}
if lower != "" && !strings.Contains(strings.ToLower(is.Title), lower) && !strings.Contains(strings.ToLower(is.Report), lower) {
continue
}
@@ -857,11 +912,22 @@ func (t *Tracker) List(a ListAsk) (*Listed, error) {
out.Rows = append(out.Rows, row)
}
}
out.Missing = missing
below := ""
if a.Before != 0 {
below = fmt.Sprintf(" below %d", a.Before)
}
switch {
case usingNumbers && out.Matched == 0 && len(missing) == 0:
out.Said = "No issue asked matches."
case usingNumbers:
out.Said = fmt.Sprintf("%d of %d asked match, in the order given.", out.Matched, len(a.Numbers))
if out.Matched > len(out.Rows) {
out.Said += fmt.Sprintf(" The first %d are shown.", len(out.Rows))
}
if len(missing) > 0 {
out.Said += fmt.Sprintf(" %d of the numbers asked are not an issue.", len(missing))
}
case out.Matched == 0:
out.Said = "No issue matches" + below + "."
case search != "" && out.Matched > len(out.Rows):
@@ -42,15 +42,18 @@ var workerName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*@[a-z0-9][a-z0-
func worked(status string) bool { return status == StatusDiagnosing || status == StatusFixing }
// checkWorker is `by` as kept: who@node, in lower case.
func checkWorker(s string) (string, error) {
s, err := singleLine("by", s, MostWorker)
func checkWorker(s string) (string, error) { return checkWorkerField("by", s) }
// checkWorkerField is a field holding who@node (`by`, or `worker` as list filters), as kept: in lower case.
func checkWorkerField(field, s string) (string, error) {
s, err := singleLine(field, s, MostWorker)
if err != nil {
return "", err
}
s = strings.ToLower(s)
if !workerName.MatchString(s) {
return "", fmt.Errorf("by %q is not who works on it and its node: give who@node, the agent or person and the "+
"machine it works on, such as fix-399@g14 or jochen@shanks", s)
return "", fmt.Errorf("%s %q is not who works on it and its node: give who@node, the agent or person and the "+
"machine it works on, such as fix-399@g14 or jochen@shanks", field, s)
}
return s, nil
}
+2
View File
@@ -17,6 +17,8 @@ operator, it is accepted only on the operator's Approve, given on a channel that
|---|---|---|
| `ask` | `question` (at most 500 characters), `bound` | the records and clauses bearing on the question, ranked; each clause says whether it holds, what changed it, or that it is only drafted; each record says what changed it since and how to read its reasoning |
| `get` | `number`, `kind`, `reasoning`, `section`, `version`, `bound` | one record: clauses, links both ways, what changed it since, versions, reviews, the operator's ask; its reasoning only when asked |
| `list` | `kind`, `status`, `topic`, `flag` (`research-done` or `proposed-old`), `before` (a number, the cursor), `limit` | short rows, newest first, number by number: number, kind, title, status, topic, date, versions, and whether a proposal is pending; `next`, the `before` that reads on, when more match |
| `findings` | `number` (optional) | the whole-set findings kept — each a rule's failure, the records it names, what it says, and when; with `number`, only the findings naming that record |
| `holds` | `topic` or `number`, `bound` | the clauses in force for a topic of the reading order or for one record; never a drafted clause |
| `links` | `number`, `kind` | the record's typed links, out and in |
| `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line |
@@ -0,0 +1,55 @@
package main
// findings (novox/hq issue 1069): the whole-set findings kept (judgeSet, kb.go), or only those naming one
// record.
import (
"context"
"fmt"
"strings"
"time"
)
// FindingRow is one whole-set finding as findings answers it.
type FindingRow struct {
Rule string `json:"rule"`
Records []string `json:"records"`
Says string `json:"says"`
At time.Time `json:"at"`
}
// Findings is the answer to findings.
type Findings struct {
Rows []FindingRow `json:"rows"`
Count int `json:"count"`
}
// Findings is the whole-set findings kept, or only those naming one record, given as a record names it ("276",
// "ADR 276", "research 045") (novox/hq issue 1069).
func (k *KB) Findings(number string) (*Findings, error) {
named := ""
if strings.TrimSpace(number) != "" {
ref, err := ParseRef(number)
if err != nil {
return nil, err
}
key, ok := ref.Record()
if !ok {
return nil, fmt.Errorf("number is a record's number, such as 276 or research 045, not %q", number)
}
named = key.String()
}
all, err := k.Store.Findings(context.Background())
if err != nil {
return nil, err
}
out := &Findings{Rows: []FindingRow{}}
for _, f := range all {
if named != "" && !contains(f.Records, named) {
continue
}
out.Rows = append(out.Rows, FindingRow{Rule: f.Rule, Records: f.Records, Says: f.Says, At: f.At})
}
out.Count = len(out.Rows)
return out, nil
}
@@ -0,0 +1,37 @@
package main
import (
"context"
"testing"
"time"
)
// findings answers the whole-set findings kept, or only those naming one record (issue 1069).
func TestFindingsAnswersKeptFindingsOrOnlyTheOneNamingARecord(t *testing.T) {
s := newMemStore()
at := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
seeded := []Finding{
{Rule: "cited", Records: []string{"ADR 0276"}, Says: "ADR 0276 is accepted and nothing cites it", At: at},
{Rule: "supersession", Records: []string{"ADR 0100", "ADR 0101"}, Says: "ADR 0100 supersedes ADR 0101, which is accepted", At: at},
}
if err := s.SetFindings(context.Background(), seeded); err != nil {
t.Fatal(err)
}
k := &KB{Store: s}
all, err := k.Findings("")
if err != nil {
t.Fatal(err)
}
if all.Count != 2 || len(all.Rows) != 2 {
t.Fatalf("want both findings, got %+v", all)
}
one, err := k.Findings("276")
if err != nil {
t.Fatal(err)
}
if one.Count != 1 || len(one.Rows) != 1 || one.Rows[0].Rule != "cited" {
t.Fatalf("want only the finding naming 0276, got %+v", one)
}
}
@@ -0,0 +1,221 @@
package main
// list (novox/hq issue 1064): one short row per record, filtered by kind, status, topic or a flag, and paged
// newest first, number by number, like issue-tracker.list.
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// The flags list may be asked for.
const (
FlagResearchDone = "research-done"
FlagProposedOld = "proposed-old"
)
// ListFlags are the flags list takes.
var ListFlags = []string{FlagResearchDone, FlagProposedOld}
// ProposedOldAfter is how long a proposal may sit at proposed before flag proposed-old names it.
const ProposedOldAfter = 24 * time.Hour
// MostListResult is the most rows list may answer in one call.
const MostListResult = 500
// AllStatuses are every status a record of either kind may hold.
var AllStatuses = []string{StatusReserved, StatusProposed, StatusAccepted, StatusSuperseded, StatusWithdrawn,
StatusActive, StatusGraduated, StatusAbandoned}
// ListAsk narrows a list.
type ListAsk struct {
Kind string
Status string
Topic string
// Flag keeps only the research efforts done (research-done) or the proposals grown old (proposed-old).
Flag string
// Before keeps only the records numbered below it: the cursor that pages the newest-first list.
Before int
Limit int
}
// ListRow is one record in a list: short enough to read in a chat.
type ListRow struct {
// Number is the record's number as the records print it: "0276" for a decision, "research 045" for
// research.
Number string `json:"number"`
Kind string `json:"kind"`
Title string `json:"title"`
Status string `json:"status"`
Topic string `json:"topic,omitempty"`
Date string `json:"date,omitempty"`
Versions int `json:"versions"`
// Pending says a version waits for the knowledge base's own review, or the operator still waits to Approve
// (an ask that ended, approved or declined, is not pending; novox/hq issue 1064, review round 1).
Pending bool `json:"pending,omitempty"`
}
// Listed is the answer to a list.
type Listed struct {
Rows []ListRow `json:"rows"`
Matched int `json:"matched"`
// Next is the before that continues the list where this page ends; 0 when nothing more matches.
Next int `json:"next,omitempty"`
Said string `json:"said"`
}
// rowNumber is a record's number as list's row prints it.
func rowNumber(kind string, number int) string {
if kind == KindResearch {
return fmt.Sprintf("research %03d", number)
}
return fmt.Sprintf("%04d", number)
}
// List is the records that match, newest first, number by number (novox/hq issue 1064).
func (k *KB) List(a ListAsk) (*Listed, error) {
if a.Kind != "" && !contains(Kinds, a.Kind) {
return nil, fmt.Errorf("kind is decision or research, not %q", a.Kind)
}
if a.Status != "" && !contains(AllStatuses, a.Status) {
return nil, fmt.Errorf("status %q is not one of %s", a.Status, strings.Join(AllStatuses, ", "))
}
if a.Flag != "" && !contains(ListFlags, a.Flag) {
return nil, fmt.Errorf("flag is %s, not %q", strings.Join(ListFlags, " or "), a.Flag)
}
limit := a.Limit
if limit <= 0 {
limit = 50
}
limit = min(limit, MostListResult)
ctx := context.Background()
all, err := k.Store.All(ctx)
if err != nil {
return nil, err
}
byKey := map[Key]*Entry{}
for _, e := range all {
byKey[Key{e.Current.Kind, e.Current.Number}] = e
}
keys := make([]Key, 0, len(byKey))
for key := range byKey {
if a.Before == 0 || key.Number < a.Before {
keys = append(keys, key)
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].Number != keys[j].Number {
return keys[i].Number > keys[j].Number
}
return keys[i].Kind < keys[j].Kind
})
out := &Listed{Rows: []ListRow{}}
var lastNumber int
for _, key := range keys {
e := byKey[key]
r := e.Current
if a.Kind != "" && r.Kind != a.Kind {
continue
}
if a.Status != "" && r.Status != a.Status {
continue
}
if a.Topic != "" && r.Topic != a.Topic {
continue
}
if a.Flag != "" {
ok, err := k.matchesListFlag(ctx, a.Flag, key, e, byKey)
if err != nil {
return nil, err
}
if !ok {
continue
}
}
out.Matched++
// Never cut a page between two records of the same number: a decision and a research effort may share
// one, and a bare-number cursor (before) must stay correct for both (novox/hq issue 1064, review round
// 1).
if len(out.Rows) < limit || (len(out.Rows) > 0 && key.Number == lastNumber) {
out.Rows = append(out.Rows, ListRow{
Number: rowNumber(r.Kind, r.Number), Kind: r.Kind, Title: r.Title, Status: r.Status,
Topic: r.Topic, Date: r.Date, Versions: e.Versions,
Pending: e.Pending != nil || (r.Ask != nil && r.Ask.Said == AskWaiting),
})
lastNumber = key.Number
}
}
below := ""
if a.Before != 0 {
below = fmt.Sprintf(" below %d", a.Before)
}
switch {
case out.Matched == 0:
out.Said = "No record matches" + below + "."
case out.Matched > len(out.Rows):
out.Next = lastNumber
out.Said = fmt.Sprintf("%d records match%s; the newest %d are shown. The next page is before %d.", out.Matched,
below, len(out.Rows), out.Next)
default:
out.Said = fmt.Sprintf("%d records match%s, newest first.", out.Matched, below)
}
return out, nil
}
// matchesListFlag says whether an entry matches a list flag.
func (k *KB) matchesListFlag(ctx context.Context, flag string, key Key, e *Entry, byKey map[Key]*Entry) (bool, error) {
switch flag {
case FlagResearchDone:
return researchDone(e, byKey), nil
case FlagProposedOld:
return k.proposedOld(ctx, key, e)
}
return false, nil
}
// researchDone says whether a research effort is active, names what it became in at least one became link to a
// decision (a became to a design's path is ignored), and every decision it names that way is accepted: a
// candidate for graduated (novox/hq issue 1064).
func researchDone(e *Entry, byKey map[Key]*Entry) bool {
r := e.Current
if r.Kind != KindResearch || r.Status != StatusActive {
return false
}
any := false
for _, l := range r.Links {
if l.Kind != LinkBecame {
continue
}
target, isRecord := l.To.Record()
if !isRecord || target.Kind != KindDecision {
continue
}
any = true
d := byKey[target]
if d == nil || d.Current.Status != StatusAccepted {
return false
}
}
return any
}
// proposedOld says whether a record is proposed and its current version is older than ProposedOldAfter, by the
// KB's clock (novox/hq issue 1064).
func (k *KB) proposedOld(ctx context.Context, key Key, e *Entry) (bool, error) {
if e.Current.Status != StatusProposed {
return false, nil
}
vs, err := k.Store.Versions(ctx, key)
if err != nil {
return false, err
}
for _, v := range vs {
if v.N == e.CurrentVersion {
return k.now().Sub(v.At) > ProposedOldAfter, nil
}
}
return false, nil
}
@@ -0,0 +1,77 @@
package main
import (
"context"
"testing"
"time"
)
// list's pending counts only an ask still waiting, or a pending version: an ask that ended (approved or
// declined) does not make an accepted record show pending (review round 1, issue 1064).
func TestListPendingIgnoresAnAskThatHasEnded(t *testing.T) {
s := newMemStore()
seedRecord(t, s, &Record{Kind: KindDecision, Number: 500, Title: "approved, ask ended", Status: StatusAccepted,
Ask: &OperatorAsk{ID: "a1", Said: AskApproved}}, time.Now())
seedRecord(t, s, &Record{Kind: KindDecision, Number: 501, Title: "still waiting on the operator", Status: StatusProposed,
Ask: &OperatorAsk{ID: "a2", Said: AskWaiting}}, time.Now())
k := &KB{Store: s}
got, err := k.List(ListAsk{})
if err != nil {
t.Fatal(err)
}
byNumber := map[string]bool{}
for _, row := range got.Rows {
byNumber[row.Number] = row.Pending
}
if byNumber["0500"] {
t.Fatalf("an ended ask (approved) must not show pending: %+v", got.Rows)
}
if !byNumber["0501"] {
t.Fatalf("an ask still waiting must show pending: %+v", got.Rows)
}
}
// list's before cursor never cuts a page between a decision and a research effort of the same bare number: the
// page extends to include the twin (review round 1, issue 1064).
func TestListNeverCutsAPageBetweenRecordsOfTheSameNumber(t *testing.T) {
s := newMemStore()
ctx := context.Background()
now := time.Now()
// Two records both numbered 100: a decision and a research effort. The decision sorts first (kind
// "decision" < "research").
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
Record: &Record{Kind: KindDecision, Number: 100, Title: "decision 100", Status: StatusAccepted}}); err != nil {
t.Fatal(err)
}
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
Record: &Record{Kind: KindResearch, Number: 100, Title: "research 100", Status: StatusActive}}); err != nil {
t.Fatal(err)
}
if err := s.Create(ctx, Version{By: "test", At: now, Why: "seed",
Record: &Record{Kind: KindDecision, Number: 99, Title: "decision 99", Status: StatusAccepted}}); err != nil {
t.Fatal(err)
}
k := &KB{Store: s}
// A limit of 1 would otherwise cut between decision 100 and research 100: both must be shown.
got, err := k.List(ListAsk{Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got.Rows) != 2 || got.Rows[0].Number != "0100" || got.Rows[1].Number != "research 100" {
t.Fatalf("want both twins of 100 on the page, got %+v", got.Rows)
}
if got.Next != 100 {
t.Fatalf("want next below 100 (past both twins), got %d", got.Next)
}
// The next page, asked with before the twins' number, must still answer decision 99.
got, err = k.List(ListAsk{Before: got.Next})
if err != nil {
t.Fatal(err)
}
if len(got.Rows) != 1 || got.Rows[0].Number != "0099" {
t.Fatalf("want decision 99 on the next page, got %+v", got.Rows)
}
}
@@ -0,0 +1,92 @@
package main
import (
"context"
"testing"
"time"
)
func seedRecord(t *testing.T, s Store, r *Record, at time.Time) {
t.Helper()
if err := s.Create(context.Background(), Version{By: "test", At: at, Why: "seed", Record: r}); err != nil {
t.Fatalf("seed %s: %v", Key{r.Kind, r.Number}, err)
}
}
// list filters by kind, status and topic (combined as AND), pages with before, and answers both flags as issue
// 1064 specifies.
func TestListFiltersPagesAndFlags(t *testing.T) {
s := newMemStore()
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
k := &KB{Store: s, Now: func() time.Time { return now }}
seedRecord(t, s, &Record{Kind: KindDecision, Number: 100, Title: "accepted in the mesh", Status: StatusAccepted,
Topic: "the mesh", Date: "2026-01-01"}, now.Add(-48*time.Hour))
seedRecord(t, s, &Record{Kind: KindDecision, Number: 200, Title: "proposed, old", Status: StatusProposed,
Topic: "the controller", Date: "2026-10-15"}, now.Add(-30*time.Hour))
seedRecord(t, s, &Record{Kind: KindDecision, Number: 201, Title: "proposed, fresh", Status: StatusProposed,
Topic: "the controller", Date: "2026-10-17"}, now.Add(-1*time.Hour))
seedRecord(t, s, &Record{Kind: KindResearch, Number: 10, Title: "research done", Status: StatusActive,
Topic: "the mesh", Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 100}}}}, now.Add(-72*time.Hour))
seedRecord(t, s, &Record{Kind: KindResearch, Number: 20, Title: "research not done: no became", Status: StatusActive,
Topic: "the mesh"}, now.Add(-72*time.Hour))
seedRecord(t, s, &Record{Kind: KindResearch, Number: 30, Title: "research not done: became to a proposed decision",
Status: StatusActive, Topic: "the mesh",
Links: []Link{{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 200}}}}, now.Add(-72*time.Hour))
// kind, status and topic, combined as AND.
got, err := k.List(ListAsk{Kind: KindDecision, Status: StatusProposed, Topic: "the controller"})
if err != nil {
t.Fatal(err)
}
if got.Matched != 2 || len(got.Rows) != 2 {
t.Fatalf("want 2 proposed decisions in the controller, got %+v", got)
}
// paging: before.
got, err = k.List(ListAsk{Kind: KindDecision, Before: 200})
if err != nil {
t.Fatal(err)
}
if len(got.Rows) != 1 || got.Rows[0].Number != "0100" {
t.Fatalf("want only 0100 before 200, got %+v", got.Rows)
}
// flag research-done: active, with every became decision link accepted.
got, err = k.List(ListAsk{Kind: KindResearch, Flag: FlagResearchDone})
if err != nil {
t.Fatal(err)
}
if len(got.Rows) != 1 || got.Rows[0].Number != "research 010" {
t.Fatalf("want only research 010 done, got %+v", got.Rows)
}
// flag proposed-old: proposed, current version older than 24h by the KB's clock.
got, err = k.List(ListAsk{Flag: FlagProposedOld})
if err != nil {
t.Fatal(err)
}
if len(got.Rows) != 1 || got.Rows[0].Number != "0200" {
t.Fatalf("want only 0200 proposed-old, got %+v", got.Rows)
}
}
// The answer for 500 rows stays one call (issue 1064).
func TestListOf500RowsStaysOneCall(t *testing.T) {
s := newMemStore()
ctx := context.Background()
for n := 1; n <= 505; n++ {
if err := s.Create(ctx, Version{By: "test", Why: "seed",
Record: &Record{Kind: KindDecision, Number: n, Title: "seed", Status: StatusAccepted}}); err != nil {
t.Fatalf("seed %d: %v", n, err)
}
}
k := &KB{Store: s}
got, err := k.List(ListAsk{Limit: 500})
if err != nil {
t.Fatal(err)
}
if got.Matched != 505 || len(got.Rows) != 500 || got.Next == 0 {
t.Fatalf("want 500 of 505 matched in one call, got matched %d rows %d next %d", got.Matched, len(got.Rows), got.Next)
}
}
@@ -37,7 +37,7 @@ import (
const Seat = "knowledge-base"
// Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6).
var Verbs = []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}
var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"}
// HealthTool is the module's own tool the node-engine asks how it is.
const HealthTool = "knowledge_health"
@@ -813,6 +813,62 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
}
return k.Get(g)
}},
{Name: Seat + ".list",
Description: "The records that match, newest first, number by number, one short row each — number, kind, title, status, " +
"topic, date and versions — optionally only those of one kind (decision or research), at one status, of one topic of " +
"the reading order, or named by flag: research-done is an active research effort naming what it became in a became " +
"link to a decision, every such decision accepted — a candidate for graduated; proposed-old is a proposal whose " +
"current version is more than a day old. before pages the newest-first list: give the answer's next to read on below it.",
Input: schema(nil, map[string]any{"kind": kindArg(),
"status": map[string]any{"type": "string", "enum": AllStatuses, "description": "only records at this status"},
"topic": text("only records of this topic, as the reading order groups them"),
"flag": map[string]any{"type": "string", "enum": ListFlags,
"description": "only the research efforts done (research-done), or the proposals grown old (proposed-old)"},
"before": integer("only records numbered below this: the next of the page before, to read on"),
"limit": map[string]any{"type": "integer", "minimum": 1, "maximum": MostListResult, "description": "at most this many rows (default 50)"},
}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("list", m, "kind", "status", "topic", "flag", "before", "limit")
if err != nil {
return nil, err
}
var ask ListAsk
if ask.Kind, err = a.str("kind"); err != nil {
return nil, err
}
if ask.Status, err = a.str("status"); err != nil {
return nil, err
}
if ask.Topic, err = a.str("topic"); err != nil {
return nil, err
}
if ask.Flag, err = a.str("flag"); err != nil {
return nil, err
}
if ask.Before, err = a.num("before", false); err != nil {
return nil, err
}
if ask.Limit, err = a.num("limit", false); err != nil {
return nil, err
}
return k.List(ask)
}},
{Name: Seat + ".findings",
Description: "The whole-set findings kept: every failure of the rules that judge the whole record set, each saying which " +
"records it names and what the rule says of them — a record accepted and cited by nothing, a supersession or a " +
"change no accepted record makes. With number, only the findings that name that record.",
Input: schema(nil, map[string]any{"number": text("only the findings naming this record, such as 276 or research 045")}),
Run: func(m map[string]any) (any, error) {
a, err := readArgs("findings", m, "number")
if err != nil {
return nil, err
}
number, err := a.str("number")
if err != nil {
return nil, err
}
return k.Findings(number)
}},
{Name: Seat + ".holds",
Description: "The clauses in force now, for a topic of the reading order or for one record, each with the record it comes " +
"from; a clause changed in part says by what. Never a clause only drafted from prose: those are counted and not served. " +
@@ -62,7 +62,7 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) {
if m.Module != Module || !reflect.DeepEqual(m.Seats, want) || !reflect.DeepEqual(m.Claims, want) {
t.Fatalf("module %q, seats %+v, claims %+v", m.Module, m.Seats, m.Claims)
}
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "holds", "links", "history", "reserve", "propose", "import", "return"}) {
if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return"}) {
t.Fatalf("the verbs are not ADR 0290 rule 6's: %v", Verbs)
}
if !reflect.DeepEqual(m.Emits, Events) {
+4
View File
@@ -23,6 +23,8 @@
"serves": [
"ask",
"get",
"list",
"findings",
"holds",
"links",
"history",
@@ -40,6 +42,8 @@
"serves": [
"ask",
"get",
"list",
"findings",
"holds",
"links",
"history",