mesh/merge-gate head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes …
mesh/repo-check head 14999f1d3ecb merged into trunk 9c9a76d220f8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
A question was kept on the bus before the router judged its words, so a token in it reached a bucket every node reads: every word, the options' too, is now held to the secret shapes and invisible characters before anything is kept, and a refused question keeps none of its words. The cap was check-then-create and failed open: each open question now holds one of two slot keys taken by create or compare-and-set, refusing when a slot cannot be read. A question the router never recorded frees its slot after two minutes instead of at expiry. by is required; wording matches ADR 0332.
228 lines
10 KiB
Go
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")}, 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
|
|
}
|