Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes are documented here. The format follows

## [Unreleased]

## [0.14.0] - 2026-11-06

### Added

- Topics can span repositories. Classifying a session now offers the topics of every project, so a
session in one repository joins work another started (an infrastructure change for a feature
tracked from an application repository) instead of opening a duplicate topic. The topic shows once,
under the project it started in, with "also in" naming the other projects, and its brief is
written from all of its sessions.
- The list of projects says when each was last active, such as "10 minutes ago", and is ordered by
it.

### Changed

- Topics say "last active 10 minutes ago" instead of counting whole days ("0 days").

## [0.13.0] - 2026-11-06

### Added
Expand Down Expand Up @@ -397,7 +413,8 @@ First public release.
- Single zero-dependency Go binary (around 20 MB RAM); Homebrew cask, release archives for macOS and
Linux, and `scripts/install.sh`.

[Unreleased]: https://github.com/MilosMosovsky/agentyard/compare/v0.13.0...HEAD
[Unreleased]: https://github.com/MilosMosovsky/agentyard/compare/v0.14.0...HEAD
[0.14.0]: https://github.com/MilosMosovsky/agentyard/compare/v0.13.0...v0.14.0
[0.13.0]: https://github.com/MilosMosovsky/agentyard/compare/v0.12.1...v0.13.0
[0.12.1]: https://github.com/MilosMosovsky/agentyard/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/MilosMosovsky/agentyard/compare/v0.11.1...v0.12.0
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,13 @@ repository is a project of its own), then by topic, then by session, with the ne
how long ago each session was touched. The first summary that sees a project asks the model for a
short name for it, such as "AI Adoption Onboarding" for a folder in `~/Documents`, and keeps that
name; the project's path shows beside it. To change a name, edit `names.json` in the summary folder. A topic is one piece of work, such as a feature or a
migration, and can span many sessions. Each topic opens with a brief written from all of its
migration, and can span many sessions and repositories: a session in one repository can join a topic
another started, such as a config change for a feature elsewhere, and the topic shows once, under the
project it started in, saying which other projects it is also in. Each topic opens with a brief written from all of its
sessions: what the work is about, where it stands, what is waiting on you, and what comes next.
A list of projects beside the summary (a **Jump to project** menu on a phone) shows how many topics
each has and how many sessions wait on you or are blocked, and takes you straight there. Open a
each has, when it was last active ("10 minutes ago") and how many sessions wait on you or are
blocked, and takes you straight there. Open a
topic to see its sessions. For each session:

- where it got to, what it is waiting on you for or blocked on, and what comes next;
Expand Down
25 changes: 21 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1225,18 +1225,34 @@ func byteSize(b uint64) string {
return "0"
}

func ago(t time.Time) string {
func ago(t time.Time) string { return relativeTime(t, false) }

// agoWords is ago spelled out, "10 minutes ago", for prose.
func agoWords(t time.Time) string { return relativeTime(t, true) }

func relativeTime(t time.Time, words bool) string {
if t.IsZero() {
return "—"
}
d := time.Since(t)
unit := func(n int, short, one, many string) string {
if !words {
return fmt.Sprintf("%d%s ago", n, short)
}
if n == 1 {
return "1 " + one + " ago"
}
return fmt.Sprintf("%d %s ago", n, many)
}
switch {
case words && d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
return unit(int(d.Minutes()), "m", "minute", "minutes")
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
return unit(int(d.Hours()), "h", "hour", "hours")
case d < 90*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
return unit(int(d.Hours()/24), "d", "day", "days")
}
return t.Format("Jan 2006")
}
Expand All @@ -1250,6 +1266,7 @@ var page = template.Must(template.New("page").Funcs(template.FuncMap{
"size": kbSize,
"bytes": byteSize,
"ago": ago,
"agoWords": agoWords,
"tilde": tilde,
"anchorOf": func(r *Repo) string { return anchor(r.Path) },
"base": filepath.Base,
Expand Down
4 changes: 2 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ func summaryRouteFixture(t *testing.T) (*summarizer, http.Handler, *summarySnaps
at := time.Date(2026, 11, 6, 7, 0, 0, 0, time.UTC)
wait := "Approve the migration"
snap := &summarySnapshot{At: at, Provider: "claude", Window: summaryWindow{Start: at.Add(-24 * time.Hour), End: at}, Projects: []*summarySnapshotProject{{Project: "private/repository", Topics: []*summarySnapshotTopic{
{summaryTopic: summaryTopic{Name: "Migration", Status: "Ready for review", FirstSeen: at.Add(-72 * time.Hour)}, CarriedOver: true, OpenDays: 3, IdleDays: 2, Sessions: []*summarySnapshotSession{{Tool: "codex", ID: "saved-session-id", Title: "Private migration title", Cwd: "/saved/worktree", Branch: "feature/migrate", PRURL: "https://github.com/private/repository/pull/9", PRNumber: 9, ResumeCmd: "saved exact resume command", Note: &summarySnapshotNote{State: "waiting_on_you", Progress: "<script>unsafe()</script>", Next: "Ship after approval", WaitsOn: &wait}, Subagents: []*summarySnapshotSession{{Tool: "claude", Title: "Reviewer", Progress: "Checked the migration"}}}}},
{summaryTopic: summaryTopic{Name: "Migration", Status: "Ready for review", FirstSeen: at.Add(-72 * time.Hour), LastActive: time.Now().Add(-49 * time.Hour)}, CarriedOver: true, OpenDays: 3, IdleDays: 2, Sessions: []*summarySnapshotSession{{Tool: "codex", ID: "saved-session-id", Title: "Private migration title", Cwd: "/saved/worktree", Branch: "feature/migrate", PRURL: "https://github.com/private/repository/pull/9", PRNumber: 9, ResumeCmd: "saved exact resume command", Note: &summarySnapshotNote{State: "waiting_on_you", Progress: "<script>unsafe()</script>", Next: "Ship after approval", WaitsOn: &wait}, Subagents: []*summarySnapshotSession{{Tool: "claude", Title: "Reviewer", Progress: "Checked the migration"}}}}},
{summaryTopic: summaryTopic{Name: "Hidden private topic"}, Hidden: true},
}, Unsorted: []*summarySnapshotSession{{Tool: "claude", Title: "Unreadable note title", NotSummarized: true, ResumeCmd: "original resume"}}}}}
s := &summarizer{enabled: true, provider: "claude", stateDir: t.TempDir(), now: func() time.Time { return at.Add(time.Hour) }}
Expand All @@ -797,7 +797,7 @@ func TestSummaryPageAndHistory(t *testing.T) {
}
s.runStatus = summaryRunStatus{Started: failed.At, LastError: failed.Error}
body := get(t, h, "GET", "/summary", local).Body.String()
for _, want := range []string{"Private migration title", "<template data-summary-detail>", "Ready for review", "Waiting on you", "Approve the migration", "Ship after approval", "Where it got to", "open since", "last active 2 days ago", "saved exact resume command", "feature/migrate", "PR #9", "Checked the migration", "Unsorted sessions", "Not summarized", "Unreadable note title", "Not logged in", " · Failed", "&lt;script&gt;unsafe()&lt;/script&gt;"} {
for _, want := range []string{"Private migration title", "<template data-summary-detail>", "Ready for review", "Waiting on you", "Approve the migration", "Ship after approval", "Where it got to", "open since", "last active <time", ">2 days ago</time>", "saved exact resume command", "feature/migrate", "PR #9", "Checked the migration", "Unsorted sessions", "Not summarized", "Unreadable note title", "Not logged in", " · Failed", "&lt;script&gt;unsafe()&lt;/script&gt;"} {
if !strings.Contains(body, want) {
t.Errorf("summary missing %q", want)
}
Expand Down
4 changes: 2 additions & 2 deletions page.html
Original file line number Diff line number Diff line change
Expand Up @@ -1485,7 +1485,7 @@
{{$topics := ""}}{{$waiting := ""}}{{$blocked := ""}}
{{range .Topics}}{{if not .Hidden}}{{$topics = printf "%s." $topics}}{{range .Sessions}}{{with summarySessionStatus .}}{{if eq .State "waiting_on_you"}}{{$waiting = printf "%s." $waiting}}{{else if eq .State "blocked"}}{{$blocked = printf "%s." $blocked}}{{end}}{{end}}{{end}}{{end}}{{end}}
{{range .Unsorted}}{{with summarySessionStatus .}}{{if eq .State "waiting_on_you"}}{{$waiting = printf "%s." $waiting}}{{else if eq .State "blocked"}}{{$blocked = printf "%s." $blocked}}{{end}}{{end}}{{end}}
<li><a href="#summary-project-{{printf "%x" .Project}}" data-summary-project-link><span class="summary-toc-name">{{or .Name .Project}}</span><span class="summary-toc-counts">{{counted (len $topics) "topic" "topics"}}{{if .Unsorted}}<span>· {{len .Unsorted}} unsorted</span>{{end}}</span>{{if or $waiting $blocked}}<span class="summary-toc-counts"><span class="sr-only">Sessions:</span>{{if $waiting}}<span class="summary-toc-attention tone-warn">{{len $waiting}} waiting<span class="sr-only"> on you</span></span>{{end}}{{if $blocked}}<span class="summary-toc-attention tone-bad">{{len $blocked}} blocked</span>{{end}}</span>{{end}}</a></li>
<li><a href="#summary-project-{{printf "%x" .Project}}" data-summary-project-link><span class="summary-toc-name">{{or .Name .Project}}</span><span class="summary-toc-counts">{{counted (len $topics) "topic" "topics"}}{{if .Unsorted}}<span>· {{len .Unsorted}} unsorted</span>{{end}}{{if not .LastActive.IsZero}}<span>· <time datetime="{{.LastActive.Format "2006-01-02T15:04:05Z07:00"}}" title="Last active {{.LastActive.Format "Jan 2, 15:04"}}">{{agoWords .LastActive}}</time></span>{{end}}</span>{{if or $waiting $blocked}}<span class="summary-toc-counts"><span class="sr-only">Sessions:</span>{{if $waiting}}<span class="summary-toc-attention tone-warn">{{len $waiting}} waiting<span class="sr-only"> on you</span></span>{{end}}{{if $blocked}}<span class="summary-toc-attention tone-bad">{{len $blocked}} blocked</span>{{end}}</span>{{end}}</a></li>
{{end}}{{end}}</ul>{{end}}
{{define "summary-topic-sessions"}}<span class="summary-topic-sessions">{{if .}}<span class="summary-topic-show">Show</span><span class="summary-topic-hide">Hide</span> {{counted (len .) "session" "sessions"}}{{else}}No sessions in this run{{end}}</span>{{end}}
{{define "summary"}}{{template "head" .}}
Expand All @@ -1512,7 +1512,7 @@ <h3 class="summary-topic-name">{{.Name}}</h3>
{{with .Status}}<p class="summary-topic-status"><span class="summary-topic-label">Status</span>{{.}}</p>{{end}}
{{with .WaitsOn}}<p class="summary-topic-waits tone-warn"><strong>{{template "icon" "clock"}}Waiting on you</strong><span>{{.}}</span></p>{{end}}
{{with .Next}}<p class="summary-topic-next"><span class="summary-topic-label">Next</span>{{.}}</p>{{end}}
<div class="summary-topic-meta">{{template "summary-topic-sessions" .Sessions}}{{if or .CarriedOver .IdleDays}}<span class="summary-topic-note">{{if .CarriedOver}}open since {{.FirstSeen.Format "Mon, Jan 2"}} · {{.OpenDays}} days{{end}}{{if .IdleDays}}{{if .CarriedOver}} · {{end}}last active {{.IdleDays}} {{if eq .IdleDays 1}}day{{else}}days{{end}} ago{{end}}</span>{{end}}</div>
<div class="summary-topic-meta">{{template "summary-topic-sessions" .Sessions}}<span class="summary-topic-note">{{if .CarriedOver}}open since {{.FirstSeen.Format "Mon, Jan 2"}} · {{end}}last active <time datetime="{{.LastActive.Format "2006-01-02T15:04:05Z07:00"}}" title="{{.LastActive.Format "Jan 2, 15:04"}}">{{agoWords .LastActive}}</time>{{with .AlsoIn}} · also in {{range $i, $p := .}}{{if $i}}, {{end}}{{or $p.Name $p.Project}}{{end}}{{end}}</span></div>
</div></summary>{{range .Sessions}}{{template "summary-session" .}}{{end}}</details>{{end}}{{end}}
{{if .Unsorted}}<details class="summary-topic" data-summary-topic-key="{{printf "%x" .Project}}-unsorted" open><summary><div class="summary-topic-heading"><h3 class="summary-topic-name">Unsorted sessions</h3><p class="summary-topic-status">Topic grouping could not be completed. Session notes are still available.</p><div class="summary-topic-meta">{{template "summary-topic-sessions" .Unsorted}}</div></div></summary>{{range .Unsorted}}{{template "summary-session" .}}{{end}}</details>{{end}}</section>{{end}}{{end}}
</div></div>{{else}}<div class="state-panel summary-empty">{{template "icon" "ledger"}}<div><h2>No sessions in this summary.</h2><p>No visible topics or sessions were saved for this window.</p></div></div>{{end}}
Expand Down
30 changes: 23 additions & 7 deletions summary.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ type summarySnapshot struct {
}

type summarySnapshotProject struct {
Project string `json:"project"` // key the topics are saved under
Name string `json:"name,omitempty"` // display name; absent before 0.13
Topics []*summarySnapshotTopic `json:"topics"`
Unsorted []*summarySnapshotSession `json:"unsorted"`
Project string `json:"project"` // key the topics are saved under
Name string `json:"name,omitempty"` // display name; absent before 0.13
// LastActive is the newest activity of its sessions and visible topics.
LastActive time.Time `json:"lastActive,omitzero"`
Topics []*summarySnapshotTopic `json:"topics"`
Unsorted []*summarySnapshotSession `json:"unsorted"`
}

func (p *summarySnapshotProject) sessions() []*summarySnapshotSession {
Expand All @@ -72,9 +74,15 @@ type summarySnapshotTopic struct {
OpenDays int `json:"openDays"`
IdleDays int `json:"idleDays"`
Hidden bool `json:"hidden"`
AlsoIn []summaryProjectRef `json:"alsoIn,omitempty"` // other projects its sessions ran in
Sessions []*summarySnapshotSession `json:"sessions"`
}

type summaryProjectRef struct {
Project string `json:"project"`
Name string `json:"name,omitempty"`
}

type summarySnapshotNote struct {
State string `json:"state"`
Progress string `json:"progress"`
Expand Down Expand Up @@ -381,11 +389,15 @@ func (s *summarizer) snapshotProjects(projects []*summaryTopicProject) []*summar
if s.prs != nil {
prs = s.prs()
}
names := summaryProjectNames(s.stateDir) // a project shown only in another's topic has no view of its own
result := []*summarySnapshotProject{}
for _, project := range projects {
view := &summarySnapshotProject{Project: project.Project, Name: project.Name, Topics: []*summarySnapshotTopic{}, Unsorted: []*summarySnapshotSession{}}
for _, topic := range project.Topics {
group := &summarySnapshotTopic{summaryTopic: *topic.summaryTopic, CarriedOver: topic.CarriedOver, OpenDays: topic.OpenDays, IdleDays: topic.IdleDays, Hidden: topic.Hidden, Sessions: []*summarySnapshotSession{}}
for _, other := range topic.AlsoIn {
group.AlsoIn = append(group.AlsoIn, summaryProjectRef{other, names[other]})
}
for _, noted := range topic.Sessions {
group.Sessions = append(group.Sessions, snapshotSummarySession(noted.Entry, noted.Note, wt, prs))
}
Expand Down Expand Up @@ -419,7 +431,6 @@ func sortSummaryProjects(projects []*summarySnapshotProject) {
}
sort.SliceStable(sessions, func(i, j int) bool { return sessions[i].Touched.After(sessions[j].Touched) })
}
touched := map[*summarySnapshotProject]time.Time{}
for _, p := range projects {
sortSessions(p.Unsorted)
latest := newest(p.Unsorted)
Expand All @@ -430,9 +441,14 @@ func sortSummaryProjects(projects []*summarySnapshotProject) {
}
}
sort.SliceStable(p.Topics, func(i, j int) bool { return newest(p.Topics[i].Sessions).After(newest(p.Topics[j].Sessions)) })
touched[p] = latest
for _, t := range p.Topics {
if !t.Hidden && t.LastActive.After(latest) {
latest = t.LastActive
}
}
p.LastActive = latest
}
sort.SliceStable(projects, func(i, j int) bool { return touched[projects[i]].After(touched[projects[j]]) })
sort.SliceStable(projects, func(i, j int) bool { return projects[i].LastActive.After(projects[j].LastActive) })
}

func snapshotSummarySession(entry *summarySession, note *summaryNote, wt *Snapshot, prs *PRSnapshot) *summarySnapshotSession {
Expand Down
6 changes: 3 additions & 3 deletions summary_corpus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,8 +365,8 @@ func TestSummaryCorpusLeavesOutScratchRuns(t *testing.T) {
return map[string]any{"type": "user", "cwd": cwd, "message": map[string]any{"content": "Work here"}}
}
summaryWrite(t, filepath.Join(claude, "peer.jsonl"), at, turn(filepath.Join(scratch, "xmodel-doc-peer-1")))
summaryWrite(t, filepath.Join(claude, "orca.jsonl"), at, turn(filepath.Join(home, "orca", "workspaces", "gto-brain", "portal-recovery")))
summaryWrite(t, filepath.Join(claude, "workspace.jsonl"), at, turn("/nowhere/gto-wizard-com"))
summaryWrite(t, filepath.Join(claude, "orca.jsonl"), at, turn(filepath.Join(home, "orca", "workspaces", "acme-portal", "login-fix")))
summaryWrite(t, filepath.Join(claude, "workspace.jsonl"), at, turn("/nowhere/acme"))
// A sub-agent working in a scratch folder still belongs to its parent's project.
summaryWrite(t, filepath.Join(claude, "workspace", "subagents", "agent-scratch.jsonl"), at,
map[string]any{"type": "user", "cwd": filepath.Join(scratch, "probe"), "isSidechain": true, "message": map[string]any{"content": "Probe"}})
Expand All @@ -380,7 +380,7 @@ func TestSummaryCorpusLeavesOutScratchRuns(t *testing.T) {
projects[child.Session.ID] = child.Project
}
}
want := map[string]string{"orca": "gto-brain", "workspace": "gto-wizard-com", "agent-scratch": "gto-wizard-com"}
want := map[string]string{"orca": "acme-portal", "workspace": "acme", "agent-scratch": "acme"}
if left != 2 || !reflect.DeepEqual(projects, want) {
t.Fatalf("left out %d, projects %v, want 2 and %v", left, projects, want)
}
Expand Down
22 changes: 14 additions & 8 deletions summary_names.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,7 @@ var summaryNameSchema = summaryObject(map[string]*summarySchema{"names": summary
// projects that have none yet. Naming is cosmetic: a failure leaves the key
// showing and never fails the run.
func summaryNameProjects(projects []*summaryTopicProject, stateDir string, call summaryCaller) {
names := map[string]string{}
if err := loadSummaryState(stateDir, "names.json", &names); err != nil {
log.Printf("summary: project names: %v", err)
names = map[string]string{}
}
if names == nil {
names = map[string]string{}
}
names := summaryProjectNames(stateDir)
var unnamed []*summaryTopicProject
for _, project := range projects {
if names[project.Project] == "" {
Expand Down Expand Up @@ -93,6 +86,19 @@ func summaryNameProjects(projects []*summaryTopicProject, stateDir string, call
}
}

// summaryProjectNames is every saved project name by project key.
func summaryProjectNames(stateDir string) map[string]string {
names := map[string]string{}
if err := loadSummaryState(stateDir, "names.json", &names); err != nil {
log.Printf("summary: project names: %v", err)
return map[string]string{}
}
if names == nil {
names = map[string]string{}
}
return names
}

func summaryNamesPrompt(batch []*summaryTopicProject, names map[string]string) string {
type topic struct {
Name string `json:"name"`
Expand Down
Loading
Loading