merge: integrate feat/backlog-burndown into master
master had advanced 16 commits past feat/backlog-burndown's base, and the two branches independently built four of the same features. Resolved 26 conflicts. Overlap features — kept master's implementation (more complete / production-wired / more robust), dropped the feature branch's parallel constellation: - llama-server health probe: kept master's event-store-backed tps probe; dropped the branch's LlamaLivenessClient (liveness-only, throughput unwired). - event-store probe: kept master's EventStoreHealthProbe; dropped EventStoreLatencyProbe. - brief echo-back gate: kept master's BriefEchoDiff (Jaccard, tolerates rewording); dropped the branch's exact-set-diff BriefEchoComparator/Extractor. - static-first reviewer: kept master's command/exit-code gate (ProcessStaticAnalysisRunner, wired); dropped the branch's structured-finding static_check stage (no-op seam). Its structured-findings model is filed as a follow-up in BACKLOG. Feature-branch net-new work brought in and kept (master had none): - native task tracking (aggregate, agent tools wired into analyst/implementer/reviewer, dependency graph + gates, decompose, REST/CLI, TUI task board) - critique-outcome producer (role-rel §6 — master had deferred it) - stage-level plan checkpointing (C-A2, folded into runPostStageGates) - CLAUDE.md/AGENTS.md L0 standing context - cross-session grants + TUI (grant scopes/revoke, @ picker, session resume browser) Verified: full Gradle compile (all modules + tests) green; tests pass for core:events, core:kernel, infrastructure:workflow, apps:server, apps:cli, testing:integration; tui-go go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,8 +22,11 @@ func containsFold(haystack, needle string) bool {
|
||||
return strings.Contains(strings.ToLower(haystack), strings.ToLower(needle))
|
||||
}
|
||||
|
||||
// formatTime renders an event/activity instant as a local wall-clock time, matching the
|
||||
// operator's clock (and the local date shown in the session list). It was previously forced
|
||||
// to UTC, so every event timestamp read hours off the user's actual time.
|
||||
func formatTime(epochMillis int64) string {
|
||||
return time.UnixMilli(epochMillis).UTC().Format("15:04:05")
|
||||
return time.UnixMilli(epochMillis).Format("15:04:05")
|
||||
}
|
||||
|
||||
// inferCategory maps an event type string to a display category, matching the
|
||||
@@ -125,7 +128,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
case protocol.TypeSessionResumed:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Status = "ACTIVE"
|
||||
s.Pending = nil
|
||||
s.clearApprovals()
|
||||
s.LastEventAt = nowMillis()
|
||||
}
|
||||
case protocol.TypeSessionCompleted:
|
||||
@@ -134,6 +137,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.Active = false
|
||||
s.Clar = nil
|
||||
s.Propose = nil
|
||||
s.clearApprovals()
|
||||
}
|
||||
case protocol.TypeSessionFailed:
|
||||
m.touch(msg.SessionID, "FAILED")
|
||||
@@ -141,6 +145,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.Active = false
|
||||
s.Clar = nil
|
||||
s.Propose = nil
|
||||
s.clearApprovals()
|
||||
}
|
||||
case protocol.TypeStageStarted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
@@ -196,6 +201,8 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
}
|
||||
s.LastEventAt = nowMillis()
|
||||
}
|
||||
// Tool-start is not surfaced inline (it doubles up with the ✓/✎ result row); it
|
||||
// stays in the tool list + EVENTS panel.
|
||||
case protocol.TypeToolCompleted:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Active = false
|
||||
@@ -204,7 +211,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.addEvent(msg.OccurredAt, "ToolCompleted", msg.ToolName)
|
||||
}
|
||||
if msg.Diff != nil && *msg.Diff != "" {
|
||||
// A diff = a write/edit: name the file + the line delta, then keep the
|
||||
// existing collapsed diff row (^x opens the full diff).
|
||||
path, add, del := diffSummary(*msg.Diff)
|
||||
m.appendAction(msg.SessionID, "✎", "wrote "+path+countSuffix(add, del))
|
||||
m.appendRouter(msg.SessionID, RouterEntry{Role: "tool", Content: *msg.Diff})
|
||||
} else {
|
||||
m.appendAction(msg.SessionID, "✓", actionToolText(msg.ToolName, msg.Summary))
|
||||
}
|
||||
case protocol.TypeToolAssessed:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
@@ -222,11 +235,13 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
s.markTool(msg.ToolName, ToolFailed)
|
||||
s.LastEventAt = msg.OccurredAt
|
||||
}
|
||||
m.appendAction(msg.SessionID, "✗", actionToolText(msg.ToolName+" failed", msg.Reason))
|
||||
case protocol.TypeToolRejected:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.markTool(msg.ToolName, ToolRejected)
|
||||
s.LastEventAt = nowMillis()
|
||||
}
|
||||
m.appendAction(msg.SessionID, "✕", actionToolText(msg.ToolName+" blocked", msg.Reason))
|
||||
case protocol.TypeArtifactCreated:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.addEvent(nowMillis(), "ArtifactCreated", msg.ArtifactID)
|
||||
@@ -252,13 +267,25 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
m.onWorkflowProposed(msg)
|
||||
case protocol.TypeApprovalResolved:
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Pending = nil
|
||||
// The resolved gate names no tool on the wire; recover it from the pending
|
||||
// queue before dropping the gate, for the inline row.
|
||||
tool := ""
|
||||
for _, a := range s.PendingQueue {
|
||||
if a.RequestID == msg.RequestID {
|
||||
tool = a.ToolName
|
||||
break
|
||||
}
|
||||
}
|
||||
// Drop just the resolved gate; any others stay queued and the band
|
||||
// advances to the next rather than vanishing entirely.
|
||||
s.removeApproval(msg.RequestID)
|
||||
detail := msg.Outcome
|
||||
if msg.Reason != "" {
|
||||
detail += " — " + msg.Reason
|
||||
}
|
||||
s.addEvent(msg.OccurredAt, "ApprovalResolved", detail)
|
||||
s.LastEventAt = nowMillis()
|
||||
m.appendAction(msg.SessionID, approvalIcon(msg.Outcome), approvalActionText(msg.Outcome, tool, msg.Reason))
|
||||
}
|
||||
case protocol.TypeSessionSnapshot:
|
||||
m.onSnapshot(msg)
|
||||
@@ -325,6 +352,19 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
|
||||
case protocol.TypeHealthChecks:
|
||||
m.health = msg.Health
|
||||
m.healthLoading = false
|
||||
case protocol.TypeFileList:
|
||||
m.files = msg.Paths
|
||||
m.filesFor = msg.SessionID
|
||||
m.filesLoading = false
|
||||
if m.fileIndex >= len(m.filteredFiles()) {
|
||||
m.fileIndex = 0
|
||||
}
|
||||
case protocol.TypeGrantList:
|
||||
m.grants = msg.Grants
|
||||
m.grantsLoading = false
|
||||
if m.grantIndex >= len(m.grants) {
|
||||
m.grantIndex = 0
|
||||
}
|
||||
case protocol.TypeConfigSnapshot:
|
||||
m.configFields = msg.ConfigFields
|
||||
m.configRestart = msg.ConfigRestartRequired
|
||||
@@ -370,13 +410,81 @@ func sessionIDOf(msg protocol.ServerMessage) string {
|
||||
protocol.TypeWorkflowList, protocol.TypeRouterResponse,
|
||||
protocol.TypeModelChanged, protocol.TypeModelList, protocol.TypeResourceStatus,
|
||||
protocol.TypeArtifactList, protocol.TypeConfigSnapshot, protocol.TypeSessionStats,
|
||||
protocol.TypeIdeaList, protocol.TypeHealthChecks:
|
||||
protocol.TypeIdeaList, protocol.TypeHealthChecks,
|
||||
protocol.TypeFileList, protocol.TypeGrantList:
|
||||
return ""
|
||||
default:
|
||||
return msg.SessionID
|
||||
}
|
||||
}
|
||||
|
||||
// --- inline action-row helpers (external-feedback events surfaced in OUTPUT) ---
|
||||
|
||||
// diffSummary extracts the written path and +/- line counts from a unified diff for the
|
||||
// inline write row. Falls back to "file" when no `+++` header is present.
|
||||
func diffSummary(diff string) (path string, added, removed int) {
|
||||
path = "file"
|
||||
for _, ln := range strings.Split(diff, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(ln, "+++ "):
|
||||
p := strings.TrimSpace(strings.TrimPrefix(ln, "+++ "))
|
||||
p = strings.TrimPrefix(p, "b/")
|
||||
if p != "" && p != "/dev/null" {
|
||||
path = p
|
||||
}
|
||||
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++ "):
|
||||
added++
|
||||
case strings.HasPrefix(ln, "-") && !strings.HasPrefix(ln, "--- "):
|
||||
removed++
|
||||
}
|
||||
}
|
||||
return path, added, removed
|
||||
}
|
||||
|
||||
// countSuffix renders the " (+a −b)" delta, or "" when nothing changed.
|
||||
func countSuffix(added, removed int) string {
|
||||
if added == 0 && removed == 0 {
|
||||
return ""
|
||||
}
|
||||
return " (+" + itoa(added) + " −" + itoa(removed) + ")"
|
||||
}
|
||||
|
||||
// actionToolText joins a tool label with a short, clipped result summary.
|
||||
func actionToolText(label, summary string) string {
|
||||
s := strings.TrimSpace(summary)
|
||||
if s == "" {
|
||||
return label
|
||||
}
|
||||
return label + " · " + clip(s, 48)
|
||||
}
|
||||
|
||||
func approvalIcon(outcome string) string {
|
||||
if outcome == "REJECTED" {
|
||||
return "✕"
|
||||
}
|
||||
return "⌘"
|
||||
}
|
||||
|
||||
// approvalActionText renders the inline approval row, noting an auto-approval that fired via a
|
||||
// standing grant (reason "grant:<id>").
|
||||
func approvalActionText(outcome, tool, reason string) string {
|
||||
verb := "approved"
|
||||
switch outcome {
|
||||
case "REJECTED":
|
||||
verb = "rejected"
|
||||
case "AUTO_APPROVED":
|
||||
verb = "auto-approved"
|
||||
}
|
||||
txt := verb
|
||||
if tool != "" {
|
||||
txt = verb + " " + tool
|
||||
}
|
||||
if strings.HasPrefix(reason, "grant:") {
|
||||
txt += " · via grant"
|
||||
}
|
||||
return txt
|
||||
}
|
||||
|
||||
// onSessionAnnounced fills in a session's workflow identity (the announce is the
|
||||
// only event carrying workflowId) and applies auto-focus. The session entry itself
|
||||
// was already created by the auto-vivify path in applyServer.
|
||||
@@ -414,7 +522,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
|
||||
Rationale: rationale,
|
||||
}
|
||||
if s := m.session(msg.SessionID); s != nil {
|
||||
s.Pending = info
|
||||
s.enqueueApproval(info)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,25 +564,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
|
||||
}
|
||||
|
||||
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
|
||||
var pending *Approval
|
||||
if len(msg.PendingAppr) > 0 {
|
||||
a := msg.PendingAppr[0]
|
||||
pending = &Approval{
|
||||
var queue []*Approval
|
||||
for _, a := range msg.PendingAppr {
|
||||
queue = append(queue, &Approval{
|
||||
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
|
||||
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
|
||||
}
|
||||
})
|
||||
}
|
||||
status := "running"
|
||||
if msg.State != nil {
|
||||
status = msg.State.Status
|
||||
}
|
||||
if pending != nil {
|
||||
if len(queue) > 0 {
|
||||
status = "PAUSED awaiting approval"
|
||||
}
|
||||
sess := Session{
|
||||
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
|
||||
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
|
||||
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
|
||||
}
|
||||
sess.syncPending()
|
||||
if msg.State != nil && msg.State.CurrentStageID != nil {
|
||||
sess.CurrentStage = *msg.State.CurrentStageID
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user