@hobocode/thought-layer 0.6.0 → 0.7.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/core/sync.ts ADDED
@@ -0,0 +1,150 @@
1
+ // Pure helpers for the git-backed session sync: slug a human session name into a
2
+ // filename, model the machine-local sync config (which private repos and clone
3
+ // dirs the user has set up), resolve which clone dir an op targets, and parse
4
+ // `git status` output. No fs and no node deps live here; the actual git/gh shell
5
+ // outs and config read/write are in sync-io.ts.
6
+ //
7
+ // Copy rule for any user-facing string: no em-dashes, no en-dashes, no spaced
8
+ // hyphen dashes.
9
+
10
+ // ---- session naming ----------------------------------------------------------
11
+
12
+ // Turn a human session name ("Photo Booth!", "Peptide v2") into a safe file slug
13
+ // ("photo-booth", "peptide-v2"). Lowercase, non [a-z0-9] runs collapse to a
14
+ // single hyphen, trimmed, capped. Returns "" for an empty or all-punctuation
15
+ // name so the caller can reject it (a session must be named, never timestamped).
16
+ export function slugify(name: string): string {
17
+ return String(name || "")
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-+|-+$/g, "")
21
+ .slice(0, 40)
22
+ .replace(/-+$/g, "");
23
+ }
24
+
25
+ // ---- machine-local sync config -----------------------------------------------
26
+
27
+ // A workspace is one (private repo, local clone dir) pair. The user can have
28
+ // several: a personal one for their own projects, and one per outside founder
29
+ // they collaborate with. The config is machine-local and never committed.
30
+ export interface SyncWorkspace {
31
+ name: string; // human label for the workspace (e.g. "personal", "acme-co")
32
+ repo: string; // the GitHub repo, e.g. "hobocode-ofc/tl-sessions" or a URL
33
+ defaultBranch: string; // usually "main"
34
+ cloneDir: string; // absolute path to the local clone
35
+ activeSession?: string; // the <slug>.json last opened in this workspace
36
+ }
37
+
38
+ export interface SyncConfig {
39
+ schema: number;
40
+ activeWorkspace?: string; // workspace name selected when no --dir is given
41
+ workspaces: SyncWorkspace[];
42
+ }
43
+
44
+ export function emptySyncConfig(): SyncConfig {
45
+ return { schema: 1, workspaces: [] };
46
+ }
47
+
48
+ const str = (v: unknown, fb = ""): string => (typeof v === "string" ? v : fb);
49
+
50
+ // Defensive parse of a hand-editable sync.json. Drops malformed workspaces
51
+ // (a workspace needs at least a cloneDir); never throws on junk.
52
+ export function parseSyncConfig(text: string): SyncConfig {
53
+ let raw: Record<string, unknown>;
54
+ try {
55
+ raw = JSON.parse(text) as Record<string, unknown>;
56
+ } catch {
57
+ return emptySyncConfig();
58
+ }
59
+ const list = Array.isArray(raw["workspaces"]) ? (raw["workspaces"] as unknown[]) : [];
60
+ const workspaces: SyncWorkspace[] = [];
61
+ for (const w of list) {
62
+ if (!w || typeof w !== "object") continue;
63
+ const r = w as Record<string, unknown>;
64
+ const cloneDir = str(r["cloneDir"]).trim();
65
+ if (!cloneDir) continue;
66
+ const ws: SyncWorkspace = {
67
+ name: str(r["name"]).trim() || cloneDir,
68
+ repo: str(r["repo"]).trim(),
69
+ defaultBranch: str(r["defaultBranch"]).trim() || "main",
70
+ cloneDir,
71
+ };
72
+ const active = str(r["activeSession"]).trim();
73
+ if (active) ws.activeSession = active;
74
+ workspaces.push(ws);
75
+ }
76
+ const cfg: SyncConfig = { schema: 1, workspaces };
77
+ const aw = str(raw["activeWorkspace"]).trim();
78
+ if (aw) cfg.activeWorkspace = aw;
79
+ return cfg;
80
+ }
81
+
82
+ export function serializeSyncConfig(cfg: SyncConfig): string {
83
+ return JSON.stringify({ schema: 1, activeWorkspace: cfg.activeWorkspace, workspaces: cfg.workspaces }, null, 2) + "\n";
84
+ }
85
+
86
+ // Find a workspace by name, or fall back to the active one, or the only one.
87
+ export function selectWorkspace(cfg: SyncConfig, name?: string): SyncWorkspace | null {
88
+ if (name) return cfg.workspaces.find((w) => w.name === name) || null;
89
+ if (cfg.activeWorkspace) {
90
+ const w = cfg.workspaces.find((ws) => ws.name === cfg.activeWorkspace);
91
+ if (w) return w;
92
+ }
93
+ return cfg.workspaces.length === 1 ? cfg.workspaces[0]! : null;
94
+ }
95
+
96
+ // The default personal clone dir, under one dot-dir off every product tree so it
97
+ // can never leak into a product repo. Per-client dirs are explicit cloneDirs
98
+ // recorded as workspaces. `home` is passed in to keep this pure.
99
+ export function defaultSessionsDir(home: string): string {
100
+ return `${home}/.thought-layer/sessions`;
101
+ }
102
+
103
+ // Resolve the clone dir an op targets. Precedence mirrors resolveStatePath:
104
+ // an explicit --dir wins, then the env var, then the selected workspace, then
105
+ // the personal default. Returns the absolute dir (relative inputs are the
106
+ // caller's responsibility to absolutize).
107
+ export function resolveCloneDir(opts: {
108
+ explicit?: string;
109
+ env?: string;
110
+ workspace?: SyncWorkspace | null;
111
+ home: string;
112
+ }): string {
113
+ if (opts.explicit && opts.explicit.trim()) return opts.explicit.trim();
114
+ if (opts.env && opts.env.trim()) return opts.env.trim();
115
+ if (opts.workspace?.cloneDir) return opts.workspace.cloneDir;
116
+ return defaultSessionsDir(opts.home);
117
+ }
118
+
119
+ // ---- git status parsing ------------------------------------------------------
120
+
121
+ export interface GitStatus {
122
+ branch: string | null;
123
+ ahead: number;
124
+ behind: number;
125
+ dirty: boolean; // any tracked change or untracked file
126
+ files: string[]; // the changed/untracked paths
127
+ }
128
+
129
+ // Parse `git status --porcelain=v1 --branch`. The first line is a `## ` branch
130
+ // header (with optional "[ahead N, behind M]"); the rest are file entries.
131
+ export function parseGitStatus(out: string): GitStatus {
132
+ const lines = String(out || "").split("\n").filter((l) => l.length > 0);
133
+ let branch: string | null = null;
134
+ let ahead = 0;
135
+ let behind = 0;
136
+ const files: string[] = [];
137
+ for (const line of lines) {
138
+ if (line.startsWith("## ")) {
139
+ const header = line.slice(3);
140
+ branch = header.split(/\.\.\.| /)[0] || null;
141
+ const a = header.match(/ahead (\d+)/);
142
+ const b = header.match(/behind (\d+)/);
143
+ if (a) ahead = Number(a[1]);
144
+ if (b) behind = Number(b[1]);
145
+ } else {
146
+ files.push(line.slice(3).trim());
147
+ }
148
+ }
149
+ return { branch, ahead, behind, dirty: files.length > 0, files };
150
+ }