claude-code: an agent proposes a shared instruction section, and every node takes it only on the operator's warrant (hq ADR 0274)
mesh/merge-gate fail: builds claude-code → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: claude-code uses the…
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 fail: builds claude-code → ace, g14, novox, shanks; no bus step; a manifest the change touches fails the module check: claude-code uses the…
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
Shared instructions were the terminal's alone since hq issue 340, because the tool runner cannot tell an agent from the operator. The operator channel now proves who answered, so an agent may propose a section; each node proves the router's warrant against the exact text itself and keeps what it approved in its own file, so nothing written to the bus becomes active.
This commit is contained in:
@@ -0,0 +1,796 @@
|
||||
package main
|
||||
|
||||
// Shared instructions on the operator's approval (novox/hq ADR 0274, amending issue 340's decision 2).
|
||||
//
|
||||
// An agent may PROPOSE an instruction section for every Claude session on the mesh, or on some nodes: a rule,
|
||||
// a hand-over, the context a later session needs. A proposal is text and nothing else. It is kept in the
|
||||
// module's `proposals` state and asked of the operator through the operator channel (novox/hq ADR 0259) as an
|
||||
// ask whose two answers, Approve and Decline, are both at the level approve, so only a channel that proves who
|
||||
// answered (Telegram, today) can carry either. Nothing is active until the operator approves.
|
||||
//
|
||||
// **Every node proves the approval itself.** The `proposals` state is written by every node's tool runner, so a
|
||||
// record there proves nothing. Each instance of this module, for each proposal it has not decided yet:
|
||||
//
|
||||
// 1. reads the router's own record of the ask, under this module's name (the bus lets only the router write it,
|
||||
// and lets this module read only its own asks);
|
||||
// 2. composes the ask again from the proposal — the words, the options, the expiry, the digest of the text —
|
||||
// and holds the router's warrant to it: the same ask digest, an option it offered, at its level, in time
|
||||
// (asks.Warrant.For), and the option's bound act is this proposal's (asks.Option.Performs). An ask published
|
||||
// under this module's name with other words, or a proposal changed after it was asked, finds the digests
|
||||
// differ and nothing is done;
|
||||
// 3. applies the approved text to its own file, `approved.json` in the module's state directory, which is what
|
||||
// the managed instruction file renders from. A proposal decided here is never decided again: a warrant heard
|
||||
// twice, or replayed, changes nothing.
|
||||
//
|
||||
// Removing an approved section is a proposal too, approved the same way: a record deleted from the bus removes
|
||||
// nothing, because the node's own file is what renders.
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
)
|
||||
|
||||
// Module is this module's name: the one the bus grants its asks under.
|
||||
const Module = "claude-code"
|
||||
|
||||
// ProposalsState is the module's state the proposals are kept in.
|
||||
const ProposalsState = "proposals"
|
||||
|
||||
// MaxProposalBytes is the most one proposed text may hold: it is read by every session it reaches, every time.
|
||||
const MaxProposalBytes = 16 << 10
|
||||
|
||||
// RecordFor and RecordEvery bound how long, and how often, a node asks the controller to record a warrant it
|
||||
// decided while the controller has not taken it.
|
||||
const (
|
||||
RecordFor = 7 * 24 * time.Hour
|
||||
RecordEvery = 10 * time.Minute
|
||||
)
|
||||
|
||||
// RouterKeeps is how long the router keeps its record of an ask after it closed (novox/hq ADR 0259 §3): a node
|
||||
// that has not read it by then cannot prove the answer.
|
||||
const RouterKeeps = 30 * 24 * time.Hour
|
||||
|
||||
// ProposalLasts is how long the operator has to answer: a day less a margin, so the router never refuses it for
|
||||
// lasting a day and a moment (the SDK's bound for an approval).
|
||||
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
||||
|
||||
// What a proposal does.
|
||||
const (
|
||||
ActionAdd = "add"
|
||||
ActionReplace = "replace"
|
||||
ActionRemove = "remove"
|
||||
)
|
||||
|
||||
// The two answers, both at the level approve.
|
||||
const (
|
||||
OptionApprove = "approve"
|
||||
OptionDecline = "decline"
|
||||
)
|
||||
|
||||
// How much of the text the operator's phone is shown: all of it when it is short and every line may leave the
|
||||
// mesh, else its first lines.
|
||||
const (
|
||||
WholeTextBytes = 2000
|
||||
WholeTextLines = 40
|
||||
FirstBytes = 1200
|
||||
FirstLines = 20
|
||||
)
|
||||
|
||||
// Proposal is one proposed change to the shared instructions, as the `proposals` state keeps it. Every field the
|
||||
// ask is composed from is here, so any node composes the same ask from it.
|
||||
type Proposal struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Scope string `json:"scope"`
|
||||
Nodes []string `json:"nodes,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Digest string `json:"digest"`
|
||||
From string `json:"from"`
|
||||
At time.Time `json:"at"`
|
||||
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
||||
Asked string `json:"asked,omitempty"`
|
||||
}
|
||||
|
||||
// textDigest is the digest a proposal binds: SHA-256 over the text's bytes.
|
||||
func textDigest(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Fingerprint is the digest as the operator is shown it: its first twelve hexadecimal digits.
|
||||
func (p Proposal) Fingerprint() string {
|
||||
return strings.TrimPrefix(p.Digest, "sha256:")[:12]
|
||||
}
|
||||
|
||||
// Lines is how many lines the text has.
|
||||
func (p Proposal) Lines() int {
|
||||
if p.Text == "" {
|
||||
return 0
|
||||
}
|
||||
return len(strings.Split(strings.TrimRight(p.Text, "\n"), "\n"))
|
||||
}
|
||||
|
||||
// where says which sessions a proposal reaches, in words.
|
||||
func (p Proposal) where() string {
|
||||
if p.Scope == ScopeMesh {
|
||||
return "every Claude session on the mesh"
|
||||
}
|
||||
return "every Claude session on " + joinAnd(p.Nodes)
|
||||
}
|
||||
|
||||
func joinAnd(list []string) string {
|
||||
switch len(list) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return list[0]
|
||||
}
|
||||
return strings.Join(list[:len(list)-1], ", ") + " and " + list[len(list)-1]
|
||||
}
|
||||
|
||||
// Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest,
|
||||
// and the ask names this act's digest in the option, so an approval authorises this text and nothing else.
|
||||
func (p Proposal) Act(option string) asks.Act {
|
||||
return asks.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
|
||||
"scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From,
|
||||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
||||
// router's warrant to it.
|
||||
func (p Proposal) Ask() asks.Ask {
|
||||
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
||||
headline := fmt.Sprintf("%s Claude instructions '%s'?", verb, p.Name)
|
||||
if len([]rune(headline)) > asks.HeadlineLength {
|
||||
headline = verb + " Claude instructions?"
|
||||
}
|
||||
var b strings.Builder
|
||||
switch p.Action {
|
||||
case ActionAdd:
|
||||
fmt.Fprintf(&b, "Add instructions '%s' to %s?\n\n", p.Name, p.where())
|
||||
case ActionReplace:
|
||||
fmt.Fprintf(&b, "Replace the instructions '%s' in %s?\n\n", p.Name, p.where())
|
||||
case ActionRemove:
|
||||
fmt.Fprintf(&b, "Remove the instructions '%s' from %s?\n\n", p.Name, p.where())
|
||||
}
|
||||
fmt.Fprintf(&b, "An agent proposed this through the claude-code module on %s, at %s UTC.", p.From,
|
||||
p.At.UTC().Format("15:04 on 2 Jan"))
|
||||
if p.Action == ActionRemove {
|
||||
fmt.Fprintf(&b, " The approved text it removes has the fingerprint %s.", p.Fingerprint())
|
||||
} else {
|
||||
fmt.Fprintf(&b, " The text has %d lines, %d bytes, fingerprint %s. Once approved, every new session there "+
|
||||
"reads it as the mesh's own word until it is removed the same way.\n\n", p.Lines(), len(p.Text), p.Fingerprint())
|
||||
shown, whole := excerpt(p.Text)
|
||||
if whole {
|
||||
b.WriteString("The whole text:\n\n" + shown)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "Its first lines:\n\n%s\n\nRead all of it before you answer, at the controller's terminal: "+
|
||||
"mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'. The fingerprint there must be %s.",
|
||||
shown, p.ID, p.Fingerprint())
|
||||
}
|
||||
}
|
||||
does := "the text becomes part of those sessions' instructions"
|
||||
if p.Action == ActionRemove {
|
||||
does = "the instructions are removed from those sessions"
|
||||
}
|
||||
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator,
|
||||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes",
|
||||
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name}
|
||||
if len(p.Nodes) == 0 {
|
||||
a.About = "instructions." + p.Scope + "." + p.Name
|
||||
}
|
||||
for _, o := range []struct{ id, label, does string }{
|
||||
{OptionApprove, "Approve", does},
|
||||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||||
} {
|
||||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// excerpt is what of a text the operator's phone is shown, and whether it is the whole text. A line that might
|
||||
// carry what may not leave the mesh (a path, an address, a long unbroken word) is not shown, and a marker says
|
||||
// so; the router refuses an ask whose words carry one, and the whole text is read at the terminal.
|
||||
func excerpt(text string) (string, bool) {
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
whole := len(text) <= WholeTextBytes && len(lines) <= WholeTextLines
|
||||
limitBytes, limitLines := FirstBytes, FirstLines
|
||||
if whole {
|
||||
limitBytes, limitLines = WholeTextBytes, WholeTextLines
|
||||
}
|
||||
var out []string
|
||||
size, hidden := 0, 0
|
||||
flush := func() {
|
||||
if hidden > 0 {
|
||||
out = append(out, fmt.Sprintf("[%d line(s) not shown here: a path, an address or a long word]", hidden))
|
||||
hidden = 0
|
||||
}
|
||||
}
|
||||
for i, line := range lines {
|
||||
if i >= limitLines || size+len(line) > limitBytes {
|
||||
whole = false
|
||||
break
|
||||
}
|
||||
if !shownLine(line) {
|
||||
whole = false
|
||||
hidden++
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
out = append(out, line)
|
||||
size += len(line) + 1
|
||||
}
|
||||
flush()
|
||||
return strings.Join(out, "\n"), whole
|
||||
}
|
||||
|
||||
var (
|
||||
unbroken = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
|
||||
hexRun = regexp.MustCompile(`(?i)[0-9a-f]{16,}`)
|
||||
numbersAt = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
||||
hostLike = regexp.MustCompile(`(?i)\b[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,12}\b`)
|
||||
assigned = regexp.MustCompile(`(?i)(password|passwd|passphrase|secret|token|api[_-]?key|credential|private[_-]?key)\s*[=:]`)
|
||||
)
|
||||
|
||||
// shownLine says whether a line may be shown on the phone: no path, address, host-like name, long unbroken word
|
||||
// or value given to a secret's name. Deliberately narrower than the router's own rule, so an ask is rarely
|
||||
// refused for its words; a line kept back is read at the terminal.
|
||||
func shownLine(line string) bool {
|
||||
if strings.ContainsAny(line, "/\\@") || strings.Contains(line, "~") || strings.Contains(line, "$") {
|
||||
return false
|
||||
}
|
||||
for _, re := range []*regexp.Regexp{unbroken, hexRun, numbersAt, assigned} {
|
||||
if re.MatchString(line) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// A sentence's end ("word. Next") never matches, nor does "e.g."; anything else shaped like a.name is held back.
|
||||
return !hostLike.MatchString(line)
|
||||
}
|
||||
|
||||
// secretShaped are shapes a shared text never carries: it is read by every session and shown on a phone.
|
||||
var secretShaped = []*regexp.Regexp{
|
||||
regexp.MustCompile(`-----BEGIN [A-Z ]+-----`),
|
||||
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`),
|
||||
regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`),
|
||||
regexp.MustCompile(`(?i)\b(password|passwd|passphrase|token|api[_-]?key|apikey|private[_-]?key)\s*[=:]\s*\S`),
|
||||
}
|
||||
|
||||
// ProposedTextProblem says why a text cannot be proposed, or "": empty, too long, not text, or something a
|
||||
// session would run or read in by itself (issue 340's refusals, kept): a shell line, front matter, a hook, a tool
|
||||
// server, a permission mode or pre-approved tools, or an `@` reference, which the instruction file reads in as a
|
||||
// file; or anything shaped like a secret.
|
||||
func ProposedTextProblem(text string) string {
|
||||
switch {
|
||||
case strings.TrimSpace(text) == "":
|
||||
return "the text is empty"
|
||||
case len(text) > MaxProposalBytes:
|
||||
return fmt.Sprintf("the text is %d bytes, more than the %d a shared section may hold: every session reads all of it", len(text), MaxProposalBytes)
|
||||
case !utf8.ValidString(text) || strings.ContainsRune(text, 0):
|
||||
return "the text is not text"
|
||||
case shellLine.MatchString(text):
|
||||
return "the text holds a shell line (!`…` or ```!), which a session may run by itself; nothing proposed here runs (novox/hq issue 340)"
|
||||
}
|
||||
if keys, problem := frontMatterKeys(text); problem != "" || len(keys) > 0 {
|
||||
for _, key := range keys {
|
||||
norm := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(key))
|
||||
if why, ok := runsFrontMatter[norm]; ok {
|
||||
return fmt.Sprintf("the text's front matter says %q: %s; nothing proposed here runs or loosens what runs (novox/hq issue 340)", key, why)
|
||||
}
|
||||
}
|
||||
return "the text opens with front matter, which a session may act on; a shared section is plain text (novox/hq issue 340)"
|
||||
}
|
||||
if m := fileRef.FindStringSubmatch(text); m != nil {
|
||||
return fmt.Sprintf("the text refers to @%s, which the instruction file reads in as a file; a shared section refers to no file (novox/hq issue 340)", strings.TrimRight(m[1], ".,;:!?"))
|
||||
}
|
||||
for _, re := range secretShaped {
|
||||
if re.MatchString(text) {
|
||||
return "the text holds something shaped like a secret, which a shared section never carries"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---- what this node approved ------------------------------------------------------------------------
|
||||
|
||||
// ApprovedSection is one approved section as this node keeps it.
|
||||
type ApprovedSection struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Nodes []string `json:"nodes,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Digest string `json:"digest"`
|
||||
Proposal string `json:"proposal"`
|
||||
From string `json:"from"`
|
||||
Approved time.Time `json:"approved"`
|
||||
By string `json:"by"`
|
||||
}
|
||||
|
||||
// ProposalDecision is what this node made of one proposal: once decided, never again.
|
||||
type ProposalDecision struct {
|
||||
Outcome string `json:"outcome"`
|
||||
At time.Time `json:"at"`
|
||||
Says string `json:"says,omitempty"`
|
||||
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
|
||||
Recorded bool `json:"recorded,omitempty"`
|
||||
Record string `json:"record,omitempty"`
|
||||
LastTry time.Time `json:"last-try,omitempty"`
|
||||
}
|
||||
|
||||
// Approved is this node's own record: the sections it renders, and every proposal it decided.
|
||||
type Approved struct {
|
||||
Sections map[string]ApprovedSection `json:"sections"`
|
||||
Decided map[string]ProposalDecision `json:"decided"`
|
||||
}
|
||||
|
||||
func (p Paths) approved() string { return filepath.Join(p.State, "approved.json") }
|
||||
|
||||
func sectionKey(scope, name string) string { return scope + "/" + name }
|
||||
|
||||
// ReadApproved is this node's record, or an empty one.
|
||||
func ReadApproved(p Paths) Approved {
|
||||
var a Approved
|
||||
_ = readJSON(p.approved(), &a)
|
||||
if a.Sections == nil {
|
||||
a.Sections = map[string]ApprovedSection{}
|
||||
}
|
||||
if a.Decided == nil {
|
||||
a.Decided = map[string]ProposalDecision{}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func writeApproved(p Paths, a Approved) error {
|
||||
raw, err := indented(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p.approved() + ".next"
|
||||
if err := fsys.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p.approved())
|
||||
}
|
||||
|
||||
// ApprovedHere is the approved sections that reach this node, by scope then name.
|
||||
func ApprovedHere(a Approved, node string) []ApprovedSection {
|
||||
var out []ApprovedSection
|
||||
for _, s := range a.Sections {
|
||||
if s.Scope == ScopeMesh || contains(s.Nodes, node) {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Scope != out[j].Scope {
|
||||
return out[i].Scope < out[j].Scope
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- the bus, as proposals reach it -----------------------------------------------------------------
|
||||
|
||||
// AskBus is the operator channel as this module reaches it: publish an ask under its own name, read the router's
|
||||
// record of one of its asks, and the kinds of the channel bench with what each promises.
|
||||
type AskBus interface {
|
||||
Publish(subject string, body any, id string) error
|
||||
Record(id string) (*stdio.StateEntry, error)
|
||||
Kinds() ([]stdio.KindHeld, error)
|
||||
}
|
||||
|
||||
// ProposalStore is the `proposals` state.
|
||||
type ProposalStore interface {
|
||||
Create(key string, value any) error
|
||||
Put(key string, value any) error
|
||||
Keys() ([]string, error)
|
||||
Get(key string) (json.RawMessage, bool, error)
|
||||
}
|
||||
|
||||
// Recorder records a warrant in the controller's hand-act log: the controller reads the warrant from the
|
||||
// router's own record, never from this module.
|
||||
type Recorder func(ask, what string) error
|
||||
|
||||
type sdkAskBus struct{}
|
||||
|
||||
func (sdkAskBus) Publish(subject string, body any, id string) error {
|
||||
return stdio.SeatPublish(subject, body, id)
|
||||
}
|
||||
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
|
||||
func (sdkAskBus) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() }
|
||||
|
||||
// recordWarrant asks the controller to record a warrant this module acted on.
|
||||
func recordWarrant(ask, what string) error {
|
||||
_, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask, "what": what})
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- proposing --------------------------------------------------------------------------------------
|
||||
|
||||
// ProposeInput is what an agent proposes.
|
||||
type ProposeInput struct {
|
||||
Scope string
|
||||
Nodes []string
|
||||
Name string
|
||||
Text string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
||||
const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead: mesh-cli settings set claude-code " +
|
||||
"'{\"instructions\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340)"
|
||||
|
||||
// approverReady says whether a channel that proves who answered can carry an approval: a kind of the channel
|
||||
// bench promising a choice, an exact rendering and a verified sender. The router judges again, live; this is
|
||||
// only so a proposal no channel can carry is refused at once, in words.
|
||||
func approverReady(bus AskBus) string {
|
||||
kinds, err := bus.Kinds()
|
||||
if err != nil {
|
||||
return "the channels could not be read (" + err.Error() + ")"
|
||||
}
|
||||
for _, k := range kinds {
|
||||
if k.Seat == "channel" && k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "no channel that proves who answers (one promising a verified sender, such as Telegram) is on the mesh"
|
||||
}
|
||||
|
||||
func newProposalID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return "instr-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// Propose records a proposal and asks the operator. Nothing becomes active here or anywhere: every node applies it
|
||||
// only on the operator's warrant, which it proves itself (Reconcile).
|
||||
func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time.Time) (map[string]any, error) {
|
||||
refuse := func(why string) (map[string]any, error) {
|
||||
return nil, errors.New(why + ". Nothing was proposed")
|
||||
}
|
||||
if in.Scope != ScopeMesh && in.Scope != ScopeNode {
|
||||
return refuse(fmt.Sprintf("%q is not a scope a shared section is proposed at: mesh (every node) or node (the nodes given); "+
|
||||
"a home is registered with claude_code_instructions_register", in.Scope))
|
||||
}
|
||||
if !itemName.MatchString(in.Name) {
|
||||
return refuse(fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", in.Name))
|
||||
}
|
||||
var nodes []string
|
||||
if in.Scope == ScopeNode {
|
||||
for _, n := range in.Nodes {
|
||||
if n == "all" {
|
||||
return refuse("\"all\" is the mesh scope: propose it with scope mesh")
|
||||
}
|
||||
if !contains(nodes, n) {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
nodes = []string{p.Node}
|
||||
}
|
||||
if problem := nodesProblem(nodes); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
} else if len(in.Nodes) > 0 {
|
||||
return refuse("the mesh scope reaches every node; name nodes with scope node")
|
||||
}
|
||||
approved := ReadApproved(p)
|
||||
active, isActive := approved.Sections[sectionKey(in.Scope, in.Name)]
|
||||
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
||||
switch {
|
||||
case in.Remove:
|
||||
if !isActive {
|
||||
return refuse(fmt.Sprintf("no approved section %q at the %s scope is held on this node to remove; one set at the "+
|
||||
"controller's terminal is removed there", in.Name, in.Scope))
|
||||
}
|
||||
prop.Action, prop.Digest = ActionRemove, active.Digest
|
||||
if in.Scope == ScopeNode && strings.Join(active.Nodes, ",") != strings.Join(nodes, ",") {
|
||||
return refuse(fmt.Sprintf("the approved section %q is for %s; a removal names the same nodes", in.Name, joinAnd(active.Nodes)))
|
||||
}
|
||||
default:
|
||||
if problem := ProposedTextProblem(in.Text); problem != "" {
|
||||
return refuse(problem)
|
||||
}
|
||||
prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text)
|
||||
if isActive {
|
||||
if active.Digest == prop.Digest {
|
||||
return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name))
|
||||
}
|
||||
prop.Action = ActionReplace
|
||||
}
|
||||
}
|
||||
if terminal, _ := terminalText(p); len(terminal) > 0 {
|
||||
for _, it := range terminal {
|
||||
if it.Kind == KindInstructions && it.Name == in.Name {
|
||||
return refuse(fmt.Sprintf("a section %q is set at the controller's terminal; it is changed there", in.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
if why := approverReady(bus); why != "" {
|
||||
return refuse(why + "; " + AtTheTerminalInstead)
|
||||
}
|
||||
if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen {
|
||||
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
|
||||
"questions at once; claude_code_proposals lists them", waiting, asks.MostOpen))
|
||||
}
|
||||
ask := prop.Ask()
|
||||
if err := ask.Check(now); err != nil {
|
||||
return refuse(err.Error())
|
||||
}
|
||||
if err := store.Create(prop.ID, prop); err != nil {
|
||||
return nil, fmt.Errorf("the proposal could not be kept, so the operator was not asked: %w", err)
|
||||
}
|
||||
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||||
prop.Asked = "not asked: " + err.Error()
|
||||
_ = store.Put(prop.ID, prop)
|
||||
say("proposal %s (%s %s %s) could not be asked: %v", prop.ID, prop.Action, prop.Scope, prop.Name, err)
|
||||
return nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept and will never become active. "+
|
||||
"Propose it again, or %s", err, AtTheTerminalInstead)
|
||||
}
|
||||
say("proposal %s (%s %s %s, fingerprint %s) asked of the operator", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint())
|
||||
return map[string]any{
|
||||
"proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(),
|
||||
"asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"",
|
||||
"expires": ask.Expires.Format(time.RFC3339),
|
||||
"until then": "nothing changes on any node; the text becomes part of those sessions only on the operator's Approve, " +
|
||||
"and is discarded on Decline or at expiry. claude_code_proposals shows where it stands",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
|
||||
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, k := range keys {
|
||||
prop, ok := readProposal(store, k)
|
||||
if !ok || prop.Asked != "" {
|
||||
continue
|
||||
}
|
||||
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func readProposal(store ProposalStore, key string) (Proposal, bool) {
|
||||
raw, found, err := store.Get(key)
|
||||
if err != nil || !found {
|
||||
return Proposal{}, false
|
||||
}
|
||||
var prop Proposal
|
||||
if json.Unmarshal(raw, &prop) != nil || prop.ID != key {
|
||||
return Proposal{}, false
|
||||
}
|
||||
return prop, true
|
||||
}
|
||||
|
||||
// ---- deciding, on every node ------------------------------------------------------------------------
|
||||
|
||||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||||
type routerRecord struct {
|
||||
State string `json:"state"`
|
||||
Warrant *asks.Warrant `json:"warrant"`
|
||||
}
|
||||
|
||||
var reconcileMu sync.Mutex
|
||||
|
||||
// Reconcile decides, on this node, every proposal it has not decided yet whose ask the router has closed, each held
|
||||
// to the warrant as described at the top of this file, oldest warrant first. It answers whether what this node
|
||||
// renders changed, and a line for each decision.
|
||||
func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now time.Time) (bool, []string, error) {
|
||||
reconcileMu.Lock()
|
||||
defer reconcileMu.Unlock()
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
}
|
||||
approved := ReadApproved(p)
|
||||
type closed struct {
|
||||
prop Proposal
|
||||
w asks.Warrant
|
||||
}
|
||||
var ready []closed
|
||||
for _, k := range keys {
|
||||
prop, ok := readProposal(store, k)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, done := approved.Decided[prop.ID]; done {
|
||||
continue
|
||||
}
|
||||
entry, err := bus.Record(prop.ID)
|
||||
if err != nil || entry == nil {
|
||||
continue // open, not yet recorded, or not readable now: asked again on the next look
|
||||
}
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||||
continue
|
||||
}
|
||||
ready = append(ready, closed{prop, *rec.Warrant})
|
||||
}
|
||||
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
||||
changed := false
|
||||
var said []string
|
||||
for _, c := range ready {
|
||||
d, applied, line := decide(p, &approved, c.prop, c.w, now)
|
||||
approved.Decided[c.prop.ID] = d
|
||||
changed = changed || applied
|
||||
said = append(said, line)
|
||||
}
|
||||
// The hand-act log: one entry per warrant mesh-wide (the controller keeps one per ask, read from the router's
|
||||
// own record), asked by every node that decided it, and asked again every ten minutes for a week while the
|
||||
// controller has not taken it.
|
||||
retried := false
|
||||
if record != nil {
|
||||
for id, d := range approved.Decided {
|
||||
if d.Recorded || (d.Outcome != "approved" && d.Outcome != "declined") || now.Sub(d.At) > RecordFor ||
|
||||
(!d.LastTry.IsZero() && now.Sub(d.LastTry) < RecordEvery) {
|
||||
continue
|
||||
}
|
||||
d.LastTry = now.UTC()
|
||||
if err := record(id, "claude-code proposal "+id+" "+d.Outcome+" on "+p.Node); err != nil {
|
||||
d.Record = "not recorded in the hand-act log yet: " + err.Error()
|
||||
} else {
|
||||
d.Recorded, d.Record = true, ""
|
||||
}
|
||||
approved.Decided[id] = d
|
||||
retried = true
|
||||
}
|
||||
}
|
||||
if len(ready) == 0 && !retried {
|
||||
return false, nil, nil
|
||||
}
|
||||
if err := writeApproved(p, approved); err != nil {
|
||||
return false, said, fmt.Errorf("what this node decided could not be kept: %w", err)
|
||||
}
|
||||
return changed, said, nil
|
||||
}
|
||||
|
||||
// decide holds one closed ask's warrant to its proposal, and applies it to this node's record when it holds.
|
||||
func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time.Time) (ProposalDecision, bool, string) {
|
||||
d := ProposalDecision{At: now.UTC(), Says: w.Says()}
|
||||
refused := func(why string) (ProposalDecision, bool, string) {
|
||||
d.Outcome = "refused: " + why
|
||||
say("REFUSED the answer to proposal %s: %s", prop.ID, why)
|
||||
return d, false, "proposal " + prop.ID + ": refused, " + why
|
||||
}
|
||||
if w.Asker != Module || w.Ask != prop.ID {
|
||||
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
||||
}
|
||||
if w.Outcome != asks.OutcomeChosen {
|
||||
d.Outcome = "discarded: the ask " + string(w.Outcome)
|
||||
if w.Words != "" {
|
||||
d.Outcome += " (" + w.Words + ")"
|
||||
}
|
||||
return d, false, "proposal " + prop.ID + ": " + d.Outcome
|
||||
}
|
||||
if prop.Action != ActionRemove && textDigest(prop.Text) != prop.Digest {
|
||||
return refused("its text is not the text its digest names")
|
||||
}
|
||||
option, err := w.For(Module, prop.Ask())
|
||||
if err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
if err := option.Performs(prop.Act(option.ID)); err != nil {
|
||||
return refused(err.Error())
|
||||
}
|
||||
if option.ID == OptionDecline {
|
||||
d.Outcome = "declined"
|
||||
return d, false, "proposal " + prop.ID + ": declined by the operator; nothing changes"
|
||||
}
|
||||
if option.ID != OptionApprove {
|
||||
return refused("the option " + option.ID + " is not one a proposal offers")
|
||||
}
|
||||
d.Outcome = "approved"
|
||||
key := sectionKey(prop.Scope, prop.Name)
|
||||
reaches := prop.Scope == ScopeMesh || contains(prop.Nodes, p.Node)
|
||||
switch prop.Action {
|
||||
case ActionRemove:
|
||||
held, ok := approved.Sections[key]
|
||||
if !ok || held.Digest != prop.Digest {
|
||||
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
|
||||
return d, false, "proposal " + prop.ID + ": " + d.Outcome
|
||||
}
|
||||
delete(approved.Sections, key)
|
||||
default:
|
||||
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text,
|
||||
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: w.At.UTC(), By: w.Says()}
|
||||
}
|
||||
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), w.Says())
|
||||
return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name
|
||||
}
|
||||
|
||||
// ---- the list ---------------------------------------------------------------------------------------
|
||||
|
||||
// ListProposals is every proposal on the bus, newest first, with where the router has it and what this node made
|
||||
// of it; with an id, that one with its whole text. And the sections approved here.
|
||||
func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time.Time) (map[string]any, error) {
|
||||
approved := ReadApproved(p)
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var props []Proposal
|
||||
for _, k := range keys {
|
||||
if prop, ok := readProposal(store, k); ok && (id == "" || prop.ID == id) {
|
||||
props = append(props, prop)
|
||||
}
|
||||
}
|
||||
if id != "" && len(props) == 0 {
|
||||
return nil, fmt.Errorf("no proposal %q is kept on the bus", id)
|
||||
}
|
||||
sort.Slice(props, func(i, j int) bool { return props[i].At.After(props[j].At) })
|
||||
var rows []map[string]any
|
||||
for _, prop := range props {
|
||||
row := map[string]any{"id": prop.ID, "action": prop.Action, "name": prop.Name, "reaches": prop.where(),
|
||||
"from": prop.From, "at": prop.At.Format(time.RFC3339), "fingerprint": prop.Fingerprint(), "lines": prop.Lines(),
|
||||
"bytes": len(prop.Text)}
|
||||
if d, ok := approved.Decided[prop.ID]; ok {
|
||||
row["here"] = d
|
||||
} else if prop.Asked != "" {
|
||||
row["here"] = prop.Asked + "; it never becomes active"
|
||||
} else if now.After(prop.At.Add(ProposalLasts + RouterKeeps)) {
|
||||
row["here"] = "cannot be proven here: the router keeps its record of an answer 30 days, and this node never " +
|
||||
"read it, so nothing is rendered here; propose it again, or set it at the controller's terminal"
|
||||
} else if now.After(prop.At.Add(ProposalLasts)) {
|
||||
row["here"] = "not decided here yet: the ask has ended, and the router's word on it has not been read here"
|
||||
} else {
|
||||
row["here"] = "waiting for the operator's answer; nothing is active"
|
||||
}
|
||||
if id != "" {
|
||||
row["text"] = prop.Text
|
||||
row["digest"] = prop.Digest
|
||||
if prop.Action != ActionRemove && textDigest(prop.Text) != prop.Digest {
|
||||
row["warning"] = "this text is NOT the text the digest names: no node will apply it"
|
||||
}
|
||||
if entry, err := bus.Record(prop.ID); err == nil && entry != nil {
|
||||
var rec routerRecord
|
||||
if json.Unmarshal(entry.Value, &rec) == nil {
|
||||
row["router"] = rec.State
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
var here []map[string]any
|
||||
for _, s := range ApprovedHere(approved, p.Node) {
|
||||
here = append(here, map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal,
|
||||
"from": s.From, "approved": s.Approved.Format(time.RFC3339), "by": s.By,
|
||||
"fingerprint": strings.TrimPrefix(s.Digest, "sha256:")[:12]})
|
||||
}
|
||||
return map[string]any{"proposals": rows, "approved here": here,
|
||||
"how": "a proposal becomes active on a node only when that node has held the operator's warrant, read from the " +
|
||||
"router's own record, to the proposal's exact text (novox/hq ADR 0274)"}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user