diff --git a/modules/claude-code/cmd/claude-code/memory.go b/modules/claude-code/cmd/claude-code/memory.go index 85835c9f..3e570c25 100644 --- a/modules/claude-code/cmd/claude-code/memory.go +++ b/modules/claude-code/cmd/claude-code/memory.go @@ -49,7 +49,7 @@ const ( MemoryKeep = 30 * 24 * time.Hour // LookEvery is how often the memory folders are looked at. LookEvery = 10 * time.Second - // The two home kinds (ADR 0343 §2). + // The two home kinds (ADR 0343 §4). HomeOperator = "operator" HomeAgent = "agent" // stagePrefix names a file staged beside its target; a look never carries one. @@ -131,7 +131,7 @@ func HomeKind(p Paths) (string, string) { return HomeAgent, "" } return "", "the agent runs as an account of its own here, and the home it runs in is not that account's own: " + - "whose memory it is cannot be judged, so none is carried (novox/hq ADR 0343 §2)" + "whose memory it is cannot be judged, so none is carried (novox/hq ADR 0343 §4)" } // NewMemoryCarrier is the carrier for these paths. @@ -158,7 +158,7 @@ func digestOf(text []byte) string { return "sha256:" + hex.EncodeToString(sum[:]) } -// memorySecret are the shapes that are always a secret (ADR 0343 §1): a key block, a signed token, a known token +// memorySecret are the shapes that are always a secret (ADR 0343 §2): a key block, a signed token, a known token // prefix, a password in an address. Narrower than the mesh's general check on purpose: memory names commit hashes. var memorySecret = []*regexp.Regexp{ regexp.MustCompile(`-----BEGIN [A-Z ]+-----`), @@ -437,16 +437,21 @@ func (m *MemoryCarrier) warn(key, project, path string, nodes []string) { return } label := projectLabel(project) + headline := "Claude memory of " + label + " kept twice" + if len([]rune(headline)) > 60 { + headline = "Claude memory kept twice" + } + // The plain rule (ADR 0253 §3): no path, file name, time, "agent" or "session"; the verdict opens the explanation. err := m.Notify(map[string]any{ "key": noticeKey(key), "severity": "warning", "subject": "claude-code", "summary": fmt.Sprintf("memory of project %s changed on %s and kept twice", label, strings.Join(nodes, " and ")), - "headline": "Agent memory kept twice", - "explanation": fmt.Sprintf("A memory file of project %s was changed on %s before either change reached the other. "+ - "Both versions are now in the file on every node, the newer first; read it in a session and keep what is right.", + "headline": headline, + "explanation": fmt.Sprintf("Needs you: keep what is right in the memory file of project %s. It was changed on %s "+ + "before either change reached the other, so both versions are now in it on every machine, the newer first.", label, strings.Join(nodes, " and ")), - "resolved": fmt.Sprintf("the memory file of project %s was tidied", label), + "resolved": fmt.Sprintf("the memory file of project %s holds one version again", label), }) if err != nil && m.Say != nil { m.Say("memory: the operator could not be warned of the conflict in %s/%s: %v", project, path, err) @@ -504,7 +509,7 @@ func (m *MemoryCarrier) record(key string, v MemoryVersion) { m.rec.Files[key] = carried{Project: v.Project, Path: v.Path, Digest: v.Digest, Node: v.Node, Changed: v.Changed} } -// Look looks at the memory folders once and carries what changed (ADR 0343 §3, §5, §7, §8). +// Look looks at the memory folders once and carries what changed (ADR 0343 §5, §7, §9, §10). func (m *MemoryCarrier) Look() error { if m.Kind == "" { return nil @@ -515,14 +520,15 @@ func (m *MemoryCarrier) Look() error { files, folders, not := m.scan() m.notCarried = not var problems []error - // A key the state lost is carried again, never read as a removal (ADR 0343 §7): one listing per look. + // A key the state lost is carried again, never read as a removal (ADR 0343 §9): one listing per look. + // When the state does not answer, nothing is carried on this look: the next one tries again. + listed, err := m.Store.Keys() + if err != nil { + return fmt.Errorf("the memory state did not answer; nothing was carried on this look: %w", err) + } inState := map[string]bool{} - if listed, err := m.Store.Keys(); err == nil { - for _, k := range listed { - inState[k] = true - } - } else { - inState = nil + for _, k := range listed { + inState[k] = true } keys := make([]string, 0, len(files)) for k := range files { @@ -530,7 +536,7 @@ func (m *MemoryCarrier) Look() error { } sort.Strings(keys) for _, key := range keys { - if inState != nil && !inState[key] { + if !inState[key] { delete(m.rec.Files, key) } if err := m.lookAt(key, files[key]); err != nil { @@ -644,7 +650,7 @@ func (m *MemoryCarrier) removedHere(key string) error { // Take is one change of the `memory` state, as the watch hands it over. func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uint64) error { if m.Kind == "" || !strings.HasPrefix(key, m.Kind+".") || op != "put" { - // A deleted key removes nothing (ADR 0343 §7); another kind's key is never taken (§2). + // A deleted key removes nothing (ADR 0343 §9); another kind's key is never taken (§4). return nil } var v MemoryVersion @@ -672,7 +678,7 @@ func (m *MemoryCarrier) Take(key, op string, value json.RawMessage, revision uin return errors.Join(err, m.save()) } -// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §4, §5, §7). +// apply writes one incoming version, read at revision, into this node's folder (ADR 0343 §6, §7, §9). func (m *MemoryCarrier) apply(key string, v MemoryVersion, revision uint64) error { if v.Node == m.Node && !v.Removed { // This node's own version: what is here is either it, or newer, which the next look carries. diff --git a/modules/claude-code/cmd/claude-code/memory_test.go b/modules/claude-code/cmd/claude-code/memory_test.go index 37f217cb..56321b35 100644 --- a/modules/claude-code/cmd/claude-code/memory_test.go +++ b/modules/claude-code/cmd/claude-code/memory_test.go @@ -221,7 +221,7 @@ func carriedOnBoth(t *testing.T, state *memState, c *clock, a, b *memNode, file, } } -// §3, §4: a memory file written on one node appears unchanged on another, at any depth, and is carried once. +// §5, §6: a memory file written on one node appears unchanged on another, at any depth, and is carried once. func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) { state, c, g14, shanks := twoNodes(t) g14.write(t, project, "MEMORY.md", "- [state](state.md) — where things stand\n", c.now) @@ -261,7 +261,7 @@ func TestAMemoryFileWrittenOnOneNodeAppearsUnchangedOnAnother(t *testing.T) { } } -// §1: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are +// §1, §2: only the memory folders are carried; a symbolic link, an oversized file and a secret's shape are not, and are // listed with why. func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) { state, c, g14, shanks := twoNodes(t) @@ -317,7 +317,7 @@ func TestOnlyTheMemoryFoldersAreCarried(t *testing.T) { } } -// §2: the operator's homes and an agent account's homes never take each other's memory. +// §4: the operator's homes and an agent account's homes never take each other's memory. func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) { state, c := newMemState(), &clock{now: time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)} root := t.TempDir() @@ -353,7 +353,7 @@ func TestHomesOfTheTwoKindsNeverTakeEachOthersMemory(t *testing.T) { } } -// §5, §6: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with +// §7, §8: a file changed on two nodes since it was carried is kept twice in one file on both, the newer first, with // one warning; it clears once the file is tidied. func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T) { state, c, g14, shanks := twoNodes(t) @@ -384,10 +384,21 @@ func TestAFileChangedOnTwoNodesIsKeptTwiceTheNewerFirstWithAWarning(t *testing.T if len(notices) != 1 || notices[0]["severity"] != "warning" || notices[0]["clear"] != nil { t.Fatalf("not one warning: %v", notices) } - for _, field := range []string{"headline", "explanation", "summary"} { - if s, _ := notices[0][field].(string); strings.ContainsAny(s, "/~") || strings.Contains(s, ".md") { - t.Errorf("the warning's %s names a path: %q", field, s) + for _, field := range []string{"headline", "explanation", "summary", "resolved"} { + s, _ := notices[0][field].(string) + if s == "" || strings.ContainsAny(s, "/~:") && field != "explanation" || strings.Contains(s, ".md") { + t.Errorf("the warning's %s is empty or names a path: %q", field, s) } + // The plain rule (ADR 0253 §3): it never sends the operator elsewhere. + if field != "summary" && (strings.Contains(strings.ToLower(s), "agent") || strings.Contains(strings.ToLower(s), "session")) { + t.Errorf("the warning's %s sends the operator elsewhere: %q", field, s) + } + } + if e, _ := notices[0]["explanation"].(string); !strings.HasPrefix(e, "Needs you: ") { + t.Errorf("the explanation does not open with its verdict: %q", e) + } + if h, _ := notices[0]["headline"].(string); len([]rune(h)) > 60 { + t.Errorf("the headline is longer than sixty characters: %q", h) } all := append(g14.copies(t), shanks.copies(t)...) for _, v := range []string{"- base\n- learned on g14\n", "- base\n- learned on shanks\n"} { @@ -430,7 +441,7 @@ func TestMergeVersionsPutsTheNewerFirst(t *testing.T) { } } -// §7: a removal moves the other node's unchanged file into the copies. +// §9: a removal moves the other node's unchanged file into the copies. func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) { state, c, g14, shanks := twoNodes(t) carriedOnBoth(t, state, c, g14, shanks, "old.md", "an old note\n") @@ -457,7 +468,7 @@ func TestARemovalMovesTheUnchangedFileIntoTheCopies(t *testing.T) { } } -// §7: a change outlives a removal, whichever reaches the state first. +// §9: a change outlives a removal, whichever reaches the state first. func TestAChangeOutlivesARemoval(t *testing.T) { for _, removalFirst := range []bool{true, false} { state, c, g14, shanks := twoNodes(t) @@ -484,7 +495,7 @@ func TestAChangeOutlivesARemoval(t *testing.T) { } } -// §7: a missing folder, a state that lost its keys and a deleted key remove nothing. +// §9: a missing folder, a state that lost its keys and a deleted key remove nothing. func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) { state, c, g14, shanks := twoNodes(t) carriedOnBoth(t, state, c, g14, shanks, "MEMORY.md", "index\n") @@ -515,7 +526,7 @@ func TestAMissingFolderOrAnAbsentKeyRemovesNothing(t *testing.T) { } } -// §8: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other. +// §10: on a first carrying, two nodes' different versions are both kept, and each node's own files reach the other. func TestTheFirstCarryingKeepsEveryVersion(t *testing.T) { state, c, g14, shanks := twoNodes(t) g14.write(t, project, "MEMORY.md", "- laptop's index\n", c.now)