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.
This commit is contained in:
jochen
2026-10-13 17:16:50 +02:00
parent 9c166398f2
commit eadf301f9f
46 changed files with 1276 additions and 162 deletions
@@ -104,6 +104,12 @@ type Proposal struct {
Digest string `json:"digest"`
From string `json:"from"`
At time.Time `json:"at"`
// Summary is what the operator's phone shows of it, at a glance, in its proposer's words; empty, the module
// says what it does (novox/hq issue 639).
Summary string `json:"summary,omitempty"`
// Form is how its ask is composed, so every node composes the same one again to hold the warrant to it: 0 for
// a proposal made before issue 639 (its text in the explanation), AskGlance for one read at a glance.
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"`
}
@@ -164,9 +170,109 @@ func (p Proposal) Act(option string) asks.Act {
"at": p.At.UTC().Format(time.RFC3339Nano)}
}
// AskGlance is the form of an ask read at a glance (Proposal.Form, Question.Form; novox/hq issue 639).
const AskGlance = 1
// 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.
// router's warrant to it — in the form it was asked in.
func (p Proposal) Ask() asks.Ask {
if p.Form == AskGlance {
return p.glanceAsk()
}
return p.listedAsk()
}
// glanceAsk is the ask read at a glance on the phone (novox/hq issue 639): its headline, and as its explanation
// the summary of what it does; the text, where it came from, its fingerprint and where to read all of it are its
// Details, which the router notes after the answers, collapsed where the channel can.
func (p Proposal) glanceAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
if p.Action != ActionRemove && !whole {
part = " Part shown"
}
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " Claude instructions?" + part
}
var d strings.Builder
if p.Action == ActionRemove {
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC. The approved text it removes has the fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Fingerprint())
} else {
if whole {
d.WriteString("The text:\n" + shown + "\n")
} else {
d.WriteString("NOT SHOWN IN FULL here. Its first lines:\n" + shown + "\n")
}
fmt.Fprintf(&d, "Proposed through the claude-code module on %s at %s UTC: %d lines, %d bytes, fingerprint %s.",
p.From, p.At.UTC().Format("15:04 on 2 Jan"), p.Lines(), len(p.Text), p.Fingerprint())
}
fmt.Fprintf(&d, "\nRead all of it at the controller's terminal: mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'.", p.ID)
does := "the text becomes part of those sessions' instructions"
if p.Action == ActionRemove {
does = "the instructions are removed from those sessions"
}
return p.withOptions(asks.Ask{ID: p.ID, Headline: headline, Explanation: p.summary(), Details: d.String()}, does)
}
// summary is what the proposal does, at a glance: its proposer's words, or the module's.
func (p Proposal) summary() string {
if s := strings.TrimSpace(p.Summary); s != "" {
return s
}
lines := fmt.Sprintf("these %d lines", p.Lines())
if p.Lines() == 1 {
lines = "this line"
}
where := strings.ToUpper(p.where()[:1]) + p.where()[1:]
switch p.Action {
case ActionRemove:
return fmt.Sprintf("%s stops reading the instructions '%s' from its next start.", where, p.Name)
case ActionReplace:
return fmt.Sprintf("%s reads %s in place of the instructions '%s', as the mesh's own word, from its next start until "+
"they are removed the same way.", where, lines, p.Name)
}
return fmt.Sprintf("%s reads %s as the mesh's own word, from its next start until they are removed the same way.", where, lines)
}
// SummaryProblem says why a proposer's summary cannot be shown at a glance, or "".
func SummaryProblem(summary string) string {
switch s := strings.TrimSpace(summary); {
case s == "":
return ""
case strings.Contains(s, "\n"):
return "the summary is more than one line"
case len([]rune(s)) > asks.SummaryLength:
return fmt.Sprintf("the summary is %d characters, more than the %d a phone shows at a glance", len([]rune(s)), asks.SummaryLength)
case !shownLine(s):
return "the summary names a path, an address, a host name or a long word, which a phone is not shown"
}
return ""
}
// withOptions is an ask with what every form of it carries: who answers, its expiry, what it is about, and its two
// options, each binding its act.
func (p Proposal) withOptions(a asks.Ask, does string) asks.Ask {
a.Who, a.Expires, a.OnExpiry = asks.Operator, p.At.Add(ProposalLasts), "the proposal is discarded; nothing changes"
a.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
}
// listedAsk is the ask as it was composed before issue 639, the text in its explanation: composed only to hold the
// warrant of a proposal made then.
func (p Proposal) listedAsk() asks.Ask {
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
shown, whole := excerpt(p.Text)
part := ""
@@ -617,6 +723,8 @@ type ProposeInput struct {
Name string
Text string
Remove bool
// Summary is what the operator's phone shows of it, in one line (novox/hq issue 639); empty, the module says it.
Summary string
}
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
@@ -696,7 +804,11 @@ func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time
return nil, err
}
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
if problem := SummaryProblem(in.Summary); problem != "" {
return refuse(problem)
}
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC(),
Summary: strings.TrimSpace(in.Summary), Form: AskGlance}
switch {
case in.Remove:
if !isActive {