diff --git a/cmd/logos/session.go b/cmd/logos/session.go index 9ede5a2..c38db63 100644 --- a/cmd/logos/session.go +++ b/cmd/logos/session.go @@ -231,6 +231,14 @@ func runCheckpoint(args []string) error { // Before the commit, so the mark is in the file and not only on stdout. claimed, claimErr := claims.Check(ix.Vault, c.Project, c.Verified) c.Unseen = claimed.Unseen + // Before the commit: once this checkpoint is in the new folder, nothing + // finds the old one any more. Split covers a folder an auto record already + // took over, which nobody has been told about yet. The name is kept as + // given because Commit rewrites c.Project to its folder. + named, stranded := c.Project, session.Stranded(ix.Vault, c.Project) + if stranded == "" { + stranded = session.Split(ix.Vault, c.Project) + } if err := session.Commit(ix.DB, ix.Vault, c); err != nil { return err } @@ -238,6 +246,9 @@ func runCheckpoint(args []string) error { if said := secret.Summary(c.Redactions); said != "" { fmt.Println(said + ".") } + if stranded != "" { + fmt.Println(session.SplitNotice(named, stranded)) + } for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.CLI) { fmt.Println(said) } diff --git a/internal/contextpack/pack.go b/internal/contextpack/pack.go index a09184b..0dba1ea 100644 --- a/internal/contextpack/pack.go +++ b/internal/contextpack/pack.go @@ -117,6 +117,17 @@ type Pack struct { // passed over as empty or torn, so the render can say the checkpoint below // is the latest that could be read, not the latest written. Unread []string `json:"unread,omitempty"` + // Stranded is the folder this project's history was read from because it + // is still where logos 0.5.6 filed a name with a symbol in it (#368). The + // render names it and the rename that moves it: the folder may hold + // another project's history as well, which only the user can judge. + Stranded string `json:"stranded,omitempty"` + // Split is the folder the history was read from until the project's own + // folder got a checkpoint nobody announced — see session.Split. + Split string `json:"split,omitempty"` + // strandedFor is the scope Stranded and Split were found for, which is the + // project's own when a worktree inherited its history, not Continuity(). + strandedFor string // Drifted is where the tree stands now, filled in only when it is not where // the checkpoint below says it was. "Repository was: memory/bugfix · // fcd0331" is a claim about a moment that may be forty commits gone, and @@ -246,7 +257,7 @@ func (p *Pack) Empty() bool { // Not empty: a checkpoint was set aside, and a caller that prints // "nothing recorded" instead would hide that two repositories share // this name. - p.OtherRepo == "" && len(p.Unread) == 0 + p.OtherRepo == "" && len(p.Unread) == 0 && p.Stranded == "" && p.Split == "" } const ( @@ -324,7 +335,7 @@ func Build(ix *index.Index, embed *provider.Provider, embedModel string, req Req // the worktree that inherits it. An unreadable file is no verdicts: // the rulings are then handed over as they were recorded, which is // the state before anyone reviewed them, not a wrong one. - reviews, _ = session.Reviews(ix.Vault, scope) + reviews, _ = session.Reviews(ix.Vault, session.HistoryScope(ix.Vault, scope)) // Read before sameRepository filters: a newer checkpoint from another // repository is set aside on purpose and said by renderOtherRepo, not // a file that could not be read. @@ -334,6 +345,9 @@ func Build(ix *index.Index, embed *provider.Provider, embedModel string, req Req shown = &all[0] } p.Unread = session.SkippedAhead(ix.Vault, scope, shown) + p.Stranded = session.Stranded(ix.Vault, scope) + p.Split = session.Split(ix.Vault, scope) + p.strandedFor = scope } if err == nil && len(all) > 0 && req.Dir != "" { all, p.OtherRepo = sameRepository(all, req.Dir) diff --git a/internal/contextpack/relevance.go b/internal/contextpack/relevance.go index 61990c6..f23e79a 100644 --- a/internal/contextpack/relevance.go +++ b/internal/contextpack/relevance.go @@ -105,7 +105,7 @@ func (p *Pack) reviveRelevant(ix *index.Index, embed *provider.Provider, embedMo rendered[normalizeKey(f)] = true } } - prefix := key(session.CheckpointDir+"/"+session.SafeScope(p.historyScope)) + "/" + prefix := key(session.CheckpointDir+"/"+session.HistoryScope(ix.Vault, p.historyScope)) + "/" matched := map[string]bool{} for _, h := range lex { diff --git a/internal/contextpack/render.go b/internal/contextpack/render.go index 0885c21..bd0509e 100644 --- a/internal/contextpack/render.go +++ b/internal/contextpack/render.go @@ -58,6 +58,7 @@ func (p *Pack) Render() string { p.renderWindow(&b) p.renderOtherRepo(&b) p.renderUnread(&b) + p.renderStranded(&b) if line := p.costLine(); line != "" { fmt.Fprintf(&b, "\n_%s_\n", line) } @@ -218,6 +219,17 @@ func (p *Pack) renderUnread(b *strings.Builder) { } } +// renderStranded says the history below came from the folder an older logos +// filed this name under, and how to move it. +func (p *Pack) renderStranded(b *strings.Builder) { + if p.Stranded != "" { + fmt.Fprintf(b, "\n_%s_\n", session.StrandedNotice(p.strandedFor, p.Stranded)) + } + if p.Split != "" { + fmt.Fprintf(b, "\n_%s_\n", session.SplitNotice(p.strandedFor, p.Split)) + } +} + // spendCheckpoint charges the checkpoint against its share and returns the body // to print. It is a single blob rather than a list, so it is trimmed rather than // truncated by item count. diff --git a/internal/deadend/atedit.go b/internal/deadend/atedit.go index d8290eb..49d34b8 100644 --- a/internal/deadend/atedit.go +++ b/internal/deadend/atedit.go @@ -131,7 +131,7 @@ func AtPath(db *sql.DB, vaultDir, project, rel string) ([]Ruling, error) { // every project with any checkpoint at all would report a phantom worktree // scope named "_convos". func scopesOf(vaultDir, project string) ([]string, error) { - project = session.SafeScope(project) + project = session.HistoryScope(vaultDir, project) root := filepath.Join(vaultDir, session.CheckpointDir, filepath.FromSlash(project)) entries, err := os.ReadDir(root) if err != nil { diff --git a/internal/mcpserver/continuity.go b/internal/mcpserver/continuity.go index 9c058f8..eca03b2 100644 --- a/internal/mcpserver/continuity.go +++ b/internal/mcpserver/continuity.go @@ -546,6 +546,14 @@ func (s *Session) checkpoint(args map[string]any, handoffTo string) (string, err // Before the commit, so the mark is in the file and not only in this reply. claimed, claimErr := claims.Check(s.vault, c.Project, c.Verified) c.Unseen = claimed.Unseen + // Before the commit: once this checkpoint is in the new folder, nothing + // finds the old one any more. Split covers a folder an auto record already + // took over, which nobody has been told about yet. The name is kept as + // given because Commit rewrites c.Project to its folder. + named, stranded := c.Project, session.Stranded(s.vault, c.Project) + if stranded == "" { + stranded = session.Split(s.vault, c.Project) + } if err := session.Commit(s.DB, s.vault, c); err != nil { return "", err } @@ -555,6 +563,9 @@ func (s *Session) checkpoint(args map[string]any, handoffTo string) (string, err if said := secret.Summary(c.Redactions); said != "" { msg += ". " + said + "." } + if stranded != "" { + msg += " " + session.SplitNotice(named, stranded) + } for _, said := range advice.Checkpoint(*c, earlier, dropped, advice.MCP) { msg += " " + said } diff --git a/internal/ops/ledger.go b/internal/ops/ledger.go index 31af793..1d4a14b 100644 --- a/internal/ops/ledger.go +++ b/internal/ops/ledger.go @@ -50,7 +50,7 @@ type Ledger struct { // structured field, quoted and cited, and what it adds around them says what // was recorded and when — history, not a rulebook. func WriteLedger(vaultDir, project, repoDir string) (Ledger, error) { - scope := session.SafeScope(project) + scope := session.HistoryScope(vaultDir, project) if scope == "" { return Ledger{}, fmt.Errorf("project name %q has no letters or digits to find its checkpoints by", project) } diff --git a/internal/ops/ledger_symbols_test.go b/internal/ops/ledger_symbols_test.go new file mode 100644 index 0000000..92a432e --- /dev/null +++ b/internal/ops/ledger_symbols_test.go @@ -0,0 +1,35 @@ +package ops + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/Coder8124/logos/internal/session" + _ "modernc.org/sqlite" +) + +// Resume reads a symbol-named project's pre-#368 history from the folder the +// old rule chose; built from SafeScope, the ledger read the new, empty one and +// showed nothing for the same project. +func TestTheLedgerReadsTheHistoryResumeShows(t *testing.T) { + dir := t.TempDir() + db, err := sql.Open("sqlite", filepath.Join(dir, "t.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := session.Init(db); err != nil { + t.Fatal(err) + } + if err := session.Commit(db, dir, &session.Checkpoint{Project: "c", Decisions: []string{"keep it, because"}}); err != nil { + t.Fatal(err) + } + l, err := WriteLedger(dir, "C++", "") + if err != nil { + t.Fatal(err) + } + if l.Checkpoints != 1 || l.InForce != 1 { + t.Errorf("ledger for C++ read %d checkpoints, %d decisions; want 1, 1", l.Checkpoints, l.InForce) + } +} diff --git a/internal/session/auto.go b/internal/session/auto.go index b9c5160..f3dd0d1 100644 --- a/internal/session/auto.go +++ b/internal/session/auto.go @@ -42,6 +42,10 @@ func WriteAuto(vaultDir string, c Checkpoint) (Checkpoint, error) { if safeScope(c.Project) == "" { return Checkpoint{}, fmt.Errorf("an auto checkpoint needs a project") } + // History is looked up under the name as given: once normalised, a name + // with a symbol no longer finds the folder 0.5.6 filed it under, and the + // record lost its link to the checkpoint before it. + asGiven := c.Project c.Project = safeScope(c.Project) if c.Agent == "" { c.Agent = "agent" @@ -69,7 +73,7 @@ func WriteAuto(vaultDir string, c Checkpoint) (Checkpoint, error) { var follows string if past { - history, err := History(vaultDir, c.Project, 0) + history, err := History(vaultDir, asGiven, 0) if err != nil { return Checkpoint{}, err } @@ -79,7 +83,7 @@ func WriteAuto(vaultDir string, c Checkpoint) (Checkpoint, error) { break } } - } else if prev, _ := Latest(vaultDir, c.Project); prev != nil { + } else if prev, _ := Latest(vaultDir, asGiven); prev != nil { follows = prev.Session } convo := convoFolder(c.HostSession) diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index 9126af2..f486431 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -160,7 +160,7 @@ const UnattachedFolder = "_unattached" // checkpoints from hosts without hooks both land there, together, rather // than each inventing a folder name nobody supplied. func convoFolder(hostSession string) string { - if s := safe(hostSession); s != "" { + if s := dropSymbols(hostSession); s != "" { return s } return UnattachedFolder @@ -522,7 +522,8 @@ func CheckpointEntries(dir string) ([]CheckpointEntry, error) { // History returns up to n checkpoints for a project, newest first. func History(vaultDir, project string, n int) ([]Checkpoint, error) { - dir := filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(safeScope(project))) + scope := historyScope(vaultDir, project) + dir := filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(scope)) entries, err := CheckpointEntries(dir) if err != nil { return nil, err @@ -549,7 +550,7 @@ func History(vaultDir, project string, n int) ([]Checkpoint, error) { if c.Empty() { continue } - c.Slug = filepath.ToSlash(filepath.Join(CheckpointDir, safeScope(project), strings.TrimSuffix(en.Rel, ".md"))) + c.Slug = filepath.ToSlash(filepath.Join(CheckpointDir, scope, strings.TrimSuffix(en.Rel, ".md"))) out = append(out, c) } return out, nil @@ -564,7 +565,8 @@ func History(vaultDir, project string, n int) ([]Checkpoint, error) { // history. But the reader is then told an older checkpoint is where work left // off, and a newer file was right there: that has to be said (#367). func SkippedAhead(vaultDir, project string, shown *Checkpoint) []string { - dir := filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(safeScope(project))) + scope := historyScope(vaultDir, project) + dir := filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(scope)) entries, err := CheckpointEntries(dir) if err != nil { return nil @@ -576,7 +578,7 @@ func SkippedAhead(vaultDir, project string, shown *Checkpoint) []string { var out []string for _, en := range entries { if en.Name > after { - out = append(out, filepath.ToSlash(filepath.Join(CheckpointDir, safeScope(project), en.Rel))) + out = append(out, filepath.ToSlash(filepath.Join(CheckpointDir, scope, en.Rel))) } } sort.Sort(sort.Reverse(sort.StringSlice(out))) diff --git a/internal/session/convos.go b/internal/session/convos.go index 6cb8241..319572d 100644 --- a/internal/session/convos.go +++ b/internal/session/convos.go @@ -52,7 +52,7 @@ type Conversation struct { // present unrelated work by different agents as one sitting, which is the // fabrication UnattachedFolder exists to avoid. func Conversations(vaultDir, projectName string) ([]Conversation, error) { - scope := safeScope(projectName) + scope := historyScope(vaultDir, projectName) if scope == "" { return nil, nil } @@ -149,7 +149,7 @@ func FindConversation(convs []Conversation, query string) (*Conversation, []Conv return nil, nil, fmt.Errorf("no conversation named") } for i := range convs { - if convs[i].ID == q || convs[i].ID == safe(q) { + if convs[i].ID == q || convs[i].ID == dropSymbols(q) { return &convs[i], nil, nil } } diff --git a/internal/session/notestore.go b/internal/session/notestore.go index 18b9ace..293ac29 100644 --- a/internal/session/notestore.go +++ b/internal/session/notestore.go @@ -195,7 +195,7 @@ func renderNotes(scope string, notes []Note) string { for _, n := range notes { // oneLine, because a note holding a newline would render as a bullet the // parser cannot read back — a lost note wearing the costume of a saved one. - fmt.Fprintf(&b, "- %s \n", oneLine(n.Text), n.TS, safe(n.Agent)) + fmt.Fprintf(&b, "- %s \n", oneLine(n.Text), n.TS, dropSymbols(n.Agent)) } return b.String() } diff --git a/internal/session/session.go b/internal/session/session.go index 72fc8a0..8cc7a5a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -22,6 +22,8 @@ package session import ( "database/sql" "fmt" + "os" + "path/filepath" "strings" "time" "unicode" @@ -119,7 +121,7 @@ func idFor(agent string, t time.Time) string { // that range started a session and then failed every checkpoint with "file // name too long". The frontmatter keeps the full name; only the filename is cut. func agentPart(agent string) string { - return capAt(safe(agent), maxNameLen-len("plan-20060102-150405-.md")) + return capAt(dropSymbols(agent), maxNameLen-len("plan-20060102-150405-.md")) } // Start opens a session. Agent defaults to "agent" rather than erroring: a @@ -442,7 +444,70 @@ func closeSessions(db *sql.DB, ids []string, slug string) error { // What is still stripped is what makes a path dangerous or ambiguous: // separators, dots, and control characters. `..` and `/` cannot survive, which // is what keeps a checkpoint inside the vault. +// +// Any other symbol is spelled out rather than dropped (#368). Dropping it made +// `C++`, `C#` and `C` one folder, and `a:b` one with `ab`, so resume handed one +// project another's stopping place as its own. The common ones read as words; +// the rest become their code point. Neither spelling contains a character this +// function would change again, because a scope is run through it more than once +// on the way to disk (Current, then Start) and a second pass that moved it +// would file the checkpoint where no lookup looks. +// +// A name made only of letters, digits and `- _ . space /` comes out exactly as +// it did before, so the only folders that move are the ones that were merged. +// Combining marks are still dropped as they were: keeping them would move the +// folder of every project named in Hindi or Arabic to fix a collision nobody +// has reported there. func safe(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + lastDash, afterWord := false, false + for _, r := range s { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + if afterWord { + b.WriteByte('-') + } + b.WriteRune(r) + lastDash, afterWord = false, false + case r == '-' || r == '_' || r == '/' || r == '.' || unicode.IsSpace(r): + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + lastDash = true + } + afterWord = false + case unicode.IsControl(r), unicode.IsMark(r): + default: + if !lastDash && b.Len() > 0 { + b.WriteByte('-') + } + b.WriteString(symbolWord(r)) + lastDash, afterWord = false, true + } + } + return capName(strings.Trim(b.String(), "-")) +} + +// symbolWord is how safe spells a symbol it used to drop. +func symbolWord(r rune) string { + switch r { + case '+': + return "plus" + case '#': + return "sharp" + case ':': + return "colon" + } + return fmt.Sprintf("u-%x", r) +} + +// dropSymbols is safe as logos 0.5.6 and earlier wrote it: symbols dropped. +// Two uses. It finds the folder that rule filed a project under, so history +// written before #368 is still read until it is moved. And it still names what +// is not a project — a host's session id, an agent in a filename — because +// those were never merged with each other, and spelling their symbols would +// only move a conversation's folder or an agent's files partway through. +func dropSymbols(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder lastDash := false @@ -508,13 +573,114 @@ func capAt(s string, n int) string { // different keys and a checkpoint never cleared its own notes. func SafeScope(s string) string { return safeScope(s) } -func safeScope(s string) string { +func safeScope(s string) string { return scopeWith(s, safe) } + +func legacyScope(s string) string { return scopeWith(s, dropSymbols) } + +func scopeWith(s string, seg func(string) string) string { parts := strings.Split(s, "/") kept := parts[:0] for _, p := range parts { - if p = safe(p); p != "" { + if p = seg(p); p != "" { kept = append(kept, p) } } return strings.Join(kept, "/") } + +// historyScope is the scope a project's history is read from: its own, or, +// while that holds no checkpoint, the folder 0.5.6 and earlier filed the same +// name under. Without the fallback an upgrade would show a project with a +// symbol in its name as having no history at all, which is the reading +// invariant 3 exists to prevent; with it, nothing moves until someone says so. +func historyScope(vaultDir, project string) string { + if old := Stranded(vaultDir, project); old != "" { + return old + } + return safeScope(project) +} + +// HistoryScope is historyScope for callers outside this package that read +// something filed beside a project's checkpoints — the verdicts in +// reviewed.md, the dead ends shown at edit time, the ledger. Built from +// SafeScope instead, they read the new, empty folder while resume shows the +// old one, and a dead end a person retired there came back as live. +func HistoryScope(vaultDir, project string) string { return historyScope(vaultDir, project) } + +// Stranded names the folder a project's history is still in when that is not +// the folder it is filed under now: the name has a symbol the old rule +// dropped, its own folder holds no checkpoint, and the old one does. Empty +// otherwise. +// +// The old folder may hold another project's history instead — that merge is +// #368 itself, and the checkpoints in it record only the folder name, so which +// project wrote them cannot be told — so this is said to the user with the +// rename that moves it, never acted on. +func Stranded(vaultDir, project string) string { + scope, old := safeScope(project), legacyScope(project) + if vaultDir == "" || old == "" || old == scope { + return "" + } + if hasCheckpoints(vaultDir, scope) || !hasCheckpoints(vaultDir, old) { + return "" + } + return old +} + +// Split names the old folder once the fallback has ended without an agent +// saying so: the project's own folder holds checkpoints now, but only auto +// records. The hook writes those at session end, where nothing it prints is +// read, so the first one quietly took every earlier checkpoint out of resume; +// resume says it instead until an agent checkpoints, which says it too. +func Split(vaultDir, project string) string { + scope, old := safeScope(project), legacyScope(project) + if vaultDir == "" || old == "" || old == scope || !hasCheckpoints(vaultDir, old) { + return "" + } + dir := filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(scope)) + entries, err := CheckpointEntries(dir) + if err != nil || len(entries) == 0 { + return "" + } + for _, en := range entries { + raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(en.Rel))) + if err != nil { + continue + } + if c := ParseCheckpoint(string(raw)); !c.Empty() && !c.Auto { + return "" + } + } + return old +} + +// StrandedNotice is the one line resume says while Stranded finds a folder. +// It does not call the history this project's: a new "C#" in a vault that has +// "C" finds C's folder the same way, and nothing in the files tells them apart. +func StrandedNotice(scope, old string) string { + return fmt.Sprintf("This history was read from %s/%s, where logos 0.5.6 and earlier filed every project whose name differs from %q only in symbols, so it may be another project's. "+ + "If it is this one's, `logos project rename %s --merge` moves it to %s/%s.", + CheckpointDir, old, scope, renameArgs(scope, old), CheckpointDir, safeScope(scope)) +} + +// SplitNotice is what checkpoint and resume say once a project's own folder +// has taken over from the one 0.5.6 filed it under: from here resume reads the +// new folder only, so the earlier history is no longer shown. +func SplitNotice(scope, old string) string { + return fmt.Sprintf("Resume now reads %s/%s only, so the earlier history in %s/%s is not shown; that folder holds every project whose name differs from %q only in symbols. "+ + "If it is this one's, `logos project rename %s --merge` brings it across.", + CheckpointDir, safeScope(scope), CheckpointDir, old, scope, renameArgs(scope, old)) +} + +// renameArgs is the rename for a scope's project. rename takes project names +// and refuses a path separator, and moving a project moves its worktrees, so a +// worktree scope names the project folders it lives in. +func renameArgs(scope, old string) string { + top := func(s string) string { return strings.SplitN(s, "/", 2)[0] } + return top(old) + " " + top(safeScope(scope)) +} + +func hasCheckpoints(vaultDir, scope string) bool { + entries, err := CheckpointEntries(filepath.Join(vaultDir, CheckpointDir, filepath.FromSlash(scope))) + return err == nil && len(entries) > 0 +} diff --git a/internal/session/symbol_names_test.go b/internal/session/symbol_names_test.go new file mode 100644 index 0000000..6a18e51 --- /dev/null +++ b/internal/session/symbol_names_test.go @@ -0,0 +1,180 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// #368: the old rule dropped every symbol, so C++, C# and C were one folder and +// resume gave each the others' stopping place as its own. +func TestProjectsWhoseNamesDifferOnlyInSymbolsKeepSeparateHistories(t *testing.T) { + db, dir := vaultDB(t) + for _, p := range []string{"C++", "C#", "C", "a:b", "ab"} { + if err := Commit(db, dir, &Checkpoint{Project: p, Next: "next for " + p}); err != nil { + t.Fatalf("checkpoint %q: %v", p, err) + } + } + for _, p := range []string{"C++", "C#", "C", "a:b", "ab"} { + hist, err := History(dir, p, 10) + if err != nil { + t.Fatal(err) + } + if len(hist) != 1 || hist[0].Next != "next for "+p { + var got []string + for _, c := range hist { + got = append(got, c.Next) + } + t.Errorf("history of %q = %q, want only its own checkpoint", p, got) + } + } +} + +func TestSymbolsAreSpelledAsWords(t *testing.T) { + for in, want := range map[string]string{ + "C++": "c-plus-plus", + "C#": "c-sharp", + "a:b": "a-colon-b", + "a: b": "a-colon-b", + "k@stre!": "k-u-40-stre-u-21", + "🚀": "u-1f680", + "F# / ML": "f-sharp-ml", + "x\x00y\ty": "xy-y", + } { + if got := safe(in); got != want { + t.Errorf("safe(%q) = %q, want %q", in, got, want) + } + } +} + +// A scope goes through safe more than once on its way to disk (Current, then +// Start). If a spelled name changed on the second pass, a checkpoint would be +// filed where no lookup looks. +func TestASpelledNameIsUnchangedBySpellingItAgain(t *testing.T) { + for _, in := range []string{"C++", "C#", "a:b", "k@stre!", "🚀", "Kestrel One/feature X", "プロジェクト"} { + once := safeScope(in) + if twice := safeScope(once); twice != once { + t.Errorf("safeScope(%q) = %q, but again gives %q", in, once, twice) + } + } +} + +// Only folders the old rule merged may move: a name of letters, digits and +// `- _ . space /` is filed exactly where it always was. +func TestNamesWithoutSymbolsKeepTheirFolder(t *testing.T) { + for _, in := range []string{"kestrel", "Kestrel One", "kestrel_one.v2", "kestrel/feature-x", "プロジェクト", "परियोजना", "проект 2"} { + if got, old := safeScope(in), legacyScope(in); got != old { + t.Errorf("safeScope(%q) = %q, but it was filed under %q", in, got, old) + } + } +} + +// History written before #368 sits in the folder the old rule chose. Until the +// project has a checkpoint of its own it is still read from there, and the +// first checkpoint into the new folder is the moment to say it stops being. +func TestHistoryFiledUnderTheOldNameIsReadUntilTheProjectHasItsOwn(t *testing.T) { + db, dir := vaultDB(t) + // What 0.5.6 wrote for "C++": the same files, under sessions/c. + if err := Commit(db, dir, &Checkpoint{Project: "c", Next: "old step"}); err != nil { + t.Fatal(err) + } + if got := Stranded(dir, "C++"); got != "c" { + t.Fatalf("Stranded = %q, want c", got) + } + hist, err := History(dir, "C++", 10) + if err != nil || len(hist) != 1 || hist[0].Next != "old step" { + t.Fatalf("C++ did not read its pre-#368 history: %+v %v", hist, err) + } + if !strings.HasPrefix(hist[0].Slug, CheckpointDir+"/c/") { + t.Errorf("slug %q does not name the folder it was read from", hist[0].Slug) + } + if !strings.Contains(StrandedNotice("C++", "c"), "logos project rename c c-plus-plus --merge") { + t.Errorf("notice does not name the rename: %s", StrandedNotice("C++", "c")) + } + if err := Commit(db, dir, &Checkpoint{Project: "C++", Next: "new step"}); err != nil { + t.Fatal(err) + } + if got := Stranded(dir, "C++"); got != "" { + t.Errorf("Stranded = %q after the project has its own folder", got) + } + // Plain names are never sent anywhere else. + if got := Stranded(dir, "c"); got != "" { + t.Errorf("Stranded(c) = %q", got) + } +} + +// The session-end hook writes an auto record, and nothing it prints is read. +// The first one in a project's new folder ended the fallback, so every earlier +// checkpoint left resume without a word; resume has to say it until an agent +// checkpoints, which says it too. +func TestAnAutoRecordDoesNotQuietlyEndTheEarlierHistory(t *testing.T) { + db, dir := vaultDB(t) + if err := Commit(db, dir, &Checkpoint{Project: "c", Next: "old step"}); err != nil { + t.Fatal(err) + } + if _, err := WriteAuto(dir, Checkpoint{Project: "C++", Task: "edited files"}); err != nil { + t.Fatal(err) + } + if got := Split(dir, "C++"); got != "c" { + t.Fatalf("Split = %q after an auto record took over, want c", got) + } + if err := Commit(db, dir, &Checkpoint{Project: "C++", Next: "new step"}); err != nil { + t.Fatal(err) + } + if got := Split(dir, "C++"); got != "" { + t.Errorf("Split = %q after an agent checkpointed, which says it itself", got) + } +} + +// Normalised before the lookup, "C++" became c-plus-plus and no longer found +// sessions/c, so the auto record lost its link to the checkpoint before it. +func TestAnAutoRecordFollowsTheCheckpointFiledUnderTheOldName(t *testing.T) { + db, dir := vaultDB(t) + prev := &Checkpoint{Project: "c", Next: "old step"} + if err := Commit(db, dir, prev); err != nil { + t.Fatal(err) + } + c, err := WriteAuto(dir, Checkpoint{Project: "C++", Task: "edited files"}) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(c.Slug)+".md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "pred: follows") { + t.Errorf("auto record follows nothing, though %s is before it:\n%s", prev.Slug, raw) + } +} + +// Nothing in the old folder says which project wrote it, so a new "C#" in a +// vault with "C" reads C's history the same way; the notice must not call it +// C#'s. +func TestTheNoticeDoesNotSayTheOldFolderIsThisProjects(t *testing.T) { + if n := StrandedNotice("C#", "c"); !strings.Contains(n, "may be another project's") { + t.Errorf("notice claims the history: %s", n) + } +} + +// rename takes project names and refuses a separator; a worktree is moved with +// its project. +func TestAWorktreeIsToldToRenameItsProject(t *testing.T) { + for _, n := range []string{StrandedNotice("C++/feat", "c/feat"), SplitNotice("C++/feat", "c/feat")} { + if !strings.Contains(n, "logos project rename c c-plus-plus --merge") { + t.Errorf("notice names a rename that is refused: %s", n) + } + } +} + +// Session ids and agent names were never merged with each other, so spelling +// their symbols only moved a conversation's folder, or an agent's files, +// partway through. +func TestHostSessionIdsAndAgentNamesKeepTheirFolder(t *testing.T) { + if got := convoFolder("abc:123"); got != "abc123" { + t.Errorf("convoFolder = %q, want abc123", got) + } + if got := agentPart("gpt-4o (copilot)"); got != "gpt-4o-copilot" { + t.Errorf("agentPart = %q, want gpt-4o-copilot", got) + } +} diff --git a/internal/session/transcript.go b/internal/session/transcript.go index f44cb36..5f53460 100644 --- a/internal/session/transcript.go +++ b/internal/session/transcript.go @@ -47,7 +47,7 @@ func IsTranscriptFile(name string) bool { // a project folder made up for an unattributed session is one nobody resuming // would look in. func TranscriptDir(vaultDir, project, hostSession string) (string, error) { - folder := safe(hostSession) + folder := dropSymbols(hostSession) if folder == "" { return "", fmt.Errorf("the transcript names no session id, so it has no conversation folder of its own") } @@ -73,7 +73,7 @@ func TranscriptDir(vaultDir, project, hostSession string) (string, error) { // a resolver this does not try to be, and a near match that served the wrong // conversation's text would be worse than a miss. func FindTranscript(vaultDir, hostSession string) string { - folder := safe(hostSession) + folder := dropSymbols(hostSession) if folder == "" { return "" }