@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/README.md CHANGED
@@ -24,9 +24,9 @@ This is open source and BYOK by design. The point is to help people build real t
24
24
 
25
25
  **A Pi package** that adds, on top of the skills:
26
26
 
27
- - **Deterministic tools** the agent can call so the math is exact and never re-derived: `tl_score` (confidence to status and grade), `tl_domains` (availability, BYOK), `tl_project` (the numeric business projection), `tl_state` (the portable progress file), `tl_scaffold` (a deterministic, deployable static site from the spec + brand), and `deploy` (take the build live to a URL you own).
27
+ - **Deterministic tools** the agent can call so the math is exact and never re-derived: `tl_score` (confidence to status and grade), `tl_domains` (availability, BYOK), `tl_project` (the numeric business projection), `tl_state` (the portable progress file), `tl_scaffold` (a deterministic, deployable static site from the spec + brand), `deploy` (take the build live to a URL you own), and `tl_sync` (store and sync your sessions in your own private GitHub repo).
28
28
  - **Slash commands** (prompt templates): `/tl` runs the whole flow; `/tl-speedrun` is the fast unranked path; `/tl-panel`, `/tl-grill`, `/tl-prd`, `/tl-naming` run each stage; `/tl-build` builds the hardened PRD into a deploy-ready artifact; `/tl-deploy` takes it live.
29
- - **A `tl` CLI** for any shell agent (`npx -y @hobocode/thought-layer tl ...`): `read`/`list`/`answer`/`feedback`/`artifact`/`cursor`/`export` for the shared progress file, `scaffold` for the deployable static-site floor, and `deploy` to take the build live.
29
+ - **A `tl` CLI** for any shell agent (`npx -y @hobocode/thought-layer tl ...`): `read`/`list`/`answer`/`feedback`/`artifact`/`cursor`/`export` for the shared progress file, `scaffold` for the deployable static-site floor, `deploy` to take the build live, and `sync` to store and version your sessions in your own private GitHub repo.
30
30
 
31
31
  ## Install
32
32
 
@@ -71,6 +71,7 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
71
71
  - **Phase 4 (done):** a `deploy` step (`/tl-deploy`, the `deploy` tool, or `tl deploy`) that takes the build live to a URL you own, closing the loop. With a Netlify token it deploys into your own account via the file-digest API (owned immediately, no claim step); with no token it delegates to your Netlify CLI - logged in it creates a site in your account, logged out it deploys anonymously with a one-hour claim link. BYOK, no central account, no lock-in. `--dry-run` shows the plan first.
72
72
  - **Backend-capable build (done):** when the three-question backend test shows a product genuinely needs a server, `/tl-build` emits a real backend alongside the static front end (serverless functions per backend requirement, a `schema.sql`, a names-only `.env.example`, an updated `netlify.toml`, and a `BACKEND.md` guide), with Neon Postgres as the documented default and overridable to any Postgres. Static stays the default, gated by the same backend test.
73
73
  - **Backend deploy (done):** when `build.json` declares a backend, `/tl-deploy` (the `deploy` tool, or `tl deploy`) ships it automatically alongside the front end: the functions go up via your Netlify CLI and the declared env var names are set on the site (values read only from your environment, BYOK). `DATABASE_URL` is bring-your-own by default; `--provision-db` (your own Neon key) and `--apply-schema` (psql) are opt in, and `--static-only` ships just the front end. Owned, no lock-in.
74
+ - **Sessions and collaboration (done):** `tl sync` (and the `tl_sync` tool) stores your session files in your OWN private GitHub repo. Save any number of named sessions (one private repo for your own projects, a separate repo per founder you collaborate with), list and open them, and sync. Git carries history and multi-user; the kit reconciles concurrent edits itself (newest wins per field, conflicts reported), so it never hand-merges JSON. Collaboration is granted on GitHub (you add collaborators, the kit never changes permissions). BYOK, no central account.
74
75
 
75
76
  ## Notes for contributors
76
77
 
package/core/index.ts CHANGED
@@ -15,6 +15,9 @@ export * from "./stages.ts";
15
15
  export * from "./stage-map.ts";
16
16
  export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
+ export * from "./merge.ts";
19
+ export * from "./sync.ts";
20
+ export * from "./sync-io.ts";
18
21
  export * from "./backend.ts";
19
22
  export * from "./backend-io.ts";
20
23
  export * from "./scaffold.ts";
package/core/merge.ts ADDED
@@ -0,0 +1,156 @@
1
+ // Pure two-way reconciler for the portable progress state. The kit owns this so
2
+ // git can be transport + history only: when two clones of the same session
3
+ // diverge, the sync layer reads both sides and calls mergeProgressStates rather
4
+ // than letting git textually merge the pretty-printed JSON (which would corrupt
5
+ // the envelope). This is the kit-local cousin of the web app's buildMergedState,
6
+ // adapted for an agent/CLI context: NO interactive conflict dialog, newest-wins
7
+ // by the timestamps the envelope actually carries, and every coarse tie-break is
8
+ // reported so the caller can tell the user where a side was dropped.
9
+ //
10
+ // LIMIT (documented, by design): the envelope has no per-field clock, only the
11
+ // whole-file writer.ts and the kit namespace updatedAt. So a field edited
12
+ // concurrently on both sides tie-breaks coarsely (newest file wins) and the
13
+ // dropped side is listed in `coarse`. Per-field clocks are deferred future work
14
+ // (they would need a PROGRESS_FORMAT bump mirrored into the web app).
15
+ //
16
+ // Pure: no fs, no node deps. Changes NO envelope format and does not bump
17
+ // PROGRESS_FORMAT, so the lossless web<->kit handoff is untouched.
18
+
19
+ import { KNOWN_STATE_KEYS, type ProgressState, type KitNamespace } from "./progress.ts";
20
+
21
+ export interface MergeResult {
22
+ state: ProgressState;
23
+ coarse: string[]; // fields whose conflict was resolved by a coarse tie-break
24
+ }
25
+
26
+ export interface MergeOpts {
27
+ oursTs: number; // our file's writer.ts
28
+ theirsTs: number; // their file's writer.ts
29
+ }
30
+
31
+ const ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"] as const;
32
+
33
+ const num = (v: unknown): number => (typeof v === "number" && !Number.isNaN(v) ? v : 0);
34
+ const genAt = (v: unknown): number =>
35
+ v && typeof v === "object" ? num((v as Record<string, unknown>)["generatedAt"]) : 0;
36
+ const jsonEq = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
37
+ const rec = (v: unknown): Record<string, unknown> =>
38
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
39
+
40
+ // Merge `ours` and `theirs` into one state. Tie-break direction: a strictly
41
+ // newer file wins; on an exact timestamp tie the incoming side (theirs) wins, so
42
+ // the result is deterministic regardless of which clone runs the merge.
43
+ export function mergeProgressStates(ours: ProgressState, theirs: ProgressState, opts: MergeOpts): MergeResult {
44
+ const coarse: string[] = [];
45
+ const oursNewer = opts.oursTs > opts.theirsTs; // strict; equal -> theirs
46
+
47
+ // ---- answers: union by qId; differing values tie-break coarsely ----
48
+ const answers: Record<string, unknown> = {};
49
+ const oa = rec(ours.answers);
50
+ const ta = rec(theirs.answers);
51
+ for (const k of new Set([...Object.keys(oa), ...Object.keys(ta)])) {
52
+ const inO = k in oa;
53
+ const inT = k in ta;
54
+ if (inO && !inT) answers[k] = oa[k];
55
+ else if (!inO && inT) answers[k] = ta[k];
56
+ else if (jsonEq(oa[k], ta[k])) answers[k] = oa[k];
57
+ else {
58
+ answers[k] = oursNewer ? oa[k] : ta[k];
59
+ coarse.push(`answers.${k}`);
60
+ }
61
+ }
62
+
63
+ // ---- feedback: union by qId; collision by round, then entry ts, then file ts ----
64
+ const feedback: Record<string, unknown> = {};
65
+ const of = rec(ours.feedback);
66
+ const tf = rec(theirs.feedback);
67
+ for (const k of new Set([...Object.keys(of), ...Object.keys(tf)])) {
68
+ const o = of[k];
69
+ const t = tf[k];
70
+ if (o != null && t == null) feedback[k] = o;
71
+ else if (o == null && t != null) feedback[k] = t;
72
+ else if (jsonEq(o, t)) feedback[k] = o;
73
+ else {
74
+ const oR = num(rec(o)["round"]);
75
+ const tR = num(rec(t)["round"]);
76
+ const oTs = num(rec(o)["ts"]);
77
+ const tTs = num(rec(t)["ts"]);
78
+ const pickOurs = oR !== tR ? oR > tR : oTs !== tTs ? oTs > tTs : oursNewer;
79
+ feedback[k] = pickOurs ? o : t;
80
+ coarse.push(`feedback.${k}`);
81
+ }
82
+ }
83
+
84
+ // ---- artifacts: non-null beats null; both differ -> newer generatedAt, else file ts ----
85
+ const artifact = (key: (typeof ARTIFACT_KEYS)[number]): unknown => {
86
+ const o = ours[key];
87
+ const t = theirs[key];
88
+ if (o == null && t == null) return null;
89
+ if (o != null && t == null) return o;
90
+ if (o == null && t != null) return t;
91
+ if (jsonEq(o, t)) return o;
92
+ const og = genAt(o);
93
+ const tg = genAt(t);
94
+ const pickOurs = og !== tg ? og > tg : oursNewer;
95
+ coarse.push(key);
96
+ return pickOurs ? o : t;
97
+ };
98
+
99
+ const merged: ProgressState = {
100
+ version: 2,
101
+ answers,
102
+ feedback,
103
+ bizModel: artifact("bizModel"),
104
+ grill: artifact("grill"),
105
+ assets: artifact("assets"),
106
+ research: artifact("research"),
107
+ swot: artifact("swot"),
108
+ prd: artifact("prd"),
109
+ naming: artifact("naming"),
110
+ brand: artifact("brand"),
111
+ kit: mergeKit(ours.kit, theirs.kit, oursNewer),
112
+ };
113
+
114
+ // ---- unknown future keys: carried through, newer side wins ----
115
+ // Apply the older side first, then the newer, so the newer value overwrites.
116
+ for (const src of oursNewer ? [theirs, ours] : [ours, theirs]) {
117
+ for (const k of Object.keys(src)) {
118
+ if (!(KNOWN_STATE_KEYS as readonly string[]).includes(k)) merged[k] = (src as Record<string, unknown>)[k];
119
+ }
120
+ }
121
+
122
+ return { state: merged, coarse: Array.from(new Set(coarse)).sort() };
123
+ }
124
+
125
+ // The kit namespace is agent-owned resume state. Union the additive parts
126
+ // (modulesRun, parked) and take the newer side for the singular ones (cursor),
127
+ // with updatedAt as the max of both clocks.
128
+ function mergeKit(o: KitNamespace | null, t: KitNamespace | null, oursNewer: boolean): KitNamespace | null {
129
+ if (!o && !t) return null;
130
+ if (o && !t) return o;
131
+ if (!o && t) return t;
132
+ const oo = o as KitNamespace;
133
+ const tt = t as KitNamespace;
134
+
135
+ const modulesRun = Array.from(new Set([...(oo.modulesRun || []), ...(tt.modulesRun || [])]));
136
+ const parked: Record<string, string[]> = {};
137
+ for (const src of [oo.parked || {}, tt.parked || {}]) {
138
+ for (const [k, v] of Object.entries(src)) {
139
+ parked[k] = Array.from(new Set([...(parked[k] || []), ...(Array.isArray(v) ? v : [])]));
140
+ }
141
+ }
142
+ const newer = oursNewer ? oo : tt;
143
+ const older = oursNewer ? tt : oo;
144
+ const panelMeta = { ...(older.panelMeta || {}), ...(newer.panelMeta || {}) };
145
+
146
+ const kit: KitNamespace = {
147
+ schema: Math.max(num(oo.schema) || 1, num(tt.schema) || 1),
148
+ updatedAt: Math.max(num(oo.updatedAt), num(tt.updatedAt)),
149
+ };
150
+ if (modulesRun.length) kit.modulesRun = modulesRun;
151
+ if (Object.keys(parked).length) kit.parked = parked;
152
+ const cursor = newer.cursor ?? older.cursor;
153
+ if (cursor) kit.cursor = cursor;
154
+ if (Object.keys(panelMeta).length) kit.panelMeta = panelMeta;
155
+ return kit;
156
+ }
@@ -0,0 +1,429 @@
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
+ 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
+ interface Run { status: number; out: string; err: string; }
78
+ function git(dir: string | null, args: string[], timeout = 120000): Run {
79
+ const r = spawnSync("git", dir ? ["-C", dir, ...args] : args, { encoding: "utf8", timeout });
80
+ return { status: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" };
81
+ }
82
+ function isGitRepo(dir: string): boolean {
83
+ return existsSync(join(dir, ".git")) && git(dir, ["rev-parse", "--is-inside-work-tree"]).status === 0;
84
+ }
85
+ function dirNonEmpty(dir: string): boolean {
86
+ try { return existsSync(dir) && readdirSync(dir).length > 0; }
87
+ catch { return false; }
88
+ }
89
+ const absDir = (d: string, cwd = process.cwd()): string => (isAbsolute(d) ? d : resolve(cwd, d));
90
+
91
+ // ---- the clone scaffolding files written on init -----------------------------
92
+
93
+ 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`;
94
+ 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`;
95
+ 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`;
96
+
97
+ function writeCloneScaffold(cloneDir: string): void {
98
+ mkdirSync(join(cloneDir, STATE_DIR), { recursive: true });
99
+ const put = (name: string, body: string): void => {
100
+ const p = join(cloneDir, name);
101
+ if (!existsSync(p)) writeFileSync(p, body);
102
+ };
103
+ put(".gitattributes", GITATTRIBUTES);
104
+ put(".gitignore", GITIGNORE);
105
+ put("README.md", README);
106
+ }
107
+
108
+ // ---- result helpers ----------------------------------------------------------
109
+
110
+ const ok = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: true, message, details });
111
+ const fail = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: false, message, details });
112
+
113
+ const collaboratorPointer = (repo: string): string =>
114
+ `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.`;
115
+
116
+ // ---- the orchestrator --------------------------------------------------------
117
+
118
+ export async function runSync(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): Promise<StateOpResult> {
119
+ if (!hasGit()) {
120
+ 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" });
121
+ }
122
+ try {
123
+ switch (opts.op) {
124
+ case "init": return syncInit(opts);
125
+ case "save": return syncSave(opts, ctx);
126
+ case "list": return syncList(opts);
127
+ case "open": return syncOpen(opts, ctx);
128
+ case "pull": return syncPull(opts, ctx);
129
+ case "push": return syncPush(opts);
130
+ case "status": return syncStatus(opts);
131
+ default: return fail(`Unknown sync op "${opts.op}". Use one of: init, save, list, open, pull, push, status.`);
132
+ }
133
+ } catch (e) {
134
+ return fail(`Sync ${opts.op} failed: ${(e as Error).message}`);
135
+ }
136
+ }
137
+
138
+ // Resolve the workspace + clone dir an op targets (everything except init).
139
+ function resolveWorkspace(opts: SyncRunOptions, cfg: SyncConfig): { cloneDir: string; ws: SyncWorkspace | null } {
140
+ if (opts.dir && opts.dir.trim()) return { cloneDir: absDir(opts.dir), ws: null };
141
+ const env = process.env["THOUGHT_LAYER_SESSIONS_DIR"];
142
+ if (env && env.trim()) return { cloneDir: absDir(env), ws: null };
143
+ const ws = selectWorkspace(cfg, opts.workspace);
144
+ if (ws) return { cloneDir: ws.cloneDir, ws };
145
+ return { cloneDir: defaultSessionsDir(homedir()), ws: null };
146
+ }
147
+
148
+ // ---- init --------------------------------------------------------------------
149
+
150
+ function syncInit(opts: SyncRunOptions): StateOpResult {
151
+ const repo = (opts.repo || "").trim();
152
+ if (!repo) return fail("Pass the private repo to use: tl sync init --repo <owner/name or url> [--name <label>] [--dir <path>].");
153
+
154
+ const home = homedir();
155
+ const label = (opts.name || "").trim();
156
+ const cloneDir = opts.dir
157
+ ? absDir(opts.dir)
158
+ : !label || slugify(label) === "personal"
159
+ ? defaultSessionsDir(home)
160
+ : join(home, ".thought-layer", `sessions-${slugify(label)}`);
161
+
162
+ if (dirNonEmpty(cloneDir)) {
163
+ if (isGitRepo(cloneDir)) return fail(`${cloneDir} is already a git repo. It looks initialized; use tl sync list or pick another --dir.`, { cloneDir });
164
+ return fail(`${cloneDir} already exists and is not empty. Pick another --dir or remove it first.`, { cloneDir });
165
+ }
166
+
167
+ // Owner/name (no scheme) routes through gh when available; a URL or local path
168
+ // routes through plain git clone.
169
+ const isOwnerName = /^[\w.-]+\/[\w.-]+$/.test(repo);
170
+ const useGh = isOwnerName && hasGh() && ghAuthed();
171
+
172
+ mkdirSync(dirname(cloneDir), { recursive: true });
173
+ let cloned = false;
174
+ if (useGh) {
175
+ cloned = spawnSync("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 180000 }).status === 0;
176
+ if (!cloned) {
177
+ // Repo may not exist yet: create it private (in the user's account), then clone.
178
+ const created = spawnSync("gh", ["repo", "create", repo, "--private"], { encoding: "utf8", timeout: 180000 });
179
+ if (created.status !== 0) {
180
+ 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)}`);
181
+ }
182
+ cloned = spawnSync("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 180000 }).status === 0;
183
+ }
184
+ } else {
185
+ const url = isOwnerName ? `https://github.com/${repo}.git` : repo;
186
+ cloned = git(null, ["clone", url, cloneDir], 180000).status === 0;
187
+ if (!cloned) {
188
+ return fail(
189
+ isOwnerName && !hasGh()
190
+ ? `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.`
191
+ : `Could not clone ${repo}. Check the repo path or URL and your git access, then re-run.`,
192
+ { repo, cloneDir },
193
+ );
194
+ }
195
+ }
196
+
197
+ // Scaffold the clone and make the first commit if anything is new.
198
+ writeCloneScaffold(cloneDir);
199
+ git(cloneDir, ["add", "-A"]);
200
+ const committed = git(cloneDir, ["commit", "-m", "Initialize Thought Layer sessions"]).status === 0;
201
+ let branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
202
+ if (committed && branch === "HEAD") { git(cloneDir, ["branch", "-M", "main"]); branch = "main"; }
203
+ let pushed = false;
204
+ if (committed) pushed = git(cloneDir, ["push", "-u", "origin", branch]).status === 0;
205
+
206
+ // Record the workspace and make it active.
207
+ const cfg = loadConfig();
208
+ const wsName = label || "personal";
209
+ const ws: SyncWorkspace = { name: wsName, repo, defaultBranch: branch, cloneDir };
210
+ cfg.workspaces = [...cfg.workspaces.filter((w) => w.cloneDir !== cloneDir && w.name !== wsName), ws];
211
+ cfg.activeWorkspace = wsName;
212
+ saveConfig(cfg);
213
+
214
+ return ok(
215
+ `Initialized the "${wsName}" sessions workspace at ${cloneDir} (repo ${repo}).` +
216
+ `${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."}` +
217
+ `\n${collaboratorPointer(repo)}` +
218
+ `\nSave your first session with: tl sync save --name <name>${label ? ` --workspace ${wsName}` : ""}.`,
219
+ { cloneDir, repo, workspace: wsName, branch, committed, pushed },
220
+ );
221
+ }
222
+
223
+ // ---- save --------------------------------------------------------------------
224
+
225
+ function syncSave(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
226
+ const cfg = loadConfig();
227
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
228
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
229
+
230
+ const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
231
+ if (!slug) return fail("Name the session: tl sync save --name <name> (for example photobooth, peptide, blogging).");
232
+
233
+ // Snapshot the current working state into the clone under <slug>.json. Source
234
+ // precedence: an explicit --path or THOUGHT_LAYER_STATE wins (snapshot a
235
+ // separate project's state into this named session); otherwise, if the session
236
+ // file already exists, snapshot it in place (the "work directly in the clone"
237
+ // flow, so save is just commit + push); otherwise start from the default.
238
+ const targetPath = join(cloneDir, STATE_DIR, `${slug}.json`);
239
+ const existed = existsSync(targetPath);
240
+ const useExplicit = !!((opts.path && opts.path.trim()) || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
241
+ const loadTarget = useExplicit ? opts.path : existed ? targetPath : opts.path;
242
+ const source = loadStateFile(loadTarget).state;
243
+ saveStateFile(source, { target: targetPath, ts: ctx.ts, exportedAt: ctx.exportedAt });
244
+
245
+ git(cloneDir, ["add", "-A"]);
246
+ const msg = opts.message || `${existed ? "Update" : "Save"} session ${slug}`;
247
+ const commit = git(cloneDir, ["commit", "-m", msg]);
248
+ const committed = commit.status === 0;
249
+ let pushed = false;
250
+ let pushNote = "";
251
+ if (committed && !opts.noPush) {
252
+ const p = git(cloneDir, ["push"]);
253
+ pushed = p.status === 0;
254
+ if (!pushed) pushNote = ` Could not push (${(p.err || "").split("\n")[0] || "see git output"}); commit is local, run tl sync push when ready.`;
255
+ }
256
+
257
+ // Record the active session for this workspace.
258
+ if (ws) {
259
+ ws.activeSession = `${slug}.json`;
260
+ saveConfig(cfg);
261
+ }
262
+
263
+ return ok(
264
+ `${existed ? "Updated" : "Saved"} session ${slug} in ${cloneDir}.` +
265
+ `${committed ? (opts.noPush ? " Committed locally (no push)." : pushed ? " Committed and pushed." : pushNote) : " Nothing changed since the last save."}`,
266
+ { cloneDir, session: `${slug}.json`, path: targetPath, committed, pushed },
267
+ );
268
+ }
269
+
270
+ // ---- list --------------------------------------------------------------------
271
+
272
+ function syncList(opts: SyncRunOptions): StateOpResult {
273
+ const cfg = loadConfig();
274
+ const { cloneDir } = resolveWorkspace(opts, cfg);
275
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
276
+ const files = listStateFiles(cloneDir);
277
+ if (!files.length) return ok(`No sessions yet in ${cloneDir}. Create one with tl sync save --name <name>.`, { cloneDir, sessions: [] });
278
+ const rows = files.map((f) => {
279
+ try {
280
+ const sum = summarizeState(loadStateFile(f.path).state);
281
+ return { name: f.name, path: f.path, answered: sum.answered, artifacts: sum.artifacts };
282
+ } catch {
283
+ return { name: f.name, path: f.path, answered: 0, artifacts: [] as string[], unreadable: true };
284
+ }
285
+ });
286
+ // Dash-free rows (the state-ops list render uses a banned " - ").
287
+ const lines = rows
288
+ .map((r) => ` ${r.name}: ${r.answered} answered${r.artifacts.length ? `, artifacts ${r.artifacts.join(", ")}` : ""}${"unreadable" in r ? " (unreadable)" : ""}`)
289
+ .join("\n");
290
+ return ok(`${files.length} session(s) in ${cloneDir}:\n${lines}\nOpen one with tl sync open --name <name>.`, { cloneDir, sessions: rows });
291
+ }
292
+
293
+ // ---- open --------------------------------------------------------------------
294
+
295
+ function syncOpen(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
296
+ const cfg = loadConfig();
297
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
298
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
299
+ const slug = slugify(opts.name || "");
300
+ if (!slug) return fail("Name the session to open: tl sync open --name <name>. List them with tl sync list.");
301
+
302
+ const pullResult = pullAndReconcile(cloneDir, ctx);
303
+ const targetPath = join(cloneDir, STATE_DIR, `${slug}.json`);
304
+ if (!existsSync(targetPath)) {
305
+ 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 });
306
+ }
307
+ if (ws) { ws.activeSession = `${slug}.json`; saveConfig(cfg); }
308
+
309
+ return ok(
310
+ `Opened session ${slug} (pulled latest${pullResult.merged ? `, reconciled local and remote edits${pullResult.coarse.length ? ` (review: ${pullResult.coarse.join(", ")})` : ""}` : ""}).` +
311
+ `\nWork on it by pointing the kit at this file:` +
312
+ `\n export THOUGHT_LAYER_STATE="${targetPath}"` +
313
+ `\nor pass --path "${targetPath}" to tl read/answer/artifact. Save with tl sync save --name ${slug}.`,
314
+ { cloneDir, session: `${slug}.json`, path: targetPath, merged: pullResult.merged, coarse: pullResult.coarse },
315
+ );
316
+ }
317
+
318
+ // ---- pull (with kit reconciliation) ------------------------------------------
319
+
320
+ interface PullResult { merged: boolean; coarse: string[]; note: string; }
321
+
322
+ // Pull and reconcile. Deterministic via merge-base, NOT git's merge exit code:
323
+ // fast-forward and ahead-only are handled directly, and on true divergence the
324
+ // kit rebuilds every changed session file from the two clean committed blobs
325
+ // (ignoring whatever git left in the conflicted working tree), takes the remote
326
+ // copy of any non-session file, and records a real two-parent merge commit.
327
+ function pullAndReconcile(cloneDir: string, ctx: { ts: number; exportedAt: string }): PullResult {
328
+ const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
329
+ if (git(cloneDir, ["fetch", "origin", branch], 120000).status !== 0) {
330
+ return { merged: false, coarse: [], note: "no remote branch to fetch yet" };
331
+ }
332
+ const local = git(cloneDir, ["rev-parse", "HEAD"]).out.trim();
333
+ const remote = git(cloneDir, ["rev-parse", `origin/${branch}`]).out.trim();
334
+ if (!remote || local === remote) return { merged: false, coarse: [], note: "up to date" };
335
+ const base = git(cloneDir, ["merge-base", local, remote]).out.trim();
336
+ if (base === remote) return { merged: false, coarse: [], note: "local is ahead; nothing to pull" };
337
+ if (base === local) {
338
+ git(cloneDir, ["merge", "--ff-only", `origin/${branch}`], 120000);
339
+ return { merged: false, coarse: [], note: "fast-forward" };
340
+ }
341
+
342
+ // True divergence. Start the merge (do not let it commit), then resolve every
343
+ // differing path ourselves from the two clean blobs.
344
+ const changed = git(cloneDir, ["diff", "--name-only", local, remote]).out.split("\n").map((s) => s.trim()).filter(Boolean);
345
+ git(cloneDir, ["merge", "--no-ff", "--no-commit", `origin/${branch}`], 120000);
346
+ const coarseAll: string[] = [];
347
+ let reconciled = 0;
348
+ for (const rel of changed) {
349
+ if (rel.startsWith(`${STATE_DIR}/`) && rel.endsWith(".json")) {
350
+ const ours = readShow(cloneDir, local, rel);
351
+ const theirs = readShow(cloneDir, remote, rel);
352
+ if (ours && theirs) {
353
+ try {
354
+ const op = parseProgress(ours);
355
+ const tp = parseProgress(theirs);
356
+ const { state, coarse } = mergeProgressStates(op.state, tp.state, { oursTs: op.writer?.ts ?? 0, theirsTs: tp.writer?.ts ?? 0 });
357
+ writeFileSync(join(cloneDir, rel), serializeProgress(buildProgress(state, { kind: "kit", ts: ctx.ts }, ctx.exportedAt)));
358
+ coarseAll.push(...coarse);
359
+ reconciled++;
360
+ } catch {
361
+ if (theirs) writeFileSync(join(cloneDir, rel), theirs);
362
+ }
363
+ } else if (theirs && !ours) {
364
+ writeFileSync(join(cloneDir, rel), theirs);
365
+ reconciled++;
366
+ }
367
+ } else {
368
+ // Non-session file: take the remote copy (session JSON is the only thing
369
+ // the kit reconciles; everything else follows the remote).
370
+ const theirs = readShow(cloneDir, remote, rel);
371
+ if (theirs !== null) writeFileSync(join(cloneDir, rel), theirs);
372
+ }
373
+ git(cloneDir, ["add", "--", rel]);
374
+ }
375
+ git(cloneDir, ["add", "-A"]);
376
+ git(cloneDir, ["commit", "--no-edit", "-m", "Reconcile sessions (kit merge)"]);
377
+ return { merged: reconciled > 0, coarse: Array.from(new Set(coarseAll)).sort(), note: `reconciled ${reconciled} session file(s)` };
378
+ }
379
+
380
+ function readShow(cloneDir: string, ref: string, rel: string): string | null {
381
+ const r = git(cloneDir, ["show", `${ref}:${rel}`]);
382
+ return r.status === 0 ? r.out : null;
383
+ }
384
+
385
+ function syncPull(opts: SyncRunOptions, ctx: { ts: number; exportedAt: string }): StateOpResult {
386
+ const cfg = loadConfig();
387
+ const { cloneDir } = resolveWorkspace(opts, cfg);
388
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
389
+ const r = pullAndReconcile(cloneDir, ctx);
390
+ const push = git(cloneDir, ["push"]);
391
+ return ok(
392
+ `Pulled ${cloneDir}.` +
393
+ `${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."}` +
394
+ `${r.merged && push.status === 0 ? " Pushed the reconciliation." : ""}`,
395
+ { cloneDir, merged: r.merged, coarse: r.coarse },
396
+ );
397
+ }
398
+
399
+ // ---- push --------------------------------------------------------------------
400
+
401
+ function syncPush(opts: SyncRunOptions): StateOpResult {
402
+ const cfg = loadConfig();
403
+ const { cloneDir } = resolveWorkspace(opts, cfg);
404
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
405
+ git(cloneDir, ["add", "-A"]);
406
+ const committed = git(cloneDir, ["commit", "-m", opts.message || "Sync sessions"]).status === 0;
407
+ const p = git(cloneDir, ["push"]);
408
+ if (p.status !== 0) return fail(`Push failed: ${(p.err || "").split("\n")[0] || "see git output"}. Pull first (tl sync pull), then push.`, { cloneDir });
409
+ return ok(`Pushed ${cloneDir}.${committed ? " Committed pending changes." : " Nothing new to commit; pushed any local commits."}`, { cloneDir, committed });
410
+ }
411
+
412
+ // ---- status ------------------------------------------------------------------
413
+
414
+ function syncStatus(opts: SyncRunOptions): StateOpResult {
415
+ const cfg = loadConfig();
416
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
417
+ if (!isGitRepo(cloneDir)) {
418
+ const known = cfg.workspaces.length ? ` Known workspaces: ${cfg.workspaces.map((w) => w.name).join(", ")}.` : "";
419
+ return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.${known}`, { cloneDir, workspaces: cfg.workspaces });
420
+ }
421
+ const st = parseGitStatus(git(cloneDir, ["status", "--porcelain=v1", "--branch"]).out);
422
+ const sessions = listStateFiles(cloneDir).length;
423
+ return ok(
424
+ `Workspace ${ws?.name || "(by dir)"} at ${cloneDir}: ${sessions} session(s), branch ${st.branch || "?"}, ` +
425
+ `${st.ahead} ahead, ${st.behind} behind, ${st.dirty ? `${st.files.length} uncommitted change(s)` : "clean"}.` +
426
+ `${ws?.activeSession ? ` Active session: ${ws.activeSession}.` : ""}`,
427
+ { cloneDir, workspace: ws?.name || null, sessions, ...st },
428
+ );
429
+ }