@hobocode/thought-layer 0.6.1 → 0.8.5

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.
@@ -0,0 +1,432 @@
1
+ // Node IO for the git-backed session sync, shared by the `tl sync` CLI and the
2
+ // tl_sync Pi tool. The pure transforms live in sync.ts and merge.ts; this is the
3
+ // git/gh shell-out layer (spawnSync, an args array, no shell), mirroring the
4
+ // Netlify CLI delegation in deploy-io.ts.
5
+ //
6
+ // Git is transport + history + multi-user ONLY. The kit owns every byte of JSON
7
+ // reconciliation via mergeProgressStates, and a .gitattributes line pins the
8
+ // session files to merge=ours so git never textually merges the envelope.
9
+ // Collaboration is delegated entirely to GitHub: the kit adds collaborators to
10
+ // NOTHING (no gh api permissions call); init just prints the repo URL and a
11
+ // pointer that the user grants access on GitHub themselves.
12
+ //
13
+ // Secrets: nothing here reads or writes a token. gh uses its own keyring and git
14
+ // its credential helper; no token is ever placed on argv or persisted. Session
15
+ // state holds validation/design data, never secrets.
16
+ //
17
+ // Copy rule: no em-dashes, no en-dashes, no spaced hyphen dashes in any message.
18
+
19
+ import { spawnSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { dirname, isAbsolute, join, resolve } from "node:path";
23
+ import {
24
+ slugify, parseSyncConfig, serializeSyncConfig, emptySyncConfig, selectWorkspace, defaultSessionsDir,
25
+ parseGitStatus, type SyncConfig, type SyncWorkspace,
26
+ } from "./sync.ts";
27
+ import { STATE_DIR, listStateFiles, loadStateFile, saveStateFile, resolveStatePath } from "./state-file.ts";
28
+ import { summarizeState, parseProgress, buildProgress, serializeProgress } from "./progress.ts";
29
+ import { mergeProgressStates } from "./merge.ts";
30
+ import type { StateOpResult } from "./state-ops.ts";
31
+
32
+ export interface SyncRunOptions {
33
+ op: string; // init | save | list | open | pull | push | status
34
+ name?: string; // session name (save/open) or workspace label (init)
35
+ repo?: string; // init: the GitHub repo to clone or create (owner/name or URL)
36
+ dir?: string; // explicit clone dir
37
+ workspace?: string; // select an existing workspace by label
38
+ message?: string; // commit message
39
+ noPush?: boolean; // commit only, do not push
40
+ path?: string; // current working state file (save reads from here)
41
+ }
42
+
43
+ // ---- probes (guided fallbacks, like hasNetlifyCli/cliLoggedIn) ---------------
44
+
45
+ export function hasGit(): boolean {
46
+ try { return spawnSync("git", ["--version"], { encoding: "utf8", timeout: 15000 }).status === 0; }
47
+ catch { return false; }
48
+ }
49
+ export function hasGh(): boolean {
50
+ try { return spawnSync("gh", ["--version"], { encoding: "utf8", timeout: 15000 }).status === 0; }
51
+ catch { return false; }
52
+ }
53
+ export function ghAuthed(): boolean {
54
+ try { return spawnSync("gh", ["auth", "status"], { encoding: "utf8", timeout: 20000 }).status === 0; }
55
+ catch { return false; }
56
+ }
57
+
58
+ // ---- config IO ---------------------------------------------------------------
59
+
60
+ function syncConfigPath(): string {
61
+ return process.env["THOUGHT_LAYER_SYNC_CONFIG"] || join(homedir(), ".thought-layer", "sync.json");
62
+ }
63
+ export function loadConfig(): SyncConfig {
64
+ const p = syncConfigPath();
65
+ if (!existsSync(p)) return emptySyncConfig();
66
+ try { return parseSyncConfig(readFileSync(p, "utf8")); }
67
+ catch { return emptySyncConfig(); }
68
+ }
69
+ function saveConfig(cfg: SyncConfig): void {
70
+ const p = syncConfigPath();
71
+ mkdirSync(dirname(p), { recursive: true });
72
+ writeFileSync(p, serializeSyncConfig(cfg));
73
+ }
74
+
75
+ // ---- git helpers -------------------------------------------------------------
76
+
77
+ export interface Run { status: number; out: string; err: string; }
78
+ // Exported so the artifacts delivery layer (artifacts-io.ts) reuses the exact
79
+ // same git shell-out plumbing rather than re-implementing spawnSync.
80
+ export function git(dir: string | null, args: string[], timeout = 120000): Run {
81
+ const r = spawnSync("git", dir ? ["-C", dir, ...args] : args, { encoding: "utf8", timeout });
82
+ return { status: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" };
83
+ }
84
+ export function isGitRepo(dir: string): boolean {
85
+ return existsSync(join(dir, ".git")) && git(dir, ["rev-parse", "--is-inside-work-tree"]).status === 0;
86
+ }
87
+ function dirNonEmpty(dir: string): boolean {
88
+ try { return existsSync(dir) && readdirSync(dir).length > 0; }
89
+ catch { return false; }
90
+ }
91
+ const absDir = (d: string, cwd = process.cwd()): string => (isAbsolute(d) ? d : resolve(cwd, d));
92
+
93
+ // ---- the clone scaffolding files written on init -----------------------------
94
+
95
+ const GITATTRIBUTES = `# The kit reconciles session JSON itself; never let git textually merge it\n# (which would corrupt the envelope). -merge keeps our copy in the working tree\n# on a conflict; the kit then rebuilds the merged result from the clean blobs.\n.thought-layer/*.json -merge\n`;
96
+ const GITIGNORE = `# A Thought Layer sessions repo holds session state only. Built product\n# artifacts and secrets never sync here.\nbuild.json\ndeploy.json\n*.local\n.env\n.env.*\n!.env.example\ndist/\n.netlify/\nnode_modules/\n# Delivered artifacts (tl artifacts) ARE intentionally tracked, even though the\n# same filenames are ignored elsewhere in the tree.\n!artifacts/\n!artifacts/**\n`;
97
+ const README = `# Thought Layer sessions\n\nThis private repo is the home for Thought Layer session files. Each session is one\nfile under \`.thought-layer/<name>.json\` (the portable validation and design state).\nUse the kit to work with them:\n\n tl sync open --name <session> pull and resume a session\n tl sync save --name <session> snapshot the current state, commit, and push\n tl sync list list the sessions in this repo\n\nCollaboration is handled by GitHub: add a collaborator to this repo in its GitHub\nsettings, and they can clone it and run the kit against the same sessions.\nThe kit reconciles concurrent edits itself (newest wins per field, conflicts are\nreported), so git never has to merge the JSON by hand.\n`;
98
+
99
+ function writeCloneScaffold(cloneDir: string): void {
100
+ mkdirSync(join(cloneDir, STATE_DIR), { recursive: true });
101
+ const put = (name: string, body: string): void => {
102
+ const p = join(cloneDir, name);
103
+ if (!existsSync(p)) writeFileSync(p, body);
104
+ };
105
+ put(".gitattributes", GITATTRIBUTES);
106
+ put(".gitignore", GITIGNORE);
107
+ put("README.md", README);
108
+ }
109
+
110
+ // ---- result helpers ----------------------------------------------------------
111
+
112
+ const ok = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: true, message, details });
113
+ const fail = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: false, message, details });
114
+
115
+ const collaboratorPointer = (repo: string): string =>
116
+ `To collaborate, add people to ${repo} in its GitHub settings (Settings, Collaborators). They clone it and run the kit; the kit never changes GitHub permissions.`;
117
+
118
+ // ---- the orchestrator --------------------------------------------------------
119
+
120
+ export async function runSync(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): Promise<StateOpResult> {
121
+ if (!hasGit()) {
122
+ return fail("git is not installed. Install git, then re-run. The sync feature stores your session files in your own private GitHub repo.", { needs: "git" });
123
+ }
124
+ try {
125
+ switch (opts.op) {
126
+ case "init": return syncInit(opts);
127
+ case "save": return syncSave(opts, ctx);
128
+ case "list": return syncList(opts);
129
+ case "open": return syncOpen(opts, ctx);
130
+ case "pull": return syncPull(opts, ctx);
131
+ case "push": return syncPush(opts);
132
+ case "status": return syncStatus(opts);
133
+ default: return fail(`Unknown sync op "${opts.op}". Use one of: init, save, list, open, pull, push, status.`);
134
+ }
135
+ } catch (e) {
136
+ return fail(`Sync ${opts.op} failed: ${(e as Error).message}`);
137
+ }
138
+ }
139
+
140
+ // Resolve the workspace + clone dir an op targets (everything except init).
141
+ // Exported so artifacts-io.ts resolves the same sessions workspace.
142
+ export function resolveWorkspace(opts: SyncRunOptions, cfg: SyncConfig): { cloneDir: string; ws: SyncWorkspace | null } {
143
+ if (opts.dir && opts.dir.trim()) return { cloneDir: absDir(opts.dir), ws: null };
144
+ const env = process.env["THOUGHT_LAYER_SESSIONS_DIR"];
145
+ if (env && env.trim()) return { cloneDir: absDir(env), ws: null };
146
+ const ws = selectWorkspace(cfg, opts.workspace);
147
+ if (ws) return { cloneDir: ws.cloneDir, ws };
148
+ return { cloneDir: defaultSessionsDir(homedir()), ws: null };
149
+ }
150
+
151
+ // ---- init --------------------------------------------------------------------
152
+
153
+ function syncInit(opts: SyncRunOptions): StateOpResult {
154
+ const repo = (opts.repo || "").trim();
155
+ if (!repo) return fail("Pass the private repo to use: tl sync init --repo <owner/name or url> [--name <label>] [--dir <path>].");
156
+
157
+ const home = homedir();
158
+ const label = (opts.name || "").trim();
159
+ const cloneDir = opts.dir
160
+ ? absDir(opts.dir)
161
+ : !label || slugify(label) === "personal"
162
+ ? defaultSessionsDir(home)
163
+ : join(home, ".thought-layer", `sessions-${slugify(label)}`);
164
+
165
+ if (dirNonEmpty(cloneDir)) {
166
+ if (isGitRepo(cloneDir)) return fail(`${cloneDir} is already a git repo. It looks initialized; use tl sync list or pick another --dir.`, { cloneDir });
167
+ return fail(`${cloneDir} already exists and is not empty. Pick another --dir or remove it first.`, { cloneDir });
168
+ }
169
+
170
+ // Owner/name (no scheme) routes through gh when available; a URL or local path
171
+ // routes through plain git clone.
172
+ const isOwnerName = /^[\w.-]+\/[\w.-]+$/.test(repo);
173
+ const useGh = isOwnerName && hasGh() && ghAuthed();
174
+
175
+ mkdirSync(dirname(cloneDir), { recursive: true });
176
+ let cloned = false;
177
+ if (useGh) {
178
+ cloned = spawnSync("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 180000 }).status === 0;
179
+ if (!cloned) {
180
+ // Repo may not exist yet: create it private (in the user's account), then clone.
181
+ const created = spawnSync("gh", ["repo", "create", repo, "--private"], { encoding: "utf8", timeout: 180000 });
182
+ if (created.status !== 0) {
183
+ return fail(`Could not clone or create ${repo} with gh. Create the private repo on GitHub yourself, then re-run. gh said: ${(created.stderr || "").slice(0, 300)}`);
184
+ }
185
+ cloned = spawnSync("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 180000 }).status === 0;
186
+ }
187
+ } else {
188
+ const url = isOwnerName ? `https://github.com/${repo}.git` : repo;
189
+ cloned = git(null, ["clone", url, cloneDir], 180000).status === 0;
190
+ if (!cloned) {
191
+ return fail(
192
+ isOwnerName && !hasGh()
193
+ ? `Could not clone https://github.com/${repo}.git. Create the private repo on GitHub first (or install gh and run gh auth login so the kit can create it), then re-run.`
194
+ : `Could not clone ${repo}. Check the repo path or URL and your git access, then re-run.`,
195
+ { repo, cloneDir },
196
+ );
197
+ }
198
+ }
199
+
200
+ // Scaffold the clone and make the first commit if anything is new.
201
+ writeCloneScaffold(cloneDir);
202
+ git(cloneDir, ["add", "-A"]);
203
+ const committed = git(cloneDir, ["commit", "-m", "Initialize Thought Layer sessions"]).status === 0;
204
+ let branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
205
+ if (committed && branch === "HEAD") { git(cloneDir, ["branch", "-M", "main"]); branch = "main"; }
206
+ let pushed = false;
207
+ if (committed) pushed = git(cloneDir, ["push", "-u", "origin", branch]).status === 0;
208
+
209
+ // Record the workspace and make it active.
210
+ const cfg = loadConfig();
211
+ const wsName = label || "personal";
212
+ const ws: SyncWorkspace = { name: wsName, repo, defaultBranch: branch, cloneDir };
213
+ cfg.workspaces = [...cfg.workspaces.filter((w) => w.cloneDir !== cloneDir && w.name !== wsName), ws];
214
+ cfg.activeWorkspace = wsName;
215
+ saveConfig(cfg);
216
+
217
+ return ok(
218
+ `Initialized the "${wsName}" sessions workspace at ${cloneDir} (repo ${repo}).` +
219
+ `${committed ? (pushed ? " Pushed the initial commit." : " Committed locally; push it once your git access is set.") : " The repo already had content; left it as is."}` +
220
+ `\n${collaboratorPointer(repo)}` +
221
+ `\nSave your first session with: tl sync save --name <name>${label ? ` --workspace ${wsName}` : ""}.`,
222
+ { cloneDir, repo, workspace: wsName, branch, committed, pushed },
223
+ );
224
+ }
225
+
226
+ // ---- save --------------------------------------------------------------------
227
+
228
+ function syncSave(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
229
+ const cfg = loadConfig();
230
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
231
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
232
+
233
+ const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
234
+ if (!slug) return fail("Name the session: tl sync save --name <name> (for example photobooth, peptide, blogging).");
235
+
236
+ // Snapshot the current working state into the clone under <slug>.json. Source
237
+ // precedence: an explicit --path or THOUGHT_LAYER_STATE wins (snapshot a
238
+ // separate project's state into this named session); otherwise, if the session
239
+ // file already exists, snapshot it in place (the "work directly in the clone"
240
+ // flow, so save is just commit + push); otherwise start from the default.
241
+ const targetPath = join(cloneDir, STATE_DIR, `${slug}.json`);
242
+ const existed = existsSync(targetPath);
243
+ const useExplicit = !!((opts.path && opts.path.trim()) || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
244
+ const loadTarget = useExplicit ? opts.path : existed ? targetPath : opts.path;
245
+ const source = loadStateFile(loadTarget).state;
246
+ saveStateFile(source, { target: targetPath, ts: ctx.ts, exportedAt: ctx.exportedAt });
247
+
248
+ git(cloneDir, ["add", "-A"]);
249
+ const msg = opts.message || `${existed ? "Update" : "Save"} session ${slug}`;
250
+ const commit = git(cloneDir, ["commit", "-m", msg]);
251
+ const committed = commit.status === 0;
252
+ let pushed = false;
253
+ let pushNote = "";
254
+ if (committed && !opts.noPush) {
255
+ const p = git(cloneDir, ["push"]);
256
+ pushed = p.status === 0;
257
+ if (!pushed) pushNote = ` Could not push (${(p.err || "").split("\n")[0] || "see git output"}); commit is local, run tl sync push when ready.`;
258
+ }
259
+
260
+ // Record the active session for this workspace.
261
+ if (ws) {
262
+ ws.activeSession = `${slug}.json`;
263
+ saveConfig(cfg);
264
+ }
265
+
266
+ return ok(
267
+ `${existed ? "Updated" : "Saved"} session ${slug} in ${cloneDir}.` +
268
+ `${committed ? (opts.noPush ? " Committed locally (no push)." : pushed ? " Committed and pushed." : pushNote) : " Nothing changed since the last save."}`,
269
+ { cloneDir, session: `${slug}.json`, path: targetPath, committed, pushed },
270
+ );
271
+ }
272
+
273
+ // ---- list --------------------------------------------------------------------
274
+
275
+ function syncList(opts: SyncRunOptions): StateOpResult {
276
+ const cfg = loadConfig();
277
+ const { cloneDir } = resolveWorkspace(opts, cfg);
278
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
279
+ const files = listStateFiles(cloneDir);
280
+ if (!files.length) return ok(`No sessions yet in ${cloneDir}. Create one with tl sync save --name <name>.`, { cloneDir, sessions: [] });
281
+ const rows = files.map((f) => {
282
+ try {
283
+ const sum = summarizeState(loadStateFile(f.path).state);
284
+ return { name: f.name, path: f.path, answered: sum.answered, artifacts: sum.artifacts };
285
+ } catch {
286
+ return { name: f.name, path: f.path, answered: 0, artifacts: [] as string[], unreadable: true };
287
+ }
288
+ });
289
+ // Dash-free rows (the state-ops list render uses a banned " - ").
290
+ const lines = rows
291
+ .map((r) => ` ${r.name}: ${r.answered} answered${r.artifacts.length ? `, artifacts ${r.artifacts.join(", ")}` : ""}${"unreadable" in r ? " (unreadable)" : ""}`)
292
+ .join("\n");
293
+ return ok(`${files.length} session(s) in ${cloneDir}:\n${lines}\nOpen one with tl sync open --name <name>.`, { cloneDir, sessions: rows });
294
+ }
295
+
296
+ // ---- open --------------------------------------------------------------------
297
+
298
+ function syncOpen(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
299
+ const cfg = loadConfig();
300
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
301
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
302
+ const slug = slugify(opts.name || "");
303
+ if (!slug) return fail("Name the session to open: tl sync open --name <name>. List them with tl sync list.");
304
+
305
+ const pullResult = pullAndReconcile(cloneDir, ctx);
306
+ const targetPath = join(cloneDir, STATE_DIR, `${slug}.json`);
307
+ if (!existsSync(targetPath)) {
308
+ return fail(`No session "${slug}" in ${cloneDir} after pulling. List them with tl sync list, or create it with tl sync save --name ${slug}.`, { cloneDir });
309
+ }
310
+ if (ws) { ws.activeSession = `${slug}.json`; saveConfig(cfg); }
311
+
312
+ return ok(
313
+ `Opened session ${slug} (pulled latest${pullResult.merged ? `, reconciled local and remote edits${pullResult.coarse.length ? ` (review: ${pullResult.coarse.join(", ")})` : ""}` : ""}).` +
314
+ `\nWork on it by pointing the kit at this file:` +
315
+ `\n export THOUGHT_LAYER_STATE="${targetPath}"` +
316
+ `\nor pass --path "${targetPath}" to tl read/answer/artifact. Save with tl sync save --name ${slug}.`,
317
+ { cloneDir, session: `${slug}.json`, path: targetPath, merged: pullResult.merged, coarse: pullResult.coarse },
318
+ );
319
+ }
320
+
321
+ // ---- pull (with kit reconciliation) ------------------------------------------
322
+
323
+ interface PullResult { merged: boolean; coarse: string[]; note: string; }
324
+
325
+ // Pull and reconcile. Deterministic via merge-base, NOT git's merge exit code:
326
+ // fast-forward and ahead-only are handled directly, and on true divergence the
327
+ // kit rebuilds every changed session file from the two clean committed blobs
328
+ // (ignoring whatever git left in the conflicted working tree), takes the remote
329
+ // copy of any non-session file, and records a real two-parent merge commit.
330
+ function pullAndReconcile(cloneDir: string, ctx: { ts: number; exportedAt: string }): PullResult {
331
+ const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
332
+ if (git(cloneDir, ["fetch", "origin", branch], 120000).status !== 0) {
333
+ return { merged: false, coarse: [], note: "no remote branch to fetch yet" };
334
+ }
335
+ const local = git(cloneDir, ["rev-parse", "HEAD"]).out.trim();
336
+ const remote = git(cloneDir, ["rev-parse", `origin/${branch}`]).out.trim();
337
+ if (!remote || local === remote) return { merged: false, coarse: [], note: "up to date" };
338
+ const base = git(cloneDir, ["merge-base", local, remote]).out.trim();
339
+ if (base === remote) return { merged: false, coarse: [], note: "local is ahead; nothing to pull" };
340
+ if (base === local) {
341
+ git(cloneDir, ["merge", "--ff-only", `origin/${branch}`], 120000);
342
+ return { merged: false, coarse: [], note: "fast-forward" };
343
+ }
344
+
345
+ // True divergence. Start the merge (do not let it commit), then resolve every
346
+ // differing path ourselves from the two clean blobs.
347
+ const changed = git(cloneDir, ["diff", "--name-only", local, remote]).out.split("\n").map((s) => s.trim()).filter(Boolean);
348
+ git(cloneDir, ["merge", "--no-ff", "--no-commit", `origin/${branch}`], 120000);
349
+ const coarseAll: string[] = [];
350
+ let reconciled = 0;
351
+ for (const rel of changed) {
352
+ if (rel.startsWith(`${STATE_DIR}/`) && rel.endsWith(".json")) {
353
+ const ours = readShow(cloneDir, local, rel);
354
+ const theirs = readShow(cloneDir, remote, rel);
355
+ if (ours && theirs) {
356
+ try {
357
+ const op = parseProgress(ours);
358
+ const tp = parseProgress(theirs);
359
+ const { state, coarse } = mergeProgressStates(op.state, tp.state, { oursTs: op.writer?.ts ?? 0, theirsTs: tp.writer?.ts ?? 0 });
360
+ writeFileSync(join(cloneDir, rel), serializeProgress(buildProgress(state, { kind: "kit", ts: ctx.ts }, ctx.exportedAt)));
361
+ coarseAll.push(...coarse);
362
+ reconciled++;
363
+ } catch {
364
+ if (theirs) writeFileSync(join(cloneDir, rel), theirs);
365
+ }
366
+ } else if (theirs && !ours) {
367
+ writeFileSync(join(cloneDir, rel), theirs);
368
+ reconciled++;
369
+ }
370
+ } else {
371
+ // Non-session file: take the remote copy (session JSON is the only thing
372
+ // the kit reconciles; everything else follows the remote).
373
+ const theirs = readShow(cloneDir, remote, rel);
374
+ if (theirs !== null) writeFileSync(join(cloneDir, rel), theirs);
375
+ }
376
+ git(cloneDir, ["add", "--", rel]);
377
+ }
378
+ git(cloneDir, ["add", "-A"]);
379
+ git(cloneDir, ["commit", "--no-edit", "-m", "Reconcile sessions (kit merge)"]);
380
+ return { merged: reconciled > 0, coarse: Array.from(new Set(coarseAll)).sort(), note: `reconciled ${reconciled} session file(s)` };
381
+ }
382
+
383
+ function readShow(cloneDir: string, ref: string, rel: string): string | null {
384
+ const r = git(cloneDir, ["show", `${ref}:${rel}`]);
385
+ return r.status === 0 ? r.out : null;
386
+ }
387
+
388
+ function syncPull(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
389
+ const cfg = loadConfig();
390
+ const { cloneDir } = resolveWorkspace(opts, cfg);
391
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
392
+ const r = pullAndReconcile(cloneDir, ctx);
393
+ const push = git(cloneDir, ["push"]);
394
+ return ok(
395
+ `Pulled ${cloneDir}.` +
396
+ `${r.merged ? ` Reconciled local and remote edits${r.coarse.length ? ` (a coarse tie-break dropped one side for: ${r.coarse.join(", ")})` : ""}.` : " Already up to date or fast-forwarded."}` +
397
+ `${r.merged && push.status === 0 ? " Pushed the reconciliation." : ""}`,
398
+ { cloneDir, merged: r.merged, coarse: r.coarse },
399
+ );
400
+ }
401
+
402
+ // ---- push --------------------------------------------------------------------
403
+
404
+ function syncPush(opts: SyncRunOptions): StateOpResult {
405
+ const cfg = loadConfig();
406
+ const { cloneDir } = resolveWorkspace(opts, cfg);
407
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
408
+ git(cloneDir, ["add", "-A"]);
409
+ const committed = git(cloneDir, ["commit", "-m", opts.message || "Sync sessions"]).status === 0;
410
+ const p = git(cloneDir, ["push"]);
411
+ if (p.status !== 0) return fail(`Push failed: ${(p.err || "").split("\n")[0] || "see git output"}. Pull first (tl sync pull), then push.`, { cloneDir });
412
+ return ok(`Pushed ${cloneDir}.${committed ? " Committed pending changes." : " Nothing new to commit; pushed any local commits."}`, { cloneDir, committed });
413
+ }
414
+
415
+ // ---- status ------------------------------------------------------------------
416
+
417
+ function syncStatus(opts: SyncRunOptions): StateOpResult {
418
+ const cfg = loadConfig();
419
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
420
+ if (!isGitRepo(cloneDir)) {
421
+ const known = cfg.workspaces.length ? ` Known workspaces: ${cfg.workspaces.map((w) => w.name).join(", ")}.` : "";
422
+ return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.${known}`, { cloneDir, workspaces: cfg.workspaces });
423
+ }
424
+ const st = parseGitStatus(git(cloneDir, ["status", "--porcelain=v1", "--branch"]).out);
425
+ const sessions = listStateFiles(cloneDir).length;
426
+ return ok(
427
+ `Workspace ${ws?.name || "(by dir)"} at ${cloneDir}: ${sessions} session(s), branch ${st.branch || "?"}, ` +
428
+ `${st.ahead} ahead, ${st.behind} behind, ${st.dirty ? `${st.files.length} uncommitted change(s)` : "clean"}.` +
429
+ `${ws?.activeSession ? ` Active session: ${ws.activeSession}.` : ""}`,
430
+ { cloneDir, workspace: ws?.name || null, sessions, ...st },
431
+ );
432
+ }
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
+ }