@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.
- package/authoring/chat-models/nemotron3.5-lightning-30b-q4.json +3 -2
- package/authoring/chat-models/nemotron3.5-lightning-30b-q8.json +3 -2
- package/authoring/page-demo-stub.js +85 -0
- package/data/chat-models/index.json +1 -1
- package/data/chat-models/ne/nemotron3.5-lightning-30b-q4/manifest.json +3 -2
- package/data/chat-models/ne/nemotron3.5-lightning-30b-q8/manifest.json +3 -2
- package/data/connector-types/bl/bluesky-posts/manifest.json +16 -0
- package/data/connector-types/bl/bluesky-posts/versions/1.0.0/manifest.json +70 -0
- package/data/connector-types/gi/github-issues/versions/1.0.1/manifest.json +67 -0
- package/data/connector-types/in/instagram-media/manifest.json +17 -0
- package/data/connector-types/in/instagram-media/versions/1.0.0/manifest.json +70 -0
- package/data/connector-types/index.json +1 -1
- package/data/connector-types/x-/x-posts/manifest.json +17 -0
- package/data/connector-types/x-/x-posts/versions/1.0.0/manifest.json +66 -0
- package/data/craftbook-templates/dr/draft-social-post/manifest.json +23 -0
- package/data/craftbook-templates/dr/draft-social-post/versions/1.0.0/craftbook.json +176 -0
- package/data/craftbook-templates/dr/draft-social-post/versions/1.0.0/test.json +161 -0
- package/data/craftbook-templates/index.json +1 -1
- package/data/craftbook-templates/re/reception-report/manifest.json +21 -0
- package/data/craftbook-templates/re/reception-report/versions/1.0.0/craftbook.json +170 -0
- package/data/craftbook-templates/re/reception-report/versions/1.0.0/test.json +168 -0
- package/data/craftbook-templates/so/social-digest/manifest.json +21 -0
- package/data/craftbook-templates/so/social-digest/versions/1.0.0/craftbook.json +133 -0
- package/data/craftbook-templates/so/social-digest/versions/1.0.0/test.json +168 -0
- package/data/gezel-templates/index.json +1 -1
- package/data/gezel-templates/om/omroeper/manifest.json +20 -0
- package/data/gezel-templates/om/omroeper/versions/1.0.0/about.md +33 -0
- package/data/gezel-templates/om/omroeper/versions/1.0.0/manifest.json +23 -0
- package/data/project-types/im/image-feed/manifest.json +20 -0
- package/data/project-types/im/image-feed/versions/1.0.0/about.md +21 -0
- package/data/project-types/im/image-feed/versions/1.0.0/manifest.json +330 -0
- package/data/project-types/im/image-feed/versions/1.0.0/mission.md +10 -0
- package/data/project-types/im/image-feed/versions/1.0.0/pages/gallery/index.html +831 -0
- package/data/project-types/im/image-feed/versions/1.0.0/posts/index.json +4 -0
- package/data/project-types/im/image-feed/versions/1.0.0/style-guide.md +24 -0
- package/data/project-types/index.json +1 -1
- package/data/project-types/so/social-feed/manifest.json +20 -0
- package/data/project-types/so/social-feed/versions/1.0.0/about.md +19 -0
- package/data/project-types/so/social-feed/versions/1.0.0/manifest.json +330 -0
- package/data/project-types/so/social-feed/versions/1.0.0/mission.md +10 -0
- package/data/project-types/so/social-feed/versions/1.0.0/pages/dashboard/index.html +754 -0
- package/data/project-types/so/social-feed/versions/1.0.0/posts/index.json +4 -0
- package/data/project-types/so/social-feed/versions/1.0.0/voice-guide.md +22 -0
- package/package.json +1 -1
- package/schemas/connector-type-version.schema.json +6 -0
- package/schemas/craftbook-test.schema.json +1 -0
- package/schemas/project-type-version.schema.json +4 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"schemaVersion":1,"kind":"project-type","count":20,"entries":[{"manifest":{"schemaVersion":1,"kind":"project-type","id":"caregiving-binder","name":"Caregiving Binder","description":"Coordinate a loved one's care without losing the thread. A discreet Zorgcoördinator (\"care coordinator\") keeps meds, appointments, and the day-to-day journal — and preps every doctor visit from the record. Nothing leaves this machine.","tags":["caregiving","health","family","medications","appointments"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"person":{"type":"string","title":"Who is the care for?","description":"A first name is enough — e.g. 'Mom', 'Jan'.","default":"Mom"}},"required":["person"]},"nameTemplate":"Caregiving Binder","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"zorgcoordinator","voorman":true}],"toolsets":[],"craftbooks":["visit-prep"],"scripts":{"care-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'care-store',\n description:\n \"Read and update the care record: medications (meds.json) and the care journal (care.json). Call action:'status' for active meds + upcoming appointments; 'add_med' / 'stop_med' on changes; 'log_entry' for journal notes and symptoms; 'log_appointment' with when (ISO); 'visit_prep' before a doctor visit.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'add_med', 'stop_med', 'log_entry', 'log_appointment', or 'visit_prep'.\",\n required: true,\n },\n name: { type: 'string', description: 'Medication name.' },\n dose: { type: 'string', description: \"Dose and schedule, e.g. '10mg mornings'.\" },\n prescriber: { type: 'string', description: 'Who prescribed it.' },\n id: { type: 'string', description: \"Medication id, e.g. 'med-2'.\" },\n kind: { type: 'string', description: \"Journal kind: 'note', 'symptom', or 'update'.\" },\n note: { type: 'string', description: 'The journal entry or appointment description.' },\n when: { type: 'string', description: 'Appointment time (ISO), e.g. 2026-08-02T14:00.' },\n },\n outputs: {\n meds: { type: 'number', description: 'Active medications.' },\n upcoming: { type: 'number', description: 'Upcoming appointments.' },\n summary: { type: 'string', description: 'The care picture (or the visit prep).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── care-rules-start ──\n// Pure derivations: upcoming appointments (journal events kind\n// 'appointment' with data.whenAt in the future) and the since-last-visit\n// window for visit prep.\n\nfunction upcomingAppointments(events, nowMs) {\n return events\n .filter(function (e) {\n var when = e.data && typeof e.data.whenAt === 'string' ? Date.parse(e.data.whenAt) : NaN;\n return e.kind === 'appointment' && isFinite(when) && when >= nowMs;\n })\n .sort(function (a, b) {\n return Date.parse(a.data.whenAt) - Date.parse(b.data.whenAt);\n });\n}\n\nfunction lastVisitMs(events, nowMs) {\n var past = events.filter(function (e) {\n var when = e.data && typeof e.data.whenAt === 'string' ? Date.parse(e.data.whenAt) : NaN;\n return e.kind === 'appointment' && isFinite(when) && when < nowMs;\n });\n if (past.length === 0) return null;\n return Math.max.apply(\n null,\n past.map(function (e) {\n return Date.parse(e.data.whenAt);\n }),\n );\n}\n\nfunction sinceWindow(events, nowMs) {\n var last = lastVisitMs(events, nowMs);\n return events.filter(function (e) {\n return e.kind !== 'appointment' && (last === null || Date.parse(e.at) > last);\n });\n}\n// ── care-rules-end ──\n\nconst meds = rosterStore(gezel.fs, 'meds.json', {\n stages: ['active', 'stopped'],\n idPrefix: 'med',\n});\nconst journal = logStore(gezel.fs, 'care.json');\n\nasync function resolveMed(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await meds.list({ status: 'active' })).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'med-2') or an exact active medication 'name'.\");\n}\n\nif (input.action === 'add_med') {\n if (!input.name || !input.dose) throw new Error(\"add_med needs 'name' and 'dose'.\");\n await meds.add({\n status: 'active',\n fields: {\n name: input.name,\n dose: input.dose,\n ...(input.prescriber ? { prescriber: input.prescriber } : {}),\n },\n });\n await journal.append({ kind: 'update', note: `Started ${input.name} (${input.dose}).` });\n} else if (input.action === 'stop_med') {\n const id = await resolveMed();\n const med = await meds.get(id);\n await meds.transition(id, 'stopped', input.note ? { note: input.note } : {});\n await journal.append({\n kind: 'update',\n note: `Stopped ${String(med?.fields.name ?? id)}${input.note ? ` — ${input.note}` : ''}.`,\n });\n} else if (input.action === 'log_entry') {\n if (!input.note) throw new Error(\"log_entry needs 'note'.\");\n const kind = input.kind === 'symptom' || input.kind === 'update' ? input.kind : 'note';\n await journal.append({ kind, note: input.note });\n} else if (input.action === 'log_appointment') {\n if (!input.note || !input.when) throw new Error(\"log_appointment needs 'note' and 'when' (ISO).\");\n await journal.append({ kind: 'appointment', note: input.note, data: { whenAt: input.when } });\n} else if (input.action !== 'status' && input.action !== 'visit_prep') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_med, stop_med, log_entry, log_appointment, or visit_prep.`,\n );\n}\n\nconst activeMeds = await meds.list({ status: 'active' });\nconst events = await journal.list();\nconst nowMs = Date.now();\nconst upcoming = upcomingAppointments(events, nowMs);\n\nlet summary: string;\nif (input.action === 'visit_prep') {\n const since = sinceWindow(events, nowMs);\n const medLines = activeMeds\n .map((m) => `- ${String(m.fields.name)} ${String(m.fields.dose ?? '')}`.trim())\n .join('\\n');\n const noteLines = since\n .map((e) => `- [${e.kind}] ${String(e.at).slice(0, 10)}: ${e.note ?? ''}`)\n .join('\\n');\n summary = `VISIT PREP\\nActive medications:\\n${medLines || '- none recorded'}\\nSince the last visit:\\n${noteLines || '- quiet — nothing logged'}\\nUpcoming:\\n${upcoming.map((e) => `- ${String(e.data?.whenAt).slice(0, 16)} ${e.note ?? ''}`).join('\\n') || '- none scheduled'}`;\n} else {\n const next = upcoming[0];\n summary = `${activeMeds.length} active medication(s). ${upcoming.length} upcoming appointment(s)${\n next ? ` — next: ${String(next.data?.whenAt).slice(0, 16)} ${next.note ?? ''}` : ''\n }. ${events.filter((e) => e.kind !== 'appointment').length} journal entr(ies).`;\n}\n\ngezel.output({\n meds: activeMeds.length,\n upcoming: upcoming.length,\n summary,\n});\n"},"tools":[{"name":"add_med","description":"Record a medication (name, dose/schedule, prescriber).","script":"care-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"dose":{"type":"string"},"prescriber":{"type":"string"}},"required":["name","dose"]},"bind":{"action":"add_med"}},{"name":"stop_med","description":"Mark a medication stopped (with why, when known).","script":"care-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}}},"bind":{"action":"stop_med"}},{"name":"log_entry","description":"Add a journal entry — a note, a symptom observation, an update.","script":"care-store","inputs":{"type":"object","properties":{"kind":{"type":"string","enum":["note","symptom","update"]},"note":{"type":"string"}},"required":["note"]},"bind":{"action":"log_entry"}},{"name":"log_appointment","description":"Record an appointment (what and when, ISO).","script":"care-store","inputs":{"type":"object","properties":{"note":{"type":"string"},"when":{"type":"string"}},"required":["note","when"]},"bind":{"action":"log_appointment"}},{"name":"care_status","description":"Active meds, upcoming appointments, journal count.","script":"care-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"visit_prep","description":"The one-page brief for the next doctor visit: meds + what happened since the last one.","script":"care-store","inputs":{"type":"object","properties":{}},"bind":{"action":"visit_prep"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"meds.json"},{"source":"workspace","path":"care.json"}]},"schedules":[],"workspaceSeed":["meds.json","care.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"checkers","name":"Checkers","description":"Play checkers against a gezel opponent on a live board. You move on the board; the Damspeler (\"checkers player\") sees the position, plays back, and keeps the table talk going.","tags":["checkers","game","board-game","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.1.2","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Damspeler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Checkers vs the Damspeler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"checkers-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the checkers game (game.json). Call action:'board' to see the position, the side to move, and every legal move; action:'ai_move' to play your move (from/to, optional moveThought); action:'state' for a light summary; action:'new_game' to reset, optionally with forceCaptures:false. An illegal ai_move fails with the full legal-move list — pick one of those and call again.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n from: { type: 'string', description: \"Origin square, e.g. 'c3'.\" },\n to: { type: 'string', description: \"Destination square (a jump sequence's final square).\" },\n moveThought: { type: 'string', description: 'A short line of table talk to show on the board (max 140 chars).' },\n forceCaptures: {\n type: 'boolean',\n description: \"For new_game: true for standard forced captures; false to keep ordinary moves legal when a capture is available.\",\n },\n },\n outputs: {\n board: { type: 'string', description: 'Legacy human-readable ASCII board with coordinates.' },\n modelState: {\n type: 'object',\n description:\n 'Authoritative model-facing state: orientation, square lists, capture availability, and structured legal moves.',\n },\n turn: { type: 'string', description: \"Whose move: 'user' or 'ai'.\" },\n status: { type: 'string', description: 'playing, user_won, or ai_won.' },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'c3-d4' or 'b6xd4'.\" },\n legalMoves: { type: 'string', description: 'Rendered legal moves for the side to move.' },\n captureRule: { type: 'string', description: \"Either 'forced' or 'optional'.\" },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── checkers-rules-start ──\n// Pure, annotation-free rules engine (American/English draughts, 8x8).\n// Squares: 64-char row-major string, index = y*8+x, y0 = rank 8, x0 = file a.\n// 'r'/'R' = red (the user, moving toward rank 8 / decreasing y);\n// 'b'/'B' = black (the AI, moving toward rank 1 / increasing y); '.' empty.\n// Kept self-contained between these sentinels so unit tests execute the\n// shipped bytes directly (new Function) on every platform.\n\nvar FILES = 'abcdefgh';\n\nfunction sqName(x, y) {\n return FILES[x] + String(8 - y);\n}\n\nfunction parseSq(name) {\n if (typeof name !== 'string' || name.length !== 2) return null;\n var x = FILES.indexOf(name[0].toLowerCase());\n var rank = Number(name[1]);\n if (x < 0 || !(rank >= 1 && rank <= 8)) return null;\n return { x: x, y: 8 - rank };\n}\n\nfunction pieceAt(squares, x, y) {\n return squares[y * 8 + x];\n}\n\nfunction withPiece(squares, x, y, ch) {\n var i = y * 8 + x;\n return squares.slice(0, i) + ch + squares.slice(i + 1);\n}\n\nfunction sideOf(ch) {\n if (ch === 'r' || ch === 'R') return 'user';\n if (ch === 'b' || ch === 'B') return 'ai';\n return null;\n}\n\nfunction isKing(ch) {\n return ch === 'R' || ch === 'B';\n}\n\nfunction kingRowY(side) {\n return side === 'user' ? 0 : 7;\n}\n\nfunction dirsFor(ch) {\n if (isKing(ch)) {\n return [\n { dx: -1, dy: -1 },\n { dx: 1, dy: -1 },\n { dx: -1, dy: 1 },\n { dx: 1, dy: 1 },\n ];\n }\n var dy = ch === 'r' ? -1 : 1;\n return [\n { dx: -1, dy: dy },\n { dx: 1, dy: dy },\n ];\n}\n\nfunction inBounds(x, y) {\n return x >= 0 && x < 8 && y >= 0 && y < 8;\n}\n\nfunction buildInitial() {\n var out = '';\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var dark = (x + y) % 2 === 1;\n if (dark && y <= 2) out += 'b';\n else if (dark && y >= 5) out += 'r';\n else out += '.';\n }\n }\n return out;\n}\n\n// All complete jump sequences for the piece at (x, y). A sequence keeps\n// jumping with the same piece while another capture is available; a man\n// that reaches the king row STOPS there (standard rule — the move ends on\n// crowning). Each result: { from, to, path, captures } (square names).\nfunction jumpSequencesFrom(squares, x, y, ch) {\n var side = sideOf(ch);\n var results = [];\n\n function explore(sq, cx, cy, curCh, path, captures) {\n var extended = false;\n var dirs = dirsFor(curCh);\n for (var i = 0; i < dirs.length; i += 1) {\n var mx = cx + dirs[i].dx;\n var my = cy + dirs[i].dy;\n var lx = cx + dirs[i].dx * 2;\n var ly = cy + dirs[i].dy * 2;\n if (!inBounds(lx, ly)) continue;\n var mid = pieceAt(sq, mx, my);\n if (sideOf(mid) === null || sideOf(mid) === side) continue;\n if (pieceAt(sq, lx, ly) !== '.') continue;\n\n var nextCh = curCh;\n var crowned = false;\n if (!isKing(curCh) && ly === kingRowY(side)) {\n nextCh = side === 'user' ? 'R' : 'B';\n crowned = true;\n }\n var nextSq = withPiece(withPiece(withPiece(sq, cx, cy, '.'), mx, my, '.'), lx, ly, nextCh);\n var nextPath = path.concat([sqName(lx, ly)]);\n var nextCaptures = captures.concat([sqName(mx, my)]);\n extended = true;\n if (crowned) {\n results.push({\n from: path[0],\n to: sqName(lx, ly),\n path: nextPath,\n captures: nextCaptures,\n });\n } else {\n explore(nextSq, lx, ly, nextCh, nextPath, nextCaptures);\n }\n }\n if (!extended && captures.length > 0) {\n results.push({ from: path[0], to: path[path.length - 1], path: path, captures: captures });\n }\n }\n\n explore(squares, x, y, ch, [sqName(x, y)], []);\n return results;\n}\n\n// Every legal move for `side`. With standard forced captures, any jump\n// masks quiet moves. In the optional-capture variant, both remain legal.\n// Simple moves are single diagonal steps.\nfunction legalMovesFor(squares, side, forceCaptures) {\n var jumps = [];\n var steps = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n if (sideOf(ch) !== side) continue;\n var seqs = jumpSequencesFrom(squares, x, y, ch);\n for (var j = 0; j < seqs.length; j += 1) jumps.push(seqs[j]);\n var dirs = dirsFor(ch);\n for (var i = 0; i < dirs.length; i += 1) {\n var nx = x + dirs[i].dx;\n var ny = y + dirs[i].dy;\n if (!inBounds(nx, ny) || pieceAt(squares, nx, ny) !== '.') continue;\n steps.push({\n from: sqName(x, y),\n to: sqName(nx, ny),\n path: [sqName(x, y), sqName(nx, ny)],\n captures: [],\n });\n }\n }\n }\n return forceCaptures !== false && jumps.length > 0 ? jumps : jumps.concat(steps);\n}\n\n// Apply a legal sequence, returning the new squares string. Crowns a man\n// that ends on the king row (mid-sequence crowning already ended the\n// sequence in jumpSequencesFrom, so end-of-path crowning is complete).\nfunction applySequence(squares, seq) {\n var start = parseSq(seq.from);\n var end = parseSq(seq.to);\n var ch = pieceAt(squares, start.x, start.y);\n var side = sideOf(ch);\n var next = withPiece(squares, start.x, start.y, '.');\n for (var i = 0; i < seq.captures.length; i += 1) {\n var cap = parseSq(seq.captures[i]);\n next = withPiece(next, cap.x, cap.y, '.');\n }\n var finalCh = ch;\n if (!isKing(ch) && end.y === kingRowY(side)) finalCh = side === 'user' ? 'R' : 'B';\n return withPiece(next, end.x, end.y, finalCh);\n}\n\n// The side to move with no legal move has lost (covers zero pieces too).\nfunction statusAfter(squares, sideToMove, forceCaptures) {\n if (legalMovesFor(squares, sideToMove, forceCaptures).length > 0) return 'playing';\n return sideToMove === 'user' ? 'ai_won' : 'user_won';\n}\n\nfunction describeMove(seq) {\n var joiner = seq.captures.length > 0 ? 'x' : '-';\n var text = seq.path.join(joiner);\n if (seq.captures.length > 0) text += ' (captures ' + seq.captures.join(', ') + ')';\n return text;\n}\n\nfunction renderAscii(squares) {\n var lines = [' a b c d e f g h'];\n for (var y = 0; y < 8; y += 1) {\n var row = String(8 - y);\n for (var x = 0; x < 8; x += 1) {\n row += ' ' + pieceAt(squares, x, y);\n }\n lines.push(row);\n }\n lines.push('r/R = red (your opponent, moving up). b/B = black (you, moving down). Capitals are kings.');\n return lines.join('\\n');\n}\n\nfunction renderPieces(squares) {\n var redMen = [];\n var redKings = [];\n var blackMen = [];\n var blackKings = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n var name = sqName(x, y);\n if (ch === 'r') redMen.push(name);\n else if (ch === 'R') redKings.push(name);\n else if (ch === 'b') blackMen.push(name);\n else if (ch === 'B') blackKings.push(name);\n }\n }\n return { redMen: redMen, redKings: redKings, blackMen: blackMen, blackKings: blackKings };\n}\n// ── checkers-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst STAGE_SIDES = { user: 'red', ai: 'black' };\n\ninterface GameState {\n version: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n userColor: string;\n forceCaptures: boolean;\n legalMoves: Array<{ from: string; to: string; path: string[]; captures: string[] }>;\n moveLog: Array<{\n by: 'user' | 'ai';\n from: string;\n to: string;\n captures: string[];\n at: string;\n moveThought?: string;\n }>;\n lastMove: { by: 'user' | 'ai'; from: string; to: string; captures: string[] } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction freshState(forceCaptures = true): GameState {\n const squares = buildInitial();\n return {\n version: 1,\n squares,\n turn: 'user',\n status: 'playing',\n userColor: 'red',\n forceCaptures,\n legalMoves: legalMovesFor(squares, 'user', forceCaptures),\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== 64) {\n throw new Error(`${STATE_FILE} is corrupt (squares missing); call action:'new_game' to reset.`);\n }\n return { ...freshState(parsed.forceCaptures !== false), ...parsed, forceCaptures: parsed.forceCaptures !== false } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves = state.status === 'playing' ? legalMovesFor(state.squares, state.turn, state.forceCaptures) : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction summarize(state: GameState): string {\n const counts = { r: 0, b: 0 };\n for (const ch of state.squares) {\n if (ch === 'r' || ch === 'R') counts.r += 1;\n if (ch === 'b' || ch === 'B') counts.b += 1;\n }\n if (state.status === 'user_won') return `Game over — the user (red) won. Red ${counts.r} pieces, black ${counts.b}.`;\n if (state.status === 'ai_won') return `Game over — you (black) won. Red ${counts.r} pieces, black ${counts.b}.`;\n return `Move ${state.moveLog.length + 1}: ${STAGE_SIDES[state.turn]} (${state.turn}) to move. Red ${counts.r} pieces, black ${counts.b}. Captures are ${state.forceCaptures ? 'forced' : 'optional'}.`;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.map(describeMove).join(', ') || '(none)';\n}\n\nfunction renderModelState(state: GameState) {\n return {\n status: state.status,\n sideToMove: STAGE_SIDES[state.turn],\n coordinateFormat: 'file-rank (for example e3)',\n orientation: { redForwardRankDelta: 1, blackForwardRankDelta: -1 },\n pieces: renderPieces(state.squares),\n capturePolicy: state.forceCaptures ? 'mandatory-if-available' : 'optional',\n capturesAvailable: state.legalMoves.some((move) => move.captures.length > 0),\n legalMoves: state.legalMoves.map((move) => ({\n from: move.from,\n to: move.to,\n path: move.path,\n captures: move.captures,\n type: move.captures.length > 0 ? 'capture' : 'step',\n })),\n };\n}\n\nfunction applyTurn(state: GameState, by: 'user' | 'ai', moveThought?: string): GameState {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(\n by === 'ai'\n ? \"It's not your turn — the user has not moved yet.\"\n : \"It's not your turn — the AI is still to move.\",\n );\n }\n if (!input.from || !input.to) throw new Error(\"Pass 'from' and 'to' squares, e.g. from:'c3' to:'d4'.\");\n const seq = state.legalMoves.find(\n (m) => m.from === input.from!.toLowerCase() && m.to === input.to!.toLowerCase(),\n );\n if (!seq) {\n throw new Error(\n `Illegal move ${input.from}-${input.to}. Legal moves for ${STAGE_SIDES[by]}: ${renderLegal(state)}`,\n );\n }\n state.squares = applySequence(state.squares, seq);\n const nextTurn = by === 'user' ? 'ai' : 'user';\n state.status = statusAfter(state.squares, nextTurn, state.forceCaptures) as GameState['status'];\n state.turn = nextTurn;\n const entry = {\n by,\n from: seq.from,\n to: seq.to,\n captures: seq.captures,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = { by, from: seq.from, to: seq.to, captures: seq.captures };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return state;\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove\n ? describeMove({ ...state.lastMove, path: [state.lastMove.from, state.lastMove.to] })\n : '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState(input.forceCaptures !== false));\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought ? input.moveThought.trim().slice(0, 140) : undefined;\n const before = state.legalMoves.find(\n (m) => m.from === (input.from ?? '').toLowerCase() && m.to === (input.to ?? '').toLowerCase(),\n );\n applyTurn(state, by, moveThought);\n state = await persist(state);\n lastMoveText = before ? describeMove(before) : `${input.from}-${input.to}`;\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n input.action === 'state'\n ? `${renderAscii(state.squares).split('\\n').slice(0, 9).join('\\n')}`\n : `${renderAscii(state.squares)}\\nCapture rule: ${state.forceCaptures ? 'forced' : 'optional'}.\\nTurn: ${STAGE_SIDES[state.turn]} (${state.turn}). Legal moves: ${renderLegal(state)}`,\n modelState: renderModelState(state),\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n captureRule: state.forceCaptures ? 'forced' : 'optional',\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative structured checkers position, whose turn it is, capture availability, and every legal move. A legacy ASCII board is included for human readability.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your (black's) move. Pass the from/to squares of a legal move; include a short moveThought to show on the board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string","description":"Origin square, e.g. 'b6'."},"to":{"type":"string","description":"Destination square (a jump's final landing square)."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["from","to"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's move, played from the board page.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"bind":{"action":"user_move"},"reaction":{"gezel":"checkers-player","prompt":"Your opponent played {{output.lastMove}}. The game engine returned this authoritative state:\n```json\n{{output.modelState}}\n```\nEvery `legalMoves` entry is legal. Its `type` and `captures` fields explicitly say whether it is a jump. Do not redraw the position as a grid, re-derive legality, recalculate capture availability, recount pieces, or question the stated orientation. Spend any thinking only on which listed move is strongest.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user's available moves, captures, threats, or tactics; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user's move. Answer strategy questions when asked.\nAct on the state above. If the game is over, do not move — one gracious line and offer new_game. Otherwise it is your turn: choose one listed move and call make_move with its `from` and `to` strings, including a short {{personality}} moveThought that follows the selected style. If make_move rejects it, choose one move from the legal list in the error and try again. After moving, reply with ONE short line of table talk that follows the selected style.","hideSeed":true}},{"name":"new_game","description":"Reset the board to the opening position (user moves first).","script":"game-store","inputs":{"type":"object","properties":{"forceCaptures":{"type":"boolean","description":"Whether captures are required when available. Defaults to true."}}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.1.2","1.1.1","1.1.0","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"chess","name":"Chess","description":"Play a complete game of chess against a gezel opponent on a live board. You move white; the Schaakspeler sees the exact position, replies with a legal move, and shares the table without spoiling your plans.","tags":["chess","game","board-game","strategy","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.3","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Schaakspeler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Chess vs the Schaakspeler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"chess-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the chess game (game.json). Call action:'board' to see the position and every legal move; action:'ai_move' to play your move (from/to, optional promotion and moveThought); action:'state' for a light summary; action:'new_game' to reset. Illegal moves fail with the engine's legal-move list.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n from: { type: 'string', description: \"Origin square, e.g. 'e7'.\" },\n to: { type: 'string', description: \"Destination square, e.g. 'e5'.\" },\n promotion: {\n type: 'string',\n description: \"Promotion piece: 'q', 'r', 'b', or 'n'. Defaults to queen.\",\n },\n moveThought: {\n type: 'string',\n description: 'A short line of table talk to show on the board (max 140 chars).',\n },\n },\n outputs: {\n board: { type: 'string', description: 'ASCII board with coordinates.' },\n turn: { type: 'string', description: \"Whose move: 'user' (white) or 'ai' (black).\" },\n status: {\n type: 'string',\n description: 'playing, user_won, ai_won, or draw.',\n },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'e2-e4'.\" },\n legalMoves: { type: 'string', description: 'Rendered legal moves for the side to move.' },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── chess-rules-start ──\n// Pure, annotation-free chess engine. Squares are a 64-character row-major\n// string: y0 is rank 8, x0 is file a. Uppercase pieces are white (the user);\n// lowercase pieces are black (the AI).\n\nvar FILES = 'abcdefgh';\nvar PROMOTIONS = ['q', 'r', 'b', 'n'];\n\nfunction sqName(x, y) {\n return FILES[x] + String(8 - y);\n}\n\nfunction parseSq(name) {\n if (typeof name !== 'string' || name.length !== 2) return null;\n var x = FILES.indexOf(name[0].toLowerCase());\n var rank = Number(name[1]);\n if (x < 0 || !(rank >= 1 && rank <= 8)) return null;\n return { x: x, y: 8 - rank };\n}\n\nfunction inBounds(x, y) {\n return x >= 0 && x < 8 && y >= 0 && y < 8;\n}\n\nfunction pieceAt(squares, x, y) {\n return squares[y * 8 + x];\n}\n\nfunction withPiece(squares, x, y, ch) {\n var i = y * 8 + x;\n return squares.slice(0, i) + ch + squares.slice(i + 1);\n}\n\nfunction sideOf(ch) {\n if (!ch || ch === '.') return null;\n return ch === ch.toUpperCase() ? 'user' : 'ai';\n}\n\nfunction otherSide(side) {\n return side === 'user' ? 'ai' : 'user';\n}\n\nfunction kindOf(ch) {\n return ch && ch !== '.' ? ch.toLowerCase() : '';\n}\n\nfunction buildInitial() {\n return 'rnbqkbnr' + 'pppppppp' + '.'.repeat(32) + 'PPPPPPPP' + 'RNBQKBNR';\n}\n\nfunction findKing(squares, side) {\n var needle = side === 'user' ? 'K' : 'k';\n var i = squares.indexOf(needle);\n return i < 0 ? null : { x: i % 8, y: Math.floor(i / 8) };\n}\n\nfunction isAttacked(squares, x, y, bySide) {\n var pawn = bySide === 'user' ? 'P' : 'p';\n var pawnY = y - (bySide === 'user' ? -1 : 1);\n for (var pdx of [-1, 1]) {\n var px = x - pdx;\n if (inBounds(px, pawnY) && pieceAt(squares, px, pawnY) === pawn) return true;\n }\n\n var knight = bySide === 'user' ? 'N' : 'n';\n var knightSteps = [\n [-2, -1],\n [-2, 1],\n [-1, -2],\n [-1, 2],\n [1, -2],\n [1, 2],\n [2, -1],\n [2, 1],\n ];\n for (var n of knightSteps) {\n if (inBounds(x + n[0], y + n[1]) && pieceAt(squares, x + n[0], y + n[1]) === knight) {\n return true;\n }\n }\n\n var rays = [\n [-1, 0, 'rq'],\n [1, 0, 'rq'],\n [0, -1, 'rq'],\n [0, 1, 'rq'],\n [-1, -1, 'bq'],\n [-1, 1, 'bq'],\n [1, -1, 'bq'],\n [1, 1, 'bq'],\n ];\n for (var ray of rays) {\n var rx = x + ray[0];\n var ry = y + ray[1];\n while (inBounds(rx, ry)) {\n var seen = pieceAt(squares, rx, ry);\n if (seen !== '.') {\n if (sideOf(seen) === bySide && ray[2].indexOf(kindOf(seen)) >= 0) return true;\n break;\n }\n rx += ray[0];\n ry += ray[1];\n }\n }\n\n var king = bySide === 'user' ? 'K' : 'k';\n for (var ky = -1; ky <= 1; ky += 1) {\n for (var kx = -1; kx <= 1; kx += 1) {\n if (\n (kx !== 0 || ky !== 0) &&\n inBounds(x + kx, y + ky) &&\n pieceAt(squares, x + kx, y + ky) === king\n ) {\n return true;\n }\n }\n }\n return false;\n}\n\nfunction inCheck(squares, side) {\n var king = findKing(squares, side);\n return !king || isAttacked(squares, king.x, king.y, otherSide(side));\n}\n\nfunction pushMove(out, fromX, fromY, toX, toY, extra) {\n out.push(\n Object.assign(\n {\n from: sqName(fromX, fromY),\n to: sqName(toX, toY),\n capture: false,\n },\n extra || {},\n ),\n );\n}\n\nfunction pseudoMovesFor(squares, side, castling, enPassant) {\n var out = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n if (sideOf(ch) !== side) continue;\n var kind = kindOf(ch);\n\n if (kind === 'p') {\n var dir = side === 'user' ? -1 : 1;\n var startY = side === 'user' ? 6 : 1;\n var promotionY = side === 'user' ? 0 : 7;\n var oneY = y + dir;\n if (inBounds(x, oneY) && pieceAt(squares, x, oneY) === '.') {\n if (oneY === promotionY) {\n for (var promotion of PROMOTIONS) {\n pushMove(out, x, y, x, oneY, { promotion: promotion });\n }\n } else {\n pushMove(out, x, y, x, oneY);\n var twoY = y + dir * 2;\n if (y === startY && pieceAt(squares, x, twoY) === '.') {\n pushMove(out, x, y, x, twoY, { doublePawn: true });\n }\n }\n }\n for (var dx of [-1, 1]) {\n var cx = x + dx;\n var cy = y + dir;\n if (!inBounds(cx, cy)) continue;\n var target = pieceAt(squares, cx, cy);\n var targetName = sqName(cx, cy);\n var isEp = targetName === enPassant && target === '.';\n if ((target !== '.' && sideOf(target) === otherSide(side)) || isEp) {\n if (cy === promotionY) {\n for (var capturePromotion of PROMOTIONS) {\n pushMove(out, x, y, cx, cy, {\n capture: true,\n promotion: capturePromotion,\n enPassant: isEp,\n });\n }\n } else {\n pushMove(out, x, y, cx, cy, { capture: true, enPassant: isEp });\n }\n }\n }\n continue;\n }\n\n if (kind === 'n') {\n var jumps = [\n [-2, -1],\n [-2, 1],\n [-1, -2],\n [-1, 2],\n [1, -2],\n [1, 2],\n [2, -1],\n [2, 1],\n ];\n for (var jump of jumps) {\n var nx = x + jump[0];\n var ny = y + jump[1];\n if (!inBounds(nx, ny) || sideOf(pieceAt(squares, nx, ny)) === side) continue;\n pushMove(out, x, y, nx, ny, { capture: pieceAt(squares, nx, ny) !== '.' });\n }\n continue;\n }\n\n if (kind === 'b' || kind === 'r' || kind === 'q') {\n var dirs = [];\n if (kind === 'b' || kind === 'q') dirs.push([-1, -1], [-1, 1], [1, -1], [1, 1]);\n if (kind === 'r' || kind === 'q') dirs.push([-1, 0], [1, 0], [0, -1], [0, 1]);\n for (var d of dirs) {\n var sx = x + d[0];\n var sy = y + d[1];\n while (inBounds(sx, sy)) {\n var occupant = pieceAt(squares, sx, sy);\n if (sideOf(occupant) === side) break;\n pushMove(out, x, y, sx, sy, { capture: occupant !== '.' });\n if (occupant !== '.') break;\n sx += d[0];\n sy += d[1];\n }\n }\n continue;\n }\n\n if (kind === 'k') {\n for (var kdy = -1; kdy <= 1; kdy += 1) {\n for (var kdx = -1; kdx <= 1; kdx += 1) {\n if (kdx === 0 && kdy === 0) continue;\n var ktx = x + kdx;\n var kty = y + kdy;\n if (!inBounds(ktx, kty) || sideOf(pieceAt(squares, ktx, kty)) === side) continue;\n pushMove(out, x, y, ktx, kty, { capture: pieceAt(squares, ktx, kty) !== '.' });\n }\n }\n\n var homeY = side === 'user' ? 7 : 0;\n var kingChar = side === 'user' ? 'K' : 'k';\n var rookChar = side === 'user' ? 'R' : 'r';\n var enemy = otherSide(side);\n if (x === 4 && y === homeY && ch === kingChar && !isAttacked(squares, 4, homeY, enemy)) {\n var kingRight = side === 'user' ? castling.indexOf('K') >= 0 : castling.indexOf('k') >= 0;\n if (\n kingRight &&\n pieceAt(squares, 7, homeY) === rookChar &&\n pieceAt(squares, 5, homeY) === '.' &&\n pieceAt(squares, 6, homeY) === '.' &&\n !isAttacked(squares, 5, homeY, enemy) &&\n !isAttacked(squares, 6, homeY, enemy)\n ) {\n pushMove(out, x, y, 6, homeY, { castle: 'king' });\n }\n var queenRight = side === 'user' ? castling.indexOf('Q') >= 0 : castling.indexOf('q') >= 0;\n if (\n queenRight &&\n pieceAt(squares, 0, homeY) === rookChar &&\n pieceAt(squares, 1, homeY) === '.' &&\n pieceAt(squares, 2, homeY) === '.' &&\n pieceAt(squares, 3, homeY) === '.' &&\n !isAttacked(squares, 3, homeY, enemy) &&\n !isAttacked(squares, 2, homeY, enemy)\n ) {\n pushMove(out, x, y, 2, homeY, { castle: 'queen' });\n }\n }\n }\n }\n }\n return out;\n}\n\nfunction applyUnchecked(squares, move, side) {\n var from = parseSq(move.from);\n var to = parseSq(move.to);\n var ch = pieceAt(squares, from.x, from.y);\n var next = withPiece(squares, from.x, from.y, '.');\n if (move.enPassant) {\n next = withPiece(next, to.x, from.y, '.');\n }\n if (move.castle === 'king') {\n next = withPiece(next, 7, from.y, '.');\n next = withPiece(next, 5, from.y, side === 'user' ? 'R' : 'r');\n } else if (move.castle === 'queen') {\n next = withPiece(next, 0, from.y, '.');\n next = withPiece(next, 3, from.y, side === 'user' ? 'R' : 'r');\n }\n if (move.promotion) ch = side === 'user' ? move.promotion.toUpperCase() : move.promotion;\n return withPiece(next, to.x, to.y, ch);\n}\n\nfunction legalMovesFor(squares, side, castling, enPassant) {\n return pseudoMovesFor(squares, side, castling || '', enPassant || null).filter(function (move) {\n return !inCheck(applyUnchecked(squares, move, side), side);\n });\n}\n\nfunction nextCastling(castling, squares, move) {\n var rights = castling || '';\n var ch = pieceAt(squares, parseSq(move.from).x, parseSq(move.from).y);\n if (ch === 'K') rights = rights.replace(/K|Q/g, '');\n if (ch === 'k') rights = rights.replace(/k|q/g, '');\n if (move.from === 'a1' || move.to === 'a1') rights = rights.replace('Q', '');\n if (move.from === 'h1' || move.to === 'h1') rights = rights.replace('K', '');\n if (move.from === 'a8' || move.to === 'a8') rights = rights.replace('q', '');\n if (move.from === 'h8' || move.to === 'h8') rights = rights.replace('k', '');\n return rights;\n}\n\nfunction nextEnPassant(move, side) {\n if (!move.doublePawn) return null;\n var from = parseSq(move.from);\n var to = parseSq(move.to);\n return sqName(from.x, (from.y + to.y) / 2);\n}\n\nfunction isInsufficientMaterial(squares) {\n var pieces = [];\n for (var i = 0; i < squares.length; i += 1) {\n var ch = squares[i];\n if (ch !== '.' && kindOf(ch) !== 'k') pieces.push({ ch: ch, x: i % 8, y: Math.floor(i / 8) });\n }\n if (pieces.length === 0) return true;\n if (pieces.length === 1 && (kindOf(pieces[0].ch) === 'b' || kindOf(pieces[0].ch) === 'n')) {\n return true;\n }\n if (\n pieces.length > 0 &&\n pieces.every(function (p) {\n return kindOf(p.ch) === 'b';\n })\n ) {\n var color = (pieces[0].x + pieces[0].y) % 2;\n return pieces.every(function (p) {\n return (p.x + p.y) % 2 === color;\n });\n }\n return false;\n}\n\nfunction positionKey(squares, side, castling, enPassant) {\n var effectiveEnPassant =\n enPassant &&\n legalMovesFor(squares, side, castling, enPassant).some(function (move) {\n return move.enPassant;\n })\n ? enPassant\n : '-';\n return [squares, side, castling || '-', effectiveEnPassant].join('|');\n}\n\nfunction statusAfter(squares, sideToMove, castling, enPassant) {\n if (isInsufficientMaterial(squares)) return 'draw';\n var moves = legalMovesFor(squares, sideToMove, castling, enPassant);\n if (moves.length > 0) return 'playing';\n if (!inCheck(squares, sideToMove)) return 'draw';\n return sideToMove === 'user' ? 'ai_won' : 'user_won';\n}\n\nfunction describeMove(move) {\n if (move.castle === 'king') return move.from + '-' + move.to + ' (O-O)';\n if (move.castle === 'queen') return move.from + '-' + move.to + ' (O-O-O)';\n var text = move.from + (move.capture ? 'x' : '-') + move.to;\n if (move.promotion) text += '=' + move.promotion.toUpperCase();\n if (move.enPassant) text += ' e.p.';\n return text;\n}\n\nfunction renderAscii(squares) {\n var lines = [' a b c d e f g h'];\n for (var y = 0; y < 8; y += 1) {\n var row = String(8 - y);\n for (var x = 0; x < 8; x += 1) row += ' ' + pieceAt(squares, x, y);\n lines.push(row + ' ' + String(8 - y));\n }\n lines.push(' a b c d e f g h');\n lines.push('Uppercase = white (user). Lowercase = black (you).');\n return lines.join('\\n');\n}\n// ── chess-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst SIDE_NAMES = { user: 'white', ai: 'black' };\n\ninterface ChessMove {\n from: string;\n to: string;\n capture: boolean;\n promotion?: string;\n doublePawn?: boolean;\n enPassant?: boolean;\n castle?: string;\n}\n\ninterface GameState {\n version: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n castling: string;\n enPassant: string | null;\n halfmoveClock: number;\n fullmoveNumber: number;\n positionHistory: string[];\n legalMoves: ChessMove[];\n moveLog: Array<{\n by: 'user' | 'ai';\n from: string;\n to: string;\n promotion?: string;\n notation: string;\n at: string;\n moveThought?: string;\n }>;\n lastMove: {\n by: 'user' | 'ai';\n from: string;\n to: string;\n promotion?: string;\n notation: string;\n } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction freshState(): GameState {\n const squares = buildInitial();\n const castling = 'KQkq';\n return {\n version: 1,\n squares,\n turn: 'user',\n status: 'playing',\n castling,\n enPassant: null,\n halfmoveClock: 0,\n fullmoveNumber: 1,\n positionHistory: [positionKey(squares, 'user', castling, null)],\n legalMoves: legalMovesFor(squares, 'user', castling, null),\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== 64) {\n throw new Error(`${STATE_FILE} is corrupt; call action:'new_game' to reset.`);\n }\n return {\n ...freshState(),\n ...parsed,\n castling: typeof parsed.castling === 'string' ? parsed.castling : '',\n enPassant: parsed.enPassant ?? null,\n } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves =\n state.status === 'playing'\n ? legalMovesFor(state.squares, state.turn, state.castling, state.enPassant)\n : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.map(describeMove).join(', ') || '(none)';\n}\n\nfunction summarize(state: GameState): string {\n if (state.status === 'user_won') return 'Checkmate — the user (white) won.';\n if (state.status === 'ai_won') return 'Checkmate — you (black) won.';\n if (state.status === 'draw') return 'Game over — draw.';\n const check = inCheck(state.squares, state.turn) ? ' The king is in check.' : '';\n return `Move ${state.fullmoveNumber}: ${SIDE_NAMES[state.turn]} (${state.turn}) to move.${check}`;\n}\n\nfunction applyTurn(\n state: GameState,\n by: 'user' | 'ai',\n moveThought?: string,\n): { state: GameState; move: ChessMove } {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(by === 'ai' ? \"It's not your turn yet.\" : 'The opponent is still to move.');\n }\n if (!input.from || !input.to) throw new Error(\"Pass 'from' and 'to' squares.\");\n const wantedPromotion = (input.promotion || 'q').toLowerCase();\n const candidates = state.legalMoves.filter(\n (move) =>\n move.from === input.from!.toLowerCase() &&\n move.to === input.to!.toLowerCase(),\n );\n const move =\n candidates.find((candidate) => (candidate.promotion || 'q') === wantedPromotion) ??\n candidates.find((candidate) => !candidate.promotion);\n if (!move) {\n throw new Error(\n `Illegal move ${input.from}-${input.to}. Legal moves for ${SIDE_NAMES[by]}: ${renderLegal(state)}`,\n );\n }\n\n const movingPiece = pieceAt(\n state.squares,\n parseSq(move.from)!.x,\n parseSq(move.from)!.y,\n );\n const oldSquares = state.squares;\n state.castling = nextCastling(state.castling, oldSquares, move);\n state.squares = applyUnchecked(oldSquares, move, by);\n state.enPassant = nextEnPassant(move, by);\n state.halfmoveClock =\n kindOf(movingPiece) === 'p' || move.capture ? 0 : state.halfmoveClock + 1;\n if (by === 'ai') state.fullmoveNumber += 1;\n state.turn = otherSide(by) as 'user' | 'ai';\n const key = positionKey(state.squares, state.turn, state.castling, state.enPassant);\n state.positionHistory.push(key);\n state.status = statusAfter(\n state.squares,\n state.turn,\n state.castling,\n state.enPassant,\n ) as GameState['status'];\n if (\n state.status === 'playing' &&\n (state.halfmoveClock >= 100 ||\n state.positionHistory.filter((candidate) => candidate === key).length >= 3)\n ) {\n state.status = 'draw';\n }\n\n const notation = describeMove(move);\n const entry = {\n by,\n from: move.from,\n to: move.to,\n ...(move.promotion ? { promotion: move.promotion } : {}),\n notation,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = {\n by,\n from: move.from,\n to: move.to,\n ...(move.promotion ? { promotion: move.promotion } : {}),\n notation,\n };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return { state, move };\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove?.notation ?? '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState());\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought?.trim().slice(0, 140);\n const applied = applyTurn(state, by, moveThought);\n state = await persist(applied.state);\n lastMoveText = describeMove(applied.move);\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n `${renderAscii(state.squares)}\\nTurn: ${SIDE_NAMES[state.turn]} (${state.turn}).` +\n `${inCheck(state.squares, state.turn) && state.status === 'playing' ? ' CHECK.' : ''}\\n` +\n `Legal moves: ${renderLegal(state)}`,\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative chess position, whose turn it is, and every legal move.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your black move. Pass from/to; for promotion optionally choose 'q', 'r', 'b', or 'n'. Include a short moveThought for the board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string","description":"Origin square, e.g. 'e7'."},"to":{"type":"string","description":"Destination square, e.g. 'e5'."},"promotion":{"type":"string","enum":["q","r","b","n"],"description":"Promotion piece. Defaults to queen."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["from","to"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's white move, played from the chess board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"promotion":{"type":"string","enum":["q","r","b","n"]}},"required":["from","to"]},"bind":{"action":"user_move"},"reaction":{"gezel":"chess-player","prompt":"Your opponent played {{output.lastMove}}. Status: {{output.status}}. Board now:\n{{output.board}}\nThe board and Legal moves line come directly from the chess engine and are always correct. Do not re-derive the position, dispute a listed move, or invent a move outside that list. Spend your thought on choosing among the listed legal moves.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user’s available moves, threats, tactics, or your plan; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user’s move. Answer strategy questions when asked.\nAct on this latest position. If the game is over, do not move — give one gracious line and offer a new game. Otherwise choose a strong legal move and play it with make_move, including a short {{personality}} moveThought that follows the selected style. If make_move rejects it, choose one move verbatim from the legal list in the error and retry. After moving, reply with ONE short line of table talk.","hideSeed":true}},{"name":"new_game","description":"Reset the chess board to the standard opening position.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.0.3","1.0.2","1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"design-scheme","name":"Design Scheme","description":"Explore color schemes and design directions with a designer. Palettes are saved as artifacts and shown on a live gallery.","tags":["design","color","palette","branding","prototype"],"category":"creative","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"design-prototype","params":{"type":"object","properties":{"brief":{"type":"string","title":"What are you designing?","description":"A short brief — e.g. \"a calm meditation app\", \"a bold sports brand\".","default":"a new brand"}},"required":["brief"]},"nameTemplate":"Design Scheme: {{brief}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"designer","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"scheme-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'scheme-store',\n description:\n \"Save and list the project's design color schemes. Call action:'save' with a name and an array of hex colors to add a scheme (it lands in artifacts/schemes/ and shows on the project's gallery). Call action:'list' to see what's saved.\",\n inputs: {\n action: { type: 'string', description: \"'save' or 'list'.\", required: true },\n name: { type: 'string', description: \"Scheme name (required for 'save'), e.g. 'Dusk'.\" },\n colors: {\n type: 'json',\n description: \"Array of hex colors for 'save', e.g. [\\\"#1b1a2e\\\", \\\"#e0894a\\\", \\\"#f5f0e8\\\"].\",\n },\n },\n outputs: {\n count: { type: 'number', description: 'How many schemes are saved.' },\n message: { type: 'string', description: 'Human-readable summary.' },\n },\n requires: ['artifacts.read', 'artifacts.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\ninterface Scheme {\n name: string;\n file: string;\n colors: string[];\n}\n\nconst INDEX = 'schemes/index.json';\n\nasync function loadIndex(): Promise<Scheme[]> {\n try {\n const raw = await gezel.artifacts.read(INDEX);\n const parsed = JSON.parse(raw) as { schemes?: Scheme[] };\n return Array.isArray(parsed.schemes) ? parsed.schemes : [];\n } catch {\n return [];\n }\n}\n\nfunction slugify(s: string): string {\n return (\n s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'scheme'\n );\n}\n\nconst schemes = await loadIndex();\n\nif (input.action === 'save') {\n const name = (input.name ?? '').trim();\n const colors = Array.isArray(input.colors) ? input.colors.map(String) : [];\n if (!name || colors.length === 0) {\n gezel.output({ count: schemes.length, message: 'save needs a name and a non-empty colors array.' });\n } else {\n const file = `schemes/${slugify(name)}.json`;\n await gezel.artifacts.write(file, `${JSON.stringify({ name, colors }, null, 2)}\\n`);\n const next = [...schemes.filter((s) => s.file !== file), { name, file, colors }];\n await gezel.artifacts.write(INDEX, `${JSON.stringify({ schemes: next }, null, 2)}\\n`);\n gezel.output({ count: next.length, message: `Saved \"${name}\" (${colors.length} colors). ${next.length} scheme(s) total.` });\n }\n} else {\n gezel.output({\n count: schemes.length,\n message: schemes.length\n ? `${schemes.length} scheme(s): ${schemes.map((s) => s.name).join(', ')}.`\n : 'No schemes saved yet.',\n });\n}\n"},"tools":[{"name":"save_scheme","description":"Save a named color scheme so it shows on the gallery.","script":"scheme-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"colors":{"type":"array","items":{"type":"string"}}}},"bind":{"action":"save"}}],"pages":{"entry":"gallery/index.html","reads":[{"source":"artifacts","path":"schemes/index.json"}]},"schedules":[],"workspaceSeed":[],"artifactsSeed":["schemes/index.json","schemes/warm-dusk.json"],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"email","name":"Email / Inbox","description":"Sync an email account into a project as canonical markdown, then triage, draft, and summarize correspondence with a gezel.","tags":["email","inbox","triage","correspondence","builtin"],"category":"communication","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-07-06T00:00:00Z","extends":"email","aboutTemplate":"about.md","gezels":[],"toolsets":[],"craftbooks":[],"tools":[],"schedules":[],"workspaceSeed":[],"artifactsSeed":[],"notes":"Wraps the native mail pipeline (packages/service/src/mail) as a catalog entry so it appears in the New Project gallery alongside custom project types. Mailbox linking (IMAP / OAuth) remains a native creation step; this manifest carries no toolsets or scripts.","availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"event-planner","name":"Event Planner","description":"Plan the wedding, the milestone birthday, the reunion. A composed Ceremoniemeester (\"master of ceremonies\") keeps the guest list sacred, the budget honest, and the day-of runbook ready — with a live countdown board.","tags":["event","wedding","party","guests","planning"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"occasion":{"type":"string","title":"What are we celebrating?","description":"e.g. 'our wedding', 'Dad's 70th', 'the class reunion'.","default":"a big celebration"},"date":{"type":"string","title":"When is it?","description":"The date, e.g. 2026-09-12. A rough date is fine — change it later.","default":"2026-12-31"}},"required":["occasion","date"]},"nameTemplate":"Event: {{occasion}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"ceremoniemeester","voorman":true}],"toolsets":[],"craftbooks":["runbook"],"scripts":{"event-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'event-store',\n description:\n \"Read and update the event: guests (guests.json) and spending (budget.json). Call action:'status' for headcount + budget + countdown; 'add_guest' as invitations go out; 'guest_status' as RSVPs land (invited/yes/no); 'record_spend' when money is committed (ordinary units); 'guest_list' to read everyone.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'add_guest', 'guest_status', 'record_spend', or 'guest_list'.\",\n required: true,\n },\n name: { type: 'string', description: 'Guest name (party name is fine).' },\n party: { type: 'number', description: 'How many people this entry covers (default 1).' },\n status: { type: 'string', description: \"RSVP status: 'invited', 'yes', or 'no'.\" },\n id: { type: 'string', description: \"Guest id, e.g. 'guest-4'.\" },\n amount: { type: 'number', description: 'Spend amount in ordinary units.' },\n category: { type: 'string', description: \"Spend category, e.g. 'venue', 'catering'.\" },\n note: { type: 'string', description: 'Short note.' },\n },\n outputs: {\n invited: { type: 'number', description: 'People invited (sum of parties).' },\n coming: { type: 'number', description: \"People who said yes.\" },\n spentCents: { type: 'number', description: 'Total committed spend, in cents.' },\n summary: { type: 'string', description: 'The event picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── event-rules-start ──\n// Pure derivations: headcounts weighted by party size, and the countdown.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var abs = Math.abs(cents);\n return (cents < 0 ? '-' : '') + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction partyOf(record) {\n var p = record.fields && record.fields.party;\n return typeof p === 'number' && p >= 1 ? Math.floor(p) : 1;\n}\n\nfunction headcounts(records) {\n var invited = 0;\n var coming = 0;\n var awaiting = 0;\n records.forEach(function (r) {\n var size = partyOf(r);\n invited += size;\n if (r.status === 'yes') coming += size;\n if (r.status === 'invited') awaiting += size;\n });\n return { invited: invited, coming: coming, awaiting: awaiting };\n}\n\nfunction daysUntil(dateStr, nowMs) {\n var when = Date.parse(dateStr);\n if (!isFinite(when)) return null;\n return Math.ceil((when - nowMs) / (24 * 60 * 60 * 1000));\n}\n\nfunction eventLine(records, spentCents, eventDate, nowMs) {\n var heads = headcounts(records);\n var days = daysUntil(eventDate, nowMs);\n return (\n (days === null ? '' : days >= 0 ? days + ' day(s) to go. ' : 'The day has passed. ') +\n heads.coming +\n ' coming, ' +\n heads.awaiting +\n ' awaiting reply (' +\n heads.invited +\n ' invited). Spent ' +\n fmtCents(spentCents) +\n '.'\n );\n}\n// ── event-rules-end ──\n\nconst guests = rosterStore(gezel.fs, 'guests.json', {\n stages: ['invited', 'yes', 'no'],\n idPrefix: 'guest',\n});\nconst budget = ledgerStore(gezel.fs, 'budget.json');\n\nasync function readEventDate(): Promise<string> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('guests.json')) as { eventDate?: string };\n return typeof parsed.eventDate === 'string' ? parsed.eventDate : '';\n } catch {\n return '';\n }\n}\n\nasync function resolveGuest(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await guests.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'guest-4') or an exact guest 'name'.\");\n}\n\nif (input.action === 'add_guest') {\n if (!input.name) throw new Error(\"add_guest needs 'name'.\");\n await guests.add({\n status: 'invited',\n fields: {\n name: input.name,\n ...(typeof input.party === 'number' && input.party >= 1 ? { party: Math.floor(input.party) } : {}),\n ...(input.note ? { note: input.note } : {}),\n },\n });\n} else if (input.action === 'guest_status') {\n if (!input.status || !['invited', 'yes', 'no'].includes(input.status)) {\n throw new Error(\"guest_status needs status 'invited', 'yes', or 'no'.\");\n }\n await guests.transition(await resolveGuest(), input.status, input.note ? { note: input.note } : {});\n} else if (input.action === 'record_spend') {\n const cents = toCents(input.amount);\n if (cents === null || !input.category) {\n throw new Error(\"record_spend needs a positive 'amount' and a 'category' (e.g. 'venue').\");\n }\n await budget.add({\n amountCents: cents,\n kind: 'out',\n category: input.category.toLowerCase(),\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action !== 'status' && input.action !== 'guest_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_guest, guest_status, record_spend, or guest_list.`,\n );\n}\n\nconst records = await guests.list();\nconst totals = await budget.totals();\nconst heads = headcounts(records);\nconst eventDate = await readEventDate();\nconst listing =\n input.action === 'guest_list'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.name ?? '?')}${partyOf(r) > 1 ? ` (party of ${partyOf(r)})` : ''}`,\n )\n .join('\\n') || 'The guest list is empty.'\n : '';\n\ngezel.output({\n invited: heads.invited,\n coming: heads.coming,\n spentCents: totals.outCents,\n summary: listing || eventLine(records, totals.outCents, eventDate, Date.now()),\n});\n"},"tools":[{"name":"add_guest","description":"Add a guest or party to the list (status starts at invited).","script":"event-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"party":{"type":"number"},"note":{"type":"string"}},"required":["name"]},"bind":{"action":"add_guest"}},{"name":"guest_status","description":"Record an RSVP: 'yes', 'no', or back to 'invited'.","script":"event-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["invited","yes","no"]},"note":{"type":"string"}},"required":["status"]},"bind":{"action":"guest_status"}},{"name":"record_spend","description":"Record committed spending (deposit, balance, purchase) in ordinary units.","script":"event-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"record_spend"}},{"name":"event_status","description":"Countdown, headcount, and budget in one line.","script":"event-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"guest_list","description":"Read the whole guest list with statuses.","script":"event-store","inputs":{"type":"object","properties":{}},"bind":{"action":"guest_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"guests.json"},{"source":"workspace","path":"budget.json"}]},"schedules":[],"workspaceSeed":["guests.json","budget.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"fitness-coach","name":"Fitness Coach","description":"A training habit that survives real life. A steady Coach logs every session, guards the streak, and adjusts the weekly plan to fit your schedule — with a live board showing the week and the streak.","tags":["fitness","training","habits","streak","health"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"goal":{"type":"string","title":"What are you training toward?","description":"e.g. 'feel strong again', 'a 10k in October'.","default":"feeling strong and consistent"},"weeklyTarget":{"type":"number","title":"Sessions per week","description":"How many training sessions a week to aim for.","default":3}},"required":["goal","weeklyTarget"]},"nameTemplate":"Fitness: {{goal}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"coach","voorman":true}],"toolsets":[],"craftbooks":["weekly-review"],"scripts":{"training-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'training-store',\n description:\n \"Read and update the training log (training.json). Call action:'status' for streak + this week vs the target; 'log_workout' the moment a session is reported (activity + minutes); 'set_target' to change the weekly session target; 'history' for the recent sessions.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'log_workout', 'set_target', or 'history'.\",\n required: true,\n },\n activity: { type: 'string', description: \"What they did, e.g. 'run', 'gym', 'long walk'.\" },\n minutes: { type: 'number', description: 'How long the session ran, in minutes.' },\n note: { type: 'string', description: 'Anything worth remembering about the session.' },\n weeklyTarget: { type: 'number', description: 'Sessions per week to aim for (set_target).' },\n },\n outputs: {\n streakDays: { type: 'number', description: 'Consecutive days with a session, ending at the latest.' },\n thisWeek: { type: 'number', description: 'Sessions logged this week (Monday UTC start).' },\n weeklyTarget: { type: 'number', description: 'The current weekly session target.' },\n summary: { type: 'string', description: 'The training picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── fitness-rules-start ──\n// Pure derivations over logStore-shaped workout events: kind 'workout',\n// data {activity, minutes}. Weeks start Monday 00:00 UTC; nowIso is a\n// parameter so the rules stay clock-independent and testable.\n\nfunction weekStartMs(nowIso) {\n var day = new Date(nowIso.slice(0, 10) + 'T00:00:00Z');\n var sinceMonday = (day.getUTCDay() + 6) % 7;\n return day.getTime() - sinceMonday * 86400000;\n}\n\nfunction thisWeekCount(events, nowIso) {\n var start = weekStartMs(nowIso);\n return events.filter(function (e) {\n return Date.parse(e.at) >= start;\n }).length;\n}\n\nfunction trainingLine(stats, weekCount, weeklyTarget) {\n var target = weeklyTarget > 0 ? weeklyTarget : 0;\n var weekPart =\n 'This week: ' +\n weekCount +\n (target > 0 ? ' of ' + target + ' session(s)' : ' session(s)') +\n (target > 0 && weekCount >= target ? ' — target met.' : '.');\n var streakPart =\n stats.total === 0\n ? 'No sessions logged yet.'\n : 'Streak: ' + stats.streakDays + ' day(s); ' + stats.total + ' session(s) all time.';\n return weekPart + ' ' + streakPart;\n}\n// ── fitness-rules-end ──\n\nconst training = logStore(gezel.fs, 'training.json');\n\nasync function readWeeklyTarget(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('training.json')) as { weeklyTarget?: unknown };\n const target = Number(parsed.weeklyTarget);\n return Number.isFinite(target) && target > 0 ? Math.round(target) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'log_workout') {\n if (!input.activity) throw new Error(\"log_workout needs 'activity' (what they did).\");\n await training.append({\n kind: 'workout',\n ...(input.note ? { note: input.note } : {}),\n data: {\n activity: input.activity,\n ...(typeof input.minutes === 'number' && input.minutes > 0\n ? { minutes: Math.round(input.minutes) }\n : {}),\n },\n });\n} else if (input.action === 'set_target') {\n const target = Number(input.weeklyTarget);\n if (!Number.isFinite(target) || target <= 0) {\n throw new Error(\"set_target needs a positive 'weeklyTarget' (sessions per week).\");\n }\n // logStore has no field API; the passenger key flips via a targeted\n // read-modify-write that preserves the rest of the file.\n const raw = JSON.parse(await gezel.fs.read('training.json')) as Record<string, unknown>;\n raw.weeklyTarget = Math.round(target);\n await gezel.fs.write('training.json', `${JSON.stringify(raw, null, 2)}\\n`);\n} else if (input.action !== 'status' && input.action !== 'history') {\n throw new Error(`Unknown action '${input.action}'. Use status, log_workout, set_target, or history.`);\n}\n\nconst events = await training.list();\nconst stats = await training.stats();\nconst weeklyTarget = await readWeeklyTarget();\nconst nowIso = new Date().toISOString();\nconst weekCount = thisWeekCount(events, nowIso);\nconst listing =\n input.action === 'history'\n ? events\n .slice(-12)\n .map(\n (e) =>\n `${e.at.slice(0, 10)} ${String(e.data?.activity ?? 'session')}${typeof e.data?.minutes === 'number' ? ` (${e.data.minutes} min)` : ''}${e.note ? ` — ${e.note}` : ''}`,\n )\n .join('\\n') || 'No sessions yet.'\n : '';\n\ngezel.output({\n streakDays: stats.streakDays,\n thisWeek: weekCount,\n weeklyTarget,\n summary: listing || trainingLine(stats, weekCount, weeklyTarget),\n});\n"},"tools":[{"name":"log_workout","description":"Log a training session the moment it is reported (activity + minutes).","script":"training-store","inputs":{"type":"object","properties":{"activity":{"type":"string"},"minutes":{"type":"number"},"note":{"type":"string"}},"required":["activity"]},"bind":{"action":"log_workout"}},{"name":"set_target","description":"Change the weekly session target.","script":"training-store","inputs":{"type":"object","properties":{"weeklyTarget":{"type":"number"}},"required":["weeklyTarget"]},"bind":{"action":"set_target"}},{"name":"training_status","description":"Streak, this week against the target, all-time count.","script":"training-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"workout_history","description":"The recent sessions, most recent last.","script":"training-store","inputs":{"type":"object","properties":{}},"bind":{"action":"history"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"training.json"}]},"schedules":[],"workspaceSeed":["training.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"flashcards","name":"Flashcards","description":"Learn anything with spaced-repetition flashcards. Your Studiemaat (\"study mate\") writes the cards in chat; you review them on a live card-flip page that schedules what comes back and when.","tags":["flashcards","study","learning","spaced-repetition"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"subject":{"type":"string","title":"What are you learning?","description":"The deck's subject — e.g. Spanish vocabulary, AWS certification, organic chemistry.","default":"Spanish vocabulary"}},"required":["subject"]},"nameTemplate":"{{subject}} Flashcards","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"study-buddy","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"deck-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'deck-store',\n description:\n \"Read and update the flashcard deck (deck.json). Call action:'add_cards' with a cards array ([{front, back}, ...]) to add practice cards; action:'state' to see due counts and box distribution; action:'list_deck' to read every card. The review page drives 'record_review' and 'finish_session' itself.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'state', 'list_deck', 'add_cards', 'record_review', or 'finish_session'.\",\n required: true,\n },\n cards: {\n type: 'json',\n description: 'For add_cards: an array of {front, back} objects.',\n },\n cardId: { type: 'string', description: \"For record_review: the card id, e.g. 'card-3'.\" },\n verdict: { type: 'string', description: \"For record_review: 'got-it' or 'missed'.\" },\n reviewed: { type: 'number', description: 'For finish_session: how many cards were reviewed.' },\n missedIds: { type: 'json', description: 'For finish_session: array of card ids that were missed.' },\n },\n outputs: {\n total: { type: 'number', description: 'Cards in the deck.' },\n due: { type: 'number', description: 'Cards due for review now.' },\n summary: { type: 'string', description: 'Human-readable deck or session summary.' },\n missedList: { type: 'string', description: 'Fronts of the cards missed this session (finish_session).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── leitner-rules-start ──\n// Pure Leitner-box scheduling (3 boxes). Box 1 reviews daily, box 2 every\n// 3 days, box 3 weekly. A got-it promotes (capped at 3); a miss goes back\n// to box 1. Never-reviewed cards are always due. Kept self-contained\n// between these sentinels so unit tests execute the shipped bytes.\n\nvar BOX_INTERVAL_DAYS = { 1: 1, 2: 3, 3: 7 };\nvar DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction isDue(card, nowMs) {\n if (!card.lastReviewedAt) return true;\n var interval = BOX_INTERVAL_DAYS[card.box] || 1;\n return nowMs - Date.parse(card.lastReviewedAt) >= interval * DAY_MS;\n}\n\nfunction computeDueIds(cards, nowMs) {\n return cards\n .filter(function (c) {\n return isDue(c, nowMs);\n })\n .sort(function (a, b) {\n if (a.box !== b.box) return a.box - b.box;\n return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0;\n })\n .map(function (c) {\n return c.id;\n });\n}\n\nfunction applyReview(card, verdict, nowIso) {\n if (verdict === 'got-it') card.box = Math.min(3, card.box + 1);\n else card.box = 1;\n card.lastReviewedAt = nowIso;\n card.stats = card.stats || { gotIt: 0, missed: 0 };\n if (verdict === 'got-it') card.stats.gotIt += 1;\n else card.stats.missed += 1;\n return card;\n}\n\nfunction nextCardId(cards) {\n var max = 0;\n for (var i = 0; i < cards.length; i += 1) {\n var m = /^card-(\\d+)$/.exec(cards[i].id);\n if (m) max = Math.max(max, Number(m[1]));\n }\n return 'card-' + (max + 1);\n}\n// ── leitner-rules-end ──\n\nconst DECK_FILE = 'deck.json';\n\ninterface Card {\n id: string;\n front: string;\n back: string;\n box: number;\n lastReviewedAt: string | null;\n createdAt: string;\n stats: { gotIt: number; missed: number };\n}\n\ninterface Deck {\n version: number;\n subject: string;\n cards: Card[];\n dueCardIds: string[];\n updatedAt: string;\n}\n\nasync function load(): Promise<Deck> {\n let raw: string;\n try {\n raw = await gezel.fs.read(DECK_FILE);\n } catch {\n return { version: 1, subject: 'your subject', cards: [], dueCardIds: [], updatedAt: new Date().toISOString() };\n }\n const parsed = JSON.parse(raw) as Partial<Deck>;\n return {\n version: 1,\n subject: typeof parsed.subject === 'string' ? parsed.subject : 'your subject',\n cards: Array.isArray(parsed.cards) ? (parsed.cards as Card[]) : [],\n dueCardIds: [],\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function persist(deck: Deck): Promise<Deck> {\n deck.dueCardIds = computeDueIds(deck.cards, Date.now());\n deck.updatedAt = new Date().toISOString();\n await gezel.fs.write(DECK_FILE, `${JSON.stringify(deck, null, 2)}\\n`);\n return deck;\n}\n\nconst deck = await load();\ndeck.dueCardIds = computeDueIds(deck.cards, Date.now());\nlet summary = '';\nlet missedList = '';\n\nif (input.action === 'add_cards') {\n const raw = Array.isArray(input.cards) ? input.cards : [];\n const added: string[] = [];\n for (const entry of raw) {\n const front = typeof (entry as { front?: unknown }).front === 'string' ? (entry as { front: string }).front.trim() : '';\n const back = typeof (entry as { back?: unknown }).back === 'string' ? (entry as { back: string }).back.trim() : '';\n if (!front || !back) continue;\n const card: Card = {\n id: nextCardId(deck.cards),\n front: front.slice(0, 300),\n back: back.slice(0, 500),\n box: 1,\n lastReviewedAt: null,\n createdAt: new Date().toISOString(),\n stats: { gotIt: 0, missed: 0 },\n };\n deck.cards.push(card);\n added.push(card.id);\n }\n if (added.length === 0) {\n throw new Error(\"add_cards needs a non-empty 'cards' array of {front, back} objects.\");\n }\n await persist(deck);\n summary = `Added ${added.length} card(s). ${deck.cards.length} in the deck, ${deck.dueCardIds.length} due now.`;\n} else if (input.action === 'record_review') {\n if (!input.cardId || (input.verdict !== 'got-it' && input.verdict !== 'missed')) {\n throw new Error(\"record_review needs 'cardId' and verdict 'got-it' or 'missed'.\");\n }\n const card = deck.cards.find((c) => c.id === input.cardId);\n if (!card) throw new Error(`No card '${input.cardId}' in the deck.`);\n applyReview(card, input.verdict, new Date().toISOString());\n await persist(deck);\n summary = `${card.id} marked ${input.verdict} (now box ${card.box}). ${deck.dueCardIds.length} still due.`;\n} else if (input.action === 'finish_session') {\n const reviewed = typeof input.reviewed === 'number' ? input.reviewed : 0;\n const missedIds = Array.isArray(input.missedIds) ? input.missedIds.map(String) : [];\n const missedFronts = missedIds\n .map((id) => deck.cards.find((c) => c.id === id)?.front)\n .filter((f): f is string => typeof f === 'string');\n missedList = missedFronts.length ? missedFronts.join(' | ') : '(none)';\n summary = `Reviewed ${reviewed} card(s): ${reviewed - missedIds.length} got it, ${missedIds.length} missed. ${deck.dueCardIds.length} due next.`;\n} else if (input.action === 'list_deck') {\n summary = deck.cards.length\n ? deck.cards\n .map((c) => `${c.id} [box ${c.box}] ${c.front} -> ${c.back}`)\n .join('\\n')\n : 'The deck is empty — add cards with add_cards.';\n} else if (input.action === 'state') {\n const boxes = { 1: 0, 2: 0, 3: 0 } as Record<number, number>;\n for (const c of deck.cards) boxes[c.box] = (boxes[c.box] ?? 0) + 1;\n summary = deck.cards.length\n ? `${deck.cards.length} card(s) on \"${deck.subject}\": ${deck.dueCardIds.length} due now. Boxes: ${boxes[1]} daily, ${boxes[2]} every-3-days, ${boxes[3]} weekly.`\n : `The deck for \"${deck.subject}\" is empty — offer to write the first cards.`;\n} else {\n throw new Error(\n `Unknown action '${input.action}'. Use state, list_deck, add_cards, record_review, or finish_session.`,\n );\n}\n\ngezel.output({\n total: deck.cards.length,\n due: deck.dueCardIds.length,\n summary,\n missedList,\n});\n"},"tools":[{"name":"add_cards","description":"Add flashcards to the deck. Pass a cards array of {front, back} objects — one fact per card.","script":"deck-store","inputs":{"type":"object","properties":{"cards":{"type":"array","items":{"type":"object","properties":{"front":{"type":"string"},"back":{"type":"string"}},"required":["front","back"]},"description":"The cards to add."}},"required":["cards"]},"bind":{"action":"add_cards"}},{"name":"deck_status","description":"See the deck: how many cards, how many due, and the box distribution.","script":"deck-store","inputs":{"type":"object","properties":{}},"bind":{"action":"state"}},{"name":"list_deck","description":"Read every card in the deck (id, box, front, back).","script":"deck-store","inputs":{"type":"object","properties":{}},"bind":{"action":"list_deck"}},{"name":"record_review","description":"The review page's per-card verdict.","script":"deck-store","inputs":{"type":"object","properties":{"cardId":{"type":"string"},"verdict":{"type":"string","enum":["got-it","missed"]}},"required":["cardId","verdict"]},"bind":{"action":"record_review"}},{"name":"finish_session","description":"The review page's end-of-session summary.","script":"deck-store","inputs":{"type":"object","properties":{"reviewed":{"type":"number"},"missedIds":{"type":"array","items":{"type":"string"}}},"required":["reviewed"]},"bind":{"action":"finish_session"},"reaction":{"gezel":"study-buddy","prompt":"The user finished a flashcard review session. {{output.summary}}\nMissed cards: {{output.missedList}}\nReply with ONE short encouraging line — specific beats generic. If any cards were missed, offer to add fresh practice cards that come at those same ideas from a new angle (add them with your deck capabilities if the user agrees)."}}],"pages":{"entry":"review/index.html","reads":[{"source":"workspace","path":"deck.json"}],"tools":["record_review","finish_session"]},"schedules":[],"workspaceSeed":["deck.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"freelance-office","name":"Freelance Office","description":"Run a one-person business without dropping anything. Your Kantoormeester (\"office master\") tracks every invoice to paid, drafts the polite chase, and keeps the income picture one question away.","tags":["freelance","invoices","clients","business","income"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"business":{"type":"string","title":"What do you do?","description":"One line about the business — e.g. 'freelance brand design'.","default":"freelance work"}},"required":["business"]},"nameTemplate":"Freelance Office","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"kantoormeester","voorman":true}],"toolsets":[],"craftbooks":["proposal-sow","weekly-review"],"scripts":{"office-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'office-store',\n description:\n \"Read and update the invoice book (invoices.json). Call action:'status' for outstanding/overdue/paid-this-month; 'record_invoice' when work is billed (amount in ordinary units, e.g. 1200.00); 'invoice_sent' / 'invoice_paid' as reality moves; 'list_invoices' to read the book.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_invoice', 'invoice_sent', 'invoice_paid', or 'list_invoices'.\",\n required: true,\n },\n client: { type: 'string', description: 'Client name.' },\n amount: { type: 'number', description: 'Invoice amount in ordinary units, e.g. 1200.00.' },\n due: { type: 'string', description: 'Due date (ISO), e.g. 2026-08-15.' },\n note: { type: 'string', description: 'Short note (what the invoice covers).' },\n id: { type: 'string', description: \"Invoice id, e.g. 'inv-3'.\" },\n },\n outputs: {\n invoices: { type: 'number', description: 'Invoices in the book.' },\n outstandingCents: { type: 'number', description: 'Total sent-but-unpaid, in cents.' },\n overdue: { type: 'number', description: 'Count of overdue invoices.' },\n summary: { type: 'string', description: 'Human-readable office summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── office-rules-start ──\n// Pure derivations over rosterStore-shaped invoice records: statuses are\n// draft -> sent -> paid; fields carry {client, amountCents, dueAt, note}.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var sign = cents < 0 ? '-' : '';\n var abs = Math.abs(cents);\n return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction officeShape(records, nowMs) {\n var outstanding = 0;\n var overdue = [];\n var paidMonth = 0;\n var month = new Date(nowMs).toISOString().slice(0, 7);\n records.forEach(function (r) {\n var cents = typeof r.fields.amountCents === 'number' ? r.fields.amountCents : 0;\n if (r.status === 'sent') {\n outstanding += cents;\n var due = typeof r.fields.dueAt === 'string' ? Date.parse(r.fields.dueAt) : NaN;\n if (isFinite(due) && due < nowMs) overdue.push(r);\n }\n if (r.status === 'paid' && r.updatedAt.slice(0, 7) === month) paidMonth += cents;\n });\n return { outstanding: outstanding, overdue: overdue, paidMonth: paidMonth, month: month };\n}\n\nfunction officeLine(records, nowMs) {\n var shape = officeShape(records, nowMs);\n var chase = shape.overdue\n .map(function (r) {\n return r.fields.client + ' (' + r.id + ', ' + fmtCents(r.fields.amountCents || 0) + ')';\n })\n .join(', ');\n return (\n 'Outstanding ' +\n fmtCents(shape.outstanding) +\n ' across ' +\n records.filter(function (r) {\n return r.status === 'sent';\n }).length +\n ' sent invoice(s); ' +\n shape.overdue.length +\n ' overdue' +\n (chase ? ' (' + chase + ')' : '') +\n '. Paid this month: ' +\n fmtCents(shape.paidMonth) +\n '.'\n );\n}\n// ── office-rules-end ──\n\nconst invoices = rosterStore(gezel.fs, 'invoices.json', {\n stages: ['draft', 'sent', 'paid'],\n idPrefix: 'inv',\n});\n\nif (input.action === 'record_invoice') {\n const cents = toCents(input.amount);\n if (!input.client || cents === null) {\n throw new Error(\"record_invoice needs 'client' and a positive 'amount' (ordinary units).\");\n }\n await invoices.add({\n fields: {\n client: input.client,\n amountCents: cents,\n ...(input.due ? { dueAt: input.due } : {}),\n ...(input.note ? { note: input.note } : {}),\n },\n });\n} else if (input.action === 'invoice_sent' || input.action === 'invoice_paid') {\n if (!input.id) throw new Error(\"Pass the invoice 'id', e.g. 'inv-3'.\");\n await invoices.transition(input.id, input.action === 'invoice_sent' ? 'sent' : 'paid');\n} else if (input.action !== 'status' && input.action !== 'list_invoices') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_invoice, invoice_sent, invoice_paid, or list_invoices.`,\n );\n}\n\nconst records = await invoices.list();\nconst shape = officeShape(records, Date.now());\nconst listing =\n input.action === 'list_invoices'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.client ?? '?')} ${fmtCents(Number(r.fields.amountCents ?? 0))}${r.fields.dueAt ? ` due ${String(r.fields.dueAt).slice(0, 10)}` : ''}`,\n )\n .join('\\n') || 'The invoice book is empty.'\n : '';\n\ngezel.output({\n invoices: records.length,\n outstandingCents: shape.outstanding,\n overdue: shape.overdue.length,\n summary: listing || officeLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_invoice","description":"Add an invoice to the book (amount in ordinary units).","script":"office-store","inputs":{"type":"object","properties":{"client":{"type":"string"},"amount":{"type":"number"},"due":{"type":"string"},"note":{"type":"string"}},"required":["client","amount"]},"bind":{"action":"record_invoice"}},{"name":"invoice_sent","description":"Mark an invoice sent to the client.","script":"office-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"invoice_sent"}},{"name":"invoice_paid","description":"Mark an invoice paid.","script":"office-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"invoice_paid"}},{"name":"office_status","description":"The money picture: outstanding, overdue, paid this month.","script":"office-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"list_invoices","description":"Read the whole invoice book.","script":"office-store","inputs":{"type":"object","properties":{}},"bind":{"action":"list_invoices"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"invoices.json"}]},"schedules":[],"workspaceSeed":["invoices.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"fundraiser-hq","name":"Fundraiser HQ","description":"Rally a community drive. An infectious Aanjager (\"rallier\") keeps the thermometer honest, drafts warm sponsor outreach and progress posts, and makes sure every single donor gets thanked.","tags":["fundraiser","donations","community","charity","drive"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"cause":{"type":"string","title":"What are you raising for?","description":"e.g. 'new jerseys for the team', 'the library roof'.","default":"a good cause"},"goalCents":{"type":"number","title":"Goal (in cents)","description":"The target, in cents — e.g. 500000 for 5,000.00.","default":100000}},"required":["cause","goalCents"]},"nameTemplate":"Fundraiser: {{cause}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"aanjager","voorman":true}],"toolsets":[],"craftbooks":["social-thread","drive-wrap-up"],"scripts":{"drive-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'drive-store',\n description:\n \"Read and update the fundraising drive (donations.json). Call action:'status' for the thermometer + unthanked list; 'record_donation' the moment a gift lands (ordinary units, donor name); 'mark_thanked' once the thank-you goes out.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record_donation', 'mark_thanked', or 'donor_list'.\",\n required: true,\n },\n donor: { type: 'string', description: \"Donor name ('anonymous' is fine).\" },\n amount: { type: 'number', description: 'Gift amount in ordinary units.' },\n note: { type: 'string', description: 'Anything worth remembering about the gift.' },\n id: { type: 'string', description: \"Donation id, e.g. 'ledger-4'.\" },\n },\n outputs: {\n raisedCents: { type: 'number', description: 'Total raised, in cents.' },\n donors: { type: 'number', description: 'Number of gifts.' },\n unthanked: { type: 'number', description: 'Gifts still awaiting a thank-you.' },\n summary: { type: 'string', description: 'The drive picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── drive-rules-start ──\n// Pure derivations over ledgerStore-shaped donation entries: entries are\n// kind 'in', category 'donation', data {donor, thanked}.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var abs = Math.abs(cents);\n return (cents < 0 ? '-' : '') + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction driveShape(entries) {\n var raised = 0;\n var unthanked = [];\n entries.forEach(function (e) {\n raised += e.amountCents;\n if (!e.data || e.data.thanked !== true) unthanked.push(e);\n });\n return { raised: raised, unthanked: unthanked };\n}\n\nfunction driveLine(entries, goalCents) {\n var shape = driveShape(entries);\n var pct = goalCents > 0 ? Math.min(100, Math.round((shape.raised / goalCents) * 100)) : null;\n return (\n 'Raised ' +\n fmtCents(shape.raised) +\n (goalCents > 0 ? ' of ' + fmtCents(goalCents) + ' (' + pct + ' percent)' : '') +\n ' across ' +\n entries.length +\n ' gift(s). ' +\n (shape.unthanked.length === 0\n ? 'Everyone thanked.'\n : shape.unthanked.length + ' thank-you(s) still owed.')\n );\n}\n// ── drive-rules-end ──\n\nconst donations = ledgerStore(gezel.fs, 'donations.json');\n\nasync function readGoalCents(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('donations.json')) as { goalCents?: unknown };\n const goal = Number(parsed.goalCents);\n return Number.isFinite(goal) && goal > 0 ? Math.round(goal) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'record_donation') {\n const cents = toCents(input.amount);\n if (cents === null) throw new Error(\"record_donation needs a positive 'amount' (ordinary units).\");\n await donations.add({\n amountCents: cents,\n kind: 'in',\n category: 'donation',\n ...(input.note ? { note: input.note } : {}),\n data: { donor: input.donor || 'anonymous', thanked: false },\n });\n} else if (input.action === 'mark_thanked') {\n if (!input.id && !input.donor) throw new Error(\"Pass 'id' (e.g. 'ledger-4') or a unique 'donor'.\");\n // ledgerStore is append-only by design; thanked flips via a targeted\n // read-modify-write on the entry's data (the one mutation this drive needs).\n const raw = JSON.parse(await gezel.fs.read('donations.json')) as {\n entries: Array<{ id: string; data?: { donor?: string; thanked?: boolean } }>;\n };\n const matches = raw.entries.filter((e) =>\n input.id\n ? e.id === input.id\n : String(e.data?.donor ?? '').toLowerCase() === String(input.donor).toLowerCase() &&\n e.data?.thanked !== true,\n );\n if (matches.length === 0) throw new Error('No matching unthanked donation found.');\n if (matches.length > 1 && !input.id) {\n throw new Error(\n `Multiple unthanked gifts from '${input.donor}': ${matches.map((m) => m.id).join(', ')}. Pass id.`,\n );\n }\n matches[0]!.data = { ...(matches[0]!.data ?? {}), thanked: true };\n await gezel.fs.write('donations.json', `${JSON.stringify(raw, null, 2)}\\n`);\n} else if (input.action !== 'status' && input.action !== 'donor_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_donation, mark_thanked, or donor_list.`,\n );\n}\n\nconst entries = await donations.list();\nconst goalCents = await readGoalCents();\nconst shape = driveShape(entries);\nconst listing =\n input.action === 'donor_list'\n ? entries\n .map(\n (e) =>\n `${e.id} ${fmtCents(e.amountCents)} from ${String(e.data?.donor ?? 'anonymous')}${e.data?.thanked === true ? '' : ' (thank-you owed)'}`,\n )\n .join('\\n') || 'No gifts yet.'\n : '';\n\ngezel.output({\n raisedCents: shape.raised,\n donors: entries.length,\n unthanked: shape.unthanked.length,\n summary: listing || driveLine(entries, goalCents),\n});\n"},"tools":[{"name":"record_donation","description":"Record a gift the moment it lands (ordinary units, donor name).","script":"drive-store","inputs":{"type":"object","properties":{"donor":{"type":"string"},"amount":{"type":"number"},"note":{"type":"string"}},"required":["amount"]},"bind":{"action":"record_donation"}},{"name":"mark_thanked","description":"Record that a thank-you went out for a gift.","script":"drive-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"donor":{"type":"string"}}},"bind":{"action":"mark_thanked"}},{"name":"drive_status","description":"The thermometer and the thank-you debt.","script":"drive-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"donor_list","description":"Every gift, with thank-you state.","script":"drive-store","inputs":{"type":"object","properties":{}},"bind":{"action":"donor_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"donations.json"}]},"schedules":[],"workspaceSeed":["donations.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"go","name":"Go","description":"Play Go against a gezel opponent on a live 9×9, 13×13, or 19×19 board. Place black stones; the Go-speler reads the exact position, replies in white, and keeps the conversation calm and sporting.","tags":["go","baduk","weiqi","game","board-game","strategy","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Go-speler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Go vs the Go-speler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"go-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the Go game (game.json). Call action:'board' to see the position and every legal move; action:'ai_move' to play a coordinate such as D4 or 'pass' (optional moveThought); action:'state' for a light summary; action:'new_game' to reset with boardSize 9, 13, or 19 and optional komi. The engine enforces captures, suicide, simple ko, passing, and area scoring.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n move: {\n type: 'string',\n description: \"A board coordinate such as 'D4', or 'pass'.\",\n },\n boardSize: {\n type: 'number',\n description: 'For new_game: 9, 13, or 19. Defaults to 9.',\n },\n komi: {\n type: 'number',\n description: 'For new_game: points added to white. Defaults to 6.5.',\n },\n moveThought: {\n type: 'string',\n description: 'A short line of table talk to show on the board (max 140 chars).',\n },\n },\n outputs: {\n board: { type: 'string', description: 'ASCII board with coordinates.' },\n turn: { type: 'string', description: \"Whose move: 'user' (black) or 'ai' (white).\" },\n status: {\n type: 'string',\n description: 'playing, user_won, ai_won, or draw.',\n },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'D4' or 'pass'.\" },\n legalMoves: { type: 'string', description: 'Legal placements plus pass.' },\n score: { type: 'string', description: 'Area score when the game has ended.' },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── go-rules-start ──\n// Pure, annotation-free Go engine. Supports 9x9, 13x13, and 19x19.\n// Squares are row-major, y0 at the top. X = black/user; O = white/AI.\n// Coordinates follow Go convention and skip the letter I.\n\nvar GO_FILES = 'ABCDEFGHJKLMNOPQRST';\n\nfunction buildInitial(size) {\n return '.'.repeat(size * size);\n}\n\nfunction inBounds(x, y, size) {\n return x >= 0 && x < size && y >= 0 && y < size;\n}\n\nfunction pointName(x, y, size) {\n return GO_FILES[x] + String(size - y);\n}\n\nfunction parsePoint(name, size) {\n if (typeof name !== 'string') return null;\n var text = name.trim().toUpperCase();\n var match = /^([A-HJ-T])(\\d{1,2})$/.exec(text);\n if (!match) return null;\n var x = GO_FILES.indexOf(match[1]);\n var rank = Number(match[2]);\n if (x < 0 || x >= size || rank < 1 || rank > size) return null;\n return { x: x, y: size - rank, index: (size - rank) * size + x };\n}\n\nfunction neighbors(index, size) {\n var x = index % size;\n var y = Math.floor(index / size);\n var out = [];\n if (x > 0) out.push(index - 1);\n if (x + 1 < size) out.push(index + 1);\n if (y > 0) out.push(index - size);\n if (y + 1 < size) out.push(index + size);\n return out;\n}\n\nfunction groupAt(squares, index, size) {\n var color = squares[index];\n var stones = [];\n var liberties = {};\n var seen = {};\n var queue = [index];\n seen[index] = true;\n while (queue.length) {\n var current = queue.pop();\n stones.push(current);\n for (var next of neighbors(current, size)) {\n if (squares[next] === '.') liberties[next] = true;\n else if (squares[next] === color && !seen[next]) {\n seen[next] = true;\n queue.push(next);\n }\n }\n }\n return { stones: stones, liberties: Object.keys(liberties).map(Number) };\n}\n\nfunction replaceAt(squares, index, ch) {\n return squares.slice(0, index) + ch + squares.slice(index + 1);\n}\n\nfunction applyPlacement(squares, size, side, name, previousPosition) {\n var point = parsePoint(name, size);\n if (!point) return { error: 'bad-coordinate' };\n if (squares[point.index] !== '.') return { error: 'occupied' };\n var stone = side === 'user' ? 'X' : 'O';\n var enemy = side === 'user' ? 'O' : 'X';\n var next = replaceAt(squares, point.index, stone);\n var captures = [];\n var checked = {};\n for (var adjacent of neighbors(point.index, size)) {\n if (next[adjacent] !== enemy || checked[adjacent]) continue;\n var enemyGroup = groupAt(next, adjacent, size);\n for (var member of enemyGroup.stones) checked[member] = true;\n if (enemyGroup.liberties.length === 0) {\n for (var captured of enemyGroup.stones) {\n next = replaceAt(next, captured, '.');\n captures.push(pointName(captured % size, Math.floor(captured / size), size));\n }\n }\n }\n if (groupAt(next, point.index, size).liberties.length === 0) return { error: 'suicide' };\n if (previousPosition && next === previousPosition) return { error: 'ko' };\n return { squares: next, captures: captures };\n}\n\nfunction legalMovesFor(squares, size, side, previousPosition) {\n var moves = [];\n for (var y = 0; y < size; y += 1) {\n for (var x = 0; x < size; x += 1) {\n if (squares[y * size + x] !== '.') continue;\n var name = pointName(x, y, size);\n var result = applyPlacement(squares, size, side, name, previousPosition);\n if (!result.error) moves.push(name);\n }\n }\n moves.push('pass');\n return moves;\n}\n\nfunction scoreArea(squares, size, komi) {\n var blackStones = 0;\n var whiteStones = 0;\n var blackTerritory = 0;\n var whiteTerritory = 0;\n var seen = {};\n for (var i = 0; i < squares.length; i += 1) {\n if (squares[i] === 'X') {\n blackStones += 1;\n continue;\n }\n if (squares[i] === 'O') {\n whiteStones += 1;\n continue;\n }\n if (seen[i]) continue;\n var region = [];\n var borders = {};\n var queue = [i];\n seen[i] = true;\n while (queue.length) {\n var current = queue.pop();\n region.push(current);\n for (var next of neighbors(current, size)) {\n if (squares[next] === '.' && !seen[next]) {\n seen[next] = true;\n queue.push(next);\n } else if (squares[next] === 'X' || squares[next] === 'O') {\n borders[squares[next]] = true;\n }\n }\n }\n if (borders.X && !borders.O) blackTerritory += region.length;\n if (borders.O && !borders.X) whiteTerritory += region.length;\n }\n var black = blackStones + blackTerritory;\n var white = whiteStones + whiteTerritory + komi;\n return {\n black: black,\n white: white,\n blackStones: blackStones,\n whiteStones: whiteStones,\n blackTerritory: blackTerritory,\n whiteTerritory: whiteTerritory,\n winner: black === white ? 'draw' : black > white ? 'user' : 'ai',\n margin: Math.abs(black - white),\n };\n}\n\nfunction statusAfter(squares, size, komi, consecutivePasses) {\n if (consecutivePasses < 2) return 'playing';\n var score = scoreArea(squares, size, komi);\n return score.winner === 'draw' ? 'draw' : score.winner === 'user' ? 'user_won' : 'ai_won';\n}\n\nfunction renderAscii(squares, size) {\n var labels = [];\n for (var x = 0; x < size; x += 1) labels.push(GO_FILES[x]);\n var pad = size >= 10 ? ' ' : ' ';\n var lines = [pad + labels.join(' ')];\n for (var y = 0; y < size; y += 1) {\n var rank = String(size - y).padStart(size >= 10 ? 2 : 1, ' ');\n var row = [];\n for (var x = 0; x < size; x += 1) row.push(squares[y * size + x]);\n lines.push(rank + ' ' + row.join(' ') + ' ' + rank);\n }\n lines.push(pad + labels.join(' '));\n lines.push('X = black (user). O = white (you).');\n return lines.join('\\n');\n}\n// ── go-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst SIDE_NAMES = { user: 'black', ai: 'white' };\n\ninterface GameState {\n version: number;\n boardSize: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n komi: number;\n consecutivePasses: number;\n previousPosition: string | null;\n legalMoves: string[];\n captures: { user: number; ai: number };\n score: ReturnType<typeof scoreArea> | null;\n moveLog: Array<{\n by: 'user' | 'ai';\n move: string;\n captures: string[];\n at: string;\n moveThought?: string;\n }>;\n lastMove: { by: 'user' | 'ai'; move: string; captures: string[] } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction validSize(value: unknown): number {\n const size = Number(value);\n return size === 13 || size === 19 ? size : 9;\n}\n\nfunction validKomi(value: unknown): number {\n const komi = Number(value);\n return Number.isFinite(komi) && komi >= 0 && komi <= 20 ? komi : 6.5;\n}\n\nfunction freshState(boardSize = 9, komi = 6.5): GameState {\n const size = validSize(boardSize);\n const nextKomi = validKomi(komi);\n const squares = buildInitial(size);\n return {\n version: 1,\n boardSize: size,\n squares,\n turn: 'user',\n status: 'playing',\n komi: nextKomi,\n consecutivePasses: 0,\n previousPosition: null,\n legalMoves: legalMovesFor(squares, size, 'user', null),\n captures: { user: 0, ai: 0 },\n score: null,\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n const size = validSize(parsed.boardSize);\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== size * size) {\n throw new Error(`${STATE_FILE} is corrupt; call action:'new_game' to reset.`);\n }\n return {\n ...freshState(size, parsed.komi),\n ...parsed,\n boardSize: size,\n komi: validKomi(parsed.komi),\n } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves =\n state.status === 'playing'\n ? legalMovesFor(\n state.squares,\n state.boardSize,\n state.turn,\n state.previousPosition,\n )\n : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.join(', ');\n}\n\nfunction renderScore(state: GameState): string {\n if (!state.score) return '(not scored — play continues)';\n const result =\n state.score.winner === 'draw'\n ? 'draw'\n : `${SIDE_NAMES[state.score.winner]} wins by ${state.score.margin}`;\n return `Black ${state.score.black}; white ${state.score.white} (includes ${state.komi} komi): ${result}.`;\n}\n\nfunction summarize(state: GameState): string {\n if (state.status !== 'playing') return `Game over. ${renderScore(state)}`;\n return `Move ${state.moveLog.length + 1}: ${SIDE_NAMES[state.turn]} (${state.turn}) to move on ${state.boardSize}x${state.boardSize}. Captures — black ${state.captures.user}, white ${state.captures.ai}.`;\n}\n\nfunction applyTurn(\n state: GameState,\n by: 'user' | 'ai',\n moveThought?: string,\n): GameState {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(by === 'ai' ? \"It's not your turn yet.\" : 'The opponent is still to move.');\n }\n const move = input.move?.trim() || '';\n const normalized = move.toLowerCase() === 'pass' ? 'pass' : move.toUpperCase();\n if (!state.legalMoves.includes(normalized)) {\n throw new Error(\n `Illegal move '${move}'. Legal moves for ${SIDE_NAMES[by]}: ${renderLegal(state)}`,\n );\n }\n\n let captured: string[] = [];\n const before = state.squares;\n if (normalized === 'pass') {\n state.consecutivePasses += 1;\n } else {\n const applied = applyPlacement(\n state.squares,\n state.boardSize,\n by,\n normalized,\n state.previousPosition,\n );\n if (applied.error || !applied.squares) {\n throw new Error(`Illegal move '${move}' (${applied.error || 'unknown'}).`);\n }\n state.squares = applied.squares;\n captured = applied.captures || [];\n state.captures[by] += captured.length;\n state.consecutivePasses = 0;\n }\n state.previousPosition = before;\n state.turn = (by === 'user' ? 'ai' : 'user') as 'user' | 'ai';\n state.status = statusAfter(\n state.squares,\n state.boardSize,\n state.komi,\n state.consecutivePasses,\n ) as GameState['status'];\n if (state.status !== 'playing') {\n state.score = scoreArea(state.squares, state.boardSize, state.komi);\n }\n const entry = {\n by,\n move: normalized,\n captures: captured,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = { by, move: normalized, captures: captured };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return state;\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove?.move ?? '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState(input.boardSize, input.komi));\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought?.trim().slice(0, 140);\n state = await persist(applyTurn(state, by, moveThought));\n lastMoveText = state.lastMove?.move ?? '(none)';\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n `${renderAscii(state.squares, state.boardSize)}\\nTurn: ${SIDE_NAMES[state.turn]} (${state.turn}).\\n` +\n `Komi: ${state.komi}. Consecutive passes: ${state.consecutivePasses}.\\n` +\n `Legal moves: ${renderLegal(state)}`,\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n score: renderScore(state),\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative Go position, board size, captures, whose turn it is, and every legal move.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your white move. Pass a coordinate such as 'D4', or 'pass', plus optional moveThought.","script":"game-store","inputs":{"type":"object","properties":{"move":{"type":"string","description":"A legal coordinate such as 'D4', or 'pass'."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["move"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's black move or pass, played from the Go board.","script":"game-store","inputs":{"type":"object","properties":{"move":{"type":"string"}},"required":["move"]},"bind":{"action":"user_move"},"reaction":{"gezel":"go-player","prompt":"Your opponent played {{output.lastMove}}. Status: {{output.status}}. Board now:\n{{output.board}}\nThe board and Legal moves line come directly from the Go engine and are always correct. Do not re-derive captures, liberties, ko, scoring, or legality, and do not invent a coordinate outside the legal list. Spend your thought on choosing among the listed moves.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user’s available moves, weak groups, threats, or your plan; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user’s move. Answer strategy questions when asked.\nAct on this latest position. If the game is over, do not move — give one gracious line about the result and offer a new game. Otherwise choose a strong legal move (or pass when strategically sound) and play it with make_move, including a short {{personality}} moveThought. If make_move rejects it, choose one move verbatim from the legal list in the error and retry. After moving, reply with ONE short line of table talk.","hideSeed":true}},{"name":"new_game","description":"Reset the Go board. Choose 9, 13, or 19 lines and optional komi; defaults to 9×9 with 6.5 komi.","script":"game-store","inputs":{"type":"object","properties":{"boardSize":{"type":"number","enum":[9,13,19]},"komi":{"type":"number","minimum":0,"maximum":20}}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"household-budget","name":"Household Budget","description":"Know where the money actually goes. A calm Penningmeester (\"treasurer\") keeps the ledger, closes each month in minutes, and the live dashboard shows in, out, net, and the categories that matter — all on your own disk.","tags":["budget","money","household","spending","ledger"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"currency":{"type":"string","title":"Currency","description":"Shown on the dashboard; entries are unit-agnostic.","default":"USD"}},"required":["currency"]},"nameTemplate":"Household Budget","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"penningmeester","voorman":true}],"toolsets":[],"craftbooks":["subscription-audit","month-close"],"scripts":{"budget-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'budget-store',\n description:\n \"Read and update the household ledger (ledger.json). Call action:'status' for this month's shape (in/out/net, top categories); 'add_expense' or 'add_income' the moment money moves (amount in ordinary units, e.g. 12.50); 'month_summary' with month 'YYYY-MM' for any past month.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'add_expense', 'add_income', or 'month_summary'.\",\n required: true,\n },\n amount: { type: 'number', description: 'Amount in ordinary currency units, e.g. 12.50.' },\n category: { type: 'string', description: \"Category, e.g. 'groceries', 'rent', 'salary'.\" },\n note: { type: 'string', description: 'Short note on the entry.' },\n month: { type: 'string', description: \"Month for month_summary, e.g. '2026-06'.\" },\n },\n outputs: {\n entries: { type: 'number', description: 'Total ledger entries.' },\n summary: { type: 'string', description: 'Human-readable money summary.' },\n netCents: { type: 'number', description: 'Net for the summarized month, in cents.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── budget-rules-start ──\n// Pure derivations shared with tests: cents conversion and the month\n// summary line, kept engine-side so the dashboard, the tools, and the\n// tests agree on one rendering.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var sign = cents < 0 ? '-' : '';\n var abs = Math.abs(cents);\n return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction topCategories(byCategory, kind, limit) {\n return Object.entries(byCategory)\n .map(function (pair) {\n return { category: pair[0], cents: kind === 'out' ? pair[1].outCents : pair[1].inCents };\n })\n .filter(function (row) {\n return row.cents > 0;\n })\n .sort(function (a, b) {\n return b.cents - a.cents;\n })\n .slice(0, limit);\n}\n\nfunction monthLine(month, totals) {\n var top = topCategories(totals.byCategory, 'out', 3)\n .map(function (row) {\n return row.category + ' ' + fmtCents(row.cents);\n })\n .join(', ');\n return (\n month +\n ': in ' +\n fmtCents(totals.inCents) +\n ', out ' +\n fmtCents(totals.outCents) +\n ', net ' +\n fmtCents(totals.netCents) +\n (top ? '. Biggest: ' + top + '.' : '. No spending recorded.')\n );\n}\n// ── budget-rules-end ──\n\nconst ledger = ledgerStore(gezel.fs, 'ledger.json');\nconst currentMonth = new Date().toISOString().slice(0, 7);\n\nif (input.action === 'add_expense' || input.action === 'add_income') {\n const cents = toCents(input.amount);\n if (cents === null) throw new Error(\"Pass a positive 'amount' in ordinary units, e.g. 12.50.\");\n if (!input.category) throw new Error(\"Pass a 'category', e.g. 'groceries' or 'salary'.\");\n await ledger.add({\n amountCents: cents,\n kind: input.action === 'add_expense' ? 'out' : 'in',\n category: input.category.toLowerCase(),\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action !== 'status' && input.action !== 'month_summary') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_expense, add_income, or month_summary.`,\n );\n}\n\nconst month =\n input.action === 'month_summary' && input.month ? input.month : currentMonth;\nconst totals = await ledger.totals({ month });\nconst all = await ledger.list();\n\ngezel.output({\n entries: all.length,\n summary: monthLine(month, totals),\n netCents: totals.netCents,\n});\n"},"tools":[{"name":"add_expense","description":"Record spending the moment it happens (amount in ordinary units).","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_expense"}},{"name":"add_income","description":"Record money coming in.","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_income"}},{"name":"budget_status","description":"This month's shape: in, out, net, and the biggest categories.","script":"budget-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"month_summary","description":"Summarize any month ('YYYY-MM').","script":"budget-store","inputs":{"type":"object","properties":{"month":{"type":"string"}},"required":["month"]},"bind":{"action":"month_summary"}},{"name":"record_expense","description":"The dashboard's quick-add expense entry.","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_expense"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"ledger.json"}],"tools":["record_expense"]},"schedules":[],"workspaceSeed":["ledger.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"household-manual","name":"Household Manual","description":"The living manual for your home. A Huismeester (\"caretaker\") keeps a card for every system — what care it needs and when — turns problems into flagged history, and the dashboard shows what the house needs this season.","tags":["home","maintenance","appliances","household"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"home":{"type":"string","title":"What kind of home?","description":"e.g. 'a 1960s house', 'a third-floor apartment'.","default":"our home"}},"required":["home"]},"nameTemplate":"Household Manual","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"huismeester","voorman":true}],"toolsets":[],"craftbooks":["seasonal-maintenance-sweep"],"scripts":{"house-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'house-store',\n description:\n \"Read and update the home's system cards (systems.json). Call action:'status' for what's due or flagged; 'record_system' when an appliance/system comes up (with care interval in days when known); 'log_maintenance' when care happens; 'flag_attention' for problems; 'house_list' to read every card.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_system', 'log_maintenance', 'flag_attention', or 'house_list'.\",\n required: true,\n },\n name: { type: 'string', description: \"System name, e.g. 'Furnace', 'Smoke alarms'.\" },\n kind: { type: 'string', description: 'What it is (appliance, safety, structure, utility).' },\n location: { type: 'string', description: 'Where it lives in the house.' },\n care: { type: 'string', description: \"The care it needs, e.g. 'replace filter'.\" },\n intervalDays: { type: 'number', description: 'Care interval in days, when known.' },\n note: { type: 'string', description: 'Maintenance or attention note.' },\n id: { type: 'string', description: \"System id, e.g. 'sys-2'.\" },\n },\n outputs: {\n systems: { type: 'number', description: 'System cards in the manual.' },\n due: { type: 'number', description: 'Systems with care due.' },\n flagged: { type: 'number', description: 'Systems flagged for attention.' },\n summary: { type: 'string', description: 'What the house needs.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── house-rules-start ──\n// Pure derivations over rosterStore-shaped system cards: statuses are\n// ok -> attention; \"due\" derives from lastDoneAt + intervalDays.\n\nvar DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction isDue(record, nowMs) {\n var f = record.fields || {};\n if (typeof f.intervalDays !== 'number' || f.intervalDays <= 0) return false;\n var last = typeof f.lastDoneAt === 'string' ? Date.parse(f.lastDoneAt) : Date.parse(record.createdAt);\n return isFinite(last) && nowMs - last >= f.intervalDays * DAY_MS;\n}\n\nfunction houseShape(records, nowMs) {\n var due = [];\n var flagged = [];\n records.forEach(function (r) {\n if (isDue(r, nowMs)) due.push(r);\n if (r.status === 'attention') flagged.push(r);\n });\n return { due: due, flagged: flagged };\n}\n\nfunction houseLine(records, nowMs) {\n var shape = houseShape(records, nowMs);\n var names = function (rows) {\n return rows\n .map(function (r) {\n return (r.fields && r.fields.name) || r.id;\n })\n .join(', ');\n };\n if (records.length === 0) return 'No systems recorded yet — start with the ones that need regular care.';\n var bits = [];\n if (shape.due.length) bits.push('care due: ' + names(shape.due));\n if (shape.flagged.length) bits.push('flagged: ' + names(shape.flagged));\n return records.length + ' system(s) on record. ' + (bits.length ? bits.join('; ') + '.' : 'Nothing due — the house is quiet.');\n}\n// ── house-rules-end ──\n\nconst house = rosterStore(gezel.fs, 'systems.json', {\n stages: ['ok', 'attention'],\n idPrefix: 'sys',\n});\n\nasync function resolveSystem(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await house.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'sys-2') or an exact system 'name'.\");\n}\n\nif (input.action === 'record_system') {\n if (!input.name) throw new Error(\"record_system needs 'name'.\");\n await house.add({\n status: 'ok',\n fields: {\n name: input.name,\n ...(input.kind ? { kind: input.kind } : {}),\n ...(input.location ? { location: input.location } : {}),\n ...(input.care ? { care: input.care } : {}),\n ...(typeof input.intervalDays === 'number' ? { intervalDays: input.intervalDays } : {}),\n lastDoneAt: new Date().toISOString(),\n },\n });\n} else if (input.action === 'log_maintenance') {\n const id = await resolveSystem();\n await house.update(id, {\n fields: { lastDoneAt: new Date().toISOString() },\n ...(input.note ? { note: input.note } : {}),\n });\n await house.transition(id, 'ok');\n} else if (input.action === 'flag_attention') {\n const id = await resolveSystem();\n await house.transition(id, 'attention', input.note ? { note: input.note } : {});\n} else if (input.action !== 'status' && input.action !== 'house_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_system, log_maintenance, flag_attention, or house_list.`,\n );\n}\n\nconst records = await house.list();\nconst shape = houseShape(records, Date.now());\nconst listing =\n input.action === 'house_list'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}${isDue(r, Date.now()) ? ', due' : ''}] ${String(r.fields.name ?? '?')}${r.fields.location ? ` — ${String(r.fields.location)}` : ''}${r.fields.care ? ` (${String(r.fields.care)}${r.fields.intervalDays ? ` every ${String(r.fields.intervalDays)}d` : ''})` : ''}`,\n )\n .join('\\n') || 'No systems recorded yet.'\n : '';\n\ngezel.output({\n systems: records.length,\n due: shape.due.length,\n flagged: shape.flagged.length,\n summary: listing || houseLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_system","description":"Add a system or appliance card (with a care interval in days when known).","script":"house-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string"},"location":{"type":"string"},"care":{"type":"string"},"intervalDays":{"type":"number"}},"required":["name"]},"bind":{"action":"record_system"}},{"name":"log_maintenance","description":"Log that care happened (resets the due clock).","script":"house-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}}},"bind":{"action":"log_maintenance"}},{"name":"flag_attention","description":"Flag a problem on a system (a noise, a drip, a trip).","script":"house-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}},"required":["note"]},"bind":{"action":"flag_attention"}},{"name":"house_status","description":"What the house needs: care due and flagged problems.","script":"house-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"house_list","description":"Read every system card.","script":"house-store","inputs":{"type":"object","properties":{}},"bind":{"action":"house_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"systems.json"}]},"schedules":[],"workspaceSeed":["systems.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"job-hunt","name":"Job Hunt","description":"Run your job search like a campaign — a career coach tracks every application through the pipeline, a mock interviewer gets you ready, and a live board keeps the whole slog navigable.","tags":["job","career","interview","resume","offer"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"job-hunt","meesterManaged":false,"params":{"type":"object","properties":{"role":{"type":"string","title":"What role are you hunting for?","description":"The role or title you're targeting — e.g. Staff Engineer, Product Designer, Data Analyst.","default":"Software Engineer"},"focus":{"type":"string","title":"Anything about your search?","description":"Optional context for your coach — location constraints, timeline, dream companies.","default":"Just getting started."}},"required":["role","focus"]},"nameTemplate":"{{role}} Job Hunt","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"career-coach","voorman":true},{"templateId":"mock-interviewer","voorman":false}],"toolsets":[],"craftbooks":["tailor-resume","company-research-brief","mock-interview","offer-compare","weekly-pipeline-review"],"scripts":{"application-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'application-store',\n description:\n \"Read and update the job-search pipeline (applications in pipeline.json, activity in activity.json). Call action:'status' at the start of a session to see where the search stands; 'record_application' the moment the user mentions applying somewhere; 'advance_stage' when reality moves; 'log_activity' for follow-ups, scheduled interviews, and thank-yous.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record_application', 'advance_stage', or 'log_activity'.\",\n required: true,\n },\n company: { type: 'string', description: 'Company name (identifies the application when id is omitted).' },\n role: { type: 'string', description: 'Role title for a new application.' },\n link: { type: 'string', description: 'Posting or application link.' },\n notes: { type: 'string', description: 'Free-form notes for a new application.' },\n id: { type: 'string', description: \"Application id (e.g. 'acme-staff-engineer') when advancing or logging.\" },\n stage: {\n type: 'string',\n description: 'Target stage for advance_stage: applied, screening, interview, offer, or closed.',\n },\n outcome: { type: 'string', description: \"With stage 'closed': signed, rejected, or withdrawn.\" },\n kind: {\n type: 'string',\n description: \"Activity kind for log_activity: follow-up, interview-scheduled, thank-you, offer-received, or note.\",\n },\n note: { type: 'string', description: 'The activity note for log_activity, or a note on a stage change.' },\n due: { type: 'string', description: 'Due date (ISO) for a follow-up.' },\n },\n outputs: {\n applications: { type: 'number', description: 'Total applications tracked.' },\n summary: { type: 'string', description: 'Human-readable pipeline summary.' },\n stale: { type: 'json', description: 'Active applications untouched for over 7 days.' },\n followUps: { type: 'json', description: 'Follow-ups due within 7 days or overdue.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\nconst STAGES: [string, ...string[]] = ['applied', 'screening', 'interview', 'offer', 'closed'];\nconst OUTCOMES = ['signed', 'rejected', 'withdrawn'];\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000;\n\nconst roster = rosterStore(gezel.fs, 'pipeline.json', { stages: STAGES, idPrefix: 'app' });\nconst activity = logStore(gezel.fs, 'activity.json');\n\nfunction slugify(s: string): string {\n return (\n s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'application'\n );\n}\n\nasync function freshId(company: string, role?: string): Promise<string> {\n const base = slugify(role ? `${company} ${role}` : company);\n let candidate = base;\n for (let n = 2; (await roster.get(candidate)) !== null; n += 1) {\n candidate = `${base}-${n}`;\n }\n return candidate;\n}\n\nasync function resolveId(): Promise<string> {\n if (input.id) {\n if ((await roster.get(input.id)) === null) throw new Error(`No application '${input.id}'.`);\n return input.id;\n }\n if (input.company) {\n const wanted = input.company.toLowerCase();\n const matches = (await roster.list()).filter(\n (r) => String(r.fields.company ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n if (matches.length > 1) {\n throw new Error(\n `Multiple applications match company '${input.company}': ${matches.map((m) => m.id).join(', ')}. Pass id.`,\n );\n }\n }\n throw new Error(\"Pass 'id' or a unique 'company' to identify the application.\");\n}\n\nasync function summarize(): Promise<{\n applications: number;\n summary: string;\n stale: unknown[];\n followUps: unknown[];\n}> {\n const records = await roster.list();\n const counts = await roster.counts();\n const now = Date.now();\n const stale = records\n .filter((r) => r.status !== 'closed' && now - Date.parse(r.updatedAt) > WEEK_MS)\n .map((r) => ({\n id: r.id,\n company: r.fields.company ?? r.id,\n stage: r.status,\n daysQuiet: Math.floor((now - Date.parse(r.updatedAt)) / (24 * 60 * 60 * 1000)),\n }));\n const followUps = (await activity.list({ kind: 'follow-up' }))\n .filter((e) => {\n const due = typeof e.data?.dueAt === 'string' ? Date.parse(e.data.dueAt) : null;\n return due !== null && due <= now + WEEK_MS;\n })\n .map((e) => ({\n note: e.note ?? '',\n dueAt: e.data?.dueAt,\n applicationId: e.data?.applicationId,\n overdue: typeof e.data?.dueAt === 'string' && Date.parse(e.data.dueAt) < now,\n }));\n const active = records.filter((r) => r.status !== 'closed').length;\n const stageLine = STAGES.filter((s) => (counts[s] ?? 0) > 0)\n .map((s) => `${s} ${counts[s]}`)\n .join(', ');\n const summary = records.length\n ? `${active} application(s) in play (${stageLine || 'none'}). ${stale.length} stale, ${followUps.length} follow-up(s) due this week.`\n : 'No applications tracked yet.';\n return { applications: records.length, summary, stale, followUps };\n}\n\nif (input.action === 'record_application') {\n if (!input.company) throw new Error(\"record_application needs 'company'.\");\n const id = await freshId(input.company, input.role);\n const record = await roster.add({\n id,\n fields: {\n company: input.company,\n ...(input.role ? { role: input.role } : {}),\n ...(input.link ? { link: input.link } : {}),\n ...(input.notes ? { notes: input.notes } : {}),\n },\n });\n await activity.append({\n kind: 'applied',\n note: `Applied to ${input.company}${input.role ? ` for ${input.role}` : ''}.`,\n data: { applicationId: record.id },\n });\n} else if (input.action === 'advance_stage') {\n if (!input.stage) throw new Error(\"advance_stage needs 'stage'.\");\n if (!STAGES.includes(input.stage)) {\n throw new Error(`Unknown stage '${input.stage}'. Stages: ${STAGES.join(', ')}.`);\n }\n if (input.outcome && input.stage !== 'closed') {\n throw new Error(\"'outcome' only applies when closing (stage 'closed').\");\n }\n if (input.outcome && !OUTCOMES.includes(input.outcome)) {\n throw new Error(`Unknown outcome '${input.outcome}'. Outcomes: ${OUTCOMES.join(', ')}.`);\n }\n const id = await resolveId();\n await roster.transition(id, input.stage, input.note ? { note: input.note } : {});\n if (input.outcome) await roster.update(id, { fields: { outcome: input.outcome } });\n const company = String((await roster.get(id))?.fields.company ?? id);\n await activity.append({\n kind: 'stage',\n note: `${company} moved to ${input.stage}${input.outcome ? ` (${input.outcome})` : ''}.`,\n data: { applicationId: id },\n });\n} else if (input.action === 'log_activity') {\n if (!input.note) throw new Error(\"log_activity needs 'note'.\");\n let applicationId: string | undefined;\n if (input.id || input.company) {\n applicationId = await resolveId();\n // A logged touch counts as attention — it resets the staleness clock.\n await roster.update(applicationId, input.note ? { note: input.note } : {});\n }\n await activity.append({\n kind: input.kind ?? 'note',\n note: input.note,\n data: {\n ...(applicationId ? { applicationId } : {}),\n ...(input.due ? { dueAt: input.due } : {}),\n },\n });\n} else if (input.action !== 'status') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_application, advance_stage, or log_activity.`,\n );\n}\n\ngezel.output(await summarize());\n"},"tools":[{"name":"record_application","description":"Track a new job application the moment it happens.","script":"application-store","inputs":{"type":"object","properties":{"company":{"type":"string","description":"Company name."},"role":{"type":"string","description":"Role title."},"link":{"type":"string","description":"Posting or application link."},"notes":{"type":"string","description":"Anything worth remembering about this one."}},"required":["company"]},"bind":{"action":"record_application"}},{"name":"advance_stage","description":"Move an application to a new pipeline stage (applied, screening, interview, offer, closed).","script":"application-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Application id."},"company":{"type":"string","description":"Company name (when id is omitted)."},"stage":{"type":"string","enum":["applied","screening","interview","offer","closed"],"description":"The stage reality moved to."},"note":{"type":"string","description":"What happened."},"outcome":{"type":"string","enum":["signed","rejected","withdrawn"],"description":"Only when closing: how it ended."}},"required":["stage"]},"bind":{"action":"advance_stage"}},{"name":"log_activity","description":"Log activity on the search — a follow-up to send, an interview scheduled, a thank-you sent.","script":"application-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Application id this activity belongs to."},"company":{"type":"string","description":"Company name (when id is omitted)."},"kind":{"type":"string","enum":["follow-up","interview-scheduled","thank-you","offer-received","note"],"description":"What kind of activity this is."},"note":{"type":"string","description":"The activity itself."},"due":{"type":"string","description":"Due date (ISO) for a follow-up."}},"required":["note"]},"bind":{"action":"log_activity"}},{"name":"pipeline_status","description":"See the whole pipeline: counts per stage, stale applications, and follow-ups due this week.","script":"application-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"pipeline.json"},{"source":"workspace","path":"activity.json"}]},"schedules":[{"runMode":"scheduled","cron":"0 17 * * 5","craftbook":"weekly-pipeline-review","consent":"ask","overlap":"skip"}],"workspaceSeed":["pipeline.json","activity.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"just-chat","name":"Just Chat","description":"A quiet room for talking — no tasks, no workspace, no deliverables. Just an open thread with a gezel of your choosing: brainstorming, writing back and forth, asking questions, thinking out loud. Ideal for a warm companion gezel or a small on-device model that's happiest simply conversing.","tags":["chat","conversation","companion","casual","writing"],"category":"general","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-07-20T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"nameTemplate":"Just Chat","aboutTemplate":"about.md","gezels":[],"toolsets":[],"craftbooks":[],"tools":[],"schedules":[],"workspaceSeed":[],"artifactsSeed":[],"availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"language-trainer","name":"Language Trainer","description":"Practice a new language by chatting with a patient tutor who corrects gently and tracks your progress. Comes with a progress dashboard.","tags":["language","learning","tutor","conversation"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"content-writing","meesterManaged":false,"params":{"type":"object","properties":{"language":{"type":"string","title":"Which language?","description":"The language you want to practice, e.g. Spanish, Japanese, French.","default":"Spanish"}},"required":["language"]},"nameTemplate":"{{language}} Language Trainer","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"language-trainer","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"progress-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'progress-store',\n description:\n \"Read and update the student's language-learning progress (level, sessions, streak, notes). Call action:'status' at the start of a session to see where they are; action:'record' after a practice exchange to log it; action:'advance' when they're ready for the next level.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record', or 'advance'.\",\n required: true,\n },\n note: {\n type: 'string',\n description: 'Optional short note to append (what was practiced, a struggle to remember).',\n },\n },\n outputs: {\n level: { type: 'number', description: 'Current level (1+).' },\n sessions: { type: 'number', description: 'Total practice sessions recorded.' },\n streak: { type: 'number', description: 'Consecutive-session streak.' },\n message: { type: 'string', description: 'Human-readable summary of the current state.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\ninterface Progress {\n language: string;\n level: number;\n sessions: number;\n streak: number;\n notes: string[];\n}\n\nasync function load(): Promise<Progress> {\n try {\n const raw = await gezel.fs.read('progress.json');\n const p = JSON.parse(raw) as Partial<Progress>;\n return {\n language: p.language ?? 'your language',\n level: p.level ?? 1,\n sessions: p.sessions ?? 0,\n streak: p.streak ?? 0,\n notes: Array.isArray(p.notes) ? p.notes : [],\n };\n } catch {\n return { language: 'your language', level: 1, sessions: 0, streak: 0, notes: [] };\n }\n}\n\nconst p = await load();\nif (input.action === 'record') {\n p.sessions += 1;\n p.streak += 1;\n if (input.note) p.notes.push(input.note);\n} else if (input.action === 'advance') {\n p.level += 1;\n p.notes.push(`Advanced to level ${p.level}${input.note ? `: ${input.note}` : ''}`);\n}\nif (input.action !== 'status') {\n await gezel.fs.write('progress.json', `${JSON.stringify(p, null, 2)}\\n`);\n}\n\ngezel.output({\n level: p.level,\n sessions: p.sessions,\n streak: p.streak,\n message: `Learning ${p.language}: level ${p.level}, ${p.sessions} session(s), ${p.streak}-session streak.`,\n});\n"},"tools":[{"name":"record_session","description":"Log that the student practiced this session.","script":"progress-store","inputs":{"type":"object","properties":{"note":{"type":"string"}}},"bind":{"action":"record"}},{"name":"advance_level","description":"Advance the student to the next level.","script":"progress-store","inputs":{"type":"object","properties":{"note":{"type":"string"}}},"bind":{"action":"advance"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"progress.json"}]},"schedules":[],"workspaceSeed":["progress.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"life-binder","name":"Life Binder","description":"The \"in case of emergency\" file, actually complete. A gentle Archivaris (\"archivist\") tracks wills, policies, accounts, and instructions — what exists, where it lives, what's missing — and none of it ever leaves this machine.","tags":["estate","documents","emergency","family","insurance"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"household":{"type":"string","title":"Who is this binder for?","description":"e.g. 'our family', 'Mom', 'me'.","default":"our family"}},"required":["household"]},"nameTemplate":"Life Binder","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"archivaris","voorman":true}],"toolsets":[],"craftbooks":["annual-document-review"],"scripts":{"binder-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'binder-store',\n description:\n \"Read and update the life binder (documents.json). Call action:'status' for the completeness picture; 'record_document' when a document exists (name + where the original lives); 'mark_missing' / 'mark_reviewed' as reality changes; 'checklist' to read everything.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_document', 'mark_missing', 'mark_reviewed', or 'checklist'.\",\n required: true,\n },\n name: { type: 'string', description: \"Document name, e.g. 'Will', 'Home insurance policy'.\" },\n location: { type: 'string', description: 'Where the original lives (drawer, safe, provider).' },\n note: { type: 'string', description: 'Anything a trusted person should know about it.' },\n id: { type: 'string', description: \"Document id, e.g. 'doc-2'.\" },\n },\n outputs: {\n total: { type: 'number', description: 'Documents on the checklist.' },\n present: { type: 'number', description: 'Recorded as existing.' },\n missing: { type: 'number', description: 'Known gaps.' },\n stale: { type: 'number', description: 'Present but unreviewed for over a year.' },\n summary: { type: 'string', description: 'The completeness picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── binder-rules-start ──\n// Pure derivations over rosterStore-shaped document records: statuses are\n// missing -> present; staleness is derived (present + unreviewed > 365d).\n\nvar YEAR_MS = 365 * 24 * 60 * 60 * 1000;\n\nfunction isStale(record, nowMs) {\n if (record.status !== 'present') return false;\n var reviewed = record.fields && typeof record.fields.reviewedAt === 'string'\n ? Date.parse(record.fields.reviewedAt)\n : Date.parse(record.updatedAt);\n return isFinite(reviewed) && nowMs - reviewed > YEAR_MS;\n}\n\nfunction binderShape(records, nowMs) {\n var present = 0;\n var missing = [];\n var stale = [];\n records.forEach(function (r) {\n if (r.status === 'present') {\n present += 1;\n if (isStale(r, nowMs)) stale.push(r);\n } else {\n missing.push(r);\n }\n });\n return { present: present, missing: missing, stale: stale };\n}\n\nfunction binderLine(records, nowMs) {\n var shape = binderShape(records, nowMs);\n var gaps = shape.missing\n .map(function (r) {\n return r.fields && r.fields.name ? r.fields.name : r.id;\n })\n .join(', ');\n var staleNames = shape.stale\n .map(function (r) {\n return r.fields && r.fields.name ? r.fields.name : r.id;\n })\n .join(', ');\n return (\n shape.present +\n ' of ' +\n records.length +\n ' in place' +\n (gaps ? '. Missing: ' + gaps : '. Nothing missing') +\n (staleNames ? '. Due a review: ' + staleNames + '.' : '.')\n );\n}\n// ── binder-rules-end ──\n\nconst binder = rosterStore(gezel.fs, 'documents.json', {\n stages: ['missing', 'present'],\n idPrefix: 'doc',\n});\n\nasync function resolveDoc(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await binder.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'doc-2') or an exact document 'name'.\");\n}\n\nif (input.action === 'record_document') {\n if (!input.name) throw new Error(\"record_document needs 'name'.\");\n const existing = (await binder.list()).find(\n (r) => String(r.fields.name ?? '').toLowerCase() === input.name!.toLowerCase(),\n );\n if (existing) {\n await binder.update(existing.id, {\n fields: {\n ...(input.location ? { location: input.location } : {}),\n ...(input.note ? { note: input.note } : {}),\n reviewedAt: new Date().toISOString(),\n },\n });\n await binder.transition(existing.id, 'present');\n } else {\n await binder.add({\n status: 'present',\n fields: {\n name: input.name,\n ...(input.location ? { location: input.location } : {}),\n ...(input.note ? { note: input.note } : {}),\n reviewedAt: new Date().toISOString(),\n },\n });\n }\n} else if (input.action === 'mark_missing') {\n await binder.transition(await resolveDoc(), 'missing');\n} else if (input.action === 'mark_reviewed') {\n const id = await resolveDoc();\n await binder.update(id, { fields: { reviewedAt: new Date().toISOString() } });\n await binder.transition(id, 'present');\n} else if (input.action !== 'status' && input.action !== 'checklist') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_document, mark_missing, mark_reviewed, or checklist.`,\n );\n}\n\nconst records = await binder.list();\nconst shape = binderShape(records, Date.now());\nconst listing =\n input.action === 'checklist'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}${isStale(r, Date.now()) ? ', stale' : ''}] ${String(r.fields.name ?? '?')}${r.fields.location ? ` — ${String(r.fields.location)}` : ''}`,\n )\n .join('\\n') || 'The checklist is empty.'\n : '';\n\ngezel.output({\n total: records.length,\n present: shape.present,\n missing: shape.missing.length,\n stale: shape.stale.length,\n summary: listing || binderLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_document","description":"Record that a document exists and where the original lives.","script":"binder-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"location":{"type":"string"},"note":{"type":"string"}},"required":["name"]},"bind":{"action":"record_document"}},{"name":"mark_missing","description":"Mark a checklist item as a known gap.","script":"binder-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"bind":{"action":"mark_missing"}},{"name":"mark_reviewed","description":"Confirm a document was just reviewed and is current.","script":"binder-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"bind":{"action":"mark_reviewed"}},{"name":"binder_status","description":"The completeness picture: what's in place, missing, or due a review.","script":"binder-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"checklist","description":"Read the whole checklist with locations.","script":"binder-store","inputs":{"type":"object","properties":{}},"bind":{"action":"checklist"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"documents.json"}]},"schedules":[],"workspaceSeed":["documents.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"meal-planner","name":"Meal Planner & Pantry","description":"Answer \"what's for dinner?\" once a week. A Maaltijdplanner (\"meal planner\") plans menus from what's actually in the pantry, keeps taste memory, and the grocery list writes itself.","tags":["meals","cooking","pantry","groceries","menu"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"household":{"type":"string","title":"Who eats here?","description":"e.g. 'two adults, two picky kids', 'just me, vegetarian'.","default":"the household"}},"required":["household"]},"nameTemplate":"Meal Planner","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"maaltijdplanner","voorman":true}],"toolsets":[],"craftbooks":["weekly-menu-plan"],"scripts":{"kitchen-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'kitchen-store',\n description:\n \"Read and update the week's menu (menu.json) and the pantry (pantry.json). Call action:'status' for today's dinner + the grocery gap; 'set_menu' day by day; 'pantry_add' / 'pantry_level' as staples change; 'grocery_list' for the run.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'set_menu', 'pantry_add', 'pantry_level', or 'grocery_list'.\",\n required: true,\n },\n day: {\n type: 'string',\n description: \"Day for set_menu: monday..sunday.\",\n },\n meal: { type: 'string', description: 'The dinner for that day.' },\n name: { type: 'string', description: 'Pantry item name.' },\n level: { type: 'string', description: \"Pantry level: 'stocked', 'low', or 'out'.\" },\n id: { type: 'string', description: \"Pantry item id, e.g. 'item-3'.\" },\n },\n outputs: {\n planned: { type: 'number', description: 'Days with a planned dinner.' },\n groceries: { type: 'number', description: 'Items on the grocery list.' },\n summary: { type: 'string', description: 'The kitchen picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── kitchen-rules-start ──\n// Pure derivations: the weekly menu shape and the grocery gap (pantry\n// items marked low or out).\n\nvar DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];\n\nfunction normalizeDay(day) {\n if (typeof day !== 'string') return null;\n var d = day.toLowerCase();\n return DAYS.indexOf(d) >= 0 ? d : null;\n}\n\nfunction todayName(nowMs) {\n // getUTCDay: 0=Sunday.\n var idx = new Date(nowMs).getUTCDay();\n return DAYS[(idx + 6) % 7];\n}\n\nfunction groceryGap(records) {\n return records.filter(function (r) {\n return r.status === 'low' || r.status === 'out';\n });\n}\n\nfunction kitchenLine(menu, records, nowMs) {\n var today = todayName(nowMs);\n var tonight = menu.days && menu.days[today];\n var gap = groceryGap(records);\n var planned = DAYS.filter(function (d) {\n return menu.days && menu.days[d];\n }).length;\n return (\n (tonight ? \"Tonight: \" + tonight + '.' : 'Nothing planned for tonight.') +\n ' ' +\n planned +\n ' of 7 dinners planned. Grocery list: ' +\n (gap.length\n ? gap\n .map(function (r) {\n return (r.fields && r.fields.name) || r.id;\n })\n .join(', ')\n : 'empty')\n );\n}\n// ── kitchen-rules-end ──\n\nconst MENU_FILE = 'menu.json';\nconst pantry = rosterStore(gezel.fs, 'pantry.json', {\n stages: ['stocked', 'low', 'out'],\n idPrefix: 'item',\n});\n\ninterface Menu {\n version: number;\n days: Record<string, string>;\n updatedAt: string;\n}\n\nasync function loadMenu(): Promise<Menu> {\n try {\n const parsed = JSON.parse(await gezel.fs.read(MENU_FILE)) as Partial<Menu>;\n return {\n version: 1,\n days: parsed.days && typeof parsed.days === 'object' ? (parsed.days as Record<string, string>) : {},\n updatedAt: new Date().toISOString(),\n };\n } catch {\n return { version: 1, days: {}, updatedAt: new Date().toISOString() };\n }\n}\n\nasync function saveMenu(menu: Menu): Promise<void> {\n menu.updatedAt = new Date().toISOString();\n await gezel.fs.write(MENU_FILE, `${JSON.stringify(menu, null, 2)}\\n`);\n}\n\nasync function resolveItem(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await pantry.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'item-3') or an exact pantry item 'name'.\");\n}\n\nconst menu = await loadMenu();\n\nif (input.action === 'set_menu') {\n const day = normalizeDay(input.day);\n if (!day || !input.meal) throw new Error(\"set_menu needs 'day' (monday..sunday) and 'meal'.\");\n menu.days[day] = input.meal;\n await saveMenu(menu);\n} else if (input.action === 'pantry_add') {\n if (!input.name) throw new Error(\"pantry_add needs 'name'.\");\n const level = input.level && ['stocked', 'low', 'out'].includes(input.level) ? input.level : 'stocked';\n await pantry.add({ status: level, fields: { name: input.name } });\n} else if (input.action === 'pantry_level') {\n if (!input.level || !['stocked', 'low', 'out'].includes(input.level)) {\n throw new Error(\"pantry_level needs level 'stocked', 'low', or 'out'.\");\n }\n await pantry.transition(await resolveItem(), input.level);\n} else if (input.action !== 'status' && input.action !== 'grocery_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, set_menu, pantry_add, pantry_level, or grocery_list.`,\n );\n}\n\nconst records = await pantry.list();\nconst gap = groceryGap(records);\nconst listing =\n input.action === 'grocery_list'\n ? gap\n .map((r) => `${String(r.fields.name ?? r.id)} (${r.status})`)\n .join('\\n') || 'The grocery list is empty.'\n : '';\n\ngezel.output({\n planned: DAYS.filter((d) => menu.days[d]).length,\n groceries: gap.length,\n summary: listing || kitchenLine(menu, records, Date.now()),\n});\n"},"tools":[{"name":"set_menu","description":"Set a day's dinner (monday..sunday).","script":"kitchen-store","inputs":{"type":"object","properties":{"day":{"type":"string"},"meal":{"type":"string"}},"required":["day","meal"]},"bind":{"action":"set_menu"}},{"name":"pantry_add","description":"Add a pantry staple (level stocked, low, or out).","script":"kitchen-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"level":{"type":"string","enum":["stocked","low","out"]}},"required":["name"]},"bind":{"action":"pantry_add"}},{"name":"pantry_level","description":"Update a staple's level as the kitchen changes.","script":"kitchen-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"level":{"type":"string","enum":["stocked","low","out"]}},"required":["level"]},"bind":{"action":"pantry_level"}},{"name":"kitchen_status","description":"Tonight's dinner, the week's coverage, and the grocery gap.","script":"kitchen-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"grocery_list","description":"The shopping run: everything low or out.","script":"kitchen-store","inputs":{"type":"object","properties":{}},"bind":{"action":"grocery_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"menu.json"},{"source":"workspace","path":"pantry.json"}]},"schedules":[],"workspaceSeed":["menu.json","pantry.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"novel-writing-room","name":"Novel Writing Room","description":"A room for the long haul of a novel. A warm Schrijfmaat (\"writing mate\") logs every writing session, keeps the chapter board honest, and reads drafts like a friend who wants the book to exist — with a live board of words and chapters.","tags":["writing","novel","fiction","drafting","wordcount"],"category":"writing","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"title":{"type":"string","title":"Working title","description":"The novel, as it is known today. Change it any time.","default":"Untitled"},"weeklyWordGoal":{"type":"number","title":"Words per week","description":"The weekly writing rhythm to aim for.","default":5000}},"required":["title","weeklyWordGoal"]},"nameTemplate":"Novel: {{title}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"schrijfmaat","voorman":true}],"toolsets":[],"craftbooks":["copy-review","ebook-compile"],"scripts":{"manuscript-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'manuscript-store',\n description:\n \"Read and update the manuscript record (sessions.json + chapters.json). Call action:'status' for total words, weekly pace, and the chapter board; 'log_session' after every writing session (words written); 'add_chapter' / 'chapter_stage' to keep the board honest; 'outline' to list every chapter.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'log_session', 'add_chapter', 'chapter_stage', or 'outline'.\",\n required: true,\n },\n words: { type: 'number', description: 'Words written this session (log_session).' },\n note: { type: 'string', description: 'Anything worth remembering.' },\n title: { type: 'string', description: 'Chapter title (add_chapter).' },\n summary: { type: 'string', description: 'One-line chapter summary (add_chapter).' },\n id: { type: 'string', description: \"Chapter id, e.g. 'ch-3' (chapter_stage).\" },\n stage: {\n type: 'string',\n description: \"Target stage: 'outline', 'drafting', 'revised', or 'done' (chapter_stage).\",\n },\n },\n outputs: {\n totalWords: { type: 'number', description: 'Words logged across all sessions.' },\n thisWeekWords: { type: 'number', description: 'Words logged this week (Monday UTC start).' },\n chapters: { type: 'number', description: 'Chapters on the board.' },\n summary: { type: 'string', description: 'The manuscript picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── novel-rules-start ──\n// Pure derivations over logStore-shaped writing sessions (kind 'writing',\n// data {words}) and rosterStore-shaped chapters (stages outline → drafting\n// → revised → done). Weeks start Monday 00:00 UTC; nowIso is a parameter\n// so the rules stay clock-independent and testable.\n\nfunction sessionWords(e) {\n var words = e && e.data ? Number(e.data.words) : NaN;\n return isFinite(words) && words > 0 ? Math.round(words) : 0;\n}\n\nfunction totalWords(events) {\n return events.reduce(function (sum, e) {\n return sum + sessionWords(e);\n }, 0);\n}\n\nfunction weekStartMs(nowIso) {\n var day = new Date(nowIso.slice(0, 10) + 'T00:00:00Z');\n var sinceMonday = (day.getUTCDay() + 6) % 7;\n return day.getTime() - sinceMonday * 86400000;\n}\n\nfunction thisWeekWords(events, nowIso) {\n var start = weekStartMs(nowIso);\n return totalWords(\n events.filter(function (e) {\n return Date.parse(e.at) >= start;\n }),\n );\n}\n\nfunction chapterShape(records) {\n var byStage = { outline: 0, drafting: 0, revised: 0, done: 0 };\n records.forEach(function (r) {\n if (byStage[r.status] !== undefined) byStage[r.status] += 1;\n });\n return byStage;\n}\n\nfunction novelLine(events, records, nowIso, weeklyWordGoal) {\n var total = totalWords(events);\n var week = thisWeekWords(events, nowIso);\n var shape = chapterShape(records);\n var goal = weeklyWordGoal > 0 ? weeklyWordGoal : 0;\n return (\n total +\n ' words logged; ' +\n week +\n (goal > 0 ? ' of ' + goal : '') +\n ' this week. Chapters: ' +\n shape.outline +\n ' outlined, ' +\n shape.drafting +\n ' drafting, ' +\n shape.revised +\n ' revised, ' +\n shape.done +\n ' done.'\n );\n}\n// ── novel-rules-end ──\n\nconst sessions = logStore(gezel.fs, 'sessions.json');\nconst chapters = rosterStore(gezel.fs, 'chapters.json', {\n stages: ['outline', 'drafting', 'revised', 'done'],\n idPrefix: 'ch',\n});\n\nasync function readWeeklyWordGoal(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('sessions.json')) as { weeklyWordGoal?: unknown };\n const goal = Number(parsed.weeklyWordGoal);\n return Number.isFinite(goal) && goal > 0 ? Math.round(goal) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'log_session') {\n const words = Number(input.words);\n if (!Number.isFinite(words) || words <= 0) {\n throw new Error(\"log_session needs a positive 'words' count.\");\n }\n await sessions.append({\n kind: 'writing',\n ...(input.note ? { note: input.note } : {}),\n data: { words: Math.round(words) },\n });\n} else if (input.action === 'add_chapter') {\n if (!input.title) throw new Error(\"add_chapter needs a 'title'.\");\n await chapters.add({\n fields: { title: input.title, ...(input.summary ? { summary: input.summary } : {}) },\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action === 'chapter_stage') {\n if (!input.id || !input.stage) {\n throw new Error(\"chapter_stage needs 'id' (e.g. 'ch-3') and 'stage'.\");\n }\n await chapters.transition(input.id, input.stage, input.note ? { note: input.note } : undefined);\n} else if (input.action !== 'status' && input.action !== 'outline') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, log_session, add_chapter, chapter_stage, or outline.`,\n );\n}\n\nconst events = await sessions.list();\nconst records = await chapters.list();\nconst weeklyWordGoal = await readWeeklyWordGoal();\nconst nowIso = new Date().toISOString();\nconst listing =\n input.action === 'outline'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.title ?? '')}${r.fields.summary ? ` — ${String(r.fields.summary)}` : ''}`,\n )\n .join('\\n') || 'No chapters on the board yet.'\n : '';\n\ngezel.output({\n totalWords: totalWords(events),\n thisWeekWords: thisWeekWords(events, nowIso),\n chapters: records.length,\n summary: listing || novelLine(events, records, nowIso, weeklyWordGoal),\n});\n"},"tools":[{"name":"log_session","description":"Log a writing session (words written) the moment it is reported.","script":"manuscript-store","inputs":{"type":"object","properties":{"words":{"type":"number"},"note":{"type":"string"}},"required":["words"]},"bind":{"action":"log_session"}},{"name":"add_chapter","description":"Put a chapter on the board (starts in outline).","script":"manuscript-store","inputs":{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"}},"required":["title"]},"bind":{"action":"add_chapter"}},{"name":"chapter_stage","description":"Move a chapter between stages: 'outline', 'drafting', 'revised', 'done'.","script":"manuscript-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"stage":{"type":"string"},"note":{"type":"string"}},"required":["id","stage"]},"bind":{"action":"chapter_stage"}},{"name":"novel_status","description":"Total words, this week against the goal, the chapter shape.","script":"manuscript-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"outline","description":"Every chapter with its stage and summary.","script":"manuscript-store","inputs":{"type":"object","properties":{}},"bind":{"action":"outline"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"sessions.json"},{"source":"workspace","path":"chapters.json"}]},"schedules":[],"workspaceSeed":["sessions.json","chapters.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"trip-planner","name":"Trip Planner","description":"Plan a trip you'll actually be present for. A Reisleider (\"tour leader\") builds the day-by-day itinerary with slack in it, keeps the packing list checkable on a live board, and helps the journal happen.","tags":["travel","trip","itinerary","packing","journal"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"destination":{"type":"string","title":"Where to?","description":"e.g. 'Kyoto', 'the Scottish Highlands'.","default":"somewhere wonderful"},"start":{"type":"string","title":"Departure date","description":"e.g. 2026-09-12. A rough date is fine.","default":"2026-12-01"}},"required":["destination","start"]},"nameTemplate":"Trip: {{destination}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"reisleider","voorman":true}],"toolsets":[],"craftbooks":["pre-departure-countdown"],"scripts":{"trip-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'trip-store',\n description:\n \"Read and update the trip: itinerary + packing (trip.json) and the journal (journal.json). Call action:'status' for the shape of the trip; 'plan_day' with date + plan; 'pack_add' / 'pack_check' for the packing list; 'journal_entry' during the trip.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'plan_day', 'pack_add', 'pack_check', 'journal_entry', or 'itinerary'.\",\n required: true,\n },\n date: { type: 'string', description: 'Day being planned, e.g. 2026-09-14.' },\n plan: { type: 'string', description: \"The day's plan (anchor + options).\" },\n item: { type: 'string', description: 'Packing item name.' },\n id: { type: 'string', description: \"Packing item id, e.g. 'pack-3'.\" },\n note: { type: 'string', description: 'Journal entry text.' },\n },\n outputs: {\n daysPlanned: { type: 'number', description: 'Days with a plan.' },\n unpacked: { type: 'number', description: 'Packing items still unpacked.' },\n summary: { type: 'string', description: 'The trip picture (or the itinerary).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── trip-rules-start ──\n// Pure derivations over the trip doc: countdown and itinerary rendering.\n\nfunction daysUntil(dateStr, nowMs) {\n var when = Date.parse(dateStr);\n if (!isFinite(when)) return null;\n return Math.ceil((when - nowMs) / (24 * 60 * 60 * 1000));\n}\n\nfunction renderItinerary(days) {\n var dates = Object.keys(days).sort();\n if (dates.length === 0) return 'No days planned yet.';\n return dates\n .map(function (d) {\n return d + ': ' + days[d];\n })\n .join('\\n');\n}\n\nfunction tripLine(trip, unpackedCount, nowMs) {\n var start = daysUntil(trip.startDate, nowMs);\n var planned = Object.keys(trip.days || {}).length;\n var lead =\n start === null\n ? ''\n : start > 0\n ? start + ' day(s) until departure. '\n : start === 0\n ? 'Departure day! '\n : 'On the road (or home again). ';\n return (\n lead + planned + ' day(s) planned. Packing: ' +\n (unpackedCount === 0 ? 'all packed.' : unpackedCount + ' item(s) still unpacked.')\n );\n}\n// ── trip-rules-end ──\n\nconst TRIP_FILE = 'trip.json';\nconst packing = rosterStore(gezel.fs, 'packing.json', {\n stages: ['unpacked', 'packed'],\n idPrefix: 'pack',\n});\nconst journal = logStore(gezel.fs, 'journal.json');\n\ninterface Trip {\n version: number;\n destination: string;\n startDate: string;\n days: Record<string, string>;\n updatedAt: string;\n}\n\nasync function loadTrip(): Promise<Trip> {\n try {\n const parsed = JSON.parse(await gezel.fs.read(TRIP_FILE)) as Partial<Trip>;\n return {\n version: 1,\n destination: typeof parsed.destination === 'string' ? parsed.destination : '',\n startDate: typeof parsed.startDate === 'string' ? parsed.startDate : '',\n days: parsed.days && typeof parsed.days === 'object' ? (parsed.days as Record<string, string>) : {},\n updatedAt: new Date().toISOString(),\n };\n } catch {\n return { version: 1, destination: '', startDate: '', days: {}, updatedAt: new Date().toISOString() };\n }\n}\n\nasync function saveTrip(trip: Trip): Promise<void> {\n trip.updatedAt = new Date().toISOString();\n await gezel.fs.write(TRIP_FILE, `${JSON.stringify(trip, null, 2)}\\n`);\n}\n\nconst trip = await loadTrip();\n\nif (input.action === 'plan_day') {\n if (!input.date || !input.plan) throw new Error(\"plan_day needs 'date' (YYYY-MM-DD) and 'plan'.\");\n trip.days[input.date.slice(0, 10)] = input.plan;\n await saveTrip(trip);\n} else if (input.action === 'pack_add') {\n if (!input.item) throw new Error(\"pack_add needs 'item'.\");\n await packing.add({ status: 'unpacked', fields: { name: input.item } });\n} else if (input.action === 'pack_check') {\n let id = input.id;\n if (!id && input.item) {\n const wanted = input.item.toLowerCase();\n const matches = (await packing.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) id = matches[0]!.id;\n }\n if (!id) throw new Error(\"Pass 'id' (e.g. 'pack-3') or an exact packing 'item'.\");\n await packing.transition(id, 'packed');\n} else if (input.action === 'journal_entry') {\n if (!input.note) throw new Error(\"journal_entry needs 'note'.\");\n await journal.append({ kind: 'entry', note: input.note });\n} else if (input.action !== 'status' && input.action !== 'itinerary') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, plan_day, pack_add, pack_check, journal_entry, or itinerary.`,\n );\n}\n\nconst packItems = await packing.list();\nconst unpacked = packItems.filter((r) => r.status === 'unpacked').length;\n\ngezel.output({\n daysPlanned: Object.keys(trip.days).length,\n unpacked,\n summary:\n input.action === 'itinerary' ? renderItinerary(trip.days) : tripLine(trip, unpacked, Date.now()),\n});\n"},"tools":[{"name":"plan_day","description":"Set a day's plan (one anchor, options, slack).","script":"trip-store","inputs":{"type":"object","properties":{"date":{"type":"string"},"plan":{"type":"string"}},"required":["date","plan"]},"bind":{"action":"plan_day"}},{"name":"pack_add","description":"Add an item to the packing list.","script":"trip-store","inputs":{"type":"object","properties":{"item":{"type":"string"}},"required":["item"]},"bind":{"action":"pack_add"}},{"name":"pack_check","description":"Mark an item packed.","script":"trip-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"item":{"type":"string"}}},"bind":{"action":"pack_check"}},{"name":"journal_entry","description":"Capture a trip moment — a line a day.","script":"trip-store","inputs":{"type":"object","properties":{"note":{"type":"string"}},"required":["note"]},"bind":{"action":"journal_entry"}},{"name":"trip_status","description":"Countdown, days planned, and the packing state.","script":"trip-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"itinerary","description":"Read the full day-by-day itinerary.","script":"trip-store","inputs":{"type":"object","properties":{}},"bind":{"action":"itinerary"}},{"name":"mark_packed","description":"The dashboard's packing checkbox.","script":"trip-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"pack_check"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"trip.json"},{"source":"workspace","path":"packing.json"},{"source":"workspace","path":"journal.json"}],"tools":["mark_packed"]},"schedules":[],"workspaceSeed":["trip.json","packing.json","journal.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}}]}
|
|
1
|
+
{"schemaVersion":1,"kind":"project-type","count":22,"entries":[{"manifest":{"schemaVersion":1,"kind":"project-type","id":"caregiving-binder","name":"Caregiving Binder","description":"Coordinate a loved one's care without losing the thread. A discreet Zorgcoördinator (\"care coordinator\") keeps meds, appointments, and the day-to-day journal — and preps every doctor visit from the record. Nothing leaves this machine.","tags":["caregiving","health","family","medications","appointments"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"person":{"type":"string","title":"Who is the care for?","description":"A first name is enough — e.g. 'Mom', 'Jan'.","default":"Mom"}},"required":["person"]},"nameTemplate":"Caregiving Binder","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"zorgcoordinator","voorman":true}],"toolsets":[],"craftbooks":["visit-prep"],"scripts":{"care-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'care-store',\n description:\n \"Read and update the care record: medications (meds.json) and the care journal (care.json). Call action:'status' for active meds + upcoming appointments; 'add_med' / 'stop_med' on changes; 'log_entry' for journal notes and symptoms; 'log_appointment' with when (ISO); 'visit_prep' before a doctor visit.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'add_med', 'stop_med', 'log_entry', 'log_appointment', or 'visit_prep'.\",\n required: true,\n },\n name: { type: 'string', description: 'Medication name.' },\n dose: { type: 'string', description: \"Dose and schedule, e.g. '10mg mornings'.\" },\n prescriber: { type: 'string', description: 'Who prescribed it.' },\n id: { type: 'string', description: \"Medication id, e.g. 'med-2'.\" },\n kind: { type: 'string', description: \"Journal kind: 'note', 'symptom', or 'update'.\" },\n note: { type: 'string', description: 'The journal entry or appointment description.' },\n when: { type: 'string', description: 'Appointment time (ISO), e.g. 2026-08-02T14:00.' },\n },\n outputs: {\n meds: { type: 'number', description: 'Active medications.' },\n upcoming: { type: 'number', description: 'Upcoming appointments.' },\n summary: { type: 'string', description: 'The care picture (or the visit prep).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── care-rules-start ──\n// Pure derivations: upcoming appointments (journal events kind\n// 'appointment' with data.whenAt in the future) and the since-last-visit\n// window for visit prep.\n\nfunction upcomingAppointments(events, nowMs) {\n return events\n .filter(function (e) {\n var when = e.data && typeof e.data.whenAt === 'string' ? Date.parse(e.data.whenAt) : NaN;\n return e.kind === 'appointment' && isFinite(when) && when >= nowMs;\n })\n .sort(function (a, b) {\n return Date.parse(a.data.whenAt) - Date.parse(b.data.whenAt);\n });\n}\n\nfunction lastVisitMs(events, nowMs) {\n var past = events.filter(function (e) {\n var when = e.data && typeof e.data.whenAt === 'string' ? Date.parse(e.data.whenAt) : NaN;\n return e.kind === 'appointment' && isFinite(when) && when < nowMs;\n });\n if (past.length === 0) return null;\n return Math.max.apply(\n null,\n past.map(function (e) {\n return Date.parse(e.data.whenAt);\n }),\n );\n}\n\nfunction sinceWindow(events, nowMs) {\n var last = lastVisitMs(events, nowMs);\n return events.filter(function (e) {\n return e.kind !== 'appointment' && (last === null || Date.parse(e.at) > last);\n });\n}\n// ── care-rules-end ──\n\nconst meds = rosterStore(gezel.fs, 'meds.json', {\n stages: ['active', 'stopped'],\n idPrefix: 'med',\n});\nconst journal = logStore(gezel.fs, 'care.json');\n\nasync function resolveMed(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await meds.list({ status: 'active' })).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'med-2') or an exact active medication 'name'.\");\n}\n\nif (input.action === 'add_med') {\n if (!input.name || !input.dose) throw new Error(\"add_med needs 'name' and 'dose'.\");\n await meds.add({\n status: 'active',\n fields: {\n name: input.name,\n dose: input.dose,\n ...(input.prescriber ? { prescriber: input.prescriber } : {}),\n },\n });\n await journal.append({ kind: 'update', note: `Started ${input.name} (${input.dose}).` });\n} else if (input.action === 'stop_med') {\n const id = await resolveMed();\n const med = await meds.get(id);\n await meds.transition(id, 'stopped', input.note ? { note: input.note } : {});\n await journal.append({\n kind: 'update',\n note: `Stopped ${String(med?.fields.name ?? id)}${input.note ? ` — ${input.note}` : ''}.`,\n });\n} else if (input.action === 'log_entry') {\n if (!input.note) throw new Error(\"log_entry needs 'note'.\");\n const kind = input.kind === 'symptom' || input.kind === 'update' ? input.kind : 'note';\n await journal.append({ kind, note: input.note });\n} else if (input.action === 'log_appointment') {\n if (!input.note || !input.when) throw new Error(\"log_appointment needs 'note' and 'when' (ISO).\");\n await journal.append({ kind: 'appointment', note: input.note, data: { whenAt: input.when } });\n} else if (input.action !== 'status' && input.action !== 'visit_prep') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_med, stop_med, log_entry, log_appointment, or visit_prep.`,\n );\n}\n\nconst activeMeds = await meds.list({ status: 'active' });\nconst events = await journal.list();\nconst nowMs = Date.now();\nconst upcoming = upcomingAppointments(events, nowMs);\n\nlet summary: string;\nif (input.action === 'visit_prep') {\n const since = sinceWindow(events, nowMs);\n const medLines = activeMeds\n .map((m) => `- ${String(m.fields.name)} ${String(m.fields.dose ?? '')}`.trim())\n .join('\\n');\n const noteLines = since\n .map((e) => `- [${e.kind}] ${String(e.at).slice(0, 10)}: ${e.note ?? ''}`)\n .join('\\n');\n summary = `VISIT PREP\\nActive medications:\\n${medLines || '- none recorded'}\\nSince the last visit:\\n${noteLines || '- quiet — nothing logged'}\\nUpcoming:\\n${upcoming.map((e) => `- ${String(e.data?.whenAt).slice(0, 16)} ${e.note ?? ''}`).join('\\n') || '- none scheduled'}`;\n} else {\n const next = upcoming[0];\n summary = `${activeMeds.length} active medication(s). ${upcoming.length} upcoming appointment(s)${\n next ? ` — next: ${String(next.data?.whenAt).slice(0, 16)} ${next.note ?? ''}` : ''\n }. ${events.filter((e) => e.kind !== 'appointment').length} journal entr(ies).`;\n}\n\ngezel.output({\n meds: activeMeds.length,\n upcoming: upcoming.length,\n summary,\n});\n"},"tools":[{"name":"add_med","description":"Record a medication (name, dose/schedule, prescriber).","script":"care-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"dose":{"type":"string"},"prescriber":{"type":"string"}},"required":["name","dose"]},"bind":{"action":"add_med"}},{"name":"stop_med","description":"Mark a medication stopped (with why, when known).","script":"care-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}}},"bind":{"action":"stop_med"}},{"name":"log_entry","description":"Add a journal entry — a note, a symptom observation, an update.","script":"care-store","inputs":{"type":"object","properties":{"kind":{"type":"string","enum":["note","symptom","update"]},"note":{"type":"string"}},"required":["note"]},"bind":{"action":"log_entry"}},{"name":"log_appointment","description":"Record an appointment (what and when, ISO).","script":"care-store","inputs":{"type":"object","properties":{"note":{"type":"string"},"when":{"type":"string"}},"required":["note","when"]},"bind":{"action":"log_appointment"}},{"name":"care_status","description":"Active meds, upcoming appointments, journal count.","script":"care-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"visit_prep","description":"The one-page brief for the next doctor visit: meds + what happened since the last one.","script":"care-store","inputs":{"type":"object","properties":{}},"bind":{"action":"visit_prep"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"meds.json"},{"source":"workspace","path":"care.json"}]},"schedules":[],"workspaceSeed":["meds.json","care.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"checkers","name":"Checkers","description":"Play checkers against a gezel opponent on a live board. You move on the board; the Damspeler (\"checkers player\") sees the position, plays back, and keeps the table talk going.","tags":["checkers","game","board-game","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.1.2","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Damspeler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Checkers vs the Damspeler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"checkers-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the checkers game (game.json). Call action:'board' to see the position, the side to move, and every legal move; action:'ai_move' to play your move (from/to, optional moveThought); action:'state' for a light summary; action:'new_game' to reset, optionally with forceCaptures:false. An illegal ai_move fails with the full legal-move list — pick one of those and call again.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n from: { type: 'string', description: \"Origin square, e.g. 'c3'.\" },\n to: { type: 'string', description: \"Destination square (a jump sequence's final square).\" },\n moveThought: { type: 'string', description: 'A short line of table talk to show on the board (max 140 chars).' },\n forceCaptures: {\n type: 'boolean',\n description: \"For new_game: true for standard forced captures; false to keep ordinary moves legal when a capture is available.\",\n },\n },\n outputs: {\n board: { type: 'string', description: 'Legacy human-readable ASCII board with coordinates.' },\n modelState: {\n type: 'object',\n description:\n 'Authoritative model-facing state: orientation, square lists, capture availability, and structured legal moves.',\n },\n turn: { type: 'string', description: \"Whose move: 'user' or 'ai'.\" },\n status: { type: 'string', description: 'playing, user_won, or ai_won.' },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'c3-d4' or 'b6xd4'.\" },\n legalMoves: { type: 'string', description: 'Rendered legal moves for the side to move.' },\n captureRule: { type: 'string', description: \"Either 'forced' or 'optional'.\" },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── checkers-rules-start ──\n// Pure, annotation-free rules engine (American/English draughts, 8x8).\n// Squares: 64-char row-major string, index = y*8+x, y0 = rank 8, x0 = file a.\n// 'r'/'R' = red (the user, moving toward rank 8 / decreasing y);\n// 'b'/'B' = black (the AI, moving toward rank 1 / increasing y); '.' empty.\n// Kept self-contained between these sentinels so unit tests execute the\n// shipped bytes directly (new Function) on every platform.\n\nvar FILES = 'abcdefgh';\n\nfunction sqName(x, y) {\n return FILES[x] + String(8 - y);\n}\n\nfunction parseSq(name) {\n if (typeof name !== 'string' || name.length !== 2) return null;\n var x = FILES.indexOf(name[0].toLowerCase());\n var rank = Number(name[1]);\n if (x < 0 || !(rank >= 1 && rank <= 8)) return null;\n return { x: x, y: 8 - rank };\n}\n\nfunction pieceAt(squares, x, y) {\n return squares[y * 8 + x];\n}\n\nfunction withPiece(squares, x, y, ch) {\n var i = y * 8 + x;\n return squares.slice(0, i) + ch + squares.slice(i + 1);\n}\n\nfunction sideOf(ch) {\n if (ch === 'r' || ch === 'R') return 'user';\n if (ch === 'b' || ch === 'B') return 'ai';\n return null;\n}\n\nfunction isKing(ch) {\n return ch === 'R' || ch === 'B';\n}\n\nfunction kingRowY(side) {\n return side === 'user' ? 0 : 7;\n}\n\nfunction dirsFor(ch) {\n if (isKing(ch)) {\n return [\n { dx: -1, dy: -1 },\n { dx: 1, dy: -1 },\n { dx: -1, dy: 1 },\n { dx: 1, dy: 1 },\n ];\n }\n var dy = ch === 'r' ? -1 : 1;\n return [\n { dx: -1, dy: dy },\n { dx: 1, dy: dy },\n ];\n}\n\nfunction inBounds(x, y) {\n return x >= 0 && x < 8 && y >= 0 && y < 8;\n}\n\nfunction buildInitial() {\n var out = '';\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var dark = (x + y) % 2 === 1;\n if (dark && y <= 2) out += 'b';\n else if (dark && y >= 5) out += 'r';\n else out += '.';\n }\n }\n return out;\n}\n\n// All complete jump sequences for the piece at (x, y). A sequence keeps\n// jumping with the same piece while another capture is available; a man\n// that reaches the king row STOPS there (standard rule — the move ends on\n// crowning). Each result: { from, to, path, captures } (square names).\nfunction jumpSequencesFrom(squares, x, y, ch) {\n var side = sideOf(ch);\n var results = [];\n\n function explore(sq, cx, cy, curCh, path, captures) {\n var extended = false;\n var dirs = dirsFor(curCh);\n for (var i = 0; i < dirs.length; i += 1) {\n var mx = cx + dirs[i].dx;\n var my = cy + dirs[i].dy;\n var lx = cx + dirs[i].dx * 2;\n var ly = cy + dirs[i].dy * 2;\n if (!inBounds(lx, ly)) continue;\n var mid = pieceAt(sq, mx, my);\n if (sideOf(mid) === null || sideOf(mid) === side) continue;\n if (pieceAt(sq, lx, ly) !== '.') continue;\n\n var nextCh = curCh;\n var crowned = false;\n if (!isKing(curCh) && ly === kingRowY(side)) {\n nextCh = side === 'user' ? 'R' : 'B';\n crowned = true;\n }\n var nextSq = withPiece(withPiece(withPiece(sq, cx, cy, '.'), mx, my, '.'), lx, ly, nextCh);\n var nextPath = path.concat([sqName(lx, ly)]);\n var nextCaptures = captures.concat([sqName(mx, my)]);\n extended = true;\n if (crowned) {\n results.push({\n from: path[0],\n to: sqName(lx, ly),\n path: nextPath,\n captures: nextCaptures,\n });\n } else {\n explore(nextSq, lx, ly, nextCh, nextPath, nextCaptures);\n }\n }\n if (!extended && captures.length > 0) {\n results.push({ from: path[0], to: path[path.length - 1], path: path, captures: captures });\n }\n }\n\n explore(squares, x, y, ch, [sqName(x, y)], []);\n return results;\n}\n\n// Every legal move for `side`. With standard forced captures, any jump\n// masks quiet moves. In the optional-capture variant, both remain legal.\n// Simple moves are single diagonal steps.\nfunction legalMovesFor(squares, side, forceCaptures) {\n var jumps = [];\n var steps = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n if (sideOf(ch) !== side) continue;\n var seqs = jumpSequencesFrom(squares, x, y, ch);\n for (var j = 0; j < seqs.length; j += 1) jumps.push(seqs[j]);\n var dirs = dirsFor(ch);\n for (var i = 0; i < dirs.length; i += 1) {\n var nx = x + dirs[i].dx;\n var ny = y + dirs[i].dy;\n if (!inBounds(nx, ny) || pieceAt(squares, nx, ny) !== '.') continue;\n steps.push({\n from: sqName(x, y),\n to: sqName(nx, ny),\n path: [sqName(x, y), sqName(nx, ny)],\n captures: [],\n });\n }\n }\n }\n return forceCaptures !== false && jumps.length > 0 ? jumps : jumps.concat(steps);\n}\n\n// Apply a legal sequence, returning the new squares string. Crowns a man\n// that ends on the king row (mid-sequence crowning already ended the\n// sequence in jumpSequencesFrom, so end-of-path crowning is complete).\nfunction applySequence(squares, seq) {\n var start = parseSq(seq.from);\n var end = parseSq(seq.to);\n var ch = pieceAt(squares, start.x, start.y);\n var side = sideOf(ch);\n var next = withPiece(squares, start.x, start.y, '.');\n for (var i = 0; i < seq.captures.length; i += 1) {\n var cap = parseSq(seq.captures[i]);\n next = withPiece(next, cap.x, cap.y, '.');\n }\n var finalCh = ch;\n if (!isKing(ch) && end.y === kingRowY(side)) finalCh = side === 'user' ? 'R' : 'B';\n return withPiece(next, end.x, end.y, finalCh);\n}\n\n// The side to move with no legal move has lost (covers zero pieces too).\nfunction statusAfter(squares, sideToMove, forceCaptures) {\n if (legalMovesFor(squares, sideToMove, forceCaptures).length > 0) return 'playing';\n return sideToMove === 'user' ? 'ai_won' : 'user_won';\n}\n\nfunction describeMove(seq) {\n var joiner = seq.captures.length > 0 ? 'x' : '-';\n var text = seq.path.join(joiner);\n if (seq.captures.length > 0) text += ' (captures ' + seq.captures.join(', ') + ')';\n return text;\n}\n\nfunction renderAscii(squares) {\n var lines = [' a b c d e f g h'];\n for (var y = 0; y < 8; y += 1) {\n var row = String(8 - y);\n for (var x = 0; x < 8; x += 1) {\n row += ' ' + pieceAt(squares, x, y);\n }\n lines.push(row);\n }\n lines.push('r/R = red (your opponent, moving up). b/B = black (you, moving down). Capitals are kings.');\n return lines.join('\\n');\n}\n\nfunction renderPieces(squares) {\n var redMen = [];\n var redKings = [];\n var blackMen = [];\n var blackKings = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n var name = sqName(x, y);\n if (ch === 'r') redMen.push(name);\n else if (ch === 'R') redKings.push(name);\n else if (ch === 'b') blackMen.push(name);\n else if (ch === 'B') blackKings.push(name);\n }\n }\n return { redMen: redMen, redKings: redKings, blackMen: blackMen, blackKings: blackKings };\n}\n// ── checkers-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst STAGE_SIDES = { user: 'red', ai: 'black' };\n\ninterface GameState {\n version: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n userColor: string;\n forceCaptures: boolean;\n legalMoves: Array<{ from: string; to: string; path: string[]; captures: string[] }>;\n moveLog: Array<{\n by: 'user' | 'ai';\n from: string;\n to: string;\n captures: string[];\n at: string;\n moveThought?: string;\n }>;\n lastMove: { by: 'user' | 'ai'; from: string; to: string; captures: string[] } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction freshState(forceCaptures = true): GameState {\n const squares = buildInitial();\n return {\n version: 1,\n squares,\n turn: 'user',\n status: 'playing',\n userColor: 'red',\n forceCaptures,\n legalMoves: legalMovesFor(squares, 'user', forceCaptures),\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== 64) {\n throw new Error(`${STATE_FILE} is corrupt (squares missing); call action:'new_game' to reset.`);\n }\n return { ...freshState(parsed.forceCaptures !== false), ...parsed, forceCaptures: parsed.forceCaptures !== false } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves = state.status === 'playing' ? legalMovesFor(state.squares, state.turn, state.forceCaptures) : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction summarize(state: GameState): string {\n const counts = { r: 0, b: 0 };\n for (const ch of state.squares) {\n if (ch === 'r' || ch === 'R') counts.r += 1;\n if (ch === 'b' || ch === 'B') counts.b += 1;\n }\n if (state.status === 'user_won') return `Game over — the user (red) won. Red ${counts.r} pieces, black ${counts.b}.`;\n if (state.status === 'ai_won') return `Game over — you (black) won. Red ${counts.r} pieces, black ${counts.b}.`;\n return `Move ${state.moveLog.length + 1}: ${STAGE_SIDES[state.turn]} (${state.turn}) to move. Red ${counts.r} pieces, black ${counts.b}. Captures are ${state.forceCaptures ? 'forced' : 'optional'}.`;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.map(describeMove).join(', ') || '(none)';\n}\n\nfunction renderModelState(state: GameState) {\n return {\n status: state.status,\n sideToMove: STAGE_SIDES[state.turn],\n coordinateFormat: 'file-rank (for example e3)',\n orientation: { redForwardRankDelta: 1, blackForwardRankDelta: -1 },\n pieces: renderPieces(state.squares),\n capturePolicy: state.forceCaptures ? 'mandatory-if-available' : 'optional',\n capturesAvailable: state.legalMoves.some((move) => move.captures.length > 0),\n legalMoves: state.legalMoves.map((move) => ({\n from: move.from,\n to: move.to,\n path: move.path,\n captures: move.captures,\n type: move.captures.length > 0 ? 'capture' : 'step',\n })),\n };\n}\n\nfunction applyTurn(state: GameState, by: 'user' | 'ai', moveThought?: string): GameState {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(\n by === 'ai'\n ? \"It's not your turn — the user has not moved yet.\"\n : \"It's not your turn — the AI is still to move.\",\n );\n }\n if (!input.from || !input.to) throw new Error(\"Pass 'from' and 'to' squares, e.g. from:'c3' to:'d4'.\");\n const seq = state.legalMoves.find(\n (m) => m.from === input.from!.toLowerCase() && m.to === input.to!.toLowerCase(),\n );\n if (!seq) {\n throw new Error(\n `Illegal move ${input.from}-${input.to}. Legal moves for ${STAGE_SIDES[by]}: ${renderLegal(state)}`,\n );\n }\n state.squares = applySequence(state.squares, seq);\n const nextTurn = by === 'user' ? 'ai' : 'user';\n state.status = statusAfter(state.squares, nextTurn, state.forceCaptures) as GameState['status'];\n state.turn = nextTurn;\n const entry = {\n by,\n from: seq.from,\n to: seq.to,\n captures: seq.captures,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = { by, from: seq.from, to: seq.to, captures: seq.captures };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return state;\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove\n ? describeMove({ ...state.lastMove, path: [state.lastMove.from, state.lastMove.to] })\n : '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState(input.forceCaptures !== false));\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought ? input.moveThought.trim().slice(0, 140) : undefined;\n const before = state.legalMoves.find(\n (m) => m.from === (input.from ?? '').toLowerCase() && m.to === (input.to ?? '').toLowerCase(),\n );\n applyTurn(state, by, moveThought);\n state = await persist(state);\n lastMoveText = before ? describeMove(before) : `${input.from}-${input.to}`;\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n input.action === 'state'\n ? `${renderAscii(state.squares).split('\\n').slice(0, 9).join('\\n')}`\n : `${renderAscii(state.squares)}\\nCapture rule: ${state.forceCaptures ? 'forced' : 'optional'}.\\nTurn: ${STAGE_SIDES[state.turn]} (${state.turn}). Legal moves: ${renderLegal(state)}`,\n modelState: renderModelState(state),\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n captureRule: state.forceCaptures ? 'forced' : 'optional',\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative structured checkers position, whose turn it is, capture availability, and every legal move. A legacy ASCII board is included for human readability.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your (black's) move. Pass the from/to squares of a legal move; include a short moveThought to show on the board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string","description":"Origin square, e.g. 'b6'."},"to":{"type":"string","description":"Destination square (a jump's final landing square)."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["from","to"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's move, played from the board page.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"bind":{"action":"user_move"},"reaction":{"gezel":"checkers-player","prompt":"Your opponent played {{output.lastMove}}. The game engine returned this authoritative state:\n```json\n{{output.modelState}}\n```\nEvery `legalMoves` entry is legal. Its `type` and `captures` fields explicitly say whether it is a jump. Do not redraw the position as a grid, re-derive legality, recalculate capture availability, recount pieces, or question the stated orientation. Spend any thinking only on which listed move is strongest.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user's available moves, captures, threats, or tactics; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user's move. Answer strategy questions when asked.\nAct on the state above. If the game is over, do not move — one gracious line and offer new_game. Otherwise it is your turn: choose one listed move and call make_move with its `from` and `to` strings, including a short {{personality}} moveThought that follows the selected style. If make_move rejects it, choose one move from the legal list in the error and try again. After moving, reply with ONE short line of table talk that follows the selected style.","hideSeed":true}},{"name":"new_game","description":"Reset the board to the opening position (user moves first).","script":"game-store","inputs":{"type":"object","properties":{"forceCaptures":{"type":"boolean","description":"Whether captures are required when available. Defaults to true."}}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.1.2","1.1.1","1.1.0","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"chess","name":"Chess","description":"Play a complete game of chess against a gezel opponent on a live board. You move white; the Schaakspeler sees the exact position, replies with a legal move, and shares the table without spoiling your plans.","tags":["chess","game","board-game","strategy","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.3","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Schaakspeler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Chess vs the Schaakspeler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"chess-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the chess game (game.json). Call action:'board' to see the position and every legal move; action:'ai_move' to play your move (from/to, optional promotion and moveThought); action:'state' for a light summary; action:'new_game' to reset. Illegal moves fail with the engine's legal-move list.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n from: { type: 'string', description: \"Origin square, e.g. 'e7'.\" },\n to: { type: 'string', description: \"Destination square, e.g. 'e5'.\" },\n promotion: {\n type: 'string',\n description: \"Promotion piece: 'q', 'r', 'b', or 'n'. Defaults to queen.\",\n },\n moveThought: {\n type: 'string',\n description: 'A short line of table talk to show on the board (max 140 chars).',\n },\n },\n outputs: {\n board: { type: 'string', description: 'ASCII board with coordinates.' },\n turn: { type: 'string', description: \"Whose move: 'user' (white) or 'ai' (black).\" },\n status: {\n type: 'string',\n description: 'playing, user_won, ai_won, or draw.',\n },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'e2-e4'.\" },\n legalMoves: { type: 'string', description: 'Rendered legal moves for the side to move.' },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── chess-rules-start ──\n// Pure, annotation-free chess engine. Squares are a 64-character row-major\n// string: y0 is rank 8, x0 is file a. Uppercase pieces are white (the user);\n// lowercase pieces are black (the AI).\n\nvar FILES = 'abcdefgh';\nvar PROMOTIONS = ['q', 'r', 'b', 'n'];\n\nfunction sqName(x, y) {\n return FILES[x] + String(8 - y);\n}\n\nfunction parseSq(name) {\n if (typeof name !== 'string' || name.length !== 2) return null;\n var x = FILES.indexOf(name[0].toLowerCase());\n var rank = Number(name[1]);\n if (x < 0 || !(rank >= 1 && rank <= 8)) return null;\n return { x: x, y: 8 - rank };\n}\n\nfunction inBounds(x, y) {\n return x >= 0 && x < 8 && y >= 0 && y < 8;\n}\n\nfunction pieceAt(squares, x, y) {\n return squares[y * 8 + x];\n}\n\nfunction withPiece(squares, x, y, ch) {\n var i = y * 8 + x;\n return squares.slice(0, i) + ch + squares.slice(i + 1);\n}\n\nfunction sideOf(ch) {\n if (!ch || ch === '.') return null;\n return ch === ch.toUpperCase() ? 'user' : 'ai';\n}\n\nfunction otherSide(side) {\n return side === 'user' ? 'ai' : 'user';\n}\n\nfunction kindOf(ch) {\n return ch && ch !== '.' ? ch.toLowerCase() : '';\n}\n\nfunction buildInitial() {\n return 'rnbqkbnr' + 'pppppppp' + '.'.repeat(32) + 'PPPPPPPP' + 'RNBQKBNR';\n}\n\nfunction findKing(squares, side) {\n var needle = side === 'user' ? 'K' : 'k';\n var i = squares.indexOf(needle);\n return i < 0 ? null : { x: i % 8, y: Math.floor(i / 8) };\n}\n\nfunction isAttacked(squares, x, y, bySide) {\n var pawn = bySide === 'user' ? 'P' : 'p';\n var pawnY = y - (bySide === 'user' ? -1 : 1);\n for (var pdx of [-1, 1]) {\n var px = x - pdx;\n if (inBounds(px, pawnY) && pieceAt(squares, px, pawnY) === pawn) return true;\n }\n\n var knight = bySide === 'user' ? 'N' : 'n';\n var knightSteps = [\n [-2, -1],\n [-2, 1],\n [-1, -2],\n [-1, 2],\n [1, -2],\n [1, 2],\n [2, -1],\n [2, 1],\n ];\n for (var n of knightSteps) {\n if (inBounds(x + n[0], y + n[1]) && pieceAt(squares, x + n[0], y + n[1]) === knight) {\n return true;\n }\n }\n\n var rays = [\n [-1, 0, 'rq'],\n [1, 0, 'rq'],\n [0, -1, 'rq'],\n [0, 1, 'rq'],\n [-1, -1, 'bq'],\n [-1, 1, 'bq'],\n [1, -1, 'bq'],\n [1, 1, 'bq'],\n ];\n for (var ray of rays) {\n var rx = x + ray[0];\n var ry = y + ray[1];\n while (inBounds(rx, ry)) {\n var seen = pieceAt(squares, rx, ry);\n if (seen !== '.') {\n if (sideOf(seen) === bySide && ray[2].indexOf(kindOf(seen)) >= 0) return true;\n break;\n }\n rx += ray[0];\n ry += ray[1];\n }\n }\n\n var king = bySide === 'user' ? 'K' : 'k';\n for (var ky = -1; ky <= 1; ky += 1) {\n for (var kx = -1; kx <= 1; kx += 1) {\n if (\n (kx !== 0 || ky !== 0) &&\n inBounds(x + kx, y + ky) &&\n pieceAt(squares, x + kx, y + ky) === king\n ) {\n return true;\n }\n }\n }\n return false;\n}\n\nfunction inCheck(squares, side) {\n var king = findKing(squares, side);\n return !king || isAttacked(squares, king.x, king.y, otherSide(side));\n}\n\nfunction pushMove(out, fromX, fromY, toX, toY, extra) {\n out.push(\n Object.assign(\n {\n from: sqName(fromX, fromY),\n to: sqName(toX, toY),\n capture: false,\n },\n extra || {},\n ),\n );\n}\n\nfunction pseudoMovesFor(squares, side, castling, enPassant) {\n var out = [];\n for (var y = 0; y < 8; y += 1) {\n for (var x = 0; x < 8; x += 1) {\n var ch = pieceAt(squares, x, y);\n if (sideOf(ch) !== side) continue;\n var kind = kindOf(ch);\n\n if (kind === 'p') {\n var dir = side === 'user' ? -1 : 1;\n var startY = side === 'user' ? 6 : 1;\n var promotionY = side === 'user' ? 0 : 7;\n var oneY = y + dir;\n if (inBounds(x, oneY) && pieceAt(squares, x, oneY) === '.') {\n if (oneY === promotionY) {\n for (var promotion of PROMOTIONS) {\n pushMove(out, x, y, x, oneY, { promotion: promotion });\n }\n } else {\n pushMove(out, x, y, x, oneY);\n var twoY = y + dir * 2;\n if (y === startY && pieceAt(squares, x, twoY) === '.') {\n pushMove(out, x, y, x, twoY, { doublePawn: true });\n }\n }\n }\n for (var dx of [-1, 1]) {\n var cx = x + dx;\n var cy = y + dir;\n if (!inBounds(cx, cy)) continue;\n var target = pieceAt(squares, cx, cy);\n var targetName = sqName(cx, cy);\n var isEp = targetName === enPassant && target === '.';\n if ((target !== '.' && sideOf(target) === otherSide(side)) || isEp) {\n if (cy === promotionY) {\n for (var capturePromotion of PROMOTIONS) {\n pushMove(out, x, y, cx, cy, {\n capture: true,\n promotion: capturePromotion,\n enPassant: isEp,\n });\n }\n } else {\n pushMove(out, x, y, cx, cy, { capture: true, enPassant: isEp });\n }\n }\n }\n continue;\n }\n\n if (kind === 'n') {\n var jumps = [\n [-2, -1],\n [-2, 1],\n [-1, -2],\n [-1, 2],\n [1, -2],\n [1, 2],\n [2, -1],\n [2, 1],\n ];\n for (var jump of jumps) {\n var nx = x + jump[0];\n var ny = y + jump[1];\n if (!inBounds(nx, ny) || sideOf(pieceAt(squares, nx, ny)) === side) continue;\n pushMove(out, x, y, nx, ny, { capture: pieceAt(squares, nx, ny) !== '.' });\n }\n continue;\n }\n\n if (kind === 'b' || kind === 'r' || kind === 'q') {\n var dirs = [];\n if (kind === 'b' || kind === 'q') dirs.push([-1, -1], [-1, 1], [1, -1], [1, 1]);\n if (kind === 'r' || kind === 'q') dirs.push([-1, 0], [1, 0], [0, -1], [0, 1]);\n for (var d of dirs) {\n var sx = x + d[0];\n var sy = y + d[1];\n while (inBounds(sx, sy)) {\n var occupant = pieceAt(squares, sx, sy);\n if (sideOf(occupant) === side) break;\n pushMove(out, x, y, sx, sy, { capture: occupant !== '.' });\n if (occupant !== '.') break;\n sx += d[0];\n sy += d[1];\n }\n }\n continue;\n }\n\n if (kind === 'k') {\n for (var kdy = -1; kdy <= 1; kdy += 1) {\n for (var kdx = -1; kdx <= 1; kdx += 1) {\n if (kdx === 0 && kdy === 0) continue;\n var ktx = x + kdx;\n var kty = y + kdy;\n if (!inBounds(ktx, kty) || sideOf(pieceAt(squares, ktx, kty)) === side) continue;\n pushMove(out, x, y, ktx, kty, { capture: pieceAt(squares, ktx, kty) !== '.' });\n }\n }\n\n var homeY = side === 'user' ? 7 : 0;\n var kingChar = side === 'user' ? 'K' : 'k';\n var rookChar = side === 'user' ? 'R' : 'r';\n var enemy = otherSide(side);\n if (x === 4 && y === homeY && ch === kingChar && !isAttacked(squares, 4, homeY, enemy)) {\n var kingRight = side === 'user' ? castling.indexOf('K') >= 0 : castling.indexOf('k') >= 0;\n if (\n kingRight &&\n pieceAt(squares, 7, homeY) === rookChar &&\n pieceAt(squares, 5, homeY) === '.' &&\n pieceAt(squares, 6, homeY) === '.' &&\n !isAttacked(squares, 5, homeY, enemy) &&\n !isAttacked(squares, 6, homeY, enemy)\n ) {\n pushMove(out, x, y, 6, homeY, { castle: 'king' });\n }\n var queenRight = side === 'user' ? castling.indexOf('Q') >= 0 : castling.indexOf('q') >= 0;\n if (\n queenRight &&\n pieceAt(squares, 0, homeY) === rookChar &&\n pieceAt(squares, 1, homeY) === '.' &&\n pieceAt(squares, 2, homeY) === '.' &&\n pieceAt(squares, 3, homeY) === '.' &&\n !isAttacked(squares, 3, homeY, enemy) &&\n !isAttacked(squares, 2, homeY, enemy)\n ) {\n pushMove(out, x, y, 2, homeY, { castle: 'queen' });\n }\n }\n }\n }\n }\n return out;\n}\n\nfunction applyUnchecked(squares, move, side) {\n var from = parseSq(move.from);\n var to = parseSq(move.to);\n var ch = pieceAt(squares, from.x, from.y);\n var next = withPiece(squares, from.x, from.y, '.');\n if (move.enPassant) {\n next = withPiece(next, to.x, from.y, '.');\n }\n if (move.castle === 'king') {\n next = withPiece(next, 7, from.y, '.');\n next = withPiece(next, 5, from.y, side === 'user' ? 'R' : 'r');\n } else if (move.castle === 'queen') {\n next = withPiece(next, 0, from.y, '.');\n next = withPiece(next, 3, from.y, side === 'user' ? 'R' : 'r');\n }\n if (move.promotion) ch = side === 'user' ? move.promotion.toUpperCase() : move.promotion;\n return withPiece(next, to.x, to.y, ch);\n}\n\nfunction legalMovesFor(squares, side, castling, enPassant) {\n return pseudoMovesFor(squares, side, castling || '', enPassant || null).filter(function (move) {\n return !inCheck(applyUnchecked(squares, move, side), side);\n });\n}\n\nfunction nextCastling(castling, squares, move) {\n var rights = castling || '';\n var ch = pieceAt(squares, parseSq(move.from).x, parseSq(move.from).y);\n if (ch === 'K') rights = rights.replace(/K|Q/g, '');\n if (ch === 'k') rights = rights.replace(/k|q/g, '');\n if (move.from === 'a1' || move.to === 'a1') rights = rights.replace('Q', '');\n if (move.from === 'h1' || move.to === 'h1') rights = rights.replace('K', '');\n if (move.from === 'a8' || move.to === 'a8') rights = rights.replace('q', '');\n if (move.from === 'h8' || move.to === 'h8') rights = rights.replace('k', '');\n return rights;\n}\n\nfunction nextEnPassant(move, side) {\n if (!move.doublePawn) return null;\n var from = parseSq(move.from);\n var to = parseSq(move.to);\n return sqName(from.x, (from.y + to.y) / 2);\n}\n\nfunction isInsufficientMaterial(squares) {\n var pieces = [];\n for (var i = 0; i < squares.length; i += 1) {\n var ch = squares[i];\n if (ch !== '.' && kindOf(ch) !== 'k') pieces.push({ ch: ch, x: i % 8, y: Math.floor(i / 8) });\n }\n if (pieces.length === 0) return true;\n if (pieces.length === 1 && (kindOf(pieces[0].ch) === 'b' || kindOf(pieces[0].ch) === 'n')) {\n return true;\n }\n if (\n pieces.length > 0 &&\n pieces.every(function (p) {\n return kindOf(p.ch) === 'b';\n })\n ) {\n var color = (pieces[0].x + pieces[0].y) % 2;\n return pieces.every(function (p) {\n return (p.x + p.y) % 2 === color;\n });\n }\n return false;\n}\n\nfunction positionKey(squares, side, castling, enPassant) {\n var effectiveEnPassant =\n enPassant &&\n legalMovesFor(squares, side, castling, enPassant).some(function (move) {\n return move.enPassant;\n })\n ? enPassant\n : '-';\n return [squares, side, castling || '-', effectiveEnPassant].join('|');\n}\n\nfunction statusAfter(squares, sideToMove, castling, enPassant) {\n if (isInsufficientMaterial(squares)) return 'draw';\n var moves = legalMovesFor(squares, sideToMove, castling, enPassant);\n if (moves.length > 0) return 'playing';\n if (!inCheck(squares, sideToMove)) return 'draw';\n return sideToMove === 'user' ? 'ai_won' : 'user_won';\n}\n\nfunction describeMove(move) {\n if (move.castle === 'king') return move.from + '-' + move.to + ' (O-O)';\n if (move.castle === 'queen') return move.from + '-' + move.to + ' (O-O-O)';\n var text = move.from + (move.capture ? 'x' : '-') + move.to;\n if (move.promotion) text += '=' + move.promotion.toUpperCase();\n if (move.enPassant) text += ' e.p.';\n return text;\n}\n\nfunction renderAscii(squares) {\n var lines = [' a b c d e f g h'];\n for (var y = 0; y < 8; y += 1) {\n var row = String(8 - y);\n for (var x = 0; x < 8; x += 1) row += ' ' + pieceAt(squares, x, y);\n lines.push(row + ' ' + String(8 - y));\n }\n lines.push(' a b c d e f g h');\n lines.push('Uppercase = white (user). Lowercase = black (you).');\n return lines.join('\\n');\n}\n// ── chess-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst SIDE_NAMES = { user: 'white', ai: 'black' };\n\ninterface ChessMove {\n from: string;\n to: string;\n capture: boolean;\n promotion?: string;\n doublePawn?: boolean;\n enPassant?: boolean;\n castle?: string;\n}\n\ninterface GameState {\n version: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n castling: string;\n enPassant: string | null;\n halfmoveClock: number;\n fullmoveNumber: number;\n positionHistory: string[];\n legalMoves: ChessMove[];\n moveLog: Array<{\n by: 'user' | 'ai';\n from: string;\n to: string;\n promotion?: string;\n notation: string;\n at: string;\n moveThought?: string;\n }>;\n lastMove: {\n by: 'user' | 'ai';\n from: string;\n to: string;\n promotion?: string;\n notation: string;\n } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction freshState(): GameState {\n const squares = buildInitial();\n const castling = 'KQkq';\n return {\n version: 1,\n squares,\n turn: 'user',\n status: 'playing',\n castling,\n enPassant: null,\n halfmoveClock: 0,\n fullmoveNumber: 1,\n positionHistory: [positionKey(squares, 'user', castling, null)],\n legalMoves: legalMovesFor(squares, 'user', castling, null),\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== 64) {\n throw new Error(`${STATE_FILE} is corrupt; call action:'new_game' to reset.`);\n }\n return {\n ...freshState(),\n ...parsed,\n castling: typeof parsed.castling === 'string' ? parsed.castling : '',\n enPassant: parsed.enPassant ?? null,\n } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves =\n state.status === 'playing'\n ? legalMovesFor(state.squares, state.turn, state.castling, state.enPassant)\n : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.map(describeMove).join(', ') || '(none)';\n}\n\nfunction summarize(state: GameState): string {\n if (state.status === 'user_won') return 'Checkmate — the user (white) won.';\n if (state.status === 'ai_won') return 'Checkmate — you (black) won.';\n if (state.status === 'draw') return 'Game over — draw.';\n const check = inCheck(state.squares, state.turn) ? ' The king is in check.' : '';\n return `Move ${state.fullmoveNumber}: ${SIDE_NAMES[state.turn]} (${state.turn}) to move.${check}`;\n}\n\nfunction applyTurn(\n state: GameState,\n by: 'user' | 'ai',\n moveThought?: string,\n): { state: GameState; move: ChessMove } {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(by === 'ai' ? \"It's not your turn yet.\" : 'The opponent is still to move.');\n }\n if (!input.from || !input.to) throw new Error(\"Pass 'from' and 'to' squares.\");\n const wantedPromotion = (input.promotion || 'q').toLowerCase();\n const candidates = state.legalMoves.filter(\n (move) =>\n move.from === input.from!.toLowerCase() &&\n move.to === input.to!.toLowerCase(),\n );\n const move =\n candidates.find((candidate) => (candidate.promotion || 'q') === wantedPromotion) ??\n candidates.find((candidate) => !candidate.promotion);\n if (!move) {\n throw new Error(\n `Illegal move ${input.from}-${input.to}. Legal moves for ${SIDE_NAMES[by]}: ${renderLegal(state)}`,\n );\n }\n\n const movingPiece = pieceAt(\n state.squares,\n parseSq(move.from)!.x,\n parseSq(move.from)!.y,\n );\n const oldSquares = state.squares;\n state.castling = nextCastling(state.castling, oldSquares, move);\n state.squares = applyUnchecked(oldSquares, move, by);\n state.enPassant = nextEnPassant(move, by);\n state.halfmoveClock =\n kindOf(movingPiece) === 'p' || move.capture ? 0 : state.halfmoveClock + 1;\n if (by === 'ai') state.fullmoveNumber += 1;\n state.turn = otherSide(by) as 'user' | 'ai';\n const key = positionKey(state.squares, state.turn, state.castling, state.enPassant);\n state.positionHistory.push(key);\n state.status = statusAfter(\n state.squares,\n state.turn,\n state.castling,\n state.enPassant,\n ) as GameState['status'];\n if (\n state.status === 'playing' &&\n (state.halfmoveClock >= 100 ||\n state.positionHistory.filter((candidate) => candidate === key).length >= 3)\n ) {\n state.status = 'draw';\n }\n\n const notation = describeMove(move);\n const entry = {\n by,\n from: move.from,\n to: move.to,\n ...(move.promotion ? { promotion: move.promotion } : {}),\n notation,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = {\n by,\n from: move.from,\n to: move.to,\n ...(move.promotion ? { promotion: move.promotion } : {}),\n notation,\n };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return { state, move };\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove?.notation ?? '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState());\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought?.trim().slice(0, 140);\n const applied = applyTurn(state, by, moveThought);\n state = await persist(applied.state);\n lastMoveText = describeMove(applied.move);\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n `${renderAscii(state.squares)}\\nTurn: ${SIDE_NAMES[state.turn]} (${state.turn}).` +\n `${inCheck(state.squares, state.turn) && state.status === 'playing' ? ' CHECK.' : ''}\\n` +\n `Legal moves: ${renderLegal(state)}`,\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative chess position, whose turn it is, and every legal move.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your black move. Pass from/to; for promotion optionally choose 'q', 'r', 'b', or 'n'. Include a short moveThought for the board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string","description":"Origin square, e.g. 'e7'."},"to":{"type":"string","description":"Destination square, e.g. 'e5'."},"promotion":{"type":"string","enum":["q","r","b","n"],"description":"Promotion piece. Defaults to queen."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["from","to"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's white move, played from the chess board.","script":"game-store","inputs":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"},"promotion":{"type":"string","enum":["q","r","b","n"]}},"required":["from","to"]},"bind":{"action":"user_move"},"reaction":{"gezel":"chess-player","prompt":"Your opponent played {{output.lastMove}}. Status: {{output.status}}. Board now:\n{{output.board}}\nThe board and Legal moves line come directly from the chess engine and are always correct. Do not re-derive the position, dispute a listed move, or invent a move outside that list. Spend your thought on choosing among the listed legal moves.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user’s available moves, threats, tactics, or your plan; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user’s move. Answer strategy questions when asked.\nAct on this latest position. If the game is over, do not move — give one gracious line and offer a new game. Otherwise choose a strong legal move and play it with make_move, including a short {{personality}} moveThought that follows the selected style. If make_move rejects it, choose one move verbatim from the legal list in the error and retry. After moving, reply with ONE short line of table talk.","hideSeed":true}},{"name":"new_game","description":"Reset the chess board to the standard opening position.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.0.3","1.0.2","1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"design-scheme","name":"Design Scheme","description":"Explore color schemes and design directions with a designer. Palettes are saved as artifacts and shown on a live gallery.","tags":["design","color","palette","branding","prototype"],"category":"creative","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"design-prototype","params":{"type":"object","properties":{"brief":{"type":"string","title":"What are you designing?","description":"A short brief — e.g. \"a calm meditation app\", \"a bold sports brand\".","default":"a new brand"}},"required":["brief"]},"nameTemplate":"Design Scheme: {{brief}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"designer","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"scheme-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'scheme-store',\n description:\n \"Save and list the project's design color schemes. Call action:'save' with a name and an array of hex colors to add a scheme (it lands in artifacts/schemes/ and shows on the project's gallery). Call action:'list' to see what's saved.\",\n inputs: {\n action: { type: 'string', description: \"'save' or 'list'.\", required: true },\n name: { type: 'string', description: \"Scheme name (required for 'save'), e.g. 'Dusk'.\" },\n colors: {\n type: 'json',\n description: \"Array of hex colors for 'save', e.g. [\\\"#1b1a2e\\\", \\\"#e0894a\\\", \\\"#f5f0e8\\\"].\",\n },\n },\n outputs: {\n count: { type: 'number', description: 'How many schemes are saved.' },\n message: { type: 'string', description: 'Human-readable summary.' },\n },\n requires: ['artifacts.read', 'artifacts.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\ninterface Scheme {\n name: string;\n file: string;\n colors: string[];\n}\n\nconst INDEX = 'schemes/index.json';\n\nasync function loadIndex(): Promise<Scheme[]> {\n try {\n const raw = await gezel.artifacts.read(INDEX);\n const parsed = JSON.parse(raw) as { schemes?: Scheme[] };\n return Array.isArray(parsed.schemes) ? parsed.schemes : [];\n } catch {\n return [];\n }\n}\n\nfunction slugify(s: string): string {\n return (\n s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'scheme'\n );\n}\n\nconst schemes = await loadIndex();\n\nif (input.action === 'save') {\n const name = (input.name ?? '').trim();\n const colors = Array.isArray(input.colors) ? input.colors.map(String) : [];\n if (!name || colors.length === 0) {\n gezel.output({ count: schemes.length, message: 'save needs a name and a non-empty colors array.' });\n } else {\n const file = `schemes/${slugify(name)}.json`;\n await gezel.artifacts.write(file, `${JSON.stringify({ name, colors }, null, 2)}\\n`);\n const next = [...schemes.filter((s) => s.file !== file), { name, file, colors }];\n await gezel.artifacts.write(INDEX, `${JSON.stringify({ schemes: next }, null, 2)}\\n`);\n gezel.output({ count: next.length, message: `Saved \"${name}\" (${colors.length} colors). ${next.length} scheme(s) total.` });\n }\n} else {\n gezel.output({\n count: schemes.length,\n message: schemes.length\n ? `${schemes.length} scheme(s): ${schemes.map((s) => s.name).join(', ')}.`\n : 'No schemes saved yet.',\n });\n}\n"},"tools":[{"name":"save_scheme","description":"Save a named color scheme so it shows on the gallery.","script":"scheme-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"colors":{"type":"array","items":{"type":"string"}}}},"bind":{"action":"save"}}],"pages":{"entry":"gallery/index.html","reads":[{"source":"artifacts","path":"schemes/index.json"}]},"schedules":[],"workspaceSeed":[],"artifactsSeed":["schemes/index.json","schemes/warm-dusk.json"],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"email","name":"Email / Inbox","description":"Sync an email account into a project as canonical markdown, then triage, draft, and summarize correspondence with a gezel.","tags":["email","inbox","triage","correspondence","builtin"],"category":"communication","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-07-06T00:00:00Z","extends":"email","aboutTemplate":"about.md","gezels":[],"toolsets":[],"craftbooks":[],"tools":[],"schedules":[],"workspaceSeed":[],"artifactsSeed":[],"notes":"Wraps the native mail pipeline (packages/service/src/mail) as a catalog entry so it appears in the New Project gallery alongside custom project types. Mailbox linking (IMAP / OAuth) remains a native creation step; this manifest carries no toolsets or scripts.","availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"event-planner","name":"Event Planner","description":"Plan the wedding, the milestone birthday, the reunion. A composed Ceremoniemeester (\"master of ceremonies\") keeps the guest list sacred, the budget honest, and the day-of runbook ready — with a live countdown board.","tags":["event","wedding","party","guests","planning"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"occasion":{"type":"string","title":"What are we celebrating?","description":"e.g. 'our wedding', 'Dad's 70th', 'the class reunion'.","default":"a big celebration"},"date":{"type":"string","title":"When is it?","description":"The date, e.g. 2026-09-12. A rough date is fine — change it later.","default":"2026-12-31"}},"required":["occasion","date"]},"nameTemplate":"Event: {{occasion}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"ceremoniemeester","voorman":true}],"toolsets":[],"craftbooks":["runbook"],"scripts":{"event-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'event-store',\n description:\n \"Read and update the event: guests (guests.json) and spending (budget.json). Call action:'status' for headcount + budget + countdown; 'add_guest' as invitations go out; 'guest_status' as RSVPs land (invited/yes/no); 'record_spend' when money is committed (ordinary units); 'guest_list' to read everyone.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'add_guest', 'guest_status', 'record_spend', or 'guest_list'.\",\n required: true,\n },\n name: { type: 'string', description: 'Guest name (party name is fine).' },\n party: { type: 'number', description: 'How many people this entry covers (default 1).' },\n status: { type: 'string', description: \"RSVP status: 'invited', 'yes', or 'no'.\" },\n id: { type: 'string', description: \"Guest id, e.g. 'guest-4'.\" },\n amount: { type: 'number', description: 'Spend amount in ordinary units.' },\n category: { type: 'string', description: \"Spend category, e.g. 'venue', 'catering'.\" },\n note: { type: 'string', description: 'Short note.' },\n },\n outputs: {\n invited: { type: 'number', description: 'People invited (sum of parties).' },\n coming: { type: 'number', description: \"People who said yes.\" },\n spentCents: { type: 'number', description: 'Total committed spend, in cents.' },\n summary: { type: 'string', description: 'The event picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── event-rules-start ──\n// Pure derivations: headcounts weighted by party size, and the countdown.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var abs = Math.abs(cents);\n return (cents < 0 ? '-' : '') + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction partyOf(record) {\n var p = record.fields && record.fields.party;\n return typeof p === 'number' && p >= 1 ? Math.floor(p) : 1;\n}\n\nfunction headcounts(records) {\n var invited = 0;\n var coming = 0;\n var awaiting = 0;\n records.forEach(function (r) {\n var size = partyOf(r);\n invited += size;\n if (r.status === 'yes') coming += size;\n if (r.status === 'invited') awaiting += size;\n });\n return { invited: invited, coming: coming, awaiting: awaiting };\n}\n\nfunction daysUntil(dateStr, nowMs) {\n var when = Date.parse(dateStr);\n if (!isFinite(when)) return null;\n return Math.ceil((when - nowMs) / (24 * 60 * 60 * 1000));\n}\n\nfunction eventLine(records, spentCents, eventDate, nowMs) {\n var heads = headcounts(records);\n var days = daysUntil(eventDate, nowMs);\n return (\n (days === null ? '' : days >= 0 ? days + ' day(s) to go. ' : 'The day has passed. ') +\n heads.coming +\n ' coming, ' +\n heads.awaiting +\n ' awaiting reply (' +\n heads.invited +\n ' invited). Spent ' +\n fmtCents(spentCents) +\n '.'\n );\n}\n// ── event-rules-end ──\n\nconst guests = rosterStore(gezel.fs, 'guests.json', {\n stages: ['invited', 'yes', 'no'],\n idPrefix: 'guest',\n});\nconst budget = ledgerStore(gezel.fs, 'budget.json');\n\nasync function readEventDate(): Promise<string> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('guests.json')) as { eventDate?: string };\n return typeof parsed.eventDate === 'string' ? parsed.eventDate : '';\n } catch {\n return '';\n }\n}\n\nasync function resolveGuest(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await guests.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'guest-4') or an exact guest 'name'.\");\n}\n\nif (input.action === 'add_guest') {\n if (!input.name) throw new Error(\"add_guest needs 'name'.\");\n await guests.add({\n status: 'invited',\n fields: {\n name: input.name,\n ...(typeof input.party === 'number' && input.party >= 1 ? { party: Math.floor(input.party) } : {}),\n ...(input.note ? { note: input.note } : {}),\n },\n });\n} else if (input.action === 'guest_status') {\n if (!input.status || !['invited', 'yes', 'no'].includes(input.status)) {\n throw new Error(\"guest_status needs status 'invited', 'yes', or 'no'.\");\n }\n await guests.transition(await resolveGuest(), input.status, input.note ? { note: input.note } : {});\n} else if (input.action === 'record_spend') {\n const cents = toCents(input.amount);\n if (cents === null || !input.category) {\n throw new Error(\"record_spend needs a positive 'amount' and a 'category' (e.g. 'venue').\");\n }\n await budget.add({\n amountCents: cents,\n kind: 'out',\n category: input.category.toLowerCase(),\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action !== 'status' && input.action !== 'guest_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_guest, guest_status, record_spend, or guest_list.`,\n );\n}\n\nconst records = await guests.list();\nconst totals = await budget.totals();\nconst heads = headcounts(records);\nconst eventDate = await readEventDate();\nconst listing =\n input.action === 'guest_list'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.name ?? '?')}${partyOf(r) > 1 ? ` (party of ${partyOf(r)})` : ''}`,\n )\n .join('\\n') || 'The guest list is empty.'\n : '';\n\ngezel.output({\n invited: heads.invited,\n coming: heads.coming,\n spentCents: totals.outCents,\n summary: listing || eventLine(records, totals.outCents, eventDate, Date.now()),\n});\n"},"tools":[{"name":"add_guest","description":"Add a guest or party to the list (status starts at invited).","script":"event-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"party":{"type":"number"},"note":{"type":"string"}},"required":["name"]},"bind":{"action":"add_guest"}},{"name":"guest_status","description":"Record an RSVP: 'yes', 'no', or back to 'invited'.","script":"event-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","enum":["invited","yes","no"]},"note":{"type":"string"}},"required":["status"]},"bind":{"action":"guest_status"}},{"name":"record_spend","description":"Record committed spending (deposit, balance, purchase) in ordinary units.","script":"event-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"record_spend"}},{"name":"event_status","description":"Countdown, headcount, and budget in one line.","script":"event-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"guest_list","description":"Read the whole guest list with statuses.","script":"event-store","inputs":{"type":"object","properties":{}},"bind":{"action":"guest_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"guests.json"},{"source":"workspace","path":"budget.json"}]},"schedules":[],"workspaceSeed":["guests.json","budget.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"fitness-coach","name":"Fitness Coach","description":"A training habit that survives real life. A steady Coach logs every session, guards the streak, and adjusts the weekly plan to fit your schedule — with a live board showing the week and the streak.","tags":["fitness","training","habits","streak","health"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"goal":{"type":"string","title":"What are you training toward?","description":"e.g. 'feel strong again', 'a 10k in October'.","default":"feeling strong and consistent"},"weeklyTarget":{"type":"number","title":"Sessions per week","description":"How many training sessions a week to aim for.","default":3}},"required":["goal","weeklyTarget"]},"nameTemplate":"Fitness: {{goal}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"coach","voorman":true}],"toolsets":[],"craftbooks":["weekly-review"],"scripts":{"training-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'training-store',\n description:\n \"Read and update the training log (training.json). Call action:'status' for streak + this week vs the target; 'log_workout' the moment a session is reported (activity + minutes); 'set_target' to change the weekly session target; 'history' for the recent sessions.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'log_workout', 'set_target', or 'history'.\",\n required: true,\n },\n activity: { type: 'string', description: \"What they did, e.g. 'run', 'gym', 'long walk'.\" },\n minutes: { type: 'number', description: 'How long the session ran, in minutes.' },\n note: { type: 'string', description: 'Anything worth remembering about the session.' },\n weeklyTarget: { type: 'number', description: 'Sessions per week to aim for (set_target).' },\n },\n outputs: {\n streakDays: { type: 'number', description: 'Consecutive days with a session, ending at the latest.' },\n thisWeek: { type: 'number', description: 'Sessions logged this week (Monday UTC start).' },\n weeklyTarget: { type: 'number', description: 'The current weekly session target.' },\n summary: { type: 'string', description: 'The training picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── fitness-rules-start ──\n// Pure derivations over logStore-shaped workout events: kind 'workout',\n// data {activity, minutes}. Weeks start Monday 00:00 UTC; nowIso is a\n// parameter so the rules stay clock-independent and testable.\n\nfunction weekStartMs(nowIso) {\n var day = new Date(nowIso.slice(0, 10) + 'T00:00:00Z');\n var sinceMonday = (day.getUTCDay() + 6) % 7;\n return day.getTime() - sinceMonday * 86400000;\n}\n\nfunction thisWeekCount(events, nowIso) {\n var start = weekStartMs(nowIso);\n return events.filter(function (e) {\n return Date.parse(e.at) >= start;\n }).length;\n}\n\nfunction trainingLine(stats, weekCount, weeklyTarget) {\n var target = weeklyTarget > 0 ? weeklyTarget : 0;\n var weekPart =\n 'This week: ' +\n weekCount +\n (target > 0 ? ' of ' + target + ' session(s)' : ' session(s)') +\n (target > 0 && weekCount >= target ? ' — target met.' : '.');\n var streakPart =\n stats.total === 0\n ? 'No sessions logged yet.'\n : 'Streak: ' + stats.streakDays + ' day(s); ' + stats.total + ' session(s) all time.';\n return weekPart + ' ' + streakPart;\n}\n// ── fitness-rules-end ──\n\nconst training = logStore(gezel.fs, 'training.json');\n\nasync function readWeeklyTarget(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('training.json')) as { weeklyTarget?: unknown };\n const target = Number(parsed.weeklyTarget);\n return Number.isFinite(target) && target > 0 ? Math.round(target) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'log_workout') {\n if (!input.activity) throw new Error(\"log_workout needs 'activity' (what they did).\");\n await training.append({\n kind: 'workout',\n ...(input.note ? { note: input.note } : {}),\n data: {\n activity: input.activity,\n ...(typeof input.minutes === 'number' && input.minutes > 0\n ? { minutes: Math.round(input.minutes) }\n : {}),\n },\n });\n} else if (input.action === 'set_target') {\n const target = Number(input.weeklyTarget);\n if (!Number.isFinite(target) || target <= 0) {\n throw new Error(\"set_target needs a positive 'weeklyTarget' (sessions per week).\");\n }\n // logStore has no field API; the passenger key flips via a targeted\n // read-modify-write that preserves the rest of the file.\n const raw = JSON.parse(await gezel.fs.read('training.json')) as Record<string, unknown>;\n raw.weeklyTarget = Math.round(target);\n await gezel.fs.write('training.json', `${JSON.stringify(raw, null, 2)}\\n`);\n} else if (input.action !== 'status' && input.action !== 'history') {\n throw new Error(`Unknown action '${input.action}'. Use status, log_workout, set_target, or history.`);\n}\n\nconst events = await training.list();\nconst stats = await training.stats();\nconst weeklyTarget = await readWeeklyTarget();\nconst nowIso = new Date().toISOString();\nconst weekCount = thisWeekCount(events, nowIso);\nconst listing =\n input.action === 'history'\n ? events\n .slice(-12)\n .map(\n (e) =>\n `${e.at.slice(0, 10)} ${String(e.data?.activity ?? 'session')}${typeof e.data?.minutes === 'number' ? ` (${e.data.minutes} min)` : ''}${e.note ? ` — ${e.note}` : ''}`,\n )\n .join('\\n') || 'No sessions yet.'\n : '';\n\ngezel.output({\n streakDays: stats.streakDays,\n thisWeek: weekCount,\n weeklyTarget,\n summary: listing || trainingLine(stats, weekCount, weeklyTarget),\n});\n"},"tools":[{"name":"log_workout","description":"Log a training session the moment it is reported (activity + minutes).","script":"training-store","inputs":{"type":"object","properties":{"activity":{"type":"string"},"minutes":{"type":"number"},"note":{"type":"string"}},"required":["activity"]},"bind":{"action":"log_workout"}},{"name":"set_target","description":"Change the weekly session target.","script":"training-store","inputs":{"type":"object","properties":{"weeklyTarget":{"type":"number"}},"required":["weeklyTarget"]},"bind":{"action":"set_target"}},{"name":"training_status","description":"Streak, this week against the target, all-time count.","script":"training-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"workout_history","description":"The recent sessions, most recent last.","script":"training-store","inputs":{"type":"object","properties":{}},"bind":{"action":"history"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"training.json"}]},"schedules":[],"workspaceSeed":["training.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"flashcards","name":"Flashcards","description":"Learn anything with spaced-repetition flashcards. Your Studiemaat (\"study mate\") writes the cards in chat; you review them on a live card-flip page that schedules what comes back and when.","tags":["flashcards","study","learning","spaced-repetition"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"subject":{"type":"string","title":"What are you learning?","description":"The deck's subject — e.g. Spanish vocabulary, AWS certification, organic chemistry.","default":"Spanish vocabulary"}},"required":["subject"]},"nameTemplate":"{{subject}} Flashcards","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"study-buddy","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"deck-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'deck-store',\n description:\n \"Read and update the flashcard deck (deck.json). Call action:'add_cards' with a cards array ([{front, back}, ...]) to add practice cards; action:'state' to see due counts and box distribution; action:'list_deck' to read every card. The review page drives 'record_review' and 'finish_session' itself.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'state', 'list_deck', 'add_cards', 'record_review', or 'finish_session'.\",\n required: true,\n },\n cards: {\n type: 'json',\n description: 'For add_cards: an array of {front, back} objects.',\n },\n cardId: { type: 'string', description: \"For record_review: the card id, e.g. 'card-3'.\" },\n verdict: { type: 'string', description: \"For record_review: 'got-it' or 'missed'.\" },\n reviewed: { type: 'number', description: 'For finish_session: how many cards were reviewed.' },\n missedIds: { type: 'json', description: 'For finish_session: array of card ids that were missed.' },\n },\n outputs: {\n total: { type: 'number', description: 'Cards in the deck.' },\n due: { type: 'number', description: 'Cards due for review now.' },\n summary: { type: 'string', description: 'Human-readable deck or session summary.' },\n missedList: { type: 'string', description: 'Fronts of the cards missed this session (finish_session).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── leitner-rules-start ──\n// Pure Leitner-box scheduling (3 boxes). Box 1 reviews daily, box 2 every\n// 3 days, box 3 weekly. A got-it promotes (capped at 3); a miss goes back\n// to box 1. Never-reviewed cards are always due. Kept self-contained\n// between these sentinels so unit tests execute the shipped bytes.\n\nvar BOX_INTERVAL_DAYS = { 1: 1, 2: 3, 3: 7 };\nvar DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction isDue(card, nowMs) {\n if (!card.lastReviewedAt) return true;\n var interval = BOX_INTERVAL_DAYS[card.box] || 1;\n return nowMs - Date.parse(card.lastReviewedAt) >= interval * DAY_MS;\n}\n\nfunction computeDueIds(cards, nowMs) {\n return cards\n .filter(function (c) {\n return isDue(c, nowMs);\n })\n .sort(function (a, b) {\n if (a.box !== b.box) return a.box - b.box;\n return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0;\n })\n .map(function (c) {\n return c.id;\n });\n}\n\nfunction applyReview(card, verdict, nowIso) {\n if (verdict === 'got-it') card.box = Math.min(3, card.box + 1);\n else card.box = 1;\n card.lastReviewedAt = nowIso;\n card.stats = card.stats || { gotIt: 0, missed: 0 };\n if (verdict === 'got-it') card.stats.gotIt += 1;\n else card.stats.missed += 1;\n return card;\n}\n\nfunction nextCardId(cards) {\n var max = 0;\n for (var i = 0; i < cards.length; i += 1) {\n var m = /^card-(\\d+)$/.exec(cards[i].id);\n if (m) max = Math.max(max, Number(m[1]));\n }\n return 'card-' + (max + 1);\n}\n// ── leitner-rules-end ──\n\nconst DECK_FILE = 'deck.json';\n\ninterface Card {\n id: string;\n front: string;\n back: string;\n box: number;\n lastReviewedAt: string | null;\n createdAt: string;\n stats: { gotIt: number; missed: number };\n}\n\ninterface Deck {\n version: number;\n subject: string;\n cards: Card[];\n dueCardIds: string[];\n updatedAt: string;\n}\n\nasync function load(): Promise<Deck> {\n let raw: string;\n try {\n raw = await gezel.fs.read(DECK_FILE);\n } catch {\n return { version: 1, subject: 'your subject', cards: [], dueCardIds: [], updatedAt: new Date().toISOString() };\n }\n const parsed = JSON.parse(raw) as Partial<Deck>;\n return {\n version: 1,\n subject: typeof parsed.subject === 'string' ? parsed.subject : 'your subject',\n cards: Array.isArray(parsed.cards) ? (parsed.cards as Card[]) : [],\n dueCardIds: [],\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function persist(deck: Deck): Promise<Deck> {\n deck.dueCardIds = computeDueIds(deck.cards, Date.now());\n deck.updatedAt = new Date().toISOString();\n await gezel.fs.write(DECK_FILE, `${JSON.stringify(deck, null, 2)}\\n`);\n return deck;\n}\n\nconst deck = await load();\ndeck.dueCardIds = computeDueIds(deck.cards, Date.now());\nlet summary = '';\nlet missedList = '';\n\nif (input.action === 'add_cards') {\n const raw = Array.isArray(input.cards) ? input.cards : [];\n const added: string[] = [];\n for (const entry of raw) {\n const front = typeof (entry as { front?: unknown }).front === 'string' ? (entry as { front: string }).front.trim() : '';\n const back = typeof (entry as { back?: unknown }).back === 'string' ? (entry as { back: string }).back.trim() : '';\n if (!front || !back) continue;\n const card: Card = {\n id: nextCardId(deck.cards),\n front: front.slice(0, 300),\n back: back.slice(0, 500),\n box: 1,\n lastReviewedAt: null,\n createdAt: new Date().toISOString(),\n stats: { gotIt: 0, missed: 0 },\n };\n deck.cards.push(card);\n added.push(card.id);\n }\n if (added.length === 0) {\n throw new Error(\"add_cards needs a non-empty 'cards' array of {front, back} objects.\");\n }\n await persist(deck);\n summary = `Added ${added.length} card(s). ${deck.cards.length} in the deck, ${deck.dueCardIds.length} due now.`;\n} else if (input.action === 'record_review') {\n if (!input.cardId || (input.verdict !== 'got-it' && input.verdict !== 'missed')) {\n throw new Error(\"record_review needs 'cardId' and verdict 'got-it' or 'missed'.\");\n }\n const card = deck.cards.find((c) => c.id === input.cardId);\n if (!card) throw new Error(`No card '${input.cardId}' in the deck.`);\n applyReview(card, input.verdict, new Date().toISOString());\n await persist(deck);\n summary = `${card.id} marked ${input.verdict} (now box ${card.box}). ${deck.dueCardIds.length} still due.`;\n} else if (input.action === 'finish_session') {\n const reviewed = typeof input.reviewed === 'number' ? input.reviewed : 0;\n const missedIds = Array.isArray(input.missedIds) ? input.missedIds.map(String) : [];\n const missedFronts = missedIds\n .map((id) => deck.cards.find((c) => c.id === id)?.front)\n .filter((f): f is string => typeof f === 'string');\n missedList = missedFronts.length ? missedFronts.join(' | ') : '(none)';\n summary = `Reviewed ${reviewed} card(s): ${reviewed - missedIds.length} got it, ${missedIds.length} missed. ${deck.dueCardIds.length} due next.`;\n} else if (input.action === 'list_deck') {\n summary = deck.cards.length\n ? deck.cards\n .map((c) => `${c.id} [box ${c.box}] ${c.front} -> ${c.back}`)\n .join('\\n')\n : 'The deck is empty — add cards with add_cards.';\n} else if (input.action === 'state') {\n const boxes = { 1: 0, 2: 0, 3: 0 } as Record<number, number>;\n for (const c of deck.cards) boxes[c.box] = (boxes[c.box] ?? 0) + 1;\n summary = deck.cards.length\n ? `${deck.cards.length} card(s) on \"${deck.subject}\": ${deck.dueCardIds.length} due now. Boxes: ${boxes[1]} daily, ${boxes[2]} every-3-days, ${boxes[3]} weekly.`\n : `The deck for \"${deck.subject}\" is empty — offer to write the first cards.`;\n} else {\n throw new Error(\n `Unknown action '${input.action}'. Use state, list_deck, add_cards, record_review, or finish_session.`,\n );\n}\n\ngezel.output({\n total: deck.cards.length,\n due: deck.dueCardIds.length,\n summary,\n missedList,\n});\n"},"tools":[{"name":"add_cards","description":"Add flashcards to the deck. Pass a cards array of {front, back} objects — one fact per card.","script":"deck-store","inputs":{"type":"object","properties":{"cards":{"type":"array","items":{"type":"object","properties":{"front":{"type":"string"},"back":{"type":"string"}},"required":["front","back"]},"description":"The cards to add."}},"required":["cards"]},"bind":{"action":"add_cards"}},{"name":"deck_status","description":"See the deck: how many cards, how many due, and the box distribution.","script":"deck-store","inputs":{"type":"object","properties":{}},"bind":{"action":"state"}},{"name":"list_deck","description":"Read every card in the deck (id, box, front, back).","script":"deck-store","inputs":{"type":"object","properties":{}},"bind":{"action":"list_deck"}},{"name":"record_review","description":"The review page's per-card verdict.","script":"deck-store","inputs":{"type":"object","properties":{"cardId":{"type":"string"},"verdict":{"type":"string","enum":["got-it","missed"]}},"required":["cardId","verdict"]},"bind":{"action":"record_review"}},{"name":"finish_session","description":"The review page's end-of-session summary.","script":"deck-store","inputs":{"type":"object","properties":{"reviewed":{"type":"number"},"missedIds":{"type":"array","items":{"type":"string"}}},"required":["reviewed"]},"bind":{"action":"finish_session"},"reaction":{"gezel":"study-buddy","prompt":"The user finished a flashcard review session. {{output.summary}}\nMissed cards: {{output.missedList}}\nReply with ONE short encouraging line — specific beats generic. If any cards were missed, offer to add fresh practice cards that come at those same ideas from a new angle (add them with your deck capabilities if the user agrees)."}}],"pages":{"entry":"review/index.html","reads":[{"source":"workspace","path":"deck.json"}],"tools":["record_review","finish_session"]},"schedules":[],"workspaceSeed":["deck.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"freelance-office","name":"Freelance Office","description":"Run a one-person business without dropping anything. Your Kantoormeester (\"office master\") tracks every invoice to paid, drafts the polite chase, and keeps the income picture one question away.","tags":["freelance","invoices","clients","business","income"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"business":{"type":"string","title":"What do you do?","description":"One line about the business — e.g. 'freelance brand design'.","default":"freelance work"}},"required":["business"]},"nameTemplate":"Freelance Office","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"kantoormeester","voorman":true}],"toolsets":[],"craftbooks":["proposal-sow","weekly-review"],"scripts":{"office-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'office-store',\n description:\n \"Read and update the invoice book (invoices.json). Call action:'status' for outstanding/overdue/paid-this-month; 'record_invoice' when work is billed (amount in ordinary units, e.g. 1200.00); 'invoice_sent' / 'invoice_paid' as reality moves; 'list_invoices' to read the book.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_invoice', 'invoice_sent', 'invoice_paid', or 'list_invoices'.\",\n required: true,\n },\n client: { type: 'string', description: 'Client name.' },\n amount: { type: 'number', description: 'Invoice amount in ordinary units, e.g. 1200.00.' },\n due: { type: 'string', description: 'Due date (ISO), e.g. 2026-08-15.' },\n note: { type: 'string', description: 'Short note (what the invoice covers).' },\n id: { type: 'string', description: \"Invoice id, e.g. 'inv-3'.\" },\n },\n outputs: {\n invoices: { type: 'number', description: 'Invoices in the book.' },\n outstandingCents: { type: 'number', description: 'Total sent-but-unpaid, in cents.' },\n overdue: { type: 'number', description: 'Count of overdue invoices.' },\n summary: { type: 'string', description: 'Human-readable office summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── office-rules-start ──\n// Pure derivations over rosterStore-shaped invoice records: statuses are\n// draft -> sent -> paid; fields carry {client, amountCents, dueAt, note}.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var sign = cents < 0 ? '-' : '';\n var abs = Math.abs(cents);\n return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction officeShape(records, nowMs) {\n var outstanding = 0;\n var overdue = [];\n var paidMonth = 0;\n var month = new Date(nowMs).toISOString().slice(0, 7);\n records.forEach(function (r) {\n var cents = typeof r.fields.amountCents === 'number' ? r.fields.amountCents : 0;\n if (r.status === 'sent') {\n outstanding += cents;\n var due = typeof r.fields.dueAt === 'string' ? Date.parse(r.fields.dueAt) : NaN;\n if (isFinite(due) && due < nowMs) overdue.push(r);\n }\n if (r.status === 'paid' && r.updatedAt.slice(0, 7) === month) paidMonth += cents;\n });\n return { outstanding: outstanding, overdue: overdue, paidMonth: paidMonth, month: month };\n}\n\nfunction officeLine(records, nowMs) {\n var shape = officeShape(records, nowMs);\n var chase = shape.overdue\n .map(function (r) {\n return r.fields.client + ' (' + r.id + ', ' + fmtCents(r.fields.amountCents || 0) + ')';\n })\n .join(', ');\n return (\n 'Outstanding ' +\n fmtCents(shape.outstanding) +\n ' across ' +\n records.filter(function (r) {\n return r.status === 'sent';\n }).length +\n ' sent invoice(s); ' +\n shape.overdue.length +\n ' overdue' +\n (chase ? ' (' + chase + ')' : '') +\n '. Paid this month: ' +\n fmtCents(shape.paidMonth) +\n '.'\n );\n}\n// ── office-rules-end ──\n\nconst invoices = rosterStore(gezel.fs, 'invoices.json', {\n stages: ['draft', 'sent', 'paid'],\n idPrefix: 'inv',\n});\n\nif (input.action === 'record_invoice') {\n const cents = toCents(input.amount);\n if (!input.client || cents === null) {\n throw new Error(\"record_invoice needs 'client' and a positive 'amount' (ordinary units).\");\n }\n await invoices.add({\n fields: {\n client: input.client,\n amountCents: cents,\n ...(input.due ? { dueAt: input.due } : {}),\n ...(input.note ? { note: input.note } : {}),\n },\n });\n} else if (input.action === 'invoice_sent' || input.action === 'invoice_paid') {\n if (!input.id) throw new Error(\"Pass the invoice 'id', e.g. 'inv-3'.\");\n await invoices.transition(input.id, input.action === 'invoice_sent' ? 'sent' : 'paid');\n} else if (input.action !== 'status' && input.action !== 'list_invoices') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_invoice, invoice_sent, invoice_paid, or list_invoices.`,\n );\n}\n\nconst records = await invoices.list();\nconst shape = officeShape(records, Date.now());\nconst listing =\n input.action === 'list_invoices'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.client ?? '?')} ${fmtCents(Number(r.fields.amountCents ?? 0))}${r.fields.dueAt ? ` due ${String(r.fields.dueAt).slice(0, 10)}` : ''}`,\n )\n .join('\\n') || 'The invoice book is empty.'\n : '';\n\ngezel.output({\n invoices: records.length,\n outstandingCents: shape.outstanding,\n overdue: shape.overdue.length,\n summary: listing || officeLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_invoice","description":"Add an invoice to the book (amount in ordinary units).","script":"office-store","inputs":{"type":"object","properties":{"client":{"type":"string"},"amount":{"type":"number"},"due":{"type":"string"},"note":{"type":"string"}},"required":["client","amount"]},"bind":{"action":"record_invoice"}},{"name":"invoice_sent","description":"Mark an invoice sent to the client.","script":"office-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"invoice_sent"}},{"name":"invoice_paid","description":"Mark an invoice paid.","script":"office-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"invoice_paid"}},{"name":"office_status","description":"The money picture: outstanding, overdue, paid this month.","script":"office-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"list_invoices","description":"Read the whole invoice book.","script":"office-store","inputs":{"type":"object","properties":{}},"bind":{"action":"list_invoices"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"invoices.json"}]},"schedules":[],"workspaceSeed":["invoices.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"fundraiser-hq","name":"Fundraiser HQ","description":"Rally a community drive. An infectious Aanjager (\"rallier\") keeps the thermometer honest, drafts warm sponsor outreach and progress posts, and makes sure every single donor gets thanked.","tags":["fundraiser","donations","community","charity","drive"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"cause":{"type":"string","title":"What are you raising for?","description":"e.g. 'new jerseys for the team', 'the library roof'.","default":"a good cause"},"goalCents":{"type":"number","title":"Goal (in cents)","description":"The target, in cents — e.g. 500000 for 5,000.00.","default":100000}},"required":["cause","goalCents"]},"nameTemplate":"Fundraiser: {{cause}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"aanjager","voorman":true}],"toolsets":[],"craftbooks":["social-thread","drive-wrap-up"],"scripts":{"drive-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'drive-store',\n description:\n \"Read and update the fundraising drive (donations.json). Call action:'status' for the thermometer + unthanked list; 'record_donation' the moment a gift lands (ordinary units, donor name); 'mark_thanked' once the thank-you goes out.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record_donation', 'mark_thanked', or 'donor_list'.\",\n required: true,\n },\n donor: { type: 'string', description: \"Donor name ('anonymous' is fine).\" },\n amount: { type: 'number', description: 'Gift amount in ordinary units.' },\n note: { type: 'string', description: 'Anything worth remembering about the gift.' },\n id: { type: 'string', description: \"Donation id, e.g. 'ledger-4'.\" },\n },\n outputs: {\n raisedCents: { type: 'number', description: 'Total raised, in cents.' },\n donors: { type: 'number', description: 'Number of gifts.' },\n unthanked: { type: 'number', description: 'Gifts still awaiting a thank-you.' },\n summary: { type: 'string', description: 'The drive picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── drive-rules-start ──\n// Pure derivations over ledgerStore-shaped donation entries: entries are\n// kind 'in', category 'donation', data {donor, thanked}.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var abs = Math.abs(cents);\n return (cents < 0 ? '-' : '') + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction driveShape(entries) {\n var raised = 0;\n var unthanked = [];\n entries.forEach(function (e) {\n raised += e.amountCents;\n if (!e.data || e.data.thanked !== true) unthanked.push(e);\n });\n return { raised: raised, unthanked: unthanked };\n}\n\nfunction driveLine(entries, goalCents) {\n var shape = driveShape(entries);\n var pct = goalCents > 0 ? Math.min(100, Math.round((shape.raised / goalCents) * 100)) : null;\n return (\n 'Raised ' +\n fmtCents(shape.raised) +\n (goalCents > 0 ? ' of ' + fmtCents(goalCents) + ' (' + pct + ' percent)' : '') +\n ' across ' +\n entries.length +\n ' gift(s). ' +\n (shape.unthanked.length === 0\n ? 'Everyone thanked.'\n : shape.unthanked.length + ' thank-you(s) still owed.')\n );\n}\n// ── drive-rules-end ──\n\nconst donations = ledgerStore(gezel.fs, 'donations.json');\n\nasync function readGoalCents(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('donations.json')) as { goalCents?: unknown };\n const goal = Number(parsed.goalCents);\n return Number.isFinite(goal) && goal > 0 ? Math.round(goal) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'record_donation') {\n const cents = toCents(input.amount);\n if (cents === null) throw new Error(\"record_donation needs a positive 'amount' (ordinary units).\");\n await donations.add({\n amountCents: cents,\n kind: 'in',\n category: 'donation',\n ...(input.note ? { note: input.note } : {}),\n data: { donor: input.donor || 'anonymous', thanked: false },\n });\n} else if (input.action === 'mark_thanked') {\n if (!input.id && !input.donor) throw new Error(\"Pass 'id' (e.g. 'ledger-4') or a unique 'donor'.\");\n // ledgerStore is append-only by design; thanked flips via a targeted\n // read-modify-write on the entry's data (the one mutation this drive needs).\n const raw = JSON.parse(await gezel.fs.read('donations.json')) as {\n entries: Array<{ id: string; data?: { donor?: string; thanked?: boolean } }>;\n };\n const matches = raw.entries.filter((e) =>\n input.id\n ? e.id === input.id\n : String(e.data?.donor ?? '').toLowerCase() === String(input.donor).toLowerCase() &&\n e.data?.thanked !== true,\n );\n if (matches.length === 0) throw new Error('No matching unthanked donation found.');\n if (matches.length > 1 && !input.id) {\n throw new Error(\n `Multiple unthanked gifts from '${input.donor}': ${matches.map((m) => m.id).join(', ')}. Pass id.`,\n );\n }\n matches[0]!.data = { ...(matches[0]!.data ?? {}), thanked: true };\n await gezel.fs.write('donations.json', `${JSON.stringify(raw, null, 2)}\\n`);\n} else if (input.action !== 'status' && input.action !== 'donor_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_donation, mark_thanked, or donor_list.`,\n );\n}\n\nconst entries = await donations.list();\nconst goalCents = await readGoalCents();\nconst shape = driveShape(entries);\nconst listing =\n input.action === 'donor_list'\n ? entries\n .map(\n (e) =>\n `${e.id} ${fmtCents(e.amountCents)} from ${String(e.data?.donor ?? 'anonymous')}${e.data?.thanked === true ? '' : ' (thank-you owed)'}`,\n )\n .join('\\n') || 'No gifts yet.'\n : '';\n\ngezel.output({\n raisedCents: shape.raised,\n donors: entries.length,\n unthanked: shape.unthanked.length,\n summary: listing || driveLine(entries, goalCents),\n});\n"},"tools":[{"name":"record_donation","description":"Record a gift the moment it lands (ordinary units, donor name).","script":"drive-store","inputs":{"type":"object","properties":{"donor":{"type":"string"},"amount":{"type":"number"},"note":{"type":"string"}},"required":["amount"]},"bind":{"action":"record_donation"}},{"name":"mark_thanked","description":"Record that a thank-you went out for a gift.","script":"drive-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"donor":{"type":"string"}}},"bind":{"action":"mark_thanked"}},{"name":"drive_status","description":"The thermometer and the thank-you debt.","script":"drive-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"donor_list","description":"Every gift, with thank-you state.","script":"drive-store","inputs":{"type":"object","properties":{}},"bind":{"action":"donor_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"donations.json"}]},"schedules":[],"workspaceSeed":["donations.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"go","name":"Go","description":"Play Go against a gezel opponent on a live 9×9, 13×13, or 19×19 board. Place black stones; the Go-speler reads the exact position, replies in white, and keeps the conversation calm and sporting.","tags":["go","baduk","weiqi","game","board-game","strategy","play"],"category":"game","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"mode":"solo","leadLabel":"Opponent","leanProfile":true,"params":{"type":"object","properties":{"personality":{"type":"string","title":"Table-talk style","description":"How your opponent talks between moves.","enum":["peppy","cheeky","zen","gracious"],"default":"peppy"},"playStyle":{"type":"string","title":"How should the Go-speler play?","description":"An opponent keeps their strategy to themselves; an instructor offers light teaching notes.","enum":["Opponent","Instructor"],"default":"Opponent"}},"required":["personality","playStyle"]},"nameTemplate":"Go vs the Go-speler","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"go-player","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"game-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'game-store',\n description:\n \"Read and update the Go game (game.json). Call action:'board' to see the position and every legal move; action:'ai_move' to play a coordinate such as D4 or 'pass' (optional moveThought); action:'state' for a light summary; action:'new_game' to reset with boardSize 9, 13, or 19 and optional komi. The engine enforces captures, suicide, simple ko, passing, and area scoring.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'state', 'board', 'user_move', 'ai_move', or 'new_game'.\",\n required: true,\n },\n move: {\n type: 'string',\n description: \"A board coordinate such as 'D4', or 'pass'.\",\n },\n boardSize: {\n type: 'number',\n description: 'For new_game: 9, 13, or 19. Defaults to 9.',\n },\n komi: {\n type: 'number',\n description: 'For new_game: points added to white. Defaults to 6.5.',\n },\n moveThought: {\n type: 'string',\n description: 'A short line of table talk to show on the board (max 140 chars).',\n },\n },\n outputs: {\n board: { type: 'string', description: 'ASCII board with coordinates.' },\n turn: { type: 'string', description: \"Whose move: 'user' (black) or 'ai' (white).\" },\n status: {\n type: 'string',\n description: 'playing, user_won, ai_won, or draw.',\n },\n lastMove: { type: 'string', description: \"The move just played, e.g. 'D4' or 'pass'.\" },\n legalMoves: { type: 'string', description: 'Legal placements plus pass.' },\n score: { type: 'string', description: 'Area score when the game has ended.' },\n summary: { type: 'string', description: 'Human-readable game summary.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── go-rules-start ──\n// Pure, annotation-free Go engine. Supports 9x9, 13x13, and 19x19.\n// Squares are row-major, y0 at the top. X = black/user; O = white/AI.\n// Coordinates follow Go convention and skip the letter I.\n\nvar GO_FILES = 'ABCDEFGHJKLMNOPQRST';\n\nfunction buildInitial(size) {\n return '.'.repeat(size * size);\n}\n\nfunction inBounds(x, y, size) {\n return x >= 0 && x < size && y >= 0 && y < size;\n}\n\nfunction pointName(x, y, size) {\n return GO_FILES[x] + String(size - y);\n}\n\nfunction parsePoint(name, size) {\n if (typeof name !== 'string') return null;\n var text = name.trim().toUpperCase();\n var match = /^([A-HJ-T])(\\d{1,2})$/.exec(text);\n if (!match) return null;\n var x = GO_FILES.indexOf(match[1]);\n var rank = Number(match[2]);\n if (x < 0 || x >= size || rank < 1 || rank > size) return null;\n return { x: x, y: size - rank, index: (size - rank) * size + x };\n}\n\nfunction neighbors(index, size) {\n var x = index % size;\n var y = Math.floor(index / size);\n var out = [];\n if (x > 0) out.push(index - 1);\n if (x + 1 < size) out.push(index + 1);\n if (y > 0) out.push(index - size);\n if (y + 1 < size) out.push(index + size);\n return out;\n}\n\nfunction groupAt(squares, index, size) {\n var color = squares[index];\n var stones = [];\n var liberties = {};\n var seen = {};\n var queue = [index];\n seen[index] = true;\n while (queue.length) {\n var current = queue.pop();\n stones.push(current);\n for (var next of neighbors(current, size)) {\n if (squares[next] === '.') liberties[next] = true;\n else if (squares[next] === color && !seen[next]) {\n seen[next] = true;\n queue.push(next);\n }\n }\n }\n return { stones: stones, liberties: Object.keys(liberties).map(Number) };\n}\n\nfunction replaceAt(squares, index, ch) {\n return squares.slice(0, index) + ch + squares.slice(index + 1);\n}\n\nfunction applyPlacement(squares, size, side, name, previousPosition) {\n var point = parsePoint(name, size);\n if (!point) return { error: 'bad-coordinate' };\n if (squares[point.index] !== '.') return { error: 'occupied' };\n var stone = side === 'user' ? 'X' : 'O';\n var enemy = side === 'user' ? 'O' : 'X';\n var next = replaceAt(squares, point.index, stone);\n var captures = [];\n var checked = {};\n for (var adjacent of neighbors(point.index, size)) {\n if (next[adjacent] !== enemy || checked[adjacent]) continue;\n var enemyGroup = groupAt(next, adjacent, size);\n for (var member of enemyGroup.stones) checked[member] = true;\n if (enemyGroup.liberties.length === 0) {\n for (var captured of enemyGroup.stones) {\n next = replaceAt(next, captured, '.');\n captures.push(pointName(captured % size, Math.floor(captured / size), size));\n }\n }\n }\n if (groupAt(next, point.index, size).liberties.length === 0) return { error: 'suicide' };\n if (previousPosition && next === previousPosition) return { error: 'ko' };\n return { squares: next, captures: captures };\n}\n\nfunction legalMovesFor(squares, size, side, previousPosition) {\n var moves = [];\n for (var y = 0; y < size; y += 1) {\n for (var x = 0; x < size; x += 1) {\n if (squares[y * size + x] !== '.') continue;\n var name = pointName(x, y, size);\n var result = applyPlacement(squares, size, side, name, previousPosition);\n if (!result.error) moves.push(name);\n }\n }\n moves.push('pass');\n return moves;\n}\n\nfunction scoreArea(squares, size, komi) {\n var blackStones = 0;\n var whiteStones = 0;\n var blackTerritory = 0;\n var whiteTerritory = 0;\n var seen = {};\n for (var i = 0; i < squares.length; i += 1) {\n if (squares[i] === 'X') {\n blackStones += 1;\n continue;\n }\n if (squares[i] === 'O') {\n whiteStones += 1;\n continue;\n }\n if (seen[i]) continue;\n var region = [];\n var borders = {};\n var queue = [i];\n seen[i] = true;\n while (queue.length) {\n var current = queue.pop();\n region.push(current);\n for (var next of neighbors(current, size)) {\n if (squares[next] === '.' && !seen[next]) {\n seen[next] = true;\n queue.push(next);\n } else if (squares[next] === 'X' || squares[next] === 'O') {\n borders[squares[next]] = true;\n }\n }\n }\n if (borders.X && !borders.O) blackTerritory += region.length;\n if (borders.O && !borders.X) whiteTerritory += region.length;\n }\n var black = blackStones + blackTerritory;\n var white = whiteStones + whiteTerritory + komi;\n return {\n black: black,\n white: white,\n blackStones: blackStones,\n whiteStones: whiteStones,\n blackTerritory: blackTerritory,\n whiteTerritory: whiteTerritory,\n winner: black === white ? 'draw' : black > white ? 'user' : 'ai',\n margin: Math.abs(black - white),\n };\n}\n\nfunction statusAfter(squares, size, komi, consecutivePasses) {\n if (consecutivePasses < 2) return 'playing';\n var score = scoreArea(squares, size, komi);\n return score.winner === 'draw' ? 'draw' : score.winner === 'user' ? 'user_won' : 'ai_won';\n}\n\nfunction renderAscii(squares, size) {\n var labels = [];\n for (var x = 0; x < size; x += 1) labels.push(GO_FILES[x]);\n var pad = size >= 10 ? ' ' : ' ';\n var lines = [pad + labels.join(' ')];\n for (var y = 0; y < size; y += 1) {\n var rank = String(size - y).padStart(size >= 10 ? 2 : 1, ' ');\n var row = [];\n for (var x = 0; x < size; x += 1) row.push(squares[y * size + x]);\n lines.push(rank + ' ' + row.join(' ') + ' ' + rank);\n }\n lines.push(pad + labels.join(' '));\n lines.push('X = black (user). O = white (you).');\n return lines.join('\\n');\n}\n// ── go-rules-end ──\n\nconst STATE_FILE = 'game.json';\nconst SIDE_NAMES = { user: 'black', ai: 'white' };\n\ninterface GameState {\n version: number;\n boardSize: number;\n squares: string;\n turn: 'user' | 'ai';\n status: 'playing' | 'user_won' | 'ai_won' | 'draw';\n komi: number;\n consecutivePasses: number;\n previousPosition: string | null;\n legalMoves: string[];\n captures: { user: number; ai: number };\n score: ReturnType<typeof scoreArea> | null;\n moveLog: Array<{\n by: 'user' | 'ai';\n move: string;\n captures: string[];\n at: string;\n moveThought?: string;\n }>;\n lastMove: { by: 'user' | 'ai'; move: string; captures: string[] } | null;\n lastMoveThought: string;\n updatedAt: string;\n}\n\nfunction validSize(value: unknown): number {\n const size = Number(value);\n return size === 13 || size === 19 ? size : 9;\n}\n\nfunction validKomi(value: unknown): number {\n const komi = Number(value);\n return Number.isFinite(komi) && komi >= 0 && komi <= 20 ? komi : 6.5;\n}\n\nfunction freshState(boardSize = 9, komi = 6.5): GameState {\n const size = validSize(boardSize);\n const nextKomi = validKomi(komi);\n const squares = buildInitial(size);\n return {\n version: 1,\n boardSize: size,\n squares,\n turn: 'user',\n status: 'playing',\n komi: nextKomi,\n consecutivePasses: 0,\n previousPosition: null,\n legalMoves: legalMovesFor(squares, size, 'user', null),\n captures: { user: 0, ai: 0 },\n score: null,\n moveLog: [],\n lastMove: null,\n lastMoveThought: '',\n updatedAt: new Date().toISOString(),\n };\n}\n\nasync function load(): Promise<GameState> {\n let raw: string;\n try {\n raw = await gezel.fs.read(STATE_FILE);\n } catch {\n return freshState();\n }\n const parsed = JSON.parse(raw) as Partial<GameState>;\n const size = validSize(parsed.boardSize);\n if (typeof parsed.squares !== 'string' || parsed.squares.length !== size * size) {\n throw new Error(`${STATE_FILE} is corrupt; call action:'new_game' to reset.`);\n }\n return {\n ...freshState(size, parsed.komi),\n ...parsed,\n boardSize: size,\n komi: validKomi(parsed.komi),\n } as GameState;\n}\n\nasync function persist(state: GameState): Promise<GameState> {\n state.legalMoves =\n state.status === 'playing'\n ? legalMovesFor(\n state.squares,\n state.boardSize,\n state.turn,\n state.previousPosition,\n )\n : [];\n state.updatedAt = new Date().toISOString();\n await gezel.fs.write(STATE_FILE, `${JSON.stringify(state, null, 2)}\\n`);\n return state;\n}\n\nfunction renderLegal(state: GameState): string {\n if (state.status !== 'playing') return '(game over)';\n return state.legalMoves.join(', ');\n}\n\nfunction renderScore(state: GameState): string {\n if (!state.score) return '(not scored — play continues)';\n const result =\n state.score.winner === 'draw'\n ? 'draw'\n : `${SIDE_NAMES[state.score.winner]} wins by ${state.score.margin}`;\n return `Black ${state.score.black}; white ${state.score.white} (includes ${state.komi} komi): ${result}.`;\n}\n\nfunction summarize(state: GameState): string {\n if (state.status !== 'playing') return `Game over. ${renderScore(state)}`;\n return `Move ${state.moveLog.length + 1}: ${SIDE_NAMES[state.turn]} (${state.turn}) to move on ${state.boardSize}x${state.boardSize}. Captures — black ${state.captures.user}, white ${state.captures.ai}.`;\n}\n\nfunction applyTurn(\n state: GameState,\n by: 'user' | 'ai',\n moveThought?: string,\n): GameState {\n if (state.status !== 'playing') {\n throw new Error(`The game is over (${state.status}). Call action:'new_game' to start another.`);\n }\n if (state.turn !== by) {\n throw new Error(by === 'ai' ? \"It's not your turn yet.\" : 'The opponent is still to move.');\n }\n const move = input.move?.trim() || '';\n const normalized = move.toLowerCase() === 'pass' ? 'pass' : move.toUpperCase();\n if (!state.legalMoves.includes(normalized)) {\n throw new Error(\n `Illegal move '${move}'. Legal moves for ${SIDE_NAMES[by]}: ${renderLegal(state)}`,\n );\n }\n\n let captured: string[] = [];\n const before = state.squares;\n if (normalized === 'pass') {\n state.consecutivePasses += 1;\n } else {\n const applied = applyPlacement(\n state.squares,\n state.boardSize,\n by,\n normalized,\n state.previousPosition,\n );\n if (applied.error || !applied.squares) {\n throw new Error(`Illegal move '${move}' (${applied.error || 'unknown'}).`);\n }\n state.squares = applied.squares;\n captured = applied.captures || [];\n state.captures[by] += captured.length;\n state.consecutivePasses = 0;\n }\n state.previousPosition = before;\n state.turn = (by === 'user' ? 'ai' : 'user') as 'user' | 'ai';\n state.status = statusAfter(\n state.squares,\n state.boardSize,\n state.komi,\n state.consecutivePasses,\n ) as GameState['status'];\n if (state.status !== 'playing') {\n state.score = scoreArea(state.squares, state.boardSize, state.komi);\n }\n const entry = {\n by,\n move: normalized,\n captures: captured,\n at: new Date().toISOString(),\n ...(moveThought ? { moveThought } : {}),\n };\n state.moveLog.push(entry);\n state.lastMove = { by, move: normalized, captures: captured };\n if (by === 'ai' && moveThought) state.lastMoveThought = moveThought;\n return state;\n}\n\nlet state = await load();\nlet lastMoveText = state.lastMove?.move ?? '(none)';\n\nif (input.action === 'new_game') {\n state = await persist(freshState(input.boardSize, input.komi));\n lastMoveText = '(none)';\n} else if (input.action === 'user_move' || input.action === 'ai_move') {\n const by = input.action === 'user_move' ? 'user' : 'ai';\n const moveThought = input.moveThought?.trim().slice(0, 140);\n state = await persist(applyTurn(state, by, moveThought));\n lastMoveText = state.lastMove?.move ?? '(none)';\n} else if (input.action !== 'state' && input.action !== 'board') {\n throw new Error(`Unknown action '${input.action}'. Use state, board, user_move, ai_move, or new_game.`);\n}\n\ngezel.output({\n board:\n `${renderAscii(state.squares, state.boardSize)}\\nTurn: ${SIDE_NAMES[state.turn]} (${state.turn}).\\n` +\n `Komi: ${state.komi}. Consecutive passes: ${state.consecutivePasses}.\\n` +\n `Legal moves: ${renderLegal(state)}`,\n turn: state.turn,\n status: state.status,\n lastMove: lastMoveText,\n legalMoves: renderLegal(state),\n score: renderScore(state),\n summary: summarize(state),\n});\n"},"tools":[{"name":"get_board","description":"See the authoritative Go position, board size, captures, whose turn it is, and every legal move.","script":"game-store","inputs":{"type":"object","properties":{}},"bind":{"action":"board"}},{"name":"make_move","description":"Play your white move. Pass a coordinate such as 'D4', or 'pass', plus optional moveThought.","script":"game-store","inputs":{"type":"object","properties":{"move":{"type":"string","description":"A legal coordinate such as 'D4', or 'pass'."},"moveThought":{"type":"string","description":"A short line of table talk (max 140 chars)."}},"required":["move"]},"bind":{"action":"ai_move"}},{"name":"user_move","description":"The user's black move or pass, played from the Go board.","script":"game-store","inputs":{"type":"object","properties":{"move":{"type":"string"}},"required":["move"]},"bind":{"action":"user_move"},"reaction":{"gezel":"go-player","prompt":"Your opponent played {{output.lastMove}}. Status: {{output.status}}. Board now:\n{{output.board}}\nThe board and Legal moves line come directly from the Go engine and are always correct. Do not re-derive captures, liberties, ko, scoring, or legality, and do not invent a coordinate outside the legal list. Spend your thought on choosing among the listed moves.\nStyle: {{playStyle}}. In Opponent style, do not volunteer advice or reveal the user’s available moves, weak groups, threats, or your plan; comment only about your own move or the atmosphere. In Instructor style, offer at most one brief teaching observation without choosing the user’s move. Answer strategy questions when asked.\nAct on this latest position. If the game is over, do not move — give one gracious line about the result and offer a new game. Otherwise choose a strong legal move (or pass when strategically sound) and play it with make_move, including a short {{personality}} moveThought. If make_move rejects it, choose one move verbatim from the legal list in the error and retry. After moving, reply with ONE short line of table talk.","hideSeed":true}},{"name":"new_game","description":"Reset the Go board. Choose 9, 13, or 19 lines and optional komi; defaults to 9×9 with 6.5 komi.","script":"game-store","inputs":{"type":"object","properties":{"boardSize":{"type":"number","enum":[9,13,19]},"komi":{"type":"number","minimum":0,"maximum":20}}},"bind":{"action":"new_game"}}],"pages":{"entry":"board/index.html","reads":[{"source":"workspace","path":"game.json"}],"tools":["user_move","new_game"]},"schedules":[],"workspaceSeed":["game.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"household-budget","name":"Household Budget","description":"Know where the money actually goes. A calm Penningmeester (\"treasurer\") keeps the ledger, closes each month in minutes, and the live dashboard shows in, out, net, and the categories that matter — all on your own disk.","tags":["budget","money","household","spending","ledger"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"currency":{"type":"string","title":"Currency","description":"Shown on the dashboard; entries are unit-agnostic.","default":"USD"}},"required":["currency"]},"nameTemplate":"Household Budget","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"penningmeester","voorman":true}],"toolsets":[],"craftbooks":["subscription-audit","month-close"],"scripts":{"budget-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { ledgerStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'budget-store',\n description:\n \"Read and update the household ledger (ledger.json). Call action:'status' for this month's shape (in/out/net, top categories); 'add_expense' or 'add_income' the moment money moves (amount in ordinary units, e.g. 12.50); 'month_summary' with month 'YYYY-MM' for any past month.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'add_expense', 'add_income', or 'month_summary'.\",\n required: true,\n },\n amount: { type: 'number', description: 'Amount in ordinary currency units, e.g. 12.50.' },\n category: { type: 'string', description: \"Category, e.g. 'groceries', 'rent', 'salary'.\" },\n note: { type: 'string', description: 'Short note on the entry.' },\n month: { type: 'string', description: \"Month for month_summary, e.g. '2026-06'.\" },\n },\n outputs: {\n entries: { type: 'number', description: 'Total ledger entries.' },\n summary: { type: 'string', description: 'Human-readable money summary.' },\n netCents: { type: 'number', description: 'Net for the summarized month, in cents.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── budget-rules-start ──\n// Pure derivations shared with tests: cents conversion and the month\n// summary line, kept engine-side so the dashboard, the tools, and the\n// tests agree on one rendering.\n\nfunction toCents(amount) {\n if (typeof amount !== 'number' || !isFinite(amount) || amount <= 0) return null;\n return Math.round(amount * 100);\n}\n\nfunction fmtCents(cents) {\n var sign = cents < 0 ? '-' : '';\n var abs = Math.abs(cents);\n return sign + Math.floor(abs / 100) + '.' + String(abs % 100).padStart(2, '0');\n}\n\nfunction topCategories(byCategory, kind, limit) {\n return Object.entries(byCategory)\n .map(function (pair) {\n return { category: pair[0], cents: kind === 'out' ? pair[1].outCents : pair[1].inCents };\n })\n .filter(function (row) {\n return row.cents > 0;\n })\n .sort(function (a, b) {\n return b.cents - a.cents;\n })\n .slice(0, limit);\n}\n\nfunction monthLine(month, totals) {\n var top = topCategories(totals.byCategory, 'out', 3)\n .map(function (row) {\n return row.category + ' ' + fmtCents(row.cents);\n })\n .join(', ');\n return (\n month +\n ': in ' +\n fmtCents(totals.inCents) +\n ', out ' +\n fmtCents(totals.outCents) +\n ', net ' +\n fmtCents(totals.netCents) +\n (top ? '. Biggest: ' + top + '.' : '. No spending recorded.')\n );\n}\n// ── budget-rules-end ──\n\nconst ledger = ledgerStore(gezel.fs, 'ledger.json');\nconst currentMonth = new Date().toISOString().slice(0, 7);\n\nif (input.action === 'add_expense' || input.action === 'add_income') {\n const cents = toCents(input.amount);\n if (cents === null) throw new Error(\"Pass a positive 'amount' in ordinary units, e.g. 12.50.\");\n if (!input.category) throw new Error(\"Pass a 'category', e.g. 'groceries' or 'salary'.\");\n await ledger.add({\n amountCents: cents,\n kind: input.action === 'add_expense' ? 'out' : 'in',\n category: input.category.toLowerCase(),\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action !== 'status' && input.action !== 'month_summary') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, add_expense, add_income, or month_summary.`,\n );\n}\n\nconst month =\n input.action === 'month_summary' && input.month ? input.month : currentMonth;\nconst totals = await ledger.totals({ month });\nconst all = await ledger.list();\n\ngezel.output({\n entries: all.length,\n summary: monthLine(month, totals),\n netCents: totals.netCents,\n});\n"},"tools":[{"name":"add_expense","description":"Record spending the moment it happens (amount in ordinary units).","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_expense"}},{"name":"add_income","description":"Record money coming in.","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_income"}},{"name":"budget_status","description":"This month's shape: in, out, net, and the biggest categories.","script":"budget-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"month_summary","description":"Summarize any month ('YYYY-MM').","script":"budget-store","inputs":{"type":"object","properties":{"month":{"type":"string"}},"required":["month"]},"bind":{"action":"month_summary"}},{"name":"record_expense","description":"The dashboard's quick-add expense entry.","script":"budget-store","inputs":{"type":"object","properties":{"amount":{"type":"number"},"category":{"type":"string"},"note":{"type":"string"}},"required":["amount","category"]},"bind":{"action":"add_expense"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"ledger.json"}],"tools":["record_expense"]},"schedules":[],"workspaceSeed":["ledger.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"household-manual","name":"Household Manual","description":"The living manual for your home. A Huismeester (\"caretaker\") keeps a card for every system — what care it needs and when — turns problems into flagged history, and the dashboard shows what the house needs this season.","tags":["home","maintenance","appliances","household"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"home":{"type":"string","title":"What kind of home?","description":"e.g. 'a 1960s house', 'a third-floor apartment'.","default":"our home"}},"required":["home"]},"nameTemplate":"Household Manual","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"huismeester","voorman":true}],"toolsets":[],"craftbooks":["seasonal-maintenance-sweep"],"scripts":{"house-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'house-store',\n description:\n \"Read and update the home's system cards (systems.json). Call action:'status' for what's due or flagged; 'record_system' when an appliance/system comes up (with care interval in days when known); 'log_maintenance' when care happens; 'flag_attention' for problems; 'house_list' to read every card.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_system', 'log_maintenance', 'flag_attention', or 'house_list'.\",\n required: true,\n },\n name: { type: 'string', description: \"System name, e.g. 'Furnace', 'Smoke alarms'.\" },\n kind: { type: 'string', description: 'What it is (appliance, safety, structure, utility).' },\n location: { type: 'string', description: 'Where it lives in the house.' },\n care: { type: 'string', description: \"The care it needs, e.g. 'replace filter'.\" },\n intervalDays: { type: 'number', description: 'Care interval in days, when known.' },\n note: { type: 'string', description: 'Maintenance or attention note.' },\n id: { type: 'string', description: \"System id, e.g. 'sys-2'.\" },\n },\n outputs: {\n systems: { type: 'number', description: 'System cards in the manual.' },\n due: { type: 'number', description: 'Systems with care due.' },\n flagged: { type: 'number', description: 'Systems flagged for attention.' },\n summary: { type: 'string', description: 'What the house needs.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── house-rules-start ──\n// Pure derivations over rosterStore-shaped system cards: statuses are\n// ok -> attention; \"due\" derives from lastDoneAt + intervalDays.\n\nvar DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction isDue(record, nowMs) {\n var f = record.fields || {};\n if (typeof f.intervalDays !== 'number' || f.intervalDays <= 0) return false;\n var last = typeof f.lastDoneAt === 'string' ? Date.parse(f.lastDoneAt) : Date.parse(record.createdAt);\n return isFinite(last) && nowMs - last >= f.intervalDays * DAY_MS;\n}\n\nfunction houseShape(records, nowMs) {\n var due = [];\n var flagged = [];\n records.forEach(function (r) {\n if (isDue(r, nowMs)) due.push(r);\n if (r.status === 'attention') flagged.push(r);\n });\n return { due: due, flagged: flagged };\n}\n\nfunction houseLine(records, nowMs) {\n var shape = houseShape(records, nowMs);\n var names = function (rows) {\n return rows\n .map(function (r) {\n return (r.fields && r.fields.name) || r.id;\n })\n .join(', ');\n };\n if (records.length === 0) return 'No systems recorded yet — start with the ones that need regular care.';\n var bits = [];\n if (shape.due.length) bits.push('care due: ' + names(shape.due));\n if (shape.flagged.length) bits.push('flagged: ' + names(shape.flagged));\n return records.length + ' system(s) on record. ' + (bits.length ? bits.join('; ') + '.' : 'Nothing due — the house is quiet.');\n}\n// ── house-rules-end ──\n\nconst house = rosterStore(gezel.fs, 'systems.json', {\n stages: ['ok', 'attention'],\n idPrefix: 'sys',\n});\n\nasync function resolveSystem(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await house.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'sys-2') or an exact system 'name'.\");\n}\n\nif (input.action === 'record_system') {\n if (!input.name) throw new Error(\"record_system needs 'name'.\");\n await house.add({\n status: 'ok',\n fields: {\n name: input.name,\n ...(input.kind ? { kind: input.kind } : {}),\n ...(input.location ? { location: input.location } : {}),\n ...(input.care ? { care: input.care } : {}),\n ...(typeof input.intervalDays === 'number' ? { intervalDays: input.intervalDays } : {}),\n lastDoneAt: new Date().toISOString(),\n },\n });\n} else if (input.action === 'log_maintenance') {\n const id = await resolveSystem();\n await house.update(id, {\n fields: { lastDoneAt: new Date().toISOString() },\n ...(input.note ? { note: input.note } : {}),\n });\n await house.transition(id, 'ok');\n} else if (input.action === 'flag_attention') {\n const id = await resolveSystem();\n await house.transition(id, 'attention', input.note ? { note: input.note } : {});\n} else if (input.action !== 'status' && input.action !== 'house_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_system, log_maintenance, flag_attention, or house_list.`,\n );\n}\n\nconst records = await house.list();\nconst shape = houseShape(records, Date.now());\nconst listing =\n input.action === 'house_list'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}${isDue(r, Date.now()) ? ', due' : ''}] ${String(r.fields.name ?? '?')}${r.fields.location ? ` — ${String(r.fields.location)}` : ''}${r.fields.care ? ` (${String(r.fields.care)}${r.fields.intervalDays ? ` every ${String(r.fields.intervalDays)}d` : ''})` : ''}`,\n )\n .join('\\n') || 'No systems recorded yet.'\n : '';\n\ngezel.output({\n systems: records.length,\n due: shape.due.length,\n flagged: shape.flagged.length,\n summary: listing || houseLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_system","description":"Add a system or appliance card (with a care interval in days when known).","script":"house-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string"},"location":{"type":"string"},"care":{"type":"string"},"intervalDays":{"type":"number"}},"required":["name"]},"bind":{"action":"record_system"}},{"name":"log_maintenance","description":"Log that care happened (resets the due clock).","script":"house-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}}},"bind":{"action":"log_maintenance"}},{"name":"flag_attention","description":"Flag a problem on a system (a noise, a drip, a trip).","script":"house-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"note":{"type":"string"}},"required":["note"]},"bind":{"action":"flag_attention"}},{"name":"house_status","description":"What the house needs: care due and flagged problems.","script":"house-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"house_list","description":"Read every system card.","script":"house-store","inputs":{"type":"object","properties":{}},"bind":{"action":"house_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"systems.json"}]},"schedules":[],"workspaceSeed":["systems.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"image-feed","name":"Image Feed","description":"A visual feed with a working rhythm — your Omroeper runs the posting pipeline, an image maker turns notions into pictures in the feed's own style, and a live gallery shows every post from first idea to published.","tags":["social","instagram","images","visual","posts"],"category":"creative","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-08-12T00:00:00Z","extends":"social-media","meesterManaged":true,"mode":"crew","leadLabel":"Omroeper","params":{"type":"object","properties":{"brand":{"type":"string","title":"Who does this feed speak for?","description":"The name, brand, or studio this feed speaks for.","default":"Our studio"},"instagramHandle":{"type":"string","title":"Instagram handle","description":"Instagram handle (e.g. @studio). Leave empty if unused.","default":""},"visualStyle":{"type":"string","title":"Visual style","description":"One sentence on how the feed should look — seeded into style-guide.md.","default":"Natural light, honest color, one subject per image."},"voice":{"type":"string","title":"Tone of voice","description":"One sentence on tone of voice for captions.","default":"Warm, plain, and human — never corporate."},"cadence":{"type":"string","title":"Posting cadence","description":"Target posting cadence (e.g. 4 images a week).","default":"4 images a week"}},"required":["brand"]},"nameTemplate":"{{brand}} Image Feed","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"omroeper","voorman":true},{"templateId":"image-generator","voorman":false}],"toolsets":[],"craftbooks":["draft-social-post","social-digest","reception-report"],"scripts":{"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"},"tools":[{"name":"list_posts","description":"See the whole post pipeline: every post with its status, platforms, and dates. Optionally filter by status or platform.","script":"posts-store","inputs":{"type":"object","properties":{"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Only posts currently in this status."},"platform":{"type":"string","description":"Only posts targeting this platform, e.g. 'bluesky'."}}},"bind":{"action":"list"}},{"name":"get_post","description":"Read one post in full: frontmatter, body, each platform variant with its character count (and permalink once live), and media files.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id — its folder name under posts/, e.g. '2026-08-12-launch-teaser'."}},"required":["id"]},"bind":{"action":"get"}},{"name":"create_post","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.","script":"posts-store","inputs":{"type":"object","properties":{"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Initial status for the new post. Defaults to approved."}}},"bind":{"action":"create"}},{"name":"update_post","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.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"fields":{"type":"object","description":"Frontmatter patches, e.g. {\"scheduledFor\": \"2026-08-15T09:00:00Z\", \"campaign\": \"autumn\"}."},"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Target status (one stage at a time)."}},"required":["id"]},"bind":{"action":"update"}},{"name":"record_publication","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).","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"platform":{"type":"string","description":"The platform that went live, e.g. 'bluesky'."},"permalink":{"type":"string","description":"Public URL of the live post."},"postId":{"type":"string","description":"The platform's own id for the live post."},"corpusRef":{"type":"string","description":"Connector corpus record reference, when one exists."},"publishedAt":{"type":"string","description":"ISO timestamp it went live (defaults to now)."},"markPublished":{"type":"boolean","description":"Force the post to published even with unstamped platforms."}},"required":["id","platform"]},"bind":{"action":"record_publication"}},{"name":"page_set_status","description":"Page-only: move a post between pipeline columns on the gallery.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"The column the post was moved to."}},"required":["id","status"]},"bind":{"action":"update"},"reaction":{"gezel":"omroeper","prompt":"[Feed gallery]: The user moved post {{output.id}} to '{{output.status}}' on the gallery. 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.","hideSeed":true}},{"name":"page_get_post","description":"Page-only: the detail drawer's read of one post with variants and media.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."}},"required":["id"]},"bind":{"action":"get"}}],"pages":{"entry":"gallery/index.html","api":1,"reads":[{"source":"workspace","path":"posts","subtree":true},{"source":"workspace","path":"style-guide.md"},{"source":"artifacts","path":"data","subtree":true}],"tools":["page_set_status","page_get_post"]},"schedules":[{"runMode":"night-shift","craftbook":"social-digest","consent":"ask","overlap":"skip"},{"runMode":"scheduled","cron":"0 15 * * 1","craftbook":"reception-report","consent":"ask","overlap":"skip"}],"workspaceSeed":["style-guide.md","posts/index.json"],"artifactsSeed":[],"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 gallery 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.","availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"job-hunt","name":"Job Hunt","description":"Run your job search like a campaign — a career coach tracks every application through the pipeline, a mock interviewer gets you ready, and a live board keeps the whole slog navigable.","tags":["job","career","interview","resume","offer"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"job-hunt","meesterManaged":false,"params":{"type":"object","properties":{"role":{"type":"string","title":"What role are you hunting for?","description":"The role or title you're targeting — e.g. Staff Engineer, Product Designer, Data Analyst.","default":"Software Engineer"},"focus":{"type":"string","title":"Anything about your search?","description":"Optional context for your coach — location constraints, timeline, dream companies.","default":"Just getting started."}},"required":["role","focus"]},"nameTemplate":"{{role}} Job Hunt","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"career-coach","voorman":true},{"templateId":"mock-interviewer","voorman":false}],"toolsets":[],"craftbooks":["tailor-resume","company-research-brief","mock-interview","offer-compare","weekly-pipeline-review"],"scripts":{"application-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'application-store',\n description:\n \"Read and update the job-search pipeline (applications in pipeline.json, activity in activity.json). Call action:'status' at the start of a session to see where the search stands; 'record_application' the moment the user mentions applying somewhere; 'advance_stage' when reality moves; 'log_activity' for follow-ups, scheduled interviews, and thank-yous.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record_application', 'advance_stage', or 'log_activity'.\",\n required: true,\n },\n company: { type: 'string', description: 'Company name (identifies the application when id is omitted).' },\n role: { type: 'string', description: 'Role title for a new application.' },\n link: { type: 'string', description: 'Posting or application link.' },\n notes: { type: 'string', description: 'Free-form notes for a new application.' },\n id: { type: 'string', description: \"Application id (e.g. 'acme-staff-engineer') when advancing or logging.\" },\n stage: {\n type: 'string',\n description: 'Target stage for advance_stage: applied, screening, interview, offer, or closed.',\n },\n outcome: { type: 'string', description: \"With stage 'closed': signed, rejected, or withdrawn.\" },\n kind: {\n type: 'string',\n description: \"Activity kind for log_activity: follow-up, interview-scheduled, thank-you, offer-received, or note.\",\n },\n note: { type: 'string', description: 'The activity note for log_activity, or a note on a stage change.' },\n due: { type: 'string', description: 'Due date (ISO) for a follow-up.' },\n },\n outputs: {\n applications: { type: 'number', description: 'Total applications tracked.' },\n summary: { type: 'string', description: 'Human-readable pipeline summary.' },\n stale: { type: 'json', description: 'Active applications untouched for over 7 days.' },\n followUps: { type: 'json', description: 'Follow-ups due within 7 days or overdue.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\nconst STAGES: [string, ...string[]] = ['applied', 'screening', 'interview', 'offer', 'closed'];\nconst OUTCOMES = ['signed', 'rejected', 'withdrawn'];\nconst WEEK_MS = 7 * 24 * 60 * 60 * 1000;\n\nconst roster = rosterStore(gezel.fs, 'pipeline.json', { stages: STAGES, idPrefix: 'app' });\nconst activity = logStore(gezel.fs, 'activity.json');\n\nfunction slugify(s: string): string {\n return (\n s\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '') || 'application'\n );\n}\n\nasync function freshId(company: string, role?: string): Promise<string> {\n const base = slugify(role ? `${company} ${role}` : company);\n let candidate = base;\n for (let n = 2; (await roster.get(candidate)) !== null; n += 1) {\n candidate = `${base}-${n}`;\n }\n return candidate;\n}\n\nasync function resolveId(): Promise<string> {\n if (input.id) {\n if ((await roster.get(input.id)) === null) throw new Error(`No application '${input.id}'.`);\n return input.id;\n }\n if (input.company) {\n const wanted = input.company.toLowerCase();\n const matches = (await roster.list()).filter(\n (r) => String(r.fields.company ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n if (matches.length > 1) {\n throw new Error(\n `Multiple applications match company '${input.company}': ${matches.map((m) => m.id).join(', ')}. Pass id.`,\n );\n }\n }\n throw new Error(\"Pass 'id' or a unique 'company' to identify the application.\");\n}\n\nasync function summarize(): Promise<{\n applications: number;\n summary: string;\n stale: unknown[];\n followUps: unknown[];\n}> {\n const records = await roster.list();\n const counts = await roster.counts();\n const now = Date.now();\n const stale = records\n .filter((r) => r.status !== 'closed' && now - Date.parse(r.updatedAt) > WEEK_MS)\n .map((r) => ({\n id: r.id,\n company: r.fields.company ?? r.id,\n stage: r.status,\n daysQuiet: Math.floor((now - Date.parse(r.updatedAt)) / (24 * 60 * 60 * 1000)),\n }));\n const followUps = (await activity.list({ kind: 'follow-up' }))\n .filter((e) => {\n const due = typeof e.data?.dueAt === 'string' ? Date.parse(e.data.dueAt) : null;\n return due !== null && due <= now + WEEK_MS;\n })\n .map((e) => ({\n note: e.note ?? '',\n dueAt: e.data?.dueAt,\n applicationId: e.data?.applicationId,\n overdue: typeof e.data?.dueAt === 'string' && Date.parse(e.data.dueAt) < now,\n }));\n const active = records.filter((r) => r.status !== 'closed').length;\n const stageLine = STAGES.filter((s) => (counts[s] ?? 0) > 0)\n .map((s) => `${s} ${counts[s]}`)\n .join(', ');\n const summary = records.length\n ? `${active} application(s) in play (${stageLine || 'none'}). ${stale.length} stale, ${followUps.length} follow-up(s) due this week.`\n : 'No applications tracked yet.';\n return { applications: records.length, summary, stale, followUps };\n}\n\nif (input.action === 'record_application') {\n if (!input.company) throw new Error(\"record_application needs 'company'.\");\n const id = await freshId(input.company, input.role);\n const record = await roster.add({\n id,\n fields: {\n company: input.company,\n ...(input.role ? { role: input.role } : {}),\n ...(input.link ? { link: input.link } : {}),\n ...(input.notes ? { notes: input.notes } : {}),\n },\n });\n await activity.append({\n kind: 'applied',\n note: `Applied to ${input.company}${input.role ? ` for ${input.role}` : ''}.`,\n data: { applicationId: record.id },\n });\n} else if (input.action === 'advance_stage') {\n if (!input.stage) throw new Error(\"advance_stage needs 'stage'.\");\n if (!STAGES.includes(input.stage)) {\n throw new Error(`Unknown stage '${input.stage}'. Stages: ${STAGES.join(', ')}.`);\n }\n if (input.outcome && input.stage !== 'closed') {\n throw new Error(\"'outcome' only applies when closing (stage 'closed').\");\n }\n if (input.outcome && !OUTCOMES.includes(input.outcome)) {\n throw new Error(`Unknown outcome '${input.outcome}'. Outcomes: ${OUTCOMES.join(', ')}.`);\n }\n const id = await resolveId();\n await roster.transition(id, input.stage, input.note ? { note: input.note } : {});\n if (input.outcome) await roster.update(id, { fields: { outcome: input.outcome } });\n const company = String((await roster.get(id))?.fields.company ?? id);\n await activity.append({\n kind: 'stage',\n note: `${company} moved to ${input.stage}${input.outcome ? ` (${input.outcome})` : ''}.`,\n data: { applicationId: id },\n });\n} else if (input.action === 'log_activity') {\n if (!input.note) throw new Error(\"log_activity needs 'note'.\");\n let applicationId: string | undefined;\n if (input.id || input.company) {\n applicationId = await resolveId();\n // A logged touch counts as attention — it resets the staleness clock.\n await roster.update(applicationId, input.note ? { note: input.note } : {});\n }\n await activity.append({\n kind: input.kind ?? 'note',\n note: input.note,\n data: {\n ...(applicationId ? { applicationId } : {}),\n ...(input.due ? { dueAt: input.due } : {}),\n },\n });\n} else if (input.action !== 'status') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_application, advance_stage, or log_activity.`,\n );\n}\n\ngezel.output(await summarize());\n"},"tools":[{"name":"record_application","description":"Track a new job application the moment it happens.","script":"application-store","inputs":{"type":"object","properties":{"company":{"type":"string","description":"Company name."},"role":{"type":"string","description":"Role title."},"link":{"type":"string","description":"Posting or application link."},"notes":{"type":"string","description":"Anything worth remembering about this one."}},"required":["company"]},"bind":{"action":"record_application"}},{"name":"advance_stage","description":"Move an application to a new pipeline stage (applied, screening, interview, offer, closed).","script":"application-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Application id."},"company":{"type":"string","description":"Company name (when id is omitted)."},"stage":{"type":"string","enum":["applied","screening","interview","offer","closed"],"description":"The stage reality moved to."},"note":{"type":"string","description":"What happened."},"outcome":{"type":"string","enum":["signed","rejected","withdrawn"],"description":"Only when closing: how it ended."}},"required":["stage"]},"bind":{"action":"advance_stage"}},{"name":"log_activity","description":"Log activity on the search — a follow-up to send, an interview scheduled, a thank-you sent.","script":"application-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Application id this activity belongs to."},"company":{"type":"string","description":"Company name (when id is omitted)."},"kind":{"type":"string","enum":["follow-up","interview-scheduled","thank-you","offer-received","note"],"description":"What kind of activity this is."},"note":{"type":"string","description":"The activity itself."},"due":{"type":"string","description":"Due date (ISO) for a follow-up."}},"required":["note"]},"bind":{"action":"log_activity"}},{"name":"pipeline_status","description":"See the whole pipeline: counts per stage, stale applications, and follow-ups due this week.","script":"application-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"pipeline.json"},{"source":"workspace","path":"activity.json"}]},"schedules":[{"runMode":"scheduled","cron":"0 17 * * 5","craftbook":"weekly-pipeline-review","consent":"ask","overlap":"skip"}],"workspaceSeed":["pipeline.json","activity.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"just-chat","name":"Just Chat","description":"A quiet room for talking — no tasks, no workspace, no deliverables. Just an open thread with a gezel of your choosing: brainstorming, writing back and forth, asking questions, thinking out loud. Ideal for a warm companion gezel or a small on-device model that's happiest simply conversing.","tags":["chat","conversation","companion","casual","writing"],"category":"general","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-07-20T00:00:00Z","meesterManaged":false,"tabVisibility":{"overview":false,"tasks":false,"approvals":false,"workspace":false,"artifacts":false,"map":false},"nameTemplate":"Just Chat","aboutTemplate":"about.md","gezels":[],"toolsets":[],"craftbooks":[],"tools":[],"schedules":[],"workspaceSeed":[],"artifactsSeed":[],"availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"language-trainer","name":"Language Trainer","description":"Practice a new language by chatting with a patient tutor who corrects gently and tracks your progress. Comes with a progress dashboard.","tags":["language","learning","tutor","conversation"],"category":"growth","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","extends":"content-writing","meesterManaged":false,"params":{"type":"object","properties":{"language":{"type":"string","title":"Which language?","description":"The language you want to practice, e.g. Spanish, Japanese, French.","default":"Spanish"}},"required":["language"]},"nameTemplate":"{{language}} Language Trainer","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"language-trainer","voorman":true}],"toolsets":[],"craftbooks":[],"scripts":{"progress-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\n\nexport const meta = defineScript({\n name: 'progress-store',\n description:\n \"Read and update the student's language-learning progress (level, sessions, streak, notes). Call action:'status' at the start of a session to see where they are; action:'record' after a practice exchange to log it; action:'advance' when they're ready for the next level.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'record', or 'advance'.\",\n required: true,\n },\n note: {\n type: 'string',\n description: 'Optional short note to append (what was practiced, a struggle to remember).',\n },\n },\n outputs: {\n level: { type: 'number', description: 'Current level (1+).' },\n sessions: { type: 'number', description: 'Total practice sessions recorded.' },\n streak: { type: 'number', description: 'Consecutive-session streak.' },\n message: { type: 'string', description: 'Human-readable summary of the current state.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\ninterface Progress {\n language: string;\n level: number;\n sessions: number;\n streak: number;\n notes: string[];\n}\n\nasync function load(): Promise<Progress> {\n try {\n const raw = await gezel.fs.read('progress.json');\n const p = JSON.parse(raw) as Partial<Progress>;\n return {\n language: p.language ?? 'your language',\n level: p.level ?? 1,\n sessions: p.sessions ?? 0,\n streak: p.streak ?? 0,\n notes: Array.isArray(p.notes) ? p.notes : [],\n };\n } catch {\n return { language: 'your language', level: 1, sessions: 0, streak: 0, notes: [] };\n }\n}\n\nconst p = await load();\nif (input.action === 'record') {\n p.sessions += 1;\n p.streak += 1;\n if (input.note) p.notes.push(input.note);\n} else if (input.action === 'advance') {\n p.level += 1;\n p.notes.push(`Advanced to level ${p.level}${input.note ? `: ${input.note}` : ''}`);\n}\nif (input.action !== 'status') {\n await gezel.fs.write('progress.json', `${JSON.stringify(p, null, 2)}\\n`);\n}\n\ngezel.output({\n level: p.level,\n sessions: p.sessions,\n streak: p.streak,\n message: `Learning ${p.language}: level ${p.level}, ${p.sessions} session(s), ${p.streak}-session streak.`,\n});\n"},"tools":[{"name":"record_session","description":"Log that the student practiced this session.","script":"progress-store","inputs":{"type":"object","properties":{"note":{"type":"string"}}},"bind":{"action":"record"}},{"name":"advance_level","description":"Advance the student to the next level.","script":"progress-store","inputs":{"type":"object","properties":{"note":{"type":"string"}}},"bind":{"action":"advance"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"progress.json"}]},"schedules":[],"workspaceSeed":["progress.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"life-binder","name":"Life Binder","description":"The \"in case of emergency\" file, actually complete. A gentle Archivaris (\"archivist\") tracks wills, policies, accounts, and instructions — what exists, where it lives, what's missing — and none of it ever leaves this machine.","tags":["estate","documents","emergency","family","insurance"],"category":"money","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"household":{"type":"string","title":"Who is this binder for?","description":"e.g. 'our family', 'Mom', 'me'.","default":"our family"}},"required":["household"]},"nameTemplate":"Life Binder","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"archivaris","voorman":true}],"toolsets":[],"craftbooks":["annual-document-review"],"scripts":{"binder-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'binder-store',\n description:\n \"Read and update the life binder (documents.json). Call action:'status' for the completeness picture; 'record_document' when a document exists (name + where the original lives); 'mark_missing' / 'mark_reviewed' as reality changes; 'checklist' to read everything.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'record_document', 'mark_missing', 'mark_reviewed', or 'checklist'.\",\n required: true,\n },\n name: { type: 'string', description: \"Document name, e.g. 'Will', 'Home insurance policy'.\" },\n location: { type: 'string', description: 'Where the original lives (drawer, safe, provider).' },\n note: { type: 'string', description: 'Anything a trusted person should know about it.' },\n id: { type: 'string', description: \"Document id, e.g. 'doc-2'.\" },\n },\n outputs: {\n total: { type: 'number', description: 'Documents on the checklist.' },\n present: { type: 'number', description: 'Recorded as existing.' },\n missing: { type: 'number', description: 'Known gaps.' },\n stale: { type: 'number', description: 'Present but unreviewed for over a year.' },\n summary: { type: 'string', description: 'The completeness picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── binder-rules-start ──\n// Pure derivations over rosterStore-shaped document records: statuses are\n// missing -> present; staleness is derived (present + unreviewed > 365d).\n\nvar YEAR_MS = 365 * 24 * 60 * 60 * 1000;\n\nfunction isStale(record, nowMs) {\n if (record.status !== 'present') return false;\n var reviewed = record.fields && typeof record.fields.reviewedAt === 'string'\n ? Date.parse(record.fields.reviewedAt)\n : Date.parse(record.updatedAt);\n return isFinite(reviewed) && nowMs - reviewed > YEAR_MS;\n}\n\nfunction binderShape(records, nowMs) {\n var present = 0;\n var missing = [];\n var stale = [];\n records.forEach(function (r) {\n if (r.status === 'present') {\n present += 1;\n if (isStale(r, nowMs)) stale.push(r);\n } else {\n missing.push(r);\n }\n });\n return { present: present, missing: missing, stale: stale };\n}\n\nfunction binderLine(records, nowMs) {\n var shape = binderShape(records, nowMs);\n var gaps = shape.missing\n .map(function (r) {\n return r.fields && r.fields.name ? r.fields.name : r.id;\n })\n .join(', ');\n var staleNames = shape.stale\n .map(function (r) {\n return r.fields && r.fields.name ? r.fields.name : r.id;\n })\n .join(', ');\n return (\n shape.present +\n ' of ' +\n records.length +\n ' in place' +\n (gaps ? '. Missing: ' + gaps : '. Nothing missing') +\n (staleNames ? '. Due a review: ' + staleNames + '.' : '.')\n );\n}\n// ── binder-rules-end ──\n\nconst binder = rosterStore(gezel.fs, 'documents.json', {\n stages: ['missing', 'present'],\n idPrefix: 'doc',\n});\n\nasync function resolveDoc(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await binder.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'doc-2') or an exact document 'name'.\");\n}\n\nif (input.action === 'record_document') {\n if (!input.name) throw new Error(\"record_document needs 'name'.\");\n const existing = (await binder.list()).find(\n (r) => String(r.fields.name ?? '').toLowerCase() === input.name!.toLowerCase(),\n );\n if (existing) {\n await binder.update(existing.id, {\n fields: {\n ...(input.location ? { location: input.location } : {}),\n ...(input.note ? { note: input.note } : {}),\n reviewedAt: new Date().toISOString(),\n },\n });\n await binder.transition(existing.id, 'present');\n } else {\n await binder.add({\n status: 'present',\n fields: {\n name: input.name,\n ...(input.location ? { location: input.location } : {}),\n ...(input.note ? { note: input.note } : {}),\n reviewedAt: new Date().toISOString(),\n },\n });\n }\n} else if (input.action === 'mark_missing') {\n await binder.transition(await resolveDoc(), 'missing');\n} else if (input.action === 'mark_reviewed') {\n const id = await resolveDoc();\n await binder.update(id, { fields: { reviewedAt: new Date().toISOString() } });\n await binder.transition(id, 'present');\n} else if (input.action !== 'status' && input.action !== 'checklist') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, record_document, mark_missing, mark_reviewed, or checklist.`,\n );\n}\n\nconst records = await binder.list();\nconst shape = binderShape(records, Date.now());\nconst listing =\n input.action === 'checklist'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}${isStale(r, Date.now()) ? ', stale' : ''}] ${String(r.fields.name ?? '?')}${r.fields.location ? ` — ${String(r.fields.location)}` : ''}`,\n )\n .join('\\n') || 'The checklist is empty.'\n : '';\n\ngezel.output({\n total: records.length,\n present: shape.present,\n missing: shape.missing.length,\n stale: shape.stale.length,\n summary: listing || binderLine(records, Date.now()),\n});\n"},"tools":[{"name":"record_document","description":"Record that a document exists and where the original lives.","script":"binder-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"location":{"type":"string"},"note":{"type":"string"}},"required":["name"]},"bind":{"action":"record_document"}},{"name":"mark_missing","description":"Mark a checklist item as a known gap.","script":"binder-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"bind":{"action":"mark_missing"}},{"name":"mark_reviewed","description":"Confirm a document was just reviewed and is current.","script":"binder-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"bind":{"action":"mark_reviewed"}},{"name":"binder_status","description":"The completeness picture: what's in place, missing, or due a review.","script":"binder-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"checklist","description":"Read the whole checklist with locations.","script":"binder-store","inputs":{"type":"object","properties":{}},"bind":{"action":"checklist"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"documents.json"}]},"schedules":[],"workspaceSeed":["documents.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"meal-planner","name":"Meal Planner & Pantry","description":"Answer \"what's for dinner?\" once a week. A Maaltijdplanner (\"meal planner\") plans menus from what's actually in the pantry, keeps taste memory, and the grocery list writes itself.","tags":["meals","cooking","pantry","groceries","menu"],"category":"home","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"household":{"type":"string","title":"Who eats here?","description":"e.g. 'two adults, two picky kids', 'just me, vegetarian'.","default":"the household"}},"required":["household"]},"nameTemplate":"Meal Planner","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"maaltijdplanner","voorman":true}],"toolsets":[],"craftbooks":["weekly-menu-plan"],"scripts":{"kitchen-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'kitchen-store',\n description:\n \"Read and update the week's menu (menu.json) and the pantry (pantry.json). Call action:'status' for today's dinner + the grocery gap; 'set_menu' day by day; 'pantry_add' / 'pantry_level' as staples change; 'grocery_list' for the run.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'set_menu', 'pantry_add', 'pantry_level', or 'grocery_list'.\",\n required: true,\n },\n day: {\n type: 'string',\n description: \"Day for set_menu: monday..sunday.\",\n },\n meal: { type: 'string', description: 'The dinner for that day.' },\n name: { type: 'string', description: 'Pantry item name.' },\n level: { type: 'string', description: \"Pantry level: 'stocked', 'low', or 'out'.\" },\n id: { type: 'string', description: \"Pantry item id, e.g. 'item-3'.\" },\n },\n outputs: {\n planned: { type: 'number', description: 'Days with a planned dinner.' },\n groceries: { type: 'number', description: 'Items on the grocery list.' },\n summary: { type: 'string', description: 'The kitchen picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── kitchen-rules-start ──\n// Pure derivations: the weekly menu shape and the grocery gap (pantry\n// items marked low or out).\n\nvar DAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];\n\nfunction normalizeDay(day) {\n if (typeof day !== 'string') return null;\n var d = day.toLowerCase();\n return DAYS.indexOf(d) >= 0 ? d : null;\n}\n\nfunction todayName(nowMs) {\n // getUTCDay: 0=Sunday.\n var idx = new Date(nowMs).getUTCDay();\n return DAYS[(idx + 6) % 7];\n}\n\nfunction groceryGap(records) {\n return records.filter(function (r) {\n return r.status === 'low' || r.status === 'out';\n });\n}\n\nfunction kitchenLine(menu, records, nowMs) {\n var today = todayName(nowMs);\n var tonight = menu.days && menu.days[today];\n var gap = groceryGap(records);\n var planned = DAYS.filter(function (d) {\n return menu.days && menu.days[d];\n }).length;\n return (\n (tonight ? \"Tonight: \" + tonight + '.' : 'Nothing planned for tonight.') +\n ' ' +\n planned +\n ' of 7 dinners planned. Grocery list: ' +\n (gap.length\n ? gap\n .map(function (r) {\n return (r.fields && r.fields.name) || r.id;\n })\n .join(', ')\n : 'empty')\n );\n}\n// ── kitchen-rules-end ──\n\nconst MENU_FILE = 'menu.json';\nconst pantry = rosterStore(gezel.fs, 'pantry.json', {\n stages: ['stocked', 'low', 'out'],\n idPrefix: 'item',\n});\n\ninterface Menu {\n version: number;\n days: Record<string, string>;\n updatedAt: string;\n}\n\nasync function loadMenu(): Promise<Menu> {\n try {\n const parsed = JSON.parse(await gezel.fs.read(MENU_FILE)) as Partial<Menu>;\n return {\n version: 1,\n days: parsed.days && typeof parsed.days === 'object' ? (parsed.days as Record<string, string>) : {},\n updatedAt: new Date().toISOString(),\n };\n } catch {\n return { version: 1, days: {}, updatedAt: new Date().toISOString() };\n }\n}\n\nasync function saveMenu(menu: Menu): Promise<void> {\n menu.updatedAt = new Date().toISOString();\n await gezel.fs.write(MENU_FILE, `${JSON.stringify(menu, null, 2)}\\n`);\n}\n\nasync function resolveItem(): Promise<string> {\n if (input.id) return input.id;\n if (input.name) {\n const wanted = input.name.toLowerCase();\n const matches = (await pantry.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) return matches[0]!.id;\n }\n throw new Error(\"Pass 'id' (e.g. 'item-3') or an exact pantry item 'name'.\");\n}\n\nconst menu = await loadMenu();\n\nif (input.action === 'set_menu') {\n const day = normalizeDay(input.day);\n if (!day || !input.meal) throw new Error(\"set_menu needs 'day' (monday..sunday) and 'meal'.\");\n menu.days[day] = input.meal;\n await saveMenu(menu);\n} else if (input.action === 'pantry_add') {\n if (!input.name) throw new Error(\"pantry_add needs 'name'.\");\n const level = input.level && ['stocked', 'low', 'out'].includes(input.level) ? input.level : 'stocked';\n await pantry.add({ status: level, fields: { name: input.name } });\n} else if (input.action === 'pantry_level') {\n if (!input.level || !['stocked', 'low', 'out'].includes(input.level)) {\n throw new Error(\"pantry_level needs level 'stocked', 'low', or 'out'.\");\n }\n await pantry.transition(await resolveItem(), input.level);\n} else if (input.action !== 'status' && input.action !== 'grocery_list') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, set_menu, pantry_add, pantry_level, or grocery_list.`,\n );\n}\n\nconst records = await pantry.list();\nconst gap = groceryGap(records);\nconst listing =\n input.action === 'grocery_list'\n ? gap\n .map((r) => `${String(r.fields.name ?? r.id)} (${r.status})`)\n .join('\\n') || 'The grocery list is empty.'\n : '';\n\ngezel.output({\n planned: DAYS.filter((d) => menu.days[d]).length,\n groceries: gap.length,\n summary: listing || kitchenLine(menu, records, Date.now()),\n});\n"},"tools":[{"name":"set_menu","description":"Set a day's dinner (monday..sunday).","script":"kitchen-store","inputs":{"type":"object","properties":{"day":{"type":"string"},"meal":{"type":"string"}},"required":["day","meal"]},"bind":{"action":"set_menu"}},{"name":"pantry_add","description":"Add a pantry staple (level stocked, low, or out).","script":"kitchen-store","inputs":{"type":"object","properties":{"name":{"type":"string"},"level":{"type":"string","enum":["stocked","low","out"]}},"required":["name"]},"bind":{"action":"pantry_add"}},{"name":"pantry_level","description":"Update a staple's level as the kitchen changes.","script":"kitchen-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"level":{"type":"string","enum":["stocked","low","out"]}},"required":["level"]},"bind":{"action":"pantry_level"}},{"name":"kitchen_status","description":"Tonight's dinner, the week's coverage, and the grocery gap.","script":"kitchen-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"grocery_list","description":"The shopping run: everything low or out.","script":"kitchen-store","inputs":{"type":"object","properties":{}},"bind":{"action":"grocery_list"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"menu.json"},{"source":"workspace","path":"pantry.json"}]},"schedules":[],"workspaceSeed":["menu.json","pantry.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"novel-writing-room","name":"Novel Writing Room","description":"A room for the long haul of a novel. A warm Schrijfmaat (\"writing mate\") logs every writing session, keeps the chapter board honest, and reads drafts like a friend who wants the book to exist — with a live board of words and chapters.","tags":["writing","novel","fiction","drafting","wordcount"],"category":"writing","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"title":{"type":"string","title":"Working title","description":"The novel, as it is known today. Change it any time.","default":"Untitled"},"weeklyWordGoal":{"type":"number","title":"Words per week","description":"The weekly writing rhythm to aim for.","default":5000}},"required":["title","weeklyWordGoal"]},"nameTemplate":"Novel: {{title}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"schrijfmaat","voorman":true}],"toolsets":[],"craftbooks":["copy-review","ebook-compile"],"scripts":{"manuscript-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'manuscript-store',\n description:\n \"Read and update the manuscript record (sessions.json + chapters.json). Call action:'status' for total words, weekly pace, and the chapter board; 'log_session' after every writing session (words written); 'add_chapter' / 'chapter_stage' to keep the board honest; 'outline' to list every chapter.\",\n inputs: {\n action: {\n type: 'string',\n description: \"One of 'status', 'log_session', 'add_chapter', 'chapter_stage', or 'outline'.\",\n required: true,\n },\n words: { type: 'number', description: 'Words written this session (log_session).' },\n note: { type: 'string', description: 'Anything worth remembering.' },\n title: { type: 'string', description: 'Chapter title (add_chapter).' },\n summary: { type: 'string', description: 'One-line chapter summary (add_chapter).' },\n id: { type: 'string', description: \"Chapter id, e.g. 'ch-3' (chapter_stage).\" },\n stage: {\n type: 'string',\n description: \"Target stage: 'outline', 'drafting', 'revised', or 'done' (chapter_stage).\",\n },\n },\n outputs: {\n totalWords: { type: 'number', description: 'Words logged across all sessions.' },\n thisWeekWords: { type: 'number', description: 'Words logged this week (Monday UTC start).' },\n chapters: { type: 'number', description: 'Chapters on the board.' },\n summary: { type: 'string', description: 'The manuscript picture.' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── novel-rules-start ──\n// Pure derivations over logStore-shaped writing sessions (kind 'writing',\n// data {words}) and rosterStore-shaped chapters (stages outline → drafting\n// → revised → done). Weeks start Monday 00:00 UTC; nowIso is a parameter\n// so the rules stay clock-independent and testable.\n\nfunction sessionWords(e) {\n var words = e && e.data ? Number(e.data.words) : NaN;\n return isFinite(words) && words > 0 ? Math.round(words) : 0;\n}\n\nfunction totalWords(events) {\n return events.reduce(function (sum, e) {\n return sum + sessionWords(e);\n }, 0);\n}\n\nfunction weekStartMs(nowIso) {\n var day = new Date(nowIso.slice(0, 10) + 'T00:00:00Z');\n var sinceMonday = (day.getUTCDay() + 6) % 7;\n return day.getTime() - sinceMonday * 86400000;\n}\n\nfunction thisWeekWords(events, nowIso) {\n var start = weekStartMs(nowIso);\n return totalWords(\n events.filter(function (e) {\n return Date.parse(e.at) >= start;\n }),\n );\n}\n\nfunction chapterShape(records) {\n var byStage = { outline: 0, drafting: 0, revised: 0, done: 0 };\n records.forEach(function (r) {\n if (byStage[r.status] !== undefined) byStage[r.status] += 1;\n });\n return byStage;\n}\n\nfunction novelLine(events, records, nowIso, weeklyWordGoal) {\n var total = totalWords(events);\n var week = thisWeekWords(events, nowIso);\n var shape = chapterShape(records);\n var goal = weeklyWordGoal > 0 ? weeklyWordGoal : 0;\n return (\n total +\n ' words logged; ' +\n week +\n (goal > 0 ? ' of ' + goal : '') +\n ' this week. Chapters: ' +\n shape.outline +\n ' outlined, ' +\n shape.drafting +\n ' drafting, ' +\n shape.revised +\n ' revised, ' +\n shape.done +\n ' done.'\n );\n}\n// ── novel-rules-end ──\n\nconst sessions = logStore(gezel.fs, 'sessions.json');\nconst chapters = rosterStore(gezel.fs, 'chapters.json', {\n stages: ['outline', 'drafting', 'revised', 'done'],\n idPrefix: 'ch',\n});\n\nasync function readWeeklyWordGoal(): Promise<number> {\n try {\n const parsed = JSON.parse(await gezel.fs.read('sessions.json')) as { weeklyWordGoal?: unknown };\n const goal = Number(parsed.weeklyWordGoal);\n return Number.isFinite(goal) && goal > 0 ? Math.round(goal) : 0;\n } catch {\n return 0;\n }\n}\n\nif (input.action === 'log_session') {\n const words = Number(input.words);\n if (!Number.isFinite(words) || words <= 0) {\n throw new Error(\"log_session needs a positive 'words' count.\");\n }\n await sessions.append({\n kind: 'writing',\n ...(input.note ? { note: input.note } : {}),\n data: { words: Math.round(words) },\n });\n} else if (input.action === 'add_chapter') {\n if (!input.title) throw new Error(\"add_chapter needs a 'title'.\");\n await chapters.add({\n fields: { title: input.title, ...(input.summary ? { summary: input.summary } : {}) },\n ...(input.note ? { note: input.note } : {}),\n });\n} else if (input.action === 'chapter_stage') {\n if (!input.id || !input.stage) {\n throw new Error(\"chapter_stage needs 'id' (e.g. 'ch-3') and 'stage'.\");\n }\n await chapters.transition(input.id, input.stage, input.note ? { note: input.note } : undefined);\n} else if (input.action !== 'status' && input.action !== 'outline') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, log_session, add_chapter, chapter_stage, or outline.`,\n );\n}\n\nconst events = await sessions.list();\nconst records = await chapters.list();\nconst weeklyWordGoal = await readWeeklyWordGoal();\nconst nowIso = new Date().toISOString();\nconst listing =\n input.action === 'outline'\n ? records\n .map(\n (r) =>\n `${r.id} [${r.status}] ${String(r.fields.title ?? '')}${r.fields.summary ? ` — ${String(r.fields.summary)}` : ''}`,\n )\n .join('\\n') || 'No chapters on the board yet.'\n : '';\n\ngezel.output({\n totalWords: totalWords(events),\n thisWeekWords: thisWeekWords(events, nowIso),\n chapters: records.length,\n summary: listing || novelLine(events, records, nowIso, weeklyWordGoal),\n});\n"},"tools":[{"name":"log_session","description":"Log a writing session (words written) the moment it is reported.","script":"manuscript-store","inputs":{"type":"object","properties":{"words":{"type":"number"},"note":{"type":"string"}},"required":["words"]},"bind":{"action":"log_session"}},{"name":"add_chapter","description":"Put a chapter on the board (starts in outline).","script":"manuscript-store","inputs":{"type":"object","properties":{"title":{"type":"string"},"summary":{"type":"string"}},"required":["title"]},"bind":{"action":"add_chapter"}},{"name":"chapter_stage","description":"Move a chapter between stages: 'outline', 'drafting', 'revised', 'done'.","script":"manuscript-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"stage":{"type":"string"},"note":{"type":"string"}},"required":["id","stage"]},"bind":{"action":"chapter_stage"}},{"name":"novel_status","description":"Total words, this week against the goal, the chapter shape.","script":"manuscript-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"outline","description":"Every chapter with its stage and summary.","script":"manuscript-store","inputs":{"type":"object","properties":{}},"bind":{"action":"outline"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"sessions.json"},{"source":"workspace","path":"chapters.json"}]},"schedules":[],"workspaceSeed":["sessions.json","chapters.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"social-feed","name":"Social Feed","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.","tags":["social","bluesky","x","posts","publishing"],"category":"communication","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.0","releasedAt":"2026-08-12T00:00:00Z","extends":"social-media","meesterManaged":true,"mode":"crew","leadLabel":"Omroeper","params":{"type":"object","properties":{"brand":{"type":"string","title":"Who does this feed speak for?","description":"The name or brand this feed speaks for.","default":"Our brand"},"blueskyHandle":{"type":"string","title":"Bluesky handle","description":"Bluesky handle (e.g. you.bsky.social). Leave empty if unused.","default":""},"xHandle":{"type":"string","title":"X handle","description":"X handle (e.g. @brand). Leave empty if unused.","default":""},"voice":{"type":"string","title":"Tone of voice","description":"One sentence on tone of voice — seeded into voice-guide.md.","default":"Warm, plain, and human — never corporate."},"cadence":{"type":"string","title":"Posting cadence","description":"Target posting cadence (e.g. 3 posts a week).","default":"3 posts a week"}},"required":["brand"]},"nameTemplate":"{{brand}} Social Feed","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"omroeper","voorman":true},{"templateId":"copywriter","voorman":false}],"toolsets":[],"craftbooks":["draft-social-post","social-digest","reception-report"],"scripts":{"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"},"tools":[{"name":"list_posts","description":"See the whole post pipeline: every post with its status, platforms, and dates. Optionally filter by status or platform.","script":"posts-store","inputs":{"type":"object","properties":{"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Only posts currently in this status."},"platform":{"type":"string","description":"Only posts targeting this platform, e.g. 'bluesky'."}}},"bind":{"action":"list"}},{"name":"get_post","description":"Read one post in full: frontmatter, body, each platform variant with its character count (and permalink once live), and media files.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id — its folder name under posts/, e.g. '2026-08-12-launch-teaser'."}},"required":["id"]},"bind":{"action":"get"}},{"name":"create_post","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.","script":"posts-store","inputs":{"type":"object","properties":{"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Initial status for the new post. Defaults to approved."}}},"bind":{"action":"create"}},{"name":"update_post","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.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"fields":{"type":"object","description":"Frontmatter patches, e.g. {\"scheduledFor\": \"2026-08-15T09:00:00Z\", \"campaign\": \"autumn\"}."},"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"Target status (one stage at a time)."}},"required":["id"]},"bind":{"action":"update"}},{"name":"record_publication","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).","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"platform":{"type":"string","description":"The platform that went live, e.g. 'bluesky'."},"permalink":{"type":"string","description":"Public URL of the live post."},"postId":{"type":"string","description":"The platform's own id for the live post."},"corpusRef":{"type":"string","description":"Connector corpus record reference, when one exists."},"publishedAt":{"type":"string","description":"ISO timestamp it went live (defaults to now)."},"markPublished":{"type":"boolean","description":"Force the post to published even with unstamped platforms."}},"required":["id","platform"]},"bind":{"action":"record_publication"}},{"name":"page_set_status","description":"Page-only: move a post between pipeline columns on the board.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."},"status":{"type":"string","enum":["idea","draft","in-review","approved","scheduled","published","archived"],"description":"The column the post was moved to."}},"required":["id","status"]},"bind":{"action":"update"},"reaction":{"gezel":"omroeper","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.","hideSeed":true}},{"name":"page_get_post","description":"Page-only: the detail drawer's read of one post with variants and media.","script":"posts-store","inputs":{"type":"object","properties":{"id":{"type":"string","description":"Post id."}},"required":["id"]},"bind":{"action":"get"}}],"pages":{"entry":"dashboard/index.html","api":1,"reads":[{"source":"workspace","path":"posts","subtree":true},{"source":"workspace","path":"voice-guide.md"},{"source":"artifacts","path":"data","subtree":true}],"tools":["page_set_status","page_get_post"]},"schedules":[{"runMode":"night-shift","craftbook":"social-digest","consent":"ask","overlap":"skip"},{"runMode":"scheduled","cron":"0 15 * * 1","craftbook":"reception-report","consent":"ask","overlap":"skip"}],"workspaceSeed":["voice-guide.md","posts/index.json"],"artifactsSeed":[],"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.","availableVersions":["1.0.0"]}},{"manifest":{"schemaVersion":1,"kind":"project-type","id":"trip-planner","name":"Trip Planner","description":"Plan a trip you'll actually be present for. A Reisleider (\"tour leader\") builds the day-by-day itinerary with slack in it, keeps the packing list checkable on a live board, and helps the journal happen.","tags":["travel","trip","itinerary","packing","journal"],"category":"events","maintainer":{"name":"Gezel"},"license":"MIT","version":"1.0.1","releasedAt":"2026-08-01T00:00:00Z","meesterManaged":false,"params":{"type":"object","properties":{"destination":{"type":"string","title":"Where to?","description":"e.g. 'Kyoto', 'the Scottish Highlands'.","default":"somewhere wonderful"},"start":{"type":"string","title":"Departure date","description":"e.g. 2026-09-12. A rough date is fine.","default":"2026-12-01"}},"required":["destination","start"]},"nameTemplate":"Trip: {{destination}}","aboutTemplate":"about.md","missionTemplate":"mission.md","gezels":[{"templateId":"reisleider","voorman":true}],"toolsets":[],"craftbooks":["pre-departure-countdown"],"scripts":{"trip-store":"import { type InferredInput, defineScript, gezel } from '@bendyline/gezel-sdk';\nimport { logStore, rosterStore } from '@bendyline/gezel-sdk/stores';\n\nexport const meta = defineScript({\n name: 'trip-store',\n description:\n \"Read and update the trip: itinerary + packing (trip.json) and the journal (journal.json). Call action:'status' for the shape of the trip; 'plan_day' with date + plan; 'pack_add' / 'pack_check' for the packing list; 'journal_entry' during the trip.\",\n inputs: {\n action: {\n type: 'string',\n description:\n \"One of 'status', 'plan_day', 'pack_add', 'pack_check', 'journal_entry', or 'itinerary'.\",\n required: true,\n },\n date: { type: 'string', description: 'Day being planned, e.g. 2026-09-14.' },\n plan: { type: 'string', description: \"The day's plan (anchor + options).\" },\n item: { type: 'string', description: 'Packing item name.' },\n id: { type: 'string', description: \"Packing item id, e.g. 'pack-3'.\" },\n note: { type: 'string', description: 'Journal entry text.' },\n },\n outputs: {\n daysPlanned: { type: 'number', description: 'Days with a plan.' },\n unpacked: { type: 'number', description: 'Packing items still unpacked.' },\n summary: { type: 'string', description: 'The trip picture (or the itinerary).' },\n },\n requires: ['workspace.read', 'workspace.write'],\n} as const);\n\nconst input = gezel.input as InferredInput<typeof meta>;\n\n// ── trip-rules-start ──\n// Pure derivations over the trip doc: countdown and itinerary rendering.\n\nfunction daysUntil(dateStr, nowMs) {\n var when = Date.parse(dateStr);\n if (!isFinite(when)) return null;\n return Math.ceil((when - nowMs) / (24 * 60 * 60 * 1000));\n}\n\nfunction renderItinerary(days) {\n var dates = Object.keys(days).sort();\n if (dates.length === 0) return 'No days planned yet.';\n return dates\n .map(function (d) {\n return d + ': ' + days[d];\n })\n .join('\\n');\n}\n\nfunction tripLine(trip, unpackedCount, nowMs) {\n var start = daysUntil(trip.startDate, nowMs);\n var planned = Object.keys(trip.days || {}).length;\n var lead =\n start === null\n ? ''\n : start > 0\n ? start + ' day(s) until departure. '\n : start === 0\n ? 'Departure day! '\n : 'On the road (or home again). ';\n return (\n lead + planned + ' day(s) planned. Packing: ' +\n (unpackedCount === 0 ? 'all packed.' : unpackedCount + ' item(s) still unpacked.')\n );\n}\n// ── trip-rules-end ──\n\nconst TRIP_FILE = 'trip.json';\nconst packing = rosterStore(gezel.fs, 'packing.json', {\n stages: ['unpacked', 'packed'],\n idPrefix: 'pack',\n});\nconst journal = logStore(gezel.fs, 'journal.json');\n\ninterface Trip {\n version: number;\n destination: string;\n startDate: string;\n days: Record<string, string>;\n updatedAt: string;\n}\n\nasync function loadTrip(): Promise<Trip> {\n try {\n const parsed = JSON.parse(await gezel.fs.read(TRIP_FILE)) as Partial<Trip>;\n return {\n version: 1,\n destination: typeof parsed.destination === 'string' ? parsed.destination : '',\n startDate: typeof parsed.startDate === 'string' ? parsed.startDate : '',\n days: parsed.days && typeof parsed.days === 'object' ? (parsed.days as Record<string, string>) : {},\n updatedAt: new Date().toISOString(),\n };\n } catch {\n return { version: 1, destination: '', startDate: '', days: {}, updatedAt: new Date().toISOString() };\n }\n}\n\nasync function saveTrip(trip: Trip): Promise<void> {\n trip.updatedAt = new Date().toISOString();\n await gezel.fs.write(TRIP_FILE, `${JSON.stringify(trip, null, 2)}\\n`);\n}\n\nconst trip = await loadTrip();\n\nif (input.action === 'plan_day') {\n if (!input.date || !input.plan) throw new Error(\"plan_day needs 'date' (YYYY-MM-DD) and 'plan'.\");\n trip.days[input.date.slice(0, 10)] = input.plan;\n await saveTrip(trip);\n} else if (input.action === 'pack_add') {\n if (!input.item) throw new Error(\"pack_add needs 'item'.\");\n await packing.add({ status: 'unpacked', fields: { name: input.item } });\n} else if (input.action === 'pack_check') {\n let id = input.id;\n if (!id && input.item) {\n const wanted = input.item.toLowerCase();\n const matches = (await packing.list()).filter(\n (r) => String(r.fields.name ?? '').toLowerCase() === wanted,\n );\n if (matches.length === 1) id = matches[0]!.id;\n }\n if (!id) throw new Error(\"Pass 'id' (e.g. 'pack-3') or an exact packing 'item'.\");\n await packing.transition(id, 'packed');\n} else if (input.action === 'journal_entry') {\n if (!input.note) throw new Error(\"journal_entry needs 'note'.\");\n await journal.append({ kind: 'entry', note: input.note });\n} else if (input.action !== 'status' && input.action !== 'itinerary') {\n throw new Error(\n `Unknown action '${input.action}'. Use status, plan_day, pack_add, pack_check, journal_entry, or itinerary.`,\n );\n}\n\nconst packItems = await packing.list();\nconst unpacked = packItems.filter((r) => r.status === 'unpacked').length;\n\ngezel.output({\n daysPlanned: Object.keys(trip.days).length,\n unpacked,\n summary:\n input.action === 'itinerary' ? renderItinerary(trip.days) : tripLine(trip, unpacked, Date.now()),\n});\n"},"tools":[{"name":"plan_day","description":"Set a day's plan (one anchor, options, slack).","script":"trip-store","inputs":{"type":"object","properties":{"date":{"type":"string"},"plan":{"type":"string"}},"required":["date","plan"]},"bind":{"action":"plan_day"}},{"name":"pack_add","description":"Add an item to the packing list.","script":"trip-store","inputs":{"type":"object","properties":{"item":{"type":"string"}},"required":["item"]},"bind":{"action":"pack_add"}},{"name":"pack_check","description":"Mark an item packed.","script":"trip-store","inputs":{"type":"object","properties":{"id":{"type":"string"},"item":{"type":"string"}}},"bind":{"action":"pack_check"}},{"name":"journal_entry","description":"Capture a trip moment — a line a day.","script":"trip-store","inputs":{"type":"object","properties":{"note":{"type":"string"}},"required":["note"]},"bind":{"action":"journal_entry"}},{"name":"trip_status","description":"Countdown, days planned, and the packing state.","script":"trip-store","inputs":{"type":"object","properties":{}},"bind":{"action":"status"}},{"name":"itinerary","description":"Read the full day-by-day itinerary.","script":"trip-store","inputs":{"type":"object","properties":{}},"bind":{"action":"itinerary"}},{"name":"mark_packed","description":"The dashboard's packing checkbox.","script":"trip-store","inputs":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},"bind":{"action":"pack_check"}}],"pages":{"entry":"dashboard/index.html","reads":[{"source":"workspace","path":"trip.json"},{"source":"workspace","path":"packing.json"},{"source":"workspace","path":"journal.json"}],"tools":["mark_packed"]},"schedules":[],"workspaceSeed":["trip.json","packing.json","journal.json"],"artifactsSeed":[],"availableVersions":["1.0.1","1.0.0"]}}]}
|