@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/core/notion.ts ADDED
@@ -0,0 +1,438 @@
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
+ // ---- blocks -> markdown (the inverse; the agent-replayable wiki plan) ---------
123
+
124
+ // Reverse rich_text segments to inline markdown. Adjacent segments that share
125
+ // formatting are merged first (undoing the 2000-char split), so a long bold run
126
+ // renders as one **...** rather than several.
127
+ function richTextToMarkdown(rt: RichText[]): string {
128
+ if (!rt || !rt.length) return "";
129
+ const sig = (s: RichText): string => {
130
+ const a = s.annotations || {};
131
+ return `${a.bold ? 1 : 0}${a.italic ? 1 : 0}${a.code ? 1 : 0}|${s.text.link?.url || ""}`;
132
+ };
133
+ const merged: RichText[] = [];
134
+ for (const s of rt) {
135
+ const prev = merged[merged.length - 1];
136
+ if (prev && sig(prev) === sig(s)) prev.text.content += s.text.content;
137
+ else merged.push({ type: "text", text: { content: s.text.content, ...(s.text.link ? { link: s.text.link } : {}) }, ...(s.annotations ? { annotations: { ...s.annotations } } : {}) });
138
+ }
139
+ return merged
140
+ .map((seg) => {
141
+ const a = seg.annotations || {};
142
+ let t = seg.text.content;
143
+ if (a.code) t = "`" + t + "`";
144
+ else {
145
+ if (a.bold) t = `**${t}**`;
146
+ if (a.italic) t = `*${t}*`;
147
+ }
148
+ const link = seg.text.link?.url;
149
+ return link ? `[${t}](${link})` : t;
150
+ })
151
+ .join("");
152
+ }
153
+
154
+ // The plain (unformatted) concatenation of a rich_text run, for code blocks and
155
+ // table cells where inline markdown would be wrong.
156
+ function richTextToPlain(rt: RichText[]): string {
157
+ return (rt || []).map((s) => s.text.content).join("");
158
+ }
159
+
160
+ function tableToMarkdown(tbl: Record<string, unknown>): string {
161
+ const rows = ((tbl["children"] as Block[]) || []).map((r) =>
162
+ (((obj(r["table_row"])["cells"]) as RichText[][]) || []).map((c) =>
163
+ richTextToPlain(c).replace(/\|/g, "\\|").replace(/\n+/g, " ").trim(),
164
+ ),
165
+ );
166
+ if (!rows.length) return "";
167
+ const width = Math.max(...rows.map((r) => r.length));
168
+ const pad = (r: string[]): string[] => { const c = r.slice(); while (c.length < width) c.push(""); return c; };
169
+ const line = (cells: string[]): string => `| ${cells.join(" | ")} |`;
170
+ const [header, ...body] = rows;
171
+ const out = [line(pad(header!)), line(pad(header!).map(() => "---"))];
172
+ for (const r of body) out.push(line(pad(r)));
173
+ return out.join("\n");
174
+ }
175
+
176
+ // Render a flat block array back to GitHub-flavored markdown: the inverse of
177
+ // markdownToBlocks plus the synthesized blocks buildWikiPlan adds (callouts,
178
+ // tables). Consecutive list items stay tight; other blocks get a blank line.
179
+ export function blocksToMarkdown(blocks: Block[]): string {
180
+ const isList = (t: string): boolean => t === "bulleted_list_item" || t === "numbered_list_item";
181
+ const parts: string[] = [];
182
+ let prevType = "";
183
+ let numbered = 0;
184
+ for (const b of blocks || []) {
185
+ const type = str(b["type"]);
186
+ const data = obj(b[type]);
187
+ const rt = (data["rich_text"] as RichText[]) || [];
188
+ if (type !== "numbered_list_item") numbered = 0;
189
+ let rendered: string;
190
+ switch (type) {
191
+ case "heading_1": rendered = `# ${richTextToMarkdown(rt)}`; break;
192
+ case "heading_2": rendered = `## ${richTextToMarkdown(rt)}`; break;
193
+ case "heading_3": rendered = `### ${richTextToMarkdown(rt)}`; break;
194
+ case "bulleted_list_item": rendered = `- ${richTextToMarkdown(rt)}`; break;
195
+ case "numbered_list_item": rendered = `${++numbered}. ${richTextToMarkdown(rt)}`; break;
196
+ case "quote": rendered = `> ${richTextToMarkdown(rt)}`; break;
197
+ case "callout": {
198
+ const emoji = str(obj(data["icon"])["emoji"]);
199
+ rendered = `> ${emoji ? `${emoji} ` : ""}${richTextToMarkdown(rt)}`;
200
+ break;
201
+ }
202
+ case "divider": rendered = "---"; break;
203
+ case "code": {
204
+ const lang = str(data["language"]);
205
+ rendered = "```" + (lang && lang !== "plain text" ? lang : "") + "\n" + richTextToPlain(rt) + "\n```";
206
+ break;
207
+ }
208
+ case "table": rendered = tableToMarkdown(data); break;
209
+ case "bookmark": rendered = str(data["url"]); break;
210
+ case "image": { const u = str(obj(data["external"])["url"]); rendered = u ? `![](${u})` : ""; break; }
211
+ case "paragraph": rendered = richTextToMarkdown(rt); break;
212
+ default: rendered = richTextToMarkdown(rt);
213
+ }
214
+ if (!rendered && type !== "divider") continue;
215
+ const sep = parts.length === 0 ? "" : isList(type) && isList(prevType) ? "\n" : "\n\n";
216
+ parts.push(sep + rendered);
217
+ prevType = type;
218
+ }
219
+ return parts.join("");
220
+ }
221
+
222
+ // ---- API-limit helpers (unit-tested) -----------------------------------------
223
+
224
+ // Split a children array into <=100-block append batches.
225
+ export function chunkChildren(blocks: Block[], size = CHILDREN_MAX): Block[][] {
226
+ const out: Block[][] = [];
227
+ for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
228
+ return out.length ? out : [[]];
229
+ }
230
+
231
+ // True when no block nests deeper than maxDepth levels (Notion allows 2 per
232
+ // create/append). We keep tables/toggles to a single nested level, so this holds.
233
+ export function withinDepth(blocks: Block[], maxDepth = 2): boolean {
234
+ const depth = (bs: Block[], d: number): number => {
235
+ let max = d;
236
+ for (const b of bs) {
237
+ const t = b["type"] as string;
238
+ const inner = (obj(b[t])["children"]) as Block[] | undefined;
239
+ if (Array.isArray(inner) && inner.length) max = Math.max(max, depth(inner, d + 1));
240
+ }
241
+ return max;
242
+ };
243
+ return depth(blocks, 1) <= maxDepth;
244
+ }
245
+
246
+ // ---- artifact references (link vs upload decision) ---------------------------
247
+
248
+ export function artifactCategory(path: string): string {
249
+ if (path.startsWith("Brand/")) return "Brand";
250
+ if (path.startsWith("Deploy/")) return "Deploy";
251
+ if (path.startsWith("LandingPage/")) return "Landing";
252
+ if (path.endsWith(".svg")) return "Infographic";
253
+ if (path === "BuildPrompt.md") return "Build prompt";
254
+ return "Doc";
255
+ }
256
+
257
+ // Decide how an artifact appears in the wiki: upload to Notion when small enough
258
+ // and an uploadable type, else link to its GitHub copy.
259
+ export function artifactRef(bytes: number, hasGithubUrl: boolean): "upload" | "link" {
260
+ return bytes <= NOTION_FREE_FILE_LIMIT && !hasGithubUrl ? "upload" : hasGithubUrl ? "link" : "upload";
261
+ }
262
+
263
+ // ---- the wiki plan -----------------------------------------------------------
264
+
265
+ export interface WikiArtifact { name: string; path: string; category: string; bytes: number; url?: string; }
266
+ export interface WikiArea { key: string; title: string; emoji: string; blocks: Block[]; }
267
+ export interface WikiPlan { title: string; icon: string; overview: Block[]; areas: WikiArea[]; artifacts: WikiArtifact[]; }
268
+
269
+ export interface WikiBuildOptions {
270
+ manifest?: ArtifactManifest | null;
271
+ urls?: Record<string, string>; // artifact relpath -> GitHub blob URL
272
+ }
273
+
274
+ // The 8 workflow areas, in order, with their emoji.
275
+ export const WIKI_AREAS: Array<{ key: string; title: string; emoji: string }> = [
276
+ { key: "big-idea", title: "The Big Idea", emoji: "💡" },
277
+ { key: "business-model", title: "Business Model", emoji: "💰" },
278
+ { key: "brand", title: "Brand", emoji: "🎨" },
279
+ { key: "market-research", title: "Market Research", emoji: "📊" },
280
+ { key: "strategy", title: "Strategy", emoji: "📈" },
281
+ { key: "product", title: "Product (PRD)", emoji: "📋" },
282
+ { key: "compliance", title: "Compliance & Tax", emoji: "⚖️" },
283
+ { key: "decision-science", title: "Decision Science", emoji: "🧭" },
284
+ { key: "library", title: "Library", emoji: "📚" },
285
+ ];
286
+
287
+ export function buildWikiPlan(state: ProgressState, opts: WikiBuildOptions = {}): WikiPlan {
288
+ const answers = obj(state.answers);
289
+ const brand = (state.brand && typeof state.brand === "object" ? state.brand : null) as Brand | null;
290
+ const guide = brand?.guide || null;
291
+ const grill = (state.grill && typeof state.grill === "object" ? state.grill : null) as Grill | null;
292
+ const swot = (state.swot && typeof state.swot === "object" ? state.swot : null) as Swot | null;
293
+ const prd = obj(state.prd);
294
+ const bizModel = obj(state.bizModel);
295
+ const research = obj(state.research);
296
+ const assumptions = (bizModel["assumptions"] || null) as Assumptions | null;
297
+ const brandName = str(guide?.brandName) || "Your Product";
298
+ const oneLiner = str(answers["what-statement"]) || str(answers["pitch"]) || str(guide?.positioning);
299
+
300
+ const overview: Block[] = [];
301
+ if (oneLiner) overview.push(callout(oneLiner, "💡"));
302
+ 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."));
303
+
304
+ const areaBlocks: Record<string, Block[]> = {};
305
+
306
+ // Big idea: the one-liner, positioning, and the press release if present.
307
+ {
308
+ const b: Block[] = [];
309
+ if (oneLiner) b.push(heading(2, "What it is"), callout(oneLiner, "🎯"));
310
+ if (guide?.positioning) b.push(heading(2, "Who it is for"), para(guide.positioning));
311
+ const press = str(answers["press-release"]);
312
+ if (press) b.push(heading(2, "The press release"), ...markdownToBlocks(press));
313
+ areaBlocks["big-idea"] = b;
314
+ }
315
+
316
+ // Business model: a parties table + the projection summary.
317
+ {
318
+ const b: Block[] = [];
319
+ const proj = computeProjection(assumptions);
320
+ if (proj && assumptions) {
321
+ const s = proj.summary;
322
+ const cur = assumptions.currency || "USD";
323
+ b.push(heading(2, "The numbers"));
324
+ b.push(callout(
325
+ `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)}.`,
326
+ "💰",
327
+ ));
328
+ const rows = (assumptions.parties || []).slice(0, 12).map((p) => [
329
+ str(p.name) || p.id, str(p.role), String(p.startingCount ?? ""), String(p.monthlyNewBase ?? ""),
330
+ `${p.monthlyChurnPct ?? 0}%`, fmtMoney(Number(p.revenuePerUnitPerMonth) || 0, cur), fmtMoney(Number(p.cacPerUnit) || 0, cur),
331
+ ]);
332
+ b.push(heading(2, "Parties"), table(["Party", "Role", "Start", "New/mo", "Churn", "Rev/unit/mo", "CAC"], rows));
333
+ if (assumptions.narrative) b.push(heading(2, "Notes"), para(assumptions.narrative));
334
+ }
335
+ areaBlocks["business-model"] = b;
336
+ }
337
+
338
+ // Brand: the style guide rendered natively, plus a palette table.
339
+ {
340
+ const b: Block[] = [];
341
+ if (guide) {
342
+ b.push(...markdownToBlocks(brandGuideMarkdown(guide)));
343
+ const pal = (guide.palette || []).filter((p) => p?.hex);
344
+ if (pal.length) b.push(heading(2, "Palette"), table(["Color", "Hex", "Role"], pal.map((p) => [str(p.name), str(p.hex), str(p.role)])));
345
+ }
346
+ areaBlocks["brand"] = b;
347
+ }
348
+
349
+ // Market research.
350
+ {
351
+ const b: Block[] = [];
352
+ const brief = str(research["brief"]);
353
+ if (brief) {
354
+ const desc = str(research["description"]);
355
+ if (desc) b.push(callout(desc, "📊"));
356
+ b.push(...markdownToBlocks(brief));
357
+ }
358
+ areaBlocks["market-research"] = b;
359
+ }
360
+
361
+ // Strategy: the SWOT.
362
+ {
363
+ const b: Block[] = [];
364
+ const hasSwot = !!swot && Object.values(swot).some((v) => Array.isArray(v) && v.some((x) => x && String(x).trim()));
365
+ if (hasSwot) b.push(...markdownToBlocks(swotMarkdown(swot)));
366
+ areaBlocks["strategy"] = b;
367
+ }
368
+
369
+ // Product: the PRD, requirements, and glossary.
370
+ {
371
+ const b: Block[] = [];
372
+ const prdMd = str(prd["markdown"]);
373
+ if (prdMd) b.push(...markdownToBlocks(prdMd));
374
+ if (grill?.requirements?.length) { b.push(divider()); b.push(...markdownToBlocks(requirementsMarkdown(grill))); }
375
+ if (grill?.glossary?.length) { b.push(divider()); b.push(...markdownToBlocks(glossaryMarkdown(grill))); }
376
+ areaBlocks["product"] = b;
377
+ }
378
+
379
+ // Compliance: the researched GRC, licensing, and tax report (from the
380
+ // thought-layer-compliance skill). The report carries its own disclaimer.
381
+ {
382
+ const b: Block[] = [];
383
+ const report = str(obj(state.governance)["report"]);
384
+ if (report.trim()) b.push(...markdownToBlocks(report));
385
+ areaBlocks["compliance"] = b;
386
+ }
387
+
388
+ // Decision science: any dq-* / decision-* answers.
389
+ {
390
+ const b: Block[] = [];
391
+ const dq = Object.keys(answers).filter((k) => /^(dq|decision)/i.test(k) && str(answers[k]).trim());
392
+ if (dq.length) { b.push(heading(2, "Decision records")); for (const k of dq) b.push(bullet(`**${k}**: ${str(answers[k])}`)); }
393
+ areaBlocks["decision-science"] = b;
394
+ }
395
+
396
+ // Library: no structured data in state; left empty (the io layer skips empties).
397
+ areaBlocks["library"] = [];
398
+
399
+ const areas: WikiArea[] = WIKI_AREAS
400
+ .map((a) => ({ ...a, blocks: areaBlocks[a.key] || [] }))
401
+ .filter((a) => a.blocks.length > 0);
402
+
403
+ // Artifacts for the database: skip the landing-page SEO sidecars (keep only
404
+ // index.html), keep everything else.
405
+ const urls = opts.urls || {};
406
+ const files = opts.manifest?.files || [];
407
+ const artifacts: WikiArtifact[] = files
408
+ .filter((f) => !(f.path.startsWith("LandingPage/") && f.path !== "LandingPage/index.html"))
409
+ .map((f) => ({ name: f.path, path: f.path, category: artifactCategory(f.path), bytes: f.bytes, ...(urls[f.path] ? { url: urls[f.path] } : {}) }));
410
+
411
+ return { title: `${brandName} workspace`, icon: "🚀", overview, areas, artifacts };
412
+ }
413
+
414
+ // ---- the agent-replayable plan (markdown per area; no token, no network) ------
415
+
416
+ export interface WikiPlanArea { key: string; title: string; emoji: string; markdown: string; }
417
+ export interface WikiPlanMarkdown {
418
+ title: string;
419
+ icon: string;
420
+ overview: string;
421
+ areas: WikiPlanArea[];
422
+ artifacts: WikiArtifact[];
423
+ }
424
+
425
+ // Render a WikiPlan to per-area markdown so an agent that already holds a Notion
426
+ // MCP can replay it (create a root page, one child page per area, an Artifacts
427
+ // database) without the kit ever touching Notion or needing a token. The token
428
+ // path (notion-io) renders the same plan as native blocks; this is the same
429
+ // content, one canonical plan with two executors.
430
+ export function wikiPlanToMarkdown(plan: WikiPlan): WikiPlanMarkdown {
431
+ return {
432
+ title: plan.title,
433
+ icon: plan.icon,
434
+ overview: blocksToMarkdown(plan.overview),
435
+ areas: plan.areas.map((a) => ({ key: a.key, title: a.title, emoji: a.emoji, markdown: blocksToMarkdown(a.blocks) })),
436
+ artifacts: plan.artifacts,
437
+ };
438
+ }
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 {
package/core/sync-io.ts CHANGED
@@ -60,7 +60,7 @@ export function ghAuthed(): boolean {
60
60
  function syncConfigPath(): string {
61
61
  return process.env["THOUGHT_LAYER_SYNC_CONFIG"] || join(homedir(), ".thought-layer", "sync.json");
62
62
  }
63
- function loadConfig(): SyncConfig {
63
+ export function loadConfig(): SyncConfig {
64
64
  const p = syncConfigPath();
65
65
  if (!existsSync(p)) return emptySyncConfig();
66
66
  try { return parseSyncConfig(readFileSync(p, "utf8")); }
@@ -74,12 +74,14 @@ function saveConfig(cfg: SyncConfig): void {
74
74
 
75
75
  // ---- git helpers -------------------------------------------------------------
76
76
 
77
- interface Run { status: number; out: string; err: string; }
78
- function git(dir: string | null, args: string[], timeout = 120000): Run {
77
+ export interface Run { status: number; out: string; err: string; }
78
+ // Exported so the artifacts delivery layer (artifacts-io.ts) reuses the exact
79
+ // same git shell-out plumbing rather than re-implementing spawnSync.
80
+ export function git(dir: string | null, args: string[], timeout = 120000): Run {
79
81
  const r = spawnSync("git", dir ? ["-C", dir, ...args] : args, { encoding: "utf8", timeout });
80
82
  return { status: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" };
81
83
  }
82
- function isGitRepo(dir: string): boolean {
84
+ export function isGitRepo(dir: string): boolean {
83
85
  return existsSync(join(dir, ".git")) && git(dir, ["rev-parse", "--is-inside-work-tree"]).status === 0;
84
86
  }
85
87
  function dirNonEmpty(dir: string): boolean {
@@ -91,7 +93,7 @@ const absDir = (d: string, cwd = process.cwd()): string => (isAbsolute(d) ? d :
91
93
  // ---- the clone scaffolding files written on init -----------------------------
92
94
 
93
95
  const GITATTRIBUTES = `# The kit reconciles session JSON itself; never let git textually merge it\n# (which would corrupt the envelope). -merge keeps our copy in the working tree\n# on a conflict; the kit then rebuilds the merged result from the clean blobs.\n.thought-layer/*.json -merge\n`;
94
- const GITIGNORE = `# A Thought Layer sessions repo holds session state only. Built product\n# artifacts and secrets never sync here.\nbuild.json\ndeploy.json\n*.local\n.env\n.env.*\n!.env.example\ndist/\n.netlify/\nnode_modules/\n`;
96
+ const GITIGNORE = `# A Thought Layer sessions repo holds session state only. Built product\n# artifacts and secrets never sync here.\nbuild.json\ndeploy.json\n*.local\n.env\n.env.*\n!.env.example\ndist/\n.netlify/\nnode_modules/\n# Delivered artifacts (tl artifacts) ARE intentionally tracked, even though the\n# same filenames are ignored elsewhere in the tree.\n!artifacts/\n!artifacts/**\n`;
95
97
  const README = `# Thought Layer sessions\n\nThis private repo is the home for Thought Layer session files. Each session is one\nfile under \`.thought-layer/<name>.json\` (the portable validation and design state).\nUse the kit to work with them:\n\n tl sync open --name <session> pull and resume a session\n tl sync save --name <session> snapshot the current state, commit, and push\n tl sync list list the sessions in this repo\n\nCollaboration is handled by GitHub: add a collaborator to this repo in its GitHub\nsettings, and they can clone it and run the kit against the same sessions.\nThe kit reconciles concurrent edits itself (newest wins per field, conflicts are\nreported), so git never has to merge the JSON by hand.\n`;
96
98
 
97
99
  function writeCloneScaffold(cloneDir: string): void {
@@ -136,7 +138,8 @@ export async function runSync(opts: SyncRunOptions, ctx: { ts: number; exportedA
136
138
  }
137
139
 
138
140
  // Resolve the workspace + clone dir an op targets (everything except init).
139
- function resolveWorkspace(opts: SyncRunOptions, cfg: SyncConfig): { cloneDir: string; ws: SyncWorkspace | null } {
141
+ // Exported so artifacts-io.ts resolves the same sessions workspace.
142
+ export function resolveWorkspace(opts: SyncRunOptions, cfg: SyncConfig): { cloneDir: string; ws: SyncWorkspace | null } {
140
143
  if (opts.dir && opts.dir.trim()) return { cloneDir: absDir(opts.dir), ws: null };
141
144
  const env = process.env["THOUGHT_LAYER_SESSIONS_DIR"];
142
145
  if (env && env.trim()) return { cloneDir: absDir(env), ws: null };