package main // A question an agent session asks the operator, answered on a channel that proves who answered (novox/hq ADR 0332, // issue 589). // // A session that needs a yes, a no or a choice asks it with claude_code_ask instead of waiting at a terminal the // operator may not be watching. The ask is published under this module's name on the operator channel (ADR 0259): // the bus grants that subject to this module alone, so the asker is proven by the bus, and the router stays the judge // of answers, never an asker for others. Every option is at the level approve, so only a channel that proves who // answered (Telegram, today) carries it, and each option binds the digest of the question and of itself. // // The session reads the answer by the question's key with claude_code_answer: from the router's own record of the // ask, which only the router writes and only this module reads, held to the question as the `questions` state keeps // it (asks.Warrant.For and asks.Option.Performs). A record that does not hold is said as not proven, never as a // choice. An answer informs the session; it authorises nothing (ADR 0286 stays the way to an approved call). import ( "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "regexp" "sort" "strconv" "strings" "sync" "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" ) // QuestionsState is the module's state the questions are kept in. const QuestionsState = "questions" // QuestionPrefix starts every question's key, so the router's word on one is never taken for a proposal's. const QuestionPrefix = "q-" // MostQuestions is how many questions may wait at once, mesh-wide: the router allows an asker three open asks // (asks.MostOpen), and questions alone never fill them, so one is left for a proposed instruction section (ADR 0274) // while two questions are open. Each open question holds one slot key in the `questions` state (slotKey), taken by // create or by compare-and-set, so two nodes asking at once cannot both take the last one. const MostQuestions = asks.MostOpen - 1 // A question the router holds no record of, and whose refusal was not heard here, keeps its slot until it expires: // the router may still have it in its queue (down, or backlogged), and neither the queue's order nor a cancel can // take it back — the queue offers a piece of work again after an error, so a cancel may be taken before the ask it // cancels, and a cancel of an ask not yet recorded is dropped. Once the question has expired the router refuses it // when it is taken (asks.Ask.Check), so the slot is free and the question has ended. // slotKey is the key of one of the MostQuestions slots: not a question's key, so no question is ever read from it. func slotKey(n int) string { return fmt.Sprintf("slot-%d", n) } // slot is what a slot key holds: the question holding it. type slot struct { Question string `json:"question"` At time.Time `json:"at"` } // The bounds of a question's life (ADR 0332 §2): at least five minutes, at most a day less ten minutes (the // router's bound for an approval, with the margin a proposal keeps), an hour when none is given. const ( QuestionShortest = 5 * time.Minute QuestionLongest = ProposalLasts QuestionDefault = time.Hour ) // The bounds of what a question says. const ( FewestQuestionOptions = 2 MostContextBytes = asks.SummaryLength MostMeansBytes = 200 MostWhyBytes = 120 MostOnExpiryBytes = 200 ) // How long one claude_code_answer call may wait for the answer, and how often it looks meanwhile. var ( AnswerWaitsAtMost = 60 * time.Second AnswerLooksEvery = 2 * time.Second ) // QuestionOption is one answer a session offers. type QuestionOption struct { ID string `json:"id"` Label string `json:"label"` Means string `json:"means"` Recommended bool `json:"recommended,omitempty"` } // QuestionStore is the `questions` state: a value with its revision, created only when absent, updated only at the // revision read. type QuestionStore interface { Get(key string) (json.RawMessage, uint64, bool, error) Create(key string, value any) error Update(key string, value any, revision uint64) error Put(key string, value any) error } // keptQuestions is the `questions` state on the bus. type keptQuestions struct{ s stdio.KeptState } func (k keptQuestions) Get(key string) (json.RawMessage, uint64, bool, error) { e, err := k.s.Get(key) if err != nil || e == nil { return nil, 0, false, err } return e.Value, e.Revision, true, nil } func (k keptQuestions) Create(key string, value any) error { _, err := k.s.Create(key, value) return err } func (k keptQuestions) Update(key string, value any, revision uint64) error { _, err := k.s.Update(key, value, revision) return err } func (k keptQuestions) Put(key string, value any) error { _, err := k.s.Put(key, value); return err } // Question is one question, as the `questions` state keeps it. Every field the ask is composed from is here, so the // answer is held to exactly the question asked. type Question struct { ID string `json:"id"` Headline string `json:"headline"` Context string `json:"context"` Options []QuestionOption `json:"options"` Why string `json:"why"` OnExpiry string `json:"on-expiry"` By string `json:"by"` From string `json:"from"` At time.Time `json:"at"` Expires time.Time `json:"expires"` // Form is how its ask is composed (AskGlance since novox/hq issue 639), so the answer is held to the ask asked. Form int `json:"form,omitempty"` // Asked is empty once the ask was published, or says why it was not; nothing composes from it. Asked string `json:"asked,omitempty"` // Ended is the router's word that the ask ended without a choice, as heard on the decided subject: a refused ask // leaves no record of the router's to read it from. It never makes a choice. Ended string `json:"ended,omitempty"` } // recommended is the option marked as recommended. func (q Question) recommended() (QuestionOption, bool) { for _, o := range q.Options { if o.Recommended { return o, true } } return QuestionOption{}, false } // Act is what an answer binds: this question, whole, and the option chosen. func (q Question) Act(option string) asks.Act { act := asks.Act{"verb": Module + ".question", "option": option, "question": q.ID, "headline": q.Headline, "context": q.Context, "why": q.Why, "on-expiry": q.OnExpiry, "by": q.By, "from": q.From, "at": q.At.UTC().Format(time.RFC3339Nano), "expires": q.Expires.UTC().Format(time.RFC3339Nano), "options": strconv.Itoa(len(q.Options))} for i, o := range q.Options { act[fmt.Sprintf("option.%d", i)] = o.ID + "\n" + o.Label + "\n" + o.Means + "\n" + strconv.FormatBool(o.Recommended) } return act } // Ask is the ask for a question, composed from it alone, in the form it was asked in. func (q Question) Ask() asks.Ask { if q.Form == AskGlance { return q.glanceAsk() } return q.listedAsk() } // glanceAsk is the question read at a glance on the phone (novox/hq issue 639): its context, then the recommended // answer and why as its one point; who asked and what the answer does are its Details. func (q Question) glanceAsk() asks.Ask { explanation := strings.TrimSpace(q.Context) if rec, ok := q.recommended(); ok { explanation += fmt.Sprintf("\n• Recommended: %s, because %s.", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), ".")) } details := fmt.Sprintf("Asked by the agent session %s on %s through the claude-code module, at %s UTC. Your answer is told "+ "to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan")) return q.withOptions(asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: explanation, Details: details}) } func (q Question) withOptions(a asks.Ask) asks.Ask { a.Who, a.Expires, a.OnExpiry = asks.Operator, q.Expires, q.OnExpiry for _, o := range q.Options { does := o.Means if o.Recommended { does = "Recommended. " + does } binds, _ := asks.ActDigest(q.Act(o.ID)) a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds}) } return a } // listedAsk is the ask as it was composed before issue 639: composed only to hold the answer to a question asked then. func (q Question) listedAsk() asks.Ask { var b strings.Builder b.WriteString(strings.TrimSpace(q.Context)) if rec, ok := q.recommended(); ok { fmt.Fprintf(&b, "\n\nRecommended: %s, because %s", rec.Label, strings.TrimRight(strings.TrimSpace(q.Why), ".")) b.WriteString(".") } fmt.Fprintf(&b, "\n\nAsked by the agent session %s on %s through the claude-code module, at %s UTC. "+ "Your answer is told to that session; it performs nothing by itself.", q.By, q.From, q.At.UTC().Format("15:04 on 2 Jan")) a := asks.Ask{ID: q.ID, Headline: q.Headline, Explanation: b.String(), Who: asks.Operator, Expires: q.Expires, OnExpiry: q.OnExpiry} for _, o := range q.Options { does := o.Means if o.Recommended { does = "Recommended. " + does } binds, _ := asks.ActDigest(q.Act(o.ID)) a.Options = append(a.Options, asks.Option{ID: o.ID, Label: o.Label, Does: does, Level: asks.Approve, Binds: binds}) } return a } var sessionName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$`) // QuestionProblem says why a question cannot be asked, or "": every bound of ADR 0332 §2 that this module holds // itself. The words are held to the content rule by the router, which says its refusal back. func QuestionProblem(q Question) string { var problems []string add := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } switch { case strings.TrimSpace(q.Headline) == "": add("it has no headline") case len([]rune(q.Headline)) > asks.HeadlineLength: add("its headline is %d characters, more than %d", len([]rune(q.Headline)), asks.HeadlineLength) } switch { case strings.TrimSpace(q.Context) == "": add("it gives no context: say in plain words what the operator decides and why it matters") case len([]rune(q.Context)) > MostContextBytes: add("its context is %d characters, more than the %d a phone shows in one glance", len([]rune(q.Context)), MostContextBytes) case strings.Contains(strings.TrimSpace(q.Context), "\n"): add("its context is more than one line: a phone shows it at a glance as one short paragraph") } if n := len(q.Options); n < FewestQuestionOptions || n > asks.MostOptions { add("it offers %d options; a question offers %d to %d", n, FewestQuestionOptions, asks.MostOptions) } recommended, labels := 0, map[string]bool{} for i, o := range q.Options { switch { case strings.TrimSpace(o.Label) == "": add("option %d has no label", i+1) case len([]rune(o.Label)) > asks.LabelLength: add("option %d's label is longer than %d characters", i+1, asks.LabelLength) case labels[strings.ToLower(o.Label)]: add("two options are labelled %q", o.Label) } labels[strings.ToLower(o.Label)] = true if strings.TrimSpace(o.Means) == "" { add("option %d does not say what choosing it means", i+1) } else if len(o.Means) > MostMeansBytes { add("what option %d means is longer than %d bytes", i+1, MostMeansBytes) } if !asks.UsableID(o.ID) { add("option %d's id is not one", i+1) } if o.Recommended { recommended++ } } if recommended != 1 { add("%d options are marked recommended; exactly one is", recommended) } switch { case strings.TrimSpace(q.Why) == "": add("it does not say why the recommended option is recommended") case len(q.Why) > MostWhyBytes: add("why the option is recommended is longer than %d bytes", MostWhyBytes) } switch { case strings.TrimSpace(q.OnExpiry) == "": add("it does not say what the session does if nobody answers") case len(q.OnExpiry) > MostOnExpiryBytes: add("what happens if nobody answers is longer than %d bytes", MostOnExpiryBytes) } if !sessionName.MatchString(q.By) { add("who asks is not a session's name: letters, digits, - and _, such as fix-589") } if !nodeName.MatchString(q.From) { add("where it comes from is not a node's name") } if life := q.Expires.Sub(q.At); life < QuestionShortest || life > QuestionLongest { add("it lasts %s; a question lasts %s to %s", life.Round(time.Minute), QuestionShortest, QuestionLongest) } if problem := questionWordsProblem(q); problem != "" { add("%s", problem) } if len(problems) == 0 { return "" } return strings.Join(problems, "; ") } // questionWordsProblem says why a question's words may not be kept at all, or "": anything shaped like a secret, or a // character a phone does not show, in any word the operator is shown — the headline, the context, why, what happens // on expiry, and each option's label and meaning. Judged before anything is kept: the `questions` state is read by // every node, and the router judges the content rule only after the question was kept. func questionWordsProblem(q Question) string { fields := []struct{ name, text string }{{"the headline", q.Headline}, {"the context", q.Context}, {"why", q.Why}, {"what happens on expiry", q.OnExpiry}} for i, o := range q.Options { fields = append(fields, struct{ name, text string }{fmt.Sprintf("option %d's label", i+1), o.Label}, struct{ name, text string }{fmt.Sprintf("what option %d means", i+1), o.Means}) } for _, f := range fields { for _, re := range secretShaped { if re.MatchString(f.text) { return f.name + " holds something shaped like a secret, which is never kept or shown on a phone" } } if what := invisible(f.text); what != "" { return f.name + " holds " + what + ", which a phone does not show" } } return "" } func newQuestionID() string { var b [8]byte _, _ = rand.Read(b[:]) return QuestionPrefix + hex.EncodeToString(b[:]) } // QuestionInput is what a session asks. type QuestionInput struct { Headline string Context string Options []QuestionOption Why string OnExpiry string By string Expires time.Duration } // AskQuestion keeps a question and asks the operator. It answers within seconds, once the router has said whether it // took the question, with the question's key; the answer is read with ReadAnswer. func AskQuestion(p Paths, in QuestionInput, store QuestionStore, bus AskBus, now time.Time) (map[string]any, error) { refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was asked") } by := strings.TrimSpace(in.By) if who, node, found := strings.Cut(by, "@"); found { if node != p.Node { return refuse(fmt.Sprintf("who asks names the node %q, and this is %s: ask on the node the session runs on", node, p.Node)) } by = who } if by == "" { return refuse("it does not say who asks: give by, your session's name as who@node, such as fix-589@g14") } life := in.Expires if life == 0 { life = QuestionDefault } q := Question{ID: newQuestionID(), Headline: strings.TrimSpace(in.Headline), Context: strings.TrimSpace(in.Context), Why: strings.TrimSpace(in.Why), OnExpiry: strings.TrimSpace(in.OnExpiry), By: by, From: p.Node, At: now.UTC(), Expires: now.UTC().Add(life), Form: AskGlance} for i, o := range in.Options { q.Options = append(q.Options, QuestionOption{ID: fmt.Sprintf("o%d", i+1), Label: strings.TrimSpace(o.Label), Means: strings.TrimSpace(o.Means), Recommended: o.Recommended}) } if problem := QuestionProblem(q); problem != "" { return refuse(problem) } ask := q.Ask() if err := ask.Check(now); err != nil { return refuse(err.Error()) } // The question is kept before its slot is taken, so a slot always names a question another node can judge. if err := store.Create(q.ID, q); err != nil { return nil, fmt.Errorf("the question could not be kept, so the operator was not asked: %w", err) } if open, err := takeSlot(store, bus, q, now); err != nil { _ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error())) return refuse(err.Error()) } else if open != nil { _ = store.Put(q.ID, tombstone(q, "not asked: two questions were open")) return refuse(fmt.Sprintf("%d questions already wait for the operator's answer (%s), and sessions hold at most %d "+ "at once, so one of the router's three open asks is left for a proposed instruction section; read them with "+ "claude_code_answer, or wait for one to end", len(open), strings.Join(open, ", "), MostQuestions)) } if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { _ = store.Put(q.ID, tombstone(q, "not asked: "+err.Error())) return nil, fmt.Errorf("the operator could not be asked: %v", err) } say("question %s from the session %s asked of the operator", q.ID, q.By) taken := "the router has not said yet whether it took the question; claude_code_answer says" defer forgetQuestionEnd(q.ID) for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); { if w, ended := questionEnd(q.ID); ended { return nil, fmt.Errorf("the router did not ask the operator: the question %s (%s)", w.Outcome, w.Words) } if entry, err := bus.Record(q.ID); err == nil && entry != nil { taken = "the router took the question and shows it where who answers is proven (Telegram)" break } time.Sleep(RouterAnswersEvery) } return map[string]any{ "key": q.ID, "router": taken, "asked": "\"" + q.Headline + "\", with the answers " + labelsOf(q), "expires": q.Expires.Format(time.RFC3339), "next": "read the answer with claude_code_answer {\"key\": \"" + q.ID + "\"} (it may wait up to a minute); " + "keep working meanwhile, and if it expires do what you said: " + q.OnExpiry, }, nil } func labelsOf(q Question) string { var out []string for _, o := range q.Options { out = append(out, o.Label) } return strings.Join(out, ", ") } // questionOpen says whether a question still waits: kept, asked, not ended, not expired, and open in the router's // record or not recorded yet. An unreadable record is open: a slot is never freed on a guess. func questionOpen(store QuestionStore, bus AskBus, id string, now time.Time) (bool, error) { q, ok, err := readQuestionErr(store, id) if err != nil { return true, err } if !ok || q.Asked != "" || q.Ended != "" || !now.Before(q.Expires) { return false, nil } entry, err := bus.Record(q.ID) if err != nil { return true, err } if entry == nil { return true, nil } var rec routerRecord if json.Unmarshal(entry.Value, &rec) != nil { return true, nil } return rec.State == "" || rec.State == "open", nil } // takeSlot takes one of the MostQuestions slots for q: a slot no key holds, by create, or one whose question no longer // waits, by compare-and-set at the revision read. Of two nodes taking the same slot only one wins; the other tries // the next. It answers the open questions when every slot is held, and an error — refusing, never guessing — when a // slot or its question cannot be read. func takeSlot(store QuestionStore, bus AskBus, q Question, now time.Time) ([]string, error) { mine := slot{Question: q.ID, At: q.At} var open []string for n := 1; n <= MostQuestions; n++ { key := slotKey(n) raw, rev, found, err := store.Get(key) if err != nil { return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) } if !found { if err := store.Create(key, mine); err == nil { return nil, nil } else if !errors.Is(err, stdio.ErrChanged) { return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) } if raw, rev, found, err = store.Get(key); err != nil || !found { return nil, fmt.Errorf("the open questions could not be counted, so none more is asked") } } var held slot if json.Unmarshal(raw, &held) == nil && held.Question != "" { waits, err := questionOpen(store, bus, held.Question, now) if err != nil { return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) } if waits { open = append(open, held.Question) continue } } if err := store.Update(key, mine, rev); err == nil { return nil, nil } else if !errors.Is(err, stdio.ErrChanged) { return nil, fmt.Errorf("the open questions could not be counted (%v), so none more is asked", err) } open = append(open, "one taken just now") } sort.Strings(open) return open, nil } // tombstone is what is kept of a question that was not asked or that the router refused: its key, who asked, when, and // why it ended — none of its words, which the router judged unfit to leave the mesh or never saw. func tombstone(q Question, ended string) Question { return Question{ID: q.ID, By: q.By, From: q.From, At: q.At, Expires: q.Expires, Ended: ended} } func readQuestion(store QuestionStore, key string) (Question, bool) { q, ok, _ := readQuestionErr(store, key) return q, ok } func readQuestionErr(store QuestionStore, key string) (Question, bool, error) { if !strings.HasPrefix(key, QuestionPrefix) { return Question{}, false, nil } raw, _, found, err := store.Get(key) if err != nil || !found { return Question{}, false, err } var q Question if json.Unmarshal(raw, &q) != nil || q.ID != key { return Question{}, false, nil } return q, true, nil } // The router's word that a question ended without a choice, as heard on the decided subject. var ( questionEndsMu sync.Mutex questionEnds = map[string]asks.Warrant{} ) func questionEnd(id string) (asks.Warrant, bool) { questionEndsMu.Lock() defer questionEndsMu.Unlock() w, ok := questionEnds[id] return w, ok } func forgetQuestionEnd(id string) { questionEndsMu.Lock() delete(questionEnds, id) questionEndsMu.Unlock() } // HeardQuestionEnd takes the router's word that a question ended without a choice — refused, expired, cancelled or // replaced — and keeps it on the question, because a refused ask leaves no record of the router's. A refused question // keeps none of its words (tombstone). A choice is never taken from this word: only from the router's record, held to // the question (ReadAnswer). func HeardQuestionEnd(store QuestionStore, w asks.Warrant) { if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !strings.HasPrefix(w.Ask, QuestionPrefix) { return } q, known := readQuestion(store, w.Ask) if !known { return } if q.Ended == "" { ended := string(w.Outcome) if w.Words != "" { ended += ": " + w.Words } if w.Outcome == asks.OutcomeRefused { q = tombstone(q, ended) } else { q.Ended = ended } _ = store.Put(q.ID, q) } // Kept on the question first, then said to a session waiting on it: what it reads next is already there. questionEndsMu.Lock() questionEnds[w.Ask] = w questionEndsMu.Unlock() } // The answer's states, as claude_code_answer says them. const ( AnswerWaiting = "waiting" AnswerChosen = "chosen" AnswerEnded = "ended" AnswerNotProven = "not proven" ) // ReadAnswer is where a question stands: waiting, chosen (and what), ended without a choice, or not proven. func ReadAnswer(store QuestionStore, bus AskBus, key string, now time.Time) (map[string]any, error) { q, ok := readQuestion(store, strings.TrimSpace(key)) if !ok { return nil, fmt.Errorf("no question %q is kept: the key is the one claude_code_ask gave", key) } out := map[string]any{"key": q.ID, "headline": q.Headline, "asked": q.At.Format(time.RFC3339), "expires": q.Expires.Format(time.RFC3339)} if q.Asked != "" { out["state"], out["words"] = AnswerEnded, q.Asked return out, nil } entry, err := bus.Record(q.ID) if err != nil { return nil, fmt.Errorf("the router's record of the question could not be read now (%v); ask again", err) } var rec routerRecord if entry != nil { if err := json.Unmarshal(entry.Value, &rec); err != nil { out["state"], out["words"] = AnswerNotProven, "the router's record of it cannot be read: "+err.Error() return out, nil } } switch { case entry == nil && q.Ended != "": out["state"], out["words"] = AnswerEnded, q.Ended case entry == nil && !now.Before(q.Expires): out["state"], out["words"] = AnswerEnded, "expired without the router recording it: refused (its refusal was not "+ "heard here) or never taken; the router refuses it now if it is taken late" out["now"] = "do what you said: " + q.OnExpiry case entry == nil: out["state"] = AnswerWaiting out["words"] = "not yet recorded by the router: it may still be in the router's queue, or was refused without the " + "refusal being heard here. It holds its slot, and the operator may still be asked, until it expires at " + q.Expires.Format(time.RFC3339) case entry == nil, rec.State == "open", rec.Warrant == nil: out["state"] = AnswerWaiting if q.Ended != "" { out["words"] = "the router said it ended (" + q.Ended + "), and its record says it is open; read again" } default: w := *rec.Warrant if w.Outcome != asks.OutcomeChosen { out["state"], out["words"] = AnswerEnded, string(w.Outcome) if w.Words != "" { out["words"] = string(w.Outcome) + ": " + w.Words } if w.Outcome == asks.OutcomeExpired { out["now"] = "do what you said: " + q.OnExpiry } return out, nil } if problem := QuestionProblem(q); problem != "" { out["state"], out["words"] = AnswerNotProven, "the question kept is not one this module asks: "+problem return out, nil } option, err := w.For(Module, q.Ask()) if err == nil { err = option.Performs(q.Act(option.ID)) } if err != nil { say("REFUSED the answer to question %s: %v", q.ID, err) out["state"], out["words"] = AnswerNotProven, "the router's record does not hold to the question asked: "+err.Error() return out, nil } chosen := map[string]any{"id": option.ID, "label": option.Label} for _, o := range q.Options { if o.ID == option.ID { chosen["means"], chosen["recommended"] = o.Means, o.Recommended } } out["state"], out["chosen"], out["by"], out["channel"], out["at"] = AnswerChosen, chosen, w.Says(), w.Channel, w.At.Format(time.RFC3339) out["note"] = "the operator's word to this session; it authorises no call that needs the operator's approval" } return out, nil } // WaitForAnswer reads the answer, and while it is waiting reads it again until wait has passed. func WaitForAnswer(store QuestionStore, bus AskBus, key string, wait time.Duration, now func() time.Time) (map[string]any, error) { if wait > AnswerWaitsAtMost { wait = AnswerWaitsAtMost } deadline := time.Now().Add(wait) for { out, err := ReadAnswer(store, bus, key, now()) if err != nil || out["state"] != AnswerWaiting || !time.Now().Before(deadline) { return out, err } time.Sleep(AnswerLooksEvery) } }