@hobocode/thought-layer 0.8.5 → 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 CHANGED
@@ -73,7 +73,7 @@ The hosted version of the rigor lives at [weareallproductmanagersnow.com](https:
73
73
  - **Phase 4 (done):** a `deploy` step (`/tl-deploy`, the `deploy` tool, or `tl deploy`) that takes the build live to a URL you own, closing the loop. With a Netlify token it deploys into your own account via the file-digest API (owned immediately, no claim step); with no token it delegates to your Netlify CLI - logged in it creates a site in your account, logged out it deploys anonymously with a one-hour claim link. BYOK, no central account, no lock-in. `--dry-run` shows the plan first.
74
74
  - **Backend-capable build (done):** when the three-question backend test shows a product genuinely needs a server, `/tl-build` emits a real backend alongside the static front end (serverless functions per backend requirement, a `schema.sql`, a names-only `.env.example`, an updated `netlify.toml`, and a `BACKEND.md` guide), with Neon Postgres as the documented default and overridable to any Postgres. Static stays the default, gated by the same backend test.
75
75
  - **Backend deploy (done):** when `build.json` declares a backend, `/tl-deploy` (the `deploy` tool, or `tl deploy`) ships it automatically alongside the front end: the functions go up via your Netlify CLI and the declared env var names are set on the site (values read only from your environment, BYOK). `DATABASE_URL` is bring-your-own by default; `--provision-db` (your own Neon key) and `--apply-schema` (psql) are opt in, and `--static-only` ships just the front end. Owned, no lock-in.
76
- - **Artifacts and wiki (done):** `tl artifacts` (the `tl_artifacts` tool) delivers the full asset bundle for a session, the PRD, brand guide + look book + logo, SWOT and business-model infographics, market research, a landing page, and any build/deploy provenance, to your own private sessions repo under `artifacts/<session>/`. `tl wiki` (the `tl_wiki` tool) then builds a **private Notion wiki**, an internal intranet that organizes all of it into a page per workflow area plus an Artifacts database that links each file. Notion pages are private to your own workspace (auth, not public); the integration token is BYOK from the environment. Free for one user, upgradeable for a team.
76
+ - **Artifacts and wiki (done):** `tl artifacts` (the `tl_artifacts` tool) delivers the full asset bundle for a session, the PRD, brand guide + look book + logo, SWOT and business-model infographics, market research, a landing page, and any build/deploy provenance, to your own private sessions repo under `artifacts/<session>/`. `tl wiki` (the `tl_wiki` tool) then builds a **private Notion wiki**, an internal intranet that organizes all of it into a page per workflow area plus an Artifacts database that links each file. Notion pages are private to your own workspace (auth, not public); the integration token is BYOK from the environment. If your agent already has a **Notion MCP / connector**, `tl wiki --emit-plan` (or `tl_wiki { emitPlan: true }`) returns the wiki as per-area markdown so the agent builds it through that MCP with no token at all; the BYOK token stays the floor for hosts without one. Free for one user, upgradeable for a team.
77
77
  - **Sessions and collaboration (done):** `tl sync` (and the `tl_sync` tool) stores your session files in your OWN private GitHub repo. Save any number of named sessions (one private repo for your own projects, a separate repo per founder you collaborate with), list and open them, and sync. Git carries history and multi-user; the kit reconciles concurrent edits itself (newest wins per field, conflicts reported), so it never hand-merges JSON. Collaboration is granted on GitHub (you add collaborators, the kit never changes permissions). BYOK, no central account.
78
78
 
79
79
  ## Notes for contributors
package/core/notion-io.ts CHANGED
@@ -16,7 +16,7 @@ import { git, isGitRepo, loadConfig, resolveWorkspace } from "./sync-io.ts";
16
16
  import { slugify } from "./sync.ts";
17
17
  import { repoOwnerName } from "./artifacts-io.ts";
18
18
  import { STATE_DIR, loadStateFile } from "./state-file.ts";
19
- import { buildWikiPlan, chunkChildren, type Block, type WikiPlan, type WikiArtifact } from "./notion.ts";
19
+ import { buildWikiPlan, wikiPlanToMarkdown, chunkChildren, type Block, type WikiPlan, type WikiArtifact } from "./notion.ts";
20
20
  import type { ArtifactManifest } from "./artifacts.ts";
21
21
  import type { StateOpResult } from "./state-ops.ts";
22
22
 
@@ -33,6 +33,7 @@ export interface WikiRunOptions {
33
33
  parentPage?: string; // Notion page id or URL the integration is shared with
34
34
  replace?: boolean; // recreate the root page from scratch (new ids)
35
35
  dryRun?: boolean; // build the plan, report counts, no network
36
+ emitPlan?: boolean; // emit the plan as agent-replayable markdown (MCP path), no token, no network
36
37
  }
37
38
 
38
39
  const ok = (message: string, details: Record<string, unknown> = {}): StateOpResult => ({ ok: true, message, details });
@@ -223,6 +224,21 @@ export async function runWiki(opts: WikiRunOptions): Promise<StateOpResult> {
223
224
  );
224
225
  }
225
226
 
227
+ // Emit the plan as agent-replayable markdown (no token, no network). This is
228
+ // the MCP path: when the agent already holds a Notion MCP, it creates the
229
+ // pages itself from this plan instead of the kit calling Notion with a token.
230
+ if (opts.emitPlan) {
231
+ const pm = wikiPlanToMarkdown(plan);
232
+ const areaList = pm.areas.map((a) => `${a.emoji} ${a.title}`).join(", ");
233
+ return ok(
234
+ `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). ` +
235
+ `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.` +
236
+ `${manifest ? "" : " No delivered artifacts were found, so the Artifacts list is empty; run tl artifacts first to add GitHub links."}` +
237
+ `\nAreas: ${areaList || "(none with content yet)"}.`,
238
+ { plan: pm, delivered: !!manifest },
239
+ );
240
+ }
241
+
226
242
  // Token (BYOK, env only) and parent page are required for the live run.
227
243
  const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
228
244
  if (!token) {
package/core/notion.ts CHANGED
@@ -119,6 +119,106 @@ export function markdownToBlocks(md: string): Block[] {
119
119
  return out;
120
120
  }
121
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
+
122
222
  // ---- API-limit helpers (unit-tested) -----------------------------------------
123
223
 
124
224
  // Split a children array into <=100-block append batches.
@@ -310,3 +410,29 @@ export function buildWikiPlan(state: ProgressState, opts: WikiBuildOptions = {})
310
410
 
311
411
  return { title: `${brandName} workspace`, icon: "🚀", overview, areas, artifacts };
312
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/dist/tl.js CHANGED
@@ -2841,6 +2841,119 @@ function markdownToBlocks(md) {
2841
2841
  }
2842
2842
  return out;
2843
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 ? `![](${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
+ }
2844
2957
  function chunkChildren(blocks, size = CHILDREN_MAX) {
2845
2958
  const out = [];
2846
2959
  for (let i = 0; i < blocks.length; i += size) out.push(blocks.slice(i, i + size));
@@ -2975,6 +3088,15 @@ function buildWikiPlan(state, opts = {}) {
2975
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] } : {} }));
2976
3089
  return { title: `${brandName} workspace`, icon: "\u{1F680}", overview, areas, artifacts };
2977
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
+ }
2978
3100
 
2979
3101
  // core/notion-io.ts
2980
3102
  var NOTION_API = "https://api.notion.com/v1";
@@ -3152,6 +3274,15 @@ Areas: ${areaList || "(none with content yet)"}.${manifest ? "" : "\nNo delivere
3152
3274
  { title: plan.title, areas: plan.areas.map((a) => ({ key: a.key, blocks: a.blocks.length })), blockCount, artifacts: plan.artifacts.length, delivered: !!manifest }
3153
3275
  );
3154
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
+ }
3155
3286
  const token = TOKEN_ENVS.map((e) => process.env[e]).find((v) => v && v.trim())?.trim() || "";
3156
3287
  if (!token) {
3157
3288
  return fail3(
@@ -3220,7 +3351,8 @@ var HELP = `tl - read/write a portable Thought Layer state file (default: .thoug
3220
3351
  tl artifacts [--name x] [--workspace w] deliver the full asset bundle (PRD, brand, infographics, landing, deploy rules) to your sessions repo
3221
3352
  [--no-push] [--no-deliver] [--domain x.com] [--founder "Name"]
3222
3353
  tl wiki [--parent-page id|url] [--name x] build/refresh a private Notion wiki from the session + delivered artifacts
3223
- [--workspace w] [--replace] [--dry-run] (set THOUGHT_LAYER_NOTION_TOKEN; share a Notion page with your integration)
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)
3224
3356
  tl export [path] handoff check
3225
3357
  tl answer <qId> <value> [path] record an answer
3226
3358
  tl feedback --data '<json>' record a panel verdict ({qId,mode,personas,endState,round})
@@ -3376,7 +3508,8 @@ function main() {
3376
3508
  dir: typeof flags["dir"] === "string" ? flags["dir"] : void 0,
3377
3509
  parentPage: typeof flags["parent-page"] === "string" ? flags["parent-page"] : void 0,
3378
3510
  replace: flags["replace"] === true,
3379
- dryRun: flags["dry-run"] === true
3511
+ dryRun: flags["dry-run"] === true,
3512
+ emitPlan: flags["emit-plan"] === true
3380
3513
  }).then((r2) => {
3381
3514
  if (flags["json"]) console.log(JSON.stringify(r2.details, null, 2));
3382
3515
  else console.log(r2.message);
@@ -312,7 +312,8 @@ export default function (pi: ExtensionAPI) {
312
312
  description:
313
313
  "Build or refresh a PRIVATE Notion wiki (an internal intranet) for a session: a root page, one child page per workflow area (Big Idea, Business Model, Brand, Market Research, Strategy, PRD, Decision Science), rendered natively in Notion, plus an Artifacts database that links the files delivered by tl_artifacts. " +
314
314
  "Notion pages are private to the user's workspace, so this satisfies an auth requirement with no public exposure. BYOK: the integration token is read ONLY from THOUGHT_LAYER_NOTION_TOKEN (or NOTION_TOKEN) in the environment, never a parameter. Setup once: create an internal integration at notion.so/my-integrations, set the token, share a page with the integration, and pass that page as parentPage. " +
315
- "Idempotent: it stores the page ids locally and refreshes content on re-run; replace recreates the wiki from scratch; dryRun reports the plan with no network call. Run tl_artifacts first so the Artifacts database has GitHub links.",
315
+ "Idempotent: it stores the page ids locally and refreshes content on re-run; replace recreates the wiki from scratch; dryRun reports the plan with no network call. Run tl_artifacts first so the Artifacts database has GitHub links. " +
316
+ "If you already have a Notion MCP connected, prefer emitPlan:true: it returns the wiki as per-area markdown (no token, no network) for you to create the pages and Artifacts database through that MCP; the token path stays the fallback.",
316
317
  parameters: Type.Object({
317
318
  name: Type.Optional(Type.String({ description: "Session name to publish (defaults to the workspace's active session)." })),
318
319
  parentPage: Type.Optional(Type.String({ description: "Notion page id or URL the integration is shared with (where the wiki root is created). Or set THOUGHT_LAYER_NOTION_PARENT." })),
@@ -321,10 +322,11 @@ export default function (pi: ExtensionAPI) {
321
322
  dir: Type.Optional(Type.String({ description: "Explicit clone dir for the workspace." })),
322
323
  replace: Type.Optional(Type.Boolean({ description: "Recreate the wiki from scratch (new pages) instead of refreshing the existing one." })),
323
324
  dryRun: Type.Optional(Type.Boolean({ description: "Build the plan and report area/block/artifact counts with no network call." })),
325
+ emitPlan: Type.Optional(Type.Boolean({ description: "Return the wiki as per-area markdown (the agent-replayable plan) with no token and no network call, so you can create the pages through a connected Notion MCP. The plan is in the result details under `plan`." })),
324
326
  }),
325
327
  async execute(_id, params): Promise<ToolResult> {
326
- const p = params as { name?: string; parentPage?: string; workspace?: string; path?: string; dir?: string; replace?: boolean; dryRun?: boolean };
327
- const r = await runWiki({ name: p.name, parentPage: p.parentPage, workspace: p.workspace, path: p.path, dir: p.dir, replace: p.replace, dryRun: p.dryRun });
328
+ const p = params as { name?: string; parentPage?: string; workspace?: string; path?: string; dir?: string; replace?: boolean; dryRun?: boolean; emitPlan?: boolean };
329
+ const r = await runWiki({ name: p.name, parentPage: p.parentPage, workspace: p.workspace, path: p.path, dir: p.dir, replace: p.replace, dryRun: p.dryRun, emitPlan: p.emitPlan });
328
330
  return text(r.message, r.details);
329
331
  },
330
332
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hobocode/thought-layer",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
4
4
  "description": "The Thought Layer: rigor for building. Validate an idea, grill it into a buildable spec, then build and deploy it, inside the agent you already use. BYOK, no telemetry.",
5
5
  "license": "MIT",
6
6
  "author": "Hobocode LLC <jerm@hobocode.net>",
@@ -2,8 +2,10 @@ Apply the **thought-layer-wiki** skill. Build me a private Notion wiki, an inter
2
2
 
3
3
  First make sure I have delivered the artifacts (`tl artifacts --name <session>`), so the wiki can link to the files in my repo; if I have not, build from the session state and tell me the Artifacts database will be empty until I deliver them. The wiki reads my session from my private sessions repo, so honor `--name` / `--workspace` / `--dir`.
4
4
 
5
- Read my Notion token only from the environment (`THOUGHT_LAYER_NOTION_TOKEN` or `NOTION_TOKEN`), never ask me to paste it. I create an internal integration at notion.so/my-integrations, set the token, and share a Notion page with the integration; pass that page as `--parent-page <id or url>`. If the token or the parent page is missing, tell me exactly what to do rather than pretending it worked.
5
+ **Check what you have first.** If you already have a Notion MCP connected, prefer it and skip the token entirely: run `tl wiki --name <session> --emit-plan` (no token, no network call) to get the wiki as per-area markdown, then create the root "<Product> workspace" page, one child page per area, and the Artifacts database through that MCP. Search Notion for an existing "<Product> workspace" page first so a re-run updates it instead of creating a duplicate, and ask me which Notion page should hold the wiki if you do not already know.
6
6
 
7
- Dry-run first (`tl wiki --name <session> --dry-run`) so I can see the area pages, block counts, and artifact count. Then build it: a root "<Product> workspace" page, a child page per workflow area that has content (Big Idea, Business Model, Brand, Market Research, Strategy, PRD, Decision Science), rendered natively in Notion, plus an Artifacts database that links each delivered file to its GitHub copy. Upload small files only where no link exists; Notion's free tier caps uploads at 5 MiB, so larger files link out.
7
+ Otherwise (no Notion MCP), use the token path. Read my Notion token only from the environment (`THOUGHT_LAYER_NOTION_TOKEN` or `NOTION_TOKEN`), never ask me to paste it. I create an internal integration at notion.so/my-integrations, set the token, and share a Notion page with the integration; pass that page as `--parent-page <id or url>`. Dry-run first (`tl wiki --name <session> --dry-run`) so I can see the area pages, block counts, and artifact count. If the token or the parent page is missing, tell me exactly what to do rather than pretending it worked.
8
8
 
9
- It is idempotent: re-running refreshes the existing pages (the page ids are stored locally, never synced); `--replace` rebuilds from scratch. After it is built, give me the root page URL.
9
+ Either way, build: a root "<Product> workspace" page, a child page per workflow area that has content (Big Idea, Business Model, Brand, Market Research, Strategy, PRD, Compliance & Tax, Decision Science), rendered natively in Notion, plus an Artifacts database that links each delivered file to its GitHub copy. Upload small files only where no link exists; Notion's free tier caps uploads at 5 MiB, so larger files link out.
10
+
11
+ The token path is idempotent: re-running refreshes the existing pages (the page ids are stored locally, never synced); `--replace` rebuilds from scratch. The MCP path has no local id-map, so search for the existing wiki page first to avoid duplicates. After it is built, give me the root page URL.
@@ -11,9 +11,25 @@ The wiki is the founder's keepable home for everything the workflow produced: th
11
11
 
12
12
  1. **A sessions workspace.** The wiki reads the session from the user's private sessions repo (set up by the `tl_sync` tool / `tl sync init`). Honor the usual selection: `--name <session>` (or the workspace's active session), `--workspace <label>`, or an explicit `--dir`.
13
13
  2. **Delivered artifacts (recommended).** Run the `tl_artifacts` tool / **`tl artifacts`** first so `artifacts/<session>/artifacts.json` exists. The wiki reads it to fill the Artifacts database with GitHub links. Without it, the wiki still builds from the session state, but the Artifacts database is empty; the tool says so. Tell the user to deliver artifacts first for the full result.
14
- 3. **A Notion integration (one-time, BYOK).** The user creates an internal integration at https://www.notion.so/my-integrations, copies the secret, and sets it as `THOUGHT_LAYER_NOTION_TOKEN` (or `NOTION_TOKEN`) in the environment. Then, in Notion, they open the page that should hold the wiki, click **Share**, and add the integration so it has access. That page's id or URL is the `parent-page`. The token is read **only from the environment**; never ask the user to paste it into the chat or put it in a parameter or file.
14
+ 3. **Notion access (one of two ways).** Either (a) a **Notion MCP / connector** already authorized to the user's workspace, in which case you need no token at all (see "Two ways to build it" below); or (b) a one-time **BYOK integration**: the user creates an internal integration at https://www.notion.so/my-integrations, copies the secret, sets it as `THOUGHT_LAYER_NOTION_TOKEN` (or `NOTION_TOKEN`) in the environment, then in Notion opens the page that should hold the wiki, clicks **Share**, and adds the integration so it has access. That page's id or URL is the `parent-page`. The token is read **only from the environment**; never ask the user to paste it into the chat or put it in a parameter or file.
15
15
 
16
- ## How to run it
16
+ ## Two ways to build it
17
+
18
+ **First check what you have.** If you (the agent) already have a **Notion MCP / connector** authorized to the user's workspace, prefer it — the user skips the entire integration-token setup. Otherwise use the BYOK token path, which stays the universal floor (the browser SPA and a plain CLI/CI host have no MCP). Both paths render the **same plan**; pick one.
19
+
20
+ ### A. Notion MCP connected (preferred, no token)
21
+
22
+ Build the wiki through the MCP and never ask for a token:
23
+
24
+ 1. **Get the plan:** `tl wiki --name <session> --emit-plan --json` (or the `tl_wiki` tool with `emitPlan: true`). With **no token and no network call** it returns `{ title, icon, overview, areas: [{ key, title, emoji, markdown }], artifacts: [{ name, category, bytes, url? }] }`. Each area's `markdown` is the ready-to-post page body.
25
+ 2. **Pick the parent.** Ask the user which Notion page should hold the wiki, unless they already said.
26
+ 3. **Avoid duplicates first.** The local id-map (`~/.thought-layer/notion.json`) is written only by the token path, so on the MCP path it does not exist. Before creating, **search Notion for an existing page titled `<title>`**; if it exists, update it in place; otherwise create it. If you cannot search, ask the user whether to create fresh or point you at the existing page.
27
+ 4. **Create the root page** titled `plan.title` under the parent (icon `plan.icon`), with `overview` as its body.
28
+ 5. **Create one child page per area** in `areas[]`, titled `"<emoji> <title>"`, with that area's `markdown` as the body.
29
+ 6. **Create the Artifacts database** under the root with columns Name, Category, Size, Link, and one row per `artifacts[]` entry (use its `url` for Link when present).
30
+ 7. **Report the root page URL.**
31
+
32
+ ### B. No MCP: the BYOK token path (the floor)
17
33
 
18
34
  Use the tool, never a hand-written Notion `curl`:
19
35
  - **Pi:** the `tl_wiki` tool. Start with `tl_wiki { name, dryRun: true }` to show the plan (area pages, block counts, artifact count), then `tl_wiki { name, parentPage }` to build it.
@@ -21,6 +37,8 @@ Use the tool, never a hand-written Notion `curl`:
21
37
 
22
38
  Always **dry-run first** and show the user the area + artifact plan, then build.
23
39
 
40
+ > GitHub needs no equivalent path: `tl artifacts` and `tl sync` already authenticate via the `gh`/git keyring (no token paste), so a GitHub MCP is not required for delivery.
41
+
24
42
  ## What it builds
25
43
 
26
44
  - A root page **"<Product> workspace"** under the shared parent page.