@hobocode/thought-layer 0.6.1 → 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 +3 -2
- package/core/index.ts +3 -0
- package/core/merge.ts +156 -0
- package/core/sync-io.ts +429 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +598 -30
- package/extensions/thought-layer.ts +32 -1
- package/package.json +4 -1
- package/skills/thought-layer-framework/SKILL.md +9 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
380
|
-
if (p.value == null) return
|
|
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
|
|
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
|
|
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
|
|
408
|
+
return fail2(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
|
|
395
409
|
} catch (e) {
|
|
396
|
-
return
|
|
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, "&").replace(/</g, "<").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
|
|
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) =>
|
|
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:
|
|
425
|
-
tagline:
|
|
426
|
-
pitch:
|
|
427
|
-
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:
|
|
452
|
+
voiceTone: str2(voice["tone"]),
|
|
439
453
|
logoSvg: chosen?.svg || void 0,
|
|
440
|
-
pricing:
|
|
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
|
|
751
|
+
const str2 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
738
752
|
return {
|
|
739
|
-
provider:
|
|
740
|
-
schemaFile:
|
|
741
|
-
envVar:
|
|
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
|
|
786
|
+
const str2 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
773
787
|
return {
|
|
774
788
|
backendKind: kind ?? "serverless",
|
|
775
|
-
functionsDir:
|
|
776
|
-
runtime:
|
|
777
|
-
nodeVersion:
|
|
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:
|
|
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 = (
|
|
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(
|
|
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 ?
|
|
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);
|