Add mesh-knowledge, the knowledge base's first slice (hq issue 425, ADR 0290)
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
An agent learns what a decision says only by reading whole records. This module holds every decision record and research effort whole in a database in the store, with clauses that carry their own status and source, typed links read from both ends, and every version. It answers ask, get and holds within a stated bound, and takes a record through reserve, propose (refused on any of the 29 per-record rules), review by someone other than the author, and acceptance, on the operator's proven warrant when the record needs it. The 4 whole-set rules make the module unhealthy when they fail. The import reads hq at a commit through the records module, never overwrites, and changes nothing when run again. Prose decisions get drafted clauses that a second judgement must confirm before they are served.
This commit is contained in:
@@ -0,0 +1,934 @@
|
||||
// mesh-knowledge: the knowledge base, holder of the knowledge-base seat (novox/hq ADR 0290, to-be 55). A Go
|
||||
// bundle the control-node's runtime launches. It holds every decision record and research effort whole — its
|
||||
// fields, its clauses each with a status and a source, its typed links read from both ends, its reasoning — with
|
||||
// every version kept, in a database in the store; answers what holds in one bounded call; takes a record proposed,
|
||||
// checked when it is written, reviewed by someone who did not write it and accepted, on the operator's proven
|
||||
// word when it needs it; and imports hq's records at a commit. stdout is the MCP channel; what this module says,
|
||||
// it says on stderr.
|
||||
//
|
||||
// mesh-knowledge the bundle: the seat's verbs and the health tool
|
||||
// mesh-knowledge prepare the preparation step: the database's schema brought to this version's
|
||||
// mesh-knowledge keep acts: judges the whole record set every few minutes and keeps the findings
|
||||
// mesh-knowledge watch judges: reads whether the database serves and what the findings say, every
|
||||
// minute, and leaves its verdict in the status file the health tool reads
|
||||
// mesh-knowledge dry-run <hq checkout> the import's dry run over a checkout on disk, against no database
|
||||
//
|
||||
// The two long-running processes are the act and the judge, as the mounts module's are: keep is judged by its
|
||||
// unit, watch by the health tool, so the module never judges itself by its own tool alone (ADR 0240 rule 2).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds, once for the mesh.
|
||||
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", "review", "import", "return"}
|
||||
|
||||
// HealthTool is the module's own tool the node-engine asks how it is.
|
||||
const HealthTool = "knowledge_health"
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
func readSettings(path string) (Settings, error) {
|
||||
var s Settings
|
||||
if path == "" {
|
||||
return s, errors.New("the module was started without a settings file")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return s, fmt.Errorf("the settings file is not the module's settings: %v", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "prepare":
|
||||
os.Exit(prepare())
|
||||
case "keep":
|
||||
os.Exit(keep())
|
||||
case "watch":
|
||||
os.Exit(watch(os.Getenv("MESH_KNOWLEDGE_STATUS")))
|
||||
case "dry-run":
|
||||
os.Exit(dryRunAtTheTerminal(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
st := &lazyStore{open: func(ctx context.Context) (*PgStore, error) { return PgStoreFromEnv(ctx) }}
|
||||
k := newKB(st)
|
||||
serve(k, os.Getenv("MESH_KNOWLEDGE_STATUS"))
|
||||
}
|
||||
|
||||
// newKB is the knowledge base as the runtime launches it: everything it reaches, over the bus.
|
||||
func newKB(store Store) *KB {
|
||||
file := os.Getenv("MESH_KNOWLEDGE_SETTINGS")
|
||||
return &KB{
|
||||
Store: store,
|
||||
Records: busRecords{},
|
||||
Reserve: busReserver,
|
||||
Judge: busJudgement(os.Getenv("MESH_NODE")),
|
||||
Asks: sdkAskBus{},
|
||||
Notify: busNotifier,
|
||||
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
|
||||
Settings: func() (Settings, error) { return readSettings(file) },
|
||||
Logf: logf,
|
||||
}
|
||||
}
|
||||
|
||||
// serve runs the bundle: the verbs, the operator's answers heard, and the background work.
|
||||
func serve(k *KB, status string) {
|
||||
go hear(k)
|
||||
go background(k)
|
||||
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now))); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// callerOf is who called, as the runtime proves it. The runtime does not hand a module its caller yet (hq issue
|
||||
// 365), so it is Unproven; it is never read from the call's arguments, so no caller can name another.
|
||||
func callerOf(map[string]any) string { return Unproven }
|
||||
|
||||
// hear subscribes to the router's word on this module's asks, asked again until the runtime takes it.
|
||||
func hear(k *KB) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
time.Sleep(wait)
|
||||
err := stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error {
|
||||
var w asks.Warrant
|
||||
if json.Unmarshal(e.Body, &w) == nil {
|
||||
k.HeardEnd(w)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
logf("[mesh-knowledge] hears the operator's answers")
|
||||
return
|
||||
}
|
||||
logf("[mesh-knowledge] does not hear the operator's answers yet (%v); asking again in %s", err, wait)
|
||||
}
|
||||
}
|
||||
|
||||
// background reads the router's record of every waiting ask once a minute, withdraws reservations never
|
||||
// proposed, and drafts and confirms clauses one step at a time.
|
||||
func background(k *KB) {
|
||||
tick := time.NewTicker(time.Minute)
|
||||
defer tick.Stop()
|
||||
time.Sleep(5 * time.Second)
|
||||
for {
|
||||
if said, err := k.Reconcile(); err != nil {
|
||||
logf("[mesh-knowledge] reading the operator's answers: %v", err)
|
||||
} else {
|
||||
for _, s := range said {
|
||||
logf("[mesh-knowledge] %s", s)
|
||||
}
|
||||
}
|
||||
if n, err := k.Withdraw(); err != nil {
|
||||
logf("[mesh-knowledge] withdrawing reservations: %v", err)
|
||||
} else if n > 0 {
|
||||
logf("[mesh-knowledge] withdrew %d reservation(s) never proposed", n)
|
||||
}
|
||||
for {
|
||||
said, err := k.DraftStep()
|
||||
if err != nil {
|
||||
logf("[mesh-knowledge] %v", err)
|
||||
break
|
||||
}
|
||||
if said == "" {
|
||||
break
|
||||
}
|
||||
logf("[mesh-knowledge] %s", said)
|
||||
}
|
||||
<-tick.C
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the bus -------------------------------------------------------------------------------------------------
|
||||
|
||||
// busReserver asks the issue tracker for a number (issue-tracker.reserve, hq issue 424).
|
||||
func busReserver(kind, title string) (int, error) {
|
||||
raw, err := stdio.Ask("seat:issue-tracker.reserve", map[string]any{"kind": kind, "for": title})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var out struct {
|
||||
Kind string `json:"kind"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil || out.Number < 1 || out.Kind != kind {
|
||||
return 0, fmt.Errorf("the issue tracker's answer is not a %s number: %.200s", kind, raw)
|
||||
}
|
||||
return out.Number, nil
|
||||
}
|
||||
|
||||
// busJudgement asks the agent module on this node for one judgement (ADR 0278).
|
||||
func busJudgement(node string) Judgement {
|
||||
return func(question string, schema map[string]any, model string) (json.RawMessage, string, error) {
|
||||
key := "claude-code.claude_code_judge"
|
||||
if node != "" {
|
||||
key += "@" + node
|
||||
}
|
||||
raw, err := stdio.Ask(key, map[string]any{"question": question, "schema": schema, "model": model})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var out struct {
|
||||
Answer json.RawMessage `json:"answer"`
|
||||
Model string `json:"model"`
|
||||
Node string `json:"node"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil || len(out.Answer) == 0 {
|
||||
return nil, "", fmt.Errorf("the agent module's answer is not a judgement: %.200s", raw)
|
||||
}
|
||||
return out.Answer, fmt.Sprintf("the agent on %s (%s)", out.Node, out.Model), nil
|
||||
}
|
||||
}
|
||||
|
||||
// busNotifier tells the operator's channel, as a warning: it is the one severity below urgent.
|
||||
func busNotifier(key, summary, headline, explanation string) error {
|
||||
_, err := stdio.Ask("seat:operator-channel.notify", map[string]any{"key": key, "subject": "the knowledge base",
|
||||
"severity": "warning", "summary": summary, "headline": headline, "explanation": explanation})
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- the store, opened when it is first needed ------------------------------------------------------------
|
||||
|
||||
// lazyStore opens the database on the first call, and again after a failure, so a database that arrives late is
|
||||
// not fatal; and refuses to serve a schema behind this version.
|
||||
type lazyStore struct {
|
||||
open func(context.Context) (*PgStore, error)
|
||||
mu sync.Mutex
|
||||
pg *PgStore
|
||||
}
|
||||
|
||||
func (l *lazyStore) store(ctx context.Context) (*PgStore, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.pg == nil {
|
||||
pg, err := l.open(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the knowledge base's database cannot be opened: %w", err)
|
||||
}
|
||||
l.pg = pg
|
||||
}
|
||||
if err := l.pg.Ready(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return l.pg, nil
|
||||
}
|
||||
|
||||
// Ready says why the store cannot serve, or nil.
|
||||
func (l *lazyStore) Ready(ctx context.Context) error { _, err := l.store(ctx); return err }
|
||||
|
||||
func (l *lazyStore) Get(ctx context.Context, k Key) (*Entry, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, k)
|
||||
}
|
||||
func (l *lazyStore) All(ctx context.Context) ([]*Entry, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.All(ctx)
|
||||
}
|
||||
func (l *lazyStore) Create(ctx context.Context, v Version) error {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Create(ctx, v)
|
||||
}
|
||||
func (l *lazyStore) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Save(ctx, v, current, clearPending, revision)
|
||||
}
|
||||
func (l *lazyStore) Versions(ctx context.Context, k Key) ([]Version, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Versions(ctx, k)
|
||||
}
|
||||
func (l *lazyStore) Search(ctx context.Context, q string, limit int) ([]Hit, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Search(ctx, q, limit)
|
||||
}
|
||||
func (l *lazyStore) Inbound(ctx context.Context, k Key) ([]Inbound, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Inbound(ctx, k)
|
||||
}
|
||||
func (l *lazyStore) SetFindings(ctx context.Context, f []Finding) error {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SetFindings(ctx, f)
|
||||
}
|
||||
func (l *lazyStore) Findings(ctx context.Context) ([]Finding, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Findings(ctx)
|
||||
}
|
||||
func (l *lazyStore) Told(ctx context.Context, topic string) (bool, error) {
|
||||
s, err := l.store(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.Told(ctx, topic)
|
||||
}
|
||||
|
||||
// ---- prepare, check, dry-run --------------------------------------------------------------------------------
|
||||
|
||||
func prepare() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
logf("[mesh-knowledge] preparing the database: %v", err)
|
||||
return 1
|
||||
}
|
||||
defer s.Close()
|
||||
said, err := s.Prepare(ctx)
|
||||
for _, line := range said {
|
||||
logf("[mesh-knowledge] %s", line)
|
||||
}
|
||||
if err != nil {
|
||||
logf("[mesh-knowledge] preparing the database failed: %v", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// KeepEvery is how often keep judges the whole record set; WatchEvery how often watch looks.
|
||||
const (
|
||||
KeepEvery = 5 * time.Minute
|
||||
WatchEvery = time.Minute
|
||||
)
|
||||
|
||||
// keep judges the whole record set every few minutes and keeps the findings in the database: a change that
|
||||
// reached the database by any way is judged within minutes, not only one an acceptance made.
|
||||
func keep() int {
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err == nil {
|
||||
if err = s.Ready(ctx); err == nil {
|
||||
k := &KB{Store: s, Logf: logf}
|
||||
f := k.judgeSet(ctx)
|
||||
logf("[mesh-knowledge] the whole record set judged: %d finding(s)", len(f))
|
||||
}
|
||||
s.Close()
|
||||
}
|
||||
if err != nil {
|
||||
logf("[mesh-knowledge] the whole record set cannot be judged now: %v", err)
|
||||
}
|
||||
cancel()
|
||||
time.Sleep(KeepEvery)
|
||||
}
|
||||
}
|
||||
|
||||
// watch reads, every minute, whether the database serves this version and what the whole-set findings say,
|
||||
// and leaves its verdict in the status file.
|
||||
func watch(status string) int {
|
||||
if status == "" {
|
||||
logf("[mesh-knowledge] watch was started without MESH_KNOWLEDGE_STATUS")
|
||||
return 2
|
||||
}
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
var answer HealthAnswer
|
||||
s, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
answer = HealthAnswer{State: "unhealthy", Why: "the knowledge base's database cannot be opened: " + err.Error()}
|
||||
} else {
|
||||
answer = (&KB{Store: s}).Health(s)
|
||||
s.Close()
|
||||
}
|
||||
cancel()
|
||||
answer.At = time.Now().UTC()
|
||||
if err := writeStatus(status, answer); err != nil {
|
||||
logf("[mesh-knowledge] the status could not be written: %v", err)
|
||||
}
|
||||
time.Sleep(WatchEvery)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatus(path string, a HealthAnswer) error {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".next"
|
||||
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// StatusStale is how old the watch's verdict may be before the health tool says the watch stopped looking.
|
||||
const StatusStale = 3 * WatchEvery
|
||||
|
||||
// ReadStatus is the watch's last verdict, as the health tool answers it: unhealthy when there is none, or when it
|
||||
// is older than StatusStale.
|
||||
func ReadStatus(path string, now time.Time) HealthAnswer {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return HealthAnswer{State: "unhealthy", Why: "the watch has left no verdict yet: " + err.Error()}
|
||||
}
|
||||
var a HealthAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil || (a.State != "healthy" && a.State != "unhealthy") {
|
||||
return HealthAnswer{State: "unhealthy", Why: "the watch's verdict cannot be read"}
|
||||
}
|
||||
if age := now.Sub(a.At); age > StatusStale {
|
||||
return HealthAnswer{State: "unhealthy", Why: fmt.Sprintf("the watch has not looked for %s (its last verdict: %s)", age.Round(time.Second), a.Why)}
|
||||
}
|
||||
a.Healthy = a.State == "healthy"
|
||||
return a
|
||||
}
|
||||
|
||||
// dryRunAtTheTerminal is the import's dry run over a checkout on disk, against an empty knowledge base: what the
|
||||
// import would create, before the module is rolled out. Only the verb writes.
|
||||
func dryRunAtTheTerminal(args []string) int {
|
||||
if len(args) != 1 {
|
||||
logf("usage: mesh-knowledge dry-run <hq checkout>")
|
||||
return 2
|
||||
}
|
||||
k := &KB{Store: newMemStore(), Logf: logf}
|
||||
out, err := k.Import(Unproven, ImportAsk{DryRun: true}, dirRecords{dir: args[0]})
|
||||
if err != nil {
|
||||
logf("%v", err)
|
||||
return 1
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- health ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// Healther is a store that can say why it cannot serve.
|
||||
type Healther interface {
|
||||
Ready(ctx context.Context) error
|
||||
}
|
||||
|
||||
// HealthAnswer is the health tool's answer (novox/hq ADR 0240, ADR 0283): healthy or unhealthy, and why; At is
|
||||
// when the watch looked.
|
||||
type HealthAnswer struct {
|
||||
State string `json:"state"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Why string `json:"why"`
|
||||
At time.Time `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
// Health is how the knowledge base is: unhealthy when its database cannot serve, or when a rule that judges the
|
||||
// whole record set fails — the mesh raises a condition naming the rule and the records.
|
||||
func (k *KB) Health(ready Healther) HealthAnswer {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
if ready != nil {
|
||||
if err := ready.Ready(ctx); err != nil {
|
||||
return HealthAnswer{State: "unhealthy", Why: err.Error()}
|
||||
}
|
||||
}
|
||||
findings, err := k.Store.Findings(ctx)
|
||||
if err != nil {
|
||||
return HealthAnswer{State: "unhealthy", Why: "the whole-set findings cannot be read: " + err.Error()}
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
var says []string
|
||||
for _, f := range findings {
|
||||
says = append(says, "the rule "+f.Rule+": "+f.Says)
|
||||
}
|
||||
sort.Strings(says)
|
||||
if len(says) > 8 {
|
||||
says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8))
|
||||
}
|
||||
return HealthAnswer{State: "unhealthy", Why: fmt.Sprintf("%d rule(s) that judge the whole record set fail: %s", len(findings), strings.Join(says, "; "))}
|
||||
}
|
||||
return HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves, and every rule that judges the whole record set holds"}
|
||||
}
|
||||
|
||||
func healthTool(status string, now func() time.Time) stdio.Tool {
|
||||
return stdio.Tool{Name: HealthTool,
|
||||
Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version and every " +
|
||||
"rule that judges the whole record set holds; unhealthy otherwise, naming the rule and the records — a decision accepted " +
|
||||
"that nothing cites, a supersession or a change said on one end only, a record extending a superseded one.",
|
||||
Run: func(map[string]any) (any, error) { return ReadStatus(status, now()), nil }}
|
||||
}
|
||||
|
||||
// ---- the verbs --------------------------------------------------------------------------------------------------
|
||||
|
||||
// args reads one call's arguments strictly: an argument the verb does not take is refused, never ignored — and no
|
||||
// argument can say who the caller is.
|
||||
type args struct {
|
||||
verb string
|
||||
m map[string]any
|
||||
}
|
||||
|
||||
func readArgs(verb string, m map[string]any, takes ...string) (args, error) {
|
||||
allowed := map[string]bool{}
|
||||
for _, k := range takes {
|
||||
allowed[k] = true
|
||||
}
|
||||
var unknown []string
|
||||
for k := range m {
|
||||
if !allowed[k] {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return args{}, fmt.Errorf("%s takes %s; it does not take %s", verb, strings.Join(takes, ", "), strings.Join(unknown, ", "))
|
||||
}
|
||||
return args{verb: verb, m: m}, nil
|
||||
}
|
||||
|
||||
func (a args) str(k string) (string, error) {
|
||||
v, ok := a.m[k]
|
||||
if !ok || v == nil {
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is text", k)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (a args) strs(k string) ([]string, error) {
|
||||
v, ok := a.m[k]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return []string{t}, nil
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, e := range t {
|
||||
s, ok := e.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("every entry of %s is text", k)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of text", k)
|
||||
}
|
||||
|
||||
func (a args) num(k string, required bool) (int, error) {
|
||||
v, ok := a.m[k]
|
||||
if !ok || v == nil {
|
||||
if required {
|
||||
return 0, fmt.Errorf("%s needs %s", a.verb, k)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t >= 0 && t <= 1e6 && t == math.Trunc(t) {
|
||||
return int(t), nil
|
||||
}
|
||||
case string:
|
||||
if kindNumber.MatchString(strings.TrimSpace(t)) {
|
||||
return atoi(strings.TrimSpace(t)), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%s is a whole number, such as 276", k)
|
||||
}
|
||||
|
||||
func (a args) flag(k string) (bool, error) {
|
||||
v, ok := a.m[k]
|
||||
if !ok || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false", k)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// key reads kind (decision when absent) and number.
|
||||
func (a args) key() (Key, error) {
|
||||
kind, err := a.str("kind")
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
if kind == "" {
|
||||
kind = KindDecision
|
||||
}
|
||||
if !contains(Kinds, kind) {
|
||||
return Key{}, fmt.Errorf("kind is decision or research, not %q", kind)
|
||||
}
|
||||
n, err := a.num("number", true)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
if n < 1 {
|
||||
return Key{}, errors.New("number is a record's number, from 1")
|
||||
}
|
||||
return Key{kind, n}, nil
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
func texts(d string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": d}
|
||||
}
|
||||
func integer(d string) map[string]any {
|
||||
return map[string]any{"type": "integer", "minimum": 1, "description": d}
|
||||
}
|
||||
func flag(d string) map[string]any { return map[string]any{"type": "boolean", "description": d} }
|
||||
func kindArg() map[string]any {
|
||||
return map[string]any{"type": "string", "enum": Kinds, "description": "decision (the default) or research"}
|
||||
}
|
||||
func boundArg() map[string]any {
|
||||
return map[string]any{"type": "integer", "minimum": LeastBound, "maximum": MostBound,
|
||||
"description": "the most bytes the answer may hold (default 4096)"}
|
||||
}
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Tools are the seat's verbs, each said in plain words: what it does and what it refuses. caller is who called,
|
||||
// as the runtime proves it.
|
||||
func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: Seat + ".ask",
|
||||
Description: "Ask what holds about something, in plain words — \"what holds about the merge window, and why\" — and get the " +
|
||||
"decision records and research efforts that bear on it, ranked, with their matching clauses: each clause says whether " +
|
||||
"it is in force, what changed or superseded it, or that it is only drafted and not the record's word; each record says " +
|
||||
"what changed it since and how to read its reasoning. One call, within a bound it states (4 KiB unless asked for more), " +
|
||||
"saying what it cut.",
|
||||
Input: schema([]string{"question"}, map[string]any{"question": text("the question, in plain words"), "bound": boundArg()}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("ask", m, "question", "bound")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q, err := a.str("question")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := a.num("bound", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Ask(q, b)
|
||||
}},
|
||||
{Name: Seat + ".get",
|
||||
Description: "One record by its number: its title, status, topic, date and deciders; its clauses, each with whether it holds; " +
|
||||
"its links both ways; what changed it since; its versions; its reviews, and the operator's ask when it waits for one. " +
|
||||
"Its reasoning (context, options, consequences, how it is checked; a research effort's documents) only with reasoning, " +
|
||||
"or one section by name; an earlier version with version. Within a bound it states, 4 KiB unless asked for more, " +
|
||||
"saying what it cut.",
|
||||
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number, such as 276"),
|
||||
"reasoning": flag("include its reasoning whole"), "section": text("only this section of its reasoning, by name"),
|
||||
"version": integer("an earlier version"), "bound": boundArg()}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("get", m, "kind", "number", "reasoning", "section", "version", "bound")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := GetAsk{Kind: key.Kind, Number: key.Number}
|
||||
if g.Reasoning, err = a.flag("reasoning"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.Section, err = a.str("section"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.Version, err = a.num("version", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if g.Bound, err = a.num("bound", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Get(g)
|
||||
}},
|
||||
{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. " +
|
||||
"Within a bound it states, 4 KiB unless asked for more, saying what it cut.",
|
||||
Input: schema(nil, map[string]any{"topic": text("a topic, such as the mesh"), "kind": kindArg(),
|
||||
"number": integer("one record's number"), "bound": boundArg()}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("holds", m, "topic", "kind", "number", "bound")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topic, err := a.str("topic")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := a.num("bound", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var key *Key
|
||||
if _, given := m["number"]; given {
|
||||
kk, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key = &kk
|
||||
}
|
||||
if key != nil && topic != "" {
|
||||
return nil, errors.New("holds takes a topic or a record's number, not both")
|
||||
}
|
||||
return k.Holds(topic, key, b)
|
||||
}},
|
||||
{Name: Seat + ".links",
|
||||
Description: "A record's typed links, both ways: what it supersedes, changes (clause by clause), extends, became or cites, " +
|
||||
"and every record that points at it, each with how it was found.",
|
||||
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("links", m, "kind", "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Links(key)
|
||||
}},
|
||||
{Name: Seat + ".history",
|
||||
Description: "A record's versions, every one kept: who made it, when, why, and whether it was a progressive insight; with " +
|
||||
"from and to, the difference between those two versions, line by line.",
|
||||
Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
||||
"from": integer("the earlier version to compare"), "to": integer("the later version to compare")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("history", m, "kind", "number", "from", "to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
from, err := a.num("from", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, err := a.num("to", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.History(key, from, to)
|
||||
}},
|
||||
{Name: Seat + ".reserve",
|
||||
Description: "Reserve the next decision or research number for a record you are about to propose, from the issue " +
|
||||
"tracker's counter: never one another caller got. The number is spent whether or not its record is ever accepted; " +
|
||||
"one never proposed is withdrawn after a week. Refused: a kind other than decision or research, an empty title.",
|
||||
Input: schema([]string{"kind", "for"}, map[string]any{"kind": map[string]any{"type": "string", "enum": Kinds},
|
||||
"for": text("the title of the record the number is for")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("reserve", m, "kind", "for")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, err := a.str("kind")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
title, err := a.str("for")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.ReserveNumber(caller(m), kind, title)
|
||||
}},
|
||||
{Name: Seat + ".propose",
|
||||
Description: "Propose a record whole under a reserved number: its title, topic, date and deciders; for a decision its " +
|
||||
"clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " +
|
||||
"Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " +
|
||||
"links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator. Refused, naming " +
|
||||
"every reason at once and writing nothing, when it breaks one of the 29 rules that judge one record or its prose — a " +
|
||||
"link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " +
|
||||
"proposal is revised by proposing it again; an accepted record changes only by a progressive insight (insight: what " +
|
||||
"stood before). Nothing proposed is in force.",
|
||||
Input: schema([]string{"number", "title"}, map[string]any{
|
||||
"kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line"),
|
||||
"topic": text("for a decision: a topic of the reading order, such as the mesh"),
|
||||
"date": text("the day, YYYY-MM-DD (today when absent)"),
|
||||
"deciders": texts("who decided"),
|
||||
"clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""),
|
||||
"sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")},
|
||||
"required": []string{"name", "text"}}},
|
||||
"links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)",
|
||||
"items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds},
|
||||
"to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}},
|
||||
"homes": texts("hq documents that cite it, such as a design's path"),
|
||||
"needs-operator": flag("it holds only once the operator approves it"),
|
||||
"status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"},
|
||||
"insight": text("for an accepted record: a progressive insight, saying what stood before"),
|
||||
"why": text("one line: why this version"),
|
||||
}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections",
|
||||
"links", "homes", "needs-operator", "status", "insight", "why")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := ProposeAsk{Kind: key.Kind, Number: key.Number}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *string
|
||||
}{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} {
|
||||
if *f.p, err = a.str(f.k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, f := range []struct {
|
||||
k string
|
||||
p *[]string
|
||||
}{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} {
|
||||
if *f.p, err = a.strs(f.k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if p.NeedsOperator, err = a.flag("needs-operator"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Propose(caller(m), p)
|
||||
}},
|
||||
{Name: Seat + ".review",
|
||||
Description: "Review a proposed record you did not write: approve, or ask for changes with your findings (name the section " +
|
||||
"and clause). Approved, it is accepted and its clauses are in force, and every clause it changes or supersedes moves; a " +
|
||||
"record that needs the operator is asked of the operator first, on a channel that proves who answered, and accepted only " +
|
||||
"on their Approve. Refused: a review by the record's author, a record not proposed, changes without findings.",
|
||||
Input: schema([]string{"number", "verdict"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
||||
"verdict": map[string]any{"type": "string", "enum": []string{VerdictApprove, VerdictChanges}},
|
||||
"findings": text("what you found, by section and clause")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("review", m, "kind", "number", "verdict", "findings")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := ReviewAsk{Kind: key.Kind, Number: key.Number}
|
||||
if r.Verdict, err = a.str("verdict"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Findings, err = a.str("findings"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Review(caller(m), r)
|
||||
}},
|
||||
{Name: Seat + ".import",
|
||||
Description: "Import hq's decision records and research efforts, read through the records module at the commit its checkout " +
|
||||
"stands at, each whole at its own number as its first version naming that commit. It only creates a record that is not " +
|
||||
"held, never overwrites one, never moves a number counter, and run again changes nothing and says so. A decision written " +
|
||||
"as prose waits for its clauses to be drafted and confirmed. With dry-run it writes nothing and says what it would do; " +
|
||||
"with commit it refuses a checkout standing elsewhere.",
|
||||
Input: schema(nil, map[string]any{"dry-run": flag("read everything, write nothing, say what would be done"),
|
||||
"commit": text("the commit of hq the record must stand at (a prefix is enough)")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("import", m, "dry-run", "commit")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ask ImportAsk
|
||||
if ask.DryRun, err = a.flag("dry-run"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ask.Commit, err = a.str("commit"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Import(caller(m), ask, k.Records)
|
||||
}},
|
||||
{Name: Seat + ".return",
|
||||
Description: "Send the clauses drafted from a prose decision back to be drafted again, with your reason, which the next " +
|
||||
"drafting reads. Until they are drafted and confirmed again the record serves none. Refused: a record whose clauses its " +
|
||||
"author wrote, an empty reason.",
|
||||
Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"),
|
||||
"reason": text("what is wrong with the drafted clauses")}),
|
||||
Run: func(m map[string]any) (any, error) {
|
||||
a, err := readArgs("return", m, "kind", "number", "reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := a.key()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reason, err := a.str("reason")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return k.Return(caller(m), key, reason)
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// decode reads a structured argument strictly.
|
||||
func decode(v any, into any, says string) error {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return errors.New(says)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(into); err != nil {
|
||||
return fmt.Errorf("%s (%v)", says, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user