diff --git a/.codespellrc b/.codespellrc
index 5fdb581f..23b68e71 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 bfb17933..8db6da7a 100644
--- a/.github/plugin/marketplace.json
+++ b/.github/plugin/marketplace.json
@@ -557,7 +557,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 7a19d6ea..d30b7dc5 100644
--- a/docs/README.plugins.md
+++ b/docs/README.plugins.md
@@ -56,7 +56,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t
| [diagram-viewer](../plugins/diagram-viewer/README.md) | Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas. | 1 items | architecture-mapping, canvas-navigation, exploratory-analysis, interactive-diagrams, node-drilldown, relationship-visualization |
| [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. | 7 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 |
| [feedback-themes](../plugins/feedback-themes/README.md) | Explore grouped customer feedback signals by impact and drill into a theme to guide product next steps. | 1 items | customer-feedback, impact-prioritization, product-insights, signal-grouping, theme-analysis, trend-discovery |
diff --git a/docs/README.skills.md b/docs/README.skills.md
index d59aadaf..7098af2e 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -145,6 +145,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/eng/extension-plugin-ownership.mjs b/eng/extension-plugin-ownership.mjs
new file mode 100644
index 00000000..3a226f97
--- /dev/null
+++ b/eng/extension-plugin-ownership.mjs
@@ -0,0 +1,81 @@
+import fs from "fs";
+import path from "path";
+
+const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot";
+
+function extensionIdFromReference(reference) {
+ if (typeof reference !== "string" || !reference.startsWith("./extensions/")) {
+ return null;
+ }
+
+ return reference.replace(/^\.\/extensions\//, "").replace(/\/$/, "");
+}
+
+export function buildExtensionPluginOwners(pluginEntries) {
+ const owners = new Map();
+ const sortedEntries = [...pluginEntries].sort((a, b) =>
+ a.directoryName.localeCompare(b.directoryName)
+ );
+
+ for (const { directoryName, manifest } of sortedEntries) {
+ const pluginName =
+ typeof manifest?.name === "string" && manifest.name.trim()
+ ? manifest.name.trim()
+ : directoryName;
+ const extensionIds = new Set([directoryName]);
+ const references =
+ manifest?.extensions?.[AWESOME_COPILOT_NAMESPACE]?.extensions;
+
+ if (Array.isArray(references)) {
+ for (const reference of references) {
+ const extensionId = extensionIdFromReference(reference);
+ if (extensionId) {
+ extensionIds.add(extensionId);
+ }
+ }
+ }
+
+ for (const extensionId of extensionIds) {
+ const pluginNames = owners.get(extensionId) ?? [];
+ if (!pluginNames.includes(pluginName)) {
+ pluginNames.push(pluginName);
+ }
+ owners.set(extensionId, pluginNames);
+ }
+ }
+
+ return owners;
+}
+
+export function readExtensionPluginOwners(pluginsDir) {
+ if (!fs.existsSync(pluginsDir)) {
+ return new Map();
+ }
+
+ const pluginEntries = fs
+ .readdirSync(pluginsDir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => {
+ const manifestPath = path.join(pluginsDir, entry.name, "plugin.json");
+ if (!fs.existsSync(manifestPath)) {
+ return null;
+ }
+
+ return {
+ directoryName: entry.name,
+ manifest: JSON.parse(fs.readFileSync(manifestPath, "utf-8")),
+ };
+ })
+ .filter(Boolean);
+
+ return buildExtensionPluginOwners(pluginEntries);
+}
+
+export function resolveExtensionPluginName(extensionId, owners) {
+ const pluginNames = owners.get(extensionId) ?? [];
+ return (
+ pluginNames.find((pluginName) => pluginName === extensionId) ??
+ pluginNames[0] ??
+ extensionId
+ );
+}
diff --git a/eng/extension-plugin-ownership.test.mjs b/eng/extension-plugin-ownership.test.mjs
new file mode 100644
index 00000000..6f226631
--- /dev/null
+++ b/eng/extension-plugin-ownership.test.mjs
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ buildExtensionPluginOwners,
+ resolveExtensionPluginName,
+} from "./extension-plugin-ownership.mjs";
+
+const namespace = "com.github.awesome-copilot";
+
+test("resolves an extension bundled only by its parent plugin", () => {
+ const owners = buildExtensionPluginOwners([
+ {
+ directoryName: "ember",
+ manifest: {
+ name: "ember",
+ extensions: {
+ [namespace]: {
+ extensions: ["./extensions/daily-focus-board"],
+ },
+ },
+ },
+ },
+ ]);
+
+ assert.equal(
+ resolveExtensionPluginName("daily-focus-board", owners),
+ "ember"
+ );
+});
+
+test("prefers a same-named standalone plugin over another owner", () => {
+ const owners = buildExtensionPluginOwners([
+ {
+ directoryName: "parent-plugin",
+ manifest: {
+ name: "parent-plugin",
+ extensions: {
+ [namespace]: {
+ extensions: ["./extensions/daily-focus-board/"],
+ },
+ },
+ },
+ },
+ {
+ directoryName: "daily-focus-board",
+ manifest: { name: "daily-focus-board" },
+ },
+ ]);
+
+ assert.equal(
+ resolveExtensionPluginName("daily-focus-board", owners),
+ "daily-focus-board"
+ );
+});
+
+test("preserves the extension name when no plugin owns it", () => {
+ assert.equal(
+ resolveExtensionPluginName("daily-focus-board", new Map()),
+ "daily-focus-board"
+ );
+});
diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs
index 93721cc0..21aeddd4 100755
--- a/eng/generate-website-data.mjs
+++ b/eng/generate-website-data.mjs
@@ -26,6 +26,10 @@ import {
parseYamlFile,
} from "./yaml-parser.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
+import {
+ readExtensionPluginOwners,
+ resolveExtensionPluginName,
+} from "./extension-plugin-ownership.mjs";
const __filename = fileURLToPath(import.meta.url);
@@ -1177,6 +1181,7 @@ function generateCanvasManifest(gitDates, commitSha) {
return { items: [], filters: { keywords: [] } };
}
+ const extensionPluginOwners = readExtensionPluginOwners(PLUGINS_DIR);
const extensionDirs = fs
.readdirSync(EXTENSIONS_DIR, { withFileTypes: true })
.filter((entry) => {
@@ -1212,6 +1217,7 @@ function generateCanvasManifest(gitDates, commitSha) {
normalizeText(packageJson.description, "Canvas extension")
);
const extensionName = normalizeText(pluginJson.name, normalizeText(packageJson.name, dir.name));
+ const pluginName = resolveExtensionPluginName(dir.name, extensionPluginOwners);
const extensionVersion = normalizeText(pluginJson.version, normalizeText(packageJson.version, "1.0.0"));
const readmeFile = fs.existsSync(path.join(extensionDir, "README.md"))
? `${relPath}/README.md`
@@ -1230,7 +1236,7 @@ function generateCanvasManifest(gitDates, commitSha) {
/\\/g,
"/"
)}`;
- const installCommand = `copilot plugin install ${extensionName}@awesome-copilot`;
+ const installCommand = `copilot plugin install ${pluginName}@awesome-copilot`;
for (const canvas of canvasEntries) {
const canvasId = normalizeText(canvas.id, dir.name);
@@ -1241,7 +1247,7 @@ function generateCanvasManifest(gitDates, commitSha) {
canvasId,
extensionId: dir.name,
extensionName,
- pluginName: extensionName,
+ pluginName,
name: canvasName,
version: extensionVersion,
readmeFile,
diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs
index 38d0304a..1c01eaff 100755
--- a/eng/validate-plugins.mjs
+++ b/eng/validate-plugins.mjs
@@ -2,6 +2,7 @@
import fs from "fs";
import path from "path";
+import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
import { validateLicenseField } from "./lib/license.mjs";
@@ -334,7 +335,11 @@ function validateExtensionScreenshotPath(extensionDir, pathValue, fieldName, err
}
// Main validation function
-function validatePlugins() {
+export function isReusableExtensionRegistered(extensionName, pluginDirectoryNames, referencedExtensionNames) {
+ return pluginDirectoryNames.has(extensionName) || referencedExtensionNames.has(extensionName);
+}
+
+export function validatePlugins() {
const pluginDirs = fs.existsSync(PLUGINS_DIR)
? fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
@@ -350,6 +355,8 @@ function validatePlugins() {
let hasErrors = false;
const seenNames = new Set();
const localPluginNames = [];
+ const pluginDirectoryNames = new Set(pluginDirs);
+ const referencedExtensionNames = new Set();
for (const dir of pluginDirs) {
console.log(`Validating ${dir}...`);
@@ -377,12 +384,20 @@ function validatePlugins() {
localPluginNames.push(plugin.name);
}
}
+
+ const extensionReferences = plugin?.extensions?.[AWESOME_COPILOT_NAMESPACE]?.extensions;
+ if (Array.isArray(extensionReferences)) {
+ for (const reference of extensionReferences) {
+ if (typeof reference === "string" && reference.startsWith("./extensions/")) {
+ referencedExtensionNames.add(reference.replace(/^\.\/extensions\//, "").replace(/\/$/, ""));
+ }
+ }
+ }
}
for (const dir of getExtensionFolderNames()) {
- const pluginJsonPath = path.join(PLUGINS_DIR, dir, "plugin.json");
- if (!fs.existsSync(pluginJsonPath)) {
- console.error(`❌ extension ${dir}: missing plugin manifest at plugins/${dir}/plugin.json`);
+ if (!isReusableExtensionRegistered(dir, pluginDirectoryNames, referencedExtensionNames)) {
+ console.error(`❌ extension ${dir}: must be referenced by a plugin or have a standalone manifest at plugins/${dir}/plugin.json`);
hasErrors = true;
}
}
@@ -410,15 +425,16 @@ function validatePlugins() {
return !hasErrors;
}
-// Run validation
-try {
- const isValid = validatePlugins();
- if (!isValid) {
- console.error("\n❌ Plugin validation failed");
+if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+ try {
+ const isValid = validatePlugins();
+ if (!isValid) {
+ console.error("\n❌ Plugin validation failed");
+ process.exit(1);
+ }
+ console.log("\n🎉 Plugin validation passed");
+ } catch (error) {
+ console.error(`Error during validation: ${error.message}`);
process.exit(1);
}
- console.log("\n🎉 Plugin validation passed");
-} catch (error) {
- console.error(`Error during validation: ${error.message}`);
- process.exit(1);
}
diff --git a/eng/validate-plugins.test.mjs b/eng/validate-plugins.test.mjs
new file mode 100644
index 00000000..e1d2fd3d
--- /dev/null
+++ b/eng/validate-plugins.test.mjs
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { isReusableExtensionRegistered } from "./validate-plugins.mjs";
+
+test("accepts a reusable extension bundled only by a parent plugin", () => {
+ assert.equal(
+ isReusableExtensionRegistered(
+ "daily-focus-board",
+ new Set(["ember"]),
+ new Set(["daily-focus-board"])
+ ),
+ true
+ );
+});
+
+test("accepts a same-named standalone extension plugin", () => {
+ assert.equal(
+ isReusableExtensionRegistered(
+ "daily-focus-board",
+ new Set(["daily-focus-board"]),
+ new Set()
+ ),
+ true
+ );
+});
+
+test("rejects an orphaned reusable extension", () => {
+ assert.equal(
+ isReusableExtensionRegistered(
+ "daily-focus-board",
+ new Set(["ember"]),
+ new Set()
+ ),
+ false
+ );
+});
diff --git a/extensions/daily-focus-board/assets/board.html b/extensions/daily-focus-board/assets/board.html
new file mode 100644
index 00000000..d41474ff
--- /dev/null
+++ b/extensions/daily-focus-board/assets/board.html
@@ -0,0 +1,299 @@
+
+
+
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.
"; } + 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 00000000..65cbfce8 --- /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-Just notice where you're arriving — no wrong answer.
+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.
+ + +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
+