From 9f911f18ced04b7f82ad9a7787ca196d559944da Mon Sep 17 00:00:00 2001 From: Jenny Ferries Date: Tue, 28 Jul 2026 11:39:21 -0700 Subject: [PATCH] ember: add daily-focus-board canvas extension Adds a canvas version of the daily-focus-board alongside the skill, addressing @aaronpowell's review suggestion. The canvas renders the board in the Copilot app and is backed by a JSON state file the assistant reads and writes, so you can mark tasks done, add tasks, log progress, and recap your day from chat -- the file-backed "close the loop" upgrade over the localStorage skill. The skill stays as the zero-install universal fallback for anyone not in the Copilot app (same both-not-either pattern as the-workshop's signals-dashboard). - extensions/daily-focus-board/: extension.mjs (canvas + session wiring) + board-core.mjs (loopback server, JSON state file, mutations, recap) + assets/board.html (file-backed UI) + preview.png + manifests. - plugins/ember: register via x-awesome-copilot.extensions, bump 1.1.0 -> 1.2.0, add a Components row. Note: assets/preview.png is a placeholder pending a real board screenshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb356aa8-0af2-48f3-b3c6-8086c69d5308 --- .../.github/plugin/plugin.json | 19 + .../daily-focus-board/assets/board.html | 294 +++++++++++++++ .../daily-focus-board/assets/preview.png | Bin 0 -> 33770 bytes extensions/daily-focus-board/board-core.mjs | 357 ++++++++++++++++++ extensions/daily-focus-board/extension.mjs | 175 +++++++++ extensions/daily-focus-board/package.json | 18 + plugins/ember/.github/plugin/plugin.json | 9 +- plugins/ember/README.md | 1 + 8 files changed, 871 insertions(+), 2 deletions(-) create mode 100644 extensions/daily-focus-board/.github/plugin/plugin.json create mode 100644 extensions/daily-focus-board/assets/board.html create mode 100644 extensions/daily-focus-board/assets/preview.png create mode 100644 extensions/daily-focus-board/board-core.mjs create mode 100644 extensions/daily-focus-board/extension.mjs create mode 100644 extensions/daily-focus-board/package.json 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 @@ + + + + + +Daily Focus Board Β· let's go πŸ”₯ + + + + + +
+
+
0/0
+
+

Let's go πŸ”₯

+

+

+

+
+ 🎯 Focus mode + πŸŒ™ Reduce motion +
+
+
+ +
show all βœ•
+ +
+ +
+

🧠 Parked thoughts

+

Something pulling at your attention? Park it here so it's out of your head β€” deal with it later, not now.

+
+
+
+ +
+

πŸ”₯ Today's momentum

+

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

+
+ + + diff --git a/extensions/daily-focus-board/assets/preview.png b/extensions/daily-focus-board/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..cc5416c5d44fed4f8a068b8e07624d461cf978cf GIT binary patch literal 33770 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 F{{RO}Z%hCH literal 0 HcmV?d00001 diff --git a/extensions/daily-focus-board/board-core.mjs b/extensions/daily-focus-board/board-core.mjs new file mode 100644 index 00000000..6cf2673c --- /dev/null +++ b/extensions/daily-focus-board/board-core.mjs @@ -0,0 +1,357 @@ +// board-core.mjs β€” SDK-free core for the daily-focus-board canvas extension. +// +// Everything here is independent of @github/copilot-sdk so it can be unit-tested +// headlessly (start a server, hit the API, inspect the state file). extension.mjs +// imports from here and only adds the canvas/session wiring. +// +// The board's single source of truth is a JSON state file. Both the canvas UI +// (via the local HTTP API) and the AI partner (via extension actions) read and +// mutate that same file, so the agent can "mark X done" from chat and summarize +// the day β€” the file-backed "close the loop" upgrade over the localStorage skill. + +import { createServer } from "node:http"; +import { readFile, writeFile, rename, mkdir, stat } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, dirname, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOARD_HTML_PATH = join(__dirname, "assets", "board.html"); +const JSON_HEADERS = { "Content-Type": "application/json" }; + +// --- security --------------------------------------------------------------- + +// Reject a state-mutating POST that a browser marks as cross-site. Mirrors the +// workshop signals-dashboard guard: same-origin fetches from the served page +// carry an Origin equal to the host (allowed); anything else is blocked so a +// random web page can't drive the local board server. +export function isCrossSiteRequest(req) { + const origin = req.headers.origin; + if (origin) { + if (origin === `http://${req.headers.host}`) return false; + if (origin === "null") return true; + if (/^https?:\/\//i.test(origin)) return true; + return false; + } + const site = req.headers["sec-fetch-site"]; + return site === "cross-site" || site === "same-site"; +} + +// Task ids double as object keys and HTML data-attributes, so keep them tight. +const ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const TAG_COLORS = ["new", "deadline", "career"]; +export function validId(s) { return typeof s === "string" && ID_RE.test(s); } +function text(s, max = 2000) { return typeof s === "string" ? s.slice(0, max) : ""; } +function num(v) { + if (v === undefined || v === null || v === "") return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +} +function slug(s) { + return text(s, 64).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48); +} + +// --- state file: normalize, load, atomic write, per-file serialize ---------- + +export function todayKey() { return new Date().toISOString().slice(0, 10); } + +function normalizeTaskDef(t) { + const o = { id: t.id }; + if (t.emoji) o.emoji = text(t.emoji, 8); + o.title = text(t.title, 200) || t.id; + if (t.sub) o.sub = text(t.sub, 200); + if (t.tag) o.tag = text(t.tag, 40); + if (t.tagc && TAG_COLORS.includes(t.tagc)) o.tagc = t.tagc; + if (t.due) o.due = text(t.due, 40); + const goal = num(t.goal); + if (goal !== undefined && goal > 0) { + o.goal = Math.round(goal); + o.start = Math.max(0, Math.round(num(t.start) || 0)); + const inc = num(t.inc); + if (inc !== undefined) o.inc = Math.max(1, Math.round(inc)); + if (t.unit) o.unit = text(t.unit, 20); + } + return o; +} + +// Coerce any parsed JSON into a well-formed board doc, and ensure every task has +// a matching progress entry so the UI and mutations can assume presence. +export function normalize(doc) { + doc = doc && typeof doc === "object" ? doc : {}; + if (typeof doc.name !== "string") doc.name = ""; + if (typeof doc.dateKey !== "string") doc.dateKey = todayKey(); + doc.tasks = Array.isArray(doc.tasks) ? doc.tasks.filter(t => t && validId(t.id)).map(normalizeTaskDef) : []; + + const p = doc.progress && typeof doc.progress === "object" ? doc.progress : {}; + p.counters = p.counters && typeof p.counters === "object" ? p.counters : {}; + p.t = p.t && typeof p.t === "object" ? p.t : {}; + p.day = Array.isArray(p.day) ? p.day : []; + p.brain = Array.isArray(p.brain) ? p.brain : []; + p.focus = validId(p.focus) ? p.focus : null; + p.rm = !!p.rm; + + for (const t of doc.tasks) { + if (typeof t.goal === "number") { + if (typeof p.counters[t.id] !== "number") p.counters[t.id] = t.start || 0; + } else { + const e = p.t[t.id] && typeof p.t[t.id] === "object" ? p.t[t.id] : {}; + if (!["todo", "doing", "done"].includes(e.status)) e.status = "todo"; + e.notes = Array.isArray(e.notes) ? e.notes.filter(n => n && typeof n.txt === "string") : []; + e.carried = !!e.carried; + p.t[t.id] = e; + } + } + doc.progress = p; + return doc; +} + +export async function loadDoc(file) { + let doc = {}; + try { doc = JSON.parse(await readFile(file, "utf-8")); } catch { /* missing/corrupt -> fresh */ } + return normalize(doc); +} + +async function atomicWrite(file, obj) { + const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; + await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8"); + await rename(tmp, file); +} + +// Serialize read-modify-write per state file: the UI and the agent both mutate +// the same file, so two overlapping writes could otherwise drop each other. +const locks = new Map(); +function withLock(file, fn) { + const prev = locks.get(file) || Promise.resolve(); + const run = prev.then(fn, fn); + locks.set(file, run.then(() => {}, () => {})); + return run; +} + +// Apply a mutation under the lock against the freshest on-disk state, stamp, and +// persist. Returns { ok, state } or { error } (fn may return { error } / { id }). +export function mutate(file, fn) { + return withLock(file, async () => { + const doc = await loadDoc(file); + const out = fn(doc) || {}; + if (out.error) return { ok: false, error: out.error }; + doc.updatedAt = new Date().toISOString(); + await atomicWrite(file, doc); + return out.id ? { ok: true, state: doc, id: out.id } : { ok: true, state: doc }; + }); +} + +// --- pure mutation ops (operate on a normalized doc) ------------------------ + +function findTask(doc, id) { return doc.tasks.find(t => t.id === id); } +function isCounter(t) { return t && typeof t.goal === "number"; } +export function statusOf(doc, t) { + if (isCounter(t)) { + const v = doc.progress.counters[t.id] || 0; + return v >= t.goal ? "done" : (v > (t.start || 0) ? "doing" : "todo"); + } + return (doc.progress.t[t.id] || {}).status || "todo"; +} + +export function opStatus(doc, id, status) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + const e = doc.progress.t[id]; + if (status && ["todo", "doing", "done"].includes(status)) e.status = status; + else { const o = ["todo", "doing", "done"]; e.status = o[(o.indexOf(e.status) + 1) % 3]; } + e.carried = false; +} +export function opNote(doc, id, txt) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + txt = text(txt).trim(); + if (!txt) return; + const e = doc.progress.t[id]; + e.notes.push({ t: Date.now(), txt }); + if (e.status === "todo") e.status = "doing"; + e.carried = false; +} +export function opNoteDel(doc, id, idx) { + const e = doc.progress.t[id]; + if (e && Array.isArray(e.notes) && idx >= 0 && idx < e.notes.length) e.notes.splice(idx, 1); +} +export function opCount(doc, id, { value, inc } = {}) { + const t = findTask(doc, id); + if (!t || !isCounter(t)) return; + let v = doc.progress.counters[id] || 0; + const nv = num(value), ni = num(inc); + if (nv !== undefined) v = nv; + else if (ni !== undefined) v = v + ni; + doc.progress.counters[id] = Math.max(0, Math.round(v || 0)); +} +export function opCarry(doc, id, value) { + const t = findTask(doc, id); + if (!t || isCounter(t)) return; + const e = doc.progress.t[id]; + e.carried = typeof value === "boolean" ? value : !e.carried; +} +export function opFocus(doc, id) { + const p = doc.progress; + if (id === null || id === undefined || id === "") { p.focus = null; return; } + if (!findTask(doc, id)) return; + p.focus = p.focus === id ? null : id; +} +export function opDay(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.day.unshift({ t: Date.now(), txt }); } +export function opDayDel(doc, idx) { const d = doc.progress.day; if (idx >= 0 && idx < d.length) d.splice(idx, 1); } +export function opBrain(doc, txt) { txt = text(txt).trim(); if (txt) doc.progress.brain.unshift({ t: Date.now(), txt }); } +export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (idx >= 0 && idx < b.length) b.splice(idx, 1); } +export function opRM(doc, value) { doc.progress.rm = !!value; } +export function opAddTask(doc, task) { + task = task && typeof task === "object" ? task : {}; + let id = validId(task.id) ? task.id : slug(task.title); + if (!validId(id)) return { error: "invalid task id or title" }; + if (findTask(doc, id)) return { error: `task '${id}' already exists` }; + const def = normalizeTaskDef({ ...task, id }); + doc.tasks.push(def); + if (def.goal !== undefined) doc.progress.counters[id] = def.start || 0; + else doc.progress.t[id] = { status: "todo", notes: [], carried: false }; + return { id }; +} + +// --- end-of-day recap (Markdown the agent can journal) ---------------------- + +export function recapMarkdown(doc) { + const p = doc.progress, lines = []; + const carried = t => !isCounter(t) && (p.t[t.id] || {}).carried; + const live = doc.tasks.filter(t => !carried(t)); + const done = live.filter(t => statusOf(doc, t) === "done"); + lines.push(`# Focus board β€” ${doc.name ? doc.name + " Β· " : ""}${doc.dateKey}`); + lines.push(""); + lines.push(`**${done.length}/${live.length} done** for today${live.length - done.length ? `, ${live.length - done.length} still open` : ""}.`); + lines.push(""); + lines.push("## Tasks"); + for (const t of doc.tasks) { + const s = carried(t) ? "β†’ tomorrow" : statusOf(doc, t); + const mark = s === "done" ? "x" : " "; + let line = `- [${mark}] ${t.emoji ? t.emoji + " " : ""}${t.title} β€” _${s}_`; + if (isCounter(t)) line += ` (${p.counters[t.id] || 0}/${t.goal}${t.unit ? " " + t.unit : ""})`; + lines.push(line); + if (!isCounter(t)) for (const n of (p.t[t.id] || {}).notes || []) lines.push(` - ${n.txt}`); + } + if (p.day.length) { lines.push(""); lines.push("## Momentum"); for (const n of [...p.day].reverse()) lines.push(`- ${n.txt}`); } + if (p.brain.length) { lines.push(""); lines.push("## Parked thoughts"); for (const n of p.brain) lines.push(`- ${n.txt}`); } + return lines.join("\n"); +} + +// --- HTTP API --------------------------------------------------------------- + +function readBody(req) { + return new Promise((resolve) => { + let d = "", n = 0; + req.on("data", c => { n += c.length; if (n > 1e6) { req.destroy(); resolve({}); return; } d += c; }); + req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } }); + req.on("error", () => resolve({})); + }); +} + +export async function handleApi(stateFile, op, b) { + switch (op) { + case "status": return mutate(stateFile, doc => opStatus(doc, b.id, b.status)); + case "progress": return mutate(stateFile, doc => opNote(doc, b.id, b.txt)); + case "note-del": return mutate(stateFile, doc => opNoteDel(doc, b.id, Number(b.idx))); + case "count": return mutate(stateFile, doc => opCount(doc, b.id, { value: b.value, inc: b.inc })); + case "carry": return mutate(stateFile, doc => opCarry(doc, b.id, b.value)); + case "focus": return mutate(stateFile, doc => opFocus(doc, b.id)); + case "day": return mutate(stateFile, doc => opDay(doc, b.txt)); + case "day-del": return mutate(stateFile, doc => opDayDel(doc, Number(b.idx))); + case "brain": return mutate(stateFile, doc => opBrain(doc, b.txt)); + case "brain-del": return mutate(stateFile, doc => opBrainDel(doc, Number(b.idx))); + case "rm": return mutate(stateFile, doc => opRM(doc, b.value)); + case "add-task": return mutate(stateFile, doc => opAddTask(doc, b.task || b)); + default: return { ok: false, error: "unknown_op" }; + } +} + +// Start the local board server bound to loopback. Serves the board HTML at / and +// the JSON state + mutation API under /api/. Returns { server, url }. +export async function startServer(stateFile) { + let boardHtml; + try { boardHtml = await readFile(BOARD_HTML_PATH, "utf-8"); } + catch { boardHtml = "

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 |