From aba00cccc2440e8b8014f240def5e6032b028ee5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 12 Oct 2026 09:06:15 -0700 Subject: [PATCH 1/3] connect: admit boosts on the agent's own posts as a boosted trigger Basecamp addresses a boost to the person whose recording was boosted, but the admission matrix knew only mentioned, subscribed, assigned and completed, so boost.created was discarded as not_in_matrix and the agent never heard a boost on its own comment, line or post. boost.created now admits under boosted when the boost is on the recording itself and the recording's creator is the agent. The booster is trusted like a mention's performer in every trust mode (not operator-only); agent, delegated and allowed-agent boosts stay discarded. The boost is read first, for the boosted recording's type (the summary read routes by type, and boost.created names none) and for its content, which is what is handed off. The request is answered where the boosted recording is: a comment's thread, a line's Campfire, otherwise the recording. Acknowledge is false, owner is false, and the rule requires a served project. A boost on one of a recording's events is discarded as not_addressed before any read: Basecamp addresses that to the event's performer, which this rule does not read. queued (the rest of step 25) and edited (step 28) are not built. --- .../connector/admission/admission_test.go | 15 +- internal/connector/admission/boost.go | 84 +++++ internal/connector/admission/boost_test.go | 335 ++++++++++++++++++ internal/connector/admission/doc.go | 24 +- internal/connector/admission/event.go | 6 +- internal/connector/admission/fakes_test.go | 25 +- internal/connector/admission/matrix.go | 21 +- internal/connector/admission/reads.go | 10 + internal/connector/admission/sdk.go | 1 + internal/connector/admission/sdk_test.go | 25 ++ internal/connector/admission/verdict.go | 107 +++++- internal/connector/agent_caps_test.go | 2 +- internal/connector/ledger_admission_test.go | 86 ++++- skills/basecamp-connect/SKILL.md | 22 +- skills/basecamp-connect/subagent.md | 14 +- 15 files changed, 723 insertions(+), 54 deletions(-) create mode 100644 internal/connector/admission/boost.go create mode 100644 internal/connector/admission/boost_test.go diff --git a/internal/connector/admission/admission_test.go b/internal/connector/admission/admission_test.go index d8d32f5e7..45803f22b 100644 --- a/internal/connector/admission/admission_test.go +++ b/internal/connector/admission/admission_test.go @@ -64,6 +64,7 @@ func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { "card.assignment_changed": TriggerAssigned, "todo.completed": TriggerCompleted, "card.completed": TriggerCompleted, + "boost.created": TriggerBoosted, } require.Len(t, V1Matrix(), len(admitted), "the matrix and this table must name the same types") @@ -76,10 +77,18 @@ func TestEveryCatalogueTypeHasAVerdict(t *testing.T) { s.Assignees = []basecamp.Person{{ID: agentID}} f.summaries[recordingID] = s f.assignments[eventID] = []int64{agentID} + ev := Event{ID: eventID, EventType: eventType, BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID} + if eventType == "boost.created" { + // The operator boosts the agent's card; the boost is the content. + s.Creator = person(agentID) + f.boosts[boostID] = &basecamp.Boost{ + ID: boostID, Content: "please look", Booster: person(operatorID), + Recording: &basecamp.Parent{ID: recordingID, Type: "Kanban::Card"}, + } + ev.Details = boostDetailsJSON(boostID, nil) + } - v := decide(t, newAdmitter(t, basePolicy(), f), Event{ - ID: eventID, EventType: eventType, BucketID: servedProj, RecordingID: recordingID, CreatorID: operatorID, - }) + v := decide(t, newAdmitter(t, basePolicy(), f), ev) want, inMatrix := admitted[eventType] if !inMatrix { diff --git a/internal/connector/admission/boost.go b/internal/connector/admission/boost.go new file mode 100644 index 000000000..bf0d8bf76 --- /dev/null +++ b/internal/connector/admission/boost.go @@ -0,0 +1,84 @@ +package admission + +import ( + "context" + "encoding/json" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" +) + +// eventBoostCreated is the feed's one type for every boost, whatever it +// landed on. +const eventBoostCreated = "boost.created" + +// boostDetails is the part of boost.created's details admission reads. The +// feed publishes boosted_event_id as null for a boost on the recording itself +// and as the event's id for a boost on one of the recording's events. +type boostDetails struct { + BoostID int64 `json:"boost_id"` + BoostedEventID *int64 `json:"boosted_event_id"` +} + +// opens reports whether the gate left trigger open. +func opens(gate GateResult, trigger Trigger) bool { + for _, rule := range gate.Rules { + if rule.Trigger == trigger { + return true + } + } + return false +} + +// boostOnRecording reads the boost's id from the pointer's details, and +// settles from them alone what they can. +// +// A boost on an event is discarded as not_addressed. Basecamp addresses it to +// whoever performed the event (a completion, a move), not to the recording's +// author, and the boosted rule admits a boost on something the agent wrote: +// the agent's own comment, line or post. Whether the agent performed a given +// event is not something this rule reads. +// +// Details without a boost id are not a boost.created Basecamp writes, and are +// discarded as an invalid pointer: there is no boost to read. +func boostOnRecording(ev Event) (int64, Reason) { + var d boostDetails + if len(ev.Details) == 0 || json.Unmarshal(ev.Details, &d) != nil || d.BoostID <= 0 { + return 0, ReasonInvalidPointer + } + if d.BoostedEventID != nil { + return 0, ReasonNotAddressed + } + return d.BoostID, "" +} + +// readBoost reads the boost the event names. It is the content an admitted +// boost hands off, and it names the boosted recording's type, which the +// summary read routes by. +// +// The boost must be on the pointer's recording, and its booster must be the +// performer the gate trusted: the boost is the words handed off, so whoever +// wrote them is held to the trust the performer was. A read that answered +// without either is a read that failed. +func (a *decision) readBoost(ctx context.Context, ev Event, boostID int64) (*basecamp.Boost, State, Reason, error) { + var boost *basecamp.Boost + err := a.retry(ctx, func() error { + var err error + boost, err = a.reads.Boosts.Get(ctx, boostID) + return err + }) + if err != nil { + reason, ctxErr := failure(ctx, err) + if ctxErr != nil { + return nil, "", "", ctxErr + } + return nil, StateBlocked, reason, nil + } + switch { + case boost == nil, boost.Booster == nil, boost.Booster.ID <= 0, + boost.Recording == nil, boost.Recording.ID != ev.RecordingID, boost.Recording.Type == "": + return nil, StateBlocked, ReasonReadFailed, nil + case boost.Booster.ID != ev.Performer(): + return nil, StateDiscarded, ReasonUntrustedAuthor, nil + } + return boost, "", "", nil +} diff --git a/internal/connector/admission/boost_test.go b/internal/connector/admission/boost_test.go new file mode 100644 index 000000000..a1d9b1f26 --- /dev/null +++ b/internal/connector/admission/boost_test.go @@ -0,0 +1,335 @@ +package admission + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Boosts: someone boosts a recording the agent wrote. The boost is a reply, +// not a request: admitted as boosted, never acknowledged, never the owner's +// word, and only from someone who may mention the agent. + +const boostID int64 = 31337 + +var boostedAt = time.Date(2026, 10, 12, 9, 30, 0, 0, time.UTC) + +// boostDetailsJSON is boost.created's details as the feed publishes them: a +// boost on the recording itself carries null event keys. +func boostDetailsJSON(id int64, onEvent *int64) json.RawMessage { + event := "null" + if onEvent != nil { + event = strconv.FormatInt(*onEvent, 10) + } + return json.RawMessage(`{"boost_id":` + strconv.FormatInt(id, 10) + + `,"boosted_event_id":` + event + `,"boosted_event_type":null}`) +} + +func boostEvent(performer int64) Event { + return Event{ + ID: eventID, EventType: "boost.created", BucketID: servedProj, RecordingID: recordingID, + CreatorID: performer, Details: boostDetailsJSON(boostID, nil), + } +} + +// boostOn sets up a boost by booster on a recording of typ that author wrote. +func boostOn(f *fakeReads, typ string, author, booster int64) *basecamp.RecordingSummary { + s := summaryWith(recordingID, servedProj, typ, author, "
Shall I ship the fix?
") + switch { + case typ == "Comment": + s.Parent = &basecamp.Parent{ID: parentID} + case strings.HasPrefix(typ, "Chat::Lines::"): + s.CampfireID = campfireID + } + f.summaries[recordingID] = s + f.boosts[boostID] = &basecamp.Boost{ + ID: boostID, + Content: "yes, go", + CreatedAt: boostedAt, + Booster: &basecamp.Person{ID: booster, Name: "Booster " + strconv.FormatInt(booster, 10)}, + Recording: &basecamp.Parent{ID: recordingID, Type: typ}, + } + return s +} + +func TestABoostOnTheAgentsCommentIsAdmittedAsAReply(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, TriggerBoosted, v.Trigger) + assert.False(t, v.Acknowledge, "a boost is a reply, not a request") + assert.Equal(t, operatorID, v.RequesterID) + assert.Equal(t, RoleOperator, v.Role) + // Answered in the comment's thread, and part of its conversation. + assert.Equal(t, &ReplyDestination{Kind: ReplyComment, RecordingID: parentID}, v.Reply) + assert.Equal(t, "recording:9000", v.ConversationKey) + + require.NotNil(t, v.Snapshot) + assert.Equal(t, "yes, go", v.Snapshot.Content, "the boost is the content") + assert.Equal(t, boostedAt, v.Snapshot.UpdatedAt) + assert.Equal(t, "Booster "+strconv.FormatInt(operatorID, 10), v.Snapshot.RequesterName) + assert.Equal(t, RoleOperator, v.Snapshot.Role) + assert.False(t, v.Snapshot.Owner, "a boost is never the owner's word") + + // The summary read is routed by the type the boost named, never by the + // event type, which names no recording. + require.Len(t, f.summaryRefs, 1) + assert.Equal(t, "Comment", f.summaryRefs[0].RecordingType) + assert.Equal(t, 1, f.boostCalls) +} + +func TestABoostIsAnsweredWhereTheBoostedRecordingIs(t *testing.T) { + t.Run("a chat line, in its Campfire", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Chat::Lines::RichText", agentID, operatorID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, &ReplyDestination{Kind: ReplyChatLine, RecordingID: campfireID}, v.Reply) + assert.Equal(t, "campfire:8000", v.ConversationKey) + }) + + t.Run("a chat line found under no Campfire cannot be answered", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Chat::Lines::RichText", agentID, operatorID).CampfireID = 0 + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonReadFailed, v.Reason) + }) + + t.Run("a comment without its recording cannot be answered", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID).Parent = nil + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonReadFailed, v.Reason) + }) + + t.Run("a message, on itself", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Message", agentID, operatorID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, &ReplyDestination{Kind: ReplyComment, RecordingID: recordingID}, v.Reply) + assert.Equal(t, "recording:9001", v.ConversationKey) + }) +} + +func TestABoostOnSomeoneElsesRecordingIsNotAdmitted(t *testing.T) { + for _, author := range []int64{operatorID, strangerID, otherAgent} { + t.Run(strconv.FormatInt(author, 10), func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", author, operatorID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonNotAddressed, v.Reason) + assert.Empty(t, v.Trigger) + assert.Nil(t, v.Snapshot, "a discarded boost carries no content") + }) + } +} + +func TestABoostIsTrustedLikeAMention(t *testing.T) { + t.Run("a booster outside the trust set is discarded at the gate", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, strangerID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(strangerID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonUntrustedPerformer, v.Reason) + assert.Zero(t, f.totalReads(), "an untrusted boost must cost no read") + }) + + t.Run("an allowlisted booster is an operator, and not the owner", func(t *testing.T) { + p := basePolicy() + p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}} + f := newFakeReads() + boostOn(f, "Comment", agentID, allowedID) + + v := decide(t, newAdmitter(t, p, f), boostEvent(allowedID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, allowedID, v.RequesterID) + assert.Equal(t, RoleOperator, v.Role) + assert.False(t, v.Snapshot.Owner) + }) + + t.Run("not operator-only: in project mode a member's boost is a participant's", func(t *testing.T) { + p := basePolicy() + p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID} + f := newFakeReads() + f.members[servedProj] = map[int64]bool{memberID: true} + boostOn(f, "Comment", agentID, memberID) + + v := decide(t, newAdmitter(t, p, f), boostEvent(memberID)) + require.Equal(t, StateAdmitted, v.State, "reason %q", v.Reason) + assert.Equal(t, TriggerBoosted, v.Trigger) + assert.Equal(t, memberID, v.RequesterID) + assert.Equal(t, RoleParticipant, v.Role) + assert.Equal(t, 1, f.memberCalls) + }) + + t.Run("in project mode someone membership does not confirm costs no boost read", func(t *testing.T) { + p := basePolicy() + p.Trust = Trust{Mode: TrustProject, OperatorID: operatorID} + f := newFakeReads() + f.members[servedProj] = map[int64]bool{memberID: true} + boostOn(f, "Comment", agentID, clientID) + + v := decide(t, newAdmitter(t, p, f), boostEvent(clientID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonUntrustedPerformer, v.Reason) + assert.Zero(t, f.boostCalls) + assert.Zero(t, f.summaryCalls) + }) + + t.Run("a boost whose booster is not the performer is not trusted on the performer's word", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, strangerID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonUntrustedAuthor, v.Reason) + assert.Nil(t, v.Snapshot) + }) +} + +func TestNoAgentWakesAnAgentByBoosting(t *testing.T) { + for _, c := range []struct { + name string + policy Policy + ev Event + want Reason + }{ + {"the agent boosting its own post", basePolicy(), boostEvent(agentID), ReasonAgentAuthored}, + {"another agent, on the push lane", basePolicy(), func() Event { + ev := boostEvent(otherAgent) + ev.ActorType = ActorTypeAgent + return ev + }(), ReasonAgentAuthored}, + // An allowed agent reaches this one by mention only: two agents that + // allow each other cannot boost each other's posts in a loop. + {"an allowed agent", agentPolicy(), boostEvent(peerAgent), ReasonNotAddressed}, + {"a person's boost an agent performed for them", basePolicy(), func() Event { + ev := boostEvent(operatorID) + ev.PerformedByID = ptr(otherAgent) + return ev + }(), ReasonDelegated}, + } { + t.Run(c.name, func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, c.ev.Performer()) + + v := decide(t, newAdmitter(t, c.policy, f), c.ev) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, c.want, v.Reason) + assert.Zero(t, f.totalReads()) + }) + } +} + +func TestABoostSettledFromItsPointerCostsNoRead(t *testing.T) { + t.Run("in a project connect.json does not serve", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + ev := boostEvent(operatorID) + ev.BucketID = unservedProj + + v := decide(t, newAdmitter(t, basePolicy(), f), ev) + assert.Equal(t, StateDiscarded, v.State, "never blocked: a holding reply to a boost answers nobody") + assert.Equal(t, ReasonNoRoute, v.Reason) + assert.Zero(t, f.totalReads()) + }) + + t.Run("on one of the recording's events", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Todo", agentID, operatorID) + ev := boostEvent(operatorID) + ev.Details = boostDetailsJSON(boostID, ptr(5150)) + + v := decide(t, newAdmitter(t, basePolicy(), f), ev) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonNotAddressed, v.Reason) + assert.Zero(t, f.totalReads()) + }) + + for name, details := range map[string]json.RawMessage{ + "without details": nil, + "without a boost id": json.RawMessage(`{"boosted_event_id":null}`), + "unreadable details": json.RawMessage(`{"boost_id":"x"`), + } { + t.Run(name, func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + ev := boostEvent(operatorID) + ev.Details = details + + v := decide(t, newAdmitter(t, basePolicy(), f), ev) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonInvalidPointer, v.Reason) + assert.Zero(t, f.totalReads()) + }) + } +} + +func TestABoostReadThatDoesNotAnswerBlocks(t *testing.T) { + t.Run("the boost is gone", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + delete(f.boosts, boostID) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonReadFailed, v.Reason) + assert.Zero(t, f.summaryCalls) + }) + + t.Run("a transport failure, after its retries", func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + f.boostErr = errTransport + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonReadFailed, v.Reason) + assert.Equal(t, DefaultReadAttempts, f.boostCalls) + }) + + for name, mutate := range map[string]func(*basecamp.Boost){ + "without its recording": func(b *basecamp.Boost) { b.Recording = nil }, + "on another recording": func(b *basecamp.Boost) { b.Recording.ID = parentID }, + "without the recording type": func(b *basecamp.Boost) { b.Recording.Type = "" }, + "without its booster": func(b *basecamp.Boost) { b.Booster = nil }, + } { + t.Run(name, func(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID) + mutate(f.boosts[boostID]) + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateBlocked, v.State) + assert.Equal(t, ReasonReadFailed, v.Reason) + assert.Zero(t, f.summaryCalls) + }) + } +} + +func TestABoostOnATrashedPostIsStale(t *testing.T) { + f := newFakeReads() + boostOn(f, "Comment", agentID, operatorID).Status = "trashed" + + v := decide(t, newAdmitter(t, basePolicy(), f), boostEvent(operatorID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonStale, v.Reason) +} diff --git a/internal/connector/admission/doc.go b/internal/connector/admission/doc.go index 9f82939c3..aee82c283 100644 --- a/internal/connector/admission/doc.go +++ b/internal/connector/admission/doc.go @@ -14,14 +14,18 @@ // as a non-client member of the project. That is a read, cached per // project, and it is the one trust decision the pointer alone cannot make. // 3. The RecordingSummary read (the SDK's step-10 helper) resolves the -// recording through the typed read its event type names. A recording -// still drafted or since trashed is stale. +// recording through the typed read its event type names. A boost's type +// names no recording, so a boost is read first, for the boosted +// recording's type and for the boost itself. A recording still drafted or +// since trashed is stale. // 4. The trigger rules decide what the event is to the agent: mentioned (the // agent's Person id is among the mention attachments — never a name), // subscribed (a subscription read on the commented recording), assigned // (the recording's events, at most five pages, for details.added_person_ids) -// or completed (trusted completer and a stake: the served project's -// watch_completions flag, an assignment, or a subscription). +// completed (trusted completer and a stake: the served project's +// watch_completions flag, an assignment, or a subscription), or boosted +// (a boost on the recording itself, which the agent wrote, given by the +// trusted performer; a boost on one of a recording's events is not). // 5. Whether the project is served, its class, the reply destination, the // conversation key and the content snapshot come from the policy and the // summary, never from the pointer. @@ -48,8 +52,10 @@ // Trust has two roles. Operators are the operator and the people the // allowlist names, in allowlist or project mode; their word authorizes the // agent. Participants are the members project mode admits besides them: -// they reach the agent by mention, by comment on a thread it follows, and by -// a completion it has a stake in, never by assignment. Every admitted +// they reach the agent by mention, by comment on a thread it follows, by a +// completion it has a stake in, and by boosting something it wrote, never by +// assignment. A boost's content is the boost, which its booster wrote, so the +// booster is held to the performer's trust and there is no other author. Every admitted // verdict carries its Role, the lesser of the performer's and, for mentioned // and subscribed, the author's, so words a participant wrote stay a // participant's request whoever brought them in. @@ -109,7 +115,7 @@ // // Tasks, attempts, the outbox, acknowledgements, and the holding reply a // blocked(no_route) record receives are dispatch and lifecycle (plan steps 17 -// to 20). The boosted and queued triggers are step 25 and edited is step 28: -// each is a row added to the Matrix plus its rule, and the gate does not -// change. +// to 20). The queued trigger is the rest of step 25, whose boosted trigger is +// built, and edited is step 28: each is a row added to the Matrix plus its +// rule, and the gate does not change. package admission diff --git a/internal/connector/admission/event.go b/internal/connector/admission/event.go index bcd23a0f6..1916c827b 100644 --- a/internal/connector/admission/event.go +++ b/internal/connector/admission/event.go @@ -29,8 +29,10 @@ type Event struct { PerformedByID *int64 // ActorType is "person" or "agent" on the push lane, empty on the poll lane. ActorType string - // Details is the type-specific detail object, verbatim; nil for the v1 - // trigger types, which publish none. + // Details is the type-specific detail object, verbatim; nil for the + // types that publish none. Of the matrix's types only boost.created + // publishes one: the boost's id, and the event it landed on when it was + // not the recording itself. Details json.RawMessage // Revision is the ledger record's revision when it was loaded. It is not diff --git a/internal/connector/admission/fakes_test.go b/internal/connector/admission/fakes_test.go index b34505e6d..e214f191a 100644 --- a/internal/connector/admission/fakes_test.go +++ b/internal/connector/admission/fakes_test.go @@ -51,11 +51,15 @@ type fakeReads struct { members map[int64]map[int64]bool memberErr error memberAsOf []time.Time + boosts map[int64]*basecamp.Boost // boost id → boost + boostErr error summaryCalls int + summaryRefs []basecamp.RecordingRef subCalls []int64 assignCalls int memberCalls int + boostCalls int } func newFakeReads() *fakeReads { @@ -64,17 +68,19 @@ func newFakeReads() *fakeReads { subscriptions: map[int64]bool{}, assignments: map[int64][]int64{}, members: map[int64]map[int64]bool{}, + boosts: map[int64]*basecamp.Boost{}, } } func (f *fakeReads) reads() Reads { - return Reads{Summaries: f, Subscriptions: f, Assignments: f, Members: f} + return Reads{Summaries: f, Subscriptions: f, Assignments: f, Boosts: f, Members: f} } func (f *fakeReads) Summarize(_ context.Context, ref basecamp.RecordingRef) (*basecamp.RecordingSummary, error) { f.mu.Lock() defer f.mu.Unlock() f.summaryCalls++ + f.summaryRefs = append(f.summaryRefs, ref) if len(f.summaryErrs) > 0 { err := f.summaryErrs[0] f.summaryErrs = f.summaryErrs[1:] @@ -122,10 +128,25 @@ func (f *fakeReads) NonClientMember(_ context.Context, bucketID, personID int64, return f.members[bucketID][personID], nil } +func (f *fakeReads) Get(_ context.Context, boostID int64) (*basecamp.Boost, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.boostCalls++ + if f.boostErr != nil { + return nil, f.boostErr + } + b, ok := f.boosts[boostID] + if !ok { + return nil, &basecamp.Error{Code: basecamp.CodeNotFound, Message: "not found"} + } + copied := *b + return &copied, nil +} + func (f *fakeReads) totalReads() int { f.mu.Lock() defer f.mu.Unlock() - return f.summaryCalls + len(f.subCalls) + f.assignCalls + f.memberCalls + return f.summaryCalls + len(f.subCalls) + f.assignCalls + f.memberCalls + f.boostCalls } var errTransport = errors.New("connection reset") diff --git a/internal/connector/admission/matrix.go b/internal/connector/admission/matrix.go index dfa27cd0c..3844330a3 100644 --- a/internal/connector/admission/matrix.go +++ b/internal/connector/admission/matrix.go @@ -3,12 +3,15 @@ package admission // Trigger names why an admitted event is the agent's business. type Trigger string -// The v1 triggers. boosted and queued are step 25; edited is step 28. +// The triggers. queued is step 25, still to come; edited is step 28. const ( TriggerMentioned Trigger = "mentioned" TriggerSubscribed Trigger = "subscribed" TriggerAssigned Trigger = "assigned" TriggerCompleted Trigger = "completed" + // TriggerBoosted: someone boosted a recording the agent wrote. The boost + // is the content; the boosted recording is what it answers. + TriggerBoosted Trigger = "boosted" ) // Rule is one row of the trigger matrix: a trigger an event type can admit @@ -28,8 +31,8 @@ type Rule struct { // read. RequiresServed bool // Acknowledge says a person asked for something, so the worker's - // acknowledgement and the thirty-second guard apply. A completion or a - // subscription comment is not a request. + // acknowledgement and the thirty-second guard apply. A completion, a + // subscription comment or a boost is not a request. Acknowledge bool } @@ -43,9 +46,15 @@ var ( ruleSubscribed = Rule{Trigger: TriggerSubscribed, RequiresServed: true} ruleAssigned = Rule{Trigger: TriggerAssigned, OperatorOnly: true, Acknowledge: true} ruleCompleted = Rule{Trigger: TriggerCompleted, RequiresServed: true} + // A boost answers something the agent wrote; it asks for nothing, so it + // is not acknowledged. It requires a served project because nothing in an + // unserved one waits on it: blocked(no_route) would post a holding reply + // to a thumbs-up. Who may boost is whoever may mention the agent, in + // every trust mode, so it is not OperatorOnly. + ruleBoosted = Rule{Trigger: TriggerBoosted, RequiresServed: true} ) -// V1Matrix returns the version-1 trigger matrix. Every other cataloged type +// V1Matrix returns the trigger matrix: version 1, plus boosted. Every other cataloged type // is discarded as not_in_matrix. A fresh copy each call, so no caller can // widen another's. func V1Matrix() Matrix { @@ -64,6 +73,10 @@ func V1Matrix() Matrix { "todo.completed": {ruleCompleted}, "card.completed": {ruleCompleted}, + + // Admitted only for a boost on a recording the agent wrote, never on + // one of a recording's events (boostedRecording). + "boost.created": {ruleBoosted}, } } diff --git a/internal/connector/admission/reads.go b/internal/connector/admission/reads.go index f42fcc150..03092aa8d 100644 --- a/internal/connector/admission/reads.go +++ b/internal/connector/admission/reads.go @@ -27,6 +27,15 @@ type AssignmentReader interface { AddedPersonIDs(ctx context.Context, recordingID, eventID int64) (added []int64, found bool, err error) } +// BoostReader reads one boost: who gave it, what it says, and the recording +// it is on, with that recording's type. A boost.created pointer names the +// boosted recording but not its type, and the summary read routes by type +// (basecamp.ErrNoRecordingType), so the boost is read first. +// *basecamp.BoostsService satisfies it. +type BoostReader interface { + Get(ctx context.Context, boostID int64) (*basecamp.Boost, error) +} + // MemberReader answers whether a person is a non-client member of a project. // Only project trust mode consults it. // @@ -48,5 +57,6 @@ type Reads struct { Summaries SummaryReader Subscriptions SubscriptionReader Assignments AssignmentReader + Boosts BoostReader Members MemberReader } diff --git a/internal/connector/admission/sdk.go b/internal/connector/admission/sdk.go index c2b356241..79b5abe37 100644 --- a/internal/connector/admission/sdk.go +++ b/internal/connector/admission/sdk.go @@ -34,6 +34,7 @@ func NewSDKReads(cfg *basecamp.Config, tokens basecamp.TokenProvider, accountID Summaries: client.Recordings(), Subscriptions: NewSubscriptions(client, time.Now), Assignments: &Assignments{client: client}, + Boosts: client.Boosts(), Members: NewMembers(client, time.Now), } } diff --git a/internal/connector/admission/sdk_test.go b/internal/connector/admission/sdk_test.go index d78edd79a..b36d931cb 100644 --- a/internal/connector/admission/sdk_test.go +++ b/internal/connector/admission/sdk_test.go @@ -462,3 +462,28 @@ func TestNotSubscribedTakesNoCacheSlot(t *testing.T) { defer subs.cache.mu.Unlock() assert.Empty(t, subs.cache.entries, "a stream of unsubscribed recordings must not evict the subscribed ones") } + +// The boost read is the one that names a boosted recording's type, and the +// summary read routes by it: the SDK's read must carry it through. +func TestTheBoostReadNamesTheBoostedRecordingsType(t *testing.T) { + var path string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":31337,"content":"yes, go","created_at":"2026-10-12T09:30:00Z", + "booster":{"id":26909558,"name":"Op"}, + "recording":{"id":9001,"title":"Shall I?","type":"Comment","url":"https://example.test/c.json","app_url":"https://example.test/c"}}`)) + })) + t.Cleanup(srv.Close) + reads := NewSDKReads(&basecamp.Config{BaseURL: srv.URL}, staticToken{}, "999") + + boost, err := reads.Boosts.Get(context.Background(), 31337) + require.NoError(t, err) + assert.Equal(t, "/999/boosts/31337", path) + assert.Equal(t, "yes, go", boost.Content) + require.NotNil(t, boost.Booster) + assert.EqualValues(t, 26909558, boost.Booster.ID) + require.NotNil(t, boost.Recording) + assert.EqualValues(t, 9001, boost.Recording.ID) + assert.Equal(t, "Comment", boost.Recording.Type) +} diff --git a/internal/connector/admission/verdict.go b/internal/connector/admission/verdict.go index 311f4b076..e3ccce99f 100644 --- a/internal/connector/admission/verdict.go +++ b/internal/connector/admission/verdict.go @@ -6,6 +6,7 @@ import ( "fmt" "slices" "strconv" + "strings" "time" "github.com/basecamp/basecamp-sdk/go/pkg/basecamp" @@ -61,8 +62,8 @@ type Snapshot struct { Role Role `json:"role,omitempty"` // Owner says the operator connect.json names asked, in words they wrote: // they mentioned the agent, or assigned it a recording they wrote. A - // comment on a followed thread and a completion are never the owner's - // word. Someone named with --allow is an operator but never the + // comment on a followed thread, a completion and a boost are never the + // owner's word. Someone named with --allow is an operator but never the // owner, and neither is the owner bringing in someone else's words: an // allowlisted person's to-do the owner moves in is that person's. Owner bool `json:"owner,omitempty"` @@ -95,7 +96,7 @@ type Verdict struct { // none of it. Trigger Trigger // Acknowledge says the worker acknowledges and the guard arms. False for - // completed and subscribed. + // completed, subscribed and boosted. Acknowledge bool // ConversationKey groups events into one task: the commented recording // for a comment, the Campfire for a chat line, the recording otherwise. @@ -242,8 +243,8 @@ func NewAdmitter(policy Policy, reads Reads, opts ...Option) (*Admitter, error) return nil, err } switch { - case reads.Summaries == nil || reads.Subscriptions == nil || reads.Assignments == nil: - return nil, errors.New("admission: the summary, subscription and assignment reads are required") + case reads.Summaries == nil || reads.Subscriptions == nil || reads.Assignments == nil || reads.Boosts == nil: + return nil, errors.New("admission: the summary, subscription, assignment and boost reads are required") case policy.Trust.Mode == TrustProject && reads.Members == nil: return nil, errors.New("admission: project trust mode needs a membership read") } @@ -325,6 +326,16 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error return v.end(StateBlocked, ReasonConfigUnreadable), nil } + // A boost names its boost in the pointer's details. What the details + // alone can settle is settled before any read. + var boostID int64 + if opens(gate, TriggerBoosted) { + var reason Reason + if boostID, reason = boostOnRecording(ev); reason != "" { + return v.end(StateDiscarded, reason), nil + } + } + if gate.ConfirmMembership { member, reason, err := d.memberOf(ctx, ev.BucketID, ev.Performer(), ev.SeenAt) if err != nil { @@ -338,8 +349,27 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error } } - var summary *basecamp.RecordingSummary + var ( + summary *basecamp.RecordingSummary + boost *basecamp.Boost + ) ref := basecamp.RecordingRef{BucketID: ev.BucketID, RecordingID: ev.RecordingID, EventType: ev.EventType} + if boostID > 0 { + var ( + state State + reason Reason + ) + boost, state, reason, err = d.readBoost(ctx, ev, boostID) + if err != nil { + return Verdict{}, err + } + if state != "" { + return v.end(state, reason), nil + } + // The boost names the boosted recording's type, which the pointer + // does not: the summary read routes by it. + ref.RecordingType = boost.Recording.Type + } err = d.retry(ctx, func() error { var err error summary, err = a.reads.Summaries.Summarize(ctx, ref) @@ -363,9 +393,10 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error // A read that answered without what admission decides from is a read // that failed: an author to trust, the recording a comment hangs off, the // Campfire a line was found in. None of them is evidence of anything. + shape := shapeOf(ev.EventType, summary) if summary.Creator == nil || summary.Creator.ID <= 0 || - (ev.EventType == "comment.created" && (summary.Parent == nil || summary.Parent.ID <= 0)) || - (ev.EventType == "chat.line.created" && summary.CampfireID <= 0) { + (shape == shapeComment && (summary.Parent == nil || summary.Parent.ID <= 0)) || + (shape == shapeChatLine && summary.CampfireID <= 0) { return v.end(StateBlocked, ReasonReadFailed), nil } @@ -396,7 +427,7 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error v.RequesterID = summary.Creator.ID } } - v.address(summary) + v.address(summary, shape) if gate.Agent { v.agentCaps = &agentCaps{ agentID: ev.Performer(), @@ -425,8 +456,9 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error Role: v.Role, // Only a request the owner made, in words the owner wrote: a mention, // or an assignment of a recording they wrote. A comment on a thread - // the agent follows asks it nothing, a completion is context, and an - // assigned recording someone else wrote is that person's words. + // the agent follows asks it nothing, a completion is context, a boost + // answers the agent and names nothing it approves, and an assigned + // recording someone else wrote is that person's words. Owner: v.Role == RoleOperator && ev.Performer() == policy.Trust.OperatorID && (rule.Trigger == TriggerMentioned || rule.Trigger == TriggerAssigned) && summary.Creator.ID == policy.Trust.OperatorID, @@ -434,7 +466,15 @@ func (a *Admitter) Decide(ctx context.Context, ev Event) (out Verdict, err error if summary.Bucket != nil { v.Snapshot.ProjectName = summary.Bucket.Name } - if summary.Creator != nil && summary.Creator.ID == v.RequesterID { + switch { + case boost != nil: + // The boost is what was said, and its booster said it: the boosted + // recording is the agent's own, which the worker reads by its URL. + v.Snapshot.Content, v.Snapshot.UpdatedAt = boost.Content, boost.CreatedAt + if boost.Booster.ID == v.RequesterID { + v.Snapshot.RequesterName = boost.Booster.Name + } + case summary.Creator != nil && summary.Creator.ID == v.RequesterID: v.Snapshot.RequesterName = summary.Creator.Name } return v, nil @@ -519,6 +559,17 @@ func (a *decision) match(ctx context.Context, ev Event, policy Policy, gate Gate return rule, "", "", "", nil } + case TriggerBoosted: + // Basecamp addresses a boost on a recording to whoever published + // it (bc3 Boostable#boostee: published_by, else edited_by), and + // the recording's creator stands for them here: they differ only + // for a draft someone else published. The booster was trusted at + // the gate and wrote the boost (readBoost); the boosted words are + // the agent's own, so there is no author to trust. + if summary.Creator.ID == agent { + return rule, "", "", "", nil + } + case TriggerCompleted: project, served := policy.served(ev.BucketID) if served && project.WatchCompletions { @@ -588,15 +639,39 @@ func (a *decision) authorTrusted(ctx context.Context, ev Event, summary *basecam return "", StateDiscarded, ReasonUntrustedAuthor, nil } +// shape is how a recording is answered: a comment on the recording it hangs +// off, a chat line in its Campfire, anything else on itself. +type shape int + +const ( + shapeRecording shape = iota + shapeComment + shapeChatLine +) + +// shapeOf reads the shape from the event type, except for a boost, which +// takes the shape of the recording it is on: a boost on the agent's comment +// is answered in that comment's thread, and joins its conversation. +func shapeOf(eventType string, summary *basecamp.RecordingSummary) shape { + boost := eventType == eventBoostCreated + switch { + case eventType == "comment.created", boost && summary.Type == "Comment": + return shapeComment + case eventType == "chat.line.created", boost && strings.HasPrefix(summary.Type, "Chat::Lines::"): + return shapeChatLine + } + return shapeRecording +} + // address sets the conversation key and reply destination. Decide has // already refused a comment without its recording and a line without its // Campfire. -func (v *Verdict) address(summary *basecamp.RecordingSummary) { - switch v.EventType { - case "chat.line.created": +func (v *Verdict) address(summary *basecamp.RecordingSummary, shape shape) { + switch shape { + case shapeChatLine: v.ConversationKey = "campfire:" + strconv.FormatInt(summary.CampfireID, 10) v.Reply = &ReplyDestination{Kind: ReplyChatLine, RecordingID: summary.CampfireID} - case "comment.created": + case shapeComment: v.ConversationKey = "recording:" + strconv.FormatInt(summary.Parent.ID, 10) v.Reply = &ReplyDestination{Kind: ReplyComment, RecordingID: summary.Parent.ID} default: diff --git a/internal/connector/agent_caps_test.go b/internal/connector/agent_caps_test.go index f7cc5b9d6..0cd4c167e 100644 --- a/internal/connector/agent_caps_test.go +++ b/internal/connector/agent_caps_test.go @@ -42,7 +42,7 @@ func newCapsFixture(t *testing.T, threadCap, dailyCap int) *capsFixture { AgentIDs: []int64{capsPeerAgent}, AgentThreadCap: threadCap, AgentDailyCap: dailyCap, }, Projects: map[int64]admission.Project{adapterBucketID: {}}, - }, admission.Reads{Summaries: f.reads, Subscriptions: f.reads, Assignments: f.reads}) + }, admission.Reads{Summaries: f.reads, Subscriptions: f.reads, Assignments: f.reads, Boosts: f.reads}) require.NoError(t, err) f.admitter = admitter f.committer = admission.NewCommitter(f.ledger.Admission()) diff --git a/internal/connector/ledger_admission_test.go b/internal/connector/ledger_admission_test.go index 84c60beec..5041efe75 100644 --- a/internal/connector/ledger_admission_test.go +++ b/internal/connector/ledger_admission_test.go @@ -649,7 +649,7 @@ func TestRunAdmissionDecidesWhatIntakeHandsOver(t *testing.T) { AgentID: adapterAgentID, Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, Projects: map[int64]admission.Project{adapterBucketID: {Class: "internal"}}, - }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}) + }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads, Boosts: reads}) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) @@ -699,11 +699,81 @@ func TestRunAdmissionDecidesWhatIntakeHandsOver(t *testing.T) { }, states, "the line reports what the ledger wrote") } +// A boost on the agent's comment, end to end: its details survive the ledger, +// admission reads the boost and the comment, and the session gets a boosted +// request whose content is the boost, answered in the comment's thread, that +// asks for no acknowledgement and is never the owner's word. +func TestABoostOnTheAgentsCommentIsHandedOffAsAReply(t *testing.T) { + ledger := newTestLedger(t) + queue, err := NewQueue(10, 100) + require.NoError(t, err) + var out bytes.Buffer + + const commentID, cardID, boostID int64 = 601, 600, 31337 + reads := &adapterReads{ + summaries: map[int64]*basecamp.RecordingSummary{commentID: { + ID: commentID, Status: "active", Type: "Comment", Title: "Shall I ship it?", + AppURL: "https://app.basecamp.com/2914079/buckets/48699913/recordings/601", + Bucket: &basecamp.Bucket{ID: adapterBucketID}, + Parent: &basecamp.Parent{ID: cardID}, + Creator: &basecamp.Person{ID: adapterAgentID}, + Content: "
Shall I ship it?
", + MentionedPersonIDs: []int64{}, + }}, + boosts: map[int64]*basecamp.Boost{boostID: { + ID: boostID, Content: "yes, go", CreatedAt: time.Date(2026, 9, 16, 10, 0, 5, 0, time.UTC), + Booster: &basecamp.Person{ID: adapterOperatorID, Name: "The Operator"}, + Recording: &basecamp.Parent{ID: commentID, Type: "Comment"}, + }}, + } + admitter, err := admission.NewAdmitter(admission.Policy{ + AgentID: adapterAgentID, + Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, + Projects: map[int64]admission.Project{adapterBucketID: {Class: "internal"}}, + }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads, Boosts: reads}) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + done <- RunAdmission(ctx, AdmissionOptions{Ledger: ledger, Queue: queue, Admitter: admitter, Workers: 1, Lines: ndjson.NewWriter(&bytes.Buffer{})}) + }() + ev := testEvent(1) + ev.EventType, ev.Kind, ev.Action, ev.RecordingID = "boost.created", "comment_boosted", "boosted", commentID + ev.Details = json.RawMessage(`{"boost_id":31337,"boosted_event_id":null,"boosted_event_type":null}`) + _, err = ledger.RecordSeen(ctx, ev, LanePoll) + require.NoError(t, err) + require.NoError(t, queue.Offer(ctx, ev.ID)) + require.Eventually(t, func() bool { return getRecord(t, ledger, ev.ID).State != StateSeen }, 5*time.Second, 10*time.Millisecond) + cancel() + require.NoError(t, <-done) + require.Equal(t, StateAdmitted, getRecord(t, ledger, ev.ID).State, "reason %q", getRecord(t, ledger, ev.ID).Reason) + + opts := handoffOptions(ledger, &out) + opts.AgentID = adapterAgentID + require.NoError(t, handOffReady(context.Background(), opts)) + lines := handedOffLines(t, &out) + require.Len(t, lines, 1) + line := lines[0] + assert.Equal(t, "boost.created", line.EventType) + assert.Equal(t, "boosted", line.Trigger) + assert.False(t, line.Acknowledge) + assert.False(t, line.Owner) + assert.Equal(t, "operator", line.Role) + assert.Equal(t, adapterOperatorID, line.RequesterID) + assert.Equal(t, "The Operator", line.RequesterName) + assert.Equal(t, "yes, go", line.Content) + assert.Equal(t, commentID, line.Recording.RecordingID) + assert.Equal(t, "Comment", line.Recording.Type) + assert.Equal(t, InstructionReply{Kind: "comment", RecordingID: cardID}, line.ReplyTo) +} + func TestRunAdmissionNeedsTheLedgerAndQueue(t *testing.T) { admitter, err := admission.NewAdmitter(admission.Policy{ AgentID: adapterAgentID, Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, - }, admission.Reads{Summaries: &adapterReads{}, Subscriptions: &adapterReads{}, Assignments: &adapterReads{}}) + }, admission.Reads{Summaries: &adapterReads{}, Subscriptions: &adapterReads{}, Assignments: &adapterReads{}, Boosts: &adapterReads{}}) require.NoError(t, err) queue, err := NewQueue(1, 1) require.NoError(t, err) @@ -725,6 +795,7 @@ func TestRunAdmissionNeedsTheLedgerAndQueue(t *testing.T) { // adapterReads answers admission's reads for the handoff test. type adapterReads struct { summaries map[int64]*basecamp.RecordingSummary + boosts map[int64]*basecamp.Boost } func (r *adapterReads) Summarize(_ context.Context, ref basecamp.RecordingRef) (*basecamp.RecordingSummary, error) { @@ -742,6 +813,15 @@ func (r *adapterReads) AddedPersonIDs(context.Context, int64, int64) ([]int64, b return nil, false, nil } +func (r *adapterReads) Get(_ context.Context, boostID int64) (*basecamp.Boost, error) { + b, ok := r.boosts[boostID] + if !ok { + return nil, &basecamp.Error{Code: basecamp.CodeNotFound, Message: "not found"} + } + copied := *b + return &copied, nil +} + // mentionMarkup is a mention attachment naming id only inside its sgid, the // way Basecamp renders one. func mentionMarkup(id int64) string { @@ -787,7 +867,7 @@ func TestAnUnreadableConfigHoldsItsRecordsUntilTheFileAndAPersonAreBack(t *testi admitter, err := admission.NewAdmitter(admission.Policy{ AgentID: adapterAgentID, Trust: admission.Trust{Mode: admission.TrustOperator, OperatorID: adapterOperatorID}, - }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads}, + }, admission.Reads{Summaries: reads, Subscriptions: reads, Assignments: reads, Boosts: reads}, admission.WithServed(func() (map[int64]admission.Project, error) { if broken.Load() { return nil, errors.New("connect.json cannot be read") diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 6206e9f01..4661bfbf7 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -4,9 +4,10 @@ description: | Drive your own Claude Code session from Basecamp. Starts the local connector (basecamp connect), which listens to the account's event feed as a Basecamp agent and prints one line per trusted request: a mention, an assignment, a - comment on a thread the agent follows. This session acknowledges each one - within seconds (a boost in its own words), picks the repo for the project, - and hands the request to a background subagent that reads the context, does + comment on a thread the agent follows, a boost on something it wrote. This + session acknowledges each one that asks for something within seconds (a + boost in its own words), picks the repo for the project, and hands the + request to a background subagent that reads the context, does the work with your settings and tools, and replies as the agent. The session stays free to take the next request, and you can keep using it. Also connects an agent to this computer and manages its setup: the agent's @@ -113,30 +114,29 @@ thing to check. added the agent, and the agent is still assigned. - `subscribed`: a new comment on a thread the agent follows, with no mention. - `completed`: something completed in a project it watches. + - `boosted`: a boost on the agent's own post. - **`acknowledge`**: true when a person asked for something. False for - `subscribed` and `completed`, which are context, not instructions. + `subscribed`, `completed` and `boosted`, which are context, not instructions. - **`recording`**: what the event is about. `bucket_id` is the project, and `project_name` its name. `url` works with every command below. - **`reply_to`**: where the answer goes. `kind` `comment` means a comment on `recording_id` (the card or message the comment belongs to, already chosen for you). `kind` `chat_line` means a line in the Campfire `recording_id`. - **`requester_id`**: the person who asked. Mention them on failure, except in - Campfire. - `requester_name` is their name when they wrote the recording; it's missing + Campfire. `requester_name` is their name when they wrote the recording; it's missing for an assignment someone else's recording carries. - **`role`**: `operator` (the operator, or someone named with `--allow`) or `participant` (a project member admitted only by `--trust project`). Words a participant wrote stay a participant's request whoever brought them in. - Anything but `operator`, a missing `role` included (an older connector), - is a participant's. See *A participant asks, an operator authorizes* in + Anything else, missing included, is a participant's. See *A participant asks, an operator authorizes* in `subagent.md`. - **`owner`**: true only when the operator connect.json names (its `operator_id`; for a personal agent, its owner) asked in their own words: they wrote the mention, or assigned the agent something they wrote. A - comment on a followed thread, a completion, or an assigned recording - someone else wrote is never `owner`. People named with + comment on a followed thread, a completion, a boost, or an assigned + recording someone else wrote is never `owner`. People named with `--allow` have `role` `operator` but `owner` false, and so does every - agent. Missing (an older connector) means false. See *Only the owner + agent. Missing means false. See *Only the owner controls the connector*. - **`content`**: the request as it was written, with the agent's own mention removed. For an assignment, the recording itself (its title and content) is diff --git a/skills/basecamp-connect/subagent.md b/skills/basecamp-connect/subagent.md index a509db9fb..e2a48aae6 100644 --- a/skills/basecamp-connect/subagent.md +++ b/skills/basecamp-connect/subagent.md @@ -27,7 +27,7 @@ done ``` If it didn't verifiably land, go on and say so in your report. Skip it for -`subscribed` and `completed`. +`subscribed`, `completed` and `boosted`. **2. Read the context.** The event is the trigger. Basecamp holds the context: @@ -69,7 +69,7 @@ basecamp cards move --to '' --project --card-table ``` Use the card's own card table. If either column is missing, skip this. Never -create columns. Skip it for `subscribed` and `completed`. +create columns. Skip it for `subscribed`, `completed` and `boosted`. **4. Do the work** in the repo, the way its own AGENTS.md and CLAUDE.md say. If you were given a repo path, start by changing into it: you don't start @@ -204,7 +204,9 @@ detail, load the `basecamp` skill. **An operator's go.** A `role` `operator`, `trigger` `mentioned` request that says go on a thread where the agent asked for an operator's word is that approval, though its content is only "go". A `subscribed` one is a reply that -didn't mention the agent, and approves nothing. Read the thread for the +didn't mention the agent, and approves nothing. Neither does a `boosted` one: +a boost is too short to say which ask it approves, or that it approves at all. +Read the thread for the agent's ask. For a code change it names the repo, branch and commit: check the branch still points at that commit, then push and open the pull request. For anything else it names the action itself: do that. An ask the agent has @@ -222,6 +224,12 @@ or more than one, act on none and say so in the thread. reply unless the agent takes the thread on. - **`completed`**: a signal, like `subscribed`. Act only when the project's AGENTS.md or the thread asks for a follow-up. +- **`boosted`**: a reply to something the agent wrote, like `subscribed`. + `recording` is the agent's own post; `content` is the boost itself, a few + characters, often an emoji; `requester_name` is the booster's. Read the + boosted post and its thread, and **default to silence**: a boost is thanks + or agreement far more often than a question. Reply only when the boost + plainly needs one. **Transient CLI failures.** With several subagents at once, the CLI occasionally fails with `Not authenticated for profile:` or `token refresh From 152c058d68929179f2bdf8ee674d0d1133b39d9c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 12 Oct 2026 09:16:24 -0700 Subject: [PATCH 2/3] connect: a boost.created without boosted_event_id is not a boost on the recording A missing key said nothing about where the boost landed, but decoded as null and passed as a boost on the recording itself. Require the key, and read only an explicit null as the recording. Also qualify the skill's summary: only requests that ask for something are acknowledged. --- internal/connector/admission/boost.go | 17 ++++++++++------- internal/connector/admission/boost_test.go | 4 +++- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/connector/admission/boost.go b/internal/connector/admission/boost.go index bf0d8bf76..68ca357b8 100644 --- a/internal/connector/admission/boost.go +++ b/internal/connector/admission/boost.go @@ -13,10 +13,11 @@ const eventBoostCreated = "boost.created" // boostDetails is the part of boost.created's details admission reads. The // feed publishes boosted_event_id as null for a boost on the recording itself -// and as the event's id for a boost on one of the recording's events. +// and as the event's id for a boost on one of the recording's events. It is +// kept raw so that a missing key, which says neither, is told from null. type boostDetails struct { - BoostID int64 `json:"boost_id"` - BoostedEventID *int64 `json:"boosted_event_id"` + BoostID int64 `json:"boost_id"` + BoostedEventID json.RawMessage `json:"boosted_event_id"` } // opens reports whether the gate left trigger open. @@ -38,14 +39,16 @@ func opens(gate GateResult, trigger Trigger) bool { // the agent's own comment, line or post. Whether the agent performed a given // event is not something this rule reads. // -// Details without a boost id are not a boost.created Basecamp writes, and are -// discarded as an invalid pointer: there is no boost to read. +// Details without a boost id, or without boosted_event_id, are not a +// boost.created Basecamp writes, and are discarded as an invalid pointer: +// there is no boost to read, or nothing says whether it landed on the +// recording. A missing key is never read as null. func boostOnRecording(ev Event) (int64, Reason) { var d boostDetails - if len(ev.Details) == 0 || json.Unmarshal(ev.Details, &d) != nil || d.BoostID <= 0 { + if len(ev.Details) == 0 || json.Unmarshal(ev.Details, &d) != nil || d.BoostID <= 0 || len(d.BoostedEventID) == 0 { return 0, ReasonInvalidPointer } - if d.BoostedEventID != nil { + if string(d.BoostedEventID) != "null" { return 0, ReasonNotAddressed } return d.BoostID, "" diff --git a/internal/connector/admission/boost_test.go b/internal/connector/admission/boost_test.go index a1d9b1f26..23d1d4922 100644 --- a/internal/connector/admission/boost_test.go +++ b/internal/connector/admission/boost_test.go @@ -267,7 +267,9 @@ func TestABoostSettledFromItsPointerCostsNoRead(t *testing.T) { for name, details := range map[string]json.RawMessage{ "without details": nil, "without a boost id": json.RawMessage(`{"boosted_event_id":null}`), - "unreadable details": json.RawMessage(`{"boost_id":"x"`), + // Not the same as null: nothing says the boost is on the recording. + "without boosted_event_id": json.RawMessage(`{"boost_id":31337}`), + "unreadable details": json.RawMessage(`{"boost_id":"x"`), } { t.Run(name, func(t *testing.T) { f := newFakeReads() From f1bbe9a8c4cf4568b6f10b93477cdb79750db133 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 12 Oct 2026 11:15:59 -0700 Subject: [PATCH 3/3] connect: refuse an Agent booster at the boost read The poll lane carries no actor type, so an Agent person the operator wrongly put in the allowlist passes the gate. The boost read names the booster's personable type; refuse an Agent there as agent_authored, as authorTrusted refuses an Agent author. --- internal/connector/admission/boost.go | 7 ++++++- internal/connector/admission/boost_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/connector/admission/boost.go b/internal/connector/admission/boost.go index 68ca357b8..707586a2e 100644 --- a/internal/connector/admission/boost.go +++ b/internal/connector/admission/boost.go @@ -61,7 +61,10 @@ func boostOnRecording(ev Event) (int64, Reason) { // The boost must be on the pointer's recording, and its booster must be the // performer the gate trusted: the boost is the words handed off, so whoever // wrote them is held to the trust the performer was. A read that answered -// without either is a read that failed. +// without either is a read that failed. An Agent booster is refused here as +// agent_authored: the poll lane carries no actor type, so an Agent person the +// operator wrongly put in the allowlist passes the gate, and this read is +// where it shows, as an author's type shows in authorTrusted. func (a *decision) readBoost(ctx context.Context, ev Event, boostID int64) (*basecamp.Boost, State, Reason, error) { var boost *basecamp.Boost err := a.retry(ctx, func() error { @@ -82,6 +85,8 @@ func (a *decision) readBoost(ctx context.Context, ev Event, boostID int64) (*bas return nil, StateBlocked, ReasonReadFailed, nil case boost.Booster.ID != ev.Performer(): return nil, StateDiscarded, ReasonUntrustedAuthor, nil + case boost.Booster.PersonableType == personableAgent: + return nil, StateDiscarded, ReasonAgentAuthored, nil } return boost, "", "", nil } diff --git a/internal/connector/admission/boost_test.go b/internal/connector/admission/boost_test.go index 23d1d4922..6158eb3af 100644 --- a/internal/connector/admission/boost_test.go +++ b/internal/connector/admission/boost_test.go @@ -335,3 +335,19 @@ func TestABoostOnATrashedPostIsStale(t *testing.T) { assert.Equal(t, StateDiscarded, v.State) assert.Equal(t, ReasonStale, v.Reason) } + +// On the poll lane no actor type shows an agent; an Agent person the operator +// wrongly allowlisted passes the gate, and the boost read is where it shows. +func TestAnAllowlistedAgentsBoostIsRefusedByTheBoostRead(t *testing.T) { + p := basePolicy() + p.Trust = Trust{Mode: TrustAllowlist, OperatorID: operatorID, AllowlistIDs: []int64{allowedID}} + f := newFakeReads() + boostOn(f, "Comment", agentID, allowedID) + f.boosts[boostID].Booster.PersonableType = personableAgent + + v := decide(t, newAdmitter(t, p, f), boostEvent(allowedID)) + assert.Equal(t, StateDiscarded, v.State) + assert.Equal(t, ReasonAgentAuthored, v.Reason) + assert.Nil(t, v.Snapshot) + assert.Zero(t, f.summaryCalls, "refused before the boosted recording is read") +}