Merge pull request 'Find a tool by the shell command it replaces, best first (hq ADR 0245)' (#21) from feat/search-finds-what-a-verb-replaces into main

This commit was merged in pull request #21.
This commit is contained in:
2026-10-07 19:04:13 +00:00
5 changed files with 468 additions and 72 deletions
+7
View File
@@ -68,6 +68,13 @@ address) and `mesh_runtimes` (which runtimes answered discovery: per runtime its
answer took, its size in bytes, how many modules and tools it announced, whether it was shortened to
fit the bus, when it was last heard — and who was expected and not heard).
`mesh_search` also finds a tool by the shell command it replaces (novox/hq ADR 0245): each seat verb and
module tool says what it replaces in the controller's records (`replaces`, read from the controller's
`tools` and `modules` answers), so `journalctl -u x`, `systemctl status x` or `docker ps` puts the verb
for it first, and says the command it matched as `instead_of`. A word also matches through a short table
of the words agents use for the mesh's (`logs` finds the journal). Best first: a replaced command, then
words in a name or a replaced command, then words in a description; seats before modules on a tie.
Discovery asks the bus (ADR 0197): every runtime answers the NATS services protocol's `$SRV.PING` and
`$SRV.INFO` with what it serves at that moment, and the console reads where the controller's records
place each module. The console waits at least 750 ms, and up to 5 s for every runtime that answered
+3 -2
View File
@@ -30,8 +30,9 @@ bundleless=""
if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; then
echo "NOT TESTED HERE: internal/runtime and internal/console launch bundles that need node and @novox/mesh-sdk"
packages=$(printf '%s\n' "$packages" | grep -v -e '/internal/runtime$' -e '/internal/console$')
# What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287).
bundleless='^TestASeatAndAModuleOfOneNameAreEachReached$'
# What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287),
# and how a search ranks (novox/hq ADR 0245).
bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$'
fi
if command -v gcc >/dev/null 2>&1; then
CGO_ENABLED=1 go test -race -count=1 $packages
+35 -70
View File
@@ -67,9 +67,10 @@ func discovery() []map[string]any {
{"name": verbMachine, "inputSchema": obj(map[string]any{"node": str("the machine, as mesh_overview names it")}, "node"),
"description": "One machine: the seats it holds with their verbs, and the modules assigned to it with their tools — " +
"each with the address to describe or call it by. " + grammar},
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases`")}, "query"),
{"name": verbSearch, "inputSchema": obj(map[string]any{"query": str("words to find in tool names and descriptions, e.g. `postgres databases` — or the shell command you would have run, e.g. `journalctl -u x`, `docker ps`")}, "query"),
"description": "Find tools anywhere in the mesh by words: every match's address and a line of what it does, across the " +
"mesh's seats, the machines' seats and every module on every machine. " + grammar},
"mesh's seats, the machines' seats and every module on every machine, best first. A shell command finds the tool " +
"that replaces it — search before reaching for ssh, journalctl, systemctl or docker. " + grammar},
{"name": verbDescribe, "inputSchema": obj(map[string]any{"address": str("the tool's address")}, "address"),
"description": "What one tool does and the arguments it takes, as a JSON schema. The machine is in the address, " +
"never an argument. " + grammar},
@@ -130,6 +131,9 @@ type index struct {
// what it serves in time, or it answered before and not now. An address it might answer is never
// called missing while it is here (2026-10-05).
Unheard []unheard
// Replaces is what each verb and tool replaces, from the controller's records (novox/hq ADR 0245):
// `seat:<seat>.<verb>` and `<module>.<tool>`.
Replaces map[string][]string
}
// unheard is one runtime discovery did not hear in full, and why.
@@ -221,6 +225,8 @@ type recordedModule struct {
Module string `json:"module"`
On []string `json:"on"`
Tools bool `json:"tools"`
// Replaces is what each of its own tools replaces (novox/hq ADR 0245).
Replaces map[string][]string `json:"replaces,omitempty"`
}
// recordedMachine is a machine as the controller's records hold it (`node list --json`).
@@ -234,9 +240,18 @@ type recordedMachine struct {
func indexOn(conn *bus.Conn) (*index, error) {
var wg sync.WaitGroup
var nodesOut, modulesOut string
wg.Add(2)
var toolsAnswer json.RawMessage
wg.Add(3)
go func() { defer wg.Done(); nodesOut, _ = controllerOutput(conn, "nodes") }()
go func() { defer wg.Done(); modulesOut, _ = controllerOutput(conn, "modules") }()
// The seats' verbs as the records define them, for what each replaces (novox/hq ADR 0245): answered
// in-process from the records, never a command.
go func() {
defer wg.Done()
if got, err := conn.Ask("seat:mesh-controller.tools", map[string]any{}, ""); err == nil {
toolsAnswer = got.Result
}
}()
d, err := announce.Gather(conn)
wg.Wait()
if err != nil {
@@ -258,7 +273,9 @@ func indexOn(conn *bus.Conn) (*index, error) {
// What should have answered: every assignment of a module that declares tools. Silence is named;
// a module with no tools is never a name here.
var recorded []recordedModule
if json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil {
recordedOK := json.Unmarshal([]byte(jsonIn(modulesOut)), &recorded) == nil
x.Replaces = replacesOf(toolsAnswer, recorded)
if recordedOK {
for _, m := range recorded {
if !m.Tools {
continue
@@ -803,70 +820,10 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
case verbSearch:
query := strings.ToLower(str("query"))
if query == "" {
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`")
}
words := strings.Fields(query)
type hit struct {
Address string `json:"address"`
Does string `json:"does"`
Also string `json:"also,omitempty"`
}
var hits []hit
matches := func(parts ...string) bool {
hay := strings.ToLower(strings.Join(parts, " "))
for _, w := range words {
if !strings.Contains(hay, w) {
return false
}
}
return true
}
for _, st := range x.Seats {
for _, v := range st.Verbs {
if !matches(st.Seat, v.Name, v.Description) {
continue
}
if st.Scope == "node" {
on := nodesOf(st.Holders)
first := "<node>"
also := ""
if len(on) > 0 {
first = on[0]
if len(on) > 1 {
also = "also on " + strings.Join(on[1:], ", ")
}
}
hits = append(hits, hit{first + "/" + st.Seat + "." + v.Name, firstLine(v.Description), also})
} else {
hits = append(hits, hit{st.Seat + "." + v.Name, firstLine(v.Description), ""})
}
}
}
names := make([]string, 0, len(x.Modules))
for n := range x.Modules {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := x.Modules[n]
for _, t := range m.Tools {
if !matches(m.Module, t.Name, t.Description) {
continue
}
switch {
case m.Interchangeable:
hits = append(hits, hit{m.Module + "." + t.Name, firstLine(t.Description), "any instance; on " + orNobody(m.On)})
case len(m.On) == 0:
hits = append(hits, hit{"<node>/" + m.Module + "." + t.Name, firstLine(t.Description), "the mesh places it on no machine"})
default:
also := ""
if len(m.On) > 1 {
also = "also on " + strings.Join(m.On[1:], ", ")
}
hits = append(hits, hit{m.On[0] + "/" + m.Module + "." + t.Name, firstLine(t.Description), also})
}
}
return failure("mesh_search needs `query`: words to find, e.g. `postgres databases`, or the command you would " +
"have run, e.g. `journalctl` or `docker ps`")
}
hits := x.search(query)
out := map[string]any{"matches": hits}
if len(hits) > searchCap {
out["matches"] = hits[:searchCap]
@@ -874,7 +831,8 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
}
if len(hits) == 0 {
out["matches"] = []hit{}
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse"
out["hint"] = "nothing matched every word; try fewer words, or mesh_overview and mesh_machine to browse. " +
"If no tool does it, one is created in the module that owns it, on its seat — never worked around (novox/hq ADR 0245)"
}
if len(x.Unheard) > 0 {
out["incomplete"] = "discovery did not hear in full from " + sayUnheard(x.Unheard) +
@@ -895,8 +853,15 @@ func (s *Surface) discover(name string, args map[string]any) map[string]any {
if description == "" {
description = t.Name
}
return answerText(map[string]any{"address": t.Address, "description": description,
"arguments": schemaAsPassed(t)})
described := map[string]any{"address": t.Address, "description": description, "arguments": schemaAsPassed(t)}
key := t.Tool.Module + "." + t.Tool.Name
if t.Seat {
key = "seat:" + key
}
if r := x.Replaces[key]; len(r) > 0 {
described["instead_of"] = r
}
return answerText(described)
case verbCall:
t, err := resolve(x, str("address"))
+272
View File
@@ -0,0 +1,272 @@
package console
// Finding a tool by the words an agent already has (novox/hq ADR 0245).
//
// An agent that wants a unit's log reaches for `journalctl`, and one that wants a container's for `docker
// logs`; it searches with those words, or with `logs`. Matched against names and descriptions alone, none
// of them found the service manager's `journal` — whose description says "journal", never "logs" or
// "journalctl" — and the agent went round the mesh over ssh. So each verb and tool says what it replaces,
// and search matches three ways, best first:
//
// 1. **The query is a command a tool replaces** — `journalctl -u x`, `systemctl status x`, `docker ps`:
// every word of the replaced command, in order, among the query's words. The most specific command wins.
// 2. **Every word is in the tool's name, its replaced commands or its description**, a word also matching
// through a short table of the words agents use for the mesh's (logs ⇄ journal, ps ⇄ list). A word in
// the name or a replaced command weighs more than one in the description, and a name the query says
// whole more than one it says in part.
// 3. Nothing else: a search that matches nothing says so.
//
// Ties go to a seat — the place tools belong — then to the shorter name, then by address.
import (
"encoding/json"
"path"
"sort"
"strings"
"unicode"
)
// synonyms are the words an agent uses for the mesh's own, each matching the other's (a word, not part
// of one: `log` must not match `login`). Kept short: a command is said by the tool that replaces it.
var synonyms = map[string][]string{
"logs": {"log", "journal"},
"log": {"logs", "journal"},
"journal": {"logs", "log"},
"ps": {"list", "running"},
"ls": {"list"},
"list": {"ps", "ls"},
"restart": {"restarts"},
"service": {"unit", "units"},
"services": {"unit", "units"},
"unit": {"service"},
"container": {"containers", "docker"},
"firewall": {"filter", "nftables"},
"hosts": {"hostname"},
}
// hit is one match as mesh_search answers it.
type hit struct {
Address string `json:"address"`
Does string `json:"does"`
// InsteadOf is the command the query matched, when it matched one the tool replaces.
InsteadOf string `json:"instead_of,omitempty"`
Also string `json:"also,omitempty"`
score int
}
// words are a text's words, lower-cased, split at anything that is not a letter, a digit, `-` or `_`
// within a word — so `node-service-manager.journal` is its three names' parts and the whole.
func words(s string) []string {
var out []string
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == '/')
}) {
out = append(out, w)
for _, part := range strings.FieldsFunc(w, func(r rune) bool { return r == '-' || r == '_' || r == '/' }) {
if part != w {
out = append(out, part)
}
}
}
return out
}
// commandWords are a command line's words as matched against a replaced command: lower-cased, a
// program named by path reduced to its name, and what only runs it (`sudo`) left out.
func commandWords(line string) []string {
var out []string
for i, w := range strings.Fields(strings.ToLower(line)) {
if i == 0 || strings.HasPrefix(w, "/") {
w = path.Base(w)
}
if w == "sudo" {
continue
}
out = append(out, w)
}
return out
}
// inOrder says whether every one of want is among have, in the same order.
func inOrder(want, have []string) bool {
i := 0
for _, h := range have {
if i < len(want) && h == want[i] {
i++
}
}
return len(want) > 0 && i == len(want)
}
// replacedBy is the replaced command a query names, the most specific of those it matches, or "".
func replacedBy(query string, replaces []string) string {
q := commandWords(query)
best, bestLen := "", 0
for _, r := range replaces {
w := commandWords(r)
if inOrder(w, q) && len(w) > bestLen {
best, bestLen = r, len(w)
}
}
return best
}
// scoreOf is how well a tool matches the query: 0 for not at all.
func scoreOf(query string, qwords []string, name, description string, replaces []string) (int, string) {
if cmd := replacedBy(query, replaces); cmd != "" {
return 100 + 10*len(commandWords(cmd)), cmd
}
strong := strings.ToLower(name + " " + strings.Join(replaces, " "))
weak := strings.ToLower(description)
strongWords := map[string]bool{}
for _, w := range words(strong) {
strongWords[w] = true
}
weakWords := map[string]bool{}
for _, w := range words(weak) {
weakWords[w] = true
}
score := 10
for _, w := range qwords {
switch {
case strings.Contains(strong, w):
score += 5
case strings.Contains(weak, w):
score++
default:
found := 0
for _, s := range synonyms[w] {
if strongWords[s] {
found = 5
break
}
if weakWords[s] {
found = 1
}
}
if found == 0 {
return 0, ""
}
score += found
}
}
// A name the query says whole — `docker logs` for docker_logs, not docker_secrets_in_logs — first.
own := name[strings.LastIndex(name, ".")+1:]
whole := true
for _, part := range strings.FieldsFunc(strings.ToLower(own), func(r rune) bool { return r == '_' || r == '-' }) {
in := false
for _, w := range qwords {
in = in || w == part
}
whole = whole && in
}
if whole {
score += 2
}
return score, ""
}
// search is mesh_search's answer: every match, best first.
func (x *index) search(query string) []hit {
query = strings.ToLower(strings.TrimSpace(query))
qwords := strings.Fields(query)
var hits []hit
for _, st := range x.Seats {
for _, v := range st.Verbs {
replaces := x.Replaces["seat:"+st.Seat+"."+v.Name]
score, cmd := scoreOf(query, qwords, st.Seat+"."+v.Name, v.Description, replaces)
if score == 0 {
continue
}
h := hit{Does: firstLine(v.Description), InsteadOf: cmd, score: score + 1} // a seat first, on a tie
if st.Scope == "node" {
on := nodesOf(st.Holders)
first := "<node>"
if len(on) > 0 {
first = on[0]
if len(on) > 1 {
h.Also = "also on " + strings.Join(on[1:], ", ")
}
}
h.Address = first + "/" + st.Seat + "." + v.Name
} else {
h.Address = st.Seat + "." + v.Name
}
hits = append(hits, h)
}
}
names := make([]string, 0, len(x.Modules))
for n := range x.Modules {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
m := x.Modules[n]
for _, t := range m.Tools {
score, cmd := scoreOf(query, qwords, m.Module+"."+t.Name, t.Description, x.Replaces[m.Module+"."+t.Name])
if score == 0 {
continue
}
h := hit{Does: firstLine(t.Description), InsteadOf: cmd, score: score}
switch {
case m.Interchangeable:
h.Address, h.Also = m.Module+"."+t.Name, "any instance; on "+orNobody(m.On)
case len(m.On) == 0:
h.Address, h.Also = "<node>/"+m.Module+"."+t.Name, "the mesh places it on no machine"
default:
h.Address = m.On[0] + "/" + m.Module + "." + t.Name
if len(m.On) > 1 {
h.Also = "also on " + strings.Join(m.On[1:], ", ")
}
}
hits = append(hits, h)
}
}
// Best first; on a tie the shorter name — the plainer tool — then the address, so an answer never
// depends on the order discovery heard things in.
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
a, b := hits[i].Address[strings.LastIndex(hits[i].Address, ".")+1:], hits[j].Address[strings.LastIndex(hits[j].Address, ".")+1:]
if len(a) != len(b) {
return len(a) < len(b)
}
return hits[i].Address < hits[j].Address
})
return hits
}
// replacesOf is what every verb and tool replaces, from the controller's records (novox/hq ADR 0245):
// a seat's verbs from its `tools` answer, keyed `seat:<seat>.<verb>`, and a module's own tools from
// its `modules` answer, keyed `<module>.<tool>`. A controller older than the field answers neither,
// and search matches names and descriptions as before.
func replacesOf(toolsAnswer json.RawMessage, modules []recordedModule) map[string][]string {
out := map[string][]string{}
var seats struct {
Seats []struct {
Seat string `json:"seat"`
Tools []struct {
Name string `json:"name"`
Replaces []string `json:"replaces"`
} `json:"tools"`
} `json:"seats"`
}
if json.Unmarshal(toolsAnswer, &seats) == nil {
for _, s := range seats.Seats {
for _, t := range s.Tools {
if len(t.Replaces) > 0 {
out["seat:"+s.Seat+"."+t.Name] = t.Replaces
}
}
}
}
for _, m := range modules {
for tool, r := range m.Replaces {
if len(r) > 0 {
out[m.Module+"."+tool] = r
}
}
}
return out
}
+151
View File
@@ -0,0 +1,151 @@
package console
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-tools/node-tools/internal/announce"
)
// meshToSearch is a mesh as discovery and the controller's records describe it: the service manager's
// verbs on two machines, the controller's own, a docker module with its tools, and what each replaces
// as the records say it — the seats' from `tools`, the module's from `modules`.
func meshToSearch(t *testing.T) *index {
t.Helper()
var endpoints []announce.Endpoint
verbs := map[string]string{
"units": "The units the service manager knows in a scope, each with its load, active and sub state.",
"status": "One unit as the service manager sees it now: its states, whether it starts at boot.",
"restart": "Restart one unit.",
"journal": "The last lines of one unit's journal (at most 2000).",
}
for _, node := range []string{"anchor", "laptop"} {
for verb, does := range verbs {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "systemd", Seat: "node-service-manager",
Tool: verb, Scope: "node", Node: node, Description: does,
Subject: "mesh.seat.node-service-manager.tool." + verb + "." + node})
}
}
for verb, does := range map[string]string{
"node": "What one machine reported it can do, what it is assigned, and why.",
"nodes": "Every machine the mesh knows, with whether it is converged or adopted.",
} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller",
Tool: verb, Scope: "mesh", Description: does, Subject: "mesh.seat.mesh-controller.tool." + verb})
}
for tool, does := range map[string]string{
"docker_list": "Every container on this machine: name, image, state, ports.",
"docker_logs": "The last lines a container wrote.",
"docker_secrets_in_logs": "Whether a container's logs hold a secret.",
"docker_restart": "Restart one container.",
} {
endpoints = append(endpoints, announce.Endpoint{Kind: announce.KindTool, Module: "docker", Tool: tool, Node: "anchor",
Description: does, Subject: "mesh.mod.docker.tool." + tool + ".anchor"})
}
x, _, _ := indexOfEndpoints(endpoints)
tools := json.RawMessage(`{"seats":[{"seat":"node-service-manager","scope":"node","tools":[
{"name":"units","replaces":["systemctl list-units","systemctl --failed"]},
{"name":"status","replaces":["systemctl status","systemctl is-active"]},
{"name":"restart","replaces":["systemctl restart"]},
{"name":"journal","replaces":["journalctl"]}]},
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","replaces":["hostnamectl","uptime"]},{"name":"nodes"}]}]}`)
x.Replaces = replacesOf(tools, []recordedModule{{Module: "docker", On: []string{"anchor"}, Tools: true,
Replaces: map[string][]string{"docker_list": {"docker ps"}, "docker_logs": {"docker logs"}, "docker_restart": {"docker restart"}}}})
return x
}
// **The words an agent has find the verb first** (novox/hq ADR 0245): the command it would have run, or
// the word it would have used, puts the mesh's tool for it at the top.
func TestSearchFindsTheToolThatReplacesACommandFirst(t *testing.T) {
x := meshToSearch(t)
for query, want := range map[string]string{
"journalctl": "anchor/node-service-manager.journal",
"journalctl -u mesh-controller -n 50": "anchor/node-service-manager.journal",
"sudo journalctl -fu sshd": "anchor/node-service-manager.journal",
"/usr/bin/journalctl": "anchor/node-service-manager.journal",
"logs": "anchor/node-service-manager.journal",
"systemctl status": "anchor/node-service-manager.status",
"systemctl status nats.service": "anchor/node-service-manager.status",
"systemctl restart mesh-controller": "anchor/node-service-manager.restart",
"systemctl --failed": "anchor/node-service-manager.units",
"docker ps": "anchor/docker.docker_list",
"docker ps -a": "anchor/docker.docker_list",
"docker logs": "anchor/docker.docker_logs",
"docker logs --tail 100 nats": "anchor/docker.docker_logs",
"docker restart nats": "anchor/docker.docker_restart",
"container logs": "anchor/docker.docker_logs",
"uptime": "mesh-controller.node",
} {
hits := x.search(query)
if len(hits) == 0 {
t.Errorf("%q found nothing; want %s first", query, want)
continue
}
if hits[0].Address != want {
var got []string
for _, h := range hits {
got = append(got, h.Address)
}
t.Errorf("%q found %s first; want %s (all: %s)", query, hits[0].Address, want, strings.Join(got, ", "))
}
}
}
// A match on a replaced command says which command it replaced, and the most specific one wins.
func TestASearchByCommandSaysWhatItReplaces(t *testing.T) {
x := meshToSearch(t)
hits := x.search("systemctl status sshd")
if hits[0].InsteadOf != "systemctl status" {
t.Errorf("the match does not say what it replaces: %+v", hits[0])
}
if hits[0].Also != "also on laptop" {
t.Errorf("a node seat's verb does not say its other machines: %+v", hits[0])
}
// `logs` also finds the container's logs, after the journal — both are answers.
var docker bool
for _, h := range x.search("logs")[:3] {
docker = docker || h.Address == "anchor/docker.docker_logs"
}
if !docker {
t.Errorf("`logs` does not find docker_logs among the first: %+v", x.search("logs"))
}
}
// What search did before stays: every word in a name or description, and nothing for a word nothing has.
// A synonym is a word, never part of one: `logs` matches through `log`, and finds no `login`.
func TestSearchStillMatchesEveryWord(t *testing.T) {
x := meshToSearch(t)
if hits := x.search("machine converged"); len(hits) != 1 || hits[0].Address != "mesh-controller.nodes" {
t.Errorf("words in a description: %+v", hits)
}
if hits := x.search("journalctl kubernetes"); len(hits) != 1 || hits[0].Address != "anchor/node-service-manager.journal" {
t.Errorf("a command with arguments nothing has is still that command: %+v", hits)
}
if hits := x.search("kubernetes"); len(hits) != 0 {
t.Errorf("a word nothing has matched: %+v", hits)
}
x.Modules["shell"] = &moduleInfo{Module: "shell", On: []string{"anchor"}, Tools: []Tool{{Module: "shell", Name: "shell_login",
Description: "The account's login shell."}}}
for _, h := range x.search("logs") {
if h.Address == "anchor/shell.shell_login" {
t.Errorf("`logs` found `login` through its synonym `log`: %+v", h)
}
}
}
// A controller older than `replaces` answers without it: search matches names and descriptions as before.
func TestSearchWithoutReplacesMatchesAsBefore(t *testing.T) {
x := meshToSearch(t)
x.Replaces = replacesOf(json.RawMessage(`{"seats":[{"seat":"node-service-manager","tools":[{"name":"journal"}]}]}`),
[]recordedModule{{Module: "docker"}})
if len(x.Replaces) != 0 {
t.Fatalf("replaces from records that have none: %v", x.Replaces)
}
if hits := x.search("journal"); len(hits) == 0 || hits[0].Address != "anchor/node-service-manager.journal" {
t.Errorf("a verb's own name: %+v", hits)
}
if hits := x.search("docker logs"); len(hits) == 0 || hits[0].Address != "anchor/docker.docker_logs" {
t.Errorf("a tool's own name: %+v", hits)
}
}