package main // The tools and the watch of the shared instructions proposed for the operator's approval (novox/hq ADR 0274). import ( "encoding/json" "errors" "strings" "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" ) // Proposing is what the proposal tools reach: the `proposals` state, the operator channel, the controller's // hand-act log, the clock and the managed directory. Nil outside a launch. type Proposing struct { Store ProposalStore Bus AskBus Record Recorder Now func() time.Time Write WriteManaged // Questions is the `questions` state (novox/hq ADR 0332); nil is the bus's, made on first use. Questions QuestionStore questionsMu sync.Mutex } // questions is the `questions` state. func (d *Proposing) questions() QuestionStore { d.questionsMu.Lock() defer d.questionsMu.Unlock() if d.Questions == nil { d.Questions = keptQuestions{stdio.State(QuestionsState)} } return d.Questions } var proposing *Proposing // decideNow decides what the router closed, and renders when what reaches this node changed. func (d *Proposing) decideNow(p Paths) { changed, said, err := Reconcile(p, d.Store, d.Bus, d.Record, d.Now()) for _, line := range said { say("%s", line) } if err != nil { say("deciding the proposals: %v", err) } if changed { if out, err := RenderNow(p, d.Write); err != nil { say("rendering what the operator approved: %v", err) } else { for _, line := range out { if !strings.HasSuffix(line, "unchanged") { say("%s", line) } } } } } // watch decides on every word of the router's, every change of the proposals, and once a minute: a node that was // away reads the router's record of what was decided meanwhile. One decider, woken by each. func (d *Proposing) watch(p Paths) { wake := make(chan struct{}, 1) poke := func() { select { case wake <- struct{}{}: default: } } go persist("hearing the operator's answers", func() error { return stdio.Subscribe(asks.Seat+".decided."+Module, func(e stdio.Envelope) error { var w asks.Warrant if json.Unmarshal(e.Body, &w) == nil { if strings.HasPrefix(w.Ask, QuestionPrefix) { HeardQuestionEnd(d.questions(), w) return nil } if line, err := HeardEnd(p, d.Store, w, d.Now()); err != nil { say("taking the router's word on %s: %v", w.Ask, err) } else if line != "" { say("%s", line) } } poke() return nil }) }, func(n int) { say("hearing the operator's answers%s", refusals(n)) }) go persist("watching the proposals", func() error { return stdio.State(ProposalsState).Watch("", func(stdio.StateChange) error { poke(); return nil }) }, func(n int) { say("watching the proposals%s", refusals(n)) }) go func() { for range time.Tick(time.Minute) { poke() } }() poke() for range wake { d.decideNow(p) } } func proposalTools(p Paths) []stdio.Tool { notLaunched := errors.New("this module was not launched with the bus; nothing can be proposed") return append([]stdio.Tool{ {Name: "claude_code_instructions_propose", Description: "Propose an instruction section for every Claude session on the mesh (scope mesh) or on some nodes " + "(scope node): a rule, a hand-over, the context a later session needs — or, with remove, the removal of one " + "approved before. Nothing becomes active: the operator is asked on a channel that proves who answered " + "(Telegram), shown the text (whole when it is short, else its first lines and its fingerprint), and the text " + "reaches those sessions only on Approve. Decline, or no answer within a day, discards it. Refused for text " + "that would run or read in something by itself (a shell line, front matter, an @ file reference), anything " + "shaped like a secret, more than 16 KiB, a name set at the controller's terminal, or when no such channel is " + "there (novox/hq ADR 0274, issue 340). claude_code_proposals shows where it stands.", Input: map[string]any{ "scope": str("mesh (every node) or node (the nodes given, or this one)"), "nodes": str("for scope node: a comma-separated list of nodes; absent is this node"), "name": str("the section's name: lower-case letters, digits and -, e.g. handoff-from-g14"), "text": str("the section's text, markdown, as every session will read it"), "remove": map[string]any{"type": "boolean", "description": "propose removing the approved section of this name and scope instead"}, }, Run: func(a map[string]any) (any, error) { if proposing == nil { return nil, notLaunched } text, _ := a["text"].(string) if text == "" { text, _ = a["content"].(string) } return Propose(p, ProposeInput{Scope: scopeOf(a), Nodes: nodesOf(a["nodes"]), Name: strArg(a, "name"), Text: text, Remove: boolArg(a, "remove")}, proposing.Store, proposing.Bus, proposing.Now()) }}, {Name: "claude_code_proposals", Description: "The instruction sections proposed for the operator's approval (novox/hq ADR 0274), newest first: what " + "each does, where it reaches, its fingerprint, and what this node made of it — waiting, approved, declined, " + "discarded at expiry, or refused — and the sections approved here. With id, that proposal's whole text and " + "digest: how the operator reads all of a long text before answering. Reads the router's word on each first.", Input: map[string]any{"id": str("one proposal's id, for its whole text")}, Run: func(a map[string]any) (any, error) { if proposing == nil { return nil, notLaunched } proposing.decideNow(p) return ListProposals(p, proposing.Store, proposing.Bus, strArg(a, "id"), proposing.Now()) }}, }, questionTools(p, notLaunched)...) } // questionTools are a session's question to the operator and its answer (novox/hq ADR 0332). func questionTools(p Paths, notLaunched error) []stdio.Tool { return []stdio.Tool{ {Name: "claude_code_ask", Description: "Ask the operator a question on Telegram when you need a decision and the operator may not be at the " + "terminal (novox/hq ADR 0332): a headline, the context in plain words, two to four options with exactly one " + "marked recommended and why, what you do if nobody answers, who you are (by, required), and when it expires (5 to " + "1430 minutes, 60 when absent). Answers within seconds, once the router has taken it, with the question's " + "key, or with the router's refusal; read the answer with claude_code_answer and keep working " + "meanwhile. Only a channel that proves who answered carries it, never the desk. The words leave the mesh, so the " + "router refuses a path, an address, a host name, a commit id or anything shaped like a secret: say it in " + "words (messenger_check tests words first); anything shaped like a secret is refused before it is kept. At most two " + "questions wait at once, mesh-wide. The answer is the " + "operator's word to you; it authorises no call that needs the operator's approval.", Input: map[string]any{ "headline": str("what is decided, in a few plain words, at most 60 characters, e.g. 'Merge the resolver fix tonight?'"), "context": str("what the operator decides and why it matters, in plain words, at most 1500 bytes"), "options": map[string]any{"type": "array", "description": "two to four answers; exactly one recommended", "items": map[string]any{"type": "object", "properties": map[string]any{ "label": str("the button's words, at most 24 characters"), "means": str("what you do when it is chosen, in plain words"), "recommended": map[string]any{"type": "boolean", "description": "true on the one you recommend"}, }, "required": []string{"label", "means"}}}, "why": str("why the recommended option is the one you recommend"), "on_expiry": str("what you do if nobody answers before it expires, e.g. 'I leave the pull request open'"), "by": str("required: your session's name, as who@node or who, e.g. fix-589@g14"), "expires_minutes": map[string]any{"type": "integer", "description": "minutes until it expires, 5 to 1430; 60 when absent"}, }, Run: func(a map[string]any) (any, error) { if proposing == nil { return nil, notLaunched } in := QuestionInput{Headline: strArg(a, "headline"), Context: strArg(a, "context"), Why: strArg(a, "why"), OnExpiry: strArg(a, "on_expiry"), By: strArg(a, "by"), Options: questionOptionsOf(a["options"])} if m, ok := a["expires_minutes"].(float64); ok { in.Expires = time.Duration(m * float64(time.Minute)) } return AskQuestion(p, in, proposing.questions(), proposing.Bus, proposing.Now()) }}, {Name: "claude_code_answer", Description: "Where a question asked with claude_code_ask stands, by its key (novox/hq ADR 0332): waiting; chosen, " + "with the option, who answered, on which channel and when; ended without a choice (expired, refused, " + "cancelled, replaced) with the router's words; or not proven, when the router's record does not hold to the " + "question asked. Read from the router's own record. wait_seconds (at most 60) waits for an answer within the " + "call; read a question at most once a minute (asked of you, not enforced).", Input: map[string]any{ "key": str("the key claude_code_ask gave, e.g. q-0123456789abcdef"), "wait_seconds": map[string]any{"type": "integer", "description": "wait up to this many seconds (at most 60) while it is waiting"}, }, Run: func(a map[string]any) (any, error) { if proposing == nil { return nil, notLaunched } wait := time.Duration(0) if s, ok := a["wait_seconds"].(float64); ok && s > 0 { wait = time.Duration(s * float64(time.Second)) } return WaitForAnswer(proposing.questions(), proposing.Bus, strArg(a, "key"), wait, proposing.Now) }}, } } // questionOptionsOf reads the `options` argument: a list of objects with label, means and recommended. func questionOptionsOf(v any) []QuestionOption { list, _ := v.([]any) var out []QuestionOption for _, item := range list { m, _ := item.(map[string]any) if m == nil { out = append(out, QuestionOption{}) continue } out = append(out, QuestionOption{Label: strArg(m, "label"), Means: strArg(m, "means"), Recommended: boolArg(m, "recommended")}) } return out }