@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.
- package/README.md +7 -3
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +7 -0
- package/core/merge.ts +157 -0
- package/core/notion-io.ts +292 -0
- package/core/notion.ts +312 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +432 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +1866 -45
- package/extensions/thought-layer.ts +93 -2
- package/package.json +8 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +9 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-framework/SKILL.md +9 -0
- package/skills/thought-layer-wiki/SKILL.md +43 -0
package/core/sync-io.ts
ADDED
|
@@ -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
|
+
}
|