@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/dist/tl.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/tl.ts
4
- import { readFileSync as readFileSync3 } from "fs";
4
+ import { readFileSync as readFileSync4 } from "fs";
5
5
 
6
6
  // core/scoring.ts
7
7
  var CONFIDENCE_GOAL = 0.85;
@@ -72,6 +72,20 @@ var KIT_WRITTEN_QIDS = [
72
72
  // core/progress.ts
73
73
  var APP = "thought-layer";
74
74
  var PROGRESS_FORMAT = 2;
75
+ var KNOWN_STATE_KEYS = [
76
+ "version",
77
+ "answers",
78
+ "feedback",
79
+ "bizModel",
80
+ "grill",
81
+ "assets",
82
+ "research",
83
+ "swot",
84
+ "prd",
85
+ "naming",
86
+ "brand",
87
+ "kit"
88
+ ];
75
89
  function emptyState() {
76
90
  return {
77
91
  version: 2,
@@ -319,7 +333,7 @@ var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "
319
333
  var END_STATES = ["open", "pass", "setAside"];
320
334
  function applyStateOp(p, ctx) {
321
335
  const { ts, exportedAt } = ctx;
322
- const fail = (message) => ({ ok: false, message, details: {} });
336
+ const fail2 = (message) => ({ ok: false, message, details: {} });
323
337
  try {
324
338
  if (p.op === "list") {
325
339
  const files = listStateFiles(p.path);
@@ -354,12 +368,12 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
354
368
  return { ok: true, message, details: { path: loaded.path, exists: loaded.exists, summary: sum, state: loaded.state } };
355
369
  }
356
370
  if (p.op === "answer") {
357
- if (!p.qId || typeof p.value !== "string") return fail("answer needs a qId and a string value.");
371
+ if (!p.qId || typeof p.value !== "string") return fail2("answer needs a qId and a string value.");
358
372
  const path = save(setAnswer(loaded.state, p.qId, p.value, ts));
359
373
  return { ok: true, message: `Recorded answer for "${p.qId}" and saved ${path}.`, details: { path, qId: p.qId } };
360
374
  }
361
375
  if (p.op === "feedback") {
362
- if (!p.qId || !p.personas?.length) return fail("feedback needs a qId and at least one persona.");
376
+ if (!p.qId || !p.personas?.length) return fail2("feedback needs a qId and at least one persona.");
363
377
  const endState = END_STATES.includes(p.endState || "") ? p.endState : "open";
364
378
  const mode = p.mode || (p.personas.length > 1 ? "panel" : p.personas[0].persona);
365
379
  const entry = buildFeedbackEntry({ mode, personas: p.personas, endState, round: p.round, ts });
@@ -376,24 +390,24 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
376
390
  }
377
391
  if (p.op === "artifact") {
378
392
  const key = p.artifact;
379
- if (!ARTIFACT_KEYS.includes(key)) return fail(`artifact needs a key in: ${ARTIFACT_KEYS.join(", ")}.`);
380
- if (p.value == null) return fail("artifact needs a value object.");
393
+ if (!ARTIFACT_KEYS.includes(key)) return fail2(`artifact needs a key in: ${ARTIFACT_KEYS.join(", ")}.`);
394
+ if (p.value == null) return fail2("artifact needs a value object.");
381
395
  const path = save(setArtifact(loaded.state, key, normalizeArtifactValue(key, p.value), ts));
382
396
  return { ok: true, message: `Stored ${key} artifact and saved ${path}.`, details: { path, artifact: key } };
383
397
  }
384
398
  if (p.op === "cursor") {
385
- if (!p.cursor) return fail("cursor needs a cursor object.");
399
+ if (!p.cursor) return fail2("cursor needs a cursor object.");
386
400
  const path = save(setCursor(loaded.state, p.cursor, ts));
387
401
  return { ok: true, message: `Saved resume cursor (stage ${p.cursor.backboneStage ?? "?"}, ${p.cursor.phase ?? "?"}) to ${path}.`, details: { path } };
388
402
  }
389
403
  if (p.op === "park") {
390
- if (!p.key || !p.note) return fail("park needs a key and a note.");
404
+ if (!p.key || !p.note) return fail2("park needs a key and a note.");
391
405
  const path = save(parkNote(loaded.state, p.key, p.note, ts));
392
406
  return { ok: true, message: `Parked a note under "${p.key}" and saved ${path}.`, details: { path, key: p.key } };
393
407
  }
394
- return fail(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
408
+ return fail2(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
395
409
  } catch (e) {
396
- return fail(`tl_state error: ${e.message}`);
410
+ return fail2(`tl_state error: ${e.message}`);
397
411
  }
398
412
  }
399
413
 
@@ -406,7 +420,7 @@ var esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").re
406
420
  var fam = (f) => f.trim().replace(/\s+/g, "+");
407
421
  function extractScaffoldSpec(state) {
408
422
  const obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
409
- const str = (v) => typeof v === "string" ? v : "";
423
+ const str2 = (v) => typeof v === "string" ? v : "";
410
424
  const brand = obj(state.brand);
411
425
  const guide = obj(brand["guide"]);
412
426
  const answers = state.answers || {};
@@ -416,15 +430,15 @@ function extractScaffoldSpec(state) {
416
430
  return m?.hex || fb;
417
431
  };
418
432
  const typography = obj(guide["typography"]);
419
- const fontOf = (slot, fb) => str(obj(slot)["family"]) || fb;
433
+ const fontOf = (slot, fb) => str2(obj(slot)["family"]) || fb;
420
434
  const voice = obj(guide["voice"]);
421
435
  const logos = Array.isArray(brand["logos"]) ? brand["logos"] : [];
422
436
  const chosen = logos.find((l) => l?.id === brand["chosenLogoId"]) || logos[0];
423
437
  return {
424
- brandName: str(guide["brandName"]) || "Your Product",
425
- tagline: str(guide["tagline"]),
426
- pitch: str(answers["pitch"]) || str(answers["what-statement"]),
427
- positioning: str(guide["positioning"]),
438
+ brandName: str2(guide["brandName"]) || "Your Product",
439
+ tagline: str2(guide["tagline"]),
440
+ pitch: str2(answers["pitch"]) || str2(answers["what-statement"]),
441
+ positioning: str2(guide["positioning"]),
428
442
  personality: Array.isArray(guide["personality"]) ? guide["personality"].filter((x) => typeof x === "string") : [],
429
443
  palette: {
430
444
  primary: role("primary", palette[0]?.hex || "#1f3a5f"),
@@ -435,9 +449,9 @@ function extractScaffoldSpec(state) {
435
449
  },
436
450
  displayFont: fontOf(typography["display"], "Inter"),
437
451
  bodyFont: fontOf(typography["body"], "Inter"),
438
- voiceTone: str(voice["tone"]),
452
+ voiceTone: str2(voice["tone"]),
439
453
  logoSvg: chosen?.svg || void 0,
440
- pricing: str(answers["pricing-model"]) || void 0
454
+ pricing: str2(answers["pricing-model"]) || void 0
441
455
  };
442
456
  }
443
457
  function indexHtml(spec, opts) {
@@ -734,11 +748,11 @@ function dedupeSortEnv(envVars) {
734
748
  function normalizeDatabase(raw) {
735
749
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
736
750
  const r = raw;
737
- const str = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
751
+ const str2 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
738
752
  return {
739
- provider: str(r["provider"], "neon"),
740
- schemaFile: str(r["schemaFile"], "schema.sql"),
741
- envVar: str(r["envVar"], "DATABASE_URL")
753
+ provider: str2(r["provider"], "neon"),
754
+ schemaFile: str2(r["schemaFile"], "schema.sql"),
755
+ envVar: str2(r["envVar"], "DATABASE_URL")
742
756
  };
743
757
  }
744
758
  function normalizeEnvVars(raw) {
@@ -769,15 +783,15 @@ function normalizeBackendMeta(raw) {
769
783
  const database = normalizeDatabase(r["database"]);
770
784
  const hasFunctionsDir = typeof r["functionsDir"] === "string" && r["functionsDir"].trim().length > 0;
771
785
  if (kind === null && envVars.length === 0 && database === null && !hasFunctionsDir) return null;
772
- const str = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
786
+ const str2 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
773
787
  return {
774
788
  backendKind: kind ?? "serverless",
775
- functionsDir: str(r["functionsDir"], "netlify/functions"),
776
- runtime: str(r["runtime"], "nodejs20.x"),
777
- nodeVersion: str(r["nodeVersion"], "20"),
789
+ functionsDir: str2(r["functionsDir"], "netlify/functions"),
790
+ runtime: str2(r["runtime"], "nodejs20.x"),
791
+ nodeVersion: str2(r["nodeVersion"], "20"),
778
792
  envVars,
779
793
  database,
780
- guide: str(r["guide"], "BACKEND.md")
794
+ guide: str2(r["guide"], "BACKEND.md")
781
795
  };
782
796
  }
783
797
  var SECRET_NAME_RE = /(KEY|SECRET|TOKEN|PASSWORD|PASSWD|DATABASE_URL|DB_URL|CONN|DSN|CREDENTIAL|PRIVATE|AUTH)/;
@@ -1172,10 +1186,10 @@ Static deploy: the front end is live. This build also declares a backend that th
1172
1186
  return `${lead} To run the backend, follow ${guide}: provision Neon Postgres, set ${envList} in your host environment, then run netlify deploy with the functions present.`;
1173
1187
  })() : "";
1174
1188
  const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
1175
- const writeRecord = (rec) => {
1189
+ const writeRecord = (rec2) => {
1176
1190
  const recPath = join4(dirname3(stateFile), "deploy.json");
1177
1191
  mkdirSync3(dirname3(recPath), { recursive: true });
1178
- writeFileSync4(recPath, JSON.stringify(rec, null, 2) + "\n");
1192
+ writeFileSync4(recPath, JSON.stringify(rec2, null, 2) + "\n");
1179
1193
  return recPath;
1180
1194
  };
1181
1195
  if (opts.dryRun) {
@@ -1447,6 +1461,538 @@ ${notes.join("\n")}` : ""}` + (!url ? `
1447
1461
  };
1448
1462
  }
1449
1463
 
1464
+ // core/sync-io.ts
1465
+ import { spawnSync as spawnSync3 } from "child_process";
1466
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync5 } from "fs";
1467
+ import { homedir } from "os";
1468
+ import { dirname as dirname4, isAbsolute as isAbsolute3, join as join5, resolve as resolve4 } from "path";
1469
+
1470
+ // core/sync.ts
1471
+ function slugify(name) {
1472
+ return String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
1473
+ }
1474
+ function emptySyncConfig() {
1475
+ return { schema: 1, workspaces: [] };
1476
+ }
1477
+ var str = (v, fb = "") => typeof v === "string" ? v : fb;
1478
+ function parseSyncConfig(text) {
1479
+ let raw;
1480
+ try {
1481
+ raw = JSON.parse(text);
1482
+ } catch {
1483
+ return emptySyncConfig();
1484
+ }
1485
+ const list = Array.isArray(raw["workspaces"]) ? raw["workspaces"] : [];
1486
+ const workspaces = [];
1487
+ for (const w of list) {
1488
+ if (!w || typeof w !== "object") continue;
1489
+ const r = w;
1490
+ const cloneDir = str(r["cloneDir"]).trim();
1491
+ if (!cloneDir) continue;
1492
+ const ws = {
1493
+ name: str(r["name"]).trim() || cloneDir,
1494
+ repo: str(r["repo"]).trim(),
1495
+ defaultBranch: str(r["defaultBranch"]).trim() || "main",
1496
+ cloneDir
1497
+ };
1498
+ const active = str(r["activeSession"]).trim();
1499
+ if (active) ws.activeSession = active;
1500
+ workspaces.push(ws);
1501
+ }
1502
+ const cfg = { schema: 1, workspaces };
1503
+ const aw = str(raw["activeWorkspace"]).trim();
1504
+ if (aw) cfg.activeWorkspace = aw;
1505
+ return cfg;
1506
+ }
1507
+ function serializeSyncConfig(cfg) {
1508
+ return JSON.stringify({ schema: 1, activeWorkspace: cfg.activeWorkspace, workspaces: cfg.workspaces }, null, 2) + "\n";
1509
+ }
1510
+ function selectWorkspace(cfg, name) {
1511
+ if (name) return cfg.workspaces.find((w) => w.name === name) || null;
1512
+ if (cfg.activeWorkspace) {
1513
+ const w = cfg.workspaces.find((ws) => ws.name === cfg.activeWorkspace);
1514
+ if (w) return w;
1515
+ }
1516
+ return cfg.workspaces.length === 1 ? cfg.workspaces[0] : null;
1517
+ }
1518
+ function defaultSessionsDir(home) {
1519
+ return `${home}/.thought-layer/sessions`;
1520
+ }
1521
+ function parseGitStatus(out) {
1522
+ const lines = String(out || "").split("\n").filter((l) => l.length > 0);
1523
+ let branch = null;
1524
+ let ahead = 0;
1525
+ let behind = 0;
1526
+ const files = [];
1527
+ for (const line of lines) {
1528
+ if (line.startsWith("## ")) {
1529
+ const header = line.slice(3);
1530
+ branch = header.split(/\.\.\.| /)[0] || null;
1531
+ const a = header.match(/ahead (\d+)/);
1532
+ const b = header.match(/behind (\d+)/);
1533
+ if (a) ahead = Number(a[1]);
1534
+ if (b) behind = Number(b[1]);
1535
+ } else {
1536
+ files.push(line.slice(3).trim());
1537
+ }
1538
+ }
1539
+ return { branch, ahead, behind, dirty: files.length > 0, files };
1540
+ }
1541
+
1542
+ // core/merge.ts
1543
+ var num = (v) => typeof v === "number" && !Number.isNaN(v) ? v : 0;
1544
+ var genAt = (v) => v && typeof v === "object" ? num(v["generatedAt"]) : 0;
1545
+ var jsonEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
1546
+ var rec = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
1547
+ function mergeProgressStates(ours, theirs, opts) {
1548
+ const coarse = [];
1549
+ const oursNewer = opts.oursTs > opts.theirsTs;
1550
+ const answers = {};
1551
+ const oa = rec(ours.answers);
1552
+ const ta = rec(theirs.answers);
1553
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(oa), ...Object.keys(ta)])) {
1554
+ const inO = k in oa;
1555
+ const inT = k in ta;
1556
+ if (inO && !inT) answers[k] = oa[k];
1557
+ else if (!inO && inT) answers[k] = ta[k];
1558
+ else if (jsonEq(oa[k], ta[k])) answers[k] = oa[k];
1559
+ else {
1560
+ answers[k] = oursNewer ? oa[k] : ta[k];
1561
+ coarse.push(`answers.${k}`);
1562
+ }
1563
+ }
1564
+ const feedback = {};
1565
+ const of = rec(ours.feedback);
1566
+ const tf = rec(theirs.feedback);
1567
+ for (const k of /* @__PURE__ */ new Set([...Object.keys(of), ...Object.keys(tf)])) {
1568
+ const o = of[k];
1569
+ const t = tf[k];
1570
+ if (o != null && t == null) feedback[k] = o;
1571
+ else if (o == null && t != null) feedback[k] = t;
1572
+ else if (jsonEq(o, t)) feedback[k] = o;
1573
+ else {
1574
+ const oR = num(rec(o)["round"]);
1575
+ const tR = num(rec(t)["round"]);
1576
+ const oTs = num(rec(o)["ts"]);
1577
+ const tTs = num(rec(t)["ts"]);
1578
+ const pickOurs = oR !== tR ? oR > tR : oTs !== tTs ? oTs > tTs : oursNewer;
1579
+ feedback[k] = pickOurs ? o : t;
1580
+ coarse.push(`feedback.${k}`);
1581
+ }
1582
+ }
1583
+ const artifact = (key) => {
1584
+ const o = ours[key];
1585
+ const t = theirs[key];
1586
+ if (o == null && t == null) return null;
1587
+ if (o != null && t == null) return o;
1588
+ if (o == null && t != null) return t;
1589
+ if (jsonEq(o, t)) return o;
1590
+ const og = genAt(o);
1591
+ const tg = genAt(t);
1592
+ const pickOurs = og !== tg ? og > tg : oursNewer;
1593
+ coarse.push(key);
1594
+ return pickOurs ? o : t;
1595
+ };
1596
+ const merged = {
1597
+ version: 2,
1598
+ answers,
1599
+ feedback,
1600
+ bizModel: artifact("bizModel"),
1601
+ grill: artifact("grill"),
1602
+ assets: artifact("assets"),
1603
+ research: artifact("research"),
1604
+ swot: artifact("swot"),
1605
+ prd: artifact("prd"),
1606
+ naming: artifact("naming"),
1607
+ brand: artifact("brand"),
1608
+ kit: mergeKit(ours.kit, theirs.kit, oursNewer)
1609
+ };
1610
+ for (const src of oursNewer ? [theirs, ours] : [ours, theirs]) {
1611
+ for (const k of Object.keys(src)) {
1612
+ if (!KNOWN_STATE_KEYS.includes(k)) merged[k] = src[k];
1613
+ }
1614
+ }
1615
+ return { state: merged, coarse: Array.from(new Set(coarse)).sort() };
1616
+ }
1617
+ function mergeKit(o, t, oursNewer) {
1618
+ if (!o && !t) return null;
1619
+ if (o && !t) return o;
1620
+ if (!o && t) return t;
1621
+ const oo = o;
1622
+ const tt = t;
1623
+ const modulesRun = Array.from(/* @__PURE__ */ new Set([...oo.modulesRun || [], ...tt.modulesRun || []]));
1624
+ const parked = {};
1625
+ for (const src of [oo.parked || {}, tt.parked || {}]) {
1626
+ for (const [k, v] of Object.entries(src)) {
1627
+ parked[k] = Array.from(/* @__PURE__ */ new Set([...parked[k] || [], ...Array.isArray(v) ? v : []]));
1628
+ }
1629
+ }
1630
+ const newer = oursNewer ? oo : tt;
1631
+ const older = oursNewer ? tt : oo;
1632
+ const panelMeta = { ...older.panelMeta || {}, ...newer.panelMeta || {} };
1633
+ const kit = {
1634
+ schema: Math.max(num(oo.schema) || 1, num(tt.schema) || 1),
1635
+ updatedAt: Math.max(num(oo.updatedAt), num(tt.updatedAt))
1636
+ };
1637
+ if (modulesRun.length) kit.modulesRun = modulesRun;
1638
+ if (Object.keys(parked).length) kit.parked = parked;
1639
+ const cursor = newer.cursor ?? older.cursor;
1640
+ if (cursor) kit.cursor = cursor;
1641
+ if (Object.keys(panelMeta).length) kit.panelMeta = panelMeta;
1642
+ return kit;
1643
+ }
1644
+
1645
+ // core/sync-io.ts
1646
+ function hasGit() {
1647
+ try {
1648
+ return spawnSync3("git", ["--version"], { encoding: "utf8", timeout: 15e3 }).status === 0;
1649
+ } catch {
1650
+ return false;
1651
+ }
1652
+ }
1653
+ function hasGh() {
1654
+ try {
1655
+ return spawnSync3("gh", ["--version"], { encoding: "utf8", timeout: 15e3 }).status === 0;
1656
+ } catch {
1657
+ return false;
1658
+ }
1659
+ }
1660
+ function ghAuthed() {
1661
+ try {
1662
+ return spawnSync3("gh", ["auth", "status"], { encoding: "utf8", timeout: 2e4 }).status === 0;
1663
+ } catch {
1664
+ return false;
1665
+ }
1666
+ }
1667
+ function syncConfigPath() {
1668
+ return process.env["THOUGHT_LAYER_SYNC_CONFIG"] || join5(homedir(), ".thought-layer", "sync.json");
1669
+ }
1670
+ function loadConfig() {
1671
+ const p = syncConfigPath();
1672
+ if (!existsSync4(p)) return emptySyncConfig();
1673
+ try {
1674
+ return parseSyncConfig(readFileSync3(p, "utf8"));
1675
+ } catch {
1676
+ return emptySyncConfig();
1677
+ }
1678
+ }
1679
+ function saveConfig(cfg) {
1680
+ const p = syncConfigPath();
1681
+ mkdirSync4(dirname4(p), { recursive: true });
1682
+ writeFileSync5(p, serializeSyncConfig(cfg));
1683
+ }
1684
+ function git(dir, args, timeout = 12e4) {
1685
+ const r = spawnSync3("git", dir ? ["-C", dir, ...args] : args, { encoding: "utf8", timeout });
1686
+ return { status: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" };
1687
+ }
1688
+ function isGitRepo(dir) {
1689
+ return existsSync4(join5(dir, ".git")) && git(dir, ["rev-parse", "--is-inside-work-tree"]).status === 0;
1690
+ }
1691
+ function dirNonEmpty(dir) {
1692
+ try {
1693
+ return existsSync4(dir) && readdirSync3(dir).length > 0;
1694
+ } catch {
1695
+ return false;
1696
+ }
1697
+ }
1698
+ var absDir = (d, cwd = process.cwd()) => isAbsolute3(d) ? d : resolve4(cwd, d);
1699
+ var GITATTRIBUTES = `# The kit reconciles session JSON itself; never let git textually merge it
1700
+ # (which would corrupt the envelope). -merge keeps our copy in the working tree
1701
+ # on a conflict; the kit then rebuilds the merged result from the clean blobs.
1702
+ .thought-layer/*.json -merge
1703
+ `;
1704
+ var GITIGNORE = `# A Thought Layer sessions repo holds session state only. Built product
1705
+ # artifacts and secrets never sync here.
1706
+ build.json
1707
+ deploy.json
1708
+ *.local
1709
+ .env
1710
+ .env.*
1711
+ !.env.example
1712
+ dist/
1713
+ .netlify/
1714
+ node_modules/
1715
+ `;
1716
+ var README = `# Thought Layer sessions
1717
+
1718
+ This private repo is the home for Thought Layer session files. Each session is one
1719
+ file under \`.thought-layer/<name>.json\` (the portable validation and design state).
1720
+ Use the kit to work with them:
1721
+
1722
+ tl sync open --name <session> pull and resume a session
1723
+ tl sync save --name <session> snapshot the current state, commit, and push
1724
+ tl sync list list the sessions in this repo
1725
+
1726
+ Collaboration is handled by GitHub: add a collaborator to this repo in its GitHub
1727
+ settings, and they can clone it and run the kit against the same sessions.
1728
+ The kit reconciles concurrent edits itself (newest wins per field, conflicts are
1729
+ reported), so git never has to merge the JSON by hand.
1730
+ `;
1731
+ function writeCloneScaffold(cloneDir) {
1732
+ mkdirSync4(join5(cloneDir, STATE_DIR), { recursive: true });
1733
+ const put = (name, body) => {
1734
+ const p = join5(cloneDir, name);
1735
+ if (!existsSync4(p)) writeFileSync5(p, body);
1736
+ };
1737
+ put(".gitattributes", GITATTRIBUTES);
1738
+ put(".gitignore", GITIGNORE);
1739
+ put("README.md", README);
1740
+ }
1741
+ var ok = (message, details = {}) => ({ ok: true, message, details });
1742
+ var fail = (message, details = {}) => ({ ok: false, message, details });
1743
+ var collaboratorPointer = (repo) => `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.`;
1744
+ async function runSync(opts, ctx) {
1745
+ if (!hasGit()) {
1746
+ 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" });
1747
+ }
1748
+ try {
1749
+ switch (opts.op) {
1750
+ case "init":
1751
+ return syncInit(opts);
1752
+ case "save":
1753
+ return syncSave(opts, ctx);
1754
+ case "list":
1755
+ return syncList(opts);
1756
+ case "open":
1757
+ return syncOpen(opts, ctx);
1758
+ case "pull":
1759
+ return syncPull(opts, ctx);
1760
+ case "push":
1761
+ return syncPush(opts);
1762
+ case "status":
1763
+ return syncStatus(opts);
1764
+ default:
1765
+ return fail(`Unknown sync op "${opts.op}". Use one of: init, save, list, open, pull, push, status.`);
1766
+ }
1767
+ } catch (e) {
1768
+ return fail(`Sync ${opts.op} failed: ${e.message}`);
1769
+ }
1770
+ }
1771
+ function resolveWorkspace(opts, cfg) {
1772
+ if (opts.dir && opts.dir.trim()) return { cloneDir: absDir(opts.dir), ws: null };
1773
+ const env = process.env["THOUGHT_LAYER_SESSIONS_DIR"];
1774
+ if (env && env.trim()) return { cloneDir: absDir(env), ws: null };
1775
+ const ws = selectWorkspace(cfg, opts.workspace);
1776
+ if (ws) return { cloneDir: ws.cloneDir, ws };
1777
+ return { cloneDir: defaultSessionsDir(homedir()), ws: null };
1778
+ }
1779
+ function syncInit(opts) {
1780
+ const repo = (opts.repo || "").trim();
1781
+ if (!repo) return fail("Pass the private repo to use: tl sync init --repo <owner/name or url> [--name <label>] [--dir <path>].");
1782
+ const home = homedir();
1783
+ const label = (opts.name || "").trim();
1784
+ const cloneDir = opts.dir ? absDir(opts.dir) : !label || slugify(label) === "personal" ? defaultSessionsDir(home) : join5(home, ".thought-layer", `sessions-${slugify(label)}`);
1785
+ if (dirNonEmpty(cloneDir)) {
1786
+ if (isGitRepo(cloneDir)) return fail(`${cloneDir} is already a git repo. It looks initialized; use tl sync list or pick another --dir.`, { cloneDir });
1787
+ return fail(`${cloneDir} already exists and is not empty. Pick another --dir or remove it first.`, { cloneDir });
1788
+ }
1789
+ const isOwnerName = /^[\w.-]+\/[\w.-]+$/.test(repo);
1790
+ const useGh = isOwnerName && hasGh() && ghAuthed();
1791
+ mkdirSync4(dirname4(cloneDir), { recursive: true });
1792
+ let cloned = false;
1793
+ if (useGh) {
1794
+ cloned = spawnSync3("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 18e4 }).status === 0;
1795
+ if (!cloned) {
1796
+ const created = spawnSync3("gh", ["repo", "create", repo, "--private"], { encoding: "utf8", timeout: 18e4 });
1797
+ if (created.status !== 0) {
1798
+ 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)}`);
1799
+ }
1800
+ cloned = spawnSync3("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 18e4 }).status === 0;
1801
+ }
1802
+ } else {
1803
+ const url = isOwnerName ? `https://github.com/${repo}.git` : repo;
1804
+ cloned = git(null, ["clone", url, cloneDir], 18e4).status === 0;
1805
+ if (!cloned) {
1806
+ return fail(
1807
+ isOwnerName && !hasGh() ? `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.` : `Could not clone ${repo}. Check the repo path or URL and your git access, then re-run.`,
1808
+ { repo, cloneDir }
1809
+ );
1810
+ }
1811
+ }
1812
+ writeCloneScaffold(cloneDir);
1813
+ git(cloneDir, ["add", "-A"]);
1814
+ const committed = git(cloneDir, ["commit", "-m", "Initialize Thought Layer sessions"]).status === 0;
1815
+ let branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
1816
+ if (committed && branch === "HEAD") {
1817
+ git(cloneDir, ["branch", "-M", "main"]);
1818
+ branch = "main";
1819
+ }
1820
+ let pushed = false;
1821
+ if (committed) pushed = git(cloneDir, ["push", "-u", "origin", branch]).status === 0;
1822
+ const cfg = loadConfig();
1823
+ const wsName = label || "personal";
1824
+ const ws = { name: wsName, repo, defaultBranch: branch, cloneDir };
1825
+ cfg.workspaces = [...cfg.workspaces.filter((w) => w.cloneDir !== cloneDir && w.name !== wsName), ws];
1826
+ cfg.activeWorkspace = wsName;
1827
+ saveConfig(cfg);
1828
+ return ok(
1829
+ `Initialized the "${wsName}" sessions workspace at ${cloneDir} (repo ${repo}).${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."}
1830
+ ${collaboratorPointer(repo)}
1831
+ Save your first session with: tl sync save --name <name>${label ? ` --workspace ${wsName}` : ""}.`,
1832
+ { cloneDir, repo, workspace: wsName, branch, committed, pushed }
1833
+ );
1834
+ }
1835
+ function syncSave(opts, ctx) {
1836
+ const cfg = loadConfig();
1837
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
1838
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
1839
+ const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
1840
+ if (!slug) return fail("Name the session: tl sync save --name <name> (for example photobooth, peptide, blogging).");
1841
+ const targetPath = join5(cloneDir, STATE_DIR, `${slug}.json`);
1842
+ const existed = existsSync4(targetPath);
1843
+ const useExplicit = !!(opts.path && opts.path.trim() || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
1844
+ const loadTarget = useExplicit ? opts.path : existed ? targetPath : opts.path;
1845
+ const source = loadStateFile(loadTarget).state;
1846
+ saveStateFile(source, { target: targetPath, ts: ctx.ts, exportedAt: ctx.exportedAt });
1847
+ git(cloneDir, ["add", "-A"]);
1848
+ const msg = opts.message || `${existed ? "Update" : "Save"} session ${slug}`;
1849
+ const commit = git(cloneDir, ["commit", "-m", msg]);
1850
+ const committed = commit.status === 0;
1851
+ let pushed = false;
1852
+ let pushNote = "";
1853
+ if (committed && !opts.noPush) {
1854
+ const p = git(cloneDir, ["push"]);
1855
+ pushed = p.status === 0;
1856
+ if (!pushed) pushNote = ` Could not push (${(p.err || "").split("\n")[0] || "see git output"}); commit is local, run tl sync push when ready.`;
1857
+ }
1858
+ if (ws) {
1859
+ ws.activeSession = `${slug}.json`;
1860
+ saveConfig(cfg);
1861
+ }
1862
+ return ok(
1863
+ `${existed ? "Updated" : "Saved"} session ${slug} in ${cloneDir}.${committed ? opts.noPush ? " Committed locally (no push)." : pushed ? " Committed and pushed." : pushNote : " Nothing changed since the last save."}`,
1864
+ { cloneDir, session: `${slug}.json`, path: targetPath, committed, pushed }
1865
+ );
1866
+ }
1867
+ function syncList(opts) {
1868
+ const cfg = loadConfig();
1869
+ const { cloneDir } = resolveWorkspace(opts, cfg);
1870
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
1871
+ const files = listStateFiles(cloneDir);
1872
+ if (!files.length) return ok(`No sessions yet in ${cloneDir}. Create one with tl sync save --name <name>.`, { cloneDir, sessions: [] });
1873
+ const rows = files.map((f) => {
1874
+ try {
1875
+ const sum = summarizeState(loadStateFile(f.path).state);
1876
+ return { name: f.name, path: f.path, answered: sum.answered, artifacts: sum.artifacts };
1877
+ } catch {
1878
+ return { name: f.name, path: f.path, answered: 0, artifacts: [], unreadable: true };
1879
+ }
1880
+ });
1881
+ const lines = rows.map((r) => ` ${r.name}: ${r.answered} answered${r.artifacts.length ? `, artifacts ${r.artifacts.join(", ")}` : ""}${"unreadable" in r ? " (unreadable)" : ""}`).join("\n");
1882
+ return ok(`${files.length} session(s) in ${cloneDir}:
1883
+ ${lines}
1884
+ Open one with tl sync open --name <name>.`, { cloneDir, sessions: rows });
1885
+ }
1886
+ function syncOpen(opts, ctx) {
1887
+ const cfg = loadConfig();
1888
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
1889
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
1890
+ const slug = slugify(opts.name || "");
1891
+ if (!slug) return fail("Name the session to open: tl sync open --name <name>. List them with tl sync list.");
1892
+ const pullResult = pullAndReconcile(cloneDir, ctx);
1893
+ const targetPath = join5(cloneDir, STATE_DIR, `${slug}.json`);
1894
+ if (!existsSync4(targetPath)) {
1895
+ 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 });
1896
+ }
1897
+ if (ws) {
1898
+ ws.activeSession = `${slug}.json`;
1899
+ saveConfig(cfg);
1900
+ }
1901
+ return ok(
1902
+ `Opened session ${slug} (pulled latest${pullResult.merged ? `, reconciled local and remote edits${pullResult.coarse.length ? ` (review: ${pullResult.coarse.join(", ")})` : ""}` : ""}).
1903
+ Work on it by pointing the kit at this file:
1904
+ export THOUGHT_LAYER_STATE="${targetPath}"
1905
+ or pass --path "${targetPath}" to tl read/answer/artifact. Save with tl sync save --name ${slug}.`,
1906
+ { cloneDir, session: `${slug}.json`, path: targetPath, merged: pullResult.merged, coarse: pullResult.coarse }
1907
+ );
1908
+ }
1909
+ function pullAndReconcile(cloneDir, ctx) {
1910
+ const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
1911
+ if (git(cloneDir, ["fetch", "origin", branch], 12e4).status !== 0) {
1912
+ return { merged: false, coarse: [], note: "no remote branch to fetch yet" };
1913
+ }
1914
+ const local = git(cloneDir, ["rev-parse", "HEAD"]).out.trim();
1915
+ const remote = git(cloneDir, ["rev-parse", `origin/${branch}`]).out.trim();
1916
+ if (!remote || local === remote) return { merged: false, coarse: [], note: "up to date" };
1917
+ const base = git(cloneDir, ["merge-base", local, remote]).out.trim();
1918
+ if (base === remote) return { merged: false, coarse: [], note: "local is ahead; nothing to pull" };
1919
+ if (base === local) {
1920
+ git(cloneDir, ["merge", "--ff-only", `origin/${branch}`], 12e4);
1921
+ return { merged: false, coarse: [], note: "fast-forward" };
1922
+ }
1923
+ const changed = git(cloneDir, ["diff", "--name-only", local, remote]).out.split("\n").map((s) => s.trim()).filter(Boolean);
1924
+ git(cloneDir, ["merge", "--no-ff", "--no-commit", `origin/${branch}`], 12e4);
1925
+ const coarseAll = [];
1926
+ let reconciled = 0;
1927
+ for (const rel of changed) {
1928
+ if (rel.startsWith(`${STATE_DIR}/`) && rel.endsWith(".json")) {
1929
+ const ours = readShow(cloneDir, local, rel);
1930
+ const theirs = readShow(cloneDir, remote, rel);
1931
+ if (ours && theirs) {
1932
+ try {
1933
+ const op = parseProgress(ours);
1934
+ const tp = parseProgress(theirs);
1935
+ const { state, coarse } = mergeProgressStates(op.state, tp.state, { oursTs: op.writer?.ts ?? 0, theirsTs: tp.writer?.ts ?? 0 });
1936
+ writeFileSync5(join5(cloneDir, rel), serializeProgress(buildProgress(state, { kind: "kit", ts: ctx.ts }, ctx.exportedAt)));
1937
+ coarseAll.push(...coarse);
1938
+ reconciled++;
1939
+ } catch {
1940
+ if (theirs) writeFileSync5(join5(cloneDir, rel), theirs);
1941
+ }
1942
+ } else if (theirs && !ours) {
1943
+ writeFileSync5(join5(cloneDir, rel), theirs);
1944
+ reconciled++;
1945
+ }
1946
+ } else {
1947
+ const theirs = readShow(cloneDir, remote, rel);
1948
+ if (theirs !== null) writeFileSync5(join5(cloneDir, rel), theirs);
1949
+ }
1950
+ git(cloneDir, ["add", "--", rel]);
1951
+ }
1952
+ git(cloneDir, ["add", "-A"]);
1953
+ git(cloneDir, ["commit", "--no-edit", "-m", "Reconcile sessions (kit merge)"]);
1954
+ return { merged: reconciled > 0, coarse: Array.from(new Set(coarseAll)).sort(), note: `reconciled ${reconciled} session file(s)` };
1955
+ }
1956
+ function readShow(cloneDir, ref, rel) {
1957
+ const r = git(cloneDir, ["show", `${ref}:${rel}`]);
1958
+ return r.status === 0 ? r.out : null;
1959
+ }
1960
+ function syncPull(opts, ctx) {
1961
+ const cfg = loadConfig();
1962
+ const { cloneDir } = resolveWorkspace(opts, cfg);
1963
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
1964
+ const r = pullAndReconcile(cloneDir, ctx);
1965
+ const push = git(cloneDir, ["push"]);
1966
+ return ok(
1967
+ `Pulled ${cloneDir}.${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."}${r.merged && push.status === 0 ? " Pushed the reconciliation." : ""}`,
1968
+ { cloneDir, merged: r.merged, coarse: r.coarse }
1969
+ );
1970
+ }
1971
+ function syncPush(opts) {
1972
+ const cfg = loadConfig();
1973
+ const { cloneDir } = resolveWorkspace(opts, cfg);
1974
+ if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
1975
+ git(cloneDir, ["add", "-A"]);
1976
+ const committed = git(cloneDir, ["commit", "-m", opts.message || "Sync sessions"]).status === 0;
1977
+ const p = git(cloneDir, ["push"]);
1978
+ if (p.status !== 0) return fail(`Push failed: ${(p.err || "").split("\n")[0] || "see git output"}. Pull first (tl sync pull), then push.`, { cloneDir });
1979
+ return ok(`Pushed ${cloneDir}.${committed ? " Committed pending changes." : " Nothing new to commit; pushed any local commits."}`, { cloneDir, committed });
1980
+ }
1981
+ function syncStatus(opts) {
1982
+ const cfg = loadConfig();
1983
+ const { cloneDir, ws } = resolveWorkspace(opts, cfg);
1984
+ if (!isGitRepo(cloneDir)) {
1985
+ const known = cfg.workspaces.length ? ` Known workspaces: ${cfg.workspaces.map((w) => w.name).join(", ")}.` : "";
1986
+ return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.${known}`, { cloneDir, workspaces: cfg.workspaces });
1987
+ }
1988
+ const st = parseGitStatus(git(cloneDir, ["status", "--porcelain=v1", "--branch"]).out);
1989
+ const sessions = listStateFiles(cloneDir).length;
1990
+ return ok(
1991
+ `Workspace ${ws?.name || "(by dir)"} at ${cloneDir}: ${sessions} session(s), branch ${st.branch || "?"}, ${st.ahead} ahead, ${st.behind} behind, ${st.dirty ? `${st.files.length} uncommitted change(s)` : "clean"}.${ws?.activeSession ? ` Active session: ${ws.activeSession}.` : ""}`,
1992
+ { cloneDir, workspace: ws?.name || null, sessions, ...st }
1993
+ );
1994
+ }
1995
+
1450
1996
  // bin/tl.ts
1451
1997
  var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
1452
1998
 
@@ -1455,6 +2001,8 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
1455
2001
  tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
1456
2002
  tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
1457
2003
  [--static-only] [--provision-db] [--apply-schema] when build.json has a backend: ships functions+env by default; flags opt out or add Neon provision/schema
2004
+ tl sync <init|save|list|open|pull|push|status> store/sync your session files in your own private GitHub repo
2005
+ [--repo owner/name] [--name x] [--dir p] [--workspace w] [--message m] [--no-push]
1458
2006
  tl export [path] handoff check
1459
2007
  tl answer <qId> <value> [path] record an answer
1460
2008
  tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
@@ -1489,7 +2037,7 @@ function parseArgs(argv) {
1489
2037
  function readData(flags) {
1490
2038
  const d = flags["data"];
1491
2039
  if (d === void 0) return void 0;
1492
- const raw = d === "-" || d === true ? readFileSync3(0, "utf8") : String(d);
2040
+ const raw = d === "-" || d === true ? readFileSync4(0, "utf8") : String(d);
1493
2041
  try {
1494
2042
  return JSON.parse(raw);
1495
2043
  } catch {
@@ -1563,6 +2111,26 @@ function main() {
1563
2111
  });
1564
2112
  return;
1565
2113
  }
2114
+ if (args[0] === "sync") {
2115
+ runSync(
2116
+ {
2117
+ op: typeof args[1] === "string" ? args[1] : "status",
2118
+ name: typeof flags["name"] === "string" ? flags["name"] : void 0,
2119
+ repo: typeof flags["repo"] === "string" ? flags["repo"] : void 0,
2120
+ dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
2121
+ workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
2122
+ message: typeof flags["message"] === "string" ? flags["message"] : void 0,
2123
+ noPush: flags["no-push"] === true,
2124
+ path: typeof flags["path"] === "string" ? flags["path"] : void 0
2125
+ },
2126
+ { ts: Date.now(), exportedAt: (/* @__PURE__ */ new Date()).toISOString() }
2127
+ ).then((r2) => {
2128
+ if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
2129
+ else console.log(r2.message);
2130
+ process.exit(r2.ok ? 0 : 1);
2131
+ });
2132
+ return;
2133
+ }
1566
2134
  let payload;
1567
2135
  try {
1568
2136
  payload = buildOp(args, flags);