@hobocode/thought-layer 0.6.1 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +7 -0
- package/core/merge.ts +157 -0
- package/core/notion-io.ts +292 -0
- package/core/notion.ts +312 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +432 -0
- package/core/sync.ts +150 -0
- package/dist/tl.js +1866 -45
- package/extensions/thought-layer.ts +93 -2
- package/package.json +8 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +9 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-framework/SKILL.md +9 -0
- package/skills/thought-layer-wiki/SKILL.md +43 -0
package/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 readFileSync6 } from "fs";
|
|
5
5
|
|
|
6
6
|
// core/scoring.ts
|
|
7
7
|
var CONFIDENCE_GOAL = 0.85;
|
|
@@ -72,6 +72,21 @@ 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
|
+
"governance",
|
|
88
|
+
"kit"
|
|
89
|
+
];
|
|
75
90
|
function emptyState() {
|
|
76
91
|
return {
|
|
77
92
|
version: 2,
|
|
@@ -85,6 +100,7 @@ function emptyState() {
|
|
|
85
100
|
prd: null,
|
|
86
101
|
naming: null,
|
|
87
102
|
brand: null,
|
|
103
|
+
governance: null,
|
|
88
104
|
kit: null
|
|
89
105
|
};
|
|
90
106
|
}
|
|
@@ -101,12 +117,12 @@ function parseProgress(text) {
|
|
|
101
117
|
const rawFormat = payload["format"];
|
|
102
118
|
const formatNewer = typeof rawFormat === "number" && rawFormat > PROGRESS_FORMAT;
|
|
103
119
|
const s = payload["state"] && typeof payload["state"] === "object" ? payload["state"] : {};
|
|
104
|
-
const
|
|
120
|
+
const obj3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
105
121
|
const state = {
|
|
106
122
|
...s,
|
|
107
123
|
version: 2,
|
|
108
|
-
answers:
|
|
109
|
-
feedback:
|
|
124
|
+
answers: obj3(s["answers"]),
|
|
125
|
+
feedback: obj3(s["feedback"]),
|
|
110
126
|
bizModel: s["bizModel"] ?? null,
|
|
111
127
|
grill: s["grill"] ?? null,
|
|
112
128
|
assets: s["assets"] ?? null,
|
|
@@ -115,6 +131,7 @@ function parseProgress(text) {
|
|
|
115
131
|
prd: s["prd"] ?? null,
|
|
116
132
|
naming: s["naming"] ?? null,
|
|
117
133
|
brand: s["brand"] ?? null,
|
|
134
|
+
governance: s["governance"] ?? null,
|
|
118
135
|
kit: s["kit"] ?? null
|
|
119
136
|
};
|
|
120
137
|
return {
|
|
@@ -139,6 +156,7 @@ function buildProgress(state, writer, exportedAt) {
|
|
|
139
156
|
prd,
|
|
140
157
|
naming,
|
|
141
158
|
brand,
|
|
159
|
+
governance,
|
|
142
160
|
kit,
|
|
143
161
|
version: _v,
|
|
144
162
|
exportedAt: _ea,
|
|
@@ -163,6 +181,7 @@ function buildProgress(state, writer, exportedAt) {
|
|
|
163
181
|
prd: prd ?? null,
|
|
164
182
|
naming: naming ?? null,
|
|
165
183
|
brand: brand ?? null,
|
|
184
|
+
governance: governance ?? null,
|
|
166
185
|
kit: kit ?? null,
|
|
167
186
|
...rest
|
|
168
187
|
}
|
|
@@ -245,7 +264,7 @@ function summarizeState(state) {
|
|
|
245
264
|
if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
|
|
246
265
|
else byStatus.ungraded++;
|
|
247
266
|
}
|
|
248
|
-
const artifacts = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"].filter((k) => state[k] != null);
|
|
267
|
+
const artifacts = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"].filter((k) => state[k] != null);
|
|
249
268
|
const kit = state.kit && typeof state.kit === "object" ? state.kit : null;
|
|
250
269
|
return {
|
|
251
270
|
answered,
|
|
@@ -315,11 +334,11 @@ function saveStateFile(state, opts) {
|
|
|
315
334
|
}
|
|
316
335
|
|
|
317
336
|
// core/state-ops.ts
|
|
318
|
-
var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
|
|
337
|
+
var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"];
|
|
319
338
|
var END_STATES = ["open", "pass", "setAside"];
|
|
320
339
|
function applyStateOp(p, ctx) {
|
|
321
340
|
const { ts, exportedAt } = ctx;
|
|
322
|
-
const
|
|
341
|
+
const fail4 = (message) => ({ ok: false, message, details: {} });
|
|
323
342
|
try {
|
|
324
343
|
if (p.op === "list") {
|
|
325
344
|
const files = listStateFiles(p.path);
|
|
@@ -328,8 +347,8 @@ function applyStateOp(p, ctx) {
|
|
|
328
347
|
}
|
|
329
348
|
const rows = files.map((f) => {
|
|
330
349
|
try {
|
|
331
|
-
const
|
|
332
|
-
return { name: f.name, path: f.path, answered:
|
|
350
|
+
const sum2 = summarizeState(loadStateFile(f.path).state);
|
|
351
|
+
return { name: f.name, path: f.path, answered: sum2.answered, artifacts: sum2.artifacts };
|
|
333
352
|
} catch {
|
|
334
353
|
return { name: f.name, path: f.path, answered: 0, artifacts: [], unreadable: true };
|
|
335
354
|
}
|
|
@@ -342,24 +361,24 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
|
|
|
342
361
|
const loaded = loadStateFile(p.path);
|
|
343
362
|
const save = (next) => saveStateFile(next, { target: p.path, ts, exportedAt }).path;
|
|
344
363
|
if (p.op === "read" || p.op === "export") {
|
|
345
|
-
const
|
|
364
|
+
const sum2 = summarizeState(loaded.state);
|
|
346
365
|
let message;
|
|
347
366
|
if (loaded.exists) {
|
|
348
|
-
message = `Loaded ${loaded.path}: ${
|
|
367
|
+
message = `Loaded ${loaded.path}: ${sum2.answered}/${sum2.totalAnswerable} answered (${sum2.byStatus.green} green, ${sum2.byStatus.yellow} yellow, ${sum2.byStatus.red} red), artifacts: ${sum2.artifacts.join(", ") || "none"}. Resume at ${sum2.cursor ? `stage ${sum2.cursor.backboneStage ?? "?"} (${sum2.cursor.phase ?? "?"})` : "the beginning"}.`;
|
|
349
368
|
} else {
|
|
350
369
|
const others = listStateFiles().filter((f) => f.path !== loaded.path);
|
|
351
370
|
const hint = others.length ? ` Other state files here: ${others.map((f) => f.name).join(", ")} (pick one with --path, or 'tl list').` : "";
|
|
352
371
|
message = `No state file yet at ${loaded.path}.${hint} Start a fresh run; it will be created on first write.`;
|
|
353
372
|
}
|
|
354
|
-
return { ok: true, message, details: { path: loaded.path, exists: loaded.exists, summary:
|
|
373
|
+
return { ok: true, message, details: { path: loaded.path, exists: loaded.exists, summary: sum2, state: loaded.state } };
|
|
355
374
|
}
|
|
356
375
|
if (p.op === "answer") {
|
|
357
|
-
if (!p.qId || typeof p.value !== "string") return
|
|
376
|
+
if (!p.qId || typeof p.value !== "string") return fail4("answer needs a qId and a string value.");
|
|
358
377
|
const path = save(setAnswer(loaded.state, p.qId, p.value, ts));
|
|
359
378
|
return { ok: true, message: `Recorded answer for "${p.qId}" and saved ${path}.`, details: { path, qId: p.qId } };
|
|
360
379
|
}
|
|
361
380
|
if (p.op === "feedback") {
|
|
362
|
-
if (!p.qId || !p.personas?.length) return
|
|
381
|
+
if (!p.qId || !p.personas?.length) return fail4("feedback needs a qId and at least one persona.");
|
|
363
382
|
const endState = END_STATES.includes(p.endState || "") ? p.endState : "open";
|
|
364
383
|
const mode = p.mode || (p.personas.length > 1 ? "panel" : p.personas[0].persona);
|
|
365
384
|
const entry = buildFeedbackEntry({ mode, personas: p.personas, endState, round: p.round, ts });
|
|
@@ -376,24 +395,24 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
|
|
|
376
395
|
}
|
|
377
396
|
if (p.op === "artifact") {
|
|
378
397
|
const key = p.artifact;
|
|
379
|
-
if (!ARTIFACT_KEYS.includes(key)) return
|
|
380
|
-
if (p.value == null) return
|
|
398
|
+
if (!ARTIFACT_KEYS.includes(key)) return fail4(`artifact needs a key in: ${ARTIFACT_KEYS.join(", ")}.`);
|
|
399
|
+
if (p.value == null) return fail4("artifact needs a value object.");
|
|
381
400
|
const path = save(setArtifact(loaded.state, key, normalizeArtifactValue(key, p.value), ts));
|
|
382
401
|
return { ok: true, message: `Stored ${key} artifact and saved ${path}.`, details: { path, artifact: key } };
|
|
383
402
|
}
|
|
384
403
|
if (p.op === "cursor") {
|
|
385
|
-
if (!p.cursor) return
|
|
404
|
+
if (!p.cursor) return fail4("cursor needs a cursor object.");
|
|
386
405
|
const path = save(setCursor(loaded.state, p.cursor, ts));
|
|
387
406
|
return { ok: true, message: `Saved resume cursor (stage ${p.cursor.backboneStage ?? "?"}, ${p.cursor.phase ?? "?"}) to ${path}.`, details: { path } };
|
|
388
407
|
}
|
|
389
408
|
if (p.op === "park") {
|
|
390
|
-
if (!p.key || !p.note) return
|
|
409
|
+
if (!p.key || !p.note) return fail4("park needs a key and a note.");
|
|
391
410
|
const path = save(parkNote(loaded.state, p.key, p.note, ts));
|
|
392
411
|
return { ok: true, message: `Parked a note under "${p.key}" and saved ${path}.`, details: { path, key: p.key } };
|
|
393
412
|
}
|
|
394
|
-
return
|
|
413
|
+
return fail4(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
|
|
395
414
|
} catch (e) {
|
|
396
|
-
return
|
|
415
|
+
return fail4(`tl_state error: ${e.message}`);
|
|
397
416
|
}
|
|
398
417
|
}
|
|
399
418
|
|
|
@@ -405,26 +424,26 @@ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve
|
|
|
405
424
|
var esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
406
425
|
var fam = (f) => f.trim().replace(/\s+/g, "+");
|
|
407
426
|
function extractScaffoldSpec(state) {
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
const brand =
|
|
411
|
-
const guide =
|
|
427
|
+
const obj3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
428
|
+
const str4 = (v) => typeof v === "string" ? v : "";
|
|
429
|
+
const brand = obj3(state.brand);
|
|
430
|
+
const guide = obj3(brand["guide"]);
|
|
412
431
|
const answers = state.answers || {};
|
|
413
432
|
const palette = Array.isArray(guide["palette"]) ? guide["palette"] : [];
|
|
414
433
|
const role = (re, fb) => {
|
|
415
434
|
const m = palette.find((p) => p?.name && new RegExp(re, "i").test(p.name));
|
|
416
435
|
return m?.hex || fb;
|
|
417
436
|
};
|
|
418
|
-
const typography =
|
|
419
|
-
const fontOf = (slot, fb) =>
|
|
420
|
-
const voice =
|
|
437
|
+
const typography = obj3(guide["typography"]);
|
|
438
|
+
const fontOf = (slot, fb) => str4(obj3(slot)["family"]) || fb;
|
|
439
|
+
const voice = obj3(guide["voice"]);
|
|
421
440
|
const logos = Array.isArray(brand["logos"]) ? brand["logos"] : [];
|
|
422
441
|
const chosen = logos.find((l) => l?.id === brand["chosenLogoId"]) || logos[0];
|
|
423
442
|
return {
|
|
424
|
-
brandName:
|
|
425
|
-
tagline:
|
|
426
|
-
pitch:
|
|
427
|
-
positioning:
|
|
443
|
+
brandName: str4(guide["brandName"]) || "Your Product",
|
|
444
|
+
tagline: str4(guide["tagline"]),
|
|
445
|
+
pitch: str4(answers["pitch"]) || str4(answers["what-statement"]),
|
|
446
|
+
positioning: str4(guide["positioning"]),
|
|
428
447
|
personality: Array.isArray(guide["personality"]) ? guide["personality"].filter((x) => typeof x === "string") : [],
|
|
429
448
|
palette: {
|
|
430
449
|
primary: role("primary", palette[0]?.hex || "#1f3a5f"),
|
|
@@ -435,9 +454,9 @@ function extractScaffoldSpec(state) {
|
|
|
435
454
|
},
|
|
436
455
|
displayFont: fontOf(typography["display"], "Inter"),
|
|
437
456
|
bodyFont: fontOf(typography["body"], "Inter"),
|
|
438
|
-
voiceTone:
|
|
457
|
+
voiceTone: str4(voice["tone"]),
|
|
439
458
|
logoSvg: chosen?.svg || void 0,
|
|
440
|
-
pricing:
|
|
459
|
+
pricing: str4(answers["pricing-model"]) || void 0
|
|
441
460
|
};
|
|
442
461
|
}
|
|
443
462
|
function indexHtml(spec, opts) {
|
|
@@ -734,11 +753,11 @@ function dedupeSortEnv(envVars) {
|
|
|
734
753
|
function normalizeDatabase(raw) {
|
|
735
754
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
736
755
|
const r = raw;
|
|
737
|
-
const
|
|
756
|
+
const str4 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
738
757
|
return {
|
|
739
|
-
provider:
|
|
740
|
-
schemaFile:
|
|
741
|
-
envVar:
|
|
758
|
+
provider: str4(r["provider"], "neon"),
|
|
759
|
+
schemaFile: str4(r["schemaFile"], "schema.sql"),
|
|
760
|
+
envVar: str4(r["envVar"], "DATABASE_URL")
|
|
742
761
|
};
|
|
743
762
|
}
|
|
744
763
|
function normalizeEnvVars(raw) {
|
|
@@ -769,15 +788,15 @@ function normalizeBackendMeta(raw) {
|
|
|
769
788
|
const database = normalizeDatabase(r["database"]);
|
|
770
789
|
const hasFunctionsDir = typeof r["functionsDir"] === "string" && r["functionsDir"].trim().length > 0;
|
|
771
790
|
if (kind === null && envVars.length === 0 && database === null && !hasFunctionsDir) return null;
|
|
772
|
-
const
|
|
791
|
+
const str4 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
773
792
|
return {
|
|
774
793
|
backendKind: kind ?? "serverless",
|
|
775
|
-
functionsDir:
|
|
776
|
-
runtime:
|
|
777
|
-
nodeVersion:
|
|
794
|
+
functionsDir: str4(r["functionsDir"], "netlify/functions"),
|
|
795
|
+
runtime: str4(r["runtime"], "nodejs20.x"),
|
|
796
|
+
nodeVersion: str4(r["nodeVersion"], "20"),
|
|
778
797
|
envVars,
|
|
779
798
|
database,
|
|
780
|
-
guide:
|
|
799
|
+
guide: str4(r["guide"], "BACKEND.md")
|
|
781
800
|
};
|
|
782
801
|
}
|
|
783
802
|
var SECRET_NAME_RE = /(KEY|SECRET|TOKEN|PASSWORD|PASSWD|DATABASE_URL|DB_URL|CONN|DSN|CREDENTIAL|PRIVATE|AUTH)/;
|
|
@@ -1172,10 +1191,10 @@ Static deploy: the front end is live. This build also declares a backend that th
|
|
|
1172
1191
|
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
1192
|
})() : "";
|
|
1174
1193
|
const token = process.env.NETLIFY_AUTH_TOKEN || process.env.NETLIFY_TOKEN || "";
|
|
1175
|
-
const writeRecord = (
|
|
1194
|
+
const writeRecord = (rec2) => {
|
|
1176
1195
|
const recPath = join4(dirname3(stateFile), "deploy.json");
|
|
1177
1196
|
mkdirSync3(dirname3(recPath), { recursive: true });
|
|
1178
|
-
writeFileSync4(recPath, JSON.stringify(
|
|
1197
|
+
writeFileSync4(recPath, JSON.stringify(rec2, null, 2) + "\n");
|
|
1179
1198
|
return recPath;
|
|
1180
1199
|
};
|
|
1181
1200
|
if (opts.dryRun) {
|
|
@@ -1447,6 +1466,1747 @@ ${notes.join("\n")}` : ""}` + (!url ? `
|
|
|
1447
1466
|
};
|
|
1448
1467
|
}
|
|
1449
1468
|
|
|
1469
|
+
// core/sync-io.ts
|
|
1470
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
1471
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
1472
|
+
import { homedir } from "os";
|
|
1473
|
+
import { dirname as dirname4, isAbsolute as isAbsolute3, join as join5, resolve as resolve4 } from "path";
|
|
1474
|
+
|
|
1475
|
+
// core/sync.ts
|
|
1476
|
+
function slugify(name) {
|
|
1477
|
+
return String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/g, "");
|
|
1478
|
+
}
|
|
1479
|
+
function emptySyncConfig() {
|
|
1480
|
+
return { schema: 1, workspaces: [] };
|
|
1481
|
+
}
|
|
1482
|
+
var str = (v, fb = "") => typeof v === "string" ? v : fb;
|
|
1483
|
+
function parseSyncConfig(text) {
|
|
1484
|
+
let raw;
|
|
1485
|
+
try {
|
|
1486
|
+
raw = JSON.parse(text);
|
|
1487
|
+
} catch {
|
|
1488
|
+
return emptySyncConfig();
|
|
1489
|
+
}
|
|
1490
|
+
const list = Array.isArray(raw["workspaces"]) ? raw["workspaces"] : [];
|
|
1491
|
+
const workspaces = [];
|
|
1492
|
+
for (const w of list) {
|
|
1493
|
+
if (!w || typeof w !== "object") continue;
|
|
1494
|
+
const r = w;
|
|
1495
|
+
const cloneDir = str(r["cloneDir"]).trim();
|
|
1496
|
+
if (!cloneDir) continue;
|
|
1497
|
+
const ws = {
|
|
1498
|
+
name: str(r["name"]).trim() || cloneDir,
|
|
1499
|
+
repo: str(r["repo"]).trim(),
|
|
1500
|
+
defaultBranch: str(r["defaultBranch"]).trim() || "main",
|
|
1501
|
+
cloneDir
|
|
1502
|
+
};
|
|
1503
|
+
const active = str(r["activeSession"]).trim();
|
|
1504
|
+
if (active) ws.activeSession = active;
|
|
1505
|
+
workspaces.push(ws);
|
|
1506
|
+
}
|
|
1507
|
+
const cfg = { schema: 1, workspaces };
|
|
1508
|
+
const aw = str(raw["activeWorkspace"]).trim();
|
|
1509
|
+
if (aw) cfg.activeWorkspace = aw;
|
|
1510
|
+
return cfg;
|
|
1511
|
+
}
|
|
1512
|
+
function serializeSyncConfig(cfg) {
|
|
1513
|
+
return JSON.stringify({ schema: 1, activeWorkspace: cfg.activeWorkspace, workspaces: cfg.workspaces }, null, 2) + "\n";
|
|
1514
|
+
}
|
|
1515
|
+
function selectWorkspace(cfg, name) {
|
|
1516
|
+
if (name) return cfg.workspaces.find((w) => w.name === name) || null;
|
|
1517
|
+
if (cfg.activeWorkspace) {
|
|
1518
|
+
const w = cfg.workspaces.find((ws) => ws.name === cfg.activeWorkspace);
|
|
1519
|
+
if (w) return w;
|
|
1520
|
+
}
|
|
1521
|
+
return cfg.workspaces.length === 1 ? cfg.workspaces[0] : null;
|
|
1522
|
+
}
|
|
1523
|
+
function defaultSessionsDir(home) {
|
|
1524
|
+
return `${home}/.thought-layer/sessions`;
|
|
1525
|
+
}
|
|
1526
|
+
function parseGitStatus(out) {
|
|
1527
|
+
const lines = String(out || "").split("\n").filter((l) => l.length > 0);
|
|
1528
|
+
let branch = null;
|
|
1529
|
+
let ahead = 0;
|
|
1530
|
+
let behind = 0;
|
|
1531
|
+
const files = [];
|
|
1532
|
+
for (const line of lines) {
|
|
1533
|
+
if (line.startsWith("## ")) {
|
|
1534
|
+
const header = line.slice(3);
|
|
1535
|
+
branch = header.split(/\.\.\.| /)[0] || null;
|
|
1536
|
+
const a = header.match(/ahead (\d+)/);
|
|
1537
|
+
const b = header.match(/behind (\d+)/);
|
|
1538
|
+
if (a) ahead = Number(a[1]);
|
|
1539
|
+
if (b) behind = Number(b[1]);
|
|
1540
|
+
} else {
|
|
1541
|
+
files.push(line.slice(3).trim());
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
return { branch, ahead, behind, dirty: files.length > 0, files };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// core/merge.ts
|
|
1548
|
+
var num = (v) => typeof v === "number" && !Number.isNaN(v) ? v : 0;
|
|
1549
|
+
var genAt = (v) => v && typeof v === "object" ? num(v["generatedAt"]) : 0;
|
|
1550
|
+
var jsonEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1551
|
+
var rec = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1552
|
+
function mergeProgressStates(ours, theirs, opts) {
|
|
1553
|
+
const coarse = [];
|
|
1554
|
+
const oursNewer = opts.oursTs > opts.theirsTs;
|
|
1555
|
+
const answers = {};
|
|
1556
|
+
const oa = rec(ours.answers);
|
|
1557
|
+
const ta = rec(theirs.answers);
|
|
1558
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(oa), ...Object.keys(ta)])) {
|
|
1559
|
+
const inO = k in oa;
|
|
1560
|
+
const inT = k in ta;
|
|
1561
|
+
if (inO && !inT) answers[k] = oa[k];
|
|
1562
|
+
else if (!inO && inT) answers[k] = ta[k];
|
|
1563
|
+
else if (jsonEq(oa[k], ta[k])) answers[k] = oa[k];
|
|
1564
|
+
else {
|
|
1565
|
+
answers[k] = oursNewer ? oa[k] : ta[k];
|
|
1566
|
+
coarse.push(`answers.${k}`);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
const feedback = {};
|
|
1570
|
+
const of = rec(ours.feedback);
|
|
1571
|
+
const tf = rec(theirs.feedback);
|
|
1572
|
+
for (const k of /* @__PURE__ */ new Set([...Object.keys(of), ...Object.keys(tf)])) {
|
|
1573
|
+
const o = of[k];
|
|
1574
|
+
const t = tf[k];
|
|
1575
|
+
if (o != null && t == null) feedback[k] = o;
|
|
1576
|
+
else if (o == null && t != null) feedback[k] = t;
|
|
1577
|
+
else if (jsonEq(o, t)) feedback[k] = o;
|
|
1578
|
+
else {
|
|
1579
|
+
const oR = num(rec(o)["round"]);
|
|
1580
|
+
const tR = num(rec(t)["round"]);
|
|
1581
|
+
const oTs = num(rec(o)["ts"]);
|
|
1582
|
+
const tTs = num(rec(t)["ts"]);
|
|
1583
|
+
const pickOurs = oR !== tR ? oR > tR : oTs !== tTs ? oTs > tTs : oursNewer;
|
|
1584
|
+
feedback[k] = pickOurs ? o : t;
|
|
1585
|
+
coarse.push(`feedback.${k}`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const artifact = (key) => {
|
|
1589
|
+
const o = ours[key];
|
|
1590
|
+
const t = theirs[key];
|
|
1591
|
+
if (o == null && t == null) return null;
|
|
1592
|
+
if (o != null && t == null) return o;
|
|
1593
|
+
if (o == null && t != null) return t;
|
|
1594
|
+
if (jsonEq(o, t)) return o;
|
|
1595
|
+
const og = genAt(o);
|
|
1596
|
+
const tg = genAt(t);
|
|
1597
|
+
const pickOurs = og !== tg ? og > tg : oursNewer;
|
|
1598
|
+
coarse.push(key);
|
|
1599
|
+
return pickOurs ? o : t;
|
|
1600
|
+
};
|
|
1601
|
+
const merged = {
|
|
1602
|
+
version: 2,
|
|
1603
|
+
answers,
|
|
1604
|
+
feedback,
|
|
1605
|
+
bizModel: artifact("bizModel"),
|
|
1606
|
+
grill: artifact("grill"),
|
|
1607
|
+
assets: artifact("assets"),
|
|
1608
|
+
research: artifact("research"),
|
|
1609
|
+
swot: artifact("swot"),
|
|
1610
|
+
prd: artifact("prd"),
|
|
1611
|
+
naming: artifact("naming"),
|
|
1612
|
+
brand: artifact("brand"),
|
|
1613
|
+
governance: artifact("governance"),
|
|
1614
|
+
kit: mergeKit(ours.kit, theirs.kit, oursNewer)
|
|
1615
|
+
};
|
|
1616
|
+
for (const src of oursNewer ? [theirs, ours] : [ours, theirs]) {
|
|
1617
|
+
for (const k of Object.keys(src)) {
|
|
1618
|
+
if (!KNOWN_STATE_KEYS.includes(k)) merged[k] = src[k];
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
return { state: merged, coarse: Array.from(new Set(coarse)).sort() };
|
|
1622
|
+
}
|
|
1623
|
+
function mergeKit(o, t, oursNewer) {
|
|
1624
|
+
if (!o && !t) return null;
|
|
1625
|
+
if (o && !t) return o;
|
|
1626
|
+
if (!o && t) return t;
|
|
1627
|
+
const oo = o;
|
|
1628
|
+
const tt = t;
|
|
1629
|
+
const modulesRun = Array.from(/* @__PURE__ */ new Set([...oo.modulesRun || [], ...tt.modulesRun || []]));
|
|
1630
|
+
const parked = {};
|
|
1631
|
+
for (const src of [oo.parked || {}, tt.parked || {}]) {
|
|
1632
|
+
for (const [k, v] of Object.entries(src)) {
|
|
1633
|
+
parked[k] = Array.from(/* @__PURE__ */ new Set([...parked[k] || [], ...Array.isArray(v) ? v : []]));
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
const newer = oursNewer ? oo : tt;
|
|
1637
|
+
const older = oursNewer ? tt : oo;
|
|
1638
|
+
const panelMeta = { ...older.panelMeta || {}, ...newer.panelMeta || {} };
|
|
1639
|
+
const kit = {
|
|
1640
|
+
schema: Math.max(num(oo.schema) || 1, num(tt.schema) || 1),
|
|
1641
|
+
updatedAt: Math.max(num(oo.updatedAt), num(tt.updatedAt))
|
|
1642
|
+
};
|
|
1643
|
+
if (modulesRun.length) kit.modulesRun = modulesRun;
|
|
1644
|
+
if (Object.keys(parked).length) kit.parked = parked;
|
|
1645
|
+
const cursor = newer.cursor ?? older.cursor;
|
|
1646
|
+
if (cursor) kit.cursor = cursor;
|
|
1647
|
+
if (Object.keys(panelMeta).length) kit.panelMeta = panelMeta;
|
|
1648
|
+
return kit;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// core/sync-io.ts
|
|
1652
|
+
function hasGit() {
|
|
1653
|
+
try {
|
|
1654
|
+
return spawnSync3("git", ["--version"], { encoding: "utf8", timeout: 15e3 }).status === 0;
|
|
1655
|
+
} catch {
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
function hasGh() {
|
|
1660
|
+
try {
|
|
1661
|
+
return spawnSync3("gh", ["--version"], { encoding: "utf8", timeout: 15e3 }).status === 0;
|
|
1662
|
+
} catch {
|
|
1663
|
+
return false;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
function ghAuthed() {
|
|
1667
|
+
try {
|
|
1668
|
+
return spawnSync3("gh", ["auth", "status"], { encoding: "utf8", timeout: 2e4 }).status === 0;
|
|
1669
|
+
} catch {
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
function syncConfigPath() {
|
|
1674
|
+
return process.env["THOUGHT_LAYER_SYNC_CONFIG"] || join5(homedir(), ".thought-layer", "sync.json");
|
|
1675
|
+
}
|
|
1676
|
+
function loadConfig() {
|
|
1677
|
+
const p = syncConfigPath();
|
|
1678
|
+
if (!existsSync4(p)) return emptySyncConfig();
|
|
1679
|
+
try {
|
|
1680
|
+
return parseSyncConfig(readFileSync3(p, "utf8"));
|
|
1681
|
+
} catch {
|
|
1682
|
+
return emptySyncConfig();
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
function saveConfig(cfg) {
|
|
1686
|
+
const p = syncConfigPath();
|
|
1687
|
+
mkdirSync4(dirname4(p), { recursive: true });
|
|
1688
|
+
writeFileSync5(p, serializeSyncConfig(cfg));
|
|
1689
|
+
}
|
|
1690
|
+
function git(dir, args, timeout = 12e4) {
|
|
1691
|
+
const r = spawnSync3("git", dir ? ["-C", dir, ...args] : args, { encoding: "utf8", timeout });
|
|
1692
|
+
return { status: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" };
|
|
1693
|
+
}
|
|
1694
|
+
function isGitRepo(dir) {
|
|
1695
|
+
return existsSync4(join5(dir, ".git")) && git(dir, ["rev-parse", "--is-inside-work-tree"]).status === 0;
|
|
1696
|
+
}
|
|
1697
|
+
function dirNonEmpty(dir) {
|
|
1698
|
+
try {
|
|
1699
|
+
return existsSync4(dir) && readdirSync3(dir).length > 0;
|
|
1700
|
+
} catch {
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
var absDir = (d, cwd = process.cwd()) => isAbsolute3(d) ? d : resolve4(cwd, d);
|
|
1705
|
+
var GITATTRIBUTES = `# The kit reconciles session JSON itself; never let git textually merge it
|
|
1706
|
+
# (which would corrupt the envelope). -merge keeps our copy in the working tree
|
|
1707
|
+
# on a conflict; the kit then rebuilds the merged result from the clean blobs.
|
|
1708
|
+
.thought-layer/*.json -merge
|
|
1709
|
+
`;
|
|
1710
|
+
var GITIGNORE = `# A Thought Layer sessions repo holds session state only. Built product
|
|
1711
|
+
# artifacts and secrets never sync here.
|
|
1712
|
+
build.json
|
|
1713
|
+
deploy.json
|
|
1714
|
+
*.local
|
|
1715
|
+
.env
|
|
1716
|
+
.env.*
|
|
1717
|
+
!.env.example
|
|
1718
|
+
dist/
|
|
1719
|
+
.netlify/
|
|
1720
|
+
node_modules/
|
|
1721
|
+
# Delivered artifacts (tl artifacts) ARE intentionally tracked, even though the
|
|
1722
|
+
# same filenames are ignored elsewhere in the tree.
|
|
1723
|
+
!artifacts/
|
|
1724
|
+
!artifacts/**
|
|
1725
|
+
`;
|
|
1726
|
+
var README = `# Thought Layer sessions
|
|
1727
|
+
|
|
1728
|
+
This private repo is the home for Thought Layer session files. Each session is one
|
|
1729
|
+
file under \`.thought-layer/<name>.json\` (the portable validation and design state).
|
|
1730
|
+
Use the kit to work with them:
|
|
1731
|
+
|
|
1732
|
+
tl sync open --name <session> pull and resume a session
|
|
1733
|
+
tl sync save --name <session> snapshot the current state, commit, and push
|
|
1734
|
+
tl sync list list the sessions in this repo
|
|
1735
|
+
|
|
1736
|
+
Collaboration is handled by GitHub: add a collaborator to this repo in its GitHub
|
|
1737
|
+
settings, and they can clone it and run the kit against the same sessions.
|
|
1738
|
+
The kit reconciles concurrent edits itself (newest wins per field, conflicts are
|
|
1739
|
+
reported), so git never has to merge the JSON by hand.
|
|
1740
|
+
`;
|
|
1741
|
+
function writeCloneScaffold(cloneDir) {
|
|
1742
|
+
mkdirSync4(join5(cloneDir, STATE_DIR), { recursive: true });
|
|
1743
|
+
const put = (name, body) => {
|
|
1744
|
+
const p = join5(cloneDir, name);
|
|
1745
|
+
if (!existsSync4(p)) writeFileSync5(p, body);
|
|
1746
|
+
};
|
|
1747
|
+
put(".gitattributes", GITATTRIBUTES);
|
|
1748
|
+
put(".gitignore", GITIGNORE);
|
|
1749
|
+
put("README.md", README);
|
|
1750
|
+
}
|
|
1751
|
+
var ok = (message, details = {}) => ({ ok: true, message, details });
|
|
1752
|
+
var fail = (message, details = {}) => ({ ok: false, message, details });
|
|
1753
|
+
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.`;
|
|
1754
|
+
async function runSync(opts, ctx) {
|
|
1755
|
+
if (!hasGit()) {
|
|
1756
|
+
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" });
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
switch (opts.op) {
|
|
1760
|
+
case "init":
|
|
1761
|
+
return syncInit(opts);
|
|
1762
|
+
case "save":
|
|
1763
|
+
return syncSave(opts, ctx);
|
|
1764
|
+
case "list":
|
|
1765
|
+
return syncList(opts);
|
|
1766
|
+
case "open":
|
|
1767
|
+
return syncOpen(opts, ctx);
|
|
1768
|
+
case "pull":
|
|
1769
|
+
return syncPull(opts, ctx);
|
|
1770
|
+
case "push":
|
|
1771
|
+
return syncPush(opts);
|
|
1772
|
+
case "status":
|
|
1773
|
+
return syncStatus(opts);
|
|
1774
|
+
default:
|
|
1775
|
+
return fail(`Unknown sync op "${opts.op}". Use one of: init, save, list, open, pull, push, status.`);
|
|
1776
|
+
}
|
|
1777
|
+
} catch (e) {
|
|
1778
|
+
return fail(`Sync ${opts.op} failed: ${e.message}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
function resolveWorkspace(opts, cfg) {
|
|
1782
|
+
if (opts.dir && opts.dir.trim()) return { cloneDir: absDir(opts.dir), ws: null };
|
|
1783
|
+
const env = process.env["THOUGHT_LAYER_SESSIONS_DIR"];
|
|
1784
|
+
if (env && env.trim()) return { cloneDir: absDir(env), ws: null };
|
|
1785
|
+
const ws = selectWorkspace(cfg, opts.workspace);
|
|
1786
|
+
if (ws) return { cloneDir: ws.cloneDir, ws };
|
|
1787
|
+
return { cloneDir: defaultSessionsDir(homedir()), ws: null };
|
|
1788
|
+
}
|
|
1789
|
+
function syncInit(opts) {
|
|
1790
|
+
const repo = (opts.repo || "").trim();
|
|
1791
|
+
if (!repo) return fail("Pass the private repo to use: tl sync init --repo <owner/name or url> [--name <label>] [--dir <path>].");
|
|
1792
|
+
const home = homedir();
|
|
1793
|
+
const label = (opts.name || "").trim();
|
|
1794
|
+
const cloneDir = opts.dir ? absDir(opts.dir) : !label || slugify(label) === "personal" ? defaultSessionsDir(home) : join5(home, ".thought-layer", `sessions-${slugify(label)}`);
|
|
1795
|
+
if (dirNonEmpty(cloneDir)) {
|
|
1796
|
+
if (isGitRepo(cloneDir)) return fail(`${cloneDir} is already a git repo. It looks initialized; use tl sync list or pick another --dir.`, { cloneDir });
|
|
1797
|
+
return fail(`${cloneDir} already exists and is not empty. Pick another --dir or remove it first.`, { cloneDir });
|
|
1798
|
+
}
|
|
1799
|
+
const isOwnerName = /^[\w.-]+\/[\w.-]+$/.test(repo);
|
|
1800
|
+
const useGh = isOwnerName && hasGh() && ghAuthed();
|
|
1801
|
+
mkdirSync4(dirname4(cloneDir), { recursive: true });
|
|
1802
|
+
let cloned = false;
|
|
1803
|
+
if (useGh) {
|
|
1804
|
+
cloned = spawnSync3("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 18e4 }).status === 0;
|
|
1805
|
+
if (!cloned) {
|
|
1806
|
+
const created = spawnSync3("gh", ["repo", "create", repo, "--private"], { encoding: "utf8", timeout: 18e4 });
|
|
1807
|
+
if (created.status !== 0) {
|
|
1808
|
+
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)}`);
|
|
1809
|
+
}
|
|
1810
|
+
cloned = spawnSync3("gh", ["repo", "clone", repo, cloneDir], { encoding: "utf8", timeout: 18e4 }).status === 0;
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
const url = isOwnerName ? `https://github.com/${repo}.git` : repo;
|
|
1814
|
+
cloned = git(null, ["clone", url, cloneDir], 18e4).status === 0;
|
|
1815
|
+
if (!cloned) {
|
|
1816
|
+
return fail(
|
|
1817
|
+
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.`,
|
|
1818
|
+
{ repo, cloneDir }
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
writeCloneScaffold(cloneDir);
|
|
1823
|
+
git(cloneDir, ["add", "-A"]);
|
|
1824
|
+
const committed = git(cloneDir, ["commit", "-m", "Initialize Thought Layer sessions"]).status === 0;
|
|
1825
|
+
let branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
|
|
1826
|
+
if (committed && branch === "HEAD") {
|
|
1827
|
+
git(cloneDir, ["branch", "-M", "main"]);
|
|
1828
|
+
branch = "main";
|
|
1829
|
+
}
|
|
1830
|
+
let pushed = false;
|
|
1831
|
+
if (committed) pushed = git(cloneDir, ["push", "-u", "origin", branch]).status === 0;
|
|
1832
|
+
const cfg = loadConfig();
|
|
1833
|
+
const wsName = label || "personal";
|
|
1834
|
+
const ws = { name: wsName, repo, defaultBranch: branch, cloneDir };
|
|
1835
|
+
cfg.workspaces = [...cfg.workspaces.filter((w) => w.cloneDir !== cloneDir && w.name !== wsName), ws];
|
|
1836
|
+
cfg.activeWorkspace = wsName;
|
|
1837
|
+
saveConfig(cfg);
|
|
1838
|
+
return ok(
|
|
1839
|
+
`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."}
|
|
1840
|
+
${collaboratorPointer(repo)}
|
|
1841
|
+
Save your first session with: tl sync save --name <name>${label ? ` --workspace ${wsName}` : ""}.`,
|
|
1842
|
+
{ cloneDir, repo, workspace: wsName, branch, committed, pushed }
|
|
1843
|
+
);
|
|
1844
|
+
}
|
|
1845
|
+
function syncSave(opts, ctx) {
|
|
1846
|
+
const cfg = loadConfig();
|
|
1847
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
1848
|
+
if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
|
|
1849
|
+
const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
|
|
1850
|
+
if (!slug) return fail("Name the session: tl sync save --name <name> (for example photobooth, peptide, blogging).");
|
|
1851
|
+
const targetPath = join5(cloneDir, STATE_DIR, `${slug}.json`);
|
|
1852
|
+
const existed = existsSync4(targetPath);
|
|
1853
|
+
const useExplicit = !!(opts.path && opts.path.trim() || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
|
|
1854
|
+
const loadTarget = useExplicit ? opts.path : existed ? targetPath : opts.path;
|
|
1855
|
+
const source = loadStateFile(loadTarget).state;
|
|
1856
|
+
saveStateFile(source, { target: targetPath, ts: ctx.ts, exportedAt: ctx.exportedAt });
|
|
1857
|
+
git(cloneDir, ["add", "-A"]);
|
|
1858
|
+
const msg = opts.message || `${existed ? "Update" : "Save"} session ${slug}`;
|
|
1859
|
+
const commit = git(cloneDir, ["commit", "-m", msg]);
|
|
1860
|
+
const committed = commit.status === 0;
|
|
1861
|
+
let pushed = false;
|
|
1862
|
+
let pushNote = "";
|
|
1863
|
+
if (committed && !opts.noPush) {
|
|
1864
|
+
const p = git(cloneDir, ["push"]);
|
|
1865
|
+
pushed = p.status === 0;
|
|
1866
|
+
if (!pushed) pushNote = ` Could not push (${(p.err || "").split("\n")[0] || "see git output"}); commit is local, run tl sync push when ready.`;
|
|
1867
|
+
}
|
|
1868
|
+
if (ws) {
|
|
1869
|
+
ws.activeSession = `${slug}.json`;
|
|
1870
|
+
saveConfig(cfg);
|
|
1871
|
+
}
|
|
1872
|
+
return ok(
|
|
1873
|
+
`${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."}`,
|
|
1874
|
+
{ cloneDir, session: `${slug}.json`, path: targetPath, committed, pushed }
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
function syncList(opts) {
|
|
1878
|
+
const cfg = loadConfig();
|
|
1879
|
+
const { cloneDir } = resolveWorkspace(opts, cfg);
|
|
1880
|
+
if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
|
|
1881
|
+
const files = listStateFiles(cloneDir);
|
|
1882
|
+
if (!files.length) return ok(`No sessions yet in ${cloneDir}. Create one with tl sync save --name <name>.`, { cloneDir, sessions: [] });
|
|
1883
|
+
const rows = files.map((f) => {
|
|
1884
|
+
try {
|
|
1885
|
+
const sum2 = summarizeState(loadStateFile(f.path).state);
|
|
1886
|
+
return { name: f.name, path: f.path, answered: sum2.answered, artifacts: sum2.artifacts };
|
|
1887
|
+
} catch {
|
|
1888
|
+
return { name: f.name, path: f.path, answered: 0, artifacts: [], unreadable: true };
|
|
1889
|
+
}
|
|
1890
|
+
});
|
|
1891
|
+
const lines = rows.map((r) => ` ${r.name}: ${r.answered} answered${r.artifacts.length ? `, artifacts ${r.artifacts.join(", ")}` : ""}${"unreadable" in r ? " (unreadable)" : ""}`).join("\n");
|
|
1892
|
+
return ok(`${files.length} session(s) in ${cloneDir}:
|
|
1893
|
+
${lines}
|
|
1894
|
+
Open one with tl sync open --name <name>.`, { cloneDir, sessions: rows });
|
|
1895
|
+
}
|
|
1896
|
+
function syncOpen(opts, ctx) {
|
|
1897
|
+
const cfg = loadConfig();
|
|
1898
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
1899
|
+
if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.`, { cloneDir });
|
|
1900
|
+
const slug = slugify(opts.name || "");
|
|
1901
|
+
if (!slug) return fail("Name the session to open: tl sync open --name <name>. List them with tl sync list.");
|
|
1902
|
+
const pullResult = pullAndReconcile(cloneDir, ctx);
|
|
1903
|
+
const targetPath = join5(cloneDir, STATE_DIR, `${slug}.json`);
|
|
1904
|
+
if (!existsSync4(targetPath)) {
|
|
1905
|
+
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 });
|
|
1906
|
+
}
|
|
1907
|
+
if (ws) {
|
|
1908
|
+
ws.activeSession = `${slug}.json`;
|
|
1909
|
+
saveConfig(cfg);
|
|
1910
|
+
}
|
|
1911
|
+
return ok(
|
|
1912
|
+
`Opened session ${slug} (pulled latest${pullResult.merged ? `, reconciled local and remote edits${pullResult.coarse.length ? ` (review: ${pullResult.coarse.join(", ")})` : ""}` : ""}).
|
|
1913
|
+
Work on it by pointing the kit at this file:
|
|
1914
|
+
export THOUGHT_LAYER_STATE="${targetPath}"
|
|
1915
|
+
or pass --path "${targetPath}" to tl read/answer/artifact. Save with tl sync save --name ${slug}.`,
|
|
1916
|
+
{ cloneDir, session: `${slug}.json`, path: targetPath, merged: pullResult.merged, coarse: pullResult.coarse }
|
|
1917
|
+
);
|
|
1918
|
+
}
|
|
1919
|
+
function pullAndReconcile(cloneDir, ctx) {
|
|
1920
|
+
const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || "main";
|
|
1921
|
+
if (git(cloneDir, ["fetch", "origin", branch], 12e4).status !== 0) {
|
|
1922
|
+
return { merged: false, coarse: [], note: "no remote branch to fetch yet" };
|
|
1923
|
+
}
|
|
1924
|
+
const local = git(cloneDir, ["rev-parse", "HEAD"]).out.trim();
|
|
1925
|
+
const remote = git(cloneDir, ["rev-parse", `origin/${branch}`]).out.trim();
|
|
1926
|
+
if (!remote || local === remote) return { merged: false, coarse: [], note: "up to date" };
|
|
1927
|
+
const base = git(cloneDir, ["merge-base", local, remote]).out.trim();
|
|
1928
|
+
if (base === remote) return { merged: false, coarse: [], note: "local is ahead; nothing to pull" };
|
|
1929
|
+
if (base === local) {
|
|
1930
|
+
git(cloneDir, ["merge", "--ff-only", `origin/${branch}`], 12e4);
|
|
1931
|
+
return { merged: false, coarse: [], note: "fast-forward" };
|
|
1932
|
+
}
|
|
1933
|
+
const changed = git(cloneDir, ["diff", "--name-only", local, remote]).out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1934
|
+
git(cloneDir, ["merge", "--no-ff", "--no-commit", `origin/${branch}`], 12e4);
|
|
1935
|
+
const coarseAll = [];
|
|
1936
|
+
let reconciled = 0;
|
|
1937
|
+
for (const rel of changed) {
|
|
1938
|
+
if (rel.startsWith(`${STATE_DIR}/`) && rel.endsWith(".json")) {
|
|
1939
|
+
const ours = readShow(cloneDir, local, rel);
|
|
1940
|
+
const theirs = readShow(cloneDir, remote, rel);
|
|
1941
|
+
if (ours && theirs) {
|
|
1942
|
+
try {
|
|
1943
|
+
const op = parseProgress(ours);
|
|
1944
|
+
const tp = parseProgress(theirs);
|
|
1945
|
+
const { state, coarse } = mergeProgressStates(op.state, tp.state, { oursTs: op.writer?.ts ?? 0, theirsTs: tp.writer?.ts ?? 0 });
|
|
1946
|
+
writeFileSync5(join5(cloneDir, rel), serializeProgress(buildProgress(state, { kind: "kit", ts: ctx.ts }, ctx.exportedAt)));
|
|
1947
|
+
coarseAll.push(...coarse);
|
|
1948
|
+
reconciled++;
|
|
1949
|
+
} catch {
|
|
1950
|
+
if (theirs) writeFileSync5(join5(cloneDir, rel), theirs);
|
|
1951
|
+
}
|
|
1952
|
+
} else if (theirs && !ours) {
|
|
1953
|
+
writeFileSync5(join5(cloneDir, rel), theirs);
|
|
1954
|
+
reconciled++;
|
|
1955
|
+
}
|
|
1956
|
+
} else {
|
|
1957
|
+
const theirs = readShow(cloneDir, remote, rel);
|
|
1958
|
+
if (theirs !== null) writeFileSync5(join5(cloneDir, rel), theirs);
|
|
1959
|
+
}
|
|
1960
|
+
git(cloneDir, ["add", "--", rel]);
|
|
1961
|
+
}
|
|
1962
|
+
git(cloneDir, ["add", "-A"]);
|
|
1963
|
+
git(cloneDir, ["commit", "--no-edit", "-m", "Reconcile sessions (kit merge)"]);
|
|
1964
|
+
return { merged: reconciled > 0, coarse: Array.from(new Set(coarseAll)).sort(), note: `reconciled ${reconciled} session file(s)` };
|
|
1965
|
+
}
|
|
1966
|
+
function readShow(cloneDir, ref, rel) {
|
|
1967
|
+
const r = git(cloneDir, ["show", `${ref}:${rel}`]);
|
|
1968
|
+
return r.status === 0 ? r.out : null;
|
|
1969
|
+
}
|
|
1970
|
+
function syncPull(opts, ctx) {
|
|
1971
|
+
const cfg = loadConfig();
|
|
1972
|
+
const { cloneDir } = resolveWorkspace(opts, cfg);
|
|
1973
|
+
if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
|
|
1974
|
+
const r = pullAndReconcile(cloneDir, ctx);
|
|
1975
|
+
const push = git(cloneDir, ["push"]);
|
|
1976
|
+
return ok(
|
|
1977
|
+
`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." : ""}`,
|
|
1978
|
+
{ cloneDir, merged: r.merged, coarse: r.coarse }
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
function syncPush(opts) {
|
|
1982
|
+
const cfg = loadConfig();
|
|
1983
|
+
const { cloneDir } = resolveWorkspace(opts, cfg);
|
|
1984
|
+
if (!isGitRepo(cloneDir)) return fail(`No sessions workspace at ${cloneDir}. Run tl sync init first.`, { cloneDir });
|
|
1985
|
+
git(cloneDir, ["add", "-A"]);
|
|
1986
|
+
const committed = git(cloneDir, ["commit", "-m", opts.message || "Sync sessions"]).status === 0;
|
|
1987
|
+
const p = git(cloneDir, ["push"]);
|
|
1988
|
+
if (p.status !== 0) return fail(`Push failed: ${(p.err || "").split("\n")[0] || "see git output"}. Pull first (tl sync pull), then push.`, { cloneDir });
|
|
1989
|
+
return ok(`Pushed ${cloneDir}.${committed ? " Committed pending changes." : " Nothing new to commit; pushed any local commits."}`, { cloneDir, committed });
|
|
1990
|
+
}
|
|
1991
|
+
function syncStatus(opts) {
|
|
1992
|
+
const cfg = loadConfig();
|
|
1993
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
1994
|
+
if (!isGitRepo(cloneDir)) {
|
|
1995
|
+
const known = cfg.workspaces.length ? ` Known workspaces: ${cfg.workspaces.map((w) => w.name).join(", ")}.` : "";
|
|
1996
|
+
return fail(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first.${known}`, { cloneDir, workspaces: cfg.workspaces });
|
|
1997
|
+
}
|
|
1998
|
+
const st = parseGitStatus(git(cloneDir, ["status", "--porcelain=v1", "--branch"]).out);
|
|
1999
|
+
const sessions = listStateFiles(cloneDir).length;
|
|
2000
|
+
return ok(
|
|
2001
|
+
`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}.` : ""}`,
|
|
2002
|
+
{ cloneDir, workspace: ws?.name || null, sessions, ...st }
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// core/artifacts-io.ts
|
|
2007
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync6 } from "fs";
|
|
2008
|
+
import { basename, dirname as dirname5, join as join6 } from "path";
|
|
2009
|
+
|
|
2010
|
+
// core/model.ts
|
|
2011
|
+
function num2(v, fallback = 0) {
|
|
2012
|
+
const n = Number(v);
|
|
2013
|
+
return Number.isFinite(n) ? n : fallback;
|
|
2014
|
+
}
|
|
2015
|
+
function sum(arr) {
|
|
2016
|
+
return arr.reduce((a, b) => a + b, 0);
|
|
2017
|
+
}
|
|
2018
|
+
function computeProjection(assumptions) {
|
|
2019
|
+
if (!assumptions?.parties?.length) return null;
|
|
2020
|
+
const horizon = Math.min(Math.max(assumptions.horizonMonths || 36, 6), 120);
|
|
2021
|
+
const parties = assumptions.parties;
|
|
2022
|
+
const fixedCosts = assumptions.fixedCosts || [];
|
|
2023
|
+
const oneTimeCosts = assumptions.oneTimeCosts || [];
|
|
2024
|
+
const counts = {};
|
|
2025
|
+
parties.forEach((p) => {
|
|
2026
|
+
counts[p.id] = num2(p.startingCount);
|
|
2027
|
+
});
|
|
2028
|
+
const rows = [];
|
|
2029
|
+
let cumulative = 0;
|
|
2030
|
+
let breakEvenMonth = null;
|
|
2031
|
+
let cumBreakEvenMonth = null;
|
|
2032
|
+
for (let m = 1; m <= horizon; m++) {
|
|
2033
|
+
const row = {
|
|
2034
|
+
month: m,
|
|
2035
|
+
parties: {},
|
|
2036
|
+
revenue: 0,
|
|
2037
|
+
variableCost: 0,
|
|
2038
|
+
cacSpend: 0,
|
|
2039
|
+
fixedCost: 0,
|
|
2040
|
+
oneTimeCost: 0,
|
|
2041
|
+
totalCost: 0,
|
|
2042
|
+
grossProfit: 0,
|
|
2043
|
+
netProfit: 0,
|
|
2044
|
+
cumulative: 0
|
|
2045
|
+
};
|
|
2046
|
+
for (const p of parties) {
|
|
2047
|
+
const prev = counts[p.id] ?? 0;
|
|
2048
|
+
const newUnits = num2(p.monthlyNewBase) * Math.pow(1 + num2(p.monthlyNewGrowthPct) / 100, m - 1);
|
|
2049
|
+
const churned = prev * (num2(p.monthlyChurnPct) / 100);
|
|
2050
|
+
const count = Math.max(0, prev + newUnits - churned);
|
|
2051
|
+
counts[p.id] = count;
|
|
2052
|
+
const revenue = count * num2(p.revenuePerUnitPerMonth);
|
|
2053
|
+
const varCost = count * num2(p.variableCostPerUnitPerMonth);
|
|
2054
|
+
const cac = newUnits * num2(p.cacPerUnit);
|
|
2055
|
+
row.parties[p.id] = { count, newUnits, churned, revenue, varCost, cac };
|
|
2056
|
+
row.revenue += revenue;
|
|
2057
|
+
row.variableCost += varCost;
|
|
2058
|
+
row.cacSpend += cac;
|
|
2059
|
+
}
|
|
2060
|
+
for (const f of fixedCosts) {
|
|
2061
|
+
if (m >= num2(f.startMonth, 1)) row.fixedCost += num2(f.monthlyAmount);
|
|
2062
|
+
}
|
|
2063
|
+
for (const o of oneTimeCosts) {
|
|
2064
|
+
if (num2(o.month, 1) === m) row.oneTimeCost += num2(o.amount);
|
|
2065
|
+
}
|
|
2066
|
+
row.totalCost = row.variableCost + row.cacSpend + row.fixedCost + row.oneTimeCost;
|
|
2067
|
+
row.grossProfit = row.revenue - row.variableCost;
|
|
2068
|
+
row.netProfit = row.revenue - row.totalCost;
|
|
2069
|
+
cumulative += row.netProfit;
|
|
2070
|
+
row.cumulative = cumulative;
|
|
2071
|
+
if (breakEvenMonth === null && row.netProfit > 0) breakEvenMonth = m;
|
|
2072
|
+
if (cumBreakEvenMonth === null && cumulative > 0) cumBreakEvenMonth = m;
|
|
2073
|
+
rows.push(row);
|
|
2074
|
+
}
|
|
2075
|
+
const last = rows[rows.length - 1];
|
|
2076
|
+
if (!last) return null;
|
|
2077
|
+
const year1 = rows.slice(0, 12);
|
|
2078
|
+
return {
|
|
2079
|
+
rows,
|
|
2080
|
+
summary: {
|
|
2081
|
+
horizon,
|
|
2082
|
+
breakEvenMonth,
|
|
2083
|
+
cumBreakEvenMonth,
|
|
2084
|
+
year1Revenue: sum(year1.map((r) => r.revenue)),
|
|
2085
|
+
year1Net: sum(year1.map((r) => r.netProfit)),
|
|
2086
|
+
totalRevenue: sum(rows.map((r) => r.revenue)),
|
|
2087
|
+
totalNet: cumulative,
|
|
2088
|
+
maxDrawdown: Math.min(...rows.map((r) => r.cumulative), 0),
|
|
2089
|
+
endingMRR: last.revenue,
|
|
2090
|
+
endingCounts: Object.fromEntries(parties.map((p) => [p.id, last.parties[p.id]?.count || 0]))
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
function fmtMoney(n, currency = "USD") {
|
|
2095
|
+
try {
|
|
2096
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency, maximumFractionDigits: 0 }).format(n);
|
|
2097
|
+
} catch {
|
|
2098
|
+
return `$${Math.round(n).toLocaleString()}`;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
// core/artifacts.ts
|
|
2103
|
+
var obj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
2104
|
+
var str2 = (v) => typeof v === "string" ? v : "";
|
|
2105
|
+
var esc2 = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2106
|
+
var fam2 = (f) => String(f).trim().replace(/\s+/g, "+");
|
|
2107
|
+
function brandTokens(guide) {
|
|
2108
|
+
if (!guide || !Array.isArray(guide.palette) || !guide.palette.length) return null;
|
|
2109
|
+
const role = (re, fb) => guide.palette.find((p) => p?.name && new RegExp(re, "i").test(p.name))?.hex || fb;
|
|
2110
|
+
return {
|
|
2111
|
+
primary: role("primary", guide.palette[0]?.hex || "#1f3a5f"),
|
|
2112
|
+
accent: role("accent|secondary", guide.palette[1]?.hex || "#e8743b"),
|
|
2113
|
+
ink: role("ink|text|dark|black", "#16202b"),
|
|
2114
|
+
surface: role("surface|background|light|paper|off.?white|cream", "#f7f8fa"),
|
|
2115
|
+
muted: role("muted|gray|grey|neutral|border", "#8a9099"),
|
|
2116
|
+
display: guide.typography?.display?.family || "Inter",
|
|
2117
|
+
body: guide.typography?.body?.family || "Inter"
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
var SVG_FONTS = `<style><![CDATA[@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap');.s{font-family:'Inter',system-ui,sans-serif}.d{font-family:'Inter',system-ui,sans-serif}.m{font-family:'IBM Plex Mono',ui-monospace,monospace}]]></style>`;
|
|
2121
|
+
function svgFontBlock(t) {
|
|
2122
|
+
if (!t) return { style: SVG_FONTS, disp: "d" };
|
|
2123
|
+
const style = `<style><![CDATA[@import url('https://fonts.googleapis.com/css2?family=${fam2(t.display)}:wght@600;700;800&family=${fam2(t.body)}:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap');.s{font-family:'${t.body}',system-ui,sans-serif}.d{font-family:'${t.display}','${t.body}',sans-serif}.m{font-family:'IBM Plex Mono',ui-monospace,monospace}]]></style>`;
|
|
2124
|
+
return { style, disp: "d" };
|
|
2125
|
+
}
|
|
2126
|
+
function wrapText(text, maxWidthPx, fontSizePx, avg = 0.54) {
|
|
2127
|
+
const maxChars = Math.max(6, Math.floor(maxWidthPx / (fontSizePx * avg)));
|
|
2128
|
+
const out = [];
|
|
2129
|
+
let line = "";
|
|
2130
|
+
for (let word of String(text).trim().split(/\s+/)) {
|
|
2131
|
+
while (word.length > maxChars) {
|
|
2132
|
+
if (line) {
|
|
2133
|
+
out.push(line);
|
|
2134
|
+
line = "";
|
|
2135
|
+
}
|
|
2136
|
+
out.push(word.slice(0, maxChars - 1) + "-");
|
|
2137
|
+
word = word.slice(maxChars - 1);
|
|
2138
|
+
}
|
|
2139
|
+
const test = line ? line + " " + word : word;
|
|
2140
|
+
if (test.length > maxChars && line) {
|
|
2141
|
+
out.push(line);
|
|
2142
|
+
line = word;
|
|
2143
|
+
} else line = test;
|
|
2144
|
+
}
|
|
2145
|
+
if (line) out.push(line);
|
|
2146
|
+
return out.length ? out : [""];
|
|
2147
|
+
}
|
|
2148
|
+
function glossaryMarkdown(grill) {
|
|
2149
|
+
const terms = grill?.glossary || [];
|
|
2150
|
+
return `# Domain Glossary
|
|
2151
|
+
|
|
2152
|
+
${terms.map((g) => `- **${g.term}**: ${g.definition}`).join("\n")}
|
|
2153
|
+
`;
|
|
2154
|
+
}
|
|
2155
|
+
var REQ_CATS = ["persona", "journey", "ux", "functional", "business-rule", "data", "integration", "non-functional", "metric"];
|
|
2156
|
+
function requirementsMarkdown(grill) {
|
|
2157
|
+
const reqs = (grill?.requirements || []).map((r) => ({
|
|
2158
|
+
id: r.id || "",
|
|
2159
|
+
category: r.category || "functional",
|
|
2160
|
+
text: r.text ?? r.statement ?? ""
|
|
2161
|
+
}));
|
|
2162
|
+
let md = "# Requirements\n";
|
|
2163
|
+
for (const cat of REQ_CATS) {
|
|
2164
|
+
const inCat = reqs.filter((r) => r.category === cat);
|
|
2165
|
+
if (!inCat.length) continue;
|
|
2166
|
+
md += `
|
|
2167
|
+
## ${cat.replace("-", " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
2168
|
+
|
|
2169
|
+
`;
|
|
2170
|
+
md += inCat.map((r) => `- **${r.id}** ${r.text}`).join("\n") + "\n";
|
|
2171
|
+
}
|
|
2172
|
+
return md;
|
|
2173
|
+
}
|
|
2174
|
+
function swotMarkdown(swot) {
|
|
2175
|
+
if (!swot) return "";
|
|
2176
|
+
const quads = [
|
|
2177
|
+
["strengths", "Strengths"],
|
|
2178
|
+
["weaknesses", "Weaknesses"],
|
|
2179
|
+
["opportunities", "Opportunities"],
|
|
2180
|
+
["threats", "Threats"]
|
|
2181
|
+
];
|
|
2182
|
+
let md = "# SWOT Analysis\n";
|
|
2183
|
+
for (const [k, label] of quads) {
|
|
2184
|
+
const items = (swot[k] || []).filter((x) => x && x.trim());
|
|
2185
|
+
md += `
|
|
2186
|
+
## ${label}
|
|
2187
|
+
|
|
2188
|
+
${items.length ? items.map((i) => `- ${i}`).join("\n") : "_(none)_"}
|
|
2189
|
+
`;
|
|
2190
|
+
}
|
|
2191
|
+
return md;
|
|
2192
|
+
}
|
|
2193
|
+
function brandGuideMarkdown(guide) {
|
|
2194
|
+
if (!guide) return "";
|
|
2195
|
+
const pal = (guide.palette || []).map((p) => `- **${p.name}** \`${p.hex}\` (${p.role})`).join("\n");
|
|
2196
|
+
const msg = (guide.messaging || []).map((m) => `- ${m}`).join("\n");
|
|
2197
|
+
const dos = (guide.voice?.dos || []).map((d) => `- ${d}`).join("\n");
|
|
2198
|
+
const donts = (guide.voice?.donts || []).map((d) => `- ${d}`).join("\n");
|
|
2199
|
+
const disp = guide.typography?.display;
|
|
2200
|
+
const body = guide.typography?.body;
|
|
2201
|
+
return `# Brand Style Guide: ${guide.brandName || ""}
|
|
2202
|
+
|
|
2203
|
+
> ${guide.tagline || ""}
|
|
2204
|
+
|
|
2205
|
+
**Positioning.** ${guide.positioning || ""}
|
|
2206
|
+
|
|
2207
|
+
**Personality.** ${(guide.personality || []).join(", ")}
|
|
2208
|
+
|
|
2209
|
+
## Voice and Tone
|
|
2210
|
+
${guide.voice?.tone || ""}
|
|
2211
|
+
|
|
2212
|
+
**Do**
|
|
2213
|
+
${dos || "- (none)"}
|
|
2214
|
+
|
|
2215
|
+
**Don't**
|
|
2216
|
+
${donts || "- (none)"}
|
|
2217
|
+
|
|
2218
|
+
## Color Palette
|
|
2219
|
+
${pal || "- (none)"}
|
|
2220
|
+
|
|
2221
|
+
## Typography
|
|
2222
|
+
- **Display:** ${disp?.family || ""} (${disp?.weights || ""}). Usage: ${disp?.usage || ""}
|
|
2223
|
+
- **Body:** ${body?.family || ""} (${body?.weights || ""}). Usage: ${body?.usage || ""}
|
|
2224
|
+
|
|
2225
|
+
## Logo Direction
|
|
2226
|
+
${guide.logoDirection || ""}
|
|
2227
|
+
|
|
2228
|
+
## Imagery and Iconography
|
|
2229
|
+
${guide.imagery || ""}
|
|
2230
|
+
|
|
2231
|
+
## Messaging Examples
|
|
2232
|
+
${msg || "- (none)"}
|
|
2233
|
+
`;
|
|
2234
|
+
}
|
|
2235
|
+
function collectTodos(feedback) {
|
|
2236
|
+
const out = [];
|
|
2237
|
+
for (const [qId, v] of Object.entries(feedback || {})) {
|
|
2238
|
+
const todos = v?.todos;
|
|
2239
|
+
if (todos?.length) out.push({ qId, summaries: todos.map((t) => t.summary || "to-do") });
|
|
2240
|
+
}
|
|
2241
|
+
return out;
|
|
2242
|
+
}
|
|
2243
|
+
var SEO_DEPLOY_SECTION = `## Discoverability and SEO (required)
|
|
2244
|
+
Build the product so both people and AI assistants can find and trust it. Implement all of the following:
|
|
2245
|
+
- Structured data: add schema.org JSON-LD as one linked @graph. Always include an Organization and a Person (the founder), cross-referenced (Organization.founder and Person.worksFor) and each with a sameAs array pointing to real profiles. Add a WebSite node and the type that matches the page (WebPage, SoftwareApplication, or Product). Add FAQPage wherever there is an FAQ, HowTo for any step-by-step process, BreadcrumbList on every real sub-page, and Article or BlogPosting on real article pages. Never add a schema type that does not match visible on-page content.
|
|
2246
|
+
- llms.txt: create /llms.txt at the site root with an H1 title, a one-paragraph summary in a blockquote, sections that link the key pages, and a short FAQ, so AI crawlers can extract what the site is and does.
|
|
2247
|
+
- sitemap.xml listing every real page, and robots.txt that allows crawling and points to the sitemap.
|
|
2248
|
+
- A canonical link on every page.
|
|
2249
|
+
- Open Graph and Twitter Card meta on every page, plus a 1200x630 social image.
|
|
2250
|
+
- Semantic, accessible HTML: landmark elements, headings in order, alt text on images, labelled form controls, and a visible focus style.
|
|
2251
|
+
- An SEO README at SEO.md that documents every item above: where each lives and how to update it later (especially the Organization and Person sameAs links and the social image).
|
|
2252
|
+
|
|
2253
|
+
## Deployment (Netlify recommended)
|
|
2254
|
+
Netlify is the simplest way to ship this. Include what it needs and give the founder a paste-ready deploy path:
|
|
2255
|
+
- Add a netlify.toml with the build command and publish directory. For a single-page app, add a SPA redirect (/* to /index.html with status 200).
|
|
2256
|
+
- Document two deploy options in SEO.md or a DEPLOY.md:
|
|
2257
|
+
1. Continuous deploy: push the repo to GitHub and connect it at app.netlify.com, so every push to the main branch deploys automatically.
|
|
2258
|
+
2. CLI deploy: run the build, then "npx netlify-cli deploy --prod --dir <publish-directory>".
|
|
2259
|
+
- Make sure llms.txt, robots.txt, sitemap.xml, and the social image sit in the publish directory so they are served at the site root.
|
|
2260
|
+
`;
|
|
2261
|
+
function buildKitPrompt(grill, prdMarkdown, assumptions, brand, feedback = {}) {
|
|
2262
|
+
const projection = assumptions ? computeProjection(assumptions) : null;
|
|
2263
|
+
let bizSummary = "";
|
|
2264
|
+
if (projection && assumptions) {
|
|
2265
|
+
const s = projection.summary;
|
|
2266
|
+
bizSummary = `Business model: ${(assumptions.parties || []).map((p) => `${p.name} (${p.role})`).join(", ")}. Monthly break-even at month ${s.breakEvenMonth ?? "beyond horizon"}; cumulative break-even at month ${s.cumBreakEvenMonth ?? "beyond horizon"}; year-1 revenue about ${fmtMoney(s.year1Revenue, assumptions.currency)}; max cash drawdown about ${fmtMoney(Math.abs(s.maxDrawdown), assumptions.currency)}.`;
|
|
2267
|
+
}
|
|
2268
|
+
const groups = collectTodos(feedback);
|
|
2269
|
+
const todoBlock = groups.length ? `
|
|
2270
|
+
## Open validation to-dos (the founder set these aside; treat as known gaps, not blockers)
|
|
2271
|
+
${groups.map((g) => `- ${g.qId}: ${g.summaries.join("; ")}`).join("\n")}
|
|
2272
|
+
` : "";
|
|
2273
|
+
const hasGuide = !!brand?.guide;
|
|
2274
|
+
return `# Build This Product
|
|
2275
|
+
|
|
2276
|
+
You are an expert full-stack engineering agent. Build version 1 of the product specified below. Work iteratively: scaffold, implement the critical user journeys first, then the remaining requirements. Ask nothing; every decision you need is in this document, and where genuinely unspecified, choose the simplest option consistent with the PRD and note it in a DECISIONS.md.
|
|
2277
|
+
|
|
2278
|
+
## Ground rules
|
|
2279
|
+
- Honor the Domain Glossary exactly: use its terms for entities, fields, and UI labels (ubiquitous language).
|
|
2280
|
+
- Every requirement has an ID (R-1, R-2, ...). Track them in a TRACEABILITY.md mapping requirement to implementation to test.
|
|
2281
|
+
- Respect the "Out of Scope" list absolutely. Do not build excluded features.
|
|
2282
|
+
- Mobile and desktop must both work.${hasGuide ? "\n- Apply the brand identity in the Brand section below to all UI, copy, color, and type, and to any generated assets." : ""}
|
|
2283
|
+
${bizSummary ? `
|
|
2284
|
+
## Business context
|
|
2285
|
+
${bizSummary}
|
|
2286
|
+
` : ""}${hasGuide ? `
|
|
2287
|
+
## Brand identity (apply consistently)
|
|
2288
|
+
${brandGuideMarkdown(brand.guide)}
|
|
2289
|
+
(The kit also includes Logo.svg and a rendered LookBook.html.)
|
|
2290
|
+
` : ""}${todoBlock}
|
|
2291
|
+
${SEO_DEPLOY_SECTION}
|
|
2292
|
+
---
|
|
2293
|
+
|
|
2294
|
+
${prdMarkdown || "(PRD not yet composed; generate the PRD first)"}
|
|
2295
|
+
|
|
2296
|
+
---
|
|
2297
|
+
|
|
2298
|
+
${requirementsMarkdown(grill)}
|
|
2299
|
+
|
|
2300
|
+
---
|
|
2301
|
+
|
|
2302
|
+
${glossaryMarkdown(grill)}
|
|
2303
|
+
`;
|
|
2304
|
+
}
|
|
2305
|
+
function swotInfographicSvg(swot, brand) {
|
|
2306
|
+
const t = brandTokens(brand?.guide);
|
|
2307
|
+
const { style: fontStyle, disp } = svgFontBlock(t);
|
|
2308
|
+
const surface = t?.surface || "#f7f8fa";
|
|
2309
|
+
const ink = t?.ink || "#0f1729";
|
|
2310
|
+
const W = 1e3, spineX = W / 2, R = 34, LH = 19, FS = 13.5;
|
|
2311
|
+
const headerH = 104, footerH = 42, gapRow = 26;
|
|
2312
|
+
const PANEL = { L: { x: 28, w: 444 }, R: { x: 528, w: 444 } };
|
|
2313
|
+
const textW = 360;
|
|
2314
|
+
const quads = [
|
|
2315
|
+
{ key: "strengths", label: "STRENGTHS", color: "#059669", icon: "up", side: -1, row: 0 },
|
|
2316
|
+
{ key: "weaknesses", label: "WEAKNESSES", color: "#dc2626", icon: "down", side: -1, row: 1 },
|
|
2317
|
+
{ key: "opportunities", label: "OPPORTUNITIES", color: "#4f46e5", icon: "search", side: 1, row: 0 },
|
|
2318
|
+
{ key: "threats", label: "THREATS", color: "#d97706", icon: "warn", side: 1, row: 1 }
|
|
2319
|
+
].map((q) => ({
|
|
2320
|
+
...q,
|
|
2321
|
+
items: (swot?.[q.key] || []).map((x) => String(x || "").trim()).filter(Boolean).slice(0, 8).map((s) => wrapText(s, textW, FS))
|
|
2322
|
+
}));
|
|
2323
|
+
const bulletsH = (q) => q.items.length ? q.items.reduce((a, lines) => a + lines.length * LH + 10, 0) : 26;
|
|
2324
|
+
const quadH = (q) => 94 + bulletsH(q) + 14;
|
|
2325
|
+
const rowH = [Math.max(quadH(quads[0]), quadH(quads[2])), Math.max(quadH(quads[1]), quadH(quads[3]))];
|
|
2326
|
+
const rowY = [headerH + gapRow, 0];
|
|
2327
|
+
rowY[1] = rowY[0] + rowH[0] + gapRow;
|
|
2328
|
+
const footerTop = rowY[1] + rowH[1] + gapRow;
|
|
2329
|
+
const H = footerTop + footerH;
|
|
2330
|
+
const icon = (type, cx, cy) => {
|
|
2331
|
+
const P = (d, w = 5) => `<path d="${d}" fill="none" stroke="#ffffff" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
2332
|
+
if (type === "up") return P(`M${cx} ${cy + 13} L${cx} ${cy - 13} M${cx - 9} ${cy - 4} L${cx} ${cy - 13} L${cx + 9} ${cy - 4}`);
|
|
2333
|
+
if (type === "down") return P(`M${cx} ${cy - 13} L${cx} ${cy + 13} M${cx - 9} ${cy + 4} L${cx} ${cy + 13} L${cx + 9} ${cy + 4}`);
|
|
2334
|
+
if (type === "search") return `<circle cx="${cx - 3}" cy="${cy - 3}" r="9.5" fill="none" stroke="#ffffff" stroke-width="4.5"/>` + P(`M${cx + 4} ${cy + 4} L${cx + 13} ${cy + 13}`);
|
|
2335
|
+
return P(`M${cx} ${cy - 15} L${cx + 15} ${cy + 12} L${cx - 15} ${cy + 12} Z`, 4.5) + `<rect x="${cx - 2.5}" y="${cy - 6}" width="5" height="11" rx="2.5" fill="#ffffff"/><circle cx="${cx}" cy="${cy + 9}" r="2.7" fill="#ffffff"/>`;
|
|
2336
|
+
};
|
|
2337
|
+
const quadrant = (q) => {
|
|
2338
|
+
const pan = q.side < 0 ? PANEL.L : PANEL.R;
|
|
2339
|
+
const top = rowY[q.row], rh = rowH[q.row];
|
|
2340
|
+
const my = top + 40;
|
|
2341
|
+
const mx = spineX + q.side * 70;
|
|
2342
|
+
const innerEdge = mx - q.side * R;
|
|
2343
|
+
const textX = pan.x + 42, dotX = pan.x + 26;
|
|
2344
|
+
let b = `<rect x="${pan.x}" y="${top + 10}" width="${pan.w}" height="${rh - 10}" rx="18" fill="${q.color}" opacity="0.06"/>`;
|
|
2345
|
+
b += `<rect x="${pan.x}" y="${top + 10}" width="${pan.w}" height="${rh - 10}" rx="18" fill="none" stroke="${q.color}" stroke-opacity="0.18"/>`;
|
|
2346
|
+
b += `<line x1="${spineX}" y1="${my}" x2="${innerEdge}" y2="${my}" stroke="${q.color}" stroke-width="2.5"/>`;
|
|
2347
|
+
b += `<circle cx="${spineX}" cy="${my}" r="4.5" fill="${q.color}"/>`;
|
|
2348
|
+
b += `<circle cx="${mx}" cy="${my}" r="${R + 6}" fill="${q.color}" opacity="0.15"/>`;
|
|
2349
|
+
b += `<circle cx="${mx}" cy="${my}" r="${R}" fill="${q.color}"/>`;
|
|
2350
|
+
b += icon(q.icon, mx, my);
|
|
2351
|
+
const tabW = Math.round(q.label.length * 8.6) + 30, tabH = 34, tabY = my - tabH / 2;
|
|
2352
|
+
const tabX = q.side < 0 ? mx - R - 12 - tabW : mx + R + 12;
|
|
2353
|
+
b += `<rect x="${tabX}" y="${tabY}" width="${tabW}" height="${tabH}" rx="8" fill="${q.color}"/>`;
|
|
2354
|
+
b += `<text class="${disp}" x="${tabX + tabW / 2}" y="${tabY + 22}" text-anchor="middle" font-size="13.5" font-weight="700" letter-spacing="1.5" fill="#ffffff">${esc2(q.label)}</text>`;
|
|
2355
|
+
let y = top + 94;
|
|
2356
|
+
if (!q.items.length) return b + `<text class="s" x="${textX}" y="${y}" font-size="13" fill="#9ca3af">(none yet)</text>`;
|
|
2357
|
+
for (const lines of q.items) {
|
|
2358
|
+
b += `<circle cx="${dotX}" cy="${y - 4}" r="3" fill="${q.color}"/>`;
|
|
2359
|
+
b += `<text class="s" x="${textX}" y="${y}" font-size="13.5" fill="#374151">` + lines.map((ln, i) => `<tspan x="${textX}"${i ? ` dy="${LH}"` : ""}>${esc2(ln)}</tspan>`).join("") + `</text>`;
|
|
2360
|
+
y += lines.length * LH + 10;
|
|
2361
|
+
}
|
|
2362
|
+
return b;
|
|
2363
|
+
};
|
|
2364
|
+
let body = `<rect width="${W}" height="${H}" fill="${surface}"/>`;
|
|
2365
|
+
body += `<line x1="${spineX}" y1="${headerH}" x2="${spineX}" y2="${footerTop}" stroke="#cfd5de" stroke-width="2"/>`;
|
|
2366
|
+
body += quads.map(quadrant).join("");
|
|
2367
|
+
body += `<rect x="0" y="0" width="${W}" height="${headerH}" fill="${ink}"/>`;
|
|
2368
|
+
body += `<text class="m" x="40" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5">THE THOUGHT LAYER</text>`;
|
|
2369
|
+
body += `<text class="m" x="${W - 40}" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5" text-anchor="end">STRATEGY</text>`;
|
|
2370
|
+
body += `<text class="${disp}" x="${spineX}" y="74" font-size="46" font-weight="700" letter-spacing="6" fill="#ffffff" text-anchor="middle">SWOT ANALYSIS</text>`;
|
|
2371
|
+
body += `<rect x="0" y="${footerTop}" width="${W}" height="${footerH}" fill="${ink}"/>`;
|
|
2372
|
+
body += `<text class="m" x="${spineX}" y="${footerTop + 26}" font-size="11" fill="#ffffff" fill-opacity="0.55" text-anchor="middle">The Thought Layer, generated locally</text>`;
|
|
2373
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${fontStyle}${body}</svg>`;
|
|
2374
|
+
}
|
|
2375
|
+
function bizModelInfographicSvg(assumptions, brand) {
|
|
2376
|
+
const projection = computeProjection(assumptions);
|
|
2377
|
+
if (!projection || !assumptions) return null;
|
|
2378
|
+
const s = projection.summary;
|
|
2379
|
+
const cur = assumptions.currency || "USD";
|
|
2380
|
+
const t = brandTokens(brand?.guide);
|
|
2381
|
+
const { style: fontStyle, disp } = svgFontBlock(t);
|
|
2382
|
+
const surface = t?.surface || "#f7f8fa";
|
|
2383
|
+
const ink = t?.ink || "#0f1729";
|
|
2384
|
+
const accent = t?.accent || "#4f46e5";
|
|
2385
|
+
const W = 1e3, spineX = W / 2, M = 28, LH = 19, FS = 13.5;
|
|
2386
|
+
const headerH = 104, footerH = 42;
|
|
2387
|
+
const PANEL = { L: { x: 28, w: 444 }, R: { x: 528, w: 444 } };
|
|
2388
|
+
const metrics = [
|
|
2389
|
+
["Year 1 revenue", fmtMoney(s.year1Revenue, cur)],
|
|
2390
|
+
["Monthly break-even", s.breakEvenMonth ? `Month ${s.breakEvenMonth}` : "Beyond horizon"],
|
|
2391
|
+
["Max cash drawdown", fmtMoney(s.maxDrawdown, cur)],
|
|
2392
|
+
[`MRR at month ${s.horizon}`, fmtMoney(s.endingMRR, cur)]
|
|
2393
|
+
];
|
|
2394
|
+
const milestones = assumptions.milestones || [];
|
|
2395
|
+
const partyLines = (assumptions.parties || []).slice(0, 7).map((p) => wrapText(`${p.name} (${p.role}): ${fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur)}/unit/mo, CAC ${fmtMoney(Number(p.cacPerUnit) || 0, cur)}`, PANEL.L.w - 60, FS));
|
|
2396
|
+
const msLines = [...milestones].sort((a, b) => (a.month || 0) - (b.month || 0)).slice(0, 9).map((m) => ({ month: m.month ?? 0, lines: wrapText(m.label || "", PANEL.R.w - 92, FS) }));
|
|
2397
|
+
const narrLines = assumptions.narrative ? wrapText(assumptions.narrative, W - 96, 12.5) : [];
|
|
2398
|
+
const cardsY = headerH + 26, cardH = 84, cardGap = 18;
|
|
2399
|
+
const cardW = (W - M * 2 - cardGap * 3) / 4;
|
|
2400
|
+
const panelTop = cardsY + cardH + 34;
|
|
2401
|
+
const listTop = panelTop + 60;
|
|
2402
|
+
let ly = listTop;
|
|
2403
|
+
partyLines.forEach((lines) => {
|
|
2404
|
+
ly += lines.length * LH + 10;
|
|
2405
|
+
});
|
|
2406
|
+
let ry = listTop;
|
|
2407
|
+
msLines.forEach((m) => {
|
|
2408
|
+
ry += m.lines.length * LH + 10;
|
|
2409
|
+
});
|
|
2410
|
+
const colBottom = Math.max(ly, ry, listTop + 26);
|
|
2411
|
+
const panelBottom = colBottom + 6;
|
|
2412
|
+
const narrTop = panelBottom + 30;
|
|
2413
|
+
const narrPanelH = narrLines.length * 17 + 36;
|
|
2414
|
+
const footerTop = narrLines.length ? narrTop + narrPanelH + 14 : panelBottom + 24;
|
|
2415
|
+
const H = footerTop + footerH;
|
|
2416
|
+
const tab = (label, x, y2) => {
|
|
2417
|
+
const w = Math.round(label.length * 8.6) + 28, h = 32;
|
|
2418
|
+
return `<rect x="${x}" y="${y2}" width="${w}" height="${h}" rx="8" fill="${accent}"/><text class="${disp}" x="${x + w / 2}" y="${y2 + 21}" text-anchor="middle" font-size="13" font-weight="700" letter-spacing="1.5" fill="#ffffff">${esc2(label)}</text>`;
|
|
2419
|
+
};
|
|
2420
|
+
let body = `<rect width="${W}" height="${H}" fill="${surface}"/>`;
|
|
2421
|
+
metrics.forEach((m, i) => {
|
|
2422
|
+
const x = M + i * (cardW + cardGap);
|
|
2423
|
+
body += `<rect x="${x}" y="${cardsY}" width="${cardW}" height="${cardH}" rx="14" fill="${accent}" opacity="0.08"/>`;
|
|
2424
|
+
body += `<rect x="${x}" y="${cardsY}" width="${cardW}" height="${cardH}" rx="14" fill="none" stroke="${accent}" stroke-opacity="0.2"/>`;
|
|
2425
|
+
body += `<text class="s" x="${x + 18}" y="${cardsY + 30}" font-size="11.5" fill="#6b7280">${esc2(m[0])}</text>`;
|
|
2426
|
+
body += `<text class="${disp}" x="${x + 18}" y="${cardsY + 62}" font-size="22" font-weight="700" fill="${accent}">${esc2(m[1])}</text>`;
|
|
2427
|
+
});
|
|
2428
|
+
body += `<rect x="${PANEL.L.x}" y="${panelTop}" width="${PANEL.L.w}" height="${panelBottom - panelTop}" rx="18" fill="${accent}" opacity="0.05"/>`;
|
|
2429
|
+
body += `<rect x="${PANEL.R.x}" y="${panelTop}" width="${PANEL.R.w}" height="${panelBottom - panelTop}" rx="18" fill="${accent}" opacity="0.05"/>`;
|
|
2430
|
+
body += `<line x1="${spineX}" y1="${panelTop}" x2="${spineX}" y2="${panelBottom}" stroke="#cfd5de" stroke-width="2"/>`;
|
|
2431
|
+
body += tab("PARTIES", PANEL.L.x + 22, panelTop + 16);
|
|
2432
|
+
body += tab("MILESTONES", PANEL.R.x + 22, panelTop + 16);
|
|
2433
|
+
let y = listTop;
|
|
2434
|
+
if (!partyLines.length) body += `<text class="s" x="${PANEL.L.x + 24}" y="${y}" font-size="13" fill="#9ca3af">(no parties)</text>`;
|
|
2435
|
+
partyLines.forEach((lines) => {
|
|
2436
|
+
body += `<circle cx="${PANEL.L.x + 26}" cy="${y - 4}" r="3" fill="${accent}"/>`;
|
|
2437
|
+
body += `<text class="s" x="${PANEL.L.x + 40}" y="${y}" font-size="13.5" fill="#374151">` + lines.map((ln, i) => `<tspan x="${PANEL.L.x + 40}"${i ? ` dy="${LH}"` : ""}>${esc2(ln)}</tspan>`).join("") + `</text>`;
|
|
2438
|
+
y += lines.length * LH + 10;
|
|
2439
|
+
});
|
|
2440
|
+
let my = listTop;
|
|
2441
|
+
if (!msLines.length) body += `<text class="s" x="${PANEL.R.x + 24}" y="${my}" font-size="13" fill="#9ca3af">(no milestones)</text>`;
|
|
2442
|
+
msLines.forEach((m) => {
|
|
2443
|
+
body += `<text class="m" x="${PANEL.R.x + 24}" y="${my}" font-size="11" font-weight="500" fill="${accent}">M${esc2(m.month)}</text>`;
|
|
2444
|
+
body += `<text class="s" x="${PANEL.R.x + 60}" y="${my}" font-size="13.5" fill="#374151">` + m.lines.map((ln, i) => `<tspan x="${PANEL.R.x + 60}"${i ? ` dy="${LH}"` : ""}>${esc2(ln)}</tspan>`).join("") + `</text>`;
|
|
2445
|
+
my += m.lines.length * LH + 10;
|
|
2446
|
+
});
|
|
2447
|
+
if (narrLines.length) {
|
|
2448
|
+
body += `<rect x="${M}" y="${narrTop}" width="${W - M * 2}" height="${narrPanelH}" rx="14" fill="${accent}" opacity="0.05"/>`;
|
|
2449
|
+
body += `<text class="m" x="${M + 18}" y="${narrTop + 22}" font-size="10.5" letter-spacing="1.5" fill="#9ca3af">NOTES</text>`;
|
|
2450
|
+
body += `<text class="s" x="${M + 18}" y="${narrTop + 42}" font-size="12.5" fill="#6b7280">` + narrLines.map((ln, i) => `<tspan x="${M + 18}"${i ? ` dy="17"` : ""}>${esc2(ln)}</tspan>`).join("") + `</text>`;
|
|
2451
|
+
}
|
|
2452
|
+
body += `<rect x="0" y="0" width="${W}" height="${headerH}" fill="${ink}"/>`;
|
|
2453
|
+
body += `<text class="m" x="40" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5">THE THOUGHT LAYER</text>`;
|
|
2454
|
+
body += `<text class="m" x="${W - 40}" y="38" font-size="11" letter-spacing="3" fill="#ffffff" fill-opacity="0.5" text-anchor="end">FINANCIALS</text>`;
|
|
2455
|
+
body += `<text class="${disp}" x="${spineX}" y="74" font-size="44" font-weight="700" letter-spacing="4" fill="#ffffff" text-anchor="middle">BUSINESS MODEL</text>`;
|
|
2456
|
+
body += `<rect x="0" y="${footerTop}" width="${W}" height="${footerH}" fill="${ink}"/>`;
|
|
2457
|
+
body += `<text class="m" x="${spineX}" y="${footerTop + 26}" font-size="11" fill="#ffffff" fill-opacity="0.55" text-anchor="middle">The Thought Layer, figures are your assumptions, computed locally</text>`;
|
|
2458
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${fontStyle}${body}</svg>`;
|
|
2459
|
+
}
|
|
2460
|
+
function brandLookbookHtml(guide, logoSvg) {
|
|
2461
|
+
if (!guide) return "";
|
|
2462
|
+
const role = (re, fb) => (guide.palette || []).find((p) => p?.name && new RegExp(re, "i").test(p.name))?.hex || fb;
|
|
2463
|
+
const primary = role("primary", guide.palette?.[0]?.hex || "#1a1a2e");
|
|
2464
|
+
const accent = role("accent|secondary", guide.palette?.[1]?.hex || "#e94f37");
|
|
2465
|
+
const ink = role("ink|text|dark|black", "#14141b");
|
|
2466
|
+
const surface = role("surface|background|light|paper|off.?white|cream", "#fbfaf7");
|
|
2467
|
+
const muted = role("muted|gray|grey|neutral|border", "#8a8a99");
|
|
2468
|
+
const disp = guide.typography?.display?.family || "Georgia";
|
|
2469
|
+
const body = guide.typography?.body?.family || "system-ui";
|
|
2470
|
+
const fontsUrl = `https://fonts.googleapis.com/css2?family=${fam2(disp)}:wght@400;600;700&family=${fam2(body)}:wght@400;500;600&display=swap`;
|
|
2471
|
+
const name = esc2(guide.brandName || "Your Brand");
|
|
2472
|
+
const tagline = esc2(guide.tagline || "");
|
|
2473
|
+
const messaging = guide.messaging || [];
|
|
2474
|
+
const hero = esc2(messaging[0] || guide.positioning || guide.brandName || "");
|
|
2475
|
+
const lockup = logoSvg || `<span class="wordmark">${name}</span>`;
|
|
2476
|
+
const initials = (guide.brandName || "B").split(/\s+/).map((w) => w[0] || "").join("").slice(0, 2).toUpperCase();
|
|
2477
|
+
const swatches = (guide.palette || []).map((p) => `<div class="sw"><div class="chip" style="background:${esc2(p.hex)}"></div><div class="swmeta"><strong>${esc2(p.name)}</strong><code>${esc2(p.hex)}</code><span>${esc2(p.role)}</span></div></div>`).join("");
|
|
2478
|
+
const traits = (guide.personality || []).map((tr) => `<span class="pill">${esc2(tr)}</span>`).join("");
|
|
2479
|
+
const dos = (guide.voice?.dos || []).map((d) => `<li>${esc2(d)}</li>`).join("");
|
|
2480
|
+
const donts = (guide.voice?.donts || []).map((d) => `<li>${esc2(d)}</li>`).join("");
|
|
2481
|
+
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2482
|
+
<title>${name} Brand Look Book</title><link href="${fontsUrl}" rel="stylesheet"><style>
|
|
2483
|
+
:root{--p:${primary};--a:${accent};--ink:${ink};--su:${surface};--mu:${muted};--disp:'${disp}',Georgia,serif;--body:'${body}',system-ui,sans-serif}
|
|
2484
|
+
*{margin:0;padding:0;box-sizing:border-box}body{background:var(--su);color:var(--ink);font-family:var(--body);line-height:1.6}
|
|
2485
|
+
.wrap{max-width:960px;margin:0 auto;padding:0 28px}
|
|
2486
|
+
section{padding:60px 0;border-bottom:1px solid color-mix(in srgb,var(--mu) 30%,transparent)}
|
|
2487
|
+
.eyebrow{font-size:12px;letter-spacing:2px;text-transform:uppercase;color:var(--mu);font-weight:600;margin-bottom:16px}
|
|
2488
|
+
h2{font-family:var(--disp);font-size:30px;font-weight:700}
|
|
2489
|
+
.wordmark{font-family:var(--disp);font-size:44px;font-weight:700;color:var(--su)}
|
|
2490
|
+
.cover{background:var(--p);color:var(--su);padding:84px 0}
|
|
2491
|
+
.cover .lockup{display:inline-block;margin-bottom:26px}.cover .lockup svg{height:64px;width:auto}
|
|
2492
|
+
.cover h1{font-family:var(--disp);font-size:clamp(32px,6vw,58px);font-weight:700;line-height:1.08;margin:12px 0}
|
|
2493
|
+
.cover p{font-size:18px;opacity:.85;max-width:640px}
|
|
2494
|
+
.pill{display:inline-block;background:color-mix(in srgb,var(--a) 16%,transparent);color:#fff;border:1px solid color-mix(in srgb,#fff 30%,transparent);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;margin:0 8px 8px 0}
|
|
2495
|
+
.swatches{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px}
|
|
2496
|
+
.sw{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:12px;overflow:hidden;background:#fff}
|
|
2497
|
+
.chip{height:80px}.swmeta{padding:12px 14px;font-size:13px;display:flex;flex-direction:column;gap:2px}
|
|
2498
|
+
.swmeta code,.swmeta span{color:var(--mu);font-size:12px}
|
|
2499
|
+
.big{font-family:var(--disp);font-size:62px;font-weight:700;line-height:1.05}
|
|
2500
|
+
.row{font-family:var(--disp);font-size:24px;margin-top:6px;color:var(--a)}
|
|
2501
|
+
.bodyspec{font-size:16px;max-width:640px;margin-top:16px}
|
|
2502
|
+
.voice{display:grid;grid-template-columns:1fr 1fr;gap:22px;margin-top:8px}
|
|
2503
|
+
.col{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:12px;padding:18px 20px;background:#fff}
|
|
2504
|
+
.col h3{font-size:13px;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px}
|
|
2505
|
+
.col ul{list-style:none}.col li{padding:5px 0 5px 22px;position:relative;font-size:14px}
|
|
2506
|
+
.col li:before{position:absolute;left:0}.do li:before{content:"+";color:var(--a);font-weight:700}.dont li:before{content:"x";color:var(--mu)}
|
|
2507
|
+
.apps{display:grid;grid-template-columns:1fr 1fr;gap:20px}
|
|
2508
|
+
.mock{border:1px solid color-mix(in srgb,var(--mu) 28%,transparent);border-radius:14px;overflow:hidden;background:#fff}
|
|
2509
|
+
.cap{font-size:12px;color:var(--mu);padding:8px 14px;border-top:1px solid color-mix(in srgb,var(--mu) 22%,transparent)}
|
|
2510
|
+
.appbar{display:flex;align-items:center;justify-content:space-between;padding:13px 18px;background:var(--su);border-bottom:1px solid color-mix(in srgb,var(--mu) 22%,transparent)}
|
|
2511
|
+
.appbar .lk svg{height:24px}.appbar .lk .wordmark{font-size:18px;color:var(--ink)}
|
|
2512
|
+
.appbar nav{font-size:12px;color:var(--mu);display:flex;gap:14px}
|
|
2513
|
+
.appbody{height:118px;background:repeating-linear-gradient(0deg,transparent,transparent 22px,color-mix(in srgb,var(--mu) 9%,transparent) 23px)}
|
|
2514
|
+
.card{aspect-ratio:1.75;background:var(--p);color:var(--su);padding:22px;display:flex;flex-direction:column;justify-content:space-between}
|
|
2515
|
+
.card .lk svg{height:26px}.card .nm{font-family:var(--disp);font-size:22px;font-weight:700}.card .tg{font-size:12px;opacity:.8}
|
|
2516
|
+
.slide{padding:26px;background:var(--su)}.slide .kicker{font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:var(--a);font-weight:600}
|
|
2517
|
+
.slide h4{font-family:var(--disp);font-size:25px;font-weight:700;margin-top:6px;line-height:1.15}.slide .bar{width:46px;height:4px;background:var(--a);margin-top:14px;border-radius:2px}
|
|
2518
|
+
.avatar{height:158px;display:flex;align-items:center;justify-content:center}.circle{width:84px;height:84px;border-radius:50%;background:var(--a);color:#fff;display:flex;align-items:center;justify-content:center;font-family:var(--disp);font-weight:700;font-size:30px}
|
|
2519
|
+
.foot{padding:36px 0;color:var(--mu);font-size:12px}
|
|
2520
|
+
@media(max-width:680px){.voice,.apps{grid-template-columns:1fr}}
|
|
2521
|
+
</style></head><body>
|
|
2522
|
+
<section class="cover"><div class="wrap">
|
|
2523
|
+
<div class="lockup">${lockup}</div>
|
|
2524
|
+
<div class="eyebrow" style="color:rgba(255,255,255,.7)">Brand Look Book</div>
|
|
2525
|
+
<h1>${hero}</h1>${tagline ? `<p>${tagline}</p>` : ""}
|
|
2526
|
+
<div style="margin-top:20px">${traits}</div>
|
|
2527
|
+
</div></section>
|
|
2528
|
+
<section><div class="wrap"><div class="eyebrow">Positioning</div><h2>${name}</h2>
|
|
2529
|
+
<p style="font-size:18px;max-width:680px;margin-top:8px">${esc2(guide.positioning || "")}</p></div></section>
|
|
2530
|
+
<section><div class="wrap"><div class="eyebrow">Color</div><h2>Palette</h2><div class="swatches" style="margin-top:20px">${swatches}</div></div></section>
|
|
2531
|
+
<section><div class="wrap"><div class="eyebrow">Type</div><h2>Typography</h2>
|
|
2532
|
+
<div class="big" style="margin-top:18px">${name}</div><div class="row">${esc2(disp)} for display</div>
|
|
2533
|
+
<p class="bodyspec">Set in ${esc2(body)} for body and UI. ${esc2(messaging[1] || "The quick brown fox jumps over the lazy dog 0123456789.")}</p></div></section>
|
|
2534
|
+
<section><div class="wrap"><div class="eyebrow">Voice</div><h2>How the brand sounds</h2>
|
|
2535
|
+
<p style="max-width:680px;margin-top:8px">${esc2(guide.voice?.tone || "")}</p>
|
|
2536
|
+
<div class="voice"><div class="col do"><h3>Do</h3><ul>${dos || "<li>(none)</li>"}</ul></div><div class="col dont"><h3>Don't</h3><ul>${donts || "<li>(none)</li>"}</ul></div></div></div></section>
|
|
2537
|
+
<section><div class="wrap"><div class="eyebrow">In the wild</div><h2>The identity applied</h2>
|
|
2538
|
+
<div class="apps" style="margin-top:20px">
|
|
2539
|
+
<div class="mock"><div class="appbar"><span class="lk">${lockup}</span><nav><span>Home</span><span>Pricing</span><span>Sign in</span></nav></div><div class="appbody"></div><div class="cap">App and website header</div></div>
|
|
2540
|
+
<div class="mock"><div class="card"><span class="lk" style="filter:brightness(0) invert(1)">${lockup}</span><div><div class="nm">${name}</div><div class="tg">${tagline}</div></div></div><div class="cap">Business card</div></div>
|
|
2541
|
+
<div class="mock"><div class="slide"><div class="kicker">${esc2((guide.personality || [])[0] || "Brand")}</div><h4>${hero}</h4><div class="bar"></div></div><div class="cap">Slide title</div></div>
|
|
2542
|
+
<div class="mock"><div class="avatar"><div class="circle">${esc2(initials)}</div></div><div class="cap">Social avatar</div></div>
|
|
2543
|
+
</div></div></section>
|
|
2544
|
+
<div class="wrap foot">${name} brand look book, generated locally by The Thought Layer. Type: ${esc2(disp)} / ${esc2(body)}.</div>
|
|
2545
|
+
</body></html>`;
|
|
2546
|
+
}
|
|
2547
|
+
var kindOf = (path) => path.endsWith(".md") ? "markdown" : path.endsWith(".svg") ? "svg" : path.endsWith(".html") ? "html" : path.endsWith(".json") ? "json" : "text";
|
|
2548
|
+
function buildArtifactSet(state, opts) {
|
|
2549
|
+
const files = {};
|
|
2550
|
+
const sources = {};
|
|
2551
|
+
const add = (path, content, source) => {
|
|
2552
|
+
if (content && content.trim()) {
|
|
2553
|
+
files[path] = content;
|
|
2554
|
+
sources[path] = source;
|
|
2555
|
+
}
|
|
2556
|
+
};
|
|
2557
|
+
const brand = state.brand && typeof state.brand === "object" ? state.brand : null;
|
|
2558
|
+
const grill = state.grill && typeof state.grill === "object" ? state.grill : null;
|
|
2559
|
+
const swot = state.swot && typeof state.swot === "object" ? state.swot : null;
|
|
2560
|
+
const prd = obj(state.prd);
|
|
2561
|
+
const bizModel = obj(state.bizModel);
|
|
2562
|
+
const research = obj(state.research);
|
|
2563
|
+
const assumptions = bizModel["assumptions"] || null;
|
|
2564
|
+
const prdMarkdown = str2(prd["markdown"]);
|
|
2565
|
+
add("PRD.md", prdMarkdown, "prd");
|
|
2566
|
+
if (grill?.requirements?.length) add("Requirements.md", requirementsMarkdown(grill), "grill");
|
|
2567
|
+
if (grill?.glossary?.length) add("DomainGlossary.md", glossaryMarkdown(grill), "grill");
|
|
2568
|
+
add("BuildPrompt.md", buildKitPrompt(grill, prdMarkdown, assumptions, brand, state.feedback), "prd+grill+bizModel+brand");
|
|
2569
|
+
const swotHasItems = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
2570
|
+
if (swotHasItems) {
|
|
2571
|
+
add("SWOT.md", swotMarkdown(swot), "swot");
|
|
2572
|
+
add("SWOT.svg", swotInfographicSvg(swot, brand), "swot");
|
|
2573
|
+
}
|
|
2574
|
+
const bizSvg = bizModelInfographicSvg(assumptions, brand);
|
|
2575
|
+
if (bizSvg) add("BusinessModel.svg", bizSvg, "bizModel");
|
|
2576
|
+
if (research["brief"]) {
|
|
2577
|
+
add("MarketResearch.md", `# Market Research
|
|
2578
|
+
|
|
2579
|
+
_${str2(research["description"])}_
|
|
2580
|
+
|
|
2581
|
+
${str2(research["brief"])}`, "research");
|
|
2582
|
+
}
|
|
2583
|
+
const governance = obj(state.governance);
|
|
2584
|
+
if (str2(governance["report"]).trim()) {
|
|
2585
|
+
add("Compliance.md", str2(governance["report"]), "governance");
|
|
2586
|
+
}
|
|
2587
|
+
if (brand?.guide) {
|
|
2588
|
+
add("Brand/BrandStyleGuide.md", brandGuideMarkdown(brand.guide), "brand");
|
|
2589
|
+
const chosen = (brand.logos || []).find((l) => l.id === brand.chosenLogoId) || (brand.logos || [])[0];
|
|
2590
|
+
if (chosen?.svg) add("Brand/Logo.svg", chosen.svg, "brand");
|
|
2591
|
+
add("Brand/LookBook.html", brandLookbookHtml(brand.guide, chosen?.svg), "brand");
|
|
2592
|
+
}
|
|
2593
|
+
try {
|
|
2594
|
+
const spec = extractScaffoldSpec(state);
|
|
2595
|
+
const site = buildStarterSite(spec, { domain: opts.domain, founderName: opts.founderName });
|
|
2596
|
+
for (const [name, content] of Object.entries(site.files)) add(`LandingPage/${name}`, content, "scaffold");
|
|
2597
|
+
} catch {
|
|
2598
|
+
}
|
|
2599
|
+
const brandName = str2(obj(brand?.guide)["brandName"]) || "Your Product";
|
|
2600
|
+
add("README.md", readmeIndex(files, brandName), "index");
|
|
2601
|
+
const manifest = {
|
|
2602
|
+
app: "thought-layer",
|
|
2603
|
+
kind: "artifacts",
|
|
2604
|
+
version: 1,
|
|
2605
|
+
generatedAt: opts.generatedAt,
|
|
2606
|
+
brandName,
|
|
2607
|
+
files: Object.keys(files).sort().map((path) => ({
|
|
2608
|
+
path,
|
|
2609
|
+
bytes: Buffer.byteLength(files[path], "utf8"),
|
|
2610
|
+
kind: kindOf(path),
|
|
2611
|
+
source: sources[path] || "index"
|
|
2612
|
+
}))
|
|
2613
|
+
};
|
|
2614
|
+
return { files, manifest };
|
|
2615
|
+
}
|
|
2616
|
+
function readmeIndex(files, brandName) {
|
|
2617
|
+
const has = (p) => p in files;
|
|
2618
|
+
const lines = [];
|
|
2619
|
+
const row = (p, desc) => {
|
|
2620
|
+
if (has(p)) lines.push(`- **${p}**: ${desc}`);
|
|
2621
|
+
};
|
|
2622
|
+
row("PRD.md", "Complete product requirements document");
|
|
2623
|
+
row("Requirements.md", "Numbered, testable requirements by category");
|
|
2624
|
+
row("DomainGlossary.md", "Ubiquitous language for the domain");
|
|
2625
|
+
row("BuildPrompt.md", "Paste into an AI coding agent (Claude Code, Cursor) to build version 1");
|
|
2626
|
+
row("BusinessModel.svg", "The numbers as a one-page infographic");
|
|
2627
|
+
row("SWOT.md", "Strengths, weaknesses, opportunities, threats");
|
|
2628
|
+
row("SWOT.svg", "The SWOT as a poster infographic");
|
|
2629
|
+
row("MarketResearch.md", "The market research brief");
|
|
2630
|
+
row("Compliance.md", "Governance, licensing, and tax requirements to review with your legal and tax advisors");
|
|
2631
|
+
row("Brand/BrandStyleGuide.md", "Brand voice, palette, and typography");
|
|
2632
|
+
row("Brand/Logo.svg", "The chosen logo (vector, editable)");
|
|
2633
|
+
row("Brand/LookBook.html", "The identity applied; open in any browser");
|
|
2634
|
+
row("LandingPage/index.html", "A deployable landing page; drag onto app.netlify.com/drop");
|
|
2635
|
+
return `# ${brandName}: Thought Layer artifacts
|
|
2636
|
+
|
|
2637
|
+
Everything The Thought Layer built for this idea, delivered to your own repo.
|
|
2638
|
+
|
|
2639
|
+
${lines.join("\n")}
|
|
2640
|
+
`;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// core/artifacts-io.ts
|
|
2644
|
+
var ok2 = (message, details = {}) => ({ ok: true, message, details });
|
|
2645
|
+
var fail2 = (message, details = {}) => ({ ok: false, message, details });
|
|
2646
|
+
var STATE_DIR_ARTIFACTS = ["build.json", "deploy.json", "TRACEABILITY.md", "DECISIONS.md"];
|
|
2647
|
+
var ROOT_ARTIFACTS = ["BACKEND.md", "schema.sql", "netlify.toml", ".env.example"];
|
|
2648
|
+
var kindOf2 = (path) => path.endsWith(".md") ? "markdown" : path.endsWith(".svg") ? "svg" : path.endsWith(".html") ? "html" : path.endsWith(".json") ? "json" : "text";
|
|
2649
|
+
function repoOwnerName(repo) {
|
|
2650
|
+
const m = String(repo || "").trim().match(/(?:github\.com[:/])?([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
|
|
2651
|
+
if (!m) return null;
|
|
2652
|
+
if (m[1] === "." || m[1] === "..") return null;
|
|
2653
|
+
return `${m[1]}/${m[2]}`;
|
|
2654
|
+
}
|
|
2655
|
+
function runArtifacts(opts, ctx) {
|
|
2656
|
+
try {
|
|
2657
|
+
const cfg = loadConfig();
|
|
2658
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
2659
|
+
if (!isGitRepo(cloneDir)) {
|
|
2660
|
+
return fail2(`No sessions workspace at ${cloneDir}. Run tl sync init --repo <owner/name> first, then save a session.`, { cloneDir });
|
|
2661
|
+
}
|
|
2662
|
+
const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
|
|
2663
|
+
if (!slug) return fail2("Name the session whose artifacts to deliver: tl artifacts --name <name>.");
|
|
2664
|
+
const sessionPath = join6(cloneDir, STATE_DIR, `${slug}.json`);
|
|
2665
|
+
const useExplicit = !!(opts.path && opts.path.trim() || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
|
|
2666
|
+
const loadTarget = useExplicit ? opts.path : existsSync5(sessionPath) ? sessionPath : opts.path;
|
|
2667
|
+
const loaded = loadStateFile(loadTarget);
|
|
2668
|
+
if (!loaded.exists) {
|
|
2669
|
+
return fail2(`No session "${slug}" found (looked at ${loaded.path}). Save it first with tl sync save --name ${slug}.`, { cloneDir });
|
|
2670
|
+
}
|
|
2671
|
+
const { files, manifest } = buildArtifactSet(loaded.state, {
|
|
2672
|
+
generatedAt: ctx.generatedAt,
|
|
2673
|
+
domain: opts.domain,
|
|
2674
|
+
founderName: opts.founderName
|
|
2675
|
+
});
|
|
2676
|
+
const srcStateDir = dirname5(loaded.path);
|
|
2677
|
+
const srcRoot = basename(srcStateDir) === STATE_DIR ? dirname5(srcStateDir) : srcStateDir;
|
|
2678
|
+
const extra = [];
|
|
2679
|
+
const copyIfPresent = (fromDir, fname) => {
|
|
2680
|
+
const from = join6(fromDir, fname);
|
|
2681
|
+
if (!existsSync5(from)) return;
|
|
2682
|
+
try {
|
|
2683
|
+
const content = readFileSync4(from, "utf8");
|
|
2684
|
+
const rel = `Deploy/${fname}`;
|
|
2685
|
+
files[rel] = content;
|
|
2686
|
+
extra.push({ path: rel, bytes: Buffer.byteLength(content, "utf8"), kind: kindOf2(rel), source: "build/deploy" });
|
|
2687
|
+
} catch {
|
|
2688
|
+
}
|
|
2689
|
+
};
|
|
2690
|
+
STATE_DIR_ARTIFACTS.forEach((f) => copyIfPresent(srcStateDir, f));
|
|
2691
|
+
ROOT_ARTIFACTS.forEach((f) => copyIfPresent(srcRoot, f));
|
|
2692
|
+
manifest.files = [...manifest.files, ...extra].sort((a, b) => a.path.localeCompare(b.path));
|
|
2693
|
+
const baseRel = join6("artifacts", slug);
|
|
2694
|
+
const baseAbs = join6(cloneDir, baseRel);
|
|
2695
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
2696
|
+
const dest = join6(baseAbs, rel);
|
|
2697
|
+
mkdirSync5(dirname5(dest), { recursive: true });
|
|
2698
|
+
writeFileSync6(dest, content);
|
|
2699
|
+
}
|
|
2700
|
+
writeFileSync6(join6(baseAbs, "artifacts.json"), JSON.stringify(manifest, null, 2) + "\n");
|
|
2701
|
+
const fileCount = Object.keys(files).length + 1;
|
|
2702
|
+
if (opts.noDeliver) {
|
|
2703
|
+
return ok2(
|
|
2704
|
+
`Generated ${fileCount} artifact file(s) into ${baseAbs} (local only; not committed).`,
|
|
2705
|
+
{ cloneDir, dir: baseAbs, session: slug, files: Object.keys(files), count: fileCount, committed: false, pushed: false }
|
|
2706
|
+
);
|
|
2707
|
+
}
|
|
2708
|
+
git(cloneDir, ["add", "-f", "--", baseRel]);
|
|
2709
|
+
const msg = opts.message || `Deliver artifacts for ${slug}`;
|
|
2710
|
+
const committed = git(cloneDir, ["commit", "-m", msg]).status === 0;
|
|
2711
|
+
let pushed = false;
|
|
2712
|
+
let pushNote = "";
|
|
2713
|
+
if (committed && !opts.noPush) {
|
|
2714
|
+
const p = git(cloneDir, ["push"]);
|
|
2715
|
+
pushed = p.status === 0;
|
|
2716
|
+
if (!pushed) pushNote = ` Could not push (${(p.err || "").split("\n")[0] || "see git output"}); commit is local, run tl sync push when ready.`;
|
|
2717
|
+
}
|
|
2718
|
+
const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || ws?.defaultBranch || "main";
|
|
2719
|
+
const ownerName = repoOwnerName(ws?.repo || "");
|
|
2720
|
+
const githubBase = ownerName ? `https://github.com/${ownerName}/blob/${branch}/${baseRel}` : null;
|
|
2721
|
+
const urls = {};
|
|
2722
|
+
if (githubBase) for (const rel of Object.keys(files)) urls[rel] = `${githubBase}/${rel.split("/").map(encodeURIComponent).join("/")}`;
|
|
2723
|
+
return ok2(
|
|
2724
|
+
`Delivered ${fileCount} artifact file(s) for "${slug}" to ${baseRel} in ${cloneDir}.${committed ? opts.noPush ? " Committed locally (no push)." : pushed ? " Committed and pushed." : pushNote : " Nothing changed since the last delivery."}${githubBase ? `
|
|
2725
|
+
View on GitHub: ${githubBase}` : ""}`,
|
|
2726
|
+
{ cloneDir, dir: baseRel, session: slug, files: Object.keys(files), count: fileCount, committed, pushed, branch, repo: ownerName, githubBase, urls }
|
|
2727
|
+
);
|
|
2728
|
+
} catch (e) {
|
|
2729
|
+
return fail2(`tl_artifacts error: ${e.message}`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// core/notion-io.ts
|
|
2734
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync5, writeFileSync as writeFileSync7 } from "fs";
|
|
2735
|
+
import { homedir as homedir2 } from "os";
|
|
2736
|
+
import { dirname as dirname6, join as join7 } from "path";
|
|
2737
|
+
|
|
2738
|
+
// core/notion.ts
|
|
2739
|
+
var NOTION_FREE_FILE_LIMIT = 5 * 1024 * 1024;
|
|
2740
|
+
var RICH_TEXT_MAX = 2e3;
|
|
2741
|
+
var CHILDREN_MAX = 100;
|
|
2742
|
+
var obj2 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
2743
|
+
var str3 = (v) => typeof v === "string" ? v : "";
|
|
2744
|
+
var rtSeg = (content, ann, link) => ({
|
|
2745
|
+
type: "text",
|
|
2746
|
+
text: { content, ...link ? { link: { url: link } } : {} },
|
|
2747
|
+
...ann ? { annotations: ann } : {}
|
|
2748
|
+
});
|
|
2749
|
+
function chunkRichText(text, ann, link) {
|
|
2750
|
+
const s = String(text ?? "");
|
|
2751
|
+
if (s.length <= RICH_TEXT_MAX) return [rtSeg(s, ann, link)];
|
|
2752
|
+
const out = [];
|
|
2753
|
+
for (let i = 0; i < s.length; i += RICH_TEXT_MAX) out.push(rtSeg(s.slice(i, i + RICH_TEXT_MAX), ann, link));
|
|
2754
|
+
return out;
|
|
2755
|
+
}
|
|
2756
|
+
function inline(text) {
|
|
2757
|
+
const out = [];
|
|
2758
|
+
const re = /\*\*([^*]+)\*\*|`([^`]+)`/g;
|
|
2759
|
+
let last = 0;
|
|
2760
|
+
let m;
|
|
2761
|
+
while (m = re.exec(text)) {
|
|
2762
|
+
if (m.index > last) out.push(...chunkRichText(text.slice(last, m.index)));
|
|
2763
|
+
if (m[1] != null) out.push(...chunkRichText(m[1], { bold: true }));
|
|
2764
|
+
else if (m[2] != null) out.push(...chunkRichText(m[2], { code: true }));
|
|
2765
|
+
last = re.lastIndex;
|
|
2766
|
+
}
|
|
2767
|
+
if (last < text.length) out.push(...chunkRichText(text.slice(last)));
|
|
2768
|
+
return out.length ? out : [rtSeg("")];
|
|
2769
|
+
}
|
|
2770
|
+
var para = (text) => ({ object: "block", type: "paragraph", paragraph: { rich_text: inline(text) } });
|
|
2771
|
+
var heading = (level, text) => {
|
|
2772
|
+
const t = `heading_${level}`;
|
|
2773
|
+
return { object: "block", type: t, [t]: { rich_text: inline(text) } };
|
|
2774
|
+
};
|
|
2775
|
+
var bullet = (text) => ({ object: "block", type: "bulleted_list_item", bulleted_list_item: { rich_text: inline(text) } });
|
|
2776
|
+
var numbered = (text) => ({ object: "block", type: "numbered_list_item", numbered_list_item: { rich_text: inline(text) } });
|
|
2777
|
+
var quote = (text) => ({ object: "block", type: "quote", quote: { rich_text: inline(text) } });
|
|
2778
|
+
var callout = (text, emoji = "\u{1F4A1}") => ({ object: "block", type: "callout", callout: { rich_text: inline(text), icon: { type: "emoji", emoji } } });
|
|
2779
|
+
var divider = () => ({ object: "block", type: "divider", divider: {} });
|
|
2780
|
+
var codeBlock = (text, language = "plain text") => ({ object: "block", type: "code", code: { rich_text: chunkRichText(text), language } });
|
|
2781
|
+
function table(headers, rows) {
|
|
2782
|
+
const toRow = (cells) => ({ object: "block", type: "table_row", table_row: { cells: cells.map((c) => chunkRichText(c)) } });
|
|
2783
|
+
return {
|
|
2784
|
+
object: "block",
|
|
2785
|
+
type: "table",
|
|
2786
|
+
table: { table_width: headers.length, has_column_header: true, has_row_header: false, children: [toRow(headers), ...rows.map(toRow)] }
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
function markdownToBlocks(md) {
|
|
2790
|
+
const lines = String(md ?? "").split("\n");
|
|
2791
|
+
const out = [];
|
|
2792
|
+
let i = 0;
|
|
2793
|
+
while (i < lines.length) {
|
|
2794
|
+
const raw = lines[i] ?? "";
|
|
2795
|
+
const t = raw.trim();
|
|
2796
|
+
if (t.startsWith("```")) {
|
|
2797
|
+
const buf = [];
|
|
2798
|
+
i++;
|
|
2799
|
+
while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) {
|
|
2800
|
+
buf.push(lines[i] ?? "");
|
|
2801
|
+
i++;
|
|
2802
|
+
}
|
|
2803
|
+
i++;
|
|
2804
|
+
out.push(codeBlock(buf.join("\n")));
|
|
2805
|
+
continue;
|
|
2806
|
+
}
|
|
2807
|
+
if (!t) {
|
|
2808
|
+
i++;
|
|
2809
|
+
continue;
|
|
2810
|
+
}
|
|
2811
|
+
if (t === "---" || t === "***" || t === "___") {
|
|
2812
|
+
out.push(divider());
|
|
2813
|
+
i++;
|
|
2814
|
+
continue;
|
|
2815
|
+
}
|
|
2816
|
+
const mh = t.match(/^(#{1,6})\s+(.*)$/);
|
|
2817
|
+
if (mh) {
|
|
2818
|
+
out.push(heading(Math.min(3, mh[1].length), mh[2]));
|
|
2819
|
+
i++;
|
|
2820
|
+
continue;
|
|
2821
|
+
}
|
|
2822
|
+
if (t.startsWith("> ")) {
|
|
2823
|
+
out.push(quote(t.slice(2)));
|
|
2824
|
+
i++;
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
const mb = t.match(/^[-*]\s+(.*)$/);
|
|
2828
|
+
if (mb) {
|
|
2829
|
+
out.push(bullet(mb[1]));
|
|
2830
|
+
i++;
|
|
2831
|
+
continue;
|
|
2832
|
+
}
|
|
2833
|
+
const mn = t.match(/^\d+\.\s+(.*)$/);
|
|
2834
|
+
if (mn) {
|
|
2835
|
+
out.push(numbered(mn[1]));
|
|
2836
|
+
i++;
|
|
2837
|
+
continue;
|
|
2838
|
+
}
|
|
2839
|
+
out.push(para(t));
|
|
2840
|
+
i++;
|
|
2841
|
+
}
|
|
2842
|
+
return out;
|
|
2843
|
+
}
|
|
2844
|
+
function chunkChildren(blocks, size = CHILDREN_MAX) {
|
|
2845
|
+
const out = [];
|
|
2846
|
+
for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
|
|
2847
|
+
return out.length ? out : [[]];
|
|
2848
|
+
}
|
|
2849
|
+
function artifactCategory(path) {
|
|
2850
|
+
if (path.startsWith("Brand/")) return "Brand";
|
|
2851
|
+
if (path.startsWith("Deploy/")) return "Deploy";
|
|
2852
|
+
if (path.startsWith("LandingPage/")) return "Landing";
|
|
2853
|
+
if (path.endsWith(".svg")) return "Infographic";
|
|
2854
|
+
if (path === "BuildPrompt.md") return "Build prompt";
|
|
2855
|
+
return "Doc";
|
|
2856
|
+
}
|
|
2857
|
+
var WIKI_AREAS = [
|
|
2858
|
+
{ key: "big-idea", title: "The Big Idea", emoji: "\u{1F4A1}" },
|
|
2859
|
+
{ key: "business-model", title: "Business Model", emoji: "\u{1F4B0}" },
|
|
2860
|
+
{ key: "brand", title: "Brand", emoji: "\u{1F3A8}" },
|
|
2861
|
+
{ key: "market-research", title: "Market Research", emoji: "\u{1F4CA}" },
|
|
2862
|
+
{ key: "strategy", title: "Strategy", emoji: "\u{1F4C8}" },
|
|
2863
|
+
{ key: "product", title: "Product (PRD)", emoji: "\u{1F4CB}" },
|
|
2864
|
+
{ key: "compliance", title: "Compliance & Tax", emoji: "\u2696\uFE0F" },
|
|
2865
|
+
{ key: "decision-science", title: "Decision Science", emoji: "\u{1F9ED}" },
|
|
2866
|
+
{ key: "library", title: "Library", emoji: "\u{1F4DA}" }
|
|
2867
|
+
];
|
|
2868
|
+
function buildWikiPlan(state, opts = {}) {
|
|
2869
|
+
const answers = obj2(state.answers);
|
|
2870
|
+
const brand = state.brand && typeof state.brand === "object" ? state.brand : null;
|
|
2871
|
+
const guide = brand?.guide || null;
|
|
2872
|
+
const grill = state.grill && typeof state.grill === "object" ? state.grill : null;
|
|
2873
|
+
const swot = state.swot && typeof state.swot === "object" ? state.swot : null;
|
|
2874
|
+
const prd = obj2(state.prd);
|
|
2875
|
+
const bizModel = obj2(state.bizModel);
|
|
2876
|
+
const research = obj2(state.research);
|
|
2877
|
+
const assumptions = bizModel["assumptions"] || null;
|
|
2878
|
+
const brandName = str3(guide?.brandName) || "Your Product";
|
|
2879
|
+
const oneLiner = str3(answers["what-statement"]) || str3(answers["pitch"]) || str3(guide?.positioning);
|
|
2880
|
+
const overview = [];
|
|
2881
|
+
if (oneLiner) overview.push(callout(oneLiner, "\u{1F4A1}"));
|
|
2882
|
+
overview.push(para("This private workspace was generated by The Thought Layer. Each section below is a page; the Artifacts database links the files delivered to your repo."));
|
|
2883
|
+
const areaBlocks = {};
|
|
2884
|
+
{
|
|
2885
|
+
const b = [];
|
|
2886
|
+
if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "\u{1F3AF}"));
|
|
2887
|
+
if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
|
|
2888
|
+
const press = str3(answers["press-release"]);
|
|
2889
|
+
if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
|
|
2890
|
+
areaBlocks["big-idea"] = b;
|
|
2891
|
+
}
|
|
2892
|
+
{
|
|
2893
|
+
const b = [];
|
|
2894
|
+
const proj = computeProjection(assumptions);
|
|
2895
|
+
if (proj && assumptions) {
|
|
2896
|
+
const s = proj.summary;
|
|
2897
|
+
const cur = assumptions.currency || "USD";
|
|
2898
|
+
b.push(heading(2, "The numbers"));
|
|
2899
|
+
b.push(callout(
|
|
2900
|
+
`Year 1 revenue ${fmtMoney(s.year1Revenue, cur)}. Monthly break-even ${s.breakEvenMonth ? `month ${s.breakEvenMonth}` : "beyond horizon"}. Max cash drawdown ${fmtMoney(s.maxDrawdown, cur)}. MRR at month ${s.horizon} is ${fmtMoney(s.endingMRR, cur)}.`,
|
|
2901
|
+
"\u{1F4B0}"
|
|
2902
|
+
));
|
|
2903
|
+
const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
|
|
2904
|
+
str3(p.name) || p.id,
|
|
2905
|
+
str3(p.role),
|
|
2906
|
+
String(p.startingCount ?? ""),
|
|
2907
|
+
String(p.monthlyNewBase ?? ""),
|
|
2908
|
+
`${p.monthlyChurnPct ?? 0}%`,
|
|
2909
|
+
fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur),
|
|
2910
|
+
fmtMoney(Number(p.cacPerUnit) || 0, cur)
|
|
2911
|
+
]);
|
|
2912
|
+
b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
|
|
2913
|
+
if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
|
|
2914
|
+
}
|
|
2915
|
+
areaBlocks["business-model"] = b;
|
|
2916
|
+
}
|
|
2917
|
+
{
|
|
2918
|
+
const b = [];
|
|
2919
|
+
if (guide) {
|
|
2920
|
+
b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
|
|
2921
|
+
const pal = (guide.palette || []).filter((p) => p?.hex);
|
|
2922
|
+
if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str3(p.name), str3(p.hex), str3(p.role)])));
|
|
2923
|
+
}
|
|
2924
|
+
areaBlocks["brand"] = b;
|
|
2925
|
+
}
|
|
2926
|
+
{
|
|
2927
|
+
const b = [];
|
|
2928
|
+
const brief = str3(research["brief"]);
|
|
2929
|
+
if (brief) {
|
|
2930
|
+
const desc = str3(research["description"]);
|
|
2931
|
+
if (desc) b.push(callout(desc, "\u{1F4CA}"));
|
|
2932
|
+
b.push(...markdownToBlocks(brief));
|
|
2933
|
+
}
|
|
2934
|
+
areaBlocks["market-research"] = b;
|
|
2935
|
+
}
|
|
2936
|
+
{
|
|
2937
|
+
const b = [];
|
|
2938
|
+
const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
2939
|
+
if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
|
|
2940
|
+
areaBlocks["strategy"] = b;
|
|
2941
|
+
}
|
|
2942
|
+
{
|
|
2943
|
+
const b = [];
|
|
2944
|
+
const prdMd = str3(prd["markdown"]);
|
|
2945
|
+
if (prdMd) b.push(...markdownToBlocks(prdMd));
|
|
2946
|
+
if (grill?.requirements?.length) {
|
|
2947
|
+
b.push(divider());
|
|
2948
|
+
b.push(...markdownToBlocks(requirementsMarkdown(grill)));
|
|
2949
|
+
}
|
|
2950
|
+
if (grill?.glossary?.length) {
|
|
2951
|
+
b.push(divider());
|
|
2952
|
+
b.push(...markdownToBlocks(glossaryMarkdown(grill)));
|
|
2953
|
+
}
|
|
2954
|
+
areaBlocks["product"] = b;
|
|
2955
|
+
}
|
|
2956
|
+
{
|
|
2957
|
+
const b = [];
|
|
2958
|
+
const report = str3(obj2(state.governance)["report"]);
|
|
2959
|
+
if (report.trim()) b.push(...markdownToBlocks(report));
|
|
2960
|
+
areaBlocks["compliance"] = b;
|
|
2961
|
+
}
|
|
2962
|
+
{
|
|
2963
|
+
const b = [];
|
|
2964
|
+
const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str3(answers[k]).trim());
|
|
2965
|
+
if (dq.length) {
|
|
2966
|
+
b.push(heading(2, "Decision records"));
|
|
2967
|
+
for (const k of dq) b.push(bullet(`**${k}**: ${str3(answers[k])}`));
|
|
2968
|
+
}
|
|
2969
|
+
areaBlocks["decision-science"] = b;
|
|
2970
|
+
}
|
|
2971
|
+
areaBlocks["library"] = [];
|
|
2972
|
+
const areas = WIKI_AREAS.map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] })).filter((a) => a.blocks.length > 0);
|
|
2973
|
+
const urls = opts.urls || {};
|
|
2974
|
+
const files = opts.manifest?.files || [];
|
|
2975
|
+
const artifacts = files.filter((f) => !(f.path.startsWith("LandingPage/") && f.path !== "LandingPage/index.html")).map((f) => ({ name: f.path, path: f.path, category: artifactCategory(f.path), bytes: f.bytes, ...urls[f.path] ? { url: urls[f.path] } : {} }));
|
|
2976
|
+
return { title: `${brandName} workspace`, icon: "\u{1F680}", overview, areas, artifacts };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// core/notion-io.ts
|
|
2980
|
+
var NOTION_API = "https://api.notion.com/v1";
|
|
2981
|
+
var NOTION_VERSION = "2022-06-28";
|
|
2982
|
+
var MIN_INTERVAL_MS = 350;
|
|
2983
|
+
var TOKEN_ENVS = ["THOUGHT_LAYER_NOTION_TOKEN", "NOTION_TOKEN"];
|
|
2984
|
+
var ok3 = (message, details = {}) => ({ ok: true, message, details });
|
|
2985
|
+
var fail3 = (message, details = {}) => ({ ok: false, message, details });
|
|
2986
|
+
function notionConfigPath() {
|
|
2987
|
+
return process.env["THOUGHT_LAYER_NOTION_CONFIG"] || join7(homedir2(), ".thought-layer", "notion.json");
|
|
2988
|
+
}
|
|
2989
|
+
function loadNotionConfig() {
|
|
2990
|
+
const p = notionConfigPath();
|
|
2991
|
+
if (!existsSync6(p)) return { schema: 1, sessions: {} };
|
|
2992
|
+
try {
|
|
2993
|
+
const raw = JSON.parse(readFileSync5(p, "utf8"));
|
|
2994
|
+
const sessions = raw["sessions"] && typeof raw["sessions"] === "object" ? raw["sessions"] : {};
|
|
2995
|
+
return { schema: 1, sessions };
|
|
2996
|
+
} catch {
|
|
2997
|
+
return { schema: 1, sessions: {} };
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
function saveNotionConfig(cfg) {
|
|
3001
|
+
const p = notionConfigPath();
|
|
3002
|
+
mkdirSync6(dirname6(p), { recursive: true });
|
|
3003
|
+
writeFileSync7(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
3004
|
+
}
|
|
3005
|
+
function pageIdFromInput(s) {
|
|
3006
|
+
const t = String(s || "").trim();
|
|
3007
|
+
const dashed = t.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/);
|
|
3008
|
+
if (dashed) return dashed[0].toLowerCase();
|
|
3009
|
+
const runs = t.match(/[0-9a-fA-F]{32}/g);
|
|
3010
|
+
if (!runs || !runs.length) return null;
|
|
3011
|
+
const id = runs[runs.length - 1].toLowerCase();
|
|
3012
|
+
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
|
3013
|
+
}
|
|
3014
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3015
|
+
var Notion = class {
|
|
3016
|
+
constructor(token) {
|
|
3017
|
+
this.token = token;
|
|
3018
|
+
}
|
|
3019
|
+
token;
|
|
3020
|
+
last = 0;
|
|
3021
|
+
async call(method, path, body) {
|
|
3022
|
+
for (let attempt = 0; ; attempt++) {
|
|
3023
|
+
const wait = MIN_INTERVAL_MS - (Date.now() - this.last);
|
|
3024
|
+
if (wait > 0) await sleep(wait);
|
|
3025
|
+
this.last = Date.now();
|
|
3026
|
+
const res = await fetch(`${NOTION_API}${path}`, {
|
|
3027
|
+
method,
|
|
3028
|
+
headers: {
|
|
3029
|
+
Authorization: `Bearer ${this.token}`,
|
|
3030
|
+
"Notion-Version": NOTION_VERSION,
|
|
3031
|
+
"Content-Type": "application/json"
|
|
3032
|
+
},
|
|
3033
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
3034
|
+
});
|
|
3035
|
+
if (res.status === 429 && attempt < 4) {
|
|
3036
|
+
const retry = Number(res.headers.get("Retry-After")) || Math.pow(2, attempt);
|
|
3037
|
+
await sleep(retry * 1e3);
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
let json = {};
|
|
3041
|
+
try {
|
|
3042
|
+
json = await res.json();
|
|
3043
|
+
} catch {
|
|
3044
|
+
}
|
|
3045
|
+
return { status: res.status, json };
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
async pageExists(id) {
|
|
3049
|
+
const r = await this.call("GET", `/pages/${id}`);
|
|
3050
|
+
return r.status === 200 && r.json["archived"] !== true;
|
|
3051
|
+
}
|
|
3052
|
+
async createPage(parentPageId, title, emoji) {
|
|
3053
|
+
const r = await this.call("POST", "/pages", {
|
|
3054
|
+
parent: { page_id: parentPageId },
|
|
3055
|
+
...emoji ? { icon: { type: "emoji", emoji } } : {},
|
|
3056
|
+
properties: { title: { title: [{ text: { content: title } }] } }
|
|
3057
|
+
});
|
|
3058
|
+
if (r.status !== 200) throw new Error(notionErr("create page", r));
|
|
3059
|
+
return String(r.json["id"]);
|
|
3060
|
+
}
|
|
3061
|
+
// Replace a page's content: delete every existing child, then append the new
|
|
3062
|
+
// blocks in <=100-block batches.
|
|
3063
|
+
async replaceChildren(pageId, blocks) {
|
|
3064
|
+
let cursor;
|
|
3065
|
+
do {
|
|
3066
|
+
const q = cursor ? `?start_cursor=${cursor}&page_size=100` : "?page_size=100";
|
|
3067
|
+
const r = await this.call("GET", `/blocks/${pageId}/children${q}`);
|
|
3068
|
+
if (r.status !== 200) break;
|
|
3069
|
+
for (const b of r.json["results"] || []) await this.call("DELETE", `/blocks/${b.id}`);
|
|
3070
|
+
cursor = r.json["has_more"] ? String(r.json["next_cursor"]) : void 0;
|
|
3071
|
+
} while (cursor);
|
|
3072
|
+
for (const batch of chunkChildren(blocks)) {
|
|
3073
|
+
if (!batch.length) continue;
|
|
3074
|
+
const r = await this.call("PATCH", `/blocks/${pageId}/children`, { children: batch });
|
|
3075
|
+
if (r.status !== 200) throw new Error(notionErr("append blocks", r));
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
async createArtifactsDb(parentPageId, artifacts) {
|
|
3079
|
+
const cats = Array.from(new Set(artifacts.map((a) => a.category)));
|
|
3080
|
+
const r = await this.call("POST", "/databases", {
|
|
3081
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
3082
|
+
title: [{ text: { content: "Artifacts" } }],
|
|
3083
|
+
icon: { type: "emoji", emoji: "\u{1F4CE}" },
|
|
3084
|
+
properties: {
|
|
3085
|
+
Name: { title: {} },
|
|
3086
|
+
Category: { select: { options: cats.map((c) => ({ name: c })) } },
|
|
3087
|
+
Size: { rich_text: {} },
|
|
3088
|
+
Link: { url: {} }
|
|
3089
|
+
}
|
|
3090
|
+
});
|
|
3091
|
+
if (r.status !== 200) throw new Error(notionErr("create database", r));
|
|
3092
|
+
return String(r.json["id"]);
|
|
3093
|
+
}
|
|
3094
|
+
async addArtifactRow(dbId, a) {
|
|
3095
|
+
await this.call("POST", "/pages", {
|
|
3096
|
+
parent: { database_id: dbId },
|
|
3097
|
+
properties: {
|
|
3098
|
+
Name: { title: [{ text: { content: a.name } }] },
|
|
3099
|
+
Category: { select: { name: a.category } },
|
|
3100
|
+
Size: { rich_text: [{ text: { content: humanSize(a.bytes) } }] },
|
|
3101
|
+
...a.url ? { Link: { url: a.url } } : {}
|
|
3102
|
+
}
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
};
|
|
3106
|
+
function notionErr(what, r) {
|
|
3107
|
+
const msg = String(r.json["message"] || "").slice(0, 200);
|
|
3108
|
+
return `Notion ${what} failed (${r.status})${msg ? `: ${msg}` : ""}`;
|
|
3109
|
+
}
|
|
3110
|
+
function humanSize(bytes) {
|
|
3111
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3112
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
3113
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3114
|
+
}
|
|
3115
|
+
async function runWiki(opts) {
|
|
3116
|
+
try {
|
|
3117
|
+
const cfg = loadConfig();
|
|
3118
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
3119
|
+
if (!isGitRepo(cloneDir)) {
|
|
3120
|
+
return fail3(`No sessions workspace at ${cloneDir}. Run tl sync init then tl artifacts before building the wiki.`, { cloneDir });
|
|
3121
|
+
}
|
|
3122
|
+
const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
|
|
3123
|
+
if (!slug) return fail3("Name the session to publish: tl wiki --name <name>.");
|
|
3124
|
+
const sessionPath = join7(cloneDir, STATE_DIR, `${slug}.json`);
|
|
3125
|
+
const useExplicit = !!(opts.path && opts.path.trim() || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
|
|
3126
|
+
const loaded = loadStateFile(useExplicit ? opts.path : existsSync6(sessionPath) ? sessionPath : opts.path);
|
|
3127
|
+
if (!loaded.exists) return fail3(`No session "${slug}" found (looked at ${loaded.path}). Save it first with tl sync save --name ${slug}.`, { cloneDir });
|
|
3128
|
+
const artifactsDir = join7(cloneDir, "artifacts", slug);
|
|
3129
|
+
let manifest = null;
|
|
3130
|
+
const manifestPath = join7(artifactsDir, "artifacts.json");
|
|
3131
|
+
if (existsSync6(manifestPath)) {
|
|
3132
|
+
try {
|
|
3133
|
+
manifest = JSON.parse(readFileSync5(manifestPath, "utf8"));
|
|
3134
|
+
} catch {
|
|
3135
|
+
manifest = null;
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || ws?.defaultBranch || "main";
|
|
3139
|
+
const ownerName = repoOwnerName(ws?.repo || "");
|
|
3140
|
+
const urls = {};
|
|
3141
|
+
if (ownerName && manifest) {
|
|
3142
|
+
const base = `https://github.com/${ownerName}/blob/${branch}/artifacts/${slug}`;
|
|
3143
|
+
for (const f of manifest.files) urls[f.path] = `${base}/${f.path.split("/").map(encodeURIComponent).join("/")}`;
|
|
3144
|
+
}
|
|
3145
|
+
const plan = buildWikiPlan(loaded.state, { manifest, urls });
|
|
3146
|
+
const blockCount = plan.overview.length + plan.areas.reduce((n, a) => n + a.blocks.length, 0);
|
|
3147
|
+
if (opts.dryRun) {
|
|
3148
|
+
const areaList = plan.areas.map((a) => `${a.emoji} ${a.title} (${a.blocks.length} blocks)`).join(", ");
|
|
3149
|
+
return ok3(
|
|
3150
|
+
`Dry run for "${plan.title}": ${plan.areas.length} area page(s), ${blockCount} blocks total, ${plan.artifacts.length} artifact(s) in the database.
|
|
3151
|
+
Areas: ${areaList || "(none with content yet)"}.${manifest ? "" : "\nNo delivered artifacts found; run tl artifacts first to populate the Artifacts database with GitHub links."}`,
|
|
3152
|
+
{ title: plan.title, areas: plan.areas.map((a) => ({ key: a.key, blocks: a.blocks.length })), blockCount, artifacts: plan.artifacts.length, delivered: !!manifest }
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
|
|
3156
|
+
if (!token) {
|
|
3157
|
+
return fail3(
|
|
3158
|
+
"No Notion token. Create an internal integration at https://www.notion.so/my-integrations, copy its secret, and set THOUGHT_LAYER_NOTION_TOKEN. Then share a Notion page with the integration and pass it as --parent-page.",
|
|
3159
|
+
{ needs: "token" }
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
const ncfg = loadNotionConfig();
|
|
3163
|
+
const entry = ncfg.sessions[slug] || {};
|
|
3164
|
+
const notion = new Notion(token);
|
|
3165
|
+
let rootId = opts.replace ? "" : entry.rootPageId || "";
|
|
3166
|
+
if (rootId && !await notion.pageExists(rootId)) rootId = "";
|
|
3167
|
+
if (!rootId) {
|
|
3168
|
+
const parentInput = opts.parentPage || process.env["THOUGHT_LAYER_NOTION_PARENT"] || "";
|
|
3169
|
+
const parentId = pageIdFromInput(parentInput);
|
|
3170
|
+
if (!parentId) {
|
|
3171
|
+
return fail3(
|
|
3172
|
+
"No Notion parent page. Share a page with your integration in Notion (Share, then add your integration), then pass it as --parent-page <id or url>.",
|
|
3173
|
+
{ needs: "parent-page" }
|
|
3174
|
+
);
|
|
3175
|
+
}
|
|
3176
|
+
if (!await notion.pageExists(parentId)) {
|
|
3177
|
+
return fail3(`Notion cannot see the parent page ${parentId}. In Notion, open the page, click Share, and add your integration so it has access.`, { needs: "share" });
|
|
3178
|
+
}
|
|
3179
|
+
rootId = await notion.createPage(parentId, plan.title, plan.icon);
|
|
3180
|
+
entry.areaPageIds = {};
|
|
3181
|
+
entry.artifactsDbId = void 0;
|
|
3182
|
+
}
|
|
3183
|
+
await notion.replaceChildren(rootId, plan.overview);
|
|
3184
|
+
const areaIds = entry.areaPageIds || {};
|
|
3185
|
+
for (const area of plan.areas) {
|
|
3186
|
+
let pid = areaIds[area.key] || "";
|
|
3187
|
+
if (pid && !await notion.pageExists(pid)) pid = "";
|
|
3188
|
+
if (!pid) pid = await notion.createPage(rootId, `${area.emoji} ${area.title}`, area.emoji);
|
|
3189
|
+
areaIds[area.key] = pid;
|
|
3190
|
+
await notion.replaceChildren(pid, area.blocks);
|
|
3191
|
+
}
|
|
3192
|
+
let dbId = entry.artifactsDbId || "";
|
|
3193
|
+
if (plan.artifacts.length && (!dbId || opts.replace)) {
|
|
3194
|
+
dbId = await notion.createArtifactsDb(rootId, plan.artifacts);
|
|
3195
|
+
for (const a of plan.artifacts) await notion.addArtifactRow(dbId, a);
|
|
3196
|
+
}
|
|
3197
|
+
ncfg.sessions[slug] = { rootPageId: rootId, areaPageIds: areaIds, artifactsDbId: dbId || void 0, updatedAt: Date.now() };
|
|
3198
|
+
saveNotionConfig(ncfg);
|
|
3199
|
+
const rootUrl = `https://www.notion.so/${rootId.replace(/-/g, "")}`;
|
|
3200
|
+
return ok3(
|
|
3201
|
+
`Built the "${plan.title}" wiki in Notion: ${plan.areas.length} area page(s), ${blockCount} blocks, ${plan.artifacts.length} artifact(s) listed.${manifest ? "" : " No delivered artifacts were found, so the Artifacts database links are empty; run tl artifacts first."}
|
|
3202
|
+
Open it: ${rootUrl}`,
|
|
3203
|
+
{ title: plan.title, rootPageId: rootId, rootUrl, areas: plan.areas.length, blockCount, artifacts: plan.artifacts.length }
|
|
3204
|
+
);
|
|
3205
|
+
} catch (e) {
|
|
3206
|
+
return fail3(`tl_wiki error: ${e.message}`);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
1450
3210
|
// bin/tl.ts
|
|
1451
3211
|
var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
|
|
1452
3212
|
|
|
@@ -1455,6 +3215,12 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
|
|
|
1455
3215
|
tl scaffold [--out dist] [--domain x.com] [--founder "Name"] deterministic deployable static site from the spec + brand
|
|
1456
3216
|
tl deploy [--dry-run] [--anonymous] [--name x] [--site id] take build.json's publish dir live to a user-owned Netlify URL
|
|
1457
3217
|
[--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
|
|
3218
|
+
tl sync <init|save|list|open|pull|push|status> store/sync your session files in your own private GitHub repo
|
|
3219
|
+
[--repo owner/name] [--name x] [--dir p] [--workspace w] [--message m] [--no-push]
|
|
3220
|
+
tl artifacts [--name x] [--workspace w] deliver the full asset bundle (PRD, brand, infographics, landing, deploy rules) to your sessions repo
|
|
3221
|
+
[--no-push] [--no-deliver] [--domain x.com] [--founder "Name"]
|
|
3222
|
+
tl wiki [--parent-page id|url] [--name x] build/refresh a private Notion wiki from the session + delivered artifacts
|
|
3223
|
+
[--workspace w] [--replace] [--dry-run] (set THOUGHT_LAYER_NOTION_TOKEN; share a Notion page with your integration)
|
|
1458
3224
|
tl export [path] handoff check
|
|
1459
3225
|
tl answer <qId> <value> [path] record an answer
|
|
1460
3226
|
tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
|
|
@@ -1489,7 +3255,7 @@ function parseArgs(argv) {
|
|
|
1489
3255
|
function readData(flags) {
|
|
1490
3256
|
const d = flags["data"];
|
|
1491
3257
|
if (d === void 0) return void 0;
|
|
1492
|
-
const raw = d === "-" || d === true ?
|
|
3258
|
+
const raw = d === "-" || d === true ? readFileSync6(0, "utf8") : String(d);
|
|
1493
3259
|
try {
|
|
1494
3260
|
return JSON.parse(raw);
|
|
1495
3261
|
} catch {
|
|
@@ -1563,6 +3329,61 @@ function main() {
|
|
|
1563
3329
|
});
|
|
1564
3330
|
return;
|
|
1565
3331
|
}
|
|
3332
|
+
if (args[0] === "sync") {
|
|
3333
|
+
runSync(
|
|
3334
|
+
{
|
|
3335
|
+
op: typeof args[1] === "string" ? args[1] : "status",
|
|
3336
|
+
name: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
3337
|
+
repo: typeof flags["repo"] === "string" ? flags["repo"] : void 0,
|
|
3338
|
+
dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
|
|
3339
|
+
workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
|
|
3340
|
+
message: typeof flags["message"] === "string" ? flags["message"] : void 0,
|
|
3341
|
+
noPush: flags["no-push"] === true,
|
|
3342
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0
|
|
3343
|
+
},
|
|
3344
|
+
{ ts: Date.now(), exportedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3345
|
+
).then((r2) => {
|
|
3346
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
3347
|
+
else console.log(r2.message);
|
|
3348
|
+
process.exit(r2.ok ? 0 : 1);
|
|
3349
|
+
});
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
if (args[0] === "artifacts") {
|
|
3353
|
+
const r2 = runArtifacts(
|
|
3354
|
+
{
|
|
3355
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
3356
|
+
name: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
3357
|
+
workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
|
|
3358
|
+
dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
|
|
3359
|
+
message: typeof flags["message"] === "string" ? flags["message"] : void 0,
|
|
3360
|
+
noPush: flags["no-push"] === true,
|
|
3361
|
+
noDeliver: flags["no-deliver"] === true,
|
|
3362
|
+
domain: typeof flags["domain"] === "string" ? flags["domain"] : void 0,
|
|
3363
|
+
founderName: typeof flags["founder"] === "string" ? flags["founder"] : void 0
|
|
3364
|
+
},
|
|
3365
|
+
{ generatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3366
|
+
);
|
|
3367
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
3368
|
+
else console.log(r2.message);
|
|
3369
|
+
process.exit(r2.ok ? 0 : 1);
|
|
3370
|
+
}
|
|
3371
|
+
if (args[0] === "wiki") {
|
|
3372
|
+
runWiki({
|
|
3373
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
3374
|
+
name: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
3375
|
+
workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
|
|
3376
|
+
dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
|
|
3377
|
+
parentPage: typeof flags["parent-page"] === "string" ? flags["parent-page"] : void 0,
|
|
3378
|
+
replace: flags["replace"] === true,
|
|
3379
|
+
dryRun: flags["dry-run"] === true
|
|
3380
|
+
}).then((r2) => {
|
|
3381
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
3382
|
+
else console.log(r2.message);
|
|
3383
|
+
process.exit(r2.ok ? 0 : 1);
|
|
3384
|
+
});
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
1566
3387
|
let payload;
|
|
1567
3388
|
try {
|
|
1568
3389
|
payload = buildOp(args, flags);
|