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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ CLI for installing, updating, validating, and diagnosing Memory Bank templates
`template/` directory as canonical payload. `template/memory-bank/**` installs
to `memory-bank/**`; every other path retains its repository-relative suffix.
Dotfiles and executable files are included, while symlinks are rejected.
Existing locks from the legacy payload roots are migrated conservatively:
unchanged files adopt canonical ownership, while local customization is
preserved for explicit resolution.

## Publish managed changes upstream

Expand All @@ -14,7 +17,11 @@ From a downstream Git repository with a clean upstream checkout at `memory-bank/
memory-bank-cli push --dry-run
```

Without `--dry-run`, `push` creates a fresh upstream branch, commits only changed `managed` Memory Bank paths, pushes it and creates a GitHub PR. It never pushes the upstream default branch directly. Non-managed paths, including project artifacts, lock/state and `.repo`, are reported as exclusions.
Without `--dry-run`, `push` creates a fresh upstream branch, publishes every
changed path recorded as `managed` in the ownership lock back below
`template/`, pushes the branch and creates a GitHub PR. It never pushes the
upstream default branch directly. Non-managed paths, including project
artifacts, lock/state and `.repo`, are reported as exclusions.

## Install

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func printRootUsage(writer io.Writer) {
fmt.Fprintln(writer, " doctor Diagnose adoption, governance, managed drift, and navigation")
fmt.Fprintln(writer, " lint Audit markdown navigation integrity")
fmt.Fprintln(writer, " github Install or update the optional GitHub workflow adapter")
fmt.Fprintln(writer, " push Publish managed Memory Bank changes upstream through a PR")
fmt.Fprintln(writer, " push Publish locked canonical template changes upstream through a PR")
fmt.Fprintln(writer)
fmt.Fprintln(writer, "Options:")
fmt.Fprintln(writer, " --help Show this help")
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestRootHelpAndVersion(t *testing.T) {
if !strings.Contains(stdout.String(), test.want) {
t.Fatalf("unexpected stdout for %v: %q", test.arguments, stdout.String())
}
if test.arguments[0] == "--help" && !strings.Contains(stdout.String(), "push Publish managed Memory Bank changes upstream through a PR") {
if test.arguments[0] == "--help" && !strings.Contains(stdout.String(), "push Publish locked canonical template changes upstream through a PR") {
t.Fatalf("root help does not document push: %q", stdout.String())
}
}
Expand Down
12 changes: 9 additions & 3 deletions internal/doctor/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ var shellErrexitPattern = regexp.MustCompile(`(?:^|\s)-[A-Za-z]*e[A-Za-z]*(?:\s|
var shellPipefailPattern = regexp.MustCompile(`(?:^|\s)-o\s+pipefail(?:\s|$)|(?:^|\s)pipefail(?:\s|$)`)
var workflowFalseExpressionPattern = regexp.MustCompile(`^\$\{\{\s*false\s*\}\}$`)

const templatePayloadRoot = "template/memory-bank"
const templateScopeRoot = "template/memory-bank"

func NormalizeProfile(value string) (Profile, error) {
profile := Profile(strings.ToLower(strings.TrimSpace(value)))
Expand All @@ -53,7 +53,7 @@ func Run(options Options) (Report, error) {
scopeRoot := options.ScopeRoot
if scopeRoot == "" {
if profile == ProfileTemplate {
scopeRoot = templatePayloadRoot
scopeRoot = templateScopeRoot
} else {
scopeRoot = "memory-bank"
}
Expand Down Expand Up @@ -101,7 +101,7 @@ func detectProfile(repoRoot string) Profile {
if _, err := os.Lstat(filepath.Join(repoRoot, ownership.LockFileName)); err == nil {
return ProfileDownstream
}
info, err := os.Lstat(filepath.Join(repoRoot, templatePayloadRoot))
info, err := os.Lstat(filepath.Join(repoRoot, ownership.CanonicalTemplateRoot))
if err != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return ProfileDownstream
}
Expand All @@ -112,18 +112,24 @@ func (report *Report) add(finding Finding) { report.Findings = append(report.Fin

func (report *Report) checkIdentityAndDrift(agentFile, scopeRoot string) {
lock, exists, lockErr := ownership.ReadLock(report.RepoRoot)
templateOwnsAgentFile := false
if lockErr != nil {
report.add(Finding{Code: "manifest.invalid", Severity: Error, Group: "manifest", Path: ownership.LockFileName, Message: lockErr.Error(), Remediation: "Repair or recreate the ownership lock with memory-bank-cli init from a trusted template checkout."})
} else if exists {
report.TemplateIdentity = TemplateIdentity{SchemaVersion: lock.SchemaVersion, Version: lock.Template.Version, SourceRef: lock.Template.SourceRef}
report.add(Finding{Code: "template.identity", Severity: Info, Group: "template_identity", Path: ownership.LockFileName, Subject: lock.Template.Version, Message: "Installed template identity is recorded in the ownership lock.", Remediation: "Use memory-bank-cli update --dry-run to compare with a newer pinned template source."})
report.checkManagedDrift(lock)
agentContract, tracked := lock.Files[filepath.ToSlash(agentFile)]
templateOwnsAgentFile = tracked && agentContract.Ownership == ownership.Managed
} else if report.Profile == ProfileDownstream {
report.add(Finding{Code: "template.identity_missing", Severity: Error, Group: "template_identity", Path: ownership.LockFileName, Message: "The downstream repository has no ownership lock, so its installed template version is unknown.", Remediation: "Adopt the template with memory-bank-cli init and commit memory-bank/.lock."})
} else {
report.add(Finding{Code: "template.source_repository", Severity: Info, Group: "template_identity", Subject: "template", Message: "Template source profile detected; an installed-template lock is not expected.", Remediation: "Create locks only in downstream repositories through memory-bank-cli init."})
}

if templateOwnsAgentFile {
return
}
contents, _, err := readRegularWithinRoot(report.RepoRoot, agentFile)
if err != nil {
severity := Error
Expand Down
57 changes: 57 additions & 0 deletions internal/doctor/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ func TestImplicitScopeFollowsResolvedProfile(t *testing.T) {
}
}

func TestDetectProfileUsesWholeCanonicalTemplateRoot(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ownership.CanonicalTemplateRoot, ".config"), 0o755); err != nil {
t.Fatal(err)
}
if got := detectProfile(root); got != ProfileTemplate {
t.Fatalf("profile with canonical template root = %q, want %q", got, ProfileTemplate)
}
}

func TestProfileDetectionUsesTemplatePayloadRoot(t *testing.T) {
for _, test := range []struct {
name string
Expand Down Expand Up @@ -275,6 +285,53 @@ func TestManagedDriftProducesStableFinding(t *testing.T) {
t.Fatalf("managed drift finding missing: %#v", report.Findings)
}

func TestTemplateOwnedAgentFileUsesLockDriftContract(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
writeSource := func(relative, contents string) {
t.Helper()
target := filepath.Join(source, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
writeSource("template/AGENTS.md", "canonical template instructions\n")
writeSource("template/memory-bank/README.md", "---\ndoc_function: index\npurpose: Test Memory Bank.\nstatus: active\n---\n# Memory Bank\n")
runGit(t, source, "init", "--quiet")
runGit(t, source, "add", "--all")
runGit(t, source, "-c", "user.name=Doctor Test", "-c", "user.email=doctor@example.invalid", "commit", "--quiet", "-m", "template")
ref := strings.TrimSpace(runGit(t, source, "rev-parse", "HEAD"))
if _, err := ownership.Init(ownership.Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: ref}); err != nil {
t.Fatal(err)
}

report, err := Run(Options{RepoRoot: repo, ScopeRoot: "memory-bank", AgentFile: "AGENTS.md", Profile: ProfileAuto, MaxDepth: 3})
if err != nil {
t.Fatal(err)
}
for _, finding := range report.Findings {
if strings.HasPrefix(finding.Code, "agent.") {
t.Fatalf("template-owned agent file received generated-block finding: %#v", finding)
}
}

if err := os.WriteFile(filepath.Join(repo, "AGENTS.md"), []byte("local drift\n"), 0o644); err != nil {
t.Fatal(err)
}
report, err = Run(Options{RepoRoot: repo, ScopeRoot: "memory-bank", AgentFile: "AGENTS.md", Profile: ProfileAuto, MaxDepth: 3})
if err != nil {
t.Fatal(err)
}
for _, finding := range report.Findings {
if finding.Code == "manifest.managed_content_drift" && finding.Path == "AGENTS.md" {
return
}
}
t.Fatalf("template-owned agent drift was not reported through the lock: %#v", report.Findings)
}

func TestGovernanceCycleAndLifecycleFindingsHaveStableCodes(t *testing.T) {
repo := t.TempDir()
write := func(relative, contents string) {
Expand Down
11 changes: 10 additions & 1 deletion internal/ownership/lock.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) {
return Lock{}, false, "", fmt.Errorf("invalid last update in %s", LockFileName)
}
for filePath, file := range lock.Files {
if filePath == LockFileName || path.IsAbs(filePath) || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || strings.HasPrefix(filePath, "../") || filePath == "." {
if filePath == LockFileName || path.IsAbs(filePath) || strings.Contains(filePath, "\\") || path.Clean(filePath) != filePath || strings.HasPrefix(filePath, "../") || filePath == "." || isGitMetadataPath(filePath) {
return Lock{}, false, "", fmt.Errorf("invalid path %q in %s", filePath, LockFileName)
}
switch file.Ownership {
Expand All @@ -91,6 +91,15 @@ func readLockSnapshot(repo pinnedRepo) (Lock, bool, string, error) {
return lock, true, lockDigest, nil
}

func isGitMetadataPath(filePath string) bool {
for _, component := range strings.Split(filePath, "/") {
if strings.EqualFold(component, ".git") {
return true
}
}
return false
}

func marshalLock(lock Lock) ([]byte, error) {
data, err := json.MarshalIndent(lock, "", " ")
if err != nil {
Expand Down
28 changes: 28 additions & 0 deletions internal/ownership/payload_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package ownership

import (
"path"
"path/filepath"
"strings"
)

const (
// CanonicalTemplateRoot is the complete tracked source payload tree.
CanonicalTemplateRoot = "template"
// DownstreamPayloadRoot is retained for legacy source-root translation and
// for the ownership lock stored below memory-bank/.
DownstreamPayloadRoot = "memory-bank"
)

// CanonicalDownstreamPath maps a path relative to template/ into a downstream
// repository. The mapping is deliberately name-agnostic: stripping the
// canonical root preserves every relative component, including memory-bank/.
func CanonicalDownstreamPath(sourceRelative string) string {
return strings.TrimPrefix(filepath.ToSlash(sourceRelative), "/")
}

// CanonicalTemplatePath is the inverse mapping used when publishing a locked
// downstream path into the canonical template tree.
func CanonicalTemplatePath(downstreamPath string) string {
return path.Join(CanonicalTemplateRoot, filepath.ToSlash(downstreamPath))
}
21 changes: 21 additions & 0 deletions internal/ownership/payload_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package ownership

import "testing"

func TestCanonicalPayloadPathRoundTripsWithoutPathRules(t *testing.T) {
for _, sourceRelative := range []string{
"memory-bank/dna/rule.md",
".config/hidden",
".github/workflows/check.yml",
"AGENTS.md",
"nested/new/path/tool",
} {
downstream := CanonicalDownstreamPath(sourceRelative)
if downstream != sourceRelative {
t.Fatalf("downstream path for %q = %q", sourceRelative, downstream)
}
if got, want := CanonicalTemplatePath(downstream), CanonicalTemplateRoot+"/"+sourceRelative; got != want {
t.Fatalf("canonical round trip for %q = %q, want %q", sourceRelative, got, want)
}
}
}
8 changes: 4 additions & 4 deletions internal/ownership/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ type pinnedSource struct {
const (
legacySourcePayloadRoot = "memory-bank"
legacyTemplateSourcePayloadRoot = "memory-bank-template"
targetSourcePayloadRoot = "template"
downstreamPayloadRoot = "memory-bank"
targetSourcePayloadRoot = CanonicalTemplateRoot
downstreamPayloadRoot = DownstreamPayloadRoot
)

func pinSourceRoot(root string) (pinnedSource, error) {
Expand Down Expand Up @@ -109,7 +109,7 @@ func verifySourceCheckout(root, expectedRef string) error {
return fmt.Errorf("inspect source template status: %w", err)
}
if payloadStatus != "" {
return errors.New("source memory-bank tree contains uncommitted or ignored payloads")
return errors.New("source template tree contains uncommitted or ignored payloads")
}
if err := verifySourcePayload(root, expectedRef, payloadRoot); err != nil {
return err
Expand All @@ -134,7 +134,7 @@ func verifySourcePayload(root, expectedRef, payloadRoot string) error {
}
mode, objectType, objectID := fields[0], fields[1], fields[2]
if objectType != "blob" || mode != "100644" && mode != "100755" {
return fmt.Errorf("pinned source payload contains unsupported entry: %q", path)
return fmt.Errorf("pinned source payload contains unsupported Git entry %q (mode %s, type %s); only regular files with modes 100644 or 100755 are supported", path, mode, objectType)
}
expected[path] = objectID
}
Expand Down
30 changes: 30 additions & 0 deletions internal/ownership/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,36 @@ func TestCanonicalTemplateIncludesAllTrackedFiles(t *testing.T) {
}
}

func TestCanonicalTemplateRejectsGitMetadataPath(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, "template/.git/hooks/post-commit", "#!/bin/sh\n")
_, err := Init(Options{
RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: strings.Repeat("a", 40),
verifySource: func(string, string) error { return nil },
})
if err == nil || !strings.Contains(err.Error(), "reserved metadata path: .git/hooks/post-commit") {
t.Fatalf("Init() error = %v, want reserved Git metadata path", err)
}
if _, err := os.Stat(filepath.Join(repo, ".git", "hooks", "post-commit")); !os.IsNotExist(err) {
t.Fatalf("Git metadata was written: %v", err)
}
}

func TestCanonicalTemplateRejectsTrackedSymlink(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, "template/memory-bank/dna/rule.md", "rule\n")
symlinkForTest(t, "memory-bank/dna/rule.md", filepath.Join(source, "template", "alias"))
commit := commitTestSource(t, source)

report, err := Init(Options{RepoRoot: repo, SourceRoot: source, TemplateVersion: "v1", SourceRef: commit})
if err == nil || !strings.Contains(err.Error(), "unsupported Git entry") {
t.Fatalf("tracked symlink was accepted: report=%#v err=%v", report, err)
}
if _, statErr := os.Lstat(filepath.Join(repo, LockFileName)); !os.IsNotExist(statErr) {
t.Fatalf("failed init created a lock: %v", statErr)
}
}

func TestCanonicalTemplateOwnsAgentFileWhenPresent(t *testing.T) {
source, repo := t.TempDir(), t.TempDir()
write(t, source, "template/AGENTS.md", "template instructions\n")
Expand Down
31 changes: 21 additions & 10 deletions internal/ownership/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ func readGitSource(source pinnedSource, ref string) (map[string]payload, error)
header, filePath, found := strings.Cut(record, "\t")
fields := strings.Fields(header)
if !found || len(fields) != 3 || fields[1] != "blob" || fields[0] != "100644" && fields[0] != "100755" {
return nil, fmt.Errorf("read pinned source tree: unsupported entry %q", filePath)
return nil, fmt.Errorf("read pinned source tree: unsupported Git entry %q; only regular files with modes 100644 or 100755 are supported", filePath)
}
data, err := gitBytes(source.root, "cat-file", "blob", fields[2])
if err != nil {
Expand All @@ -366,13 +366,7 @@ func downstreamPayloadPath(payloadRoot, relative string) string {
if payloadRoot != targetSourcePayloadRoot {
return downstreamPayloadRoot + "/" + relative
}
if relative == downstreamPayloadRoot {
return downstreamPayloadRoot
}
if strings.HasPrefix(relative, downstreamPayloadRoot+"/") {
return relative
}
return relative
return CanonicalDownstreamPath(relative)
}

func sourcePayloadClass(payloadRoot, relative string) Class {
Expand Down Expand Up @@ -411,8 +405,10 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo
if _, err := inspectRepoRoot(repo.root, repo.info); err != nil {
return nil, nil, Lock{}, err
}
if _, exists := source[LockFileName]; exists {
return nil, nil, Lock{}, fmt.Errorf("template source contains reserved metadata path: %s", LockFileName)
for sourcePath := range source {
if sourcePath == LockFileName || isGitMetadataPath(sourcePath) {
return nil, nil, Lock{}, fmt.Errorf("template source contains reserved metadata path: %s", sourcePath)
}
}
next := Lock{Files: make(map[string]File)}
var removalMutations []mutation
Expand Down Expand Up @@ -543,6 +539,21 @@ func buildPlan(repo pinnedRepo, source map[string]payload, old Lock, hasLock boo
decision.Action, decision.Reason = Preserve, "untracked existing file is downstream-owned"
file = File{Ownership: UserOwned}
}
case class == Managed && (prior.Ownership == Adapted || prior.Ownership == UserOwned):
downstreamChanged := prior.BaseDigest == "" || currentDigest != prior.BaseDigest || !modeMatches(currentMode, priorBaseMode)
upstreamChanged := prior.BaseDigest == "" || incoming.digest != prior.BaseDigest || incoming.mode != priorBaseMode
switch {
case currentDigest == incoming.digest && modeMatches(currentMode, incoming.mode):
decision.Action, decision.Reason = Preserve, "adopt matching legacy-owned file as canonical managed payload"
case downstreamChanged && upstreamChanged:
decision.Action, decision.Reason = Conflict, "legacy-owned file changed both upstream and downstream during canonical migration"
file = prior
case upstreamChanged:
decision.Action, decision.Reason = UpdateFile, "unmodified legacy-owned file follows canonical managed payload"
default:
decision.Action, decision.Reason = Preserve, "preserve legacy downstream customization during canonical migration"
file = prior
}
case class == UserOwned || prior.Ownership == UserOwned:
decision.Action, decision.Reason = Preserve, "user-owned files are never overwritten"
file = prior
Expand Down
Loading
Loading