claude-git-sessions 0.1.2 → 0.3.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
@@ -72,26 +74,32 @@ Branch names are validated with `git check-ref-format` before use.
72
74
 
73
75
  ## Commands
74
76
 
75
- ### `ccgs pull`
77
+ ### `ccgs pull [--force] [--exclude-memory]`
76
78
 
77
- Fetches `@ccgs/<name>` and writes each session where Claude Code expects it:
78
- `~/.claude/projects/<local-slug>/<id>.jsonl`. The structural `cwd` field in each
79
- transcript line is rewritten from the author's path to your local equivalent so
80
- `claude --resume` works. (Absolute paths inside tool *output* are left as-is
81
- cosmetic only.)
79
+ Fetches `@ccgs/<name>` and writes each session into the **repo root's** project
80
+ slug — `~/.claude/projects/<root-slug>/<id>.jsonl` so that `claude --resume`
81
+ run at the repo root lists every pulled session. The structural `cwd` field in
82
+ each transcript line is rewritten from the author's path to your repo root so
83
+ resume works. (Absolute paths inside tool *output* are left as-is — cosmetic.)
82
84
 
83
- If a local session is **newer** than the shared copy it is skipped with a
84
- warning; pass `--force` to overwrite anyway. Prints what was pulled / skipped.
85
+ > Sessions are always placed under the repo-root slug, even if the author
86
+ > launched Claude in a subdirectory. Honoring the author's subdir would put the
87
+ > session in a separate (and possibly non-existent) slug dir where `--resume`
88
+ > at the root can't see it. The original subdir is still recorded in `meta.json`.
85
89
 
86
- If the branch doesn't exist, it prints `nothing to pull` and exits 0.
90
+ By default this **also pulls shared memory** (see `ccgs memory`); pass
91
+ `--exclude-memory` for sessions only. If a local session is **newer** than the
92
+ shared copy it is skipped with a warning; pass `--force` to overwrite. If the
93
+ branch doesn't exist, it prints `nothing to pull` and exits 0.
87
94
 
88
- ### `ccgs push [targets...]`
95
+ ### `ccgs push [targets...] [--exclude-memory]`
89
96
 
90
97
  Finds local sessions whose working directory is this repo (or a subdirectory),
91
98
  copies each transcript verbatim onto the orphan branch, and (re)generates its
92
99
  `meta.json`. With no arguments it pushes all of them; pass session ids/names to
93
- push only those. Creates the orphan branch on first push. Reports added vs
94
- updated.
100
+ push only those. By default this **also pushes shared memory** (project /
101
+ reference facts); pass `--exclude-memory` for sessions only. Creates the orphan
102
+ branch on first push. Reports added vs updated.
95
103
 
96
104
  ### `ccgs delete <id|name> [--yes] [--local]`
97
105
 
@@ -101,6 +109,33 @@ Shows what will be deleted and asks for `y/N` confirmation (`--yes`/`-y` to skip
101
109
  for scripting). Removes both files from the branch and pushes. By default only
102
110
  the shared branch is touched; add `--local` to also remove the local copy.
103
111
 
112
+ ### `ccgs memory push [--all]` / `ccgs memory pull [--all] [--force]`
113
+
114
+ Claude Code keeps per-project memory in `~/.claude/projects/<slug>/memory/` —
115
+ one Markdown file per fact, plus a `MEMORY.md` index. `ccgs memory` shares those
116
+ facts on the same orphan branch (under a `memory/` prefix).
117
+
118
+ Plain `ccgs push` / `ccgs pull` already include memory by default (with the
119
+ type filter below, i.e. no personal facts). Use these `memory` subcommands when
120
+ you want memory **only**, or need `--all` to include personal facts.
121
+
122
+ Memory is *mixed-sensitivity*, so it's filtered by the fact's frontmatter
123
+ `type`:
124
+
125
+ - **`project` / `reference`** facts ("how we deploy", "where the API docs live")
126
+ are team knowledge and are shared **by default**.
127
+ - **`user` / `feedback`** facts (personal preferences) are held back unless you
128
+ pass **`--all`**.
129
+
130
+ `pull` writes the shared facts into your local memory dir and maintains a
131
+ clearly-marked block inside your `MEMORY.md` pointing at them — **your own index
132
+ lines are left untouched**. `MEMORY.md` itself is never pushed as a blob (two
133
+ people's indexes would clobber each other). Facts are keyed by filename with
134
+ newer-wins conflict handling (`--force` to overwrite a newer local copy).
135
+
136
+ > Scope: only the repo **root** slug's memory dir is synced (the common case);
137
+ > memory from sessions started in subdirectories is not.
138
+
104
139
  ### Global options
105
140
 
106
141
  | 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
+ }
@@ -1,5 +1,4 @@
1
1
  import fs from "node:fs";
2
- import path from "node:path";
3
2
  import { SESSIONS_DIR } from "../constants.js";
4
3
  import { assertRemote, fetchBranch, localTrackingRef, showFile, validateBranchName, } from "../git.js";
5
4
  import { listBranchSessions } from "../branch-sessions.js";
@@ -28,9 +27,13 @@ export async function pull(opts) {
28
27
  skipped.push(`${shortId(id)}: missing transcript blob`);
29
28
  continue;
30
29
  }
31
- // Reconstruct the local cwd for this session: repo root + recorded subdir.
32
- const rel = meta?.cwdRelativeToRepoRoot ?? "";
33
- const localCwd = rel ? path.join(opts.repoRoot, rel) : opts.repoRoot;
30
+ // Always land the session under the repo ROOT slug, so `claude --resume`
31
+ // run at the repo root lists it. (We deliberately do NOT honor the author's
32
+ // `cwdRelativeToRepoRoot` here: that placed subdir-authored sessions in a
33
+ // separate — and often non-existent — slug dir, making them invisible to
34
+ // resume at the root. The author's subdir is still recorded in meta for
35
+ // provenance.)
36
+ const localCwd = opts.repoRoot;
34
37
  const name = meta?.name ?? id;
35
38
  // Conflict policy: newer local copy is kept unless --force.
36
39
  const localFile = sessionFilePath(localCwd, id);
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.2";
11
+ const VERSION = "0.3.0";
11
12
  function globals(cmd) {
12
13
  const opts = cmd.optsWithGlobals();
13
14
  return {
@@ -33,24 +34,44 @@ program
33
34
  .option("--remote <remote>", "git remote to use", DEFAULT_REMOTE);
34
35
  program
35
36
  .command("pull")
36
- .description("fetch shared sessions and place them where Claude Code can resume them")
37
+ .description("fetch shared sessions (and memory) and place them where Claude Code can resume them")
37
38
  .option("--force", "overwrite local copies even if they are newer", false)
39
+ .option("--exclude-memory", "pull sessions only; do not also pull memory", false)
38
40
  .action(async (localOpts, cmd) => {
39
41
  const g = globals(cmd);
40
42
  await run(async () => {
41
43
  const root = await repoRoot();
42
- return pull({ repoRoot: root, remote: g.remote, branch: g.branch, force: localOpts.force });
44
+ let code = await pull({ repoRoot: root, remote: g.remote, branch: g.branch, force: localOpts.force });
45
+ if (!localOpts.excludeMemory) {
46
+ console.log("\nMemory:");
47
+ const m = await memoryPull({
48
+ repoRoot: root,
49
+ remote: g.remote,
50
+ branch: g.branch,
51
+ all: false,
52
+ force: localOpts.force,
53
+ });
54
+ code = code || m;
55
+ }
56
+ return code;
43
57
  });
44
58
  });
45
59
  program
46
60
  .command("push")
47
- .description("publish this repo's local sessions to the orphan branch")
61
+ .description("publish this repo's local sessions (and shared memory) to the orphan branch")
48
62
  .argument("[targets...]", "optional session ids/names to push (default: all)")
49
- .action(async (targets, _localOpts, cmd) => {
63
+ .option("--exclude-memory", "push sessions only; do not also push memory", false)
64
+ .action(async (targets, localOpts, cmd) => {
50
65
  const g = globals(cmd);
51
66
  await run(async () => {
52
67
  const root = await repoRoot();
53
- return push({ repoRoot: root, remote: g.remote, branch: g.branch, filters: targets });
68
+ let code = await push({ repoRoot: root, remote: g.remote, branch: g.branch, filters: targets });
69
+ if (!localOpts.excludeMemory) {
70
+ console.log("\nMemory:");
71
+ const m = await memoryPush({ repoRoot: root, remote: g.remote, branch: g.branch, all: false });
72
+ code = code || m;
73
+ }
74
+ return code;
54
75
  });
55
76
  });
56
77
  program
@@ -73,6 +94,43 @@ program
73
94
  });
74
95
  });
75
96
  });
97
+ const memory = program
98
+ .command("memory")
99
+ .description("share Claude Code memory files (project/reference facts) for this repo");
100
+ memory
101
+ .command("push")
102
+ .description("publish this repo's shareable memory to the orphan branch")
103
+ .option("--all", "include personal user/feedback memories too", false)
104
+ .action(async (localOpts, cmd) => {
105
+ const g = globals(cmd);
106
+ await run(async () => {
107
+ const root = await repoRoot();
108
+ return memoryPush({ repoRoot: root, remote: g.remote, branch: g.branch, all: localOpts.all });
109
+ });
110
+ });
111
+ memory
112
+ .command("pull")
113
+ .description("fetch shared memory and update this repo's local MEMORY.md index")
114
+ .option("--all", "include personal user/feedback memories too", false)
115
+ .option("--force", "overwrite local copies even if they are newer", false)
116
+ .action(async (localOpts, cmd) => {
117
+ const g = globals(cmd);
118
+ await run(async () => {
119
+ const root = await repoRoot();
120
+ return memoryPull({
121
+ repoRoot: root,
122
+ remote: g.remote,
123
+ branch: g.branch,
124
+ all: localOpts.all,
125
+ force: localOpts.force,
126
+ });
127
+ });
128
+ });
129
+ // `ccgs memory` with no subcommand: show its help on stdout and exit 0.
130
+ memory.action(() => {
131
+ memory.outputHelp();
132
+ process.exit(0);
133
+ });
76
134
  // With no subcommand, print help to stdout and exit 0 — Commander's default is
77
135
  // to print to stderr and exit 1, which looks like an error to anyone running
78
136
  // the bare command (e.g. `npx claude-git-sessions`).
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.2",
3
+ "version": "0.3.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",