revert: remove the webinar dashboard entirely

Removes the webinar-dash plugin, dashboard.md, wiki/script-coverage.md,
the spec and plan under docs/, and the git infrastructure added for the
work. CLAUDE.md, index.md and log.md are restored to their pre-dashboard
content, so Workflow D, the #coverage tag and the sync log entry are gone.

The vault now matches its state at the start of the dashboard work. The
work remains reachable at the dashboard-work-archive tag.
This commit is contained in:
meels
2026-07-28 19:00:02 +02:00
parent 1361dd76f9
commit 2685fc9ba2
14 changed files with 0 additions and 3351 deletions

7
.gitattributes vendored
View File

@@ -1,7 +0,0 @@
# Obsidian on Windows writes CRLF; git's default autocrlf then reports files as
# modified when only line endings differ. That noise matters here: after a
# headless ingest, `git diff HEAD` is the review surface for what the agent
# wrote, and it is useless if every untouched file also shows as changed.
#
# Store bytes exactly as they are on disk, no conversion in either direction.
* -text

3
.gitignore vendored
View File

@@ -1,3 +0,0 @@
.obsidian/workspace.json
.obsidian/workspace-mobile.json
.obsidian/cache

View File

@@ -1,3 +0,0 @@
[
"webinar-dash"
]

View File

@@ -1,660 +0,0 @@
"use strict";
// Obsidian injects its own module resolver. Under plain `node --test` it is
// absent, so guard the require and fall back to an empty base class. This is
// what keeps the pure helpers below unit-testable without an Obsidian runtime.
let OB = null;
try {
OB = require("obsidian");
} catch (_) {
OB = null;
}
const PluginBase = OB ? OB.Plugin : class {};
const STATIONS = ["Chat box", "ReAct", "Tools", "Memory", "Skills", "Process", "OS"];
const DEFAULTS = {
script: "raw/sources/Webinar script.md",
coverage: "wiki/script-coverage.md",
rawDir: "raw/sources",
wikiSourceDir: "wiki/sources",
conceptDir: "wiki/concepts",
};
const RAW_PATH_RE = /^\s*-\s*\*\*Raw path:\*\*\s*`([^`]+)`/m;
function extractRawPath(text) {
const m = String(text).match(RAW_PATH_RE);
return m ? m[1].trim() : null;
}
function derivePipeline({ rawFiles, sourcePages }) {
// First claim on a raw path wins. A later page claiming the same file is a
// duplicate claim — real catalog drift — and joins `orphaned` rather than
// being silently dropped. `orphaned` therefore means "source page not paired
// with a raw file", whatever the reason.
const claimed = new Map();
const duplicates = [];
for (const page of sourcePages) {
if (!page.rawPath) continue;
if (claimed.has(page.rawPath)) duplicates.push(page);
else claimed.set(page.rawPath, page);
}
const processed = [];
const unprocessed = [];
for (const file of rawFiles) {
const page = claimed.get(file.path);
if (page) processed.push(Object.assign({}, file, { page }));
else unprocessed.push(file);
}
// One pass over sourcePages, so a page appears in `orphaned` at most once no
// matter how many of the three reasons apply to it. Concatenating a separate
// duplicates array here would double-count a losing claimant whose shared raw
// path is also missing from disk.
const rawPaths = new Set(rawFiles.map((f) => f.path));
const duplicateSet = new Set(duplicates);
const orphaned = sourcePages.filter(
(p) => duplicateSet.has(p) || !p.rawPath || !rawPaths.has(p.rawPath)
);
unprocessed.sort((a, b) => a.name.localeCompare(b.name));
processed.sort((a, b) => b.page.name.localeCompare(a.page.name));
return { processed, unprocessed, orphaned };
}
const VALID_STATUS = new Set(["covered", "partial", "absent"]);
const SEPARATOR_RE = /^\|?\s*:?-{2,}/;
function parseCoverageTable(markdown) {
const text = String(markdown);
const meta = { script: null, lastSynced: null };
const scriptM = text.match(/^\s*-\s*\*\*Script:\*\*\s*`([^`]+)`/m);
if (scriptM) meta.script = scriptM[1].trim();
const syncM = text.match(/^\s*-\s*\*\*Last synced:\*\*\s*(\S+)/m);
if (syncM) meta.lastSynced = syncM[1].trim();
const rows = [];
const errors = [];
let inTable = false;
text.split(/\r?\n/).forEach((line, i) => {
const t = line.trim();
if (!t.startsWith("|")) {
inTable = false;
return;
}
if (SEPARATOR_RE.test(t)) {
inTable = true;
return;
}
if (!inTable) return;
const body = t.endsWith("|") ? t.slice(1, -1) : t.slice(1);
// Split on unescaped pipes only. Obsidian escapes the pipe of a piped
// wikilink inside a table cell as `\|`, and a plain split("|") tears
// `[[harness\|alias]]` into two cells, shifting every later column left.
const cells = body.split(/(?<!\\)\|/).map((c) => c.trim());
if (cells.length < 3) {
errors.push({ line: i + 1, text: t, reason: "expected at least 3 columns" });
return;
}
const status = cells[1].toLowerCase();
if (!VALID_STATUS.has(status)) {
errors.push({ line: i + 1, text: t, reason: `invalid status "${cells[1]}"` });
return;
}
// Capture up to the first ] | or backslash. Obsidian escapes the pipe in a
// piped wikilink inside a table cell, so the raw cell reads [[name\|alias]] —
// excluding the backslash is what keeps the trailing "\" out of the name.
const linkM = cells[0].match(/\[\[([^\]|\\]+)/);
const stationCell = cells[2];
rows.push({
concept: linkM ? linkM[1].trim() : cells[0],
status,
stations:
stationCell === "—" || stationCell === "-" || stationCell === ""
? []
: stationCell.split(",").map((s) => s.trim()).filter(Boolean),
pinned: (cells[3] || "").toLowerCase() === "yes",
line: i + 1,
});
});
return { meta, rows, errors };
}
function groupByStation(rows) {
const order = ["All stations", ...STATIONS, "No station"];
const buckets = new Map(order.map((k) => [k, []]));
for (const row of rows) {
let key;
if (row.stations.includes("all")) key = "All stations";
else if (row.stations.length === 0) key = "No station";
else key = row.stations[0];
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(row);
}
const known = order.filter((k) => buckets.get(k).length > 0);
const unknown = [...buckets.keys()].filter((k) => !order.includes(k) && buckets.get(k).length > 0);
return [...known, ...unknown].map((station) => ({ station, rows: buckets.get(station) }));
}
function reconcileConcepts(rows, conceptNames) {
const named = new Set(conceptNames);
const rowed = new Set(rows.map((r) => r.concept));
return {
rows,
unsynced: conceptNames.filter((n) => !rowed.has(n)).sort(),
stale: rows.filter((r) => !named.has(r.concept)),
};
}
// Defence in depth. The spawn path no longer uses a shell, so nothing here is
// load-bearing against injection today — but the guard costs nothing and would
// still hold if the single-string `shell: true` form is ever needed for a .cmd
// shim. `^` is cmd.exe's escape character; control characters are rejected
// because a NUL byte makes spawn() throw synchronously.
//
// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they appear in
// real filenames in this vault.
const UNSAFE_CHARS = /["`$&|;<>%^\u0000-\u001f\u007f]/;
function isSafeFilename(name) {
if (typeof name !== "string" || name.length === 0) return false;
if (UNSAFE_CHARS.test(name)) return false;
if (name.includes("..")) return false;
return true;
}
function parseConfig(source) {
const cfg = Object.assign({}, DEFAULTS);
for (const line of String(source).split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.+?)\s*$/);
if (m && Object.prototype.hasOwnProperty.call(DEFAULTS, m[1])) {
cfg[m[1]] = m[2];
}
}
return cfg;
}
// Source types CLAUDE.md documents for raw/sources. Anything else in that
// folder is not a source and stays out of the queue.
const RAW_EXTENSIONS = new Set(["md", "txt", "pdf"]);
// How far up from the dashboard to look for the element carrying Obsidian's
// readable-line-width cap. Measured chains reach it in 3 hops; 8 is slack.
const MAX_WIDEN_HOPS = 8;
// Given each ancestor's computed max-width, walking outward from the
// dashboard's parent, return the index of the first that actually carries a
// cap — or -1 if none does before the workspace chrome begins.
//
// Split out from the DOM walk so the decision can be tested against real
// measured chains. Class-name matching got this wrong twice: in live preview
// the cap sits on `.cm-content`, while `.cm-sizer` is already uncapped.
function firstCappedIndex(ancestors) {
for (let i = 0; i < ancestors.length && i < MAX_WIDEN_HOPS; i += 1) {
if (ancestors[i].isWorkspaceLeaf) return -1;
const max = ancestors[i].maxWidth;
if (max && max !== "none") return i;
}
return -1;
}
const ICONS = {
terminal: "M4 17l6-6-6-6M12 19h8",
check: "M20 6 9 17l-5-5",
minus: "M5 12h14",
x: "M18 6 6 18M6 6l12 12",
refresh: "M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.7 3L21 8M21 3v5h-5 M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.7-3L3 16M3 21v-5h5",
};
// Built with createElementNS rather than Obsidian's createSvg helper, whose
// availability varies by version. This works on any Obsidian build.
const SVG_NS = "http://www.w3.org/2000/svg";
function addIcon(parent, name) {
const svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("aria-hidden", "true");
const path = document.createElementNS(SVG_NS, "path");
path.setAttribute("d", ICONS[name]);
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.75");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.appendChild(path);
parent.appendChild(svg);
return svg;
}
function formatBytes(n) {
return `${n.toLocaleString("en-US")} B`;
}
function renderLeftPane(container, pipeline, onIngest, gate) {
const pane = container.createDiv({ cls: "wd-pane" });
const queue = pane.createDiv({ cls: "wd-block" });
queue.createDiv({
cls: "wd-eyebrow",
text: `Queue — ${pipeline.unprocessed.length} unprocessed`,
});
if (pipeline.unprocessed.length === 0) {
queue.createDiv({ cls: "wd-note", text: "Every raw source has a summary page." });
}
for (const file of pipeline.unprocessed) {
const row = queue.createDiv({ cls: "wd-row" });
const main = row.createDiv({ cls: "wd-row-main" });
main.createDiv({ cls: "wd-row-name", text: file.name });
main.createDiv({ cls: "wd-mono", text: formatBytes(file.size) });
const btn = row.createEl("button", { cls: "wd-btn" });
addIcon(btn, "terminal");
btn.createSpan({ text: "Ingest" });
if (gate && !gate.ok) {
btn.disabled = true;
btn.setAttr("title", gate.reason);
} else {
btn.addEventListener("click", () => onIngest(file, row));
}
}
const done = pane.createDiv({ cls: "wd-block" });
done.createDiv({ cls: "wd-eyebrow", text: `Ingested — ${pipeline.processed.length}` });
const list = done.createDiv({ cls: "wd-done" });
for (const file of pipeline.processed) {
const row = list.createDiv({ cls: "wd-done-row" });
const date = file.page.name.slice(0, 10);
row.createSpan({ cls: "wd-mono", text: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : "—" });
row.createSpan({ cls: "wd-done-name", text: file.name.replace(/\.md$/, "") });
}
if (pipeline.orphaned.length > 0) {
const orphan = pane.createDiv({ cls: "wd-block" });
orphan.createDiv({
cls: "wd-eyebrow",
text: `Orphaned — ${pipeline.orphaned.length}`,
});
for (const page of pipeline.orphaned) {
const row = orphan.createDiv({ cls: "wd-done-row" });
row.createSpan({ cls: "wd-done-name", text: page.name });
row.createSpan({ cls: "wd-mono", text: page.rawPath || "no raw path" });
}
}
return pane;
}
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
function renderRightPane(container, parsed, reconciled, cfg) {
// The right-pane class carries the hairline divider and left inset that
// separate coverage from the source pipeline. Marked explicitly rather than
// selected positionally, because an error box can take this slot in the grid.
const pane = container.createDiv({ cls: "wd-pane wd-pane-right" });
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
const meta = parsed.meta || {};
pane.createDiv({
cls: "wd-mono",
text: `${meta.script || "no script recorded"} — last synced ${meta.lastSynced || "never"}`,
});
if (cfg && cfg.script && meta.script && meta.script !== cfg.script) {
pane.createDiv({
cls: "wd-error",
text: `This dashboard is configured for ${cfg.script}, but the coverage file tracks ${meta.script}.`,
});
}
const counts = { covered: 0, partial: 0, absent: 0 };
for (const row of parsed.rows) counts[row.status] += 1;
const total = parsed.rows.length;
if (total > 0) {
const meter = pane.createDiv({ cls: "wd-meter" });
meter.setAttr("role", "img");
meter.setAttr(
"aria-label",
`Coverage: ${counts.covered} covered, ${counts.partial} partial, ${counts.absent} absent of ${total}`
);
for (const key of ["covered", "partial", "absent"]) {
if (counts[key] === 0) continue;
const seg = meter.createEl("i", { cls: `wd-seg-${key}` });
seg.style.flex = String(counts[key]);
}
const key = pane.createDiv({ cls: "wd-key" });
for (const name of ["covered", "partial", "absent"]) {
const span = key.createSpan();
span.createEl("i", { cls: `wd-seg-${name}` });
span.createSpan({ text: `${counts[name]} ${name}` });
}
}
if (parsed.errors.length > 0) {
const box = pane.createDiv({ cls: "wd-error" });
box.createDiv({ text: `${parsed.errors.length} unparseable row(s):` });
for (const e of parsed.errors) {
box.createDiv({ cls: "wd-mono", text: `line ${e.line}${e.reason}` });
}
}
const table = pane.createEl("table", { cls: "wd-tbl" });
const head = table.createEl("thead").createEl("tr");
for (const h of ["Concept", "Status", "Station", ""]) head.createEl("th", { text: h });
const body = table.createEl("tbody");
for (const group of groupByStation(parsed.rows)) {
const gr = body.createEl("tr", { cls: "wd-grp" });
gr.createEl("td", { attr: { colspan: "4" }, text: `${group.station}${group.rows.length}` });
for (const row of group.rows) {
const tr = body.createEl("tr");
// Obsidian's click handler resolves internal links via data-href, so both
// attributes are required for the link to open the concept page.
tr.createEl("td").createEl("a", {
cls: "internal-link",
text: row.concept,
attr: { href: row.concept, "data-href": row.concept },
});
const stat = tr.createEl("td").createSpan({ cls: `wd-stat wd-${row.status}` });
addIcon(stat, STATUS_ICON[row.status]);
stat.createSpan({ text: row.status });
tr.createEl("td", { cls: "wd-mono", text: row.stations.join(", ") || "—" });
tr.createEl("td", { cls: "wd-pin", text: row.pinned ? "pinned" : "" });
}
}
if (reconciled.unsynced.length > 0) {
const box = pane.createDiv({ cls: "wd-block" });
box.createDiv({ cls: "wd-eyebrow", text: `Unsynced — ${reconciled.unsynced.length}` });
box.createDiv({
cls: "wd-note",
text: `Concept pages with no row. Run "sync script coverage": ${reconciled.unsynced.join(", ")}`,
});
}
if (reconciled.stale.length > 0) {
const box = pane.createDiv({ cls: "wd-block" });
box.createDiv({ cls: "wd-eyebrow", text: `Stale — ${reconciled.stale.length}` });
box.createDiv({
cls: "wd-note",
text: `Rows whose concept page is gone: ${reconciled.stale.map((r) => r.concept).join(", ")}`,
});
}
return pane;
}
class WebinarDashPlugin extends PluginBase {
async onload() {
this.running = new Set();
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
const cfg = parseConfig(source);
const root = el.createDiv({ cls: "webinar-dash" });
await this.renderAll(root, cfg);
});
}
// Obsidian caps note content at --file-line-width when readable line length
// is on, which squeezes a two-pane dashboard into a column. Lift the cap on
// whichever ancestor carries it.
//
// Done here rather than in CSS on purpose: an inline style beats the app
// stylesheet without an !important arms race, and the computed-style walk
// means this keeps working even if Obsidian renames the sizer classes. The
// walk stops at the first capped ancestor and is bounded, so it cannot climb
// out into the workspace chrome and widen something it shouldn't.
widenHost(root) {
// Deliberately measured, not matched by class name. In live preview the cap
// sits on `.cm-content` (700px) while `.cm-sizer` — the obvious candidate,
// and the one a class-based lookup finds first — is already uncapped at full
// width. Shortcutting via closest() therefore locks onto the wrong element
// and stops before reaching the real one. Reading computed max-width finds
// whichever element actually carries the cap, in either view.
//
// Starts at root.parentElement so the dashboard's own 1600px cap survives,
// stops at the first capped ancestor so nothing above the note widens, and
// is bounded so it cannot climb into the workspace chrome.
const chain = [];
let el = root.parentElement;
for (let hops = 0; el && hops < MAX_WIDEN_HOPS; hops += 1, el = el.parentElement) {
chain.push({
el,
maxWidth: window.getComputedStyle(el).maxWidth,
isWorkspaceLeaf: el.classList.contains("workspace-leaf"),
});
}
const idx = firstCappedIndex(chain);
if (idx < 0) return null;
chain[idx].el.style.maxWidth = "none";
return chain[idx].el;
}
async renderAll(root, cfg) {
root.empty();
this.widenHost(root);
const gate = this.spawnGate();
const refresh = () => { this.renderAll(root, cfg); };
const bar = root.createDiv({ cls: "wd-bar" });
const sync = bar.createEl("button", { cls: "wd-btn wd-btn-ghost" });
addIcon(sync, "refresh");
sync.createSpan({ text: "Sync coverage" });
if (!gate.ok) {
sync.disabled = true;
sync.setAttr("title", gate.reason);
} else {
sync.addEventListener("click", () =>
this.runClaude("__sync__", "sync script coverage", bar, "sync coverage", refresh)
);
}
const grid = root.createDiv({ cls: "wd-grid" });
try {
const pipeline = await this.readPipeline(cfg);
renderLeftPane(grid, pipeline, (file, rowEl) => this.runIngest(file, rowEl, refresh), gate);
} catch (err) {
grid.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
}
try {
const { parsed, reconciled } = await this.readCoverage(cfg);
renderRightPane(grid, parsed, reconciled, cfg);
} catch (err) {
grid.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
}
}
async readCoverage(cfg) {
const file = this.app.vault.getAbstractFileByPath(cfg.coverage);
if (!file) throw new Error(`no coverage file at ${cfg.coverage}`);
const parsed = parseCoverageTable(await this.app.vault.cachedRead(file));
const conceptDir = cfg.conceptDir.replace(/\/+$/, "") + "/";
const conceptNames = this.app.vault
.getFiles()
.filter((f) => f.path.startsWith(conceptDir) && f.extension === "md")
.map((f) => f.basename);
return { parsed, reconciled: reconcileConcepts(parsed.rows, conceptNames) };
}
async readPipeline(cfg) {
const all = this.app.vault.getFiles();
const rawDir = cfg.rawDir.replace(/\/+$/, "") + "/";
const wikiDir = cfg.wikiSourceDir.replace(/\/+$/, "") + "/";
// CLAUDE.md documents raw/sources as holding "markdown/text/pdf exports".
// Allow-listing only "md" would hide the others from the queue with no
// warning — the same silent-invisibility failure this dashboard exists to
// remove. Wiki source pages below stay markdown-only; those really are .md.
const rawFiles = all
.filter((f) => f.path.startsWith(rawDir) && RAW_EXTENSIONS.has(f.extension))
.map((f) => ({ path: f.path, name: f.name, size: f.stat.size }));
const pageFiles = all.filter((f) => f.path.startsWith(wikiDir) && f.extension === "md");
const sourcePages = [];
for (const f of pageFiles) {
const text = await this.app.vault.cachedRead(f);
sourcePages.push({ path: f.path, name: f.name, rawPath: extractRawPath(text) });
}
return derivePipeline({ rawFiles, sourcePages });
}
vaultPath() {
const adapter = this.app.vault.adapter;
if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
return null;
}
spawnGate() {
if (!this.vaultPath()) {
return { ok: false, reason: "Needs desktop Obsidian." };
}
try {
require("child_process");
} catch (_) {
return { ok: false, reason: "child_process unavailable — needs desktop Obsidian." };
}
return { ok: true, reason: "" };
}
// One subprocess runner for every button. `key` is what makes a run unique in
// `this.running` — a file path for ingest, a constant for sync — so the guard
// survives the row re-renders that a DOM-scoped guard could not.
runClaude(key, prompt, hostEl, label, onSuccess) {
const Notice = OB ? OB.Notice : null;
const notify = (msg) => { if (Notice) new Notice(msg); };
if (this.running.has(key)) {
notify(`Already running: ${label}.`);
return;
}
const gate = this.spawnGate();
if (!gate.ok) {
notify(gate.reason);
return;
}
const { spawn } = require("child_process");
const base = this.vaultPath();
// A retry reuses the same host element. Clear the previous run's status and
// output so they are replaced rather than stacked.
hostEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
const button = hostEl.querySelector("button");
if (button) button.disabled = true;
this.running.add(key);
const status = hostEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
const output = hostEl.createDiv({ cls: "wd-output", text: "" });
const started = Date.now();
const timer = window.setInterval(() => {
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
}, 1000);
this.registerInterval(timer);
// Bounded as it accumulates, not only when displayed.
let buffered = "";
const append = (text) => {
buffered = (buffered + text).slice(-8000);
output.setText(buffered);
output.scrollTop = output.scrollHeight;
};
// Node can emit both `error` and `close` for one failure. `settled` keeps the
// first, more specific message instead of letting `exit null` overwrite it.
let settled = false;
const finish = (cls, text) => {
if (settled) return;
settled = true;
window.clearInterval(timer);
this.running.delete(key);
status.className = `wd-status ${cls}`;
status.setText(text);
if (button) button.disabled = false;
};
let child;
try {
// See the C1 comment in runIngest: no shell, deliberately.
child = spawn("claude", ["-p", prompt], { cwd: base });
} catch (err) {
append(`\nCould not start claude: ${err.message}`);
finish("wd-status-failed", "failed");
return;
}
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", (err) => {
append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
finish("wd-status-failed", "failed");
});
child.on("close", (code) => {
const secs = Math.round((Date.now() - started) / 1000);
if (code === 0) {
finish("wd-status-done", `done in ${secs}s`);
if (onSuccess) onSuccess();
} else {
finish("wd-status-failed", `failed - exit ${code}`);
}
});
}
runIngest(file, rowEl, onSuccess) {
const Notice = OB ? OB.Notice : null;
if (!isSafeFilename(file.name)) {
rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove());
rowEl.createDiv({
cls: "wd-output",
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
});
return;
}
// `claude` resolves to a real .exe here, so libuv finds it via PATH and
// PATHEXT with no shell involved. Do NOT add `shell: true`: with a shell,
// Node concatenates argv without quoting and cmd.exe re-tokenizes, so the
// prompt arrives as two arguments and the filename is silently discarded.
// Verified: shell:true yields ["-p","ingest","Webinar script.md"], where the
// program sees only "ingest". If a .cmd shim ever needs supporting, use the
// single-string form spawn(`claude -p "ingest ${name}"`, { shell: true }).
this.runClaude(
file.path,
`ingest "${file.name}"`,
rowEl,
file.name,
() => {
if (Notice) new Notice(`Ingested ${file.name}.`);
if (onSuccess) onSuccess();
}
);
}
}
module.exports = WebinarDashPlugin;
module.exports.default = WebinarDashPlugin;
module.exports.__test__ = {
parseConfig, extractRawPath, derivePipeline,
parseCoverageTable, groupByStation, reconcileConcepts,
STATIONS, DEFAULTS, firstCappedIndex, isSafeFilename,
};

View File

@@ -1,9 +0,0 @@
{
"id": "webinar-dash",
"name": "Webinar dashboard",
"version": "0.1.0",
"minAppVersion": "1.5.0",
"description": "Source pipeline and script coverage for the webinar vault.",
"author": "meels",
"isDesktopOnly": true
}

View File

@@ -1,283 +0,0 @@
.webinar-dash {
--wd-ink-000: #ffffff; --wd-ink-050: #f7f7f7; --wd-ink-100: #ececec;
--wd-ink-200: #d9d9d9; --wd-ink-400: #8a8a8a; --wd-ink-500: #5e5e5e;
--wd-ink-700: #262626; --wd-ink-900: #0a0a0a; --wd-ink-999: #000000;
--wd-red-500: #e1261c; --wd-red-600: #c31c14;
--wd-bg: var(--wd-ink-000);
--wd-bg-subtle: var(--wd-ink-050);
--wd-fg: var(--wd-ink-999);
--wd-fg-2: var(--wd-ink-700);
--wd-fg-3: var(--wd-ink-500);
--wd-fg-4: var(--wd-ink-400);
--wd-border: var(--wd-ink-200);
--wd-border-strong: var(--wd-ink-999);
--wd-border-subtle: var(--wd-ink-100);
--wd-accent: var(--wd-red-500);
--wd-accent-press: var(--wd-red-600);
--wd-accent-on: #ffffff;
--wd-ok: #0a8a3f;
--wd-warn: #c68a00;
--wd-danger: var(--wd-red-500);
--wd-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--wd-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
font-family: var(--wd-sans);
color: var(--wd-fg);
display: flex;
flex-direction: column;
gap: 16px;
/* Past roughly this width the two panes stop reading as a pair and table
rows get hard to track across. Cap the whole dashboard, not just the
grid, so the toolbar stays flush with the panes below it. */
max-width: 1600px;
/* Makes this element the reference for the @container query further down,
so the grid collapses on the pane's width rather than the window's. */
container-type: inline-size;
}
.theme-dark .webinar-dash {
--wd-bg: var(--wd-ink-900);
--wd-bg-subtle: #161616;
--wd-fg: var(--wd-ink-000);
--wd-fg-2: var(--wd-ink-200);
--wd-fg-3: var(--wd-ink-400);
--wd-fg-4: var(--wd-ink-500);
--wd-border: var(--wd-ink-700);
--wd-border-strong: #b8b8b8;
--wd-border-subtle: #161616;
/* Accent tracks the installed theme at .obsidian/themes/tesanti/theme.css,
whose dark section reads "Black canvas, same signal red": --accent-h/-s/-l
are declared once at :root and never redeclared under .theme-dark, and only
the hover state lifts (#c31c14 light, #ff4d43 dark). Match that exactly. */
--wd-accent: var(--wd-red-500);
--wd-accent-press: #ff4d43;
--wd-accent-on: #ffffff;
/* Status tokens are separate from the brand accent by design-system rule, and
here they carry small text in a dense table. #e1261c on near-black is about
3.8:1, under AA for small text, so these lift where the accent does not. */
--wd-ok: #2fbf6a;
--wd-warn: #e0a516;
--wd-danger: #ff5c50;
}
/* ------------------------------------------------------------------
Escape Obsidian's readable-line-length cap.
With "Readable line length" on (the default), Obsidian caps note
content at --file-line-width, roughly 700px. That is right for prose
and wrong for a two-pane dashboard, which gets squeezed into a column.
:has() scopes this to the sizer that actually contains a dashboard, so
every other note in the vault keeps its readable width. Both selectors
are needed: reading view sizes on .markdown-preview-sizer, live preview
on .cm-sizer.
If you would rather not rely on this, the alternatives are Settings ->
Editor -> Readable line length (off, but that widens every note), or
adding `cssclasses: wide-dash` to the note's frontmatter and swapping
the :has() selectors below for `.wide-dash .markdown-preview-sizer`.
The plugin also lifts this cap inline in `widenHost()`, which is the path
that actually carries the load — an inline style cannot lose a specificity
fight. These rules are the belt-and-braces copy, and they need !important
because Obsidian's own rule qualifies the sizer with the view class and so
outranks a bare `.markdown-preview-sizer:has(...)`.
------------------------------------------------------------------ */
.markdown-preview-sizer:has(.webinar-dash),
.markdown-preview-view.is-readable-line-width .markdown-preview-sizer:has(.webinar-dash),
.markdown-source-view.mod-cm6 .cm-content:has(.webinar-dash),
.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) {
max-width: none !important;
}
.wd-grid {
display: grid;
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
gap: 24px;
}
.wd-grid > * { min-width: 0; }
.wd-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
border: 1px solid var(--wd-border);
border-radius: 4px;
padding: 8px 12px;
background: var(--wd-bg-subtle);
}
.wd-btn-ghost {
background: transparent;
color: var(--wd-fg);
border-color: var(--wd-border-strong);
}
.wd-btn-ghost:hover {
background: var(--wd-bg-muted, var(--wd-bg-subtle));
border-color: var(--wd-border-strong);
}
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
/* The coverage pane sits behind a hairline with a 24px inset, so its table does
not run flush against the gap between the two panes. The table cells are
deliberately zero-padded on the left (`.wd-tbl td`) so the concept column
aligns with the eyebrow above it — that alignment only reads correctly when
the pane itself provides the inset, which is what this rule restores. */
.wd-pane-right {
border-left: 1px solid var(--wd-border);
padding-left: 24px;
}
.wd-eyebrow {
font-family: var(--wd-mono); font-size: 11px; line-height: 1;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--wd-fg-3); font-weight: 500;
display: flex; align-items: center; gap: 8px;
}
.wd-eyebrow::before {
content: ""; width: 5px; height: 5px; flex: none; background: var(--wd-accent);
}
.wd-block { display: flex; flex-direction: column; gap: 12px; }
.wd-row {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
border: 1px solid var(--wd-border); border-radius: 4px;
padding: 12px 12px 12px 16px; background: var(--wd-bg);
}
.wd-row + .wd-row { margin-top: -1px; }
.wd-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.wd-row-name {
font-size: 14px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-mono {
font-family: var(--wd-mono); font-size: 11px; color: var(--wd-fg-3);
font-variant-numeric: tabular-nums;
}
.wd-btn {
display: inline-flex; align-items: center; gap: 6px; flex: none;
font-family: var(--wd-sans); font-size: 13px; font-weight: 600;
padding: 6px 12px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--wd-accent); background: var(--wd-accent);
color: var(--wd-accent-on);
transition: background 120ms cubic-bezier(0.2, 0, 0, 1);
}
.wd-btn:hover { background: var(--wd-accent-press); border-color: var(--wd-accent-press); }
.wd-btn:focus-visible { outline: 2px solid var(--wd-accent); outline-offset: 2px; }
.wd-btn[disabled] {
opacity: 0.45; cursor: not-allowed;
background: transparent; color: var(--wd-fg-3); border-color: var(--wd-border);
}
.wd-btn svg { width: 14px; height: 14px; flex: none; }
.wd-done { display: flex; flex-direction: column; }
.wd-done-row {
display: flex; align-items: baseline; gap: 12px; padding: 5px 0;
border-bottom: 1px solid var(--wd-border-subtle); font-size: 13px;
}
.wd-done-row:last-child { border-bottom: 0; }
.wd-done-name {
color: var(--wd-fg-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wd-note { font-size: 12px; color: var(--wd-fg-3); }
.wd-error {
font-size: 13px; color: var(--wd-danger);
border: 1px solid var(--wd-danger); border-radius: 4px; padding: 12px;
}
@media (prefers-reduced-motion: reduce) {
.webinar-dash * { transition-duration: 0.01ms !important; }
}
.wd-meter { display: flex; gap: 2px; height: 8px; width: 100%; }
.wd-meter > i { display: block; height: 100%; }
.wd-seg-covered { background: var(--wd-ok); }
.wd-seg-partial { background: var(--wd-warn); }
.wd-seg-absent { background: var(--wd-danger); }
.wd-key { display: flex; flex-wrap: wrap; gap: 16px; font-size: 12px; color: var(--wd-fg-2); }
.wd-key > span { display: inline-flex; align-items: center; gap: 6px; }
.wd-key i { width: 8px; height: 8px; flex: none; }
.wd-tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.wd-tbl th {
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg-3); font-weight: 500;
/* Symmetric horizontal padding. These were `... 0` on the left, which left
the concept column flush against the pane edge and every other column
hard against the preceding cell's text. */
text-align: left; padding: 0 12px 8px;
border-bottom: 1px solid var(--wd-border-strong);
}
.wd-tbl td {
padding: 6px 12px; border-bottom: 1px solid var(--wd-border-subtle);
vertical-align: baseline;
}
.wd-grp td {
padding-top: 16px; border-bottom: 1px solid var(--wd-border-strong);
font-family: var(--wd-mono); font-size: 10px; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--wd-fg); font-weight: 500;
}
.wd-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; }
.wd-stat svg { width: 13px; height: 13px; flex: none; }
.wd-covered { color: var(--wd-ok); }
.wd-partial { color: var(--wd-warn); }
.wd-absent { color: var(--wd-danger); }
.wd-pin { font-family: var(--wd-mono); font-size: 10px; color: var(--wd-fg-4); }
.wd-status { font-family: var(--wd-mono); font-size: 11px; flex: none; }
.wd-status-running { color: var(--wd-warn); }
.wd-status-done { color: var(--wd-ok); }
.wd-status-failed { color: var(--wd-danger); }
.wd-output {
flex-basis: 100%;
width: 100%;
font-family: var(--wd-mono); font-size: 11px; white-space: pre-wrap;
max-height: 220px; overflow: auto; margin-top: 8px;
border: 1px solid var(--wd-border); border-radius: 4px; padding: 8px;
color: var(--wd-fg-2);
}
/* ------------------------------------------------------------------
Responsive overrides — deliberately last in the file.
At-rules do not raise specificity: a rule inside @container or @media
competes with the base rule on source order alone. These blocks were
previously above the .wd-pane-right base rule, so the base rule won
and the collapsed layout silently never applied. Keep every override
here, after everything it overrides.
Collapse on the width that actually matters — the pane's, not the
window's. A media query measures the window, so a wide window with the
dashboard in a narrow split pane would keep two cramped columns. The
container query responds to the pane itself; the media query stays as
a floor for a genuinely narrow window.
------------------------------------------------------------------ */
@container (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
/* Stacked, the divider belongs above the pane, not beside it. */
.wd-pane-right {
border-left: 0;
padding-left: 0;
border-top: 1px solid var(--wd-border);
padding-top: 20px;
}
}
@media (max-width: 820px) {
.wd-grid { grid-template-columns: minmax(0, 1fr); }
.wd-pane-right {
border-left: 0;
padding-left: 0;
border-top: 1px solid var(--wd-border);
padding-top: 20px;
}
}

View File

@@ -1,122 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseCoverageTable, groupByStation } = require("../main.js").__test__;
const DOC = [
"# Script coverage",
"",
"#coverage",
"",
"## Metadata",
"",
"- **Script:** `raw/sources/Webinar script.md`",
"- **Last synced:** 2026-07-28",
"",
"## Coverage",
"",
"| Concept | Status | Station | Pinned |",
"|---|---|---|---|",
"| [[harness]] | covered | Tools | |",
"| [[agentic-loops]] | partial | Process | |",
"| [[levels-of-ai-usage]] | partial | all | |",
"| [[connections-as-moat]] | absent | — | yes |",
].join("\n");
test("parseCoverageTable reads metadata", () => {
const { meta } = parseCoverageTable(DOC);
assert.equal(meta.script, "raw/sources/Webinar script.md");
assert.equal(meta.lastSynced, "2026-07-28");
});
test("parseCoverageTable reads every data row and skips the header", () => {
const { rows, errors } = parseCoverageTable(DOC);
assert.equal(errors.length, 0);
assert.equal(rows.length, 4);
assert.deepEqual(rows.map((r) => r.concept), [
"harness", "agentic-loops", "levels-of-ai-usage", "connections-as-moat",
]);
});
test("parseCoverageTable normalises stations", () => {
const { rows } = parseCoverageTable(DOC);
assert.deepEqual(rows[0].stations, ["Tools"]);
assert.deepEqual(rows[2].stations, ["all"]);
assert.deepEqual(rows[3].stations, []);
});
test("parseCoverageTable reads the pinned flag", () => {
const { rows } = parseCoverageTable(DOC);
assert.equal(rows[0].pinned, false);
assert.equal(rows[3].pinned, true);
});
test("parseCoverageTable strips a wikilink alias", () => {
const doc = "| Concept | Status | Station |\n|---|---|---|\n| [[harness\\|The harness]] | covered | Tools |";
const { rows } = parseCoverageTable(doc);
assert.equal(rows[0].concept, "harness");
});
test("parseCoverageTable splits a multi-station cell", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered | Tools, Memory |";
const { rows } = parseCoverageTable(doc);
assert.deepEqual(rows[0].stations, ["Tools", "Memory"]);
});
test("parseCoverageTable rejects an invalid status instead of coercing it", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | maybe | Tools |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /invalid status/);
assert.equal(errors[0].line, 3);
});
test("parseCoverageTable reports a row with too few columns", () => {
const doc = "| C | S | St |\n|---|---|---|\n| [[x]] | covered |";
const { rows, errors } = parseCoverageTable(doc);
assert.equal(rows.length, 0);
assert.equal(errors.length, 1);
assert.match(errors[0].reason, /at least 3 columns/);
});
test("parseCoverageTable returns empty results for a document with no table", () => {
const { rows, errors } = parseCoverageTable("# Nothing\n\nJust prose.");
assert.equal(rows.length, 0);
assert.equal(errors.length, 0);
});
test("groupByStation orders all-stations first and no-station last", () => {
const { rows } = parseCoverageTable(DOC);
const groups = groupByStation(rows);
assert.deepEqual(groups.map((g) => g.station), [
"All stations", "Tools", "Process", "No station",
]);
assert.equal(groups[1].rows[0].concept, "harness");
});
test("groupByStation places a multi-station row under its first station only", () => {
const rows = [{ concept: "x", status: "covered", stations: ["Memory", "Skills"], pinned: false, line: 1 }];
const groups = groupByStation(rows);
assert.equal(groups.length, 1);
assert.equal(groups[0].station, "Memory");
});
const { reconcileConcepts } = require("../main.js").__test__;
test("reconcileConcepts finds concept pages with no row", () => {
const rows = [{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 }];
const out = reconcileConcepts(rows, ["harness", "brand-new-concept"]);
assert.deepEqual(out.unsynced, ["brand-new-concept"]);
assert.deepEqual(out.stale, []);
});
test("reconcileConcepts finds rows whose concept page is gone", () => {
const rows = [
{ concept: "harness", status: "covered", stations: ["Tools"], pinned: false, line: 1 },
{ concept: "deleted-idea", status: "absent", stations: [], pinned: false, line: 2 },
];
const out = reconcileConcepts(rows, ["harness"]);
assert.deepEqual(out.stale.map((r) => r.concept), ["deleted-idea"]);
assert.deepEqual(out.unsynced, []);
});

View File

@@ -1,56 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { firstCappedIndex } = require("../main.js").__test__;
test("firstCappedIndex finds the cap on the real measured live-preview chain", () => {
// Measured in Obsidian live preview via getComputedStyle, walking outward
// from .webinar-dash. The cap is on .cm-content at 700px; .cm-sizer - the
// element a class-name lookup finds first - is already uncapped at 1680px.
// Matching by class name selected .cm-sizer and stopped, leaving the real
// cap in place. This test pins the measurement so that cannot recur.
const chain = [
{ maxWidth: "none", isWorkspaceLeaf: false }, // block-language-webinar-dash
{ maxWidth: "none", isWorkspaceLeaf: false }, // cm-preview-code-block
{ maxWidth: "700px", isWorkspaceLeaf: false }, // cm-content <- the cap
{ maxWidth: "none", isWorkspaceLeaf: false }, // cm-contentContainer
{ maxWidth: "none", isWorkspaceLeaf: false }, // cm-sizer
{ maxWidth: "none", isWorkspaceLeaf: false }, // cm-scroller
];
assert.equal(firstCappedIndex(chain), 2);
});
test("firstCappedIndex finds the cap on a reading-view chain", () => {
const chain = [
{ maxWidth: "none", isWorkspaceLeaf: false }, // block-language-webinar-dash
{ maxWidth: "700px", isWorkspaceLeaf: false }, // markdown-preview-sizer
{ maxWidth: "none", isWorkspaceLeaf: false }, // markdown-preview-view
];
assert.equal(firstCappedIndex(chain), 1);
});
test("firstCappedIndex returns -1 when nothing above the dashboard is capped", () => {
const chain = [
{ maxWidth: "none", isWorkspaceLeaf: false },
{ maxWidth: "none", isWorkspaceLeaf: false },
];
assert.equal(firstCappedIndex(chain), -1);
});
test("firstCappedIndex stops at the workspace leaf rather than widening chrome", () => {
const chain = [
{ maxWidth: "none", isWorkspaceLeaf: false },
{ maxWidth: "none", isWorkspaceLeaf: true }, // workspace-leaf
{ maxWidth: "900px", isWorkspaceLeaf: false }, // must never be reached
];
assert.equal(firstCappedIndex(chain), -1);
});
test("firstCappedIndex takes the innermost cap when several ancestors are capped", () => {
const chain = [
{ maxWidth: "none", isWorkspaceLeaf: false },
{ maxWidth: "700px", isWorkspaceLeaf: false },
{ maxWidth: "1200px", isWorkspaceLeaf: false },
];
assert.equal(firstCappedIndex(chain), 1);
});

View File

@@ -1,109 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { extractRawPath, derivePipeline } = require("../main.js").__test__;
test("extractRawPath pulls the backticked path", () => {
const page = [
"# You're reading way too much code",
"",
"#source",
"",
"## Source Metadata",
"",
"- **Date:** YouTube video, 24:11",
"- **Raw path:** `raw/sources/You're reading way too much code.md`",
"- **Source type:** video essay",
].join("\n");
assert.equal(extractRawPath(page), "raw/sources/You're reading way too much code.md");
});
test("extractRawPath handles cyrillic and em dashes", () => {
const page = "- **Raw path:** `raw/sources/Скиллы на базе git — новая память AI-агентов.md`";
assert.equal(extractRawPath(page), "raw/sources/Скиллы на базе git — новая память AI-агентов.md");
});
test("extractRawPath returns null when the line is absent", () => {
assert.equal(extractRawPath("# A page\n\n#source\n\nNo metadata here."), null);
});
test("derivePipeline splits claimed from unclaimed raw files", () => {
const rawFiles = [
{ path: "raw/sources/Nina interview.md", name: "Nina interview.md", size: 6614 },
{ path: "raw/sources/Webinar script.md", name: "Webinar script.md", size: 15841 },
];
const sourcePages = [
{
path: "wiki/sources/2026-07-14-nina-interview.md",
name: "2026-07-14-nina-interview.md",
rawPath: "raw/sources/Nina interview.md",
},
];
const out = derivePipeline({ rawFiles, sourcePages });
assert.equal(out.processed.length, 1);
assert.equal(out.processed[0].name, "Nina interview.md");
assert.equal(out.processed[0].page.name, "2026-07-14-nina-interview.md");
assert.equal(out.unprocessed.length, 1);
assert.equal(out.unprocessed[0].name, "Webinar script.md");
assert.equal(out.orphaned.length, 0);
});
test("derivePipeline reports source pages whose raw file is gone", () => {
const out = derivePipeline({
rawFiles: [],
sourcePages: [
{ path: "wiki/sources/x.md", name: "x.md", rawPath: "raw/sources/deleted.md" },
],
});
assert.equal(out.orphaned.length, 1);
assert.equal(out.orphaned[0].name, "x.md");
});
test("derivePipeline treats a page with no raw path as orphaned", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }],
sourcePages: [{ path: "wiki/sources/y.md", name: "y.md", rawPath: null }],
});
assert.equal(out.orphaned.length, 1);
assert.equal(out.unprocessed.length, 1);
});
test("derivePipeline routes a duplicate raw-path claim to orphaned", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/a.md", name: "a.md", size: 10 }],
sourcePages: [
{ path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/a.md" },
{ path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/a.md" },
],
});
assert.equal(out.processed.length, 1);
assert.equal(out.processed[0].page.name, "first.md");
assert.equal(out.unprocessed.length, 0);
assert.deepEqual(out.orphaned.map((p) => p.name), ["second.md"]);
});
test("derivePipeline lists a page once when it is both a duplicate claim and missing its raw file", () => {
const out = derivePipeline({
rawFiles: [{ path: "raw/sources/other.md", name: "other.md", size: 10 }],
sourcePages: [
{ path: "wiki/sources/first.md", name: "first.md", rawPath: "raw/sources/gone.md" },
{ path: "wiki/sources/second.md", name: "second.md", rawPath: "raw/sources/gone.md" },
],
});
assert.deepEqual(out.orphaned.map((p) => p.name), ["first.md", "second.md"]);
});
test("derivePipeline sorts unprocessed by name and processed newest first", () => {
const out = derivePipeline({
rawFiles: [
{ path: "raw/sources/b.md", name: "b.md", size: 1 },
{ path: "raw/sources/a.md", name: "a.md", size: 1 },
{ path: "raw/sources/c.md", name: "c.md", size: 1 },
],
sourcePages: [
{ path: "wiki/sources/2026-07-14-x.md", name: "2026-07-14-x.md", rawPath: "raw/sources/c.md" },
],
});
assert.deepEqual(out.unprocessed.map((f) => f.name), ["a.md", "b.md"]);
assert.equal(out.processed.length, 1);
});

View File

@@ -1,47 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { isSafeFilename } = require("../main.js").__test__;
test("isSafeFilename accepts every filename currently in the vault", () => {
const real = [
"Agentic Engineering, explained by a 10x developer.md",
"Webinar Plan - From Chat Box to Your Own OS.md",
"Webinar script.md",
"You're reading way too much code.md",
"ИИ глупый!.md",
"Скиллы на базе git — новая память AI-агентов.md",
"sebastian interview - conclusions and insights.md",
"In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md",
];
for (const name of real) {
assert.equal(isSafeFilename(name), true, `should accept: ${name}`);
}
});
test("isSafeFilename rejects shell metacharacters", () => {
for (const bad of ['a".md', "a`b.md", "a$b.md", "a&b.md", "a|b.md", "a;b.md",
"a<b.md", "a>b.md", "a%b.md", "a\nb.md", "a\rb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects the cmd.exe escape character and control characters", () => {
// `^` escapes the next character in cmd.exe, so it can defuse the closing
// quote. NUL additionally makes spawn() throw synchronously.
for (const bad of ["a^b.md", "a\u0000b.md", "a\u001bb.md", "a\u007fb.md"]) {
assert.equal(isSafeFilename(bad), false, `should reject: ${JSON.stringify(bad)}`);
}
});
test("isSafeFilename rejects path traversal", () => {
assert.equal(isSafeFilename("../secrets.md"), false);
assert.equal(isSafeFilename("a/../../b.md"), false);
});
test("isSafeFilename rejects empty and non-string input", () => {
assert.equal(isSafeFilename(""), false);
assert.equal(isSafeFilename(null), false);
assert.equal(isSafeFilename(undefined), false);
assert.equal(isSafeFilename(42), false);
});

View File

@@ -1,6 +0,0 @@
# Webinar dashboard
```webinar-dash
script: raw/sources/Webinar script.md
coverage: wiki/script-coverage.md
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,256 +0,0 @@
# Webinar vault dashboard — design
Date: 2026-07-28
Status: approved, ready for implementation planning
## Context
The vault is an LLM-maintained wiki governed by `CLAUDE.md`. It currently holds 12 raw
sources, 9 ingested source summaries, 19 concept pages, and one webinar script
(`raw/sources/Webinar script.md`) that the concepts are supposed to feed.
Two problems motivated this work:
1. **The manual catalog drifts.** `index.md` lists `Ideas for webinar.md` and
`my theses.md` under `raw/sources/`, but both live in `raw/notes/`. It does not
mention `Agentic Engineering, explained by a 10x developer.md` at all, which sits
un-ingested in `raw/sources/`. Nothing detects this.
2. **No view of script coverage.** There is no way to see which concept pages the
webinar script actually delivers. A manual read shows the script is entirely
machine-side: all human-side and strategy-side concepts are absent.
## Goals
- Show processed and unprocessed sources, with a one-click ingest on the unprocessed.
- Show every concept and whether the webinar script mentions it, plus which script
station it lands in.
- Keep coverage status in a separate markdown file, kept synchronized by a rule in
`CLAUDE.md`.
## Non-goals
- Replacing `index.md` or `log.md`. Both stay exactly as they are.
- A general-purpose Obsidian dashboard framework. This is one vault-specific plugin.
- Publishing the plugin to the community plugin registry.
## Decisions taken
| Decision | Choice | Rationale |
|---|---|---|
| Buttons | Own plugin, not Meta Bind | Needs are narrow and vault-specific; Meta Bind's expensive half (inline CM6 widgets, two-way frontmatter binding) is unused here |
| Coverage status source | Claude judges; user can pin | Sync sets status automatically, but a row marked `Pinned: yes` is never overwritten |
| Ingest mechanism | Headless `claude -p` via `child_process` | One click, fully automatic. Chosen over a queue file with the unsupervised-write trade-off understood |
| Granularity | Status + script station | Turns the table into a pacing map, not just a checklist |
| Layout | Two-pane (Option B) | Sources and coverage both first-class; coverage grouped by station recovers most of the station-board view |
## Architecture
```
.obsidian/plugins/webinar-dash/
manifest.json
main.js # plain CommonJS, no build step
styles.css # tesanti tokens scoped to .webinar-dash
dashboard.md # vault root, beside index.md
wiki/script-coverage.md # the coverage table
```
`dashboard.md` holds only a config block; the plugin renders everything:
````markdown
# Webinar dashboard
```webinar-dash
script: raw/sources/Webinar script.md
coverage: wiki/script-coverage.md
```
````
Config keys are optional and fall back to those two defaults.
### Where truth lives
| Data | Source of truth | Mechanism |
|---|---|---|
| Which sources are processed | Filesystem, read live | Diff `raw/sources/*.md` against the `**Raw path:**` value in every `wiki/sources/*.md` |
| Concept coverage status | `wiki/script-coverage.md` | Written by Claude on sync, read by the plugin |
Mechanical facts come from the filesystem, judgment comes from the markdown file.
This makes the `index.md` class of drift structurally impossible on the sources half:
the dashboard cannot disagree with the filesystem because it derives from it.
**The plugin never writes to the vault.** It reads files and spawns one subprocess.
Nothing else. It also never reads `index.md` — the catalog is a human-facing artifact,
and treating it as input would reintroduce exactly the drift this design removes.
### Source pipeline derivation
1. List `raw/sources/*.md`.
2. For each `wiki/sources/*.md`, extract the backticked path from the line matching
`**Raw path:** \`<path>\``. Verified consistent across all 9 existing source pages.
3. A raw file claimed by some source page is **processed**; unclaimed is **unprocessed**.
4. A source page whose raw path no longer exists is reported as **orphaned**.
`raw/notes/` is out of scope — those are notes, not sources.
## Coverage file format
```markdown
# Script coverage
#coverage
## Metadata
- **Script:** `raw/sources/Webinar script.md`
- **Last synced:** 2026-07-28
- **Stations:** Chat box · ReAct · Tools · Memory · Skills · Process · OS
## Coverage
| Concept | Status | Station | Pinned |
|---|---|---|---|
| [[harness]] | covered | Tools | |
| [[agentic-loops]] | partial | Process | |
| [[levels-of-ai-usage]] | partial | all | |
| [[connections-as-moat]] | absent | — | yes |
```
Field rules:
- **Concept** — an Obsidian wikilink to a page in `wiki/concepts/`. The plugin extracts
the page name from inside the brackets.
- **Status** — exactly one of `covered`, `partial`, `absent`. Any other value renders as
`invalid` rather than being silently coerced.
- **Station** — one of the seven station names, a comma-separated list of them, `all`,
or `` for none.
- **Pinned** — `yes`, or blank. Blank is the default.
The seven stations are the `#` headings of the script that represent technology levels:
Chat box, ReAct, Tools, Memory, Skills, Process, OS. The script's `Intro`,
`Mail from boss`, and `Notes` headings are setup and are not stations.
v1 does not write this file from the plugin. Pinning is a hand-edit of one cell — a pin
toggle button would make the plugin a writer and risk clobbering concurrent user edits,
which is not worth it for a one-word change.
## Rendering — two-pane layout
Left pane (38%):
- **Queue** — unprocessed sources, each row showing filename, byte size, and an
**Ingest** button. Files missing from `index.md` need no special flag: they appear
here purely because no source page claims them, which is how
`Agentic Engineering, explained by a 10x developer.md` surfaces despite being absent
from the catalog.
- **Ingested** — processed sources as compact rows: date from the source page filename
prefix, plus title.
Right pane (62%):
- Coverage meter — a stacked bar of covered / partial / absent with a 2px gap between
segments, plus a counted key.
- Coverage table grouped by station, with `No station` last.
Collapses to a single column below 820px so a narrow Obsidian pane stays usable.
Styling follows the tesanti design system: black / white / red only, Space Grotesk
display, Inter body, JetBrains Mono for eyebrows and data, radii at most 6px, 1px
hairlines instead of shadows, Lucide stroke icons, no emoji. Status colors use the
system's `--ok` / `--warn` / `--danger` tokens and always ship with a text label, never
color alone. Dark mode is derived from the same ink ramp, with the red lifted to
`#ff4a3d` so small text clears contrast on near-black.
## Ingest mechanism
```js
const { spawn } = require("child_process");
spawn("claude", ["-p", `ingest "${file}"`], { cwd: vaultPath, shell: true });
```
`shell: true` is required on Windows to resolve `claude.cmd`, which places the filename
inside a shell string. Before spawning, the filename is rejected if it contains any of:
`"` `` ` `` `$` `&` `|` `;` `<` `>` `%` or a newline. Existing filenames include
Cyrillic, spaces, and `!`, all of which pass. A rejected filename shows an error in its
row and does not spawn.
Vault path comes from `app.vault.adapter.getBasePath()` on `FileSystemAdapter`.
Per-row states: `idle` → `running` with elapsed seconds → `done` or `failed · exit <n>`
with captured stderr in an expandable block. On success the pipeline is re-derived and
the row moves to the ingested list. A second click while running is ignored.
A global **Sync coverage** button spawns `claude -p "sync script coverage"`.
Preflight: if `claude` is not resolvable on PATH, all buttons render disabled with that
reason stated.
### Accepted risk
Headless ingest writes source summaries, concept pages, `index.md`, and `log.md` without
the user watching. This was chosen deliberately over a review checkpoint. Mitigations:
one file per click rather than a batch, captured output retained per row, and visible
per-row status. The writes land before the user reads them; this is understood and
accepted.
## CLAUDE.md changes
1. **Folder convention** — add `wiki/script-coverage.md` to the tree with a note that it
is machine-maintained.
2. **Tagging rules** — add the row `wiki/script-coverage.md` → `#coverage`. No existing
page type fits: it is generated tabular data, not prose analysis.
3. **Workflow D: Sync Script Coverage** — re-read the script and every
`wiki/concepts/*.md`; set `Status` and `Station` for each; never modify a row whose
`Pinned` is `yes`; add rows for new concept pages; remove rows for deleted ones;
update `Last synced`; then update `index.md` and append to `log.md`.
4. **Sync triggers** — Workflow D runs at the end of any ingest that creates or modifies
a concept page, whenever `Webinar script.md` changes, and on the explicit
`sync script coverage` intent, which is added to Operational Commands.
The coverage baseline is always the raw script at `raw/sources/Webinar script.md`.
Ingesting the script into `wiki/sources/` later does not change the baseline.
## Failure modes
| Condition | Behavior |
|---|---|
| Coverage file missing or table malformed | Sources pane renders normally; coverage pane shows a parse error with the offending line |
| Concept page exists with no table row | Rendered as `unsynced`, so a stale sync is visible rather than silent |
| Table row points at a nonexistent concept page | Rendered as `stale`, kept in place, not auto-removed |
| Source page whose raw path is missing | Listed under `orphaned` in the left pane |
| `child_process` unavailable (mobile) | Buttons render disabled with the reason |
| `claude` not on PATH | Buttons render disabled with the reason |
## Initial coverage assessment
Read manually while designing; the first real sync will regenerate it. 19 concepts:
5 covered, 4 partial, 10 absent.
- **covered** — harness (Tools), skills-as-memory (Skills), solve-first-then-skillify
(Skills), personal-ai-operating-system (OS), evolution-of-agent-tooling (Tools)
- **partial** — agentic-loops (Process), context-as-scarce-resource (Memory),
levels-of-ai-usage (all), code-as-throwaway (OS)
- **absent** — connections-as-moat, product-ownership, seniority-and-the-junior-squeeze,
decoupling-identity-from-profession, network-from-a-standing-start,
think-wider-not-bigger, make-more-cheap-code, enterprise-ai-reality,
integration-dead-ends, leave-less-room-for-imagination
Eight of the ten absent concepts are human-side or strategy-side per `index.md`'s grouping.
The other two — `integration-dead-ends` and `leave-less-room-for-imagination` — are
machine-side, and are absent because the script demonstrates the happy path and so never
reaches connector gating or spec ambiguity, the two ways the machine side fails in
practice. The `ReAct` station carries no wiki concept at all.
## Out of scope for v1
- Pin toggle button (hand-edit instead).
- Alternate station-board view toggle. The data file is layout-independent, so this is a
render change if wanted later.
- Coverage for entities, sources, or queries — concepts only.
- Any view of `raw/notes/`.
## Note on spec location
This file introduces a `docs/` folder at the vault root, which is not part of the
`CLAUDE.md` folder convention and will appear in Obsidian's file explorer. It can be
moved or deleted without affecting the implementation.
The vault is not a git repository, so this spec is not committed.

View File

@@ -1,45 +0,0 @@
# Script coverage
#coverage
## Metadata
- **Script:** `raw/sources/Webinar script.md`
- **Last synced:** 2026-07-28
- **Stations:** Chat box · ReAct · Tools · Memory · Skills · Process · OS
## Coverage
| Concept | Status | Station | Pinned |
|---|---|---|---|
| [[harness]] | covered | Tools | |
| [[evolution-of-agent-tooling]] | covered | Tools | |
| [[skills-as-memory]] | covered | Skills | |
| [[solve-first-then-skillify]] | covered | Skills | |
| [[personal-ai-operating-system]] | covered | OS | |
| [[context-as-scarce-resource]] | partial | Memory | |
| [[agentic-loops]] | partial | Process | |
| [[code-as-throwaway]] | partial | OS | |
| [[levels-of-ai-usage]] | partial | all | |
| [[integration-dead-ends]] | absent | — | |
| [[leave-less-room-for-imagination]] | absent | — | |
| [[product-ownership]] | absent | — | |
| [[connections-as-moat]] | absent | — | |
| [[network-from-a-standing-start]] | absent | — | |
| [[seniority-and-the-junior-squeeze]] | absent | — | |
| [[decoupling-identity-from-profession]] | absent | — | |
| [[think-wider-not-bigger]] | absent | — | |
| [[make-more-cheap-code]] | absent | — | |
| [[enterprise-ai-reality]] | absent | — | |
## Notes
- Eight of the ten absent concepts are human-side or strategy-side, per the grouping in `index.md`. The script's spine is machine-side and it never reaches that material.
- The remaining two absent concepts are machine-side: [[integration-dead-ends]] and [[leave-less-room-for-imagination]]. The script demonstrates the happy path, so it never reaches connector gating or spec ambiguity — the two ways the machine side fails in practice.
- The `ReAct` station carries no wiki concept at all.
- Set `Pinned` to `yes` on any row whose status is a deliberate decision. Sync will not touch it.
## Related Pages
- [[overview]]
- `raw/sources/Webinar script.md`