claude-git-sessions 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,8 @@ history with `main` and never contains your source files — only session data:
45
45
  ```
46
46
  sessions/<session-id>.jsonl # the transcript, verbatim
47
47
  sessions/<session-id>.meta.json # sidecar metadata (name, author, cwd, …)
48
+ memory/<fact>.md # shared memory facts (see `ccgs memory`)
49
+ memory/<fact>.md.meta.json # sidecar metadata (type, author, …)
48
50
  ```
49
51
 
50
52
  Files are keyed by the globally-unique Claude Code session UUID, so transcripts
@@ -101,6 +103,29 @@ Shows what will be deleted and asks for `y/N` confirmation (`--yes`/`-y` to skip
101
103
  for scripting). Removes both files from the branch and pushes. By default only
102
104
  the shared branch is touched; add `--local` to also remove the local copy.
103
105
 
106
+ ### `ccgs memory push [--all]` / `ccgs memory pull [--all] [--force]`
107
+
108
+ Claude Code keeps per-project memory in `~/.claude/projects/<slug>/memory/` —
109
+ one Markdown file per fact, plus a `MEMORY.md` index. `ccgs memory` shares those
110
+ facts on the same orphan branch (under a `memory/` prefix).
111
+
112
+ Memory is *mixed-sensitivity*, so it's filtered by the fact's frontmatter
113
+ `type`:
114
+
115
+ - **`project` / `reference`** facts ("how we deploy", "where the API docs live")
116
+ are team knowledge and are shared **by default**.
117
+ - **`user` / `feedback`** facts (personal preferences) are held back unless you
118
+ pass **`--all`**.
119
+
120
+ `pull` writes the shared facts into your local memory dir and maintains a
121
+ clearly-marked block inside your `MEMORY.md` pointing at them — **your own index
122
+ lines are left untouched**. `MEMORY.md` itself is never pushed as a blob (two
123
+ people's indexes would clobber each other). Facts are keyed by filename with
124
+ newer-wins conflict handling (`--force` to overwrite a newer local copy).
125
+
126
+ > Scope: only the repo **root** slug's memory dir is synced (the common case);
127
+ > memory from sessions started in subdirectories is not.
128
+
104
129
  ### Global options
105
130
 
106
131
  | Option | Default | Meaning |
@@ -0,0 +1,185 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { MEMORY_DIR, MEMORY_INDEX } from "../constants.js";
5
+ import { applyToOrphanBranch, assertRemote, fetchBranch, localTrackingRef, lsTreeUnder, showFile, validateBranchName, } from "../git.js";
6
+ import { gitAuthor } from "../meta.js";
7
+ import { indexLine, isShareable, memoryDir, parseFrontmatter, readLocalMemory, titleFor, updateIndexBlock, } from "../memory.js";
8
+ function metaPath(filename) {
9
+ return `${MEMORY_DIR}/${filename}.meta.json`;
10
+ }
11
+ function blobPath(filename) {
12
+ return `${MEMORY_DIR}/${filename}`;
13
+ }
14
+ /** Filenames of `.md` memory facts on the branch (excludes sidecars). */
15
+ async function listBranchMemory(repo, ref) {
16
+ const paths = await lsTreeUnder(repo, ref, MEMORY_DIR);
17
+ const names = new Set();
18
+ for (const p of paths) {
19
+ const m = p.match(new RegExp(`^${MEMORY_DIR}/(.+\\.md)$`));
20
+ if (m && m[1] !== MEMORY_INDEX)
21
+ names.add(m[1]);
22
+ }
23
+ return [...names];
24
+ }
25
+ export async function memoryPush(opts) {
26
+ await validateBranchName(opts.repoRoot, opts.branch);
27
+ await assertRemote(opts.repoRoot, opts.remote);
28
+ const all = readLocalMemory(opts.repoRoot);
29
+ const shareable = all.filter((f) => isShareable(f.type, opts.all));
30
+ const heldBack = all.length - shareable.length;
31
+ if (shareable.length === 0) {
32
+ const extra = heldBack
33
+ ? ` (${heldBack} personal user/feedback memo(ies) held back; use --all to include)`
34
+ : "";
35
+ console.log(`nothing to push (no shareable memory found for this repo)${extra}`);
36
+ return 0;
37
+ }
38
+ await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
39
+ const ref = localTrackingRef(opts.branch);
40
+ const existing = new Set(await listBranchMemory(opts.repoRoot, ref).catch(() => []));
41
+ const author = await gitAuthor(opts.repoRoot);
42
+ const added = [];
43
+ const updated = [];
44
+ let unchanged = 0;
45
+ for (const f of shareable) {
46
+ if (!existing.has(f.filename)) {
47
+ added.push(f.filename);
48
+ }
49
+ else {
50
+ const prev = await showFile(opts.repoRoot, ref, blobPath(f.filename));
51
+ if (prev === f.content)
52
+ unchanged++;
53
+ else
54
+ updated.push(f.filename);
55
+ }
56
+ }
57
+ const result = await applyToOrphanBranch(opts.repoRoot, opts.remote, opts.branch, `ccgs: push ${added.length + updated.length} memory file(s)`, async (b) => {
58
+ for (const f of shareable) {
59
+ const sidecar = {
60
+ filename: f.filename,
61
+ type: f.type,
62
+ name: f.name,
63
+ description: f.description,
64
+ author,
65
+ machine: os.hostname(),
66
+ updatedAt: f.updatedAt,
67
+ };
68
+ await b.addBlob(blobPath(f.filename), f.content);
69
+ await b.addBlob(metaPath(f.filename), JSON.stringify(sidecar, null, 2) + "\n");
70
+ }
71
+ });
72
+ if (!result.changed) {
73
+ console.log(`Memory already up to date — ${unchanged} file(s) unchanged.`);
74
+ if (heldBack)
75
+ console.log(`(${heldBack} personal memo(ies) held back; --all to include)`);
76
+ return 0;
77
+ }
78
+ if (added.length) {
79
+ console.log(`Added ${added.length}:`);
80
+ for (const a of added)
81
+ console.log(` + ${a}`);
82
+ }
83
+ if (updated.length) {
84
+ console.log(`Updated ${updated.length}:`);
85
+ for (const u of updated)
86
+ console.log(` ~ ${u}`);
87
+ }
88
+ if (unchanged)
89
+ console.log(`(${unchanged} unchanged)`);
90
+ if (heldBack)
91
+ console.log(`(${heldBack} personal user/feedback memo(ies) held back; --all to include)`);
92
+ console.log(`Pushed memory to ${opts.remote} @ccgs/${opts.branch}.`);
93
+ return 0;
94
+ }
95
+ export async function memoryPull(opts) {
96
+ await validateBranchName(opts.repoRoot, opts.branch);
97
+ await assertRemote(opts.repoRoot, opts.remote);
98
+ const exists = await fetchBranch(opts.repoRoot, opts.remote, opts.branch);
99
+ if (!exists) {
100
+ console.log("nothing to pull (branch does not exist on remote)");
101
+ return 0;
102
+ }
103
+ const ref = localTrackingRef(opts.branch);
104
+ const filenames = await listBranchMemory(opts.repoRoot, ref);
105
+ if (filenames.length === 0) {
106
+ console.log("nothing to pull (no shared memory on branch)");
107
+ return 0;
108
+ }
109
+ const dir = memoryDir(opts.repoRoot);
110
+ const pulled = [];
111
+ const skipped = [];
112
+ // Files present locally after this pull, for index regeneration.
113
+ const indexEntries = [];
114
+ for (const filename of filenames) {
115
+ const content = await showFile(opts.repoRoot, ref, blobPath(filename));
116
+ if (content === null) {
117
+ skipped.push(`${filename}: missing blob`);
118
+ continue;
119
+ }
120
+ const rawMeta = await showFile(opts.repoRoot, ref, metaPath(filename));
121
+ let sidecar = null;
122
+ if (rawMeta) {
123
+ try {
124
+ sidecar = JSON.parse(rawMeta);
125
+ }
126
+ catch {
127
+ sidecar = null;
128
+ }
129
+ }
130
+ const type = sidecar?.type ?? parseFrontmatter(content).type;
131
+ // Apply the same type gate on pull: don't litter local memory with other
132
+ // people's personal facts unless explicitly asked.
133
+ if (!isShareable(type, opts.all)) {
134
+ continue;
135
+ }
136
+ const description = sidecar?.description ?? parseFrontmatter(content).description;
137
+ const localFile = path.join(dir, filename);
138
+ // Conflict policy: keep a newer local copy unless --force.
139
+ if (!opts.force && fs.existsSync(localFile)) {
140
+ const branchUpdated = sidecar?.updatedAt ?? "";
141
+ const localUpdated = fs.statSync(localFile).mtime.toISOString();
142
+ if (branchUpdated && localUpdated > branchUpdated) {
143
+ skipped.push(`${filename}: local copy is newer (use --force)`);
144
+ // Still index it — the file exists locally and is part of the shared set.
145
+ indexEntries.push({ filename, description, title: titleFor({ filename, name: sidecar?.name ?? null }) });
146
+ continue;
147
+ }
148
+ }
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ fs.writeFileSync(localFile, content);
151
+ pulled.push(filename);
152
+ indexEntries.push({ filename, description, title: titleFor({ filename, name: sidecar?.name ?? null }) });
153
+ }
154
+ // Rebuild the ccgs-managed block in MEMORY.md from the shared set.
155
+ if (indexEntries.length) {
156
+ const indexPath = path.join(dir, MEMORY_INDEX);
157
+ let existing = "";
158
+ try {
159
+ existing = fs.readFileSync(indexPath, "utf8");
160
+ }
161
+ catch {
162
+ existing = "";
163
+ }
164
+ const lines = indexEntries
165
+ .sort((a, b) => a.filename.localeCompare(b.filename))
166
+ .map((e) => indexLine(e));
167
+ fs.mkdirSync(dir, { recursive: true });
168
+ fs.writeFileSync(indexPath, updateIndexBlock(existing, lines));
169
+ }
170
+ if (pulled.length) {
171
+ console.log(`Pulled ${pulled.length} memory file(s):`);
172
+ for (const p of pulled)
173
+ console.log(` + ${p}`);
174
+ console.log(`Index updated: ${path.join(dir, MEMORY_INDEX)}`);
175
+ }
176
+ else {
177
+ console.log("Pulled 0 memory files.");
178
+ }
179
+ if (skipped.length) {
180
+ console.log(`Skipped ${skipped.length}:`);
181
+ for (const s of skipped)
182
+ console.log(` - ${s}`);
183
+ }
184
+ return 0;
185
+ }
package/dist/constants.js CHANGED
@@ -15,5 +15,12 @@ export const DEFAULT_BRANCH_NAME = "default";
15
15
  export const DEFAULT_REMOTE = "origin";
16
16
  /** Path prefix used for session blobs on the orphan branch. */
17
17
  export const SESSIONS_DIR = "sessions";
18
+ /**
19
+ * Path prefix used for shared memory blobs on the orphan branch, and the name
20
+ * of the local memory subdirectory inside a project's slug dir.
21
+ */
22
+ export const MEMORY_DIR = "memory";
23
+ /** The hand-maintained memory index file (lives inside the memory dir). */
24
+ export const MEMORY_INDEX = "MEMORY.md";
18
25
  /** Max length of an auto-derived display name. */
19
26
  export const DISPLAY_NAME_MAX = 80;
package/dist/git.js CHANGED
@@ -115,15 +115,19 @@ export async function trackingTip(repo, name) {
115
115
  const sha = r.stdout.trim();
116
116
  return sha || null;
117
117
  }
118
- /** List `sessions/*` paths present on a tree-ish ref. */
119
- export async function lsSessions(repo, ref) {
118
+ /** List paths under `prefix/` present on a tree-ish ref. */
119
+ export async function lsTreeUnder(repo, ref, prefix) {
120
120
  const r = await git(["ls-tree", "-r", "--name-only", ref], { cwd: repo, allowFail: true });
121
121
  if (r.code !== 0)
122
122
  return [];
123
123
  return r.stdout
124
124
  .split("\n")
125
125
  .map((s) => s.trim())
126
- .filter((p) => p.startsWith(`${SESSIONS_DIR}/`));
126
+ .filter((p) => p.startsWith(`${prefix}/`));
127
+ }
128
+ /** List `sessions/*` paths present on a tree-ish ref. */
129
+ export async function lsSessions(repo, ref) {
130
+ return lsTreeUnder(repo, ref, SESSIONS_DIR);
127
131
  }
128
132
  /** Read a blob at `ref:path` as utf-8 text, or null if it does not exist. */
129
133
  export async function showFile(repo, ref, filePath) {
package/dist/index.js CHANGED
@@ -5,9 +5,10 @@ import { repoRoot } from "./git.js";
5
5
  import { pull } from "./commands/pull.js";
6
6
  import { push } from "./commands/push.js";
7
7
  import { deleteSession } from "./commands/delete.js";
8
+ import { memoryPull, memoryPush } from "./commands/memory.js";
8
9
  // Kept in sync with package.json at publish time; hardcoded to avoid a JSON
9
10
  // import assertion just for --version.
10
- const VERSION = "0.1.1";
11
+ const VERSION = "0.2.0";
11
12
  function globals(cmd) {
12
13
  const opts = cmd.optsWithGlobals();
13
14
  return {
@@ -73,4 +74,48 @@ program
73
74
  });
74
75
  });
75
76
  });
77
+ const memory = program
78
+ .command("memory")
79
+ .description("share Claude Code memory files (project/reference facts) for this repo");
80
+ memory
81
+ .command("push")
82
+ .description("publish this repo's shareable memory to the orphan branch")
83
+ .option("--all", "include personal user/feedback memories too", false)
84
+ .action(async (localOpts, cmd) => {
85
+ const g = globals(cmd);
86
+ await run(async () => {
87
+ const root = await repoRoot();
88
+ return memoryPush({ repoRoot: root, remote: g.remote, branch: g.branch, all: localOpts.all });
89
+ });
90
+ });
91
+ memory
92
+ .command("pull")
93
+ .description("fetch shared memory and update this repo's local MEMORY.md index")
94
+ .option("--all", "include personal user/feedback memories too", false)
95
+ .option("--force", "overwrite local copies even if they are newer", false)
96
+ .action(async (localOpts, cmd) => {
97
+ const g = globals(cmd);
98
+ await run(async () => {
99
+ const root = await repoRoot();
100
+ return memoryPull({
101
+ repoRoot: root,
102
+ remote: g.remote,
103
+ branch: g.branch,
104
+ all: localOpts.all,
105
+ force: localOpts.force,
106
+ });
107
+ });
108
+ });
109
+ // `ccgs memory` with no subcommand: show its help on stdout and exit 0.
110
+ memory.action(() => {
111
+ memory.outputHelp();
112
+ process.exit(0);
113
+ });
114
+ // With no subcommand, print help to stdout and exit 0 — Commander's default is
115
+ // to print to stderr and exit 1, which looks like an error to anyone running
116
+ // the bare command (e.g. `npx claude-git-sessions`).
117
+ if (process.argv.length <= 2) {
118
+ program.outputHelp();
119
+ process.exit(0);
120
+ }
76
121
  program.parseAsync(process.argv);
package/dist/memory.js ADDED
@@ -0,0 +1,134 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { MEMORY_DIR, MEMORY_INDEX } from "./constants.js";
4
+ import { projectDirForCwd } from "./paths.js";
5
+ const KNOWN_TYPES = ["user", "feedback", "project", "reference"];
6
+ /** Local memory directory for the repo's root slug. */
7
+ export function memoryDir(repoRoot) {
8
+ return path.join(projectDirForCwd(repoRoot), MEMORY_DIR);
9
+ }
10
+ function stripQuotes(s) {
11
+ const t = s.trim();
12
+ if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
13
+ return t.slice(1, -1);
14
+ }
15
+ return t;
16
+ }
17
+ /**
18
+ * Minimal frontmatter reader for the known memory format. Deliberately not a
19
+ * full YAML parser — it only pulls out `name`, `description`, and the nested
20
+ * `type`, and degrades gracefully (type "unknown") on anything unexpected.
21
+ */
22
+ export function parseFrontmatter(content) {
23
+ let name = null;
24
+ let description = null;
25
+ let type = "unknown";
26
+ if (content.startsWith("---")) {
27
+ // Find the closing delimiter line.
28
+ const rest = content.slice(3);
29
+ const endIdx = rest.search(/\n---\s*(\n|$)/);
30
+ if (endIdx !== -1) {
31
+ const block = rest.slice(0, endIdx);
32
+ for (const line of block.split("\n")) {
33
+ const n = line.match(/^\s*name:\s*(.+?)\s*$/);
34
+ if (n && name === null)
35
+ name = stripQuotes(n[1]);
36
+ const d = line.match(/^\s*description:\s*(.+?)\s*$/);
37
+ if (d && description === null)
38
+ description = stripQuotes(d[1]);
39
+ const t = line.match(/^\s*type:\s*([A-Za-z]+)/);
40
+ if (t) {
41
+ const v = t[1].toLowerCase();
42
+ if (KNOWN_TYPES.includes(v))
43
+ type = v;
44
+ }
45
+ }
46
+ }
47
+ }
48
+ return { name, description, type };
49
+ }
50
+ /** Should a fact of `type` be shared? `all` overrides the type filter. */
51
+ export function isShareable(type, all) {
52
+ if (all)
53
+ return true;
54
+ return type === "project" || type === "reference";
55
+ }
56
+ /** kebab-or-filename -> a human "Title Case" string for the index link text. */
57
+ export function titleFor(file) {
58
+ const base = file.name ?? file.filename.replace(/\.md$/i, "");
59
+ return base
60
+ .replace(/[-_]+/g, " ")
61
+ .replace(/\s+/g, " ")
62
+ .trim()
63
+ .replace(/\b\w/g, (c) => c.toUpperCase());
64
+ }
65
+ /** Read all shareable-candidate memory files from the repo's memory dir. */
66
+ export function readLocalMemory(repoRoot) {
67
+ const dir = memoryDir(repoRoot);
68
+ let entries;
69
+ try {
70
+ entries = fs.readdirSync(dir);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const filename of entries) {
77
+ if (!filename.endsWith(".md"))
78
+ continue;
79
+ if (filename === MEMORY_INDEX)
80
+ continue; // index handled separately
81
+ const p = path.join(dir, filename);
82
+ let content;
83
+ let mtime;
84
+ try {
85
+ const st = fs.statSync(p);
86
+ if (!st.isFile())
87
+ continue;
88
+ mtime = st.mtime;
89
+ content = fs.readFileSync(p, "utf8");
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ const fm = parseFrontmatter(content);
95
+ out.push({
96
+ filename,
97
+ path: p,
98
+ content,
99
+ name: fm.name,
100
+ description: fm.description,
101
+ type: fm.type,
102
+ updatedAt: mtime.toISOString(),
103
+ });
104
+ }
105
+ return out;
106
+ }
107
+ const BLOCK_START = "<!-- ccgs:shared-memory (managed by ccgs; do not edit by hand) -->";
108
+ const BLOCK_END = "<!-- /ccgs:shared-memory -->";
109
+ /** One index pointer line: `- [Title](file.md) — description`. */
110
+ export function indexLine(file) {
111
+ const tail = file.description ? ` — ${file.description}` : "";
112
+ return `- [${file.title}](${file.filename})${tail}`;
113
+ }
114
+ /**
115
+ * Replace (or append) the ccgs-managed block in a MEMORY.md body with the given
116
+ * lines, leaving everything outside the markers untouched. Returns the new body.
117
+ */
118
+ export function updateIndexBlock(existing, lines) {
119
+ const block = lines.length
120
+ ? `${BLOCK_START}\n${lines.join("\n")}\n${BLOCK_END}`
121
+ : `${BLOCK_START}\n${BLOCK_END}`;
122
+ const startIdx = existing.indexOf(BLOCK_START);
123
+ const endIdx = existing.indexOf(BLOCK_END);
124
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
125
+ const before = existing.slice(0, startIdx);
126
+ const after = existing.slice(endIdx + BLOCK_END.length);
127
+ return before + block + after;
128
+ }
129
+ // Append, ensuring a blank line separates it from existing content.
130
+ const base = existing.trimEnd();
131
+ if (base === "")
132
+ return block + "\n";
133
+ return `${base}\n\n${block}\n`;
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-sessions",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Share Claude Code sessions across a team through an orphan git branch in your existing repo.",
5
5
  "keywords": ["claude", "claude-code", "git", "sessions", "cli"],
6
6
  "license": "MIT",