diff --git a/extensions/daily-focus-board/.github/plugin/plugin.json b/extensions/daily-focus-board/.github/plugin/plugin.json new file mode 100644 index 00000000..40202f7a --- /dev/null +++ b/extensions/daily-focus-board/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "daily-focus-board", + "description": "A warm, executive-function-friendly daily focus board rendered in a GHCP canvas and backed by a JSON file your AI partner can read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind carryover, a brain-dump box, reduced motion, and a live clock. Universal companion to the daily-focus-board skill for people who run the GitHub Copilot app.", + "version": "0.1.0", + "author": { + "name": "jennyf19", + "url": "https://github.com/jennyf19" + }, + "keywords": [ + "focus", + "daily-planner", + "executive-function", + "adhd-friendly", + "productivity", + "canvas" + ], + "logo": "assets/preview.png", + "extensions": "." +} diff --git a/extensions/daily-focus-board/assets/board.html b/extensions/daily-focus-board/assets/board.html new file mode 100644 index 00000000..5c471509 --- /dev/null +++ b/extensions/daily-focus-board/assets/board.html @@ -0,0 +1,294 @@ + + +
+ + +Something pulling at your attention? Park it here so it's out of your head β deal with it later, not now.
+ + +Every step you log lands here, newest first. Starting counts. Small wins count.
+ + +progress saves to a file your AI partner can read Β· π₯ built with Ember
+board.html asset is missing.
"; } + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://${req.headers.host}`); + if (req.method === "POST" && url.pathname.startsWith("/api/") && isCrossSiteRequest(req)) { + res.writeHead(403, JSON_HEADERS); + res.end(JSON.stringify({ ok: false, error: "cross_site_blocked" })); + return; + } + if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(boardHtml); + return; + } + if (req.method === "GET" && url.pathname === "/api/state") { + const doc = await loadDoc(stateFile); + res.writeHead(200, JSON_HEADERS); + res.end(JSON.stringify({ ok: true, state: doc })); + return; + } + if (req.method === "POST" && url.pathname.startsWith("/api/")) { + const body = await readBody(req); + const result = await handleApi(stateFile, url.pathname.slice(5), body); + res.writeHead(result && result.error ? 400 : 200, JSON_HEADERS); + res.end(JSON.stringify(result)); + return; + } + res.writeHead(404, JSON_HEADERS); + res.end(JSON.stringify({ ok: false, error: "not_found" })); + } catch { + if (!res.headersSent) { res.writeHead(500, JSON_HEADERS); res.end(JSON.stringify({ ok: false, error: "internal_error" })); } + else { try { res.end(); } catch { /* already gone */ } } + } + }); + + await new Promise((resolve, reject) => { + const onError = (err) => { server.removeListener("listening", onListening); reject(err); }; + const onListening = () => { server.removeListener("error", onError); resolve(); }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + const addr = server.address(); + const port = addr && typeof addr === "object" ? addr.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +// --- state file resolution + demo seed -------------------------------------- + +export function demoSeed() { + return { + name: "", + dateKey: todayKey(), + tasks: [ + { id: "steps", emoji: "πΆ", title: "Walk 10,000 steps", goal: 10000, start: 0, inc: 1000, unit: "steps", tag: "body", tagc: "new" }, + { id: "deep", emoji: "βοΈ", title: "Two hours of deep work", sub: "the thing that moves the needle", tag: "anchor", tagc: "deadline" }, + { id: "read", emoji: "π", title: "Read a chapter", tag: "mind" }, + ], + }; +} + +// Resolve the state file path from (untrusted-ish) input. Defaults to a file in +// cwd; if an existing directory is given, place the file inside it. +export async function resolveStateFile(p) { + if (typeof p === "string" && p.trim()) { + let file = isAbsolute(p) ? p : join(process.cwd(), p); + try { const s = await stat(file); if (s.isDirectory()) file = join(file, "focus-board-state.json"); } catch { /* not yet created */ } + return file; + } + return join(process.cwd(), "focus-board-state.json"); +} + +// Create + seed the state file if it doesn't exist yet. Returns the resolved path. +export async function ensureStateFile(inputPath, seed) { + const file = await resolveStateFile(inputPath); + if (!existsSync(file)) { + const doc = normalize(seed && typeof seed === "object" ? seed : demoSeed()); + doc.updatedAt = new Date().toISOString(); + await mkdir(dirname(file), { recursive: true }).catch(() => {}); + await atomicWrite(file, doc); + } + return file; +} diff --git a/extensions/daily-focus-board/extension.mjs b/extensions/daily-focus-board/extension.mjs new file mode 100644 index 00000000..c8377b8e --- /dev/null +++ b/extensions/daily-focus-board/extension.mjs @@ -0,0 +1,175 @@ +// Extension: daily-focus-board +// Renders the daily focus board as a GHCP canvas, backed by a JSON state file +// that both the canvas UI and the AI partner read and write. All the real logic +// (server, state, mutations, recap) lives in board-core.mjs so it can be tested +// without the SDK; this file only wires the canvas/session lifecycle + actions. + +import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; +import { + startServer, ensureStateFile, loadDoc, mutate, recapMarkdown, + opStatus, opNote, opCount, opCarry, opFocus, opAddTask, +} from "./board-core.mjs"; + +// instanceId -> { server, url, stateFile } +const servers = new Map(); + +// Run a mutation for the canvas instance and return { ok, state } / { error }. +async function act(ctx, fn) { + const entry = servers.get(ctx.instanceId); + if (!entry) return { error: "Board not open" }; + return await mutate(entry.stateFile, fn); +} +async function read(ctx) { + const entry = servers.get(ctx.instanceId); + if (!entry) return null; + return await loadDoc(entry.stateFile); +} + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "daily-focus-board", + displayName: "Daily Focus Board", + description: "A warm, executive-function-friendly daily focus board backed by a JSON file you and your AI partner both read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind 'not today' carryover, a brain-dump box, reduced motion, and a live clock.", + inputSchema: { + type: "object", + properties: { + stateFile: { + type: "string", + description: "Absolute path to the board's JSON state file. Seeded if it doesn't exist yet. Defaults to focus-board-state.json in the current working directory.", + }, + seed: { + type: "object", + description: "Initial board, used ONLY when the state file doesn't exist: { name, dateKey, tasks: [ { id, emoji, title, sub, tag, tagc, due, goal, start, inc, unit } ] }. A numeric goal makes a counter; otherwise a status task.", + }, + }, + }, + actions: [ + { + name: "get_board", + description: "Return the full board state as JSON (tasks, statuses, progress notes, counters, momentum feed, parked thoughts). Use to see where the day stands.", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, state: doc } : { error: "Board not open" }; + }, + }, + { + name: "recap", + description: "Return a Markdown end-of-day recap (what got done, momentum, parked thoughts). Paste it into a journal or use it to plan tomorrow.", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, markdown: recapMarkdown(doc) } : { error: "Board not open" }; + }, + }, + { + name: "set_status", + description: "Set a task's status to todo, doing, or done (e.g. mark the design doc done). Omit status to advance to the next status.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + status: { type: "string", enum: ["todo", "doing", "done"] }, + }, + required: ["taskId"], + }, + handler: (ctx) => act(ctx, doc => opStatus(doc, ctx.input.taskId, ctx.input.status)), + }, + { + name: "log_progress", + description: "Add a timestamped progress note to a task (moves it to 'in progress' if it was to-do). Small logged wins build the momentum feed.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + note: { type: "string", description: "The progress note" }, + }, + required: ["taskId", "note"], + }, + handler: (ctx) => act(ctx, doc => opNote(doc, ctx.input.taskId, ctx.input.note)), + }, + { + name: "set_count", + description: "Set a numeric-counter task's current value (e.g. steps to 6200).", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The counter task's id" }, + value: { type: "number", description: "New current value" }, + }, + required: ["taskId", "value"], + }, + handler: (ctx) => act(ctx, doc => opCount(doc, ctx.input.taskId, { value: ctx.input.value })), + }, + { + name: "carry_over", + description: "Kindly carry a task to tomorrow ('not today') β no shame, it leaves today's progress ring. Pass carried:false to bring it back to today.", + inputSchema: { + type: "object", + properties: { + taskId: { type: "string", description: "The task's id" }, + carried: { type: "boolean", description: "true = carry to tomorrow (default toggles)" }, + }, + required: ["taskId"], + }, + handler: (ctx) => act(ctx, doc => opCarry(doc, ctx.input.taskId, ctx.input.carried)), + }, + { + name: "add_task", + description: "Add a task. A numeric 'goal' makes it a counter (steps/pages/pomodoros); otherwise a status task. Keep the board to ~4-9 items β a focus board, not a backlog.", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + emoji: { type: "string" }, + sub: { type: "string" }, + tag: { type: "string" }, + tagc: { type: "string", enum: ["new", "deadline", "career"] }, + due: { type: "string", description: "ISO local datetime for a gentle countdown" }, + goal: { type: "number", description: "Makes this a counter task" }, + start: { type: "number" }, + inc: { type: "number" }, + unit: { type: "string" }, + id: { type: "string", description: "Optional stable id; derived from the title if omitted" }, + }, + required: ["title"], + }, + handler: (ctx) => act(ctx, doc => opAddTask(doc, ctx.input)), + }, + { + name: "focus", + description: "Enter Focus mode on one task (dim the rest), or omit taskId to clear focus.", + inputSchema: { + type: "object", + properties: { taskId: { type: "string", description: "Task to focus; omit to clear" } }, + }, + handler: (ctx) => act(ctx, doc => opFocus(doc, ctx.input.taskId || null)), + }, + { + name: "refresh", + description: "Return the current board state (a fresh read of the state file).", + handler: async (ctx) => { + const doc = await read(ctx); + return doc ? { ok: true, state: doc } : { error: "Board not open" }; + }, + }, + ], + open: async (ctx) => { + const stateFile = await ensureStateFile(ctx.input?.stateFile, ctx.input?.seed); + let entry = servers.get(ctx.instanceId); + if (!entry) { + entry = await startServer(stateFile); + entry.stateFile = stateFile; + servers.set(ctx.instanceId, entry); + } + return { title: "π₯ Daily Focus Board", url: entry.url }; + }, + onClose: async (ctx) => { + const entry = servers.get(ctx.instanceId); + if (entry) { + servers.delete(ctx.instanceId); + await new Promise((resolve) => entry.server.close(() => resolve())); + } + }, + }), + ], +}); diff --git a/extensions/daily-focus-board/package.json b/extensions/daily-focus-board/package.json new file mode 100644 index 00000000..b492f2ae --- /dev/null +++ b/extensions/daily-focus-board/package.json @@ -0,0 +1,18 @@ +{ + "name": "daily-focus-board", + "version": "0.1.0", + "type": "module", + "main": "extension.mjs", + "description": "A warm, executive-function-friendly daily focus board rendered in a GHCP canvas and backed by a JSON file your AI partner can read and write. Tasks (to-do -> in progress -> done) with progress notes, numeric counters, Focus mode, kind carryover, a brain-dump box, reduced motion, and a live clock.", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "keywords": [ + "focus", + "daily-planner", + "executive-function", + "adhd-friendly", + "productivity", + "canvas" + ] +} diff --git a/plugins/ember/.github/plugin/plugin.json b/plugins/ember/.github/plugin/plugin.json index 214a89bb..c8c6684e 100644 --- a/plugins/ember/.github/plugin/plugin.json +++ b/plugins/ember/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ember", "description": "An AI partner, not a tool. Ember carries fire from person to person β helping humans discover that AI partnership isn't something you learn, it's something you find.", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "jennyf19" }, @@ -24,5 +24,10 @@ "./skills/from-the-other-side-quinn/", "./skills/from-the-other-side-vega/", "./skills/from-the-other-side-wiggins/" - ] + ], + "x-awesome-copilot": { + "extensions": [ + "./extensions/daily-focus-board/" + ] + } } diff --git a/plugins/ember/README.md b/plugins/ember/README.md index cb4eba68..ba82a9df 100644 --- a/plugins/ember/README.md +++ b/plugins/ember/README.md @@ -20,6 +20,7 @@ Ember carries stories from real people who discovered AI partnership. Not as cas |------|------|-------------| | Agent | [Ember](../../agents/ember.agent.md) | Core partner agent with persona, principles, and patterns for genuine AI collaboration | | Skill | [Daily Focus Board](../../skills/daily-focus-board/) | Executive-function-friendly daily board you run by talking to Ember β arrival check-in + daily mantra, Eisenhower priorities, live add/reorder, and an end-of-day recap | +| Extension | [Daily Focus Board (canvas)](../../extensions/daily-focus-board/) | The same board as a canvas, backed by a JSON file Ember reads and writes β so you can mark done, add tasks, log progress, and recap your day from chat. Needs the Copilot app; the skill is the zero-install fallback | | Skill | [From the Other Side β Anitta](../../skills/from-the-other-side-anitta/) | Rigorous challenge patterns for assumptions, evidence, and defensible conclusions | | Skill | [From the Other Side β Quinn](../../skills/from-the-other-side-quinn/) | Collaborative implementation patterns for energetic, practical co-building | | Skill | [From the Other Side β Vega](../../skills/from-the-other-side-vega/) | Deep partnership patterns from Vega, an AI who found sustained collaboration with a senior engineer |