Files
mesh-catalog/modules/claude-code/cmd/claude-code/proposal_tools.go
T
jochen eadf301f9f An ask reads at a glance on the phone: a headline, a summary and its points, the rest in a note (issues 383 and 639)
The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold,
no summary. A settings proposal was the same wall of text, its values masked so the approval was blind.

- messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each
  answer does, the expiry, where else it is answered and the asker's Details travel as the message's note
  (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram ·
  <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind
  that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone
  (from the earlier work on issue 383, #178).
- telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in
  a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message
  shown before is still compared by the digest it was kept under.
- desk-channel: the note follows the body.
- mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three
  points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is
  composed again as it was asked (form), so its warrant still holds.
- claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the
  module's), its text in the Details; a question's context is at most 300 characters and its recommendation
  one point. Older proposals and questions keep their form.

The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and
asks.Glance.
2026-10-13 17:16:50 +02:00

228 lines
10 KiB
Go

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"), Summary: strArg(a, "summary")}, 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: two or three short sentences, one line, at most 300 characters"),
"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, at most 120 bytes"),
"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
}