Let an agent session ask the operator on Telegram and read the answer (issue 589, hq ADR 0332)
mesh/merge-gate head 9446bec2f969 merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 9446bec2f969 merged into trunk 9c9a76d220f8 — fail: its merge-check.sh failed: --- FAIL: TestTheRoutersRefusalIsSaidAndDiscards (0.08s)
mesh/delivery superseded: a newer head of the same pull request

A session that needs a decision could only ask in a terminal the operator may
not watch. claude_code_ask raises the question under this module's name at the
level approve, so only a channel that proves who answered carries it;
claude_code_answer reads the chosen option back by its key from the router's own
record, held to the question kept in the new questions state.
This commit is contained in:
jochen
2026-10-12 22:44:45 +02:00
parent 9c9a76d220
commit 9446bec2f9
5 changed files with 889 additions and 2 deletions
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -20,6 +21,19 @@ type Proposing struct {
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 ProposalStore
questionsMu sync.Mutex
}
// questions is the `questions` state.
func (d *Proposing) questions() ProposalStore {
d.questionsMu.Lock()
defer d.questionsMu.Unlock()
if d.Questions == nil {
d.Questions = stateOf{stdio.State(QuestionsState)}
}
return d.Questions
}
var proposing *Proposing
@@ -60,6 +74,10 @@ func (d *Proposing) watch(p Paths) {
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 != "" {
@@ -86,7 +104,7 @@ func (d *Proposing) watch(p Paths) {
func proposalTools(p Paths) []stdio.Tool {
notLaunched := errors.New("this module was not launched with the bus; nothing can be proposed")
return []stdio.Tool{
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 " +
@@ -127,5 +145,81 @@ func proposalTools(p Paths) []stdio.Tool {
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, and when it expires (5 to 1430 minutes, 60 when " +
"absent). Answers at once with the question's key; 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). 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("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 at most once a minute.",
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
}