@hobocode/thought-layer 0.6.1 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/notion.ts ADDED
@@ -0,0 +1,312 @@
1
+ // Pure mapping from a ProgressState (+ the delivered-artifacts manifest) to a
2
+ // Notion "wiki" plan: a root page, one child page per workflow area, and an
3
+ // Artifacts database. No fetch, no fs - the notion-io layer turns this plan into
4
+ // API calls. Kept pure so the block construction and the API-limit chunking are
5
+ // unit-testable without a token.
6
+ //
7
+ // Notion API limits this encodes: rich_text content max 2000 chars (chunkRichText),
8
+ // at most 100 children per append (chunkChildren), and at most 2 levels of block
9
+ // nesting per create/append (withinDepth guards it; we keep tables/toggles to one
10
+ // nested level). Notion has no upsert, so the io layer find-or-creates by stored id.
11
+
12
+ import { computeProjection, fmtMoney, type Assumptions } from "./model.ts";
13
+ import {
14
+ brandGuideMarkdown, requirementsMarkdown, glossaryMarkdown, swotMarkdown,
15
+ type Brand, type Grill, type Swot, type ArtifactManifest,
16
+ } from "./artifacts.ts";
17
+ import type { ProgressState } from "./progress.ts";
18
+
19
+ // Notion's free-tier single-file upload ceiling. Larger files link to GitHub.
20
+ export const NOTION_FREE_FILE_LIMIT = 5 * 1024 * 1024;
21
+ const RICH_TEXT_MAX = 2000;
22
+ const CHILDREN_MAX = 100;
23
+
24
+ export type Annotations = Partial<Record<"bold" | "italic" | "strikethrough" | "underline" | "code", boolean>> & { color?: string };
25
+ export interface RichText { type: "text"; text: { content: string; link?: { url: string } | null }; annotations?: Annotations; }
26
+ export type Block = Record<string, unknown>;
27
+
28
+ const obj = (v: unknown): Record<string, unknown> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
29
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
30
+
31
+ // ---- rich text (with the 2000-char split) ------------------------------------
32
+
33
+ const rtSeg = (content: string, ann?: Annotations, link?: string): RichText => ({
34
+ type: "text",
35
+ text: { content, ...(link ? { link: { url: link } } : {}) },
36
+ ...(ann ? { annotations: ann } : {}),
37
+ });
38
+
39
+ // Split a string into rich_text segments no longer than 2000 chars each.
40
+ export function chunkRichText(text: string, ann?: Annotations, link?: string): RichText[] {
41
+ const s = String(text ?? "");
42
+ if (s.length <= RICH_TEXT_MAX) return [rtSeg(s, ann, link)];
43
+ const out: RichText[] = [];
44
+ for (let i = 0; i < s.length; i += RICH_TEXT_MAX) out.push(rtSeg(s.slice(i, i + RICH_TEXT_MAX), ann, link));
45
+ return out;
46
+ }
47
+
48
+ // Minimal inline markdown: **bold** and `code`. Everything else is plain text.
49
+ // Every segment is itself chunked to the 2000-char limit.
50
+ function inline(text: string): RichText[] {
51
+ const out: RichText[] = [];
52
+ const re = /\*\*([^*]+)\*\*|`([^`]+)`/g;
53
+ let last = 0;
54
+ let m: RegExpExecArray | null;
55
+ while ((m = re.exec(text))) {
56
+ if (m.index > last) out.push(...chunkRichText(text.slice(last, m.index)));
57
+ if (m[1] != null) out.push(...chunkRichText(m[1], { bold: true }));
58
+ else if (m[2] != null) out.push(...chunkRichText(m[2], { code: true }));
59
+ last = re.lastIndex;
60
+ }
61
+ if (last < text.length) out.push(...chunkRichText(text.slice(last)));
62
+ return out.length ? out : [rtSeg("")];
63
+ }
64
+
65
+ // ---- block builders ----------------------------------------------------------
66
+
67
+ const para = (text: string): Block => ({ object: "block", type: "paragraph", paragraph: { rich_text: inline(text) } });
68
+ const heading = (level: 1 | 2 | 3, text: string): Block => {
69
+ const t = `heading_${level}`;
70
+ return { object: "block", type: t, [t]: { rich_text: inline(text) } };
71
+ };
72
+ const bullet = (text: string): Block => ({ object: "block", type: "bulleted_list_item", bulleted_list_item: { rich_text: inline(text) } });
73
+ const numbered = (text: string): Block => ({ object: "block", type: "numbered_list_item", numbered_list_item: { rich_text: inline(text) } });
74
+ const quote = (text: string): Block => ({ object: "block", type: "quote", quote: { rich_text: inline(text) } });
75
+ const callout = (text: string, emoji = "💡"): Block => ({ object: "block", type: "callout", callout: { rich_text: inline(text), icon: { type: "emoji", emoji } } });
76
+ const divider = (): Block => ({ object: "block", type: "divider", divider: {} });
77
+ const codeBlock = (text: string, language = "plain text"): Block => ({ object: "block", type: "code", code: { rich_text: chunkRichText(text), language } });
78
+ export const bookmark = (url: string): Block => ({ object: "block", type: "bookmark", bookmark: { url } });
79
+ export const externalImage = (url: string): Block => ({ object: "block", type: "image", image: { type: "external", external: { url } } });
80
+
81
+ function table(headers: string[], rows: string[][]): Block {
82
+ const toRow = (cells: string[]): Block => ({ object: "block", type: "table_row", table_row: { cells: cells.map((c) => chunkRichText(c)) } });
83
+ return {
84
+ object: "block",
85
+ type: "table",
86
+ table: { table_width: headers.length, has_column_header: true, has_row_header: false, children: [toRow(headers), ...rows.map(toRow)] },
87
+ };
88
+ }
89
+
90
+ // ---- markdown -> blocks (the workhorse; all artifacts are markdown) ----------
91
+
92
+ export function markdownToBlocks(md: string): Block[] {
93
+ const lines = String(md ?? "").split("\n");
94
+ const out: Block[] = [];
95
+ let i = 0;
96
+ while (i < lines.length) {
97
+ const raw = lines[i] ?? "";
98
+ const t = raw.trim();
99
+ if (t.startsWith("```")) {
100
+ const buf: string[] = [];
101
+ i++;
102
+ while (i < lines.length && !(lines[i] ?? "").trim().startsWith("```")) { buf.push(lines[i] ?? ""); i++; }
103
+ i++; // skip closing fence
104
+ out.push(codeBlock(buf.join("\n")));
105
+ continue;
106
+ }
107
+ if (!t) { i++; continue; }
108
+ if (t === "---" || t === "***" || t === "___") { out.push(divider()); i++; continue; }
109
+ const mh = t.match(/^(#{1,6})\s+(.*)$/);
110
+ if (mh) { out.push(heading(Math.min(3, mh[1]!.length) as 1 | 2 | 3, mh[2]!)); i++; continue; }
111
+ if (t.startsWith("> ")) { out.push(quote(t.slice(2))); i++; continue; }
112
+ const mb = t.match(/^[-*]\s+(.*)$/);
113
+ if (mb) { out.push(bullet(mb[1]!)); i++; continue; }
114
+ const mn = t.match(/^\d+\.\s+(.*)$/);
115
+ if (mn) { out.push(numbered(mn[1]!)); i++; continue; }
116
+ out.push(para(t));
117
+ i++;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // ---- API-limit helpers (unit-tested) -----------------------------------------
123
+
124
+ // Split a children array into <=100-block append batches.
125
+ export function chunkChildren(blocks: Block[], size = CHILDREN_MAX): Block[][] {
126
+ const out: Block[][] = [];
127
+ for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
128
+ return out.length ? out : [[]];
129
+ }
130
+
131
+ // True when no block nests deeper than maxDepth levels (Notion allows 2 per
132
+ // create/append). We keep tables/toggles to a single nested level, so this holds.
133
+ export function withinDepth(blocks: Block[], maxDepth = 2): boolean {
134
+ const depth = (bs: Block[], d: number): number => {
135
+ let max = d;
136
+ for (const b of bs) {
137
+ const t = b["type"] as string;
138
+ const inner = (obj(b[t])["children"]) as Block[] | undefined;
139
+ if (Array.isArray(inner) && inner.length) max = Math.max(max, depth(inner, d + 1));
140
+ }
141
+ return max;
142
+ };
143
+ return depth(blocks, 1) <= maxDepth;
144
+ }
145
+
146
+ // ---- artifact references (link vs upload decision) ---------------------------
147
+
148
+ export function artifactCategory(path: string): string {
149
+ if (path.startsWith("Brand/")) return "Brand";
150
+ if (path.startsWith("Deploy/")) return "Deploy";
151
+ if (path.startsWith("LandingPage/")) return "Landing";
152
+ if (path.endsWith(".svg")) return "Infographic";
153
+ if (path === "BuildPrompt.md") return "Build prompt";
154
+ return "Doc";
155
+ }
156
+
157
+ // Decide how an artifact appears in the wiki: upload to Notion when small enough
158
+ // and an uploadable type, else link to its GitHub copy.
159
+ export function artifactRef(bytes: number, hasGithubUrl: boolean): "upload" | "link" {
160
+ return bytes <= NOTION_FREE_FILE_LIMIT && !hasGithubUrl ? "upload" : hasGithubUrl ? "link" : "upload";
161
+ }
162
+
163
+ // ---- the wiki plan -----------------------------------------------------------
164
+
165
+ export interface WikiArtifact { name: string; path: string; category: string; bytes: number; url?: string; }
166
+ export interface WikiArea { key: string; title: string; emoji: string; blocks: Block[]; }
167
+ export interface WikiPlan { title: string; icon: string; overview: Block[]; areas: WikiArea[]; artifacts: WikiArtifact[]; }
168
+
169
+ export interface WikiBuildOptions {
170
+ manifest?: ArtifactManifest | null;
171
+ urls?: Record<string, string>; // artifact relpath -> GitHub blob URL
172
+ }
173
+
174
+ // The 8 workflow areas, in order, with their emoji.
175
+ export const WIKI_AREAS: Array<{ key: string; title: string; emoji: string }> = [
176
+ { key: "big-idea", title: "The Big Idea", emoji: "💡" },
177
+ { key: "business-model", title: "Business Model", emoji: "💰" },
178
+ { key: "brand", title: "Brand", emoji: "🎨" },
179
+ { key: "market-research", title: "Market Research", emoji: "📊" },
180
+ { key: "strategy", title: "Strategy", emoji: "📈" },
181
+ { key: "product", title: "Product (PRD)", emoji: "📋" },
182
+ { key: "compliance", title: "Compliance & Tax", emoji: "⚖️" },
183
+ { key: "decision-science", title: "Decision Science", emoji: "🧭" },
184
+ { key: "library", title: "Library", emoji: "📚" },
185
+ ];
186
+
187
+ export function buildWikiPlan(state: ProgressState, opts: WikiBuildOptions = {}): WikiPlan {
188
+ const answers = obj(state.answers);
189
+ const brand = (state.brand && typeof state.brand === "object" ? state.brand : null) as Brand | null;
190
+ const guide = brand?.guide || null;
191
+ const grill = (state.grill && typeof state.grill === "object" ? state.grill : null) as Grill | null;
192
+ const swot = (state.swot && typeof state.swot === "object" ? state.swot : null) as Swot | null;
193
+ const prd = obj(state.prd);
194
+ const bizModel = obj(state.bizModel);
195
+ const research = obj(state.research);
196
+ const assumptions = (bizModel["assumptions"] || null) as Assumptions | null;
197
+ const brandName = str(guide?.brandName) || "Your Product";
198
+ const oneLiner = str(answers["what-statement"]) || str(answers["pitch"]) || str(guide?.positioning);
199
+
200
+ const overview: Block[] = [];
201
+ if (oneLiner) overview.push(callout(oneLiner, "💡"));
202
+ 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."));
203
+
204
+ const areaBlocks: Record<string, Block[]> = {};
205
+
206
+ // Big idea: the one-liner, positioning, and the press release if present.
207
+ {
208
+ const b: Block[] = [];
209
+ if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "🎯"));
210
+ if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
211
+ const press = str(answers["press-release"]);
212
+ if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
213
+ areaBlocks["big-idea"] = b;
214
+ }
215
+
216
+ // Business model: a parties table + the projection summary.
217
+ {
218
+ const b: Block[] = [];
219
+ const proj = computeProjection(assumptions);
220
+ if (proj && assumptions) {
221
+ const s = proj.summary;
222
+ const cur = assumptions.currency || "USD";
223
+ b.push(heading(2, "The numbers"));
224
+ b.push(callout(
225
+ `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)}.`,
226
+ "💰",
227
+ ));
228
+ const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
229
+ str(p.name) || p.id, str(p.role), String(p.startingCount ?? ""), String(p.monthlyNewBase ?? ""),
230
+ `${p.monthlyChurnPct ?? 0}%`, fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur), fmtMoney(Number(p.cacPerUnit) || 0, cur),
231
+ ]);
232
+ b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
233
+ if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
234
+ }
235
+ areaBlocks["business-model"] = b;
236
+ }
237
+
238
+ // Brand: the style guide rendered natively, plus a palette table.
239
+ {
240
+ const b: Block[] = [];
241
+ if (guide) {
242
+ b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
243
+ const pal = (guide.palette || []).filter((p) => p?.hex);
244
+ if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str(p.name), str(p.hex), str(p.role)])));
245
+ }
246
+ areaBlocks["brand"] = b;
247
+ }
248
+
249
+ // Market research.
250
+ {
251
+ const b: Block[] = [];
252
+ const brief = str(research["brief"]);
253
+ if (brief) {
254
+ const desc = str(research["description"]);
255
+ if (desc) b.push(callout(desc, "📊"));
256
+ b.push(...markdownToBlocks(brief));
257
+ }
258
+ areaBlocks["market-research"] = b;
259
+ }
260
+
261
+ // Strategy: the SWOT.
262
+ {
263
+ const b: Block[] = [];
264
+ const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
265
+ if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
266
+ areaBlocks["strategy"] = b;
267
+ }
268
+
269
+ // Product: the PRD, requirements, and glossary.
270
+ {
271
+ const b: Block[] = [];
272
+ const prdMd = str(prd["markdown"]);
273
+ if (prdMd) b.push(...markdownToBlocks(prdMd));
274
+ if (grill?.requirements?.length) { b.push(divider()); b.push(...markdownToBlocks(requirementsMarkdown(grill))); }
275
+ if (grill?.glossary?.length) { b.push(divider()); b.push(...markdownToBlocks(glossaryMarkdown(grill))); }
276
+ areaBlocks["product"] = b;
277
+ }
278
+
279
+ // Compliance: the researched GRC, licensing, and tax report (from the
280
+ // thought-layer-compliance skill). The report carries its own disclaimer.
281
+ {
282
+ const b: Block[] = [];
283
+ const report = str(obj(state.governance)["report"]);
284
+ if (report.trim()) b.push(...markdownToBlocks(report));
285
+ areaBlocks["compliance"] = b;
286
+ }
287
+
288
+ // Decision science: any dq-* / decision-* answers.
289
+ {
290
+ const b: Block[] = [];
291
+ const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str(answers[k]).trim());
292
+ if (dq.length) { b.push(heading(2, "Decision records")); for (const k of dq) b.push(bullet(`**${k}**: ${str(answers[k])}`)); }
293
+ areaBlocks["decision-science"] = b;
294
+ }
295
+
296
+ // Library: no structured data in state; left empty (the io layer skips empties).
297
+ areaBlocks["library"] = [];
298
+
299
+ const areas: WikiArea[] = WIKI_AREAS
300
+ .map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] }))
301
+ .filter((a) => a.blocks.length > 0);
302
+
303
+ // Artifacts for the database: skip the landing-page SEO sidecars (keep only
304
+ // index.html), keep everything else.
305
+ const urls = opts.urls || {};
306
+ const files = opts.manifest?.files || [];
307
+ const artifacts: WikiArtifact[] = files
308
+ .filter((f) => !(f.path.startsWith("LandingPage/") && f.path !== "LandingPage/index.html"))
309
+ .map((f) => ({ name: f.path, path: f.path, category: artifactCategory(f.path), bytes: f.bytes, ...(urls[f.path] ? { url: urls[f.path] } : {}) }));
310
+
311
+ return { title: `${brandName} workspace`, icon: "🚀", overview, areas, artifacts };
312
+ }
package/core/progress.ts CHANGED
@@ -21,10 +21,10 @@ export const PROGRESS_FORMAT = 2;
21
21
 
22
22
  export const KNOWN_STATE_KEYS = [
23
23
  "version", "answers", "feedback", "bizModel", "grill",
24
- "assets", "research", "swot", "prd", "naming", "brand", "kit",
24
+ "assets", "research", "swot", "prd", "naming", "brand", "governance", "kit",
25
25
  ] as const;
26
26
 
27
- export type ArtifactKey = "bizModel" | "grill" | "assets" | "research" | "swot" | "prd" | "naming" | "brand";
27
+ export type ArtifactKey = "bizModel" | "grill" | "assets" | "research" | "swot" | "prd" | "naming" | "brand" | "governance";
28
28
 
29
29
  export interface Writer { kind: "web" | "kit"; version?: string; ts: number; }
30
30
 
@@ -81,6 +81,7 @@ export interface ProgressState {
81
81
  prd: unknown;
82
82
  naming: unknown;
83
83
  brand: unknown;
84
+ governance: unknown;
84
85
  kit: KitNamespace | null;
85
86
  [extra: string]: unknown;
86
87
  }
@@ -99,7 +100,7 @@ export interface ProgressPayload {
99
100
  export function emptyState(): ProgressState {
100
101
  return {
101
102
  version: 2, answers: {}, feedback: {}, bizModel: null, grill: null,
102
- assets: null, research: null, swot: null, prd: null, naming: null, brand: null, kit: null,
103
+ assets: null, research: null, swot: null, prd: null, naming: null, brand: null, governance: null, kit: null,
103
104
  };
104
105
  }
105
106
 
@@ -132,6 +133,7 @@ export function parseProgress(text: string): ProgressPayload {
132
133
  prd: s["prd"] ?? null,
133
134
  naming: s["naming"] ?? null,
134
135
  brand: s["brand"] ?? null,
136
+ governance: s["governance"] ?? null,
135
137
  kit: (s["kit"] as KitNamespace | undefined) ?? null,
136
138
  };
137
139
  return {
@@ -149,7 +151,7 @@ export function parseProgress(text: string): ProgressPayload {
149
151
  export function buildProgress(state: Partial<ProgressState>, writer: Writer, exportedAt: string): ProgressPayload {
150
152
  const s = (state || {}) as Record<string, unknown>;
151
153
  const {
152
- answers, feedback, bizModel, grill, assets, research, swot, prd, naming, brand, kit,
154
+ answers, feedback, bizModel, grill, assets, research, swot, prd, naming, brand, governance, kit,
153
155
  version: _v, exportedAt: _ea, formatNewer: _fn, ...rest
154
156
  } = s;
155
157
  return {
@@ -170,6 +172,7 @@ export function buildProgress(state: Partial<ProgressState>, writer: Writer, exp
170
172
  prd: prd ?? null,
171
173
  naming: naming ?? null,
172
174
  brand: brand ?? null,
175
+ governance: governance ?? null,
173
176
  kit: (kit as KitNamespace | undefined) ?? null,
174
177
  ...rest,
175
178
  },
@@ -299,7 +302,7 @@ export function summarizeState(state: ProgressState): StateSummary {
299
302
  if (s === "green" || s === "yellow" || s === "red") byStatus[s]++;
300
303
  else byStatus.ungraded++;
301
304
  }
302
- const artifacts = (["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"] as const)
305
+ const artifacts = (["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"] as const)
303
306
  .filter((k) => state[k] != null);
304
307
  const kit = (state.kit && typeof state.kit === "object") ? state.kit : null;
305
308
  return {
package/core/state-ops.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  type ArtifactKey, type PersonaInput, type EndState, type KitCursor,
13
13
  } from "./progress.ts";
14
14
 
15
- export const ARTIFACT_KEYS: ArtifactKey[] = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand"];
15
+ export const ARTIFACT_KEYS: ArtifactKey[] = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"];
16
16
  const END_STATES: EndState[] = ["open", "pass", "setAside"];
17
17
 
18
18
  export interface StateOp {