Merge pull request 'records: list the decision records by topic when asked (hq ADR 0248)' (#115) from feat/records-decisions into main

This commit was merged in pull request #115.
This commit is contained in:
2026-10-07 21:44:12 +00:00
5 changed files with 211 additions and 2 deletions
+1
View File
@@ -19,6 +19,7 @@ lagging behind it, and the lag is in `records_status`.
| `records_search` `{query, limit?}` | where a phrase appears, as written: document, line, nearest heading, and the commit read |
| `records_read` `{path}` | one document, whole |
| `records_list` `{folder?}` | what a folder holds |
| `records_decisions` `{topic?}` | the decision records by topic, in the reading order `02-DECISIONS/README.md` writes: number, title, path and status, generated when asked and never stored (hq ADR 0248) |
| `records_status` | repository, forge, commit and its date, last sync, document count, last error |
| `records_sync` | bring the checkout up to date now |
+142
View File
@@ -0,0 +1,142 @@
package main
// The decision records, by topic, in reading order (novox/hq ADR 0248, issue 297).
//
// The repository writes its reading order in `02-DECISIONS/README.md`, one line per topic:
//
// 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
//
// and each record names its own `topic:`. The list of records under each topic is generated when it is
// read and never stored, because a stored list was a line every decision added to one file, and any two
// decisions open at once conflicted there. This is the same list hq's own `index.py --print` prints, for a
// reader on the mesh rather than in a checkout. Nothing is kept between calls: every answer is read from
// the checkout at the commit it names.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// DecisionsFolder is where the records and their README live.
const DecisionsFolder = "02-DECISIONS"
var (
topicLine = regexp.MustCompile("(?m)^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*\\s+—\\s+`topic:\\s*([^`]+)`")
recordFile = regexp.MustCompile(`^(\d{4})-.+\.md$`)
recordTitle = regexp.MustCompile(`(?m)^# \d+\.\s*(.+)$`)
)
// Decision is one record, as the list names it.
type Decision struct {
Number string `json:"number"`
Title string `json:"title"`
Path string `json:"path"`
Status string `json:"status"`
}
// Topic is one topic of the reading order and the records under it, by number.
type Topic struct {
Topic string `json:"topic"`
Label string `json:"label"`
Decisions []Decision `json:"decisions"`
}
// DecisionsAnswer is the reading order with its records, and what fits none of its topics.
type DecisionsAnswer struct {
Topics []Topic `json:"topics"`
// Unfiled is every record whose topic is not one of the reading order's — hq's `index.py` fails on it,
// so on a merged commit it is empty.
Unfiled []Decision `json:"unfiled,omitempty"`
Commit string `json:"commit"`
}
// frontField is one `name: value` line of a document's frontmatter.
func frontField(text, name string) string {
if !strings.HasPrefix(text, "---\n") {
return ""
}
end := strings.Index(text[4:], "\n---")
if end < 0 {
return ""
}
for _, line := range strings.Split(text[4:4+end], "\n") {
if v, ok := strings.CutPrefix(line, name+":"); ok {
return strings.TrimSpace(v)
}
}
return ""
}
// Decisions is the records by topic, in the reading order the README writes; with a topic named, that
// topic alone. A repository that writes no reading order is answered with why, not with an empty list.
func (r *Records) Decisions(only string) (DecisionsAnswer, error) {
folder := filepath.Join(r.Dir(), DecisionsFolder)
readme, err := os.ReadFile(filepath.Join(folder, "README.md"))
if err != nil {
return DecisionsAnswer{}, fmt.Errorf("the repository holds no %s/README.md, so it writes no reading order", DecisionsFolder)
}
answer := DecisionsAnswer{Topics: []Topic{}}
at := map[string]int{}
for _, m := range topicLine.FindAllStringSubmatch(string(readme), -1) {
name := strings.TrimSpace(m[2])
at[name] = len(answer.Topics)
answer.Topics = append(answer.Topics, Topic{Topic: name, Label: strings.TrimSpace(m[1]), Decisions: []Decision{}})
}
if len(answer.Topics) == 0 {
return DecisionsAnswer{}, fmt.Errorf("%s/README.md writes no reading order: no line of the form "+
"\"1. **Label** — `topic: name`. ...\"", DecisionsFolder)
}
only = strings.TrimSpace(only)
if _, ok := at[only]; only != "" && !ok {
names := make([]string, len(answer.Topics))
for i, t := range answer.Topics {
names[i] = t.Topic
}
return DecisionsAnswer{}, fmt.Errorf("%q is not a topic of the reading order: %s", only, strings.Join(names, ", "))
}
entries, err := os.ReadDir(folder)
if err != nil {
return DecisionsAnswer{}, err
}
names := []string{}
for _, e := range entries {
if e.Type().IsRegular() && recordFile.MatchString(e.Name()) {
names = append(names, e.Name())
}
}
sort.Strings(names)
for _, name := range names {
raw, err := os.ReadFile(filepath.Join(folder, name))
if err != nil {
continue
}
text := string(raw)
d := Decision{
Number: recordFile.FindStringSubmatch(name)[1],
Title: "(no heading)",
Path: DecisionsFolder + "/" + name,
Status: frontField(text, "status"),
}
if m := recordTitle.FindStringSubmatch(text); m != nil {
d.Title = strings.TrimSpace(m[1])
}
topic := frontField(text, "topic")
i, ok := at[topic]
switch {
case !ok && only == "":
answer.Unfiled = append(answer.Unfiled, d)
case ok && (only == "" || only == topic):
answer.Topics[i].Decisions = append(answer.Topics[i].Decisions, d)
}
}
if only != "" {
answer.Topics = []Topic{answer.Topics[at[only]]}
}
answer.Commit = r.Standing().Commit
return answer, nil
}
+7 -2
View File
@@ -1,6 +1,6 @@
// records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025,
// ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every
// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel;
// merge the forge announces and on a timer, and answers six questions about it. stdout is the MCP channel;
// what this module says, it says on stderr.
package main
@@ -51,6 +51,11 @@ func tools(r *Records) []stdio.Tool {
Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
Input: map[string]any{"folder": str("a folder inside the repository (optional)")},
Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }},
{Name: "records_decisions",
Description: "The decision records by topic, in the reading order the repository writes in 02-DECISIONS/README.md: " +
"each record's number, title, path and status. Generated from the records when asked, never stored (novox/hq ADR 0248).",
Input: map[string]any{"topic": str("one topic of the reading order, e.g. \"how we work\" (optional; every topic when omitted)")},
Run: func(a map[string]any) (any, error) { return r.Decisions(strArg(a, "topic")) }},
{Name: "records_status",
Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
Run: func(map[string]any) (any, error) { return r.Standing(), nil }},
@@ -118,7 +123,7 @@ func keep(r *Records) {
func main() {
r, err := FromEnv(os.Getenv)
if err != nil {
// Without a repository to read there is nothing to answer: no tools rather than five that fail.
// Without a repository to read there is nothing to answer: no tools rather than six that fail.
fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err)
if err := stdio.Serve("", nil); err != nil {
os.Exit(1)
@@ -145,3 +145,63 @@ func TestTheReaderRefusesToGuess(t *testing.T) {
t.Fatalf("%+v %v", r, err)
}
}
// The decision records by topic, in the order the README writes, generated from each record's own
// frontmatter (novox/hq ADR 0248): a record lands under its topic, by number, with its status; a record
// whose topic the reading order does not name is unfiled rather than lost; a topic asked for alone is
// answered alone; a repository with no reading order says so.
func TestTheDecisionsAreListedByTopicInTheReadingOrderTheReadmeWrites(t *testing.T) {
origin := aRepository(t)
write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\n## Reading order\n\n"+
"1. **What the mesh is** — `topic: the mesh`. What it is for.\n"+
"2. **How we work** — `topic: how we work`. This repository.\n")
write(t, filepath.Join(origin, "02-DECISIONS/0001-a-decision.md"),
"---\ntopic: how we work\nstatus: accepted\n---\n\n# 1. A decision\n")
write(t, filepath.Join(origin, "02-DECISIONS/0003-a-later-one.md"),
"---\ntopic: the mesh\nstatus: superseded\nsuperseded-by: 02-DECISIONS/0004-x.md\n---\n\n# 3. A later one\n")
write(t, filepath.Join(origin, "02-DECISIONS/0002-an-earlier-one.md"),
"---\ntopic: the mesh\nstatus: accepted\n---\n\n# 2. An earlier one\n")
write(t, filepath.Join(origin, "02-DECISIONS/0005-astray.md"),
"---\ntopic: elsewhere\nstatus: proposed\n---\n\n# 5. Astray\n")
run(t, origin, "add", "-A")
run(t, origin, "commit", "--quiet", "-m", "records")
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
r.Sync()
got, err := r.Decisions("")
if err != nil {
t.Fatal(err)
}
if len(got.Topics) != 2 || got.Topics[0].Topic != "the mesh" || got.Topics[1].Label != "How we work" {
t.Fatalf("not the reading order: %+v", got.Topics)
}
mesh := got.Topics[0].Decisions
if len(mesh) != 2 || mesh[0].Number != "0002" || mesh[1].Number != "0003" || mesh[1].Status != "superseded" ||
mesh[0].Title != "An earlier one" || mesh[0].Path != "02-DECISIONS/0002-an-earlier-one.md" {
t.Fatalf("the mesh's records: %+v", mesh)
}
if len(got.Topics[1].Decisions) != 1 || got.Topics[1].Decisions[0].Number != "0001" {
t.Fatalf("how we work's records: %+v", got.Topics[1].Decisions)
}
if len(got.Unfiled) != 1 || got.Unfiled[0].Number != "0005" {
t.Fatalf("a record off the reading order is not unfiled: %+v", got.Unfiled)
}
if got.Commit == "" {
t.Fatal("the answer names no commit")
}
one, err := r.Decisions("how we work")
if err != nil || len(one.Topics) != 1 || one.Topics[0].Topic != "how we work" || len(one.Unfiled) != 0 {
t.Fatalf("one topic: %+v %v", one, err)
}
if _, err := r.Decisions("nowhere"); err == nil || !strings.Contains(err.Error(), "the mesh, how we work") {
t.Fatalf("an unknown topic is not refused with the topics: %v", err)
}
write(t, filepath.Join(origin, "02-DECISIONS/README.md"), "# 02-DECISIONS\n\nNo order here.\n")
run(t, origin, "commit", "--quiet", "-am", "no order")
r.Sync()
if _, err := r.Decisions(""); err == nil || !strings.Contains(err.Error(), "writes no reading order") {
t.Fatalf("a README with no reading order is not said: %v", err)
}
}
+1
View File
@@ -15,6 +15,7 @@
"records_search",
"records_read",
"records_list",
"records_decisions",
"records_status",
"records_sync"
],