mesh/merge-gate pass: builds mesh-knowledge → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
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 covered: a later merge that contains it was delivered: novox/mesh-catalog@1b7f79f76177 (merged as 73367192 into main, walk plan-17917161183…
958 lines
34 KiB
Go
958 lines
34 KiB
Go
// 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 its own judgement 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", "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) {
|
|
k.kick = make(chan struct{}, 1)
|
|
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 one place the caller is read. The tool runner takes the
|
|
// caller's bus user from the subject the call arrived on and hands it through the SDK (hq issue 365); Unproven where
|
|
// it proves none. It is never read from the call's arguments, so no caller can name another.
|
|
func callerOf(args map[string]any) string { return stdio.CallerOf(args) }
|
|
|
|
// 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, reviews every proposal waiting for its review — at once when one is proposed — and drafts and
|
|
// confirms clauses one step at a time, a review always before the next drafting step: the agent judges one
|
|
// question at a time, and a proposal does not wait behind the import's drafting.
|
|
func background(k *KB) {
|
|
tick := time.NewTicker(time.Minute)
|
|
defer tick.Stop()
|
|
time.Sleep(5 * time.Second)
|
|
review := func() {
|
|
for {
|
|
said, err := k.ReviewStep()
|
|
if err != nil {
|
|
logf("[mesh-knowledge] %v", err)
|
|
return
|
|
}
|
|
if said == "" {
|
|
return
|
|
}
|
|
logf("[mesh-knowledge] %s", said)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
review()
|
|
for {
|
|
said, err := k.DraftStep()
|
|
if err != nil {
|
|
logf("[mesh-knowledge] %v", err)
|
|
break
|
|
}
|
|
if said == "" {
|
|
break
|
|
}
|
|
logf("[mesh-knowledge] %s", said)
|
|
review()
|
|
}
|
|
select {
|
|
case <-tick.C:
|
|
case <-k.kick:
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 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, when a rule that judges the
|
|
// whole record set fails, or when a proposed record ran out of review tries — the mesh raises a condition naming
|
|
// the rule, the records, or the records not reviewed.
|
|
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()}
|
|
}
|
|
var says []string
|
|
for _, f := range findings {
|
|
says = append(says, "the rule "+f.Rule+": "+f.Says)
|
|
}
|
|
sort.Strings(says)
|
|
all, err := k.Store.All(ctx)
|
|
if err != nil {
|
|
return HealthAnswer{State: "unhealthy", Why: "the records cannot be read: " + err.Error()}
|
|
}
|
|
unreviewed := Unreviewed(all)
|
|
if len(findings)+len(unreviewed) > 0 {
|
|
var parts []string
|
|
if len(unreviewed) > 0 {
|
|
if len(unreviewed) > 8 {
|
|
unreviewed = append(unreviewed[:8], fmt.Sprintf("and %d more", len(unreviewed)-8))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d proposed record(s) could not be reviewed in %d tries and wait for their authors to propose them again: %s",
|
|
len(Unreviewed(all)), MostReviewTries, strings.Join(unreviewed, "; ")))
|
|
}
|
|
if len(says) > 0 {
|
|
if len(says) > 8 {
|
|
says = append(says[:8], fmt.Sprintf("and %d more", len(says)-8))
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d rule(s) that judge the whole record set fail: %s", len(findings), strings.Join(says, "; ")))
|
|
}
|
|
return HealthAnswer{State: "unhealthy", Why: strings.Join(parts, ". ")}
|
|
}
|
|
return HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves, every rule that judges the whole record set holds, and no proposal ran out of review tries"}
|
|
}
|
|
|
|
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 — or when a " +
|
|
"proposed record ran out of review tries, naming it.",
|
|
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": map[string]any{"type": "string", "maxLength": MostQuestion,
|
|
"description": "the question, in plain words, at most 500 characters"}, "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. 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"),
|
|
"from": integer("the earlier version to compare"), "to": integer("the later version to compare"), "bound": boundArg()}),
|
|
Run: func(m map[string]any) (any, error) {
|
|
a, err := readArgs("history", m, "kind", "number", "from", "to", "bound")
|
|
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
|
|
}
|
|
b, err := a.num("bound", false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return k.History(key, from, to, b)
|
|
}},
|
|
{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). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " +
|
|
"records it links and the rules: on approve it is accepted (a record that needs the operator waits for the operator's " +
|
|
"Approve), on changes it stays proposed with the findings for its author to revise, and when no judgement can be had " +
|
|
"it stays proposed and says so. 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 + ".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
|
|
}
|