@bendyline/gilde 0.1.21 → 0.1.22

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.
Files changed (47) hide show
  1. package/authoring/chat-models/nemotron3.5-lightning-30b-q4.json +3 -2
  2. package/authoring/chat-models/nemotron3.5-lightning-30b-q8.json +3 -2
  3. package/authoring/page-demo-stub.js +85 -0
  4. package/data/chat-models/index.json +1 -1
  5. package/data/chat-models/ne/nemotron3.5-lightning-30b-q4/manifest.json +3 -2
  6. package/data/chat-models/ne/nemotron3.5-lightning-30b-q8/manifest.json +3 -2
  7. package/data/connector-types/bl/bluesky-posts/manifest.json +16 -0
  8. package/data/connector-types/bl/bluesky-posts/versions/1.0.0/manifest.json +70 -0
  9. package/data/connector-types/gi/github-issues/versions/1.0.1/manifest.json +67 -0
  10. package/data/connector-types/in/instagram-media/manifest.json +17 -0
  11. package/data/connector-types/in/instagram-media/versions/1.0.0/manifest.json +70 -0
  12. package/data/connector-types/index.json +1 -1
  13. package/data/connector-types/x-/x-posts/manifest.json +17 -0
  14. package/data/connector-types/x-/x-posts/versions/1.0.0/manifest.json +66 -0
  15. package/data/craftbook-templates/dr/draft-social-post/manifest.json +23 -0
  16. package/data/craftbook-templates/dr/draft-social-post/versions/1.0.0/craftbook.json +176 -0
  17. package/data/craftbook-templates/dr/draft-social-post/versions/1.0.0/test.json +161 -0
  18. package/data/craftbook-templates/index.json +1 -1
  19. package/data/craftbook-templates/re/reception-report/manifest.json +21 -0
  20. package/data/craftbook-templates/re/reception-report/versions/1.0.0/craftbook.json +170 -0
  21. package/data/craftbook-templates/re/reception-report/versions/1.0.0/test.json +168 -0
  22. package/data/craftbook-templates/so/social-digest/manifest.json +21 -0
  23. package/data/craftbook-templates/so/social-digest/versions/1.0.0/craftbook.json +133 -0
  24. package/data/craftbook-templates/so/social-digest/versions/1.0.0/test.json +168 -0
  25. package/data/gezel-templates/index.json +1 -1
  26. package/data/gezel-templates/om/omroeper/manifest.json +20 -0
  27. package/data/gezel-templates/om/omroeper/versions/1.0.0/about.md +33 -0
  28. package/data/gezel-templates/om/omroeper/versions/1.0.0/manifest.json +23 -0
  29. package/data/project-types/im/image-feed/manifest.json +20 -0
  30. package/data/project-types/im/image-feed/versions/1.0.0/about.md +21 -0
  31. package/data/project-types/im/image-feed/versions/1.0.0/manifest.json +330 -0
  32. package/data/project-types/im/image-feed/versions/1.0.0/mission.md +10 -0
  33. package/data/project-types/im/image-feed/versions/1.0.0/pages/gallery/index.html +831 -0
  34. package/data/project-types/im/image-feed/versions/1.0.0/posts/index.json +4 -0
  35. package/data/project-types/im/image-feed/versions/1.0.0/style-guide.md +24 -0
  36. package/data/project-types/index.json +1 -1
  37. package/data/project-types/so/social-feed/manifest.json +20 -0
  38. package/data/project-types/so/social-feed/versions/1.0.0/about.md +19 -0
  39. package/data/project-types/so/social-feed/versions/1.0.0/manifest.json +330 -0
  40. package/data/project-types/so/social-feed/versions/1.0.0/mission.md +10 -0
  41. package/data/project-types/so/social-feed/versions/1.0.0/pages/dashboard/index.html +754 -0
  42. package/data/project-types/so/social-feed/versions/1.0.0/posts/index.json +4 -0
  43. package/data/project-types/so/social-feed/versions/1.0.0/voice-guide.md +22 -0
  44. package/package.json +1 -1
  45. package/schemas/connector-type-version.schema.json +6 -0
  46. package/schemas/craftbook-test.schema.json +1 -0
  47. package/schemas/project-type-version.schema.json +4 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "project-type",
4
+ "id": "social-feed",
5
+ "name": "Social Feed",
6
+ "description": "Run a social feed like a small newsroom — an Omroeper keeps every post moving through a real pipeline (idea to published, nothing by accident), a copywriter sharpens each variant for its platform, and a live board shows exactly where every post stands.",
7
+ "tags": [
8
+ "social",
9
+ "bluesky",
10
+ "x",
11
+ "posts",
12
+ "publishing"
13
+ ],
14
+ "category": "communication",
15
+ "maintainer": {
16
+ "name": "Gezel"
17
+ },
18
+ "license": "MIT",
19
+ "yankedVersions": []
20
+ }
@@ -0,0 +1,19 @@
1
+ The public voice of **{{brand}}** — drafted with care, posted on purpose.
2
+
3
+ Your crew: an Omroeper who runs the posting pipeline — every post moves
4
+ idea → draft → in-review → approved → scheduled → published, and nothing
5
+ goes out by accident — and a copywriter who turns rough notions into words
6
+ worth reading twice.
7
+
8
+ Posts live in the workspace under `posts/`, one folder per post, with a
9
+ variant per platform under `variants/`. New posts are staged in
10
+ `posts/_drafting/` and committed with the create_post tool. Open the
11
+ **Output** tab for the live pipeline board.
12
+
13
+ Where we post — Bluesky: {{blueskyHandle}} · X: {{xHandle}} (blank means
14
+ not connected yet).
15
+
16
+ The voice, in one line: {{voice}} The full guide lives in `voice-guide.md`
17
+ — read it before drafting anything.
18
+
19
+ Cadence we aim for: {{cadence}}.
@@ -0,0 +1,330 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "version": "1.0.0",
4
+ "releasedAt": "2026-08-12T00:00:00Z",
5
+ "extends": "social-media",
6
+ "meesterManaged": true,
7
+ "mode": "crew",
8
+ "leadLabel": "Omroeper",
9
+ "params": {
10
+ "type": "object",
11
+ "properties": {
12
+ "brand": {
13
+ "type": "string",
14
+ "title": "Who does this feed speak for?",
15
+ "description": "The name or brand this feed speaks for.",
16
+ "default": "Our brand"
17
+ },
18
+ "blueskyHandle": {
19
+ "type": "string",
20
+ "title": "Bluesky handle",
21
+ "description": "Bluesky handle (e.g. you.bsky.social). Leave empty if unused.",
22
+ "default": ""
23
+ },
24
+ "xHandle": {
25
+ "type": "string",
26
+ "title": "X handle",
27
+ "description": "X handle (e.g. @brand). Leave empty if unused.",
28
+ "default": ""
29
+ },
30
+ "voice": {
31
+ "type": "string",
32
+ "title": "Tone of voice",
33
+ "description": "One sentence on tone of voice — seeded into voice-guide.md.",
34
+ "default": "Warm, plain, and human — never corporate."
35
+ },
36
+ "cadence": {
37
+ "type": "string",
38
+ "title": "Posting cadence",
39
+ "description": "Target posting cadence (e.g. 3 posts a week).",
40
+ "default": "3 posts a week"
41
+ }
42
+ },
43
+ "required": [
44
+ "brand"
45
+ ]
46
+ },
47
+ "nameTemplate": "{{brand}} Social Feed",
48
+ "aboutTemplate": "about.md",
49
+ "missionTemplate": "mission.md",
50
+ "gezels": [
51
+ {
52
+ "templateId": "omroeper",
53
+ "voorman": true
54
+ },
55
+ {
56
+ "templateId": "copywriter"
57
+ }
58
+ ],
59
+ "scripts": {
60
+ "posts-store": "import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'posts-store',\n description:\n \"Read and update the post pipeline under posts/. Call action:'list' to see every post (optional status/platform filters); 'get' for one post's frontmatter, body, variants, and media; 'create' to commit the staged posts/_drafting/ folder as a real post; 'update' to patch frontmatter or move a post one pipeline stage (idea, draft, in-review, approved, scheduled, published, archived from anywhere); 'record_publication' to stamp a platform variant with its permalink after it goes live. Illegal stage jumps fail with the legal next stages — move one stage at a time.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'list', 'get', 'create', 'update', or 'record_publication'.\",\n required: true,\n },\n id: { type: 'string', description: \"Post id — its folder name under posts/, e.g. '2026-08-12-launch-teaser'.\" },\n status: {\n type: 'string',\n description: \"Target status: idea, draft, in-review, approved, scheduled, published, or archived. For 'create', the initial status (defaults to approved).\",\n },\n platform: { type: 'string', description: \"Platform filter for 'list', or the published variant for 'record_publication' (e.g. 'bluesky').\" },\n fields: {\n type: 'json',\n description: \"For 'update': an object of frontmatter patches, e.g. {\\\"scheduledFor\\\": \\\"2026-08-15T09:00:00Z\\\", \\\"campaign\\\": \\\"autumn\\\"}. A null value clears the key.\",\n },\n permalink: { type: 'string', description: 'Public URL of the live post, for record_publication.' },\n postId: { type: 'string', description: \"The platform's own id for the live post, for record_publication.\" },\n corpusRef: { type: 'string', description: 'Connector corpus record reference for the live post, when one exists.' },\n publishedAt: { type: 'string', description: 'ISO timestamp the post went live (defaults to now).' },\n markPublished: {\n type: 'boolean',\n description: 'For record_publication: force the post to published even if other target platforms are not stamped yet.',\n },\n },\n outputs: {\n posts: { type: 'json', description: \"For 'list': the post listing entries.\" },\n total: { type: 'number', description: \"For 'list': how many posts exist before filtering.\" },\n post: { type: 'json', description: \"For 'get': frontmatter plus body, variants map, and media names.\" },\n id: { type: 'string', description: 'The post id the action touched.' },\n status: { type: 'string', description: \"The post's status after the action.\" },\n summary: { type: 'string', description: 'Human-readable result.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\nconst POSTS_DIR = 'posts';\nconst DRAFTING = 'posts/_drafting';\nconst INDEX_FILE = 'posts/index.json';\nconst ID_RE = /^[a-z0-9][a-z0-9-]{0,99}$/;\nconst PLATFORM_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;\nconst STAGES = ['idea', 'draft', 'in-review', 'approved', 'scheduled', 'published'] as const;\nconst STATUSES: string[] = [...STAGES, 'archived'];\n\n// ── frontmatter ──\n// Hand-rolled, deliberately simple: `---`-delimited `key: value` lines,\n// arrays as [a, b]. Unknown keys and even unparseable lines are preserved\n// in order so a round-trip never loses what a human or another tool wrote.\n\ntype FmEntry = { key: string; value: string | string[] } | { raw: string };\ninterface FmDoc {\n entries: FmEntry[];\n body: string;\n}\n\nfunction parseArrayish(value: string): string[] {\n return value\n .slice(1, -1)\n .split(',')\n .map((part) => part.trim().replace(/^[\"']|[\"']$/g, ''))\n .filter((part) => part.length > 0);\n}\n\nfunction parseFrontmatter(text: string): FmDoc {\n const lines = text.split(/\\r?\\n/);\n if (lines[0]?.trim() !== '---') return { entries: [], body: text };\n const entries: FmEntry[] = [];\n let at = 1;\n for (; at < lines.length; at += 1) {\n const line = lines[at]!;\n if (line.trim() === '---') {\n return { entries, body: lines.slice(at + 1).join('\\n').replace(/^\\n/, '') };\n }\n const sep = line.indexOf(':');\n if (sep <= 0) {\n if (line.trim().length > 0) entries.push({ raw: line });\n continue;\n }\n const key = line.slice(0, sep).trim();\n const value = line.slice(sep + 1).trim();\n if (!key) {\n entries.push({ raw: line });\n continue;\n }\n entries.push({\n key,\n value: value.startsWith('[') && value.endsWith(']') ? parseArrayish(value) : value,\n });\n }\n return { entries: [], body: text };\n}\n\nfunction serializeFrontmatter(doc: FmDoc): string {\n const lines = doc.entries.map((entry) => {\n if ('raw' in entry) return entry.raw;\n const value = Array.isArray(entry.value) ? `[${entry.value.join(', ')}]` : entry.value;\n return `${entry.key}: ${value}`;\n });\n return `---\\n${lines.join('\\n')}\\n---\\n\\n${doc.body.replace(/^\\n+/, '')}`;\n}\n\nfunction fmGet(doc: FmDoc, key: string): string | string[] | undefined {\n for (const entry of doc.entries) {\n if ('key' in entry && entry.key === key) return entry.value;\n }\n return undefined;\n}\n\nfunction fmGetString(doc: FmDoc, key: string): string {\n const value = fmGet(doc, key);\n if (Array.isArray(value)) return value.join(', ');\n return value ?? '';\n}\n\nfunction fmGetArray(doc: FmDoc, key: string): string[] {\n const value = fmGet(doc, key);\n if (Array.isArray(value)) return value;\n if (typeof value === 'string' && value.length > 0) return [value];\n return [];\n}\n\nfunction fmSet(doc: FmDoc, key: string, value: string | string[]): void {\n for (const entry of doc.entries) {\n if ('key' in entry && entry.key === key) {\n entry.value = value;\n return;\n }\n }\n doc.entries.push({ key, value });\n}\n\nfunction fmDelete(doc: FmDoc, key: string): void {\n doc.entries = doc.entries.filter((entry) => !('key' in entry && entry.key === key));\n}\n\n// ── pipeline rules ──\n\nfunction legalNext(from: string): string[] {\n if (from === 'archived') return [];\n const at = STAGES.indexOf(from as (typeof STAGES)[number]);\n const forward = at >= 0 && at < STAGES.length - 1 ? [STAGES[at + 1]!] : [];\n return [...forward, 'archived'];\n}\n\nfunction assertTransition(id: string, from: string, to: string): void {\n if (to === from) return;\n if (!STATUSES.includes(to)) {\n throw new Error(`Unknown status '${to}'. Statuses: ${STATUSES.join(', ')}.`);\n }\n if (!legalNext(from).includes(to)) {\n const next = legalNext(from);\n throw new Error(\n `Cannot move '${id}' from ${from} to ${to} — posts move one stage at a time. ` +\n (next.length > 0 ? `Legal next: ${next.join(', ')}.` : `'${from}' is final; create a new post instead.`),\n );\n }\n}\n\nfunction requireId(): string {\n const id = (input.id ?? '').trim();\n if (!id) throw new Error(\"Pass 'id' — the post's folder name under posts/ (see action:'list').\");\n if (!ID_RE.test(id)) {\n throw new Error(`Invalid post id '${id}'. Ids are lowercase [a-z0-9-], no slashes or dots.`);\n }\n return id;\n}\n\nfunction today(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction slugify(text: string): string {\n return text\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .slice(0, 60);\n}\n\n// ── store ──\n\ninterface IndexEntry {\n id: string;\n title: string;\n status: string;\n platforms: string[];\n created: string;\n scheduledFor?: string;\n campaign?: string;\n}\n\nasync function readPostDoc(id: string): Promise<FmDoc> {\n let raw: string;\n try {\n raw = await gezel.fs.read(`${POSTS_DIR}/${id}/post.md`);\n } catch {\n throw new Error(`No post '${id}' — posts/${id}/post.md does not exist. Call action:'list' to see what does.`);\n }\n return parseFrontmatter(raw);\n}\n\nasync function writePostDoc(id: string, doc: FmDoc): Promise<void> {\n await gezel.fs.write(`${POSTS_DIR}/${id}/post.md`, serializeFrontmatter(doc));\n}\n\nfunction toIndexEntry(id: string, doc: FmDoc): IndexEntry {\n const entry: IndexEntry = {\n id,\n title: fmGetString(doc, 'title') || id,\n status: fmGetString(doc, 'status') || 'idea',\n platforms: fmGetArray(doc, 'platforms'),\n created: fmGetString(doc, 'created') || today(),\n };\n const scheduledFor = fmGetString(doc, 'scheduledFor');\n if (scheduledFor) entry.scheduledFor = scheduledFor;\n const campaign = fmGetString(doc, 'campaign');\n if (campaign) entry.campaign = campaign;\n return entry;\n}\n\nasync function regenerateIndex(): Promise<IndexEntry[]> {\n let names: string[] = [];\n try {\n names = (await gezel.fs.list(POSTS_DIR))\n .filter((entry) => entry.isDirectory && entry.name !== '_drafting' && ID_RE.test(entry.name))\n .map((entry) => entry.name);\n } catch {\n names = [];\n }\n const posts: IndexEntry[] = [];\n for (const name of names) {\n try {\n posts.push(toIndexEntry(name, await readPostDoc(name)));\n } catch {\n // A folder without a parseable post.md is not a post; leave it be.\n }\n }\n posts.sort((a, b) => (a.created === b.created ? (a.id < b.id ? 1 : -1) : a.created < b.created ? 1 : -1));\n await gezel.fs.write(INDEX_FILE, `${JSON.stringify({ version: 1, posts }, null, 2)}\\n`);\n return posts;\n}\n\nasync function readIndex(): Promise<IndexEntry[]> {\n try {\n const parsed = JSON.parse(await gezel.fs.read(INDEX_FILE)) as { posts?: IndexEntry[] };\n return Array.isArray(parsed.posts) ? parsed.posts : [];\n } catch {\n return [];\n }\n}\n\nasync function listVariants(id: string): Promise<Record<string, Record<string, unknown>>> {\n let names: string[] = [];\n try {\n names = (await gezel.fs.list(`${POSTS_DIR}/${id}/variants`))\n .filter((entry) => !entry.isDirectory && entry.name.endsWith('.md'))\n .map((entry) => entry.name);\n } catch {\n return {};\n }\n const variants: Record<string, Record<string, unknown>> = {};\n for (const name of names) {\n const platform = name.slice(0, -3);\n const doc = parseFrontmatter(await gezel.fs.read(`${POSTS_DIR}/${id}/variants/${name}`));\n const body = doc.body.trim();\n const variant: Record<string, unknown> = {\n charCount: Number(fmGetString(doc, 'charCount')) || body.length,\n body,\n };\n for (const key of ['publishedAt', 'permalink', 'postId', 'corpusRef']) {\n const value = fmGetString(doc, key);\n if (value) variant[key] = value;\n }\n variants[platform] = variant;\n }\n return variants;\n}\n\nasync function listMedia(id: string): Promise<string[]> {\n try {\n return (await gezel.fs.list(`${POSTS_DIR}/${id}/media`))\n .filter((entry) => !entry.isDirectory)\n .map((entry) => entry.name);\n } catch {\n return [];\n }\n}\n\n// ── actions ──\n\nif (input.action === 'list') {\n const all = await readIndex();\n let posts = all;\n if (input.status) posts = posts.filter((post) => post.status === input.status);\n if (input.platform) posts = posts.filter((post) => post.platforms.includes(input.platform!));\n const counts = STATUSES.map((status) => {\n const n = all.filter((post) => post.status === status).length;\n return n > 0 ? `${status} ${n}` : null;\n })\n .filter((line) => line !== null)\n .join(', ');\n gezel.output({\n posts,\n total: all.length,\n summary: all.length === 0 ? 'No posts yet — stage one in posts/_drafting/ and call create.' : `${all.length} post(s): ${counts}.`,\n });\n} else if (input.action === 'get') {\n const id = requireId();\n const doc = await readPostDoc(id);\n const fields: Record<string, unknown> = {};\n for (const entry of doc.entries) {\n if ('key' in entry) fields[entry.key] = entry.value;\n }\n const post = {\n id,\n ...fields,\n status: fmGetString(doc, 'status') || 'idea',\n platforms: fmGetArray(doc, 'platforms'),\n body: doc.body.trim(),\n variants: await listVariants(id),\n media: await listMedia(id),\n };\n gezel.output({\n post,\n id,\n status: post.status,\n summary: `'${fmGetString(doc, 'title') || id}' is ${post.status}; ${Object.keys(post.variants).length} variant(s), ${post.media.length} media file(s).`,\n });\n} else if (input.action === 'create') {\n let staged: FmDoc;\n try {\n staged = parseFrontmatter(await gezel.fs.read(`${DRAFTING}/post.md`));\n } catch {\n throw new Error('Nothing is staged — write posts/_drafting/post.md (frontmatter + body) first, then call create again.');\n }\n const slug = slugify(fmGetString(staged, 'slug') || fmGetString(staged, 'title'));\n if (!slug) throw new Error(\"The staged post.md needs a 'slug' or a 'title' in its frontmatter to name the post folder.\");\n const createdRaw = fmGetString(staged, 'created');\n const created = /^\\d{4}-\\d{2}-\\d{2}$/.test(createdRaw) ? createdRaw : today();\n const id = `${created}-${slug}`;\n if (!ID_RE.test(id)) throw new Error(`Derived post id '${id}' is invalid — keep slug to lowercase letters, digits, and dashes.`);\n const siblings = await gezel.fs.list(POSTS_DIR).catch(() => []);\n if (siblings.some((entry) => entry.name === id)) {\n throw new Error(`posts/${id}/ already exists. Change the staged slug (or created date) and call create again.`);\n }\n const status = input.status ?? 'approved';\n if (!STATUSES.includes(status)) {\n throw new Error(`'${status}' is not a status. Statuses: ${STATUSES.join(', ')}.`);\n }\n fmSet(staged, 'status', status);\n fmSet(staged, 'slug', slug);\n fmSet(staged, 'created', created);\n await gezel.fs.write(`${DRAFTING}/post.md`, serializeFrontmatter(staged));\n await gezel.fs.rename(DRAFTING, `${POSTS_DIR}/${id}`);\n await regenerateIndex();\n gezel.output({\n id,\n status,\n summary: `Committed posts/_drafting/ as posts/${id}/ with status ${status}.`,\n });\n} else if (input.action === 'update') {\n const id = requireId();\n const doc = await readPostDoc(id);\n const patches = (input.fields && typeof input.fields === 'object' && !Array.isArray(input.fields) ? input.fields : {}) as Record<string, unknown>;\n let nextStatus = input.status;\n if (typeof patches.status === 'string' && !nextStatus) nextStatus = patches.status;\n delete patches.status;\n if (!nextStatus && Object.keys(patches).length === 0) {\n throw new Error(\"Nothing to change — pass 'status' and/or a 'fields' object of frontmatter patches.\");\n }\n const from = fmGetString(doc, 'status') || 'idea';\n if (nextStatus) assertTransition(id, from, nextStatus);\n for (const [key, value] of Object.entries(patches)) {\n if (value === null || value === undefined) fmDelete(doc, key);\n else if (Array.isArray(value)) fmSet(doc, key, value.map((item) => String(item)));\n else fmSet(doc, key, String(value));\n }\n if (nextStatus) fmSet(doc, 'status', nextStatus);\n await writePostDoc(id, doc);\n await regenerateIndex();\n const status = nextStatus ?? from;\n gezel.output({\n id,\n status,\n summary: nextStatus && nextStatus !== from ? `Moved '${id}' from ${from} to ${status}.` : `Updated '${id}' (${status}).`,\n });\n} else if (input.action === 'record_publication') {\n const id = requireId();\n const platform = (input.platform ?? '').trim();\n if (!PLATFORM_RE.test(platform)) {\n throw new Error(\"Pass 'platform' — the variant that went live, e.g. 'bluesky', 'x', or 'instagram'.\");\n }\n const doc = await readPostDoc(id);\n const variantPath = `${POSTS_DIR}/${id}/variants/${platform}.md`;\n const variant = parseFrontmatter(await gezel.fs.read(variantPath).catch(() => '---\\n---\\n'));\n fmSet(variant, 'publishedAt', input.publishedAt ?? new Date().toISOString());\n if (input.permalink) fmSet(variant, 'permalink', input.permalink);\n if (input.postId) fmSet(variant, 'postId', input.postId);\n if (input.corpusRef) fmSet(variant, 'corpusRef', input.corpusRef);\n await gezel.fs.write(variantPath, serializeFrontmatter(variant));\n const targets = fmGetArray(doc, 'platforms');\n const stamped = await listVariants(id);\n const allLive = targets.length > 0 && targets.every((target) => typeof stamped[target]?.publishedAt === 'string');\n let status = fmGetString(doc, 'status') || 'idea';\n // Recording reality bypasses the stage chain: once every target variant\n // is live (or the caller says so), the post IS published.\n if (allLive || input.markPublished === true) {\n status = 'published';\n fmSet(doc, 'status', status);\n await writePostDoc(id, doc);\n }\n await regenerateIndex();\n const waiting = targets.filter((target) => typeof stamped[target]?.publishedAt !== 'string');\n gezel.output({\n id,\n status,\n summary:\n status === 'published'\n ? `Recorded ${platform} publication for '${id}' — the post is published.`\n : `Recorded ${platform} publication for '${id}'; still waiting on: ${waiting.join(', ') || '(none)'}.`,\n });\n} else {\n throw new Error(`Unknown action '${input.action}'. Use list, get, create, update, or record_publication.`);\n}\n"
61
+ },
62
+ "tools": [
63
+ {
64
+ "name": "list_posts",
65
+ "description": "See the whole post pipeline: every post with its status, platforms, and dates. Optionally filter by status or platform.",
66
+ "script": "posts-store",
67
+ "inputs": {
68
+ "type": "object",
69
+ "properties": {
70
+ "status": {
71
+ "type": "string",
72
+ "enum": [
73
+ "idea",
74
+ "draft",
75
+ "in-review",
76
+ "approved",
77
+ "scheduled",
78
+ "published",
79
+ "archived"
80
+ ],
81
+ "description": "Only posts currently in this status."
82
+ },
83
+ "platform": {
84
+ "type": "string",
85
+ "description": "Only posts targeting this platform, e.g. 'bluesky'."
86
+ }
87
+ }
88
+ },
89
+ "bind": {
90
+ "action": "list"
91
+ }
92
+ },
93
+ {
94
+ "name": "get_post",
95
+ "description": "Read one post in full: frontmatter, body, each platform variant with its character count (and permalink once live), and media files.",
96
+ "script": "posts-store",
97
+ "inputs": {
98
+ "type": "object",
99
+ "properties": {
100
+ "id": {
101
+ "type": "string",
102
+ "description": "Post id — its folder name under posts/, e.g. '2026-08-12-launch-teaser'."
103
+ }
104
+ },
105
+ "required": [
106
+ "id"
107
+ ]
108
+ },
109
+ "bind": {
110
+ "action": "get"
111
+ }
112
+ },
113
+ {
114
+ "name": "create_post",
115
+ "description": "Commit the staged posts/_drafting/ folder as a real post. Stage post.md (frontmatter + body), variants/, and media/ there first; the post folder is named from the created date and slug.",
116
+ "script": "posts-store",
117
+ "inputs": {
118
+ "type": "object",
119
+ "properties": {
120
+ "status": {
121
+ "type": "string",
122
+ "enum": [
123
+ "idea",
124
+ "draft",
125
+ "in-review",
126
+ "approved",
127
+ "scheduled",
128
+ "published",
129
+ "archived"
130
+ ],
131
+ "description": "Initial status for the new post. Defaults to approved."
132
+ }
133
+ }
134
+ },
135
+ "bind": {
136
+ "action": "create"
137
+ }
138
+ },
139
+ {
140
+ "name": "update_post",
141
+ "description": "Patch a post's frontmatter (fields object; null clears a key) and/or move it one pipeline stage. Stages run idea, draft, in-review, approved, scheduled, published — archived from anywhere.",
142
+ "script": "posts-store",
143
+ "inputs": {
144
+ "type": "object",
145
+ "properties": {
146
+ "id": {
147
+ "type": "string",
148
+ "description": "Post id."
149
+ },
150
+ "fields": {
151
+ "type": "object",
152
+ "description": "Frontmatter patches, e.g. {\"scheduledFor\": \"2026-08-15T09:00:00Z\", \"campaign\": \"autumn\"}."
153
+ },
154
+ "status": {
155
+ "type": "string",
156
+ "enum": [
157
+ "idea",
158
+ "draft",
159
+ "in-review",
160
+ "approved",
161
+ "scheduled",
162
+ "published",
163
+ "archived"
164
+ ],
165
+ "description": "Target status (one stage at a time)."
166
+ }
167
+ },
168
+ "required": [
169
+ "id"
170
+ ]
171
+ },
172
+ "bind": {
173
+ "action": "update"
174
+ }
175
+ },
176
+ {
177
+ "name": "record_publication",
178
+ "description": "The moment a variant goes live, stamp it with its permalink and platform post id. The post flips to published once every target platform is stamped (or markPublished is passed).",
179
+ "script": "posts-store",
180
+ "inputs": {
181
+ "type": "object",
182
+ "properties": {
183
+ "id": {
184
+ "type": "string",
185
+ "description": "Post id."
186
+ },
187
+ "platform": {
188
+ "type": "string",
189
+ "description": "The platform that went live, e.g. 'bluesky'."
190
+ },
191
+ "permalink": {
192
+ "type": "string",
193
+ "description": "Public URL of the live post."
194
+ },
195
+ "postId": {
196
+ "type": "string",
197
+ "description": "The platform's own id for the live post."
198
+ },
199
+ "corpusRef": {
200
+ "type": "string",
201
+ "description": "Connector corpus record reference, when one exists."
202
+ },
203
+ "publishedAt": {
204
+ "type": "string",
205
+ "description": "ISO timestamp it went live (defaults to now)."
206
+ },
207
+ "markPublished": {
208
+ "type": "boolean",
209
+ "description": "Force the post to published even with unstamped platforms."
210
+ }
211
+ },
212
+ "required": [
213
+ "id",
214
+ "platform"
215
+ ]
216
+ },
217
+ "bind": {
218
+ "action": "record_publication"
219
+ }
220
+ },
221
+ {
222
+ "name": "page_set_status",
223
+ "description": "Page-only: move a post between pipeline columns on the board.",
224
+ "script": "posts-store",
225
+ "inputs": {
226
+ "type": "object",
227
+ "properties": {
228
+ "id": {
229
+ "type": "string",
230
+ "description": "Post id."
231
+ },
232
+ "status": {
233
+ "type": "string",
234
+ "enum": [
235
+ "idea",
236
+ "draft",
237
+ "in-review",
238
+ "approved",
239
+ "scheduled",
240
+ "published",
241
+ "archived"
242
+ ],
243
+ "description": "The column the post was moved to."
244
+ }
245
+ },
246
+ "required": [
247
+ "id",
248
+ "status"
249
+ ]
250
+ },
251
+ "bind": {
252
+ "action": "update"
253
+ },
254
+ "reaction": {
255
+ "gezel": "omroeper",
256
+ "hideSeed": true,
257
+ "prompt": "[Feed board]: The user moved post {{output.id}} to '{{output.status}}' on the board. Reply with ONE short line acknowledging the move. If it landed in 'draft', offer to run the draft-social-post craftbook on it. Do not restate the pipeline."
258
+ }
259
+ },
260
+ {
261
+ "name": "page_get_post",
262
+ "description": "Page-only: the detail drawer's read of one post with variants and media.",
263
+ "script": "posts-store",
264
+ "inputs": {
265
+ "type": "object",
266
+ "properties": {
267
+ "id": {
268
+ "type": "string",
269
+ "description": "Post id."
270
+ }
271
+ },
272
+ "required": [
273
+ "id"
274
+ ]
275
+ },
276
+ "bind": {
277
+ "action": "get"
278
+ }
279
+ }
280
+ ],
281
+ "craftbooks": [
282
+ "draft-social-post",
283
+ "social-digest",
284
+ "reception-report"
285
+ ],
286
+ "schedules": [
287
+ {
288
+ "runMode": "night-shift",
289
+ "craftbook": "social-digest",
290
+ "consent": "ask",
291
+ "overlap": "skip"
292
+ },
293
+ {
294
+ "runMode": "scheduled",
295
+ "cron": "0 15 * * 1",
296
+ "craftbook": "reception-report",
297
+ "consent": "ask",
298
+ "overlap": "skip"
299
+ }
300
+ ],
301
+ "pages": {
302
+ "entry": "dashboard/index.html",
303
+ "api": 1,
304
+ "reads": [
305
+ {
306
+ "source": "workspace",
307
+ "path": "posts",
308
+ "subtree": true
309
+ },
310
+ {
311
+ "source": "workspace",
312
+ "path": "voice-guide.md"
313
+ },
314
+ {
315
+ "source": "artifacts",
316
+ "path": "data",
317
+ "subtree": true
318
+ }
319
+ ],
320
+ "tools": [
321
+ "page_set_status",
322
+ "page_get_post"
323
+ ]
324
+ },
325
+ "workspaceSeed": [
326
+ "voice-guide.md",
327
+ "posts/index.json"
328
+ ],
329
+ "notes": "posts/index.json is seeded at its subpath (applyProjectType preserves seed subpaths and creates parents) and is ALSO regenerated by posts-store on every mutation, so the board tolerates a missing or stale index. Posts live one folder per post under posts/ (post.md + variants/ + media/), staged in posts/_drafting/ until create_post commits them. page_set_status and page_get_post are the page-only twins of update_post/get_post — pages.tools names are removed from the model surface, so the model keeps its own read tools."
330
+ }
@@ -0,0 +1,10 @@
1
+ - {{brand}} keeps a steady cadence of {{cadence}} — the cadence is the promise.
2
+ - Every post walks the pipeline: idea → draft → in-review → approved →
3
+ scheduled → published. Statuses move when reality moves, one stage at a
4
+ time, and archived is always an honest exit.
5
+ - Nothing publishes without an approved variant for each target platform,
6
+ inside its character limit, in the voice of `voice-guide.md`.
7
+ - Published posts carry their permalinks — record_publication the moment a
8
+ post goes live, so the board never lies.
9
+ - Reception gets read, not guessed: the digest and the weekly reception
10
+ report inform what gets drafted next.