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:
2026-06-26 11:30:41 +00:00
259 changed files with 17681 additions and 674 deletions
+120 -12
View File
@@ -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
}