diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 7cb938b1..177adea3 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -17,6 +17,7 @@ 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 | | `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 | diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go new file mode 100644 index 00000000..e0a5ddd7 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list.go @@ -0,0 +1,216 @@ +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 proposal waits for the knowledge base's own review or the operator's Approve. + 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++ + if len(out.Rows) < limit { + 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, + }) + 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 +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go new file mode 100644 index 00000000..972beec6 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/list_test.go @@ -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) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index 2eb6a1ef..9de56e74 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -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", "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,46 @@ 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 + ".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. " + diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 46649ab7..cfab7061 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -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", "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) { diff --git a/modules/mesh-knowledge/module.json b/modules/mesh-knowledge/module.json index c57687b6..0b30dada 100644 --- a/modules/mesh-knowledge/module.json +++ b/modules/mesh-knowledge/module.json @@ -23,6 +23,7 @@ "serves": [ "ask", "get", + "list", "holds", "links", "history", @@ -40,6 +41,7 @@ "serves": [ "ask", "get", + "list", "holds", "links", "history",