@hobocode/thought-layer 0.7.0 → 0.8.6
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 +5 -2
- package/core/artifacts-io.ts +146 -0
- package/core/artifacts.ts +690 -0
- package/core/index.ts +4 -0
- package/core/merge.ts +2 -1
- package/core/notion-io.ts +308 -0
- package/core/notion.ts +438 -0
- package/core/progress.ts +8 -5
- package/core/state-ops.ts +1 -1
- package/core/sync-io.ts +9 -6
- package/dist/tl.js +1431 -45
- package/extensions/thought-layer.ts +64 -2
- package/package.json +5 -1
- package/prompts/tl-artifacts.md +7 -0
- package/prompts/tl-compliance.md +7 -0
- package/prompts/tl-wiki.md +11 -0
- package/skills/thought-layer-compliance/SKILL.md +139 -0
- package/skills/thought-layer-wiki/SKILL.md +61 -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;
|
|
@@ -84,6 +84,7 @@ var KNOWN_STATE_KEYS = [
|
|
|
84
84
|
"prd",
|
|
85
85
|
"naming",
|
|
86
86
|
"brand",
|
|
87
|
+
"governance",
|
|
87
88
|
"kit"
|
|
88
89
|
];
|
|
89
90
|
function emptyState() {
|
|
@@ -99,6 +100,7 @@ function emptyState() {
|
|
|
99
100
|
prd: null,
|
|
100
101
|
naming: null,
|
|
101
102
|
brand: null,
|
|
103
|
+
governance: null,
|
|
102
104
|
kit: null
|
|
103
105
|
};
|
|
104
106
|
}
|
|
@@ -115,12 +117,12 @@ function parseProgress(text) {
|
|
|
115
117
|
const rawFormat = payload["format"];
|
|
116
118
|
const formatNewer = typeof rawFormat === "number" && rawFormat > PROGRESS_FORMAT;
|
|
117
119
|
const s = payload["state"] && typeof payload["state"] === "object" ? payload["state"] : {};
|
|
118
|
-
const
|
|
120
|
+
const obj3 = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
119
121
|
const state = {
|
|
120
122
|
...s,
|
|
121
123
|
version: 2,
|
|
122
|
-
answers:
|
|
123
|
-
feedback:
|
|
124
|
+
answers: obj3(s["answers"]),
|
|
125
|
+
feedback: obj3(s["feedback"]),
|
|
124
126
|
bizModel: s["bizModel"] ?? null,
|
|
125
127
|
grill: s["grill"] ?? null,
|
|
126
128
|
assets: s["assets"] ?? null,
|
|
@@ -129,6 +131,7 @@ function parseProgress(text) {
|
|
|
129
131
|
prd: s["prd"] ?? null,
|
|
130
132
|
naming: s["naming"] ?? null,
|
|
131
133
|
brand: s["brand"] ?? null,
|
|
134
|
+
governance: s["governance"] ?? null,
|
|
132
135
|
kit: s["kit"] ?? null
|
|
133
136
|
};
|
|
134
137
|
return {
|
|
@@ -153,6 +156,7 @@ function buildProgress(state, writer, exportedAt) {
|
|
|
153
156
|
prd,
|
|
154
157
|
naming,
|
|
155
158
|
brand,
|
|
159
|
+
governance,
|
|
156
160
|
kit,
|
|
157
161
|
version: _v,
|
|
158
162
|
exportedAt: _ea,
|
|
@@ -177,6 +181,7 @@ function buildProgress(state, writer, exportedAt) {
|
|
|
177
181
|
prd: prd ?? null,
|
|
178
182
|
naming: naming ?? null,
|
|
179
183
|
brand: brand ?? null,
|
|
184
|
+
governance: governance ?? null,
|
|
180
185
|
kit: kit ?? null,
|
|
181
186
|
...rest
|
|
182
187
|
}
|
|
@@ -259,7 +264,7 @@ function summarizeState(state) {
|
|
|
259
264
|
if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
|
|
260
265
|
else byStatus.ungraded++;
|
|
261
266
|
}
|
|
262
|
-
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);
|
|
263
268
|
const kit = state.kit && typeof state.kit === "object" ? state.kit : null;
|
|
264
269
|
return {
|
|
265
270
|
answered,
|
|
@@ -329,11 +334,11 @@ function saveStateFile(state, opts) {
|
|
|
329
334
|
}
|
|
330
335
|
|
|
331
336
|
// core/state-ops.ts
|
|
332
|
-
var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
|
|
337
|
+
var ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"];
|
|
333
338
|
var END_STATES = ["open", "pass", "setAside"];
|
|
334
339
|
function applyStateOp(p, ctx) {
|
|
335
340
|
const { ts, exportedAt } = ctx;
|
|
336
|
-
const
|
|
341
|
+
const fail4 = (message) => ({ ok: false, message, details: {} });
|
|
337
342
|
try {
|
|
338
343
|
if (p.op === "list") {
|
|
339
344
|
const files = listStateFiles(p.path);
|
|
@@ -342,8 +347,8 @@ function applyStateOp(p, ctx) {
|
|
|
342
347
|
}
|
|
343
348
|
const rows = files.map((f) => {
|
|
344
349
|
try {
|
|
345
|
-
const
|
|
346
|
-
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 };
|
|
347
352
|
} catch {
|
|
348
353
|
return { name: f.name, path: f.path, answered: 0, artifacts: [], unreadable: true };
|
|
349
354
|
}
|
|
@@ -356,24 +361,24 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
|
|
|
356
361
|
const loaded = loadStateFile(p.path);
|
|
357
362
|
const save = (next) => saveStateFile(next, { target: p.path, ts, exportedAt }).path;
|
|
358
363
|
if (p.op === "read" || p.op === "export") {
|
|
359
|
-
const
|
|
364
|
+
const sum2 = summarizeState(loaded.state);
|
|
360
365
|
let message;
|
|
361
366
|
if (loaded.exists) {
|
|
362
|
-
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"}.`;
|
|
363
368
|
} else {
|
|
364
369
|
const others = listStateFiles().filter((f) => f.path !== loaded.path);
|
|
365
370
|
const hint = others.length ? ` Other state files here: ${others.map((f) => f.name).join(", ")} (pick one with --path, or 'tl list').` : "";
|
|
366
371
|
message = `No state file yet at ${loaded.path}.${hint} Start a fresh run; it will be created on first write.`;
|
|
367
372
|
}
|
|
368
|
-
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 } };
|
|
369
374
|
}
|
|
370
375
|
if (p.op === "answer") {
|
|
371
|
-
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.");
|
|
372
377
|
const path = save(setAnswer(loaded.state, p.qId, p.value, ts));
|
|
373
378
|
return { ok: true, message: `Recorded answer for "${p.qId}" and saved ${path}.`, details: { path, qId: p.qId } };
|
|
374
379
|
}
|
|
375
380
|
if (p.op === "feedback") {
|
|
376
|
-
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.");
|
|
377
382
|
const endState = END_STATES.includes(p.endState || "") ? p.endState : "open";
|
|
378
383
|
const mode = p.mode || (p.personas.length > 1 ? "panel" : p.personas[0].persona);
|
|
379
384
|
const entry = buildFeedbackEntry({ mode, personas: p.personas, endState, round: p.round, ts });
|
|
@@ -390,24 +395,24 @@ Pick one with --path .thought-layer/<name>.json, or set ${STATE_ENV} for the ses
|
|
|
390
395
|
}
|
|
391
396
|
if (p.op === "artifact") {
|
|
392
397
|
const key = p.artifact;
|
|
393
|
-
if (!ARTIFACT_KEYS.includes(key)) return
|
|
394
|
-
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.");
|
|
395
400
|
const path = save(setArtifact(loaded.state, key, normalizeArtifactValue(key, p.value), ts));
|
|
396
401
|
return { ok: true, message: `Stored ${key} artifact and saved ${path}.`, details: { path, artifact: key } };
|
|
397
402
|
}
|
|
398
403
|
if (p.op === "cursor") {
|
|
399
|
-
if (!p.cursor) return
|
|
404
|
+
if (!p.cursor) return fail4("cursor needs a cursor object.");
|
|
400
405
|
const path = save(setCursor(loaded.state, p.cursor, ts));
|
|
401
406
|
return { ok: true, message: `Saved resume cursor (stage ${p.cursor.backboneStage ?? "?"}, ${p.cursor.phase ?? "?"}) to ${path}.`, details: { path } };
|
|
402
407
|
}
|
|
403
408
|
if (p.op === "park") {
|
|
404
|
-
if (!p.key || !p.note) return
|
|
409
|
+
if (!p.key || !p.note) return fail4("park needs a key and a note.");
|
|
405
410
|
const path = save(parkNote(loaded.state, p.key, p.note, ts));
|
|
406
411
|
return { ok: true, message: `Parked a note under "${p.key}" and saved ${path}.`, details: { path, key: p.key } };
|
|
407
412
|
}
|
|
408
|
-
return
|
|
413
|
+
return fail4(`Unknown op "${p.op}". Use read, answer, feedback, artifact, cursor, park, or export.`);
|
|
409
414
|
} catch (e) {
|
|
410
|
-
return
|
|
415
|
+
return fail4(`tl_state error: ${e.message}`);
|
|
411
416
|
}
|
|
412
417
|
}
|
|
413
418
|
|
|
@@ -419,26 +424,26 @@ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve
|
|
|
419
424
|
var esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
420
425
|
var fam = (f) => f.trim().replace(/\s+/g, "+");
|
|
421
426
|
function extractScaffoldSpec(state) {
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
const brand =
|
|
425
|
-
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"]);
|
|
426
431
|
const answers = state.answers || {};
|
|
427
432
|
const palette = Array.isArray(guide["palette"]) ? guide["palette"] : [];
|
|
428
433
|
const role = (re, fb) => {
|
|
429
434
|
const m = palette.find((p) => p?.name && new RegExp(re, "i").test(p.name));
|
|
430
435
|
return m?.hex || fb;
|
|
431
436
|
};
|
|
432
|
-
const typography =
|
|
433
|
-
const fontOf = (slot, fb) =>
|
|
434
|
-
const voice =
|
|
437
|
+
const typography = obj3(guide["typography"]);
|
|
438
|
+
const fontOf = (slot, fb) => str4(obj3(slot)["family"]) || fb;
|
|
439
|
+
const voice = obj3(guide["voice"]);
|
|
435
440
|
const logos = Array.isArray(brand["logos"]) ? brand["logos"] : [];
|
|
436
441
|
const chosen = logos.find((l) => l?.id === brand["chosenLogoId"]) || logos[0];
|
|
437
442
|
return {
|
|
438
|
-
brandName:
|
|
439
|
-
tagline:
|
|
440
|
-
pitch:
|
|
441
|
-
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"]),
|
|
442
447
|
personality: Array.isArray(guide["personality"]) ? guide["personality"].filter((x) => typeof x === "string") : [],
|
|
443
448
|
palette: {
|
|
444
449
|
primary: role("primary", palette[0]?.hex || "#1f3a5f"),
|
|
@@ -449,9 +454,9 @@ function extractScaffoldSpec(state) {
|
|
|
449
454
|
},
|
|
450
455
|
displayFont: fontOf(typography["display"], "Inter"),
|
|
451
456
|
bodyFont: fontOf(typography["body"], "Inter"),
|
|
452
|
-
voiceTone:
|
|
457
|
+
voiceTone: str4(voice["tone"]),
|
|
453
458
|
logoSvg: chosen?.svg || void 0,
|
|
454
|
-
pricing:
|
|
459
|
+
pricing: str4(answers["pricing-model"]) || void 0
|
|
455
460
|
};
|
|
456
461
|
}
|
|
457
462
|
function indexHtml(spec, opts) {
|
|
@@ -748,11 +753,11 @@ function dedupeSortEnv(envVars) {
|
|
|
748
753
|
function normalizeDatabase(raw) {
|
|
749
754
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
750
755
|
const r = raw;
|
|
751
|
-
const
|
|
756
|
+
const str4 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
752
757
|
return {
|
|
753
|
-
provider:
|
|
754
|
-
schemaFile:
|
|
755
|
-
envVar:
|
|
758
|
+
provider: str4(r["provider"], "neon"),
|
|
759
|
+
schemaFile: str4(r["schemaFile"], "schema.sql"),
|
|
760
|
+
envVar: str4(r["envVar"], "DATABASE_URL")
|
|
756
761
|
};
|
|
757
762
|
}
|
|
758
763
|
function normalizeEnvVars(raw) {
|
|
@@ -783,15 +788,15 @@ function normalizeBackendMeta(raw) {
|
|
|
783
788
|
const database = normalizeDatabase(r["database"]);
|
|
784
789
|
const hasFunctionsDir = typeof r["functionsDir"] === "string" && r["functionsDir"].trim().length > 0;
|
|
785
790
|
if (kind === null && envVars.length === 0 && database === null && !hasFunctionsDir) return null;
|
|
786
|
-
const
|
|
791
|
+
const str4 = (v, fb) => typeof v === "string" && v.trim() ? v.trim() : fb;
|
|
787
792
|
return {
|
|
788
793
|
backendKind: kind ?? "serverless",
|
|
789
|
-
functionsDir:
|
|
790
|
-
runtime:
|
|
791
|
-
nodeVersion:
|
|
794
|
+
functionsDir: str4(r["functionsDir"], "netlify/functions"),
|
|
795
|
+
runtime: str4(r["runtime"], "nodejs20.x"),
|
|
796
|
+
nodeVersion: str4(r["nodeVersion"], "20"),
|
|
792
797
|
envVars,
|
|
793
798
|
database,
|
|
794
|
-
guide:
|
|
799
|
+
guide: str4(r["guide"], "BACKEND.md")
|
|
795
800
|
};
|
|
796
801
|
}
|
|
797
802
|
var SECRET_NAME_RE = /(KEY|SECRET|TOKEN|PASSWORD|PASSWD|DATABASE_URL|DB_URL|CONN|DSN|CREDENTIAL|PRIVATE|AUTH)/;
|
|
@@ -1605,6 +1610,7 @@ function mergeProgressStates(ours, theirs, opts) {
|
|
|
1605
1610
|
prd: artifact("prd"),
|
|
1606
1611
|
naming: artifact("naming"),
|
|
1607
1612
|
brand: artifact("brand"),
|
|
1613
|
+
governance: artifact("governance"),
|
|
1608
1614
|
kit: mergeKit(ours.kit, theirs.kit, oursNewer)
|
|
1609
1615
|
};
|
|
1610
1616
|
for (const src of oursNewer ? [theirs, ours] : [ours, theirs]) {
|
|
@@ -1712,6 +1718,10 @@ deploy.json
|
|
|
1712
1718
|
dist/
|
|
1713
1719
|
.netlify/
|
|
1714
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/**
|
|
1715
1725
|
`;
|
|
1716
1726
|
var README = `# Thought Layer sessions
|
|
1717
1727
|
|
|
@@ -1872,8 +1882,8 @@ function syncList(opts) {
|
|
|
1872
1882
|
if (!files.length) return ok(`No sessions yet in ${cloneDir}. Create one with tl sync save --name <name>.`, { cloneDir, sessions: [] });
|
|
1873
1883
|
const rows = files.map((f) => {
|
|
1874
1884
|
try {
|
|
1875
|
-
const
|
|
1876
|
-
return { name: f.name, path: f.path, answered:
|
|
1885
|
+
const sum2 = summarizeState(loadStateFile(f.path).state);
|
|
1886
|
+
return { name: f.name, path: f.path, answered: sum2.answered, artifacts: sum2.artifacts };
|
|
1877
1887
|
} catch {
|
|
1878
1888
|
return { name: f.name, path: f.path, answered: 0, artifacts: [], unreadable: true };
|
|
1879
1889
|
}
|
|
@@ -1993,6 +2003,1341 @@ function syncStatus(opts) {
|
|
|
1993
2003
|
);
|
|
1994
2004
|
}
|
|
1995
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 richTextToMarkdown(rt) {
|
|
2845
|
+
if (!rt || !rt.length) return "";
|
|
2846
|
+
const sig = (s) => {
|
|
2847
|
+
const a = s.annotations || {};
|
|
2848
|
+
return `${a.bold ? 1 : 0}${a.italic ? 1 : 0}${a.code ? 1 : 0}|${s.text.link?.url || ""}`;
|
|
2849
|
+
};
|
|
2850
|
+
const merged = [];
|
|
2851
|
+
for (const s of rt) {
|
|
2852
|
+
const prev = merged[merged.length - 1];
|
|
2853
|
+
if (prev && sig(prev) === sig(s)) prev.text.content += s.text.content;
|
|
2854
|
+
else merged.push({ type: "text", text: { content: s.text.content, ...s.text.link ? { link: s.text.link } : {} }, ...s.annotations ? { annotations: { ...s.annotations } } : {} });
|
|
2855
|
+
}
|
|
2856
|
+
return merged.map((seg) => {
|
|
2857
|
+
const a = seg.annotations || {};
|
|
2858
|
+
let t = seg.text.content;
|
|
2859
|
+
if (a.code) t = "`" + t + "`";
|
|
2860
|
+
else {
|
|
2861
|
+
if (a.bold) t = `**${t}**`;
|
|
2862
|
+
if (a.italic) t = `*${t}*`;
|
|
2863
|
+
}
|
|
2864
|
+
const link = seg.text.link?.url;
|
|
2865
|
+
return link ? `[${t}](${link})` : t;
|
|
2866
|
+
}).join("");
|
|
2867
|
+
}
|
|
2868
|
+
function richTextToPlain(rt) {
|
|
2869
|
+
return (rt || []).map((s) => s.text.content).join("");
|
|
2870
|
+
}
|
|
2871
|
+
function tableToMarkdown(tbl) {
|
|
2872
|
+
const rows = (tbl["children"] || []).map(
|
|
2873
|
+
(r) => (obj2(r["table_row"])["cells"] || []).map(
|
|
2874
|
+
(c) => richTextToPlain(c).replace(/\|/g, "\\|").replace(/\n+/g, " ").trim()
|
|
2875
|
+
)
|
|
2876
|
+
);
|
|
2877
|
+
if (!rows.length) return "";
|
|
2878
|
+
const width = Math.max(...rows.map((r) => r.length));
|
|
2879
|
+
const pad = (r) => {
|
|
2880
|
+
const c = r.slice();
|
|
2881
|
+
while (c.length < width) c.push("");
|
|
2882
|
+
return c;
|
|
2883
|
+
};
|
|
2884
|
+
const line = (cells) => `| ${cells.join(" | ")} |`;
|
|
2885
|
+
const [header, ...body] = rows;
|
|
2886
|
+
const out = [line(pad(header)), line(pad(header).map(() => "---"))];
|
|
2887
|
+
for (const r of body) out.push(line(pad(r)));
|
|
2888
|
+
return out.join("\n");
|
|
2889
|
+
}
|
|
2890
|
+
function blocksToMarkdown(blocks) {
|
|
2891
|
+
const isList = (t) => t === "bulleted_list_item" || t === "numbered_list_item";
|
|
2892
|
+
const parts = [];
|
|
2893
|
+
let prevType = "";
|
|
2894
|
+
let numbered2 = 0;
|
|
2895
|
+
for (const b of blocks || []) {
|
|
2896
|
+
const type = str3(b["type"]);
|
|
2897
|
+
const data = obj2(b[type]);
|
|
2898
|
+
const rt = data["rich_text"] || [];
|
|
2899
|
+
if (type !== "numbered_list_item") numbered2 = 0;
|
|
2900
|
+
let rendered;
|
|
2901
|
+
switch (type) {
|
|
2902
|
+
case "heading_1":
|
|
2903
|
+
rendered = `# ${richTextToMarkdown(rt)}`;
|
|
2904
|
+
break;
|
|
2905
|
+
case "heading_2":
|
|
2906
|
+
rendered = `## ${richTextToMarkdown(rt)}`;
|
|
2907
|
+
break;
|
|
2908
|
+
case "heading_3":
|
|
2909
|
+
rendered = `### ${richTextToMarkdown(rt)}`;
|
|
2910
|
+
break;
|
|
2911
|
+
case "bulleted_list_item":
|
|
2912
|
+
rendered = `- ${richTextToMarkdown(rt)}`;
|
|
2913
|
+
break;
|
|
2914
|
+
case "numbered_list_item":
|
|
2915
|
+
rendered = `${++numbered2}. ${richTextToMarkdown(rt)}`;
|
|
2916
|
+
break;
|
|
2917
|
+
case "quote":
|
|
2918
|
+
rendered = `> ${richTextToMarkdown(rt)}`;
|
|
2919
|
+
break;
|
|
2920
|
+
case "callout": {
|
|
2921
|
+
const emoji = str3(obj2(data["icon"])["emoji"]);
|
|
2922
|
+
rendered = `> ${emoji ? `${emoji} ` : ""}${richTextToMarkdown(rt)}`;
|
|
2923
|
+
break;
|
|
2924
|
+
}
|
|
2925
|
+
case "divider":
|
|
2926
|
+
rendered = "---";
|
|
2927
|
+
break;
|
|
2928
|
+
case "code": {
|
|
2929
|
+
const lang = str3(data["language"]);
|
|
2930
|
+
rendered = "```" + (lang && lang !== "plain text" ? lang : "") + "\n" + richTextToPlain(rt) + "\n```";
|
|
2931
|
+
break;
|
|
2932
|
+
}
|
|
2933
|
+
case "table":
|
|
2934
|
+
rendered = tableToMarkdown(data);
|
|
2935
|
+
break;
|
|
2936
|
+
case "bookmark":
|
|
2937
|
+
rendered = str3(data["url"]);
|
|
2938
|
+
break;
|
|
2939
|
+
case "image": {
|
|
2940
|
+
const u = str3(obj2(data["external"])["url"]);
|
|
2941
|
+
rendered = u ? `` : "";
|
|
2942
|
+
break;
|
|
2943
|
+
}
|
|
2944
|
+
case "paragraph":
|
|
2945
|
+
rendered = richTextToMarkdown(rt);
|
|
2946
|
+
break;
|
|
2947
|
+
default:
|
|
2948
|
+
rendered = richTextToMarkdown(rt);
|
|
2949
|
+
}
|
|
2950
|
+
if (!rendered && type !== "divider") continue;
|
|
2951
|
+
const sep = parts.length === 0 ? "" : isList(type) && isList(prevType) ? "\n" : "\n\n";
|
|
2952
|
+
parts.push(sep + rendered);
|
|
2953
|
+
prevType = type;
|
|
2954
|
+
}
|
|
2955
|
+
return parts.join("");
|
|
2956
|
+
}
|
|
2957
|
+
function chunkChildren(blocks, size = CHILDREN_MAX) {
|
|
2958
|
+
const out = [];
|
|
2959
|
+
for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
|
|
2960
|
+
return out.length ? out : [[]];
|
|
2961
|
+
}
|
|
2962
|
+
function artifactCategory(path) {
|
|
2963
|
+
if (path.startsWith("Brand/")) return "Brand";
|
|
2964
|
+
if (path.startsWith("Deploy/")) return "Deploy";
|
|
2965
|
+
if (path.startsWith("LandingPage/")) return "Landing";
|
|
2966
|
+
if (path.endsWith(".svg")) return "Infographic";
|
|
2967
|
+
if (path === "BuildPrompt.md") return "Build prompt";
|
|
2968
|
+
return "Doc";
|
|
2969
|
+
}
|
|
2970
|
+
var WIKI_AREAS = [
|
|
2971
|
+
{ key: "big-idea", title: "The Big Idea", emoji: "\u{1F4A1}" },
|
|
2972
|
+
{ key: "business-model", title: "Business Model", emoji: "\u{1F4B0}" },
|
|
2973
|
+
{ key: "brand", title: "Brand", emoji: "\u{1F3A8}" },
|
|
2974
|
+
{ key: "market-research", title: "Market Research", emoji: "\u{1F4CA}" },
|
|
2975
|
+
{ key: "strategy", title: "Strategy", emoji: "\u{1F4C8}" },
|
|
2976
|
+
{ key: "product", title: "Product (PRD)", emoji: "\u{1F4CB}" },
|
|
2977
|
+
{ key: "compliance", title: "Compliance & Tax", emoji: "\u2696\uFE0F" },
|
|
2978
|
+
{ key: "decision-science", title: "Decision Science", emoji: "\u{1F9ED}" },
|
|
2979
|
+
{ key: "library", title: "Library", emoji: "\u{1F4DA}" }
|
|
2980
|
+
];
|
|
2981
|
+
function buildWikiPlan(state, opts = {}) {
|
|
2982
|
+
const answers = obj2(state.answers);
|
|
2983
|
+
const brand = state.brand && typeof state.brand === "object" ? state.brand : null;
|
|
2984
|
+
const guide = brand?.guide || null;
|
|
2985
|
+
const grill = state.grill && typeof state.grill === "object" ? state.grill : null;
|
|
2986
|
+
const swot = state.swot && typeof state.swot === "object" ? state.swot : null;
|
|
2987
|
+
const prd = obj2(state.prd);
|
|
2988
|
+
const bizModel = obj2(state.bizModel);
|
|
2989
|
+
const research = obj2(state.research);
|
|
2990
|
+
const assumptions = bizModel["assumptions"] || null;
|
|
2991
|
+
const brandName = str3(guide?.brandName) || "Your Product";
|
|
2992
|
+
const oneLiner = str3(answers["what-statement"]) || str3(answers["pitch"]) || str3(guide?.positioning);
|
|
2993
|
+
const overview = [];
|
|
2994
|
+
if (oneLiner) overview.push(callout(oneLiner, "\u{1F4A1}"));
|
|
2995
|
+
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."));
|
|
2996
|
+
const areaBlocks = {};
|
|
2997
|
+
{
|
|
2998
|
+
const b = [];
|
|
2999
|
+
if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "\u{1F3AF}"));
|
|
3000
|
+
if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
|
|
3001
|
+
const press = str3(answers["press-release"]);
|
|
3002
|
+
if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
|
|
3003
|
+
areaBlocks["big-idea"] = b;
|
|
3004
|
+
}
|
|
3005
|
+
{
|
|
3006
|
+
const b = [];
|
|
3007
|
+
const proj = computeProjection(assumptions);
|
|
3008
|
+
if (proj && assumptions) {
|
|
3009
|
+
const s = proj.summary;
|
|
3010
|
+
const cur = assumptions.currency || "USD";
|
|
3011
|
+
b.push(heading(2, "The numbers"));
|
|
3012
|
+
b.push(callout(
|
|
3013
|
+
`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)}.`,
|
|
3014
|
+
"\u{1F4B0}"
|
|
3015
|
+
));
|
|
3016
|
+
const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
|
|
3017
|
+
str3(p.name) || p.id,
|
|
3018
|
+
str3(p.role),
|
|
3019
|
+
String(p.startingCount ?? ""),
|
|
3020
|
+
String(p.monthlyNewBase ?? ""),
|
|
3021
|
+
`${p.monthlyChurnPct ?? 0}%`,
|
|
3022
|
+
fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur),
|
|
3023
|
+
fmtMoney(Number(p.cacPerUnit) || 0, cur)
|
|
3024
|
+
]);
|
|
3025
|
+
b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
|
|
3026
|
+
if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
|
|
3027
|
+
}
|
|
3028
|
+
areaBlocks["business-model"] = b;
|
|
3029
|
+
}
|
|
3030
|
+
{
|
|
3031
|
+
const b = [];
|
|
3032
|
+
if (guide) {
|
|
3033
|
+
b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
|
|
3034
|
+
const pal = (guide.palette || []).filter((p) => p?.hex);
|
|
3035
|
+
if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str3(p.name), str3(p.hex), str3(p.role)])));
|
|
3036
|
+
}
|
|
3037
|
+
areaBlocks["brand"] = b;
|
|
3038
|
+
}
|
|
3039
|
+
{
|
|
3040
|
+
const b = [];
|
|
3041
|
+
const brief = str3(research["brief"]);
|
|
3042
|
+
if (brief) {
|
|
3043
|
+
const desc = str3(research["description"]);
|
|
3044
|
+
if (desc) b.push(callout(desc, "\u{1F4CA}"));
|
|
3045
|
+
b.push(...markdownToBlocks(brief));
|
|
3046
|
+
}
|
|
3047
|
+
areaBlocks["market-research"] = b;
|
|
3048
|
+
}
|
|
3049
|
+
{
|
|
3050
|
+
const b = [];
|
|
3051
|
+
const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
|
|
3052
|
+
if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
|
|
3053
|
+
areaBlocks["strategy"] = b;
|
|
3054
|
+
}
|
|
3055
|
+
{
|
|
3056
|
+
const b = [];
|
|
3057
|
+
const prdMd = str3(prd["markdown"]);
|
|
3058
|
+
if (prdMd) b.push(...markdownToBlocks(prdMd));
|
|
3059
|
+
if (grill?.requirements?.length) {
|
|
3060
|
+
b.push(divider());
|
|
3061
|
+
b.push(...markdownToBlocks(requirementsMarkdown(grill)));
|
|
3062
|
+
}
|
|
3063
|
+
if (grill?.glossary?.length) {
|
|
3064
|
+
b.push(divider());
|
|
3065
|
+
b.push(...markdownToBlocks(glossaryMarkdown(grill)));
|
|
3066
|
+
}
|
|
3067
|
+
areaBlocks["product"] = b;
|
|
3068
|
+
}
|
|
3069
|
+
{
|
|
3070
|
+
const b = [];
|
|
3071
|
+
const report = str3(obj2(state.governance)["report"]);
|
|
3072
|
+
if (report.trim()) b.push(...markdownToBlocks(report));
|
|
3073
|
+
areaBlocks["compliance"] = b;
|
|
3074
|
+
}
|
|
3075
|
+
{
|
|
3076
|
+
const b = [];
|
|
3077
|
+
const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str3(answers[k]).trim());
|
|
3078
|
+
if (dq.length) {
|
|
3079
|
+
b.push(heading(2, "Decision records"));
|
|
3080
|
+
for (const k of dq) b.push(bullet(`**${k}**: ${str3(answers[k])}`));
|
|
3081
|
+
}
|
|
3082
|
+
areaBlocks["decision-science"] = b;
|
|
3083
|
+
}
|
|
3084
|
+
areaBlocks["library"] = [];
|
|
3085
|
+
const areas = WIKI_AREAS.map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] })).filter((a) => a.blocks.length > 0);
|
|
3086
|
+
const urls = opts.urls || {};
|
|
3087
|
+
const files = opts.manifest?.files || [];
|
|
3088
|
+
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] } : {} }));
|
|
3089
|
+
return { title: `${brandName} workspace`, icon: "\u{1F680}", overview, areas, artifacts };
|
|
3090
|
+
}
|
|
3091
|
+
function wikiPlanToMarkdown(plan) {
|
|
3092
|
+
return {
|
|
3093
|
+
title: plan.title,
|
|
3094
|
+
icon: plan.icon,
|
|
3095
|
+
overview: blocksToMarkdown(plan.overview),
|
|
3096
|
+
areas: plan.areas.map((a) => ({ key: a.key, title: a.title, emoji: a.emoji, markdown: blocksToMarkdown(a.blocks) })),
|
|
3097
|
+
artifacts: plan.artifacts
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// core/notion-io.ts
|
|
3102
|
+
var NOTION_API = "https://api.notion.com/v1";
|
|
3103
|
+
var NOTION_VERSION = "2022-06-28";
|
|
3104
|
+
var MIN_INTERVAL_MS = 350;
|
|
3105
|
+
var TOKEN_ENVS = ["THOUGHT_LAYER_NOTION_TOKEN", "NOTION_TOKEN"];
|
|
3106
|
+
var ok3 = (message, details = {}) => ({ ok: true, message, details });
|
|
3107
|
+
var fail3 = (message, details = {}) => ({ ok: false, message, details });
|
|
3108
|
+
function notionConfigPath() {
|
|
3109
|
+
return process.env["THOUGHT_LAYER_NOTION_CONFIG"] || join7(homedir2(), ".thought-layer", "notion.json");
|
|
3110
|
+
}
|
|
3111
|
+
function loadNotionConfig() {
|
|
3112
|
+
const p = notionConfigPath();
|
|
3113
|
+
if (!existsSync6(p)) return { schema: 1, sessions: {} };
|
|
3114
|
+
try {
|
|
3115
|
+
const raw = JSON.parse(readFileSync5(p, "utf8"));
|
|
3116
|
+
const sessions = raw["sessions"] && typeof raw["sessions"] === "object" ? raw["sessions"] : {};
|
|
3117
|
+
return { schema: 1, sessions };
|
|
3118
|
+
} catch {
|
|
3119
|
+
return { schema: 1, sessions: {} };
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
function saveNotionConfig(cfg) {
|
|
3123
|
+
const p = notionConfigPath();
|
|
3124
|
+
mkdirSync6(dirname6(p), { recursive: true });
|
|
3125
|
+
writeFileSync7(p, JSON.stringify(cfg, null, 2) + "\n");
|
|
3126
|
+
}
|
|
3127
|
+
function pageIdFromInput(s) {
|
|
3128
|
+
const t = String(s || "").trim();
|
|
3129
|
+
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}/);
|
|
3130
|
+
if (dashed) return dashed[0].toLowerCase();
|
|
3131
|
+
const runs = t.match(/[0-9a-fA-F]{32}/g);
|
|
3132
|
+
if (!runs || !runs.length) return null;
|
|
3133
|
+
const id = runs[runs.length - 1].toLowerCase();
|
|
3134
|
+
return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
|
|
3135
|
+
}
|
|
3136
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3137
|
+
var Notion = class {
|
|
3138
|
+
constructor(token) {
|
|
3139
|
+
this.token = token;
|
|
3140
|
+
}
|
|
3141
|
+
token;
|
|
3142
|
+
last = 0;
|
|
3143
|
+
async call(method, path, body) {
|
|
3144
|
+
for (let attempt = 0; ; attempt++) {
|
|
3145
|
+
const wait = MIN_INTERVAL_MS - (Date.now() - this.last);
|
|
3146
|
+
if (wait > 0) await sleep(wait);
|
|
3147
|
+
this.last = Date.now();
|
|
3148
|
+
const res = await fetch(`${NOTION_API}${path}`, {
|
|
3149
|
+
method,
|
|
3150
|
+
headers: {
|
|
3151
|
+
Authorization: `Bearer ${this.token}`,
|
|
3152
|
+
"Notion-Version": NOTION_VERSION,
|
|
3153
|
+
"Content-Type": "application/json"
|
|
3154
|
+
},
|
|
3155
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
3156
|
+
});
|
|
3157
|
+
if (res.status === 429 && attempt < 4) {
|
|
3158
|
+
const retry = Number(res.headers.get("Retry-After")) || Math.pow(2, attempt);
|
|
3159
|
+
await sleep(retry * 1e3);
|
|
3160
|
+
continue;
|
|
3161
|
+
}
|
|
3162
|
+
let json = {};
|
|
3163
|
+
try {
|
|
3164
|
+
json = await res.json();
|
|
3165
|
+
} catch {
|
|
3166
|
+
}
|
|
3167
|
+
return { status: res.status, json };
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
async pageExists(id) {
|
|
3171
|
+
const r = await this.call("GET", `/pages/${id}`);
|
|
3172
|
+
return r.status === 200 && r.json["archived"] !== true;
|
|
3173
|
+
}
|
|
3174
|
+
async createPage(parentPageId, title, emoji) {
|
|
3175
|
+
const r = await this.call("POST", "/pages", {
|
|
3176
|
+
parent: { page_id: parentPageId },
|
|
3177
|
+
...emoji ? { icon: { type: "emoji", emoji } } : {},
|
|
3178
|
+
properties: { title: { title: [{ text: { content: title } }] } }
|
|
3179
|
+
});
|
|
3180
|
+
if (r.status !== 200) throw new Error(notionErr("create page", r));
|
|
3181
|
+
return String(r.json["id"]);
|
|
3182
|
+
}
|
|
3183
|
+
// Replace a page's content: delete every existing child, then append the new
|
|
3184
|
+
// blocks in <=100-block batches.
|
|
3185
|
+
async replaceChildren(pageId, blocks) {
|
|
3186
|
+
let cursor;
|
|
3187
|
+
do {
|
|
3188
|
+
const q = cursor ? `?start_cursor=${cursor}&page_size=100` : "?page_size=100";
|
|
3189
|
+
const r = await this.call("GET", `/blocks/${pageId}/children${q}`);
|
|
3190
|
+
if (r.status !== 200) break;
|
|
3191
|
+
for (const b of r.json["results"] || []) await this.call("DELETE", `/blocks/${b.id}`);
|
|
3192
|
+
cursor = r.json["has_more"] ? String(r.json["next_cursor"]) : void 0;
|
|
3193
|
+
} while (cursor);
|
|
3194
|
+
for (const batch of chunkChildren(blocks)) {
|
|
3195
|
+
if (!batch.length) continue;
|
|
3196
|
+
const r = await this.call("PATCH", `/blocks/${pageId}/children`, { children: batch });
|
|
3197
|
+
if (r.status !== 200) throw new Error(notionErr("append blocks", r));
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
async createArtifactsDb(parentPageId, artifacts) {
|
|
3201
|
+
const cats = Array.from(new Set(artifacts.map((a) => a.category)));
|
|
3202
|
+
const r = await this.call("POST", "/databases", {
|
|
3203
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
3204
|
+
title: [{ text: { content: "Artifacts" } }],
|
|
3205
|
+
icon: { type: "emoji", emoji: "\u{1F4CE}" },
|
|
3206
|
+
properties: {
|
|
3207
|
+
Name: { title: {} },
|
|
3208
|
+
Category: { select: { options: cats.map((c) => ({ name: c })) } },
|
|
3209
|
+
Size: { rich_text: {} },
|
|
3210
|
+
Link: { url: {} }
|
|
3211
|
+
}
|
|
3212
|
+
});
|
|
3213
|
+
if (r.status !== 200) throw new Error(notionErr("create database", r));
|
|
3214
|
+
return String(r.json["id"]);
|
|
3215
|
+
}
|
|
3216
|
+
async addArtifactRow(dbId, a) {
|
|
3217
|
+
await this.call("POST", "/pages", {
|
|
3218
|
+
parent: { database_id: dbId },
|
|
3219
|
+
properties: {
|
|
3220
|
+
Name: { title: [{ text: { content: a.name } }] },
|
|
3221
|
+
Category: { select: { name: a.category } },
|
|
3222
|
+
Size: { rich_text: [{ text: { content: humanSize(a.bytes) } }] },
|
|
3223
|
+
...a.url ? { Link: { url: a.url } } : {}
|
|
3224
|
+
}
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
};
|
|
3228
|
+
function notionErr(what, r) {
|
|
3229
|
+
const msg = String(r.json["message"] || "").slice(0, 200);
|
|
3230
|
+
return `Notion ${what} failed (${r.status})${msg ? `: ${msg}` : ""}`;
|
|
3231
|
+
}
|
|
3232
|
+
function humanSize(bytes) {
|
|
3233
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3234
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
3235
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3236
|
+
}
|
|
3237
|
+
async function runWiki(opts) {
|
|
3238
|
+
try {
|
|
3239
|
+
const cfg = loadConfig();
|
|
3240
|
+
const { cloneDir, ws } = resolveWorkspace(opts, cfg);
|
|
3241
|
+
if (!isGitRepo(cloneDir)) {
|
|
3242
|
+
return fail3(`No sessions workspace at ${cloneDir}. Run tl sync init then tl artifacts before building the wiki.`, { cloneDir });
|
|
3243
|
+
}
|
|
3244
|
+
const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
|
|
3245
|
+
if (!slug) return fail3("Name the session to publish: tl wiki --name <name>.");
|
|
3246
|
+
const sessionPath = join7(cloneDir, STATE_DIR, `${slug}.json`);
|
|
3247
|
+
const useExplicit = !!(opts.path && opts.path.trim() || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
|
|
3248
|
+
const loaded = loadStateFile(useExplicit ? opts.path : existsSync6(sessionPath) ? sessionPath : opts.path);
|
|
3249
|
+
if (!loaded.exists) return fail3(`No session "${slug}" found (looked at ${loaded.path}). Save it first with tl sync save --name ${slug}.`, { cloneDir });
|
|
3250
|
+
const artifactsDir = join7(cloneDir, "artifacts", slug);
|
|
3251
|
+
let manifest = null;
|
|
3252
|
+
const manifestPath = join7(artifactsDir, "artifacts.json");
|
|
3253
|
+
if (existsSync6(manifestPath)) {
|
|
3254
|
+
try {
|
|
3255
|
+
manifest = JSON.parse(readFileSync5(manifestPath, "utf8"));
|
|
3256
|
+
} catch {
|
|
3257
|
+
manifest = null;
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || ws?.defaultBranch || "main";
|
|
3261
|
+
const ownerName = repoOwnerName(ws?.repo || "");
|
|
3262
|
+
const urls = {};
|
|
3263
|
+
if (ownerName && manifest) {
|
|
3264
|
+
const base = `https://github.com/${ownerName}/blob/${branch}/artifacts/${slug}`;
|
|
3265
|
+
for (const f of manifest.files) urls[f.path] = `${base}/${f.path.split("/").map(encodeURIComponent).join("/")}`;
|
|
3266
|
+
}
|
|
3267
|
+
const plan = buildWikiPlan(loaded.state, { manifest, urls });
|
|
3268
|
+
const blockCount = plan.overview.length + plan.areas.reduce((n, a) => n + a.blocks.length, 0);
|
|
3269
|
+
if (opts.dryRun) {
|
|
3270
|
+
const areaList = plan.areas.map((a) => `${a.emoji} ${a.title} (${a.blocks.length} blocks)`).join(", ");
|
|
3271
|
+
return ok3(
|
|
3272
|
+
`Dry run for "${plan.title}": ${plan.areas.length} area page(s), ${blockCount} blocks total, ${plan.artifacts.length} artifact(s) in the database.
|
|
3273
|
+
Areas: ${areaList || "(none with content yet)"}.${manifest ? "" : "\nNo delivered artifacts found; run tl artifacts first to populate the Artifacts database with GitHub links."}`,
|
|
3274
|
+
{ title: plan.title, areas: plan.areas.map((a) => ({ key: a.key, blocks: a.blocks.length })), blockCount, artifacts: plan.artifacts.length, delivered: !!manifest }
|
|
3275
|
+
);
|
|
3276
|
+
}
|
|
3277
|
+
if (opts.emitPlan) {
|
|
3278
|
+
const pm = wikiPlanToMarkdown(plan);
|
|
3279
|
+
const areaList = pm.areas.map((a) => `${a.emoji} ${a.title}`).join(", ");
|
|
3280
|
+
return ok3(
|
|
3281
|
+
`Wiki plan for "${plan.title}" ready (no Notion call, no token needed): a root page, ${pm.areas.length} child page(s), ${blockCount} blocks, ${pm.artifacts.length} artifact(s). Create them with your connected Notion MCP: a root page "${plan.title}", one child page per area (each area's markdown is in the plan), and an Artifacts database with the listed files.${manifest ? "" : " No delivered artifacts were found, so the Artifacts list is empty; run tl artifacts first to add GitHub links."}
|
|
3282
|
+
Areas: ${areaList || "(none with content yet)"}.`,
|
|
3283
|
+
{ plan: pm, delivered: !!manifest }
|
|
3284
|
+
);
|
|
3285
|
+
}
|
|
3286
|
+
const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
|
|
3287
|
+
if (!token) {
|
|
3288
|
+
return fail3(
|
|
3289
|
+
"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.",
|
|
3290
|
+
{ needs: "token" }
|
|
3291
|
+
);
|
|
3292
|
+
}
|
|
3293
|
+
const ncfg = loadNotionConfig();
|
|
3294
|
+
const entry = ncfg.sessions[slug] || {};
|
|
3295
|
+
const notion = new Notion(token);
|
|
3296
|
+
let rootId = opts.replace ? "" : entry.rootPageId || "";
|
|
3297
|
+
if (rootId && !await notion.pageExists(rootId)) rootId = "";
|
|
3298
|
+
if (!rootId) {
|
|
3299
|
+
const parentInput = opts.parentPage || process.env["THOUGHT_LAYER_NOTION_PARENT"] || "";
|
|
3300
|
+
const parentId = pageIdFromInput(parentInput);
|
|
3301
|
+
if (!parentId) {
|
|
3302
|
+
return fail3(
|
|
3303
|
+
"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>.",
|
|
3304
|
+
{ needs: "parent-page" }
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
if (!await notion.pageExists(parentId)) {
|
|
3308
|
+
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" });
|
|
3309
|
+
}
|
|
3310
|
+
rootId = await notion.createPage(parentId, plan.title, plan.icon);
|
|
3311
|
+
entry.areaPageIds = {};
|
|
3312
|
+
entry.artifactsDbId = void 0;
|
|
3313
|
+
}
|
|
3314
|
+
await notion.replaceChildren(rootId, plan.overview);
|
|
3315
|
+
const areaIds = entry.areaPageIds || {};
|
|
3316
|
+
for (const area of plan.areas) {
|
|
3317
|
+
let pid = areaIds[area.key] || "";
|
|
3318
|
+
if (pid && !await notion.pageExists(pid)) pid = "";
|
|
3319
|
+
if (!pid) pid = await notion.createPage(rootId, `${area.emoji} ${area.title}`, area.emoji);
|
|
3320
|
+
areaIds[area.key] = pid;
|
|
3321
|
+
await notion.replaceChildren(pid, area.blocks);
|
|
3322
|
+
}
|
|
3323
|
+
let dbId = entry.artifactsDbId || "";
|
|
3324
|
+
if (plan.artifacts.length && (!dbId || opts.replace)) {
|
|
3325
|
+
dbId = await notion.createArtifactsDb(rootId, plan.artifacts);
|
|
3326
|
+
for (const a of plan.artifacts) await notion.addArtifactRow(dbId, a);
|
|
3327
|
+
}
|
|
3328
|
+
ncfg.sessions[slug] = { rootPageId: rootId, areaPageIds: areaIds, artifactsDbId: dbId || void 0, updatedAt: Date.now() };
|
|
3329
|
+
saveNotionConfig(ncfg);
|
|
3330
|
+
const rootUrl = `https://www.notion.so/${rootId.replace(/-/g, "")}`;
|
|
3331
|
+
return ok3(
|
|
3332
|
+
`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."}
|
|
3333
|
+
Open it: ${rootUrl}`,
|
|
3334
|
+
{ title: plan.title, rootPageId: rootId, rootUrl, areas: plan.areas.length, blockCount, artifacts: plan.artifacts.length }
|
|
3335
|
+
);
|
|
3336
|
+
} catch (e) {
|
|
3337
|
+
return fail3(`tl_wiki error: ${e.message}`);
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
|
|
1996
3341
|
// bin/tl.ts
|
|
1997
3342
|
var HELP = `tl - read/write a portable Thought Layer state file (default: .thought-layer/state.json)
|
|
1998
3343
|
|
|
@@ -2003,6 +3348,11 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
|
|
|
2003
3348
|
[--static-only] [--provision-db] [--apply-schema] when build.json has a backend: ships functions+env by default; flags opt out or add Neon provision/schema
|
|
2004
3349
|
tl sync <init|save|list|open|pull|push|status> store/sync your session files in your own private GitHub repo
|
|
2005
3350
|
[--repo owner/name] [--name x] [--dir p] [--workspace w] [--message m] [--no-push]
|
|
3351
|
+
tl artifacts [--name x] [--workspace w] deliver the full asset bundle (PRD, brand, infographics, landing, deploy rules) to your sessions repo
|
|
3352
|
+
[--no-push] [--no-deliver] [--domain x.com] [--founder "Name"]
|
|
3353
|
+
tl wiki [--parent-page id|url] [--name x] build/refresh a private Notion wiki from the session + delivered artifacts
|
|
3354
|
+
[--workspace w] [--replace] [--dry-run] token path: set THOUGHT_LAYER_NOTION_TOKEN and share a Notion page with your integration
|
|
3355
|
+
[--emit-plan] or emit the wiki as per-area markdown to build via a connected Notion MCP (no token)
|
|
2006
3356
|
tl export [path] handoff check
|
|
2007
3357
|
tl answer <qId> <value> [path] record an answer
|
|
2008
3358
|
tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
|
|
@@ -2037,7 +3387,7 @@ function parseArgs(argv) {
|
|
|
2037
3387
|
function readData(flags) {
|
|
2038
3388
|
const d = flags["data"];
|
|
2039
3389
|
if (d === void 0) return void 0;
|
|
2040
|
-
const raw = d === "-" || d === true ?
|
|
3390
|
+
const raw = d === "-" || d === true ? readFileSync6(0, "utf8") : String(d);
|
|
2041
3391
|
try {
|
|
2042
3392
|
return JSON.parse(raw);
|
|
2043
3393
|
} catch {
|
|
@@ -2131,6 +3481,42 @@ function main() {
|
|
|
2131
3481
|
});
|
|
2132
3482
|
return;
|
|
2133
3483
|
}
|
|
3484
|
+
if (args[0] === "artifacts") {
|
|
3485
|
+
const r2 = runArtifacts(
|
|
3486
|
+
{
|
|
3487
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
3488
|
+
name: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
3489
|
+
workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
|
|
3490
|
+
dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
|
|
3491
|
+
message: typeof flags["message"] === "string" ? flags["message"] : void 0,
|
|
3492
|
+
noPush: flags["no-push"] === true,
|
|
3493
|
+
noDeliver: flags["no-deliver"] === true,
|
|
3494
|
+
domain: typeof flags["domain"] === "string" ? flags["domain"] : void 0,
|
|
3495
|
+
founderName: typeof flags["founder"] === "string" ? flags["founder"] : void 0
|
|
3496
|
+
},
|
|
3497
|
+
{ generatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
3498
|
+
);
|
|
3499
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
3500
|
+
else console.log(r2.message);
|
|
3501
|
+
process.exit(r2.ok ? 0 : 1);
|
|
3502
|
+
}
|
|
3503
|
+
if (args[0] === "wiki") {
|
|
3504
|
+
runWiki({
|
|
3505
|
+
path: typeof flags["path"] === "string" ? flags["path"] : void 0,
|
|
3506
|
+
name: typeof flags["name"] === "string" ? flags["name"] : void 0,
|
|
3507
|
+
workspace: typeof flags["workspace"] === "string" ? flags["workspace"] : void 0,
|
|
3508
|
+
dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
|
|
3509
|
+
parentPage: typeof flags["parent-page"] === "string" ? flags["parent-page"] : void 0,
|
|
3510
|
+
replace: flags["replace"] === true,
|
|
3511
|
+
dryRun: flags["dry-run"] === true,
|
|
3512
|
+
emitPlan: flags["emit-plan"] === true
|
|
3513
|
+
}).then((r2) => {
|
|
3514
|
+
if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
|
|
3515
|
+
else console.log(r2.message);
|
|
3516
|
+
process.exit(r2.ok ? 0 : 1);
|
|
3517
|
+
});
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
2134
3520
|
let payload;
|
|
2135
3521
|
try {
|
|
2136
3522
|
payload = buildOp(args, flags);
|