@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/index.ts CHANGED
@@ -15,9 +15,16 @@ export * from "./stages.ts";
15
15
  export * from "./stage-map.ts";
16
16
  export * from "./state-file.ts";
17
17
  export * from "./state-ops.ts";
18
+ export * from "./merge.ts";
19
+ export * from "./sync.ts";
20
+ export * from "./sync-io.ts";
18
21
  export * from "./backend.ts";
19
22
  export * from "./backend-io.ts";
20
23
  export * from "./scaffold.ts";
21
24
  export * from "./scaffold-io.ts";
25
+ export * from "./artifacts.ts";
26
+ export * from "./artifacts-io.ts";
27
+ export * from "./notion.ts";
28
+ export * from "./notion-io.ts";
22
29
  export * from "./deploy.ts";
23
30
  export * from "./deploy-io.ts";
package/core/merge.ts ADDED
@@ -0,0 +1,157 @@
1
+ // Pure two-way reconciler for the portable progress state. The kit owns this so
2
+ // git can be transport + history only: when two clones of the same session
3
+ // diverge, the sync layer reads both sides and calls mergeProgressStates rather
4
+ // than letting git textually merge the pretty-printed JSON (which would corrupt
5
+ // the envelope). This is the kit-local cousin of the web app's buildMergedState,
6
+ // adapted for an agent/CLI context: NO interactive conflict dialog, newest-wins
7
+ // by the timestamps the envelope actually carries, and every coarse tie-break is
8
+ // reported so the caller can tell the user where a side was dropped.
9
+ //
10
+ // LIMIT (documented, by design): the envelope has no per-field clock, only the
11
+ // whole-file writer.ts and the kit namespace updatedAt. So a field edited
12
+ // concurrently on both sides tie-breaks coarsely (newest file wins) and the
13
+ // dropped side is listed in `coarse`. Per-field clocks are deferred future work
14
+ // (they would need a PROGRESS_FORMAT bump mirrored into the web app).
15
+ //
16
+ // Pure: no fs, no node deps. Changes NO envelope format and does not bump
17
+ // PROGRESS_FORMAT, so the lossless web<->kit handoff is untouched.
18
+
19
+ import { KNOWN_STATE_KEYS, type ProgressState, type KitNamespace } from "./progress.ts";
20
+
21
+ export interface MergeResult {
22
+ state: ProgressState;
23
+ coarse: string[]; // fields whose conflict was resolved by a coarse tie-break
24
+ }
25
+
26
+ export interface MergeOpts {
27
+ oursTs: number; // our file's writer.ts
28
+ theirsTs: number; // their file's writer.ts
29
+ }
30
+
31
+ const ARTIFACT_KEYS = ["bizModel", "grill", "assets", "research", "swot", "prd", "naming", "brand", "governance"] as const;
32
+
33
+ const num = (v: unknown): number => (typeof v === "number" && !Number.isNaN(v) ? v : 0);
34
+ const genAt = (v: unknown): number =>
35
+ v && typeof v === "object" ? num((v as Record<string, unknown>)["generatedAt"]) : 0;
36
+ const jsonEq = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
37
+ const rec = (v: unknown): Record<string, unknown> =>
38
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
39
+
40
+ // Merge `ours` and `theirs` into one state. Tie-break direction: a strictly
41
+ // newer file wins; on an exact timestamp tie the incoming side (theirs) wins, so
42
+ // the result is deterministic regardless of which clone runs the merge.
43
+ export function mergeProgressStates(ours: ProgressState, theirs: ProgressState, opts: MergeOpts): MergeResult {
44
+ const coarse: string[] = [];
45
+ const oursNewer = opts.oursTs > opts.theirsTs; // strict; equal -> theirs
46
+
47
+ // ---- answers: union by qId; differing values tie-break coarsely ----
48
+ const answers: Record<string, unknown> = {};
49
+ const oa = rec(ours.answers);
50
+ const ta = rec(theirs.answers);
51
+ for (const k of new Set([...Object.keys(oa), ...Object.keys(ta)])) {
52
+ const inO = k in oa;
53
+ const inT = k in ta;
54
+ if (inO && !inT) answers[k] = oa[k];
55
+ else if (!inO && inT) answers[k] = ta[k];
56
+ else if (jsonEq(oa[k], ta[k])) answers[k] = oa[k];
57
+ else {
58
+ answers[k] = oursNewer ? oa[k] : ta[k];
59
+ coarse.push(`answers.${k}`);
60
+ }
61
+ }
62
+
63
+ // ---- feedback: union by qId; collision by round, then entry ts, then file ts ----
64
+ const feedback: Record<string, unknown> = {};
65
+ const of = rec(ours.feedback);
66
+ const tf = rec(theirs.feedback);
67
+ for (const k of new Set([...Object.keys(of), ...Object.keys(tf)])) {
68
+ const o = of[k];
69
+ const t = tf[k];
70
+ if (o != null && t == null) feedback[k] = o;
71
+ else if (o == null && t != null) feedback[k] = t;
72
+ else if (jsonEq(o, t)) feedback[k] = o;
73
+ else {
74
+ const oR = num(rec(o)["round"]);
75
+ const tR = num(rec(t)["round"]);
76
+ const oTs = num(rec(o)["ts"]);
77
+ const tTs = num(rec(t)["ts"]);
78
+ const pickOurs = oR !== tR ? oR > tR : oTs !== tTs ? oTs > tTs : oursNewer;
79
+ feedback[k] = pickOurs ? o : t;
80
+ coarse.push(`feedback.${k}`);
81
+ }
82
+ }
83
+
84
+ // ---- artifacts: non-null beats null; both differ -> newer generatedAt, else file ts ----
85
+ const artifact = (key: (typeof ARTIFACT_KEYS)[number]): unknown => {
86
+ const o = ours[key];
87
+ const t = theirs[key];
88
+ if (o == null && t == null) return null;
89
+ if (o != null && t == null) return o;
90
+ if (o == null && t != null) return t;
91
+ if (jsonEq(o, t)) return o;
92
+ const og = genAt(o);
93
+ const tg = genAt(t);
94
+ const pickOurs = og !== tg ? og > tg : oursNewer;
95
+ coarse.push(key);
96
+ return pickOurs ? o : t;
97
+ };
98
+
99
+ const merged: ProgressState = {
100
+ version: 2,
101
+ answers,
102
+ feedback,
103
+ bizModel: artifact("bizModel"),
104
+ grill: artifact("grill"),
105
+ assets: artifact("assets"),
106
+ research: artifact("research"),
107
+ swot: artifact("swot"),
108
+ prd: artifact("prd"),
109
+ naming: artifact("naming"),
110
+ brand: artifact("brand"),
111
+ governance: artifact("governance"),
112
+ kit: mergeKit(ours.kit, theirs.kit, oursNewer),
113
+ };
114
+
115
+ // ---- unknown future keys: carried through, newer side wins ----
116
+ // Apply the older side first, then the newer, so the newer value overwrites.
117
+ for (const src of oursNewer ? [theirs, ours] : [ours, theirs]) {
118
+ for (const k of Object.keys(src)) {
119
+ if (!(KNOWN_STATE_KEYS as readonly string[]).includes(k)) merged[k] = (src as Record<string, unknown>)[k];
120
+ }
121
+ }
122
+
123
+ return { state: merged, coarse: Array.from(new Set(coarse)).sort() };
124
+ }
125
+
126
+ // The kit namespace is agent-owned resume state. Union the additive parts
127
+ // (modulesRun, parked) and take the newer side for the singular ones (cursor),
128
+ // with updatedAt as the max of both clocks.
129
+ function mergeKit(o: KitNamespace | null, t: KitNamespace | null, oursNewer: boolean): KitNamespace | null {
130
+ if (!o && !t) return null;
131
+ if (o && !t) return o;
132
+ if (!o && t) return t;
133
+ const oo = o as KitNamespace;
134
+ const tt = t as KitNamespace;
135
+
136
+ const modulesRun = Array.from(new Set([...(oo.modulesRun || []), ...(tt.modulesRun || [])]));
137
+ const parked: Record<string, string[]> = {};
138
+ for (const src of [oo.parked || {}, tt.parked || {}]) {
139
+ for (const [k, v] of Object.entries(src)) {
140
+ parked[k] = Array.from(new Set([...(parked[k] || []), ...(Array.isArray(v) ? v : [])]));
141
+ }
142
+ }
143
+ const newer = oursNewer ? oo : tt;
144
+ const older = oursNewer ? tt : oo;
145
+ const panelMeta = { ...(older.panelMeta || {}), ...(newer.panelMeta || {}) };
146
+
147
+ const kit: KitNamespace = {
148
+ schema: Math.max(num(oo.schema) || 1, num(tt.schema) || 1),
149
+ updatedAt: Math.max(num(oo.updatedAt), num(tt.updatedAt)),
150
+ };
151
+ if (modulesRun.length) kit.modulesRun = modulesRun;
152
+ if (Object.keys(parked).length) kit.parked = parked;
153
+ const cursor = newer.cursor ?? older.cursor;
154
+ if (cursor) kit.cursor = cursor;
155
+ if (Object.keys(panelMeta).length) kit.panelMeta = panelMeta;
156
+ return kit;
157
+ }
@@ -0,0 +1,292 @@
1
+ // Node IO for the Notion wiki, shared by the `tl wiki` CLI and the tl_wiki Pi
2
+ // tool. The pure plan (state -> pages + blocks) lives in notion.ts; this calls
3
+ // the Notion REST API directly with the global fetch (Node 18+), so the kit
4
+ // stays dependency-free and the bundle never grows. It find-or-creates the wiki
5
+ // pages idempotently (Notion has no upsert), refreshes their content, and builds
6
+ // an Artifacts database.
7
+ //
8
+ // Secrets: the Notion token is read ONLY from the environment (BYOK), never a
9
+ // CLI flag or tool parameter, never persisted. The parent page id is not a
10
+ // secret, so it may be a flag. The page-id map is machine-local and not synced.
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import { git, isGitRepo, loadConfig, resolveWorkspace } from "./sync-io.ts";
16
+ import { slugify } from "./sync.ts";
17
+ import { repoOwnerName } from "./artifacts-io.ts";
18
+ import { STATE_DIR, loadStateFile } from "./state-file.ts";
19
+ import { buildWikiPlan, chunkChildren, type Block, type WikiPlan, type WikiArtifact } from "./notion.ts";
20
+ import type { ArtifactManifest } from "./artifacts.ts";
21
+ import type { StateOpResult } from "./state-ops.ts";
22
+
23
+ const NOTION_API = "https://api.notion.com/v1";
24
+ const NOTION_VERSION = "2022-06-28";
25
+ const MIN_INTERVAL_MS = 350; // ~3 requests/second
26
+ const TOKEN_ENVS = ["THOUGHT_LAYER_NOTION_TOKEN", "NOTION_TOKEN"];
27
+
28
+ export interface WikiRunOptions {
29
+ path?: string; // explicit source state file
30
+ name?: string; // session name (the wiki + id-map key)
31
+ workspace?: string; // sessions workspace label
32
+ dir?: string; // explicit clone dir
33
+ parentPage?: string; // Notion page id or URL the integration is shared with
34
+ replace?: boolean; // recreate the root page from scratch (new ids)
35
+ dryRun?: boolean; // build the plan, report counts, no network
36
+ }
37
+
38
+ const ok = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: true, message, details });
39
+ const fail = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: false, message, details });
40
+
41
+ // ---- machine-local page-id map (mirrors sync.json) ---------------------------
42
+
43
+ interface NotionEntry { rootPageId?: string; areaPageIds?: Record<string, string>; artifactsDbId?: string; updatedAt?: number; }
44
+ interface NotionConfig { schema: number; sessions: Record<string, NotionEntry>; }
45
+
46
+ function notionConfigPath(): string {
47
+ return process.env["THOUGHT_LAYER_NOTION_CONFIG"] || join(homedir(), ".thought-layer", "notion.json");
48
+ }
49
+ function loadNotionConfig(): NotionConfig {
50
+ const p = notionConfigPath();
51
+ if (!existsSync(p)) return { schema: 1, sessions: {} };
52
+ try {
53
+ const raw = JSON.parse(readFileSync(p, "utf8")) as Record<string, unknown>;
54
+ const sessions = raw["sessions"] && typeof raw["sessions"] === "object" ? raw["sessions"] as Record<string, NotionEntry> : {};
55
+ return { schema: 1, sessions };
56
+ } catch { return { schema: 1, sessions: {} }; }
57
+ }
58
+ function saveNotionConfig(cfg: NotionConfig): void {
59
+ const p = notionConfigPath();
60
+ mkdirSync(dirname(p), { recursive: true });
61
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
62
+ }
63
+
64
+ // ---- helpers -----------------------------------------------------------------
65
+
66
+ // Accept a raw 32-hex id, a dashed UUID, or a Notion URL; return a dashed UUID.
67
+ export function pageIdFromInput(s: string): string | null {
68
+ const t = String(s || "").trim();
69
+ 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}/);
70
+ if (dashed) return dashed[0].toLowerCase();
71
+ const runs = t.match(/[0-9a-fA-F]{32}/g);
72
+ if (!runs || !runs.length) return null;
73
+ const id = runs[runs.length - 1]!.toLowerCase();
74
+ return `${id.slice(0, 8)}-${id.slice(8, 12)}-${id.slice(12, 16)}-${id.slice(16, 20)}-${id.slice(20)}`;
75
+ }
76
+
77
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
78
+
79
+ // A small serial Notion client: paces requests to ~3/s and backs off on 429.
80
+ class Notion {
81
+ private last = 0;
82
+ constructor(private token: string) {}
83
+
84
+ async call(method: string, path: string, body?: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
85
+ for (let attempt = 0; ; attempt++) {
86
+ const wait = MIN_INTERVAL_MS - (Date.now() - this.last);
87
+ if (wait > 0) await sleep(wait);
88
+ this.last = Date.now();
89
+ const res = await fetch(`${NOTION_API}${path}`, {
90
+ method,
91
+ headers: {
92
+ Authorization: `Bearer ${this.token}`,
93
+ "Notion-Version": NOTION_VERSION,
94
+ "Content-Type": "application/json",
95
+ },
96
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
97
+ });
98
+ if (res.status === 429 && attempt < 4) {
99
+ const retry = Number(res.headers.get("Retry-After")) || Math.pow(2, attempt);
100
+ await sleep(retry * 1000);
101
+ continue;
102
+ }
103
+ let json: Record<string, unknown> = {};
104
+ try { json = (await res.json()) as Record<string, unknown>; } catch { /* empty body */ }
105
+ return { status: res.status, json };
106
+ }
107
+ }
108
+
109
+ async pageExists(id: string): Promise<boolean> {
110
+ const r = await this.call("GET", `/pages/${id}`);
111
+ return r.status === 200 && r.json["archived"] !== true;
112
+ }
113
+
114
+ async createPage(parentPageId: string, title: string, emoji?: string): Promise<string> {
115
+ const r = await this.call("POST", "/pages", {
116
+ parent: { page_id: parentPageId },
117
+ ...(emoji ? { icon: { type: "emoji", emoji } } : {}),
118
+ properties: { title: { title: [{ text: { content: title } }] } },
119
+ });
120
+ if (r.status !== 200) throw new Error(notionErr("create page", r));
121
+ return String(r.json["id"]);
122
+ }
123
+
124
+ // Replace a page's content: delete every existing child, then append the new
125
+ // blocks in <=100-block batches.
126
+ async replaceChildren(pageId: string, blocks: Block[]): Promise<void> {
127
+ let cursor: string | undefined;
128
+ do {
129
+ const q = cursor ? `?start_cursor=${cursor}&page_size=100` : "?page_size=100";
130
+ const r = await this.call("GET", `/blocks/${pageId}/children${q}`);
131
+ if (r.status !== 200) break;
132
+ for (const b of (r.json["results"] as Array<{ id: string }>) || []) await this.call("DELETE", `/blocks/${b.id}`);
133
+ cursor = r.json["has_more"] ? String(r.json["next_cursor"]) : undefined;
134
+ } while (cursor);
135
+ for (const batch of chunkChildren(blocks)) {
136
+ if (!batch.length) continue;
137
+ const r = await this.call("PATCH", `/blocks/${pageId}/children`, { children: batch });
138
+ if (r.status !== 200) throw new Error(notionErr("append blocks", r));
139
+ }
140
+ }
141
+
142
+ async createArtifactsDb(parentPageId: string, artifacts: WikiArtifact[]): Promise<string> {
143
+ const cats = Array.from(new Set(artifacts.map((a) => a.category)));
144
+ const r = await this.call("POST", "/databases", {
145
+ parent: { type: "page_id", page_id: parentPageId },
146
+ title: [{ text: { content: "Artifacts" } }],
147
+ icon: { type: "emoji", emoji: "📎" },
148
+ properties: {
149
+ Name: { title: {} },
150
+ Category: { select: { options: cats.map((c) => ({ name: c })) } },
151
+ Size: { rich_text: {} },
152
+ Link: { url: {} },
153
+ },
154
+ });
155
+ if (r.status !== 200) throw new Error(notionErr("create database", r));
156
+ return String(r.json["id"]);
157
+ }
158
+
159
+ async addArtifactRow(dbId: string, a: WikiArtifact): Promise<void> {
160
+ await this.call("POST", "/pages", {
161
+ parent: { database_id: dbId },
162
+ properties: {
163
+ Name: { title: [{ text: { content: a.name } }] },
164
+ Category: { select: { name: a.category } },
165
+ Size: { rich_text: [{ text: { content: humanSize(a.bytes) } }] },
166
+ ...(a.url ? { Link: { url: a.url } } : {}),
167
+ },
168
+ });
169
+ }
170
+ }
171
+
172
+ function notionErr(what: string, r: { status: number; json: Record<string, unknown> }): string {
173
+ const msg = String(r.json["message"] || "").slice(0, 200);
174
+ return `Notion ${what} failed (${r.status})${msg ? `: ${msg}` : ""}`;
175
+ }
176
+ function humanSize(bytes: number): string {
177
+ if (bytes < 1024) return `${bytes} B`;
178
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
179
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
180
+ }
181
+
182
+ // ---- the orchestrator --------------------------------------------------------
183
+
184
+ export async function runWiki(opts: WikiRunOptions): Promise<StateOpResult> {
185
+ try {
186
+ const cfg = loadConfig();
187
+ const { cloneDir, ws } = resolveWorkspace(opts as never, cfg);
188
+ if (!isGitRepo(cloneDir)) {
189
+ return fail(`No sessions workspace at ${cloneDir}. Run tl sync init then tl artifacts before building the wiki.`, { cloneDir });
190
+ }
191
+ const slug = slugify(opts.name || ws?.activeSession?.replace(/\.json$/, "") || "");
192
+ if (!slug) return fail("Name the session to publish: tl wiki --name <name>.");
193
+
194
+ // Load the session state.
195
+ const sessionPath = join(cloneDir, STATE_DIR, `${slug}.json`);
196
+ const useExplicit = !!((opts.path && opts.path.trim()) || (process.env["THOUGHT_LAYER_STATE"] || "").trim());
197
+ const loaded = loadStateFile(useExplicit ? opts.path : existsSync(sessionPath) ? sessionPath : opts.path);
198
+ if (!loaded.exists) return fail(`No session "${slug}" found (looked at ${loaded.path}). Save it first with tl sync save --name ${slug}.`, { cloneDir });
199
+
200
+ // Read the delivered artifacts manifest + recompute GitHub URLs (if delivered).
201
+ const artifactsDir = join(cloneDir, "artifacts", slug);
202
+ let manifest: ArtifactManifest | null = null;
203
+ const manifestPath = join(artifactsDir, "artifacts.json");
204
+ if (existsSync(manifestPath)) {
205
+ try { manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ArtifactManifest; } catch { manifest = null; }
206
+ }
207
+ const branch = git(cloneDir, ["rev-parse", "--abbrev-ref", "HEAD"]).out.trim() || ws?.defaultBranch || "main";
208
+ const ownerName = repoOwnerName(ws?.repo || "");
209
+ const urls: Record<string, string> = {};
210
+ if (ownerName && manifest) {
211
+ const base = `https://github.com/${ownerName}/blob/${branch}/artifacts/${slug}`;
212
+ for (const f of manifest.files) urls[f.path] = `${base}/${f.path.split("/").map(encodeURIComponent).join("/")}`;
213
+ }
214
+
215
+ const plan: WikiPlan = buildWikiPlan(loaded.state, { manifest, urls });
216
+ const blockCount = plan.overview.length + plan.areas.reduce((n, a) => n + a.blocks.length, 0);
217
+
218
+ if (opts.dryRun) {
219
+ const areaList = plan.areas.map((a) => `${a.emoji} ${a.title} (${a.blocks.length} blocks)`).join(", ");
220
+ return ok(
221
+ `Dry run for "${plan.title}": ${plan.areas.length} area page(s), ${blockCount} blocks total, ${plan.artifacts.length} artifact(s) in the database.\nAreas: ${areaList || "(none with content yet)"}.${manifest ? "" : "\nNo delivered artifacts found; run tl artifacts first to populate the Artifacts database with GitHub links."}`,
222
+ { title: plan.title, areas: plan.areas.map((a) => ({ key: a.key, blocks: a.blocks.length })), blockCount, artifacts: plan.artifacts.length, delivered: !!manifest },
223
+ );
224
+ }
225
+
226
+ // Token (BYOK, env only) and parent page are required for the live run.
227
+ const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
228
+ if (!token) {
229
+ return fail(
230
+ "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.",
231
+ { needs: "token" },
232
+ );
233
+ }
234
+
235
+ const ncfg = loadNotionConfig();
236
+ const entry: NotionEntry = ncfg.sessions[slug] || {};
237
+ const notion = new Notion(token);
238
+
239
+ // Resolve / verify the root page. With --replace, force a fresh root.
240
+ let rootId = opts.replace ? "" : entry.rootPageId || "";
241
+ if (rootId && !(await notion.pageExists(rootId))) rootId = "";
242
+ if (!rootId) {
243
+ const parentInput = opts.parentPage || process.env["THOUGHT_LAYER_NOTION_PARENT"] || "";
244
+ const parentId = pageIdFromInput(parentInput);
245
+ if (!parentId) {
246
+ return fail(
247
+ "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>.",
248
+ { needs: "parent-page" },
249
+ );
250
+ }
251
+ if (!(await notion.pageExists(parentId))) {
252
+ return fail(`Notion cannot see the parent page ${parentId}. In Notion, open the page, click Share, and add your integration so it has access.`, { needs: "share" });
253
+ }
254
+ rootId = await notion.createPage(parentId, plan.title, plan.icon);
255
+ entry.areaPageIds = {};
256
+ entry.artifactsDbId = undefined;
257
+ }
258
+ await notion.replaceChildren(rootId, plan.overview);
259
+
260
+ // Area child pages: find-or-create, then refresh content.
261
+ const areaIds: Record<string, string> = entry.areaPageIds || {};
262
+ for (const area of plan.areas) {
263
+ let pid = areaIds[area.key] || "";
264
+ if (pid && !(await notion.pageExists(pid))) pid = "";
265
+ if (!pid) pid = await notion.createPage(rootId, `${area.emoji} ${area.title}`, area.emoji);
266
+ areaIds[area.key] = pid;
267
+ await notion.replaceChildren(pid, area.blocks);
268
+ }
269
+
270
+ // Artifacts database: create once, then add a row per artifact. (Idempotent
271
+ // re-runs reuse the stored db; rows are appended, so --replace gives a clean
272
+ // db by recreating the whole wiki.)
273
+ let dbId = entry.artifactsDbId || "";
274
+ if (plan.artifacts.length && (!dbId || opts.replace)) {
275
+ dbId = await notion.createArtifactsDb(rootId, plan.artifacts);
276
+ for (const a of plan.artifacts) await notion.addArtifactRow(dbId, a);
277
+ }
278
+
279
+ ncfg.sessions[slug] = { rootPageId: rootId, areaPageIds: areaIds, artifactsDbId: dbId || undefined, updatedAt: Date.now() };
280
+ saveNotionConfig(ncfg);
281
+
282
+ const rootUrl = `https://www.notion.so/${rootId.replace(/-/g, "")}`;
283
+ return ok(
284
+ `Built the "${plan.title}" wiki in Notion: ${plan.areas.length} area page(s), ${blockCount} blocks, ${plan.artifacts.length} artifact(s) listed.` +
285
+ `${manifest ? "" : " No delivered artifacts were found, so the Artifacts database links are empty; run tl artifacts first."}` +
286
+ `\nOpen it: ${rootUrl}`,
287
+ { title: plan.title, rootPageId: rootId, rootUrl, areas: plan.areas.length, blockCount, artifacts: plan.artifacts.length },
288
+ );
289
+ } catch (e) {
290
+ return fail(`tl_wiki error: ${(e as Error).message}`);
291
+ }
292
+ }