diff --git a/.codespellrc b/.codespellrc
index 5fdb581ff8..23b68e7184 100644
--- a/.codespellrc
+++ b/.codespellrc
@@ -62,6 +62,8 @@
# Vally/vally - Name of product
+# checkin - config key in the daily-focus-board skill (window.__BOARD__.checkin, state.checkin, data-checkin attribute)
+
# ACI - Azure Container Instances abbreviation
# soruce - intentional misspelling for tests
@@ -70,7 +72,7 @@
# crystalize - valid US spelling variant in mirrored copilot-workshops content
-ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally,ACI,soruce,straightaway,crystalize
+ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB,TE,FillIn,alle,vai,LOD,InOut,pixelX,aNULL,Wee,Sherif,queston,extenions,Vertexes,nin,FO,CAF,Parth,ans,gud,Vally,vally,checkin,ACI,soruce,straightaway,crystalize
# Skip certain files and directories
diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json
index 81dc77fcd9..f2abd63e7d 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -357,6 +357,12 @@
"description": "Essential prompts, instructions, and chat modes for C# and .NET development including testing, documentation, and best practices.",
"version": "1.1.0"
},
+ {
+ "name": "daily-focus-board",
+ "source": "extensions/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"
+ },
{
"name": "database-data-management",
"source": "plugins/database-data-management",
@@ -557,7 +563,7 @@
"name": "ember",
"source": "plugins/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.0.0"
+ "version": "1.2.0"
},
{
"name": "eyeball",
diff --git a/docs/README.plugins.md b/docs/README.plugins.md
index f112ef8393..f7e4f910c2 100644
--- a/docs/README.plugins.md
+++ b/docs/README.plugins.md
@@ -48,7 +48,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t
| [devops-oncall](../plugins/devops-oncall/README.md) | A focused set of prompts, instructions, and a chat mode to help triage incidents and respond quickly with DevOps tools and Azure resources. | 3 items | devops, incident-response, oncall, azure |
| [doublecheck](../plugins/doublecheck/README.md) | Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting. | 2 items | verification, hallucination, fact-check, source-citation, trust, safety |
| [edge-ai-tasks](../plugins/edge-ai-tasks/README.md) | Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai | 2 items | architecture, planning, research, tasks, implementation |
-| [ember](../plugins/ember/README.md) | 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. | 5 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience |
+| [ember](../plugins/ember/README.md) | 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. | 6 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience |
| [eyeball](../plugins/eyeball/README.md) | Document analysis with inline source screenshots. When you ask Copilot to analyze a document, Eyeball generates a Word doc where every factual claim includes a highlighted screenshot from the source material so you can verify it with your own eyes. | 1 items | document-analysis, citation-verification, screenshot, contracts, legal, trust, visual-verification |
| [fastah-ip-geo-tools](../plugins/fastah-ip-geo-tools/README.md) | This plugin is for network operations engineers who wish to tune and publish IP geolocation feeds in RFC 8805 format. It consists of an AI Skill and an associated MCP server that geocodes geolocation place names to real cities for accuracy. | 1 items | geofeed, ip-geolocation, rfc-8805, rfc-9632, network-operations, isp, cloud, hosting, ixp |
| [flowstudio-power-automate](../plugins/flowstudio-power-automate/README.md) | Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes. | 5 items | power-automate, power-platform, flowstudio, mcp, model-context-protocol, cloud-flows, workflow-automation, monitoring, governance |
diff --git a/docs/README.skills.md b/docs/README.skills.md
index d173a9d3c1..6b39c0b041 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -143,6 +143,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [csharp-nunit](../skills/csharp-nunit/SKILL.md) `gh skills install github/awesome-copilot csharp-nunit` | Get best practices for NUnit unit testing, including data-driven tests | None |
| [csharp-tunit](../skills/csharp-tunit/SKILL.md) `gh skills install github/awesome-copilot csharp-tunit` | Get best practices for TUnit unit testing, including data-driven tests | None |
| [csharp-xunit](../skills/csharp-xunit/SKILL.md) `gh skills install github/awesome-copilot csharp-xunit` | Get best practices for XUnit unit testing, including data-driven tests | None |
+| [daily-focus-board](../skills/daily-focus-board/SKILL.md) `gh skills install github/awesome-copilot daily-focus-board` | Spin up a personal, motivating daily focus board that renders in a browser canvas and that the user drives by talking to their AI partner. Tasks track status (to-do → in progress → done) with timestamped progress notes and roll up into a "today's momentum" feed; numeric-goal tasks (pages, pomodoros, reps) render as progress-bar counters. Executive-function / neurodivergent-friendly by design: Focus mode, kind "not today" carryover (no overdue-shaming), a brain-dump box, reduced-motion, and gentle deadline countdowns. Add, reorder, and relabel tasks live, assign Eisenhower priority (Do first / Schedule / Delegate / Later), open with an above/below-the-line check-in and a daily mantra, and save an end-of-day recap. Use when someone wants to plan their day, stay focused, kick off a work session, or track progress. Progress persists in the browser (localStorage). | `assets/board.template.html` `examples` `references/customize.md` `references/neurodivergent-design.md` `references/tutorial.md` `scripts/serve-board.ps1` |
| [daily-prep](../skills/daily-prep/SKILL.md) `gh skills install github/awesome-copilot daily-prep` | Prepare for tomorrow's meetings and tasks. Pulls calendar from Outlook via WorkIQ, cross-references open tasks and workspace context, classifies meetings, detects conflicts and day-fit issues, finds learning and deep-work slots, and generates a structured HTML prep file with productivity recommendations. | None |
| [data-breach-blast-radius](../skills/data-breach-blast-radius/SKILL.md) `gh skills install github/awesome-copilot data-breach-blast-radius` | Pre-breach impact analysis: inventories sensitive data (PII, PHI, PCI-DSS, credentials), traces data flows, scores exposure vectors, and produces a regulatory blast radius report with fine ranges sourced verbatim from GDPR Art. 83, CCPA § 1798.155(a), and HIPAA 45 CFR § 160.404. Cost benchmarks from IBM Cost of a Data Breach Report (annually updated). All citations in references/SOURCES.md for verification. Use when asked: "assess breach impact", "what data could be exposed", "calculate blast radius", "data exposure analysis", "how bad would a breach be", "quantify data risk", "sensitive data inventory", "data flow security audit", "pre-breach assessment", "worst-case breach scenario", "breach readiness", "data risk report", "/data-breach-blast-radius". For any stack handling user data, health records, or financial information. Output labels law-sourced figures (exact) vs heuristic estimates (planning only). Does not replace legal counsel. | `references/SOURCES.md` `references/blast-radius-calculator.md` `references/data-classification.md` `references/hardening-playbook.md` `references/regulatory-impact.md` `references/report-format.md` |
| [datanalysis-credit-risk](../skills/datanalysis-credit-risk/SKILL.md) `gh skills install github/awesome-copilot datanalysis-credit-risk` | Credit risk data cleaning and variable screening pipeline for pre-loan modeling. Use when working with raw credit data that needs quality assessment, missing value analysis, or variable selection before modeling. it covers data loading and formatting, abnormal period filtering, missing rate calculation, high-missing variable removal,low-IV variable filtering, high-PSI variable removal, Null Importance denoising, high-correlation variable removal, and cleaning report generation. Applicable scenarios arecredit risk data cleaning, variable screening, pre-loan modeling preprocessing. | `references/analysis.py` `references/func.py` `scripts/example.py` |
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 0000000000..40202f7a70
--- /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 0000000000..d41474ff97
--- /dev/null
+++ b/extensions/daily-focus-board/assets/board.html
@@ -0,0 +1,299 @@
+
+
+
+
+
+
+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 0000000000..3ece5ce0ec
Binary files /dev/null and b/extensions/daily-focus-board/assets/preview.png differ
diff --git a/extensions/daily-focus-board/board-core.mjs b/extensions/daily-focus-board/board-core.mjs
new file mode 100644
index 0000000000..ccbee089d1
--- /dev/null
+++ b/extensions/daily-focus-board/board-core.mjs
@@ -0,0 +1,440 @@
+// 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";
+import { randomUUID } from "node:crypto";
+
+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"];
+const BOARD_MARKER = "daily-focus-board";
+// "day"/"brain" are the momentum-feed and brain-dump sentinels used in the UI's
+// delete routing, so a task may not claim them as an id — otherwise deleting a
+// task note could splice the shared feed instead.
+const RESERVED_IDS = new Set(["day", "brain"]);
+// Reject reserved sentinels AND any inherited Object.prototype name (toString,
+// constructor, hasOwnProperty, __proto__, ...) via `s in {}` — task ids are used
+// as object keys, so an inherited name could otherwise resolve to a prototype
+// member. With the null-prototype progress maps in normalize(), a task id can
+// never touch the prototype.
+export function validId(s) { return typeof s === "string" && ID_RE.test(s) && !RESERVED_IDS.has(s) && !(s in {}); }
+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" && !Array.isArray(doc) ? 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" && !Array.isArray(doc.progress) ? doc.progress : {};
+ // Null-prototype maps so a task id can never resolve to an inherited member
+ // (e.g. reading p.t["toString"] returning Object.prototype.toString).
+ p.counters = Object.assign(Object.create(null), p.counters && typeof p.counters === "object" ? p.counters : {});
+ p.t = Object.assign(Object.create(null), p.t && typeof p.t === "object" ? p.t : {});
+ p.day = Array.isArray(p.day) ? p.day.filter(n => n && typeof n.txt === "string") : [];
+ p.brain = Array.isArray(p.brain) ? p.brain.filter(n => n && typeof n.txt === "string") : [];
+ 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;
+ doc.kind = BOARD_MARKER;
+ return doc;
+}
+
+export async function loadDoc(file) {
+ let raw;
+ try { raw = await readFile(file, "utf-8"); }
+ catch (e) {
+ if (e && e.code === "ENOENT") return normalize({}); // no file yet -> a fresh board is correct
+ throw e; // EACCES/EBUSY/etc: propagate so a transient read error never overwrites the file
+ }
+ let parsed;
+ try { parsed = JSON.parse(raw); }
+ catch { throw new Error(`state file is not valid JSON (refusing to overwrite): ${file}`); }
+ return normalize(parsed);
+}
+
+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) return { error: `no such task: ${id}` };
+ if (isCounter(t)) return { error: `'${id}' is a counter task; set its value, not a status` };
+ 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) return { error: `no such task: ${id}` };
+ if (isCounter(t)) return { error: `'${id}' is a counter task; it has no progress notes` };
+ txt = text(txt).trim();
+ if (!txt) return { error: "empty note" };
+ 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)) return { error: "no such note" };
+ e.notes.splice(idx, 1);
+}
+export function opCount(doc, id, { value, inc } = {}) {
+ const t = findTask(doc, id);
+ if (!t) return { error: `no such task: ${id}` };
+ if (!isCounter(t)) return { error: `'${id}' is not a counter task` };
+ const nv = num(value), ni = num(inc);
+ if (nv === undefined && ni === undefined) return { error: "count requires a numeric value or inc" };
+ let v = doc.progress.counters[id] || 0;
+ if (nv !== undefined) v = nv; else 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) return { error: `no such task: ${id}` };
+ if (isCounter(t)) return { error: `'${id}' is a counter task and can't be carried` };
+ 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 { error: `no such task: ${id}` };
+ p.focus = p.focus === id ? null : id;
+}
+export function opDay(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty momentum note" }; doc.progress.day.unshift({ t: Date.now(), txt }); }
+export function opDayDel(doc, idx) { const d = doc.progress.day; if (!(idx >= 0 && idx < d.length)) return { error: "no such momentum note" }; d.splice(idx, 1); }
+export function opBrain(doc, txt) { txt = text(txt).trim(); if (!txt) return { error: "empty note" }; doc.progress.brain.unshift({ t: Date.now(), txt }); }
+export function opBrainDel(doc, idx) { const b = doc.progress.brain; if (!(idx >= 0 && idx < b.length)) return { error: "no such parked thought" }; 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" };
+ }
+}
+
+// Pin the Host header to the exact loopback authority we bound. A DNS-rebinding
+// page reaches us under its own hostname (Host: attacker.example:), so an
+// exact match against 127.0.0.1: refuses those requests before any read or
+// write — Origin/Host equality alone can't, since the attacker controls both.
+function isCanonicalHost(req, canonicalHost) {
+ return String(req.headers.host || "").toLowerCase() === String(canonicalHost || "").toLowerCase();
+}
+
+// Per-server capability token, minted at startup and embedded in the page we
+// serve. Only the loopback document we rendered knows it, so a blind
+// cross-origin / rebinding caller can't read state or forge a mutation even if
+// it reaches the socket.
+function hasToken(req, token) {
+ const h = req.headers["x-board-token"];
+ const v = Array.isArray(h) ? h[0] : h;
+ return typeof v === "string" && v.length > 0 && v === token;
+}
+
+// Start the local board server bound to loopback. Serves the board HTML at / and
+// the JSON state + mutation API under /api/. Returns { server, url, token }.
+export async function startServer(stateFile) {
+ const token = randomUUID();
+ let boardHtml;
+ try { boardHtml = (await readFile(BOARD_HTML_PATH, "utf-8")).replace(/__BOARD_TOKEN__/g, token); }
+ catch { boardHtml = "
board.html asset is missing.
"; }
+ let canonicalHost = null;
+
+ const server = createServer(async (req, res) => {
+ try {
+ // Host pin first: reject anything not addressed to the exact loopback
+ // authority we bound (defeats DNS rebinding for reads and writes alike).
+ if (canonicalHost && !isCanonicalHost(req, canonicalHost)) {
+ res.writeHead(403, JSON_HEADERS);
+ res.end(JSON.stringify({ ok: false, error: "bad_host" }));
+ return;
+ }
+ const url = new URL(req.url, `http://${req.headers.host}`);
+
+ // Every /api/* route (read AND write) requires the capability token, so
+ // GET /api/state can't leak task data and POSTs can't be forged. Writes
+ // additionally reject cross-site browser requests.
+ if (url.pathname.startsWith("/api/")) {
+ if (!hasToken(req, token)) {
+ res.writeHead(403, JSON_HEADERS);
+ res.end(JSON.stringify({ ok: false, error: "missing_capability_token" }));
+ return;
+ }
+ if (req.method === "POST" && 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;
+ canonicalHost = `127.0.0.1:${port}`;
+ return { server, url: `http://127.0.0.1:${port}/`, token };
+}
+
+// --- 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
+// date-scoped file in cwd so reopening the board tomorrow starts a fresh day
+// instead of silently reusing today's date/tasks/progress; an explicit path is
+// used as-is (the caller owns its lifecycle). If an existing directory is given,
+// place a date-scoped 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-${todayKey()}.json`); } catch { /* not yet created */ }
+ return file;
+ }
+ return join(process.cwd(), `focus-board-${todayKey()}.json`);
+}
+
+// A parsed value we're willing to treat as an existing board — so we never adopt
+// (and then overwrite on first mutation) an unrelated file the caller pointed at.
+export function looksLikeBoard(o) {
+ return !!o && typeof o === "object" && !Array.isArray(o)
+ && (o.kind === BOARD_MARKER || (Array.isArray(o.tasks) && !!o.progress && typeof o.progress === "object"));
+}
+
+// Create + seed the state file if it doesn't exist yet. If it DOES exist, refuse
+// to use it unless it's clearly one of our boards (a marker or the board schema),
+// so an externally supplied stateFile can't cause us to clobber an unrelated file.
+export async function ensureStateFile(inputPath, seed) {
+ const file = await resolveStateFile(inputPath);
+ if (existsSync(file)) {
+ let parsed;
+ try { parsed = JSON.parse(await readFile(file, "utf-8")); }
+ catch { throw new Error(`refusing to use existing non-JSON file as a focus board: ${file}`); }
+ if (!looksLikeBoard(parsed)) throw new Error(`refusing to use an existing file that is not a daily-focus-board: ${file}`);
+ return 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 0000000000..65cbfce868
--- /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 a date-scoped focus-board-.json in the current working directory, so each day starts a fresh board; pass an explicit path to keep a single ongoing file.",
+ },
+ 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 0000000000..b492f2ae78
--- /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 91c097686a..c8c6684e29 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.0.0",
+ "version": "1.2.0",
"author": {
"name": "jennyf19"
},
@@ -19,9 +19,15 @@
"./agents/ember.md"
],
"skills": [
+ "./skills/daily-focus-board/",
"./skills/from-the-other-side-anitta/",
"./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 68567ba004..ba82a9df2f 100644
--- a/plugins/ember/README.md
+++ b/plugins/ember/README.md
@@ -19,6 +19,8 @@ Ember carries stories from real people who discovered AI partnership. Not as cas
| Type | Name | Description |
|------|------|-------------|
| 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 |
diff --git a/skills/daily-focus-board/SKILL.md b/skills/daily-focus-board/SKILL.md
new file mode 100644
index 0000000000..ca4f9c93f9
--- /dev/null
+++ b/skills/daily-focus-board/SKILL.md
@@ -0,0 +1,169 @@
+---
+name: daily-focus-board
+description: 'Spin up a personal, motivating daily focus board that renders in a browser canvas and that the user drives by talking to their AI partner. Tasks track status (to-do → in progress → done) with timestamped progress notes and roll up into a "today''s momentum" feed; numeric-goal tasks (pages, pomodoros, reps) render as progress-bar counters. Executive-function / neurodivergent-friendly by design: Focus mode, kind "not today" carryover (no overdue-shaming), a brain-dump box, reduced-motion, and gentle deadline countdowns. Add, reorder, and relabel tasks live, assign Eisenhower priority (Do first / Schedule / Delegate / Later), open with an above/below-the-line check-in and a daily mantra, and save an end-of-day recap. Use when someone wants to plan their day, stay focused, kick off a work session, or track progress. Progress persists in the browser (localStorage).'
+---
+
+# Daily Focus Board
+
+A warm, visual "let's go" board for a person's day — rendered from a self-contained HTML
+template, opened in a browser (ideally a side-panel **canvas** in the GitHub Copilot app), and
+kept current *by conversation*: the human tells you what they finished, you update the board.
+
+This is Ember partnership in daily practice: not a static to-do list, a thing you run *with*
+your AI. Keep it light, encouraging, and honest — celebrate real progress, don't inflate it.
+
+## When to use it
+
+Trigger when the user wants to: plan today, "get organized / stay focused," start a work
+session, or track progress on a set of tasks they list. If they just mention a pile of things
+to do, offer the board.
+
+## How to build it (3 steps)
+
+**1. Gather the tasks.** Ask for (or lift from what they already said) their handful of tasks
+for the day. For each, capture: a short title, an optional emoji, an optional one-line
+sub-note, and an optional tag. If a task is a *count toward a number* (steps, pages,
+pomodoros, reps), make it a **counter** with a numeric `goal`. Keep it to ~4–9 items — a focus
+board, not a backlog.
+
+**2. Generate the board.** Copy `assets/board.template.html` to a working file (e.g.
+`focus-board.html` in a scratch/working dir — NOT into a source repo unless asked). In the copy,
+find this line near the top:
+
+```html
+
+```
+
+Replace `null` with the config object. **Inject it as JSON with `<` escaped** so a task's text
+can never break out of the `
+
+
+
+
+
+
+
0/0
+
+
Let's go 🔥
+
+
+
+
+
+
+
+
+
+
+
+
+ Arriving today:
+
+
+
+
+
Just notice where you're arriving — no wrong answer.
+
+ today's mantra
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Do first important & urgent — do it now.
+
Schedule important, not urgent — plan a time. (The good, non-frantic work lives here.)
+
Delegate urgent, not important — hand it off, automate, or shrink it.
+
Later not urgent, not important — park it, or kindly let it go.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🧠 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.
+
+
+
+
+
+
🌙 End of day
+
Save the day's progress — keep the recap, or paste it to Ember to journal today and plan tomorrow.
+
+
+
+
+
+
+
+
progress saves automatically in this browser · 🔥 built with Ember