feat(guardrails): steering channel + shell-in-file rule + capability-gap detector

Bundles three operator-reliability guardrails (Vikunja #28/#29/#30) plus the
in-flight branch WIP they were built on top of (reasoning_content capture,
operator/project profile editor, write-jail workspaceRoot fix) — the tree is
interdependent (SessionOrchestrator references reasoningArtifactId from the WIP)
and does not compile as separable subsets, so it lands as one commit.

Guardrails:
- #28 mid-stage steering: ClientMessage.SteerSession -> GlobalStreamHandler ->
  orchestrator.submitSteering, reusing SteeringNoteAddedEvent + existing context
  fold (advisory, non-authoritative; invariants #3/#7). Closes the gap where
  steering typed off an approval gate was silently dropped.
- #29 shell-in-file guardrail: ShellInFileContentRule (core:toolintent) blocks a
  file_write whose content is a bare shell command (e.g. "mkdir -p ..."); FileWriteTool
  description now advertises auto-mkdir of parent dirs. Basename-allowlist so the
  extensionless case is caught; scripts/Makefiles/multiline exempt.
- #30 pt1 capability-gap detector: deterministic CapabilityGapDetector maps stage
  intent -> implied ToolCapability, compares to granted tools, emits advisory
  CapabilityGapDetectedEvent in FreestyleDriver.lockAndRun. Recorded, never fails
  the gate and never auto-grants (invariants #3/#4/#5). Reflection rung is pt2.

Verified: ./gradlew check green (whole tree).
This commit is contained in:
2026-07-07 13:27:59 +04:00
parent 879672a47d
commit 41ed6414c6
43 changed files with 1592 additions and 94 deletions
+62 -26
View File
@@ -64,6 +64,8 @@ const (
OverlayGrants
OverlayGrantScope
OverlayHelp
OverlayProjectProfile
OverlayOperatorProfile
)
// RouterEntry is one line in a session's conversation transcript.
@@ -109,6 +111,13 @@ type ManifestTool struct {
Tier int
}
// ProfileField is one editable row in the project/operator profile editors — a flat
// key/value text field, mirroring the config editor's STRING-field editing model.
type ProfileField struct {
Key string
Value string
}
// Approval is a pending approval gate for a session.
type Approval struct {
RequestID string
@@ -179,18 +188,20 @@ type Proposal struct {
// Session is the UI's view of one server session.
type Session struct {
ID string
Status string
WorkflowID string
Name string
LastEventAt int64
CurrentStage string
WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string
LastResponse string
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
Events []EventEntry
ID string
Status string
WorkflowID string
Name string
LastEventAt int64
CurrentStage string
WorkspaceRoot string // bound cwd, from session.workspace_bound
LastOutput string
LastResponse string
Tools []ToolRecord
ToolsByStage map[string][]ManifestTool
TokenBudgetByStage map[string]int // stage -> ceiling, from stage.tool_manifest
StageTokensUsed int // tokens used by the most recent inference in CurrentStage
Events []EventEntry
// Pending is the *current* approval gate shown in the band. It always mirrors
// PendingQueue[PendingIdx] (kept in sync by syncPending) so the render code can
// keep reading a single pointer while multiple gates queue up behind it.
@@ -301,6 +312,29 @@ type Model struct {
configRestart []string // keys from the last save that need a restart
configLoading bool
// project profile editor (OverlayProjectProfile) — populated by project_profile.snapshot;
// mirrors the config editor's field-list/staged-edit pattern. fields[0] is "about",
// fields[1] is "conventions" (semicolon-joined), the rest are one row per command.
projectProfileFields []ProfileField
projectProfileIndex int
projectProfileStaged map[string]string
projectProfileEditing bool
projectProfileEditBuf string
projectProfileError string
projectProfileLoading bool
// operator profile editor (OverlayOperatorProfile) — populated by operator_profile.snapshot.
// fields are "about", "approvalMode", "preferredModels" (comma-joined), "conventions"
// (semicolon-joined). proposedAdaptation is read-only (from ProfileAdaptationService).
operatorProfileFields []ProfileField
operatorProfileIndex int
operatorProfileStaged map[string]string
operatorProfileEditing bool
operatorProfileEditBuf string
operatorProfileError string
operatorProfileLoading bool
operatorProfileProposed string
// session stats (OverlayStats) — populated by the session.stats reply
stats *protocol.StatsDto
statsFor string // sessionId the current stats belong to
@@ -413,20 +447,22 @@ type Model struct {
// NewModel builds the initial idle state.
func NewModel(client *ws.Client) Model {
return Model{
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
client: client,
theme: NewTheme(SoftBlue),
wfIndex: -1,
inputMode: ModeRouter,
historyIndex: -1,
routerMessages: map[string][]RouterEntry{},
configStaged: map[string]string{},
projectProfileStaged: map[string]string{},
operatorProfileStaged: map[string]string{},
chatMode: ChatModeChat,
providerType: "LOCAL",
snapshotPhase: true,
eventStripShown: true,
transcriptSel: -1,
sbHidden: loadStatusbarHidden(),
actionsHidden: loadPrefs().InlineActionsHidden,
}
}
@@ -0,0 +1,199 @@
package app
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
const (
operatorProfileFieldAbout = "about"
operatorProfileFieldApprovalMode = "approvalMode"
operatorProfileFieldPreferredModels = "preferredModels"
operatorProfileFieldConventions = "conventions"
)
// openOperatorProfile opens the operator profile editor (home-scoped, not session-bound).
func (m *Model) openOperatorProfile() {
m.overlay = OverlayOperatorProfile
m.operatorProfileIndex = 0
m.operatorProfileEditing = false
m.operatorProfileEditBuf = ""
m.operatorProfileError = ""
m.operatorProfileStaged = map[string]string{}
m.operatorProfileLoading = true
m.client.Send(protocol.GetOperatorProfile())
}
// applyOperatorProfileSnapshot rebuilds the editable field list from an operator_profile.snapshot.
func (m *Model) applyOperatorProfileSnapshot(about, approvalMode string, preferredModels, conventions []string, proposed, errMsg string) {
m.operatorProfileLoading = false
m.operatorProfileError = errMsg
m.operatorProfileProposed = proposed
if errMsg != "" {
return
}
m.operatorProfileFields = []ProfileField{
{Key: operatorProfileFieldAbout, Value: about},
{Key: operatorProfileFieldApprovalMode, Value: approvalMode},
{Key: operatorProfileFieldPreferredModels, Value: strings.Join(preferredModels, ", ")},
{Key: operatorProfileFieldConventions, Value: strings.Join(conventions, "; ")},
}
m.operatorProfileStaged = map[string]string{}
}
// handleOperatorProfileKey owns every key while the operator profile overlay is open.
func (m Model) handleOperatorProfileKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.operatorProfileEditing {
switch k.Type {
case keyEsc:
m.operatorProfileEditing = false
m.operatorProfileEditBuf = ""
case keyEnter:
if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) {
m.operatorProfileStaged[m.operatorProfileFields[m.operatorProfileIndex].Key] = m.operatorProfileEditBuf
}
m.operatorProfileEditing = false
m.operatorProfileEditBuf = ""
case keyBackspace:
if n := len(m.operatorProfileEditBuf); n > 0 {
m.operatorProfileEditBuf = m.operatorProfileEditBuf[:n-1]
}
case keyRunes, keySpace:
m.operatorProfileEditBuf += string(k.Runes)
}
return m, nil
}
switch {
case k.Type == keyEsc || runeIs(k, "o"):
m.overlay = OverlayNone
case k.Type == keyUp || runeIs(k, "k"):
if m.operatorProfileIndex > 0 {
m.operatorProfileIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.operatorProfileIndex < len(m.operatorProfileFields)-1 {
m.operatorProfileIndex++
}
case k.Type == keyEnter || k.Type == keySpace:
if m.operatorProfileIndex >= 0 && m.operatorProfileIndex < len(m.operatorProfileFields) {
m.operatorProfileEditing = true
m.operatorProfileEditBuf = m.currentOperatorProfileValue(m.operatorProfileFields[m.operatorProfileIndex])
}
case runeIs(k, "s"):
if len(m.operatorProfileStaged) > 0 {
m = m.saveOperatorProfile()
}
}
return m, nil
}
// saveOperatorProfile merges staged edits into the field list and sends the whole profile back.
func (m Model) saveOperatorProfile() Model {
var about, approvalMode string
var preferredModels, conventions []string
for _, f := range m.operatorProfileFields {
v := m.currentOperatorProfileValue(f)
switch f.Key {
case operatorProfileFieldAbout:
about = v
case operatorProfileFieldApprovalMode:
approvalMode = v
case operatorProfileFieldPreferredModels:
preferredModels = splitTrimmed(v, ",")
case operatorProfileFieldConventions:
conventions = splitTrimmed(v, ";")
}
}
m.client.Send(protocol.UpdateOperatorProfile(about, approvalMode, preferredModels, conventions))
return m
}
func splitTrimmed(s, sep string) []string {
out := []string{}
for _, p := range strings.Split(s, sep) {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// currentOperatorProfileValue returns the staged edit for a field if present, else its server value.
func (m Model) currentOperatorProfileValue(f ProfileField) string {
if v, ok := m.operatorProfileStaged[f.Key]; ok {
return v
}
return f.Value
}
func (m Model) operatorProfileModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("operator profile"))
if n := len(m.operatorProfileStaged); n > 0 {
b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn))
}
b.WriteString("\n\n")
if m.operatorProfileLoading && len(m.operatorProfileFields) == 0 {
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if m.operatorProfileError != "" {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.operatorProfileError, w-8)) + "\n\n")
}
for i, f := range m.operatorProfileFields {
b.WriteString(m.operatorProfileRow(i, f) + "\n")
}
if m.operatorProfileProposed != "" {
b.WriteString("\n" + mbg(t, " proposed adaptation (review + merge by hand):", t.P.Warn) + "\n")
for _, ln := range wrapLine(m.operatorProfileProposed, w-6) {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Dim).Background(t.P.BgPanel).Render(" "+ln) + "\n")
}
}
b.WriteString("\n" + modalHints(t, [][2]string{
{"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"o/esc", "close"},
}))
return t.Overlay.Width(w).Render(b.String())
}
func (m Model) operatorProfileRow(i int, f ProfileField) string {
t := m.theme
_, staged := m.operatorProfileStaged[f.Key]
marker := mbg(t, " ", t.P.BgPanel)
keyFg := t.P.Fg
if i == m.operatorProfileIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
keyFg = t.P.FgStrong
}
var valStr string
if m.operatorProfileEditing && i == m.operatorProfileIndex {
caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.operatorProfileEditBuf) + caret
} else {
valFg := t.P.Accent2
if staged {
valFg = t.P.Warn
}
val := m.currentOperatorProfileValue(f)
if staged {
val = "*" + val
}
valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val)
}
return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr
}
+20 -3
View File
@@ -119,6 +119,10 @@ func (m Model) renderOverlay(base string) string {
return m.center(m.artifactsModal())
case OverlayConfig:
return m.center(m.configModal())
case OverlayProjectProfile:
return m.center(m.projectProfileModal())
case OverlayOperatorProfile:
return m.center(m.operatorProfileModal())
case OverlayStats:
return m.center(m.statsModal())
case OverlayIdeas:
@@ -160,7 +164,11 @@ func (m Model) approvalBandHeight() int {
if s := m.session(m.selectedID); s != nil && s.Pending != nil {
rows = m.approvalContentRows(s.Pending)
if n := len(s.Pending.Rationale); n > 0 {
rat = n + 1 // rationale lines + trailing blank
textW := m.width - 4 - 2 // box borders/padding + rationale marker
for _, r := range s.Pending.Rationale {
rat += len(wrapLine(r, textW))
}
rat++ // trailing blank
}
}
steer := 0
@@ -219,7 +227,10 @@ func (m Model) renderApprovalBand(h int) string {
innerH := h - 2
ratBlock := 0
if len(a.Rationale) > 0 {
ratBlock = len(a.Rationale) + 1 // rationale lines + trailing blank
for _, r := range a.Rationale {
ratBlock += len(wrapLine(r, textW-2))
}
ratBlock++ // trailing blank
}
steerBlock := 0
if m.steering || m.steerBuffer != "" {
@@ -233,7 +244,13 @@ func (m Model) renderApprovalBand(h int) string {
body = append(body, m.justify(leftHdr, rightHdr, textW, t.P.Bg), "")
for _, r := range a.Rationale {
marker := lipgloss.NewStyle().Foreground(t.P.Warn).Background(t.P.Bg).Render("▲ ")
body = append(body, marker+t.span(truncate(r, textW-2), t.P.Dim))
for i, ln := range wrapLine(r, textW-2) {
prefix := marker
if i > 0 {
prefix = " "
}
body = append(body, prefix+t.span(ln, t.P.Dim))
}
}
if len(a.Rationale) > 0 {
body = append(body, "")
@@ -0,0 +1,194 @@
package app
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/correx/tui-go/internal/protocol"
)
const (
projectProfileFieldAbout = "about"
projectProfileFieldConventions = "conventions"
)
// openProjectProfile opens the project profile editor for the selected session's bound workspace.
func (m *Model) openProjectProfile() {
s := m.session(m.selectedID)
if s == nil {
return
}
m.overlay = OverlayProjectProfile
m.projectProfileIndex = 0
m.projectProfileEditing = false
m.projectProfileEditBuf = ""
m.projectProfileError = ""
m.projectProfileStaged = map[string]string{}
m.projectProfileLoading = true
m.client.Send(protocol.GetProjectProfile(s.ID))
}
// applyProjectProfileSnapshot rebuilds the editable field list from a project_profile.snapshot.
func (m *Model) applyProjectProfileSnapshot(about string, conventions []string, commands map[string]string, errMsg string) {
m.projectProfileLoading = false
m.projectProfileError = errMsg
if errMsg != "" {
return
}
fields := []ProfileField{
{Key: projectProfileFieldAbout, Value: about},
{Key: projectProfileFieldConventions, Value: strings.Join(conventions, "; ")},
}
for k, v := range commands {
fields = append(fields, ProfileField{Key: k, Value: v})
}
m.projectProfileFields = fields
m.projectProfileStaged = map[string]string{}
}
// handleProjectProfileKey owns every key while the project profile overlay is open.
func (m Model) handleProjectProfileKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.projectProfileEditing {
switch k.Type {
case keyEsc:
m.projectProfileEditing = false
m.projectProfileEditBuf = ""
case keyEnter:
if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) {
m.projectProfileStaged[m.projectProfileFields[m.projectProfileIndex].Key] = m.projectProfileEditBuf
}
m.projectProfileEditing = false
m.projectProfileEditBuf = ""
case keyBackspace:
if n := len(m.projectProfileEditBuf); n > 0 {
m.projectProfileEditBuf = m.projectProfileEditBuf[:n-1]
}
case keyRunes, keySpace:
m.projectProfileEditBuf += string(k.Runes)
}
return m, nil
}
switch {
case k.Type == keyEsc || runeIs(k, "p"):
m.overlay = OverlayNone
case k.Type == keyUp || runeIs(k, "k"):
if m.projectProfileIndex > 0 {
m.projectProfileIndex--
}
case k.Type == keyDown || runeIs(k, "j"):
if m.projectProfileIndex < len(m.projectProfileFields)-1 {
m.projectProfileIndex++
}
case k.Type == keyEnter || k.Type == keySpace:
if m.projectProfileIndex >= 0 && m.projectProfileIndex < len(m.projectProfileFields) {
m.projectProfileEditing = true
m.projectProfileEditBuf = m.currentProjectProfileValue(m.projectProfileFields[m.projectProfileIndex])
}
case runeIs(k, "s"):
if len(m.projectProfileStaged) > 0 {
m = m.saveProjectProfile()
}
}
return m, nil
}
// saveProjectProfile merges staged edits into the field list and sends the whole profile back.
func (m Model) saveProjectProfile() Model {
s := m.session(m.selectedID)
if s == nil {
return m
}
about := ""
conventions := []string{}
commands := map[string]string{}
for _, f := range m.projectProfileFields {
v := m.currentProjectProfileValue(f)
switch f.Key {
case projectProfileFieldAbout:
about = v
case projectProfileFieldConventions:
for _, c := range strings.Split(v, ";") {
c = strings.TrimSpace(c)
if c != "" {
conventions = append(conventions, c)
}
}
default:
if v != "" {
commands[f.Key] = v
}
}
}
m.client.Send(protocol.UpdateProjectProfile(s.ID, about, conventions, commands))
return m
}
// currentProjectProfileValue returns the staged edit for a field if present, else its server value.
func (m Model) currentProjectProfileValue(f ProfileField) string {
if v, ok := m.projectProfileStaged[f.Key]; ok {
return v
}
return f.Value
}
func (m Model) projectProfileModal() string {
t := m.theme
w := m.modalWidth()
var b strings.Builder
b.WriteString(m.titleLine("project profile"))
if n := len(m.projectProfileStaged); n > 0 {
b.WriteString(mbg(t, " ("+itoa(n)+" unsaved)", t.P.Warn))
}
b.WriteString("\n\n")
if m.projectProfileLoading && len(m.projectProfileFields) == 0 {
b.WriteString(mbg(t, " loading…", t.P.Faint) + "\n")
b.WriteString("\n" + modalHints(t, [][2]string{{"esc", "close"}}))
return t.Overlay.Width(w).Render(b.String())
}
if m.projectProfileError != "" {
b.WriteString(lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.BgPanel).Render(" ▲ "+truncate(m.projectProfileError, w-8)) + "\n\n")
}
for i, f := range m.projectProfileFields {
b.WriteString(m.projectProfileRow(i, f) + "\n")
}
b.WriteString("\n" + modalHints(t, [][2]string{
{"↑↓", "select"}, {"enter", "edit"}, {"s", "save"}, {"p/esc", "close"},
}))
return t.Overlay.Width(w).Render(b.String())
}
func (m Model) projectProfileRow(i int, f ProfileField) string {
t := m.theme
_, staged := m.projectProfileStaged[f.Key]
marker := mbg(t, " ", t.P.BgPanel)
keyFg := t.P.Fg
if i == m.projectProfileIndex {
marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▸ ")
keyFg = t.P.FgStrong
}
var valStr string
if m.projectProfileEditing && i == m.projectProfileIndex {
caret := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.BgPanel).Render("▏")
valStr = lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.BgPanel).Render(m.projectProfileEditBuf) + caret
} else {
valFg := t.P.Accent2
if staged {
valFg = t.P.Warn
}
val := m.currentProjectProfileValue(f)
if staged {
val = "*" + val
}
valStr = lipgloss.NewStyle().Foreground(valFg).Background(t.P.BgPanel).Render(val)
}
return marker + lipgloss.NewStyle().Foreground(keyFg).Background(t.P.BgPanel).Render(padRaw(f.Key, 20)) + " " + valStr
}
+26 -1
View File
@@ -151,6 +151,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if s := m.session(msg.SessionID); s != nil {
s.CurrentStage = msg.StageID
s.Tools = nil
s.StageTokensUsed = 0
s.addEvent(msg.OccurredAt, "StageStarted", msg.StageID)
}
case protocol.TypeStageCompleted:
@@ -175,6 +176,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
if msg.Response != "" {
s.LastResponse = msg.Response
}
if msg.TotalTokens != nil {
s.StageTokensUsed = *msg.TotalTokens
}
s.addEvent(msg.OccurredAt, "InferenceCompleted", msg.StageID)
}
case protocol.TypeInferenceTimeout:
@@ -292,15 +296,36 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeStageManifest:
if s := m.session(msg.SessionID); s != nil {
byStage := map[string][]ManifestTool{}
budgetByStage := map[string]int{}
for _, st := range msg.Stages {
tools := make([]ManifestTool, 0, len(st.Tools))
for _, td := range st.Tools {
tools = append(tools, ManifestTool{Name: td.Name, Tier: td.Tier})
}
byStage[st.StageID] = tools
if st.TokenBudget != nil {
budgetByStage[st.StageID] = *st.TokenBudget
}
}
s.ToolsByStage = byStage
s.TokenBudgetByStage = budgetByStage
}
case protocol.TypeProjectProfile:
errMsg := ""
if msg.ConfigError != nil {
errMsg = *msg.ConfigError
}
m.applyProjectProfileSnapshot(msg.About, msg.Conventions, msg.Commands, errMsg)
case protocol.TypeOperatorProfile:
errMsg := ""
if msg.ConfigError != nil {
errMsg = *msg.ConfigError
}
proposed := ""
if msg.ProposedAdaptation != nil {
proposed = *msg.ProposedAdaptation
}
m.applyOperatorProfileSnapshot(msg.About, msg.ApprovalMode, msg.PreferredModels, msg.Conventions, proposed, errMsg)
case protocol.TypeProviderStatus:
m.currentModel = msg.ProviderID
if containsFold(msg.ProviderID, "llama") || containsFold(msg.ProviderID, "local") {
@@ -411,7 +436,7 @@ func sessionIDOf(msg protocol.ServerMessage) string {
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
protocol.TypeIdeaList, protocol.TypeHealthChecks,
protocol.TypeFileList, protocol.TypeGrantList:
protocol.TypeFileList, protocol.TypeGrantList, protocol.TypeOperatorProfile:
return ""
default:
return msg.SessionID
+11
View File
@@ -324,6 +324,10 @@ func (m Model) handleNormalKey(k keyMsg) (tea.Model, tea.Cmd) {
m.openGrants()
case "g":
m.openConfig()
case "P":
m.openProjectProfile()
case "O":
m.openOperatorProfile()
case "m":
m.openModelsOverlay()
case "w":
@@ -658,6 +662,13 @@ func (m Model) handleOverlayKey(k keyMsg) (tea.Model, tea.Cmd) {
if m.overlay == OverlayConfig {
return m.handleConfigKey(k)
}
// Project/operator profile overlays own all their keys, same reason as config.
if m.overlay == OverlayProjectProfile {
return m.handleProjectProfileKey(k)
}
if m.overlay == OverlayOperatorProfile {
return m.handleOperatorProfileKey(k)
}
// Sessions overlay owns all its keys: enter resumes (emits a cmd), so it can't
// share the generic no-cmd esc path below.
if m.overlay == OverlaySessions {
+5 -1
View File
@@ -182,7 +182,11 @@ func (m Model) renderStatus() string {
if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle {
parts = append(parts, span(s.Name, t.P.FgStrong))
if s.CurrentStage != "" && m.sbShow("stage") {
parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2))
stageLabel := "⟐ " + s.CurrentStage
if budget, ok := s.TokenBudgetByStage[s.CurrentStage]; ok && budget > 0 {
stageLabel += fmt.Sprintf(" (%d/%d)", s.StageTokensUsed, budget)
}
parts = append(parts, span(stageLabel, t.P.Accent2))
}
if s.Status != "" && m.sbShow("status") {
parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status)))
+46 -2
View File
@@ -53,6 +53,8 @@ const (
TypeRouterNarration = "router.narration"
TypeArtifactList = "artifact.list"
TypeConfigSnapshot = "config.snapshot"
TypeProjectProfile = "project_profile.snapshot"
TypeOperatorProfile = "operator_profile.snapshot"
TypeSessionStats = "session.stats"
TypeIdeaList = "idea.list"
TypeHealthChecks = "health.checks"
@@ -139,6 +141,15 @@ type ServerMessage struct {
ConfigRestartRequired []string `json:"restartRequired"`
ConfigError *string `json:"error"`
// project_profile.snapshot / operator_profile.snapshot (About/Conventions shared; Commands is
// project-only, ApprovalMode/PreferredModels/ProposedAdaptation are operator-only)
About string `json:"about"`
Conventions []string `json:"conventions"`
Commands map[string]string `json:"commands"`
ApprovalMode string `json:"approvalMode"`
PreferredModels []string `json:"preferredModels"`
ProposedAdaptation *string `json:"proposedAdaptation"`
// session.stats — derived metrics for a session (reply to GetSessionStats)
Stats *StatsDto `json:"stats"`
@@ -334,8 +345,9 @@ type ApprovalDto struct {
}
type StageToolDecl struct {
StageID string `json:"stageId"`
Tools []ToolDecl `json:"tools"`
StageID string `json:"stageId"`
Tools []ToolDecl `json:"tools"`
TokenBudget *int `json:"tokenBudget"`
}
type ToolDecl struct {
@@ -460,6 +472,38 @@ func UpdateConfig(patch map[string]string) []byte {
return encode("UpdateConfig", map[string]any{"patch": patch})
}
// GetProjectProfile requests the ProjectProfile bound to a session's workspace (replied to with
// a project_profile.snapshot).
func GetProjectProfile(sessionID string) []byte {
return encode("GetProjectProfile", map[string]any{"sessionId": sessionID})
}
// UpdateProjectProfile overwrites the ProjectProfile at a session's workspace root.
func UpdateProjectProfile(sessionID, about string, conventions []string, commands map[string]string) []byte {
return encode("UpdateProjectProfile", map[string]any{
"sessionId": sessionID,
"about": about,
"conventions": conventions,
"commands": commands,
})
}
// GetOperatorProfile requests the operator's OperatorProfile (replied to with an
// operator_profile.snapshot). Not session-scoped.
func GetOperatorProfile() []byte {
return encode("GetOperatorProfile", map[string]any{})
}
// UpdateOperatorProfile overwrites the OperatorProfile.
func UpdateOperatorProfile(about, approvalMode string, preferredModels, conventions []string) []byte {
return encode("UpdateOperatorProfile", map[string]any{
"about": about,
"approvalMode": approvalMode,
"preferredModels": preferredModels,
"conventions": conventions,
})
}
// ApprovalResponse answers an approval gate. decision is "APPROVE" or "REJECT".
func ApprovalResponse(requestID, decision string, steeringNote *string) []byte {
return encode("ApprovalResponse", map[string]any{