Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/draft.go
T
jschoubben ba7b455719
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
Add mesh-knowledge, the knowledge base's first slice (hq issue 425, ADR 0290)
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.
2026-10-11 04:58:35 +02:00

464 lines
17 KiB
Go

package main
// Drafting and confirming clauses at the import (novox/hq ADR 0290 rule 8, to-be 55 §7). For a decision whose
// Decision section is prose, one judgement drafts its clauses: the section in, numbered statements out, each one
// sentence saying nothing the section does not. They are kept `drafted`, and never served as in force. A second
// judgement, a separate call that sees only the record and the drafts, answers per clause whether the record says
// exactly that: a clause it accepts becomes `confirmed`, one it rejects stays `drafted` with the reason. Which
// clause of an older record a supersedes-in-part changes is judged and confirmed the same way. When every record
// of a topic has been through both, the operator is told once, with the counts. `return` sends a record back to
// be drafted again.
//
// The judgements are the agent's on this node (claude-code.claude_code_judge, ADR 0278): no tools, no session,
// at most 25 seconds each, one at a time. The worker takes one step at a time in the background.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// DefaultJudgeModel is the model the judgements ask unless the settings name another.
const DefaultJudgeModel = "sonnet"
// MostDraftTries is how many times a record's drafting is tried before it is left failed, said by its get.
const MostDraftTries = 3
var draftSchema = map[string]any{
"type": "object",
"properties": map[string]any{
"clauses": map[string]any{"type": "array", "items": map[string]any{
"type": "object",
"properties": map[string]any{
"lead": map[string]any{"type": "string", "description": "a few words naming what is decided, ending with a full stop"},
"text": map[string]any{"type": "string", "description": "the one sentence of the statement, after the lead"},
},
"required": []string{"lead", "text"},
}},
},
"required": []string{"clauses"},
}
var confirmSchema = map[string]any{
"type": "object",
"properties": map[string]any{
"verdicts": map[string]any{"type": "array", "items": map[string]any{
"type": "object",
"properties": map[string]any{
"clause": map[string]any{"type": "integer"},
"exact": map[string]any{"type": "boolean", "description": "true when the record says exactly this, nothing more"},
"reason": map[string]any{"type": "string", "description": "when not exact: what the record says instead"},
},
"required": []string{"clause", "exact"},
}},
},
"required": []string{"verdicts"},
}
var whichSchema = map[string]any{
"type": "object",
"properties": map[string]any{
"clauses": map[string]any{"type": "array", "items": map[string]any{"type": "integer"},
"description": "the numbers of the older record's clauses the newer record replaces part of"},
"reason": map[string]any{"type": "string"},
},
"required": []string{"clauses", "reason"},
}
var whichConfirmSchema = map[string]any{
"type": "object",
"properties": map[string]any{
"exact": map[string]any{"type": "boolean"},
"reason": map[string]any{"type": "string"},
},
"required": []string{"exact", "reason"},
}
// DraftQuestion is the first judgement's question: the record's Decision section, nothing else of the mesh.
func DraftQuestion(r *Record, section, returned string) string {
var b strings.Builder
fmt.Fprintf(&b, "Below is the Decision section of a decision record, %s: %q. It is written as prose. Write what it decides as numbered "+
"clauses: each one statement, one sentence, with a lead of a few words naming what is decided. A clause says nothing the section "+
"does not say: no inference, no context from elsewhere, nothing added. Every decision the section makes is in one clause; a "+
"sentence that only explains is in none. Keep the section's own words where you can.\n", r.Key(), r.Title)
if returned != "" {
fmt.Fprintf(&b, "\nAn earlier draft was sent back, with this reason: %s\n", returned)
}
b.WriteString("\nThe Decision section:\n\n" + section)
return b.String()
}
// ConfirmQuestion is the second judgement's question: the record and the drafts, nothing else.
func ConfirmQuestion(r *Record, section string, drafts []Clause) string {
var b strings.Builder
fmt.Fprintf(&b, "Below are the Decision section of a decision record, %s: %q, and clauses drafted from it by someone else. For each "+
"clause, answer whether the section says exactly that: exact is true only when the clause says nothing the section does not, "+
"and leaves out nothing that changes its meaning. When it is not exact, say in reason what the section says instead.\n\n"+
"The Decision section:\n\n%s\n\nThe drafted clauses:\n\n", r.Key(), r.Title, section)
for _, c := range drafts {
fmt.Fprintf(&b, "%d. %s\n", c.N, c.Text)
}
return b.String()
}
func (k *KB) judgeModel() string {
if m := strings.TrimSpace(k.settings().JudgeModel); m != "" {
return m
}
return DefaultJudgeModel
}
// DraftStep takes one step of the drafting and confirming: the first record waiting to be drafted or confirmed,
// then the first changes link whose clause is not yet judged. It answers what it did, or "" when nothing waits.
func (k *KB) DraftStep() (string, error) {
if k.Judge == nil || strings.EqualFold(strings.TrimSpace(k.settings().Drafting), "off") {
return "", nil
}
ctx := context.Background()
all, err := k.Store.All(ctx)
if err != nil {
return "", err
}
for _, e := range all {
d := e.Current.Drafting
if d == nil || d.State == DraftDone || (d.State == DraftFailed && d.Tries >= MostDraftTries) {
continue
}
if d.State == DraftFailed && k.now().Sub(d.At) < time.Duration(d.Tries)*10*time.Minute {
continue
}
return k.draftOne(ctx, e.Current.Key())
}
for _, e := range all {
if !e.Current.Accepted() {
continue
}
for i, l := range e.Current.Links {
if l.Kind == LinkChanges && (l.Source == LinkPending || l.Source == SourceDrafted) && !l.Unresolved {
return k.judgeChange(ctx, e.Current.Key(), i)
}
}
}
return "", nil
}
// draftOne drafts or confirms one record's clauses, under the write lock only to read and to write.
func (k *KB) draftOne(ctx context.Context, key Key) (string, error) {
k.mu.Lock()
e, err := k.Store.Get(ctx, key)
k.mu.Unlock()
if err != nil || e == nil {
return "", err
}
r := e.Current.Clone()
section, ok := decisionSection(r)
if !ok {
r.Drafting = &Drafting{State: DraftDone, Reason: "it has no Decision section", At: k.now()}
return k.saveDraft(ctx, e, r, EventDrafted, "no Decision section to draft from")
}
model := k.judgeModel()
fail := func(step string, err error) (string, error) {
// Tried again after a pause that grows with each try, at the step it failed at, up to MostDraftTries.
r.Drafting.State, r.Drafting.Reason, r.Drafting.At = DraftFailed, step+": "+err.Error(), k.now()
r.Drafting.Tries++
_, serr := k.saveDraft(ctx, e, r, EventDrafted, step+" failed: "+err.Error())
if serr != nil {
return "", serr
}
return "", fmt.Errorf("%s %s: %v", step, key, err)
}
if r.Drafting == nil {
r.Drafting = &Drafting{State: DraftWaiting}
}
// A failed record goes back to the step it failed at: confirming when it holds drafts, drafting otherwise.
confirming := r.Drafting.State == DraftDrafted || (r.Drafting.State == DraftFailed && hasDrafts(r))
if !confirming {
answer, by, err := k.Judge(DraftQuestion(r, section, r.Drafting.Returned), draftSchema, model)
if err != nil {
return fail("drafting", err)
}
var drafted struct {
Clauses []struct {
Lead string `json:"lead"`
Text string `json:"text"`
} `json:"clauses"`
}
if err := json.Unmarshal(answer, &drafted); err != nil || len(drafted.Clauses) == 0 {
return fail("drafting", errors.New("the judgement drafted no clause"))
}
r.Clauses = nil
for i, c := range drafted.Clauses {
lead := strings.TrimSpace(strings.Trim(c.Lead, "* "))
if !strings.HasSuffix(lead, ".") {
lead += "."
}
r.Clauses = append(r.Clauses, Clause{N: i + 1, Text: "**" + lead + "** " + strings.TrimSpace(c.Text),
Status: clauseStatusOf(r.Status), Source: SourceDrafted})
}
r.Drafting = &Drafting{State: DraftDrafted, Drafter: by, Returned: r.Drafting.Returned, Tries: r.Drafting.Tries, At: k.now()}
return k.saveDraft(ctx, e, r, EventDrafted, fmt.Sprintf("%d clauses drafted by %s", len(r.Clauses), by))
}
answer, by, err := k.Judge(ConfirmQuestion(r, section, r.Clauses), confirmSchema, model)
if err != nil {
return fail("confirming", err)
}
var verdicts struct {
Verdicts []struct {
Clause int `json:"clause"`
Exact bool `json:"exact"`
Reason string `json:"reason"`
} `json:"verdicts"`
}
if err := json.Unmarshal(answer, &verdicts); err != nil {
return fail("confirming", errors.New("the judgement answered no verdicts"))
}
confirmed := 0
for i := range r.Clauses {
r.Clauses[i].Unconfirmed = "the second judgement gave no verdict on it"
for _, v := range verdicts.Verdicts {
if v.Clause != r.Clauses[i].N {
continue
}
if v.Exact {
r.Clauses[i].Source, r.Clauses[i].Unconfirmed = SourceConfirmed, ""
confirmed++
} else {
r.Clauses[i].Unconfirmed = strings.TrimSpace(v.Reason)
if r.Clauses[i].Unconfirmed == "" {
r.Clauses[i].Unconfirmed = "not exact"
}
}
}
}
r.Drafting.State, r.Drafting.Checker, r.Drafting.At, r.Drafting.Reason = DraftDone, by, k.now(), ""
said, err := k.saveDraft(ctx, e, r, EventConfirmed, fmt.Sprintf("%d of %d drafted clauses confirmed by %s", confirmed, len(r.Clauses), by))
if err != nil {
return "", err
}
k.tellTopic(ctx, r.Topic)
return said, nil
}
func hasDrafts(r *Record) bool {
for _, c := range r.Clauses {
if c.Source == SourceDrafted {
return true
}
}
return false
}
func (k *KB) saveDraft(ctx context.Context, e *Entry, r *Record, event, why string) (string, error) {
k.mu.Lock()
defer k.mu.Unlock()
by := "the knowledge base"
if r.Drafting != nil && event == EventDrafted && r.Drafting.Drafter != "" {
by = r.Drafting.Drafter
}
if r.Drafting != nil && event == EventConfirmed && r.Drafting.Checker != "" {
by = r.Drafting.Checker
}
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return "", fmt.Errorf("%s could not be kept (%v)", r.Key(), err)
}
k.emit(event, r, by, why, e.Versions+1)
return r.Key().String() + ": " + why, nil
}
// tellTopic tells the operator, once, when every prose record of a topic has been drafted and confirmed.
func (k *KB) tellTopic(ctx context.Context, topic string) {
if k.Notify == nil || topic == "" {
return
}
all, err := k.Store.All(ctx)
if err != nil {
return
}
records, confirmed, drafted := 0, 0, 0
for _, e := range all {
r := e.Current
if r.Topic != topic || r.Drafting == nil {
continue
}
if r.Drafting.State != DraftDone {
return
}
records++
for _, c := range r.Clauses {
if c.Source == SourceConfirmed {
confirmed++
} else if c.Source == SourceDrafted {
drafted++
}
}
}
if records == 0 {
return
}
first, err := k.Store.Told(ctx, topic)
if err != nil || !first {
return
}
summary := fmt.Sprintf("The clauses of %d prose decisions on %s are drafted and checked: %d confirmed, %d still drafted.", records, topic, confirmed, drafted)
explanation := "A second judgement confirmed each clause against its record; a clause still drafted is never served as in force. " +
"You may send any record back with the knowledge base's return, and its clauses are drafted again. Nothing needs you otherwise."
if err := k.Notify("knowledge.clauses."+strings.ReplaceAll(topic, " ", "-"), summary, "Clauses drafted for "+topic, explanation); err != nil {
k.logf("[mesh-knowledge] the operator could not be told about %s's clauses: %v", topic, err)
}
}
// judgeChange judges, then confirms, which clause of an older record a supersedes-in-part of hq changes.
func (k *KB) judgeChange(ctx context.Context, from Key, i int) (string, error) {
k.mu.Lock()
e, err := k.Store.Get(ctx, from)
var t *Entry
if err == nil && e != nil && i < len(e.Current.Links) {
if to, ok := e.Current.Links[i].To.Record(); ok {
t, err = k.Store.Get(ctx, to)
}
}
k.mu.Unlock()
if err != nil || e == nil || t == nil {
return "", err
}
r := e.Current.Clone()
l := &r.Links[i]
older := t.Current
served := 0
for _, c := range older.Clauses {
if c.Source != SourceDrafted {
served++
}
}
if served == 0 {
return "", nil // the older record's clauses are not confirmed yet: judged once they are
}
model := k.judgeModel()
newer, _ := decisionSection(r)
var b strings.Builder
fmt.Fprintf(&b, "%s says it replaces part of an older record, %s. Below are the clauses of %s and the Decision section of %s.\n\n", r.Key(), older.Key(), older.Key(), r.Key())
for _, c := range older.Clauses {
fmt.Fprintf(&b, "%d. %s\n", c.N, c.Text)
}
fmt.Fprintf(&b, "\nThe Decision section of %s:\n\n%s\n", r.Key(), newer)
if l.Source == LinkPending {
answer, by, err := k.Judge(b.String()+"\nWhich of the older record's clauses does the newer record replace part of? Name their numbers.", whichSchema, model)
if err != nil {
return "", fmt.Errorf("judging which clause %s changes: %v", r.Key(), err)
}
var which struct {
Clauses []int `json:"clauses"`
Reason string `json:"reason"`
}
if json.Unmarshal(answer, &which) != nil || len(which.Clauses) == 0 || older.Clause(which.Clauses[0]) == nil {
return "", fmt.Errorf("judging which clause %s changes: the judgement named no clause of %s", r.Key(), older.Key())
}
base := *l
l.To.Clause, l.Source = which.Clauses[0], SourceDrafted
for _, n := range which.Clauses[1:] {
if older.Clause(n) != nil {
extra := base
extra.To.Clause, extra.Source = n, SourceDrafted
r.Links = append(r.Links, extra)
}
}
return k.saveLinks(ctx, e, r, by, fmt.Sprintf("which clause of %s it changes drafted: §%s (%s)", older.Key(), joinInts(which.Clauses), which.Reason))
}
answer, by, err := k.Judge(b.String()+fmt.Sprintf("\nA first judgement said %s replaces part of clause %d of %s. Is that exact?", r.Key(), l.To.Clause, older.Key()), whichConfirmSchema, model)
if err != nil {
return "", fmt.Errorf("confirming which clause %s changes: %v", r.Key(), err)
}
var v struct {
Exact bool `json:"exact"`
Reason string `json:"reason"`
}
if json.Unmarshal(answer, &v) != nil {
return "", fmt.Errorf("confirming which clause %s changes: no verdict", r.Key())
}
if !v.Exact {
clause := l.To.Clause
l.To.Clause, l.Source = 0, SourceDrafted+": not confirmed"
return k.saveLinks(ctx, e, r, by, fmt.Sprintf("that it changes %s §%d was not confirmed: %s", older.Key(), clause, v.Reason))
}
l.Source = SourceConfirmed
said, err := k.saveLinks(ctx, e, r, by, fmt.Sprintf("that it changes %s §%d confirmed by %s", older.Key(), l.To.Clause, by))
if err != nil {
return "", err
}
// The older clause is changed now, as an acceptance would have made it.
k.mu.Lock()
defer k.mu.Unlock()
t, err = k.Store.Get(ctx, older.Key())
if err != nil || t == nil {
return said, err
}
c := t.Current.Clone()
if cl := c.Clause(l.To.Clause); cl != nil && cl.Status == ClauseInForce {
cl.Status = ClauseChanged
cl.ChangedBy = append(cl.ChangedBy, r.Key().String())
what := fmt.Sprintf("§%d changed by %s (as hq said, its clause judged and confirmed)", l.To.Clause, r.Key())
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: what, Record: c}, true, false, t.Revision); err != nil {
return said, err
}
k.emit(EventChanged, c, by, what, t.Versions+1)
}
return said, nil
}
func (k *KB) saveLinks(ctx context.Context, e *Entry, r *Record, by, why string) (string, error) {
k.mu.Lock()
defer k.mu.Unlock()
if err := k.Store.Save(ctx, Version{By: by, At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return "", fmt.Errorf("%s could not be kept (%v)", r.Key(), err)
}
event := EventDrafted
if strings.Contains(why, "confirmed") {
event = EventConfirmed
}
k.emit(event, r, by, why, e.Versions+1)
return r.Key().String() + ": " + why, nil
}
func joinInts(ns []int) string {
s := make([]string, len(ns))
for i, n := range ns {
s[i] = strconv.Itoa(n)
}
return strings.Join(s, ", §")
}
// Return sends a record's drafted or confirmed clauses back to be drafted again, with the reason, which the next
// drafting reads.
func (k *KB) Return(caller string, key Key, reason string) (map[string]any, error) {
ctx := context.Background()
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, errors.New("return needs reason: what is wrong with the clauses drafted")
}
k.mu.Lock()
defer k.mu.Unlock()
e, err := k.Store.Get(ctx, key)
if err != nil {
return nil, err
}
if e == nil {
return nil, fmt.Errorf("the knowledge base holds no %s", key)
}
r := e.Current.Clone()
if r.Drafting == nil {
return nil, fmt.Errorf("%s's clauses were written by its author, not drafted: a written clause is changed by a new record", key)
}
r.Clauses = nil
r.Drafting = &Drafting{State: DraftWaiting, Returned: reason, At: k.now()}
why := "returned to be drafted again: " + reason
if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: why, Record: r}, true, false, e.Revision); err != nil {
return nil, err
}
k.emit(EventReturned, r, caller, why, e.Versions+1)
return map[string]any{"record": key.String(), "says": fmt.Sprintf("%s's clauses are taken back and will be drafted again, with your reason; until then it serves none.", key)}, nil
}