index.md groups integration-dead-ends and leave-less-room-for-imagination under Machine side, and both are absent from the script. The claim that every absent concept is human-side or strategy-side was wrong: it is 8 of 10.
61 KiB
Webinar Vault Dashboard Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build an Obsidian plugin that renders a two-pane dashboard showing the vault's source pipeline (with one-click headless ingest) and concept coverage against the webinar script.
Architecture: A single unbundled main.js CommonJS plugin. Pure logic (path extraction, pipeline derivation, table parsing, filename validation) lives at the top of the file behind a guarded require("obsidian"), so it can be unit-tested with plain node --test and no Obsidian runtime. Obsidian glue (code-block processor, DOM rendering, subprocess spawn) sits below it. The plugin reads the vault and never writes to it.
Tech Stack: Plain CommonJS (no TypeScript, no bundler, no build step), Node's built-in node:test runner, Node child_process, Obsidian Plugin API, tesanti design tokens.
Global Constraints
- No build step.
main.jsis loaded by Obsidian verbatim. No TypeScript, no esbuild, nonpm install. - No dependencies. Tests use Node's built-in
node:testandnode:assert/strictonly. isDesktopOnly: trueis mandatory inmanifest.json— the plugin useschild_process, which Obsidian only provides on desktop.minAppVersion:"1.5.0".- Relative
require()between plugin files is unsupported. All code lives in onemain.js. - The plugin never writes to the vault. It reads files and spawns one subprocess. It must never read
index.md. - Design tokens are copied verbatim from
tesanti Design System.zip → colors_and_type.css. Black / white / red only; radii at most 6px; 1px hairlines instead of shadows. - No emoji anywhere — in code, UI, comments, or commit messages. Status icons are inline Lucide stroke paths.
- Sentence case for all UI copy. No Title Case.
- The seven stations, in order:
Chat box,ReAct,Tools,Memory,Skills,Process,OS. - Valid statuses, exactly:
covered,partial,absent.
File Structure
| File | Responsibility |
|---|---|
.obsidian/plugins/webinar-dash/manifest.json |
Plugin identity and desktop-only flag |
.obsidian/plugins/webinar-dash/main.js |
Everything: pure helpers, renderers, plugin class |
.obsidian/plugins/webinar-dash/styles.css |
tesanti tokens and dashboard styling, scoped to .webinar-dash |
.obsidian/plugins/webinar-dash/test/pipeline.test.js |
Tests for extractRawPath, derivePipeline |
.obsidian/plugins/webinar-dash/test/coverage.test.js |
Tests for parseCoverageTable, groupByStation |
.obsidian/plugins/webinar-dash/test/safety.test.js |
Tests for isSafeFilename |
dashboard.md |
Vault root. Holds one webinar-dash config block |
wiki/script-coverage.md |
The coverage table |
CLAUDE.md |
Modified: folder convention, tagging rules, Workflow D, sync triggers |
Tests live inside the plugin folder. Obsidian loads only main.js from a plugin directory, so test/ is inert at runtime.
Task 1: Repository and plugin scaffold
Files:
- Create:
.gitignore - Create:
.obsidian/plugins/webinar-dash/manifest.json - Create:
.obsidian/plugins/webinar-dash/main.js - Create:
dashboard.md
Interfaces:
-
Consumes: nothing
-
Produces: a loadable plugin registering the
webinar-dashcode-block language;module.exports.__test__as the export surface every later task extends -
Step 1: Initialize the repository
The vault is not currently a git repository. The plugin will spawn agents that write to wiki/, index.md, and log.md unsupervised, so an undo path is required before that capability exists.
cd "D:/Projects/Notes/Webinar/Webinar"
git init
- Step 2: Create
.gitignore
Obsidian rewrites workspace.json constantly; it is local UI state, not vault content.
.obsidian/workspace.json
.obsidian/workspace-mobile.json
.obsidian/cache
- Step 3: Commit the vault as it stands
git add -A
git commit -m "chore: initial commit of vault before dashboard work"
- Step 4: Create the manifest
isDesktopOnly must be true — Obsidian only exposes child_process on desktop, and the ingest button depends on it.
{
"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
}
- Step 5: Create
main.jswith the Obsidian guard and a stub renderer
The try/catch around require("obsidian") is what lets node --test load this file. Under Node the require throws, OB stays null, and PluginBase becomes an empty class so class ... extends PluginBase still evaluates. Every later task adds pure functions above the plugin class and registers them in __test__.
"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",
};
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;
}
class WebinarDashPlugin extends PluginBase {
async onload() {
this.registerMarkdownCodeBlockProcessor("webinar-dash", (source, el, ctx) => {
const cfg = parseConfig(source);
const root = el.createDiv({ cls: "webinar-dash" });
root.createDiv({ cls: "wd-eyebrow", text: "Webinar dashboard" });
root.createEl("p", { text: `Reading coverage from ${cfg.coverage}` });
});
}
}
module.exports = WebinarDashPlugin;
module.exports.default = WebinarDashPlugin;
module.exports.__test__ = { parseConfig, STATIONS, DEFAULTS };
- Step 6: Create
dashboard.md
# Webinar dashboard
```webinar-dash
script: raw/sources/Webinar script.md
coverage: wiki/script-coverage.md
```
- Step 7: Enable the plugin and verify it renders
In Obsidian: Settings → Community plugins → turn off Restricted mode if on → Installed plugins → Reload → enable "Webinar dashboard". Open dashboard.md in reading view.
Expected: the text Webinar dashboard followed by Reading coverage from wiki/script-coverage.md.
If nothing renders, run "Reload app without saving" (Ctrl+R) — Obsidian caches plugin code between edits.
- Step 8: Commit
git add .gitignore .obsidian/plugins/webinar-dash dashboard.md
git commit -m "feat: scaffold webinar-dash plugin with config block"
Task 2: Source pipeline derivation
Files:
- Modify:
.obsidian/plugins/webinar-dash/main.js - Test:
.obsidian/plugins/webinar-dash/test/pipeline.test.js
Interfaces:
-
Consumes:
module.exports.__test__from Task 1 -
Produces:
extractRawPath(text: string) => string | nullderivePipeline({ rawFiles, sourcePages }) => { processed, unprocessed, orphaned }rawFilesitems:{ path: string, name: string, size: number }sourcePagesitems:{ path: string, name: string, rawPath: string | null }processeditems:rawFilesitem plus{ page }
-
Step 1: Write the failing tests
Create .obsidian/plugins/webinar-dash/test/pipeline.test.js. The fixtures are the real strings from this vault.
"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);
});
- Step 2: Run the tests to verify they fail
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/pipeline.test.js
Expected: FAIL — extractRawPath is not a function (it is not yet exported).
- Step 3: Implement the helpers
In main.js, insert directly below the DEFAULTS constant:
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 };
}
- Step 4: Export them
Replace the __test__ line at the bottom of main.js:
module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATIONS, DEFAULTS };
- Step 5: Run the tests to verify they pass
node --test test/pipeline.test.js
Expected: PASS, 9 tests.
- Step 6: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add .obsidian/plugins/webinar-dash
git commit -m "feat: derive source pipeline from raw path claims"
Task 3: Left pane — source pipeline rendering
Files:
- Modify:
.obsidian/plugins/webinar-dash/main.js - Create:
.obsidian/plugins/webinar-dash/styles.css
Interfaces:
-
Consumes:
derivePipeline,extractRawPathfrom Task 2;parseConfigfrom Task 1 -
Produces:
async readPipeline(app, cfg) => { processed, unprocessed, orphaned }as a method on the plugin classrenderLeftPane(container, pipeline, onIngest)whereonIngestis(file, rowEl) => void; Task 6 supplies the real handler, this task passes a no-op- CSS class root
.webinar-dashand the shared primitives.wd-eyebrow,.wd-row,.wd-btn,.wd-mono
-
Step 1: Create
styles.csswith tesanti tokens
Values are copied verbatim from colors_and_type.css. Scoped to .webinar-dash so nothing leaks into the rest of Obsidian. Obsidian loads styles.css from the plugin folder automatically.
.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: grid;
grid-template-columns: minmax(0, 38fr) minmax(0, 62fr);
gap: 24px;
}
.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;
}
@media (max-width: 820px) {
.webinar-dash { grid-template-columns: minmax(0, 1fr); }
}
.webinar-dash > * { min-width: 0; }
.wd-pane { display: flex; flex-direction: column; gap: 20px; }
.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;
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; }
}
- Step 2: Add the Lucide icon helper and the left-pane renderer
Insert into main.js above the plugin class. Icons are inline stroke paths — the Lucide CDN is unavailable offline and the design system forbids emoji.
// 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"]);
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) {
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" });
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;
}
- Step 3: Replace the plugin class body to read the vault and render
vault.getFiles() returns every markdown file; filter by path prefix. vault.cachedRead is the correct read for display purposes.
class WebinarDashPlugin extends PluginBase {
async onload() {
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
const cfg = parseConfig(source);
const root = el.createDiv({ cls: "webinar-dash" });
try {
const pipeline = await this.readPipeline(cfg);
renderLeftPane(root, pipeline, () => {});
} catch (err) {
root.createDiv({ cls: "wd-error", text: `Dashboard failed: ${err.message}` });
}
});
}
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 });
}
}
- Step 4: Verify in Obsidian
Reload the app (Ctrl+R) and open dashboard.md in reading view.
Expected, against the vault's current state:
-
Queue — 3 unprocessedlistingAgentic Engineering, explained by a 10x developer.md(15,020 B),Webinar Plan - From Chat Box to Your Own OS.md(17,177 B), andWebinar script.md(15,841 B), each with a red Ingest button that does nothing yet. -
Ingested — 9listing the source summaries newest first, starting with2026-07-24. -
No orphaned block.
-
Step 5: Run the existing tests to confirm nothing regressed
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test
Bare node --test auto-discovers the test files. Do not pass test/ as an
argument — Node 24 resolves a bare directory path as a module and fails with
MODULE_NOT_FOUND before running anything.
Expected: PASS, 9 tests.
- Step 6: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add .obsidian/plugins/webinar-dash
git commit -m "feat: render source pipeline in left pane"
Task 4: Coverage table parser
Files:
- Modify:
.obsidian/plugins/webinar-dash/main.js - Test:
.obsidian/plugins/webinar-dash/test/coverage.test.js
Interfaces:
-
Consumes:
STATIONSfrom Task 1 -
Produces:
parseCoverageTable(markdown) => { meta, rows, errors }meta:{ script: string | null, lastSynced: string | null }rowsitems:{ concept: string, status: "covered"|"partial"|"absent", stations: string[], pinned: boolean, line: number }errorsitems:{ line: number, text: string, reason: string }groupByStation(rows) => Array<{ station: string, rows: Row[] }>orderedAll stations, then the seven stations, thenNo station; empty groups omitted
-
Step 1: Write the failing tests
Create .obsidian/plugins/webinar-dash/test/coverage.test.js.
"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");
});
- Step 2: Run the tests to verify they fail
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/coverage.test.js
Expected: FAIL — parseCoverageTable is not a function.
- Step 3: Implement the parser
Insert into main.js below derivePipeline. The inTable flag flips on the |---| separator, which is what distinguishes the header row from data rows.
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 —
// the status cell then reads "The harness]]" and the row is rejected.
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) }));
}
The \\| in the alias test is an escaped pipe inside a JS string, which reaches the parser as a literal | — matching how Obsidian escapes piped wikilinks inside table cells.
- Step 4: Export them
module.exports.__test__ = {
parseConfig, extractRawPath, derivePipeline,
parseCoverageTable, groupByStation,
STATIONS, DEFAULTS,
};
- Step 5: Run the tests to verify they pass
node --test test/coverage.test.js
Expected: PASS, 11 tests.
- Step 6: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add .obsidian/plugins/webinar-dash
git commit -m "feat: parse the script coverage table"
Task 5: Coverage data file and CLAUDE.md contract
This is the wiki-side task. It creates the file the renderer reads and installs the rule that keeps it current. No plugin code changes.
Files:
- Create:
wiki/script-coverage.md - Modify:
CLAUDE.md(Folder Convention, Tagging Rules, Standard Workflows, Operational Commands) - Modify:
index.md - Modify:
log.md
Interfaces:
-
Consumes: the row format defined in Task 4
-
Produces:
wiki/script-coverage.mdconforming to that format, with one row per page inwiki/concepts/ -
Step 1: Create
wiki/script-coverage.md
Statuses below are the assessment made during design, read from raw/sources/Webinar script.md against each concept page. All 19 concept pages are present.
# 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`
- Step 2: Add the file to the folder convention in
CLAUDE.md
In the ## Folder Convention code block, add below the wiki/overview.md line:
script-coverage.md # machine-maintained: concept coverage vs the webinar script
- Step 3: Add the tag row in
CLAUDE.md
In the Folder → tag table under ## Tagging Rules, add a row after the wiki/overview.md row:
| `wiki/script-coverage.md` | `#coverage` |
- Step 4: Add Workflow D to
CLAUDE.md
Append to ## Standard Workflows, after Workflow C:
### Workflow D: Sync Script Coverage
Maintains `wiki/script-coverage.md` — one row per page in `wiki/concepts/`, judged
against the script named in that file's `**Script:**` metadata field.
1. Read `wiki/script-coverage.md` and note every row where `Pinned` is `yes`.
2. Read the script and every page in `wiki/concepts/`.
3. For each concept, decide `Status` and `Station`:
- `covered` — the script delivers the idea, whether or not it uses the page's name.
- `partial` — the script gestures at it but never lands it.
- `absent` — the script never reaches it.
- `Station` is one of the seven, a comma-separated list, `all`, or `—` when absent.
4. **Never modify a row whose `Pinned` is `yes`** — not its status, not its station.
A pinned row is the user's judgment and outranks yours.
5. Add rows for concept pages with no row. Remove rows whose concept page no longer exists.
6. Update `**Last synced:**` to today.
7. Update `index.md` and append a `sync` entry to `log.md`.
Run this workflow:
- at the end of any ingest that creates or modifies a page in `wiki/concepts/`
- whenever the script file itself changes
- on the explicit `sync script coverage` intent
The coverage baseline is always the raw script. Ingesting the script into
`wiki/sources/` does not change the baseline.
- Step 5: Add the intent to
CLAUDE.md
In ## Operational Commands (Natural Language), add to the supported intents list:
- "sync script coverage"
- Step 6: Update
index.md
Add a section after ## Sources, and correct the stale not-yet-ingested line.
## Coverage
- [[script-coverage]] — every concept vs `raw/sources/Webinar script.md`; 5 covered, 4 partial, 10 absent
Replace the existing **Raw, not yet ingested:** line with the accurate set — the two note files live in raw/notes/, not raw/sources/, and one raw source was missing entirely:
**Raw, not yet ingested:** `raw/sources/Agentic Engineering, explained by a 10x developer.md` · `raw/sources/Webinar Plan - From Chat Box to Your Own OS.md` · `raw/sources/Webinar script.md`
- Step 7: Append to
log.md
## 2026-07-28 — sync (script coverage, initial)
- Intent: sync script coverage
- Input: first run of Workflow D, establishing `wiki/script-coverage.md`.
- Pages created: [[script-coverage]] (coverage) — 19 rows, one per concept page.
- Pages updated: `CLAUDE.md` (folder convention, `#coverage` tag row, Workflow D, new intent), `index.md` (new Coverage section; corrected the not-yet-ingested list — `Ideas for webinar.md` and `my theses.md` were listed under `raw/sources/` but live in `raw/notes/`, and `Agentic Engineering, explained by a 10x developer.md` was missing entirely).
- Notes: Initial assessment is 5 covered, 4 partial, 10 absent. Eight of the ten absent are human-side or strategy-side per this file's own concept grouping; the other two — [[integration-dead-ends]] and [[leave-less-room-for-imagination]] — are machine-side, absent because the script demos the happy path and never reaches connector gating or spec ambiguity. The `ReAct` station carries no wiki concept. No concept pages were edited.
- Next: decide whether the human-side cluster earns a station or is a deliberate cut; decide separately whether the two absent machine-side failure modes belong in the demo; pin the rows that are decided so future syncs leave them alone.
- Step 8: Verify the file parses
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node -e "
const fs = require('fs');
const { parseCoverageTable } = require('./main.js').__test__;
const md = fs.readFileSync('../../../wiki/script-coverage.md', 'utf8');
const r = parseCoverageTable(md);
console.log('rows:', r.rows.length, 'errors:', r.errors.length);
console.log('meta:', r.meta);
if (r.errors.length) { console.log(r.errors); process.exit(1); }
if (r.rows.length !== 19) { console.log('expected 19 rows'); process.exit(1); }
"
Expected: rows: 19 errors: 0 and metadata showing the script path and 2026-07-28.
- Step 9: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add wiki/script-coverage.md CLAUDE.md index.md log.md
git commit -m "feat: add script coverage file and Workflow D sync contract"
Task 6: Right pane — coverage rendering
Files:
- Modify:
.obsidian/plugins/webinar-dash/main.js - Modify:
.obsidian/plugins/webinar-dash/styles.css
Interfaces:
-
Consumes:
parseCoverageTable,groupByStationfrom Task 4;addIconfrom Task 3;wiki/script-coverage.mdfrom Task 5 -
Produces:
reconcileConcepts(rows, conceptNames) => { rows, unsynced, stale }whereunsyncedis concept names with no row andstaleis rows whose concept page is gonerenderRightPane(container, parsed, reconciled)
-
Step 1: Add the reconcile helper and its tests
Append to .obsidian/plugins/webinar-dash/test/coverage.test.js:
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, []);
});
- Step 2: Run the tests to verify they fail
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/coverage.test.js
Expected: FAIL — reconcileConcepts is not a function.
- Step 3: Implement
reconcileConceptsand export it
Insert into main.js below groupByStation:
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)),
};
}
Add reconcileConcepts to the __test__ export object.
- Step 4: Run the tests to verify they pass
node --test test/coverage.test.js
Expected: PASS, 13 tests.
- Step 5: Add the coverage styles
Append to styles.css:
.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;
text-align: left; padding: 0 12px 8px 0;
border-bottom: 1px solid var(--wd-border-strong);
}
.wd-tbl td {
padding: 6px 12px 6px 0; 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); }
- Step 6: Add the right-pane renderer
Insert into main.js below renderLeftPane:
const STATUS_ICON = { covered: "check", partial: "minus", absent: "x" };
function renderRightPane(container, parsed, reconciled) {
const pane = container.createDiv({ cls: "wd-pane" });
pane.createDiv({ cls: "wd-eyebrow", text: "Coverage — by station" });
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(
"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;
}
- Step 7: Wire it into the plugin class
Replace the code-block processor callback and add a reader method:
this.registerMarkdownCodeBlockProcessor("webinar-dash", async (source, el, ctx) => {
const cfg = parseConfig(source);
const root = el.createDiv({ cls: "webinar-dash" });
try {
const pipeline = await this.readPipeline(cfg);
renderLeftPane(root, pipeline, () => {});
} catch (err) {
root.createDiv({ cls: "wd-error", text: `Pipeline failed: ${err.message}` });
}
try {
const { parsed, reconciled } = await this.readCoverage(cfg);
renderRightPane(root, parsed, reconciled);
} catch (err) {
root.createDiv({ cls: "wd-error", text: `Coverage failed: ${err.message}` });
}
});
The two panes render in separate try blocks so a broken coverage file never blanks the source pipeline.
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) };
}
- Step 8: Verify in Obsidian
Reload (Ctrl+R) and open dashboard.md.
Expected: a right pane with a meter reading 5 covered, 4 partial, 10 absent; a table grouped All stations — 1, Tools — 2, Memory — 1, Skills — 2, Process — 1, OS — 2, No station — 10; no unsynced block; no stale block; concept names clickable through to their pages.
- Step 9: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add .obsidian/plugins/webinar-dash
git commit -m "feat: render coverage meter and station-grouped table"
Task 7: Headless ingest
Files:
- Modify:
.obsidian/plugins/webinar-dash/main.js - Modify:
.obsidian/plugins/webinar-dash/styles.css - Test:
.obsidian/plugins/webinar-dash/test/safety.test.js
Interfaces:
-
Consumes:
renderLeftPane'sonIngest(file, rowEl)callback from Task 3 -
Produces:
isSafeFilename(name) => booleanrunIngest(file, rowEl)as a plugin method
-
Step 1: Write the failing tests
Create .obsidian/plugins/webinar-dash/test/safety.test.js. The accept cases are real filenames from this vault.
"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 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);
});
- Step 2: Run the tests to verify they fail
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test test/safety.test.js
Expected: FAIL — isSafeFilename is not a function.
- Step 3: Implement the validator and export it
Insert into main.js below reconcileConcepts:
// `shell: true` is required on Windows to resolve `claude.cmd`, which puts the
// filename into a shell string. Reject anything cmd.exe or a POSIX shell would
// interpret. Apostrophes and cyrillic are safe inside double quotes and are
// present in real filenames, so they stay allowed.
const UNSAFE_CHARS = /["`$&|;<>%\r\n]/;
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;
}
Add isSafeFilename to the __test__ export object.
- Step 4: Run the tests to verify they pass
node --test test/safety.test.js
Expected: PASS, 4 tests.
- Step 5: Add ingest-state styles
Append to styles.css:
.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 {
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);
}
- Step 6: Implement
runIngeston the plugin class
getBasePath() exists on FileSystemAdapter; on mobile the adapter is a different class and child_process is unavailable, which is why the manifest sets isDesktopOnly.
vaultPath() {
const adapter = this.app.vault.adapter;
if (typeof adapter.getBasePath === "function") return adapter.getBasePath();
return null;
}
runIngest(file, rowEl) {
if (rowEl.dataset.wdRunning === "1") return;
const Notice = OB ? OB.Notice : null;
const notify = (msg) => { if (Notice) new Notice(msg); };
if (!isSafeFilename(file.name)) {
rowEl.createDiv({
cls: "wd-output",
text: `Refused: "${file.name}" contains a character that is unsafe to pass to a shell.`,
});
return;
}
const base = this.vaultPath();
if (!base) {
notify("Ingest needs desktop Obsidian.");
return;
}
let spawn;
try {
({ spawn } = require("child_process"));
} catch (_) {
notify("child_process unavailable — ingest needs desktop Obsidian.");
return;
}
rowEl.dataset.wdRunning = "1";
const button = rowEl.querySelector("button");
if (button) button.disabled = true;
const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" });
const started = Date.now();
const timer = window.setInterval(() => {
status.setText(`running ${Math.round((Date.now() - started) / 1000)}s`);
}, 1000);
const output = rowEl.createDiv({ cls: "wd-output", text: "" });
let buffered = "";
const append = (chunk) => {
buffered += chunk.toString();
output.setText(buffered.slice(-4000));
output.scrollTop = output.scrollHeight;
};
const child = spawn("claude", ["-p", `ingest "${file.name}"`], {
cwd: base,
shell: true,
});
child.stdout.on("data", append);
child.stderr.on("data", append);
child.on("error", (err) => {
window.clearInterval(timer);
status.className = "wd-status wd-status-failed";
status.setText("failed");
append(`\nCould not start claude: ${err.message}\nIs it on PATH?`);
rowEl.dataset.wdRunning = "0";
if (button) button.disabled = false;
});
child.on("close", (code) => {
window.clearInterval(timer);
const secs = Math.round((Date.now() - started) / 1000);
if (code === 0) {
status.className = "wd-status wd-status-done";
status.setText(`done in ${secs}s`);
notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`);
} else {
status.className = "wd-status wd-status-failed";
status.setText(`failed - exit ${code}`);
}
rowEl.dataset.wdRunning = "0";
if (button) button.disabled = false;
});
}
- Step 7: Pass the real handler into the left pane
In the code-block processor, replace the no-op:
renderLeftPane(root, pipeline, (file, rowEl) => this.runIngest(file, rowEl));
- Step 8: Verify the refusal path without spawning anything
Temporarily rename a raw file to include a shell metacharacter, reload, and click Ingest.
cd "D:/Projects/Notes/Webinar/Webinar/raw/sources"
cp "Webinar script.md" 'bad&name.md'
Expected in Obsidian: the row shows Refused: "bad&name.md" contains a character that is unsafe to pass to a shell. and no process starts.
Then remove it:
rm 'bad&name.md'
- Step 9: Verify a real ingest
Reload Obsidian and click Ingest on Agentic Engineering, explained by a 10x developer.md.
Expected: the button disables, the status ticks running 1s, running 2s, …, streamed output appears below the row, and on completion the status reads done in Ns. Reopen dashboard.md: the queue drops to 2 and the ingested list rises to 10.
This writes to the vault unsupervised. Everything is committed, so git diff HEAD shows exactly what the agent changed and git checkout -- . reverts it.
- Step 10: Run the whole suite
cd "D:/Projects/Notes/Webinar/Webinar/.obsidian/plugins/webinar-dash"
node --test
Bare node --test auto-discovers the test files. Do not pass test/ as an
argument — Node 24 resolves a bare directory path as a module and fails with
MODULE_NOT_FOUND before running anything.
Expected: PASS, 26 tests across three files.
- Step 11: Commit
cd "D:/Projects/Notes/Webinar/Webinar"
git add .obsidian/plugins/webinar-dash
git commit -m "feat: spawn headless claude ingest with filename validation"
Self-Review
Spec coverage. Every section of the spec maps to a task:
| Spec section | Task |
|---|---|
| Architecture, config block, no build step | 1 |
Source pipeline derivation, **Raw path:** join |
2 |
| Left pane, tesanti styling, dark mode | 3 |
| Coverage file format, parsing rules | 4 |
Coverage data file, four CLAUDE.md changes, index.md, log.md |
5 |
Two-pane layout, meter, station grouping, unsynced / stale |
6 |
| Ingest spawn, filename rejection list, per-row states, preflight | 7 |
| Failure modes table | 3 (orphaned), 6 (parse errors, unsynced, stale), 7 (no adapter, no claude) |
The spec's "v1 does not write the coverage file from the plugin" is honored — no task adds a pin toggle.
Placeholder scan. No TBD, no "add error handling", no "similar to Task N". Every code step carries runnable code.
Type consistency. rawFiles items are {path, name, size} in Tasks 2 and 3. sourcePages items are {path, name, rawPath} in both. Coverage rows are {concept, status, stations, pinned, line} in Tasks 4, 5, and 6. onIngest(file, rowEl) is declared in Task 3 and implemented with the same signature in Task 7. addIcon(parent, name) is defined in Task 3 and reused in Task 6. STATIONS is defined in Task 1 and consumed by groupByStation in Task 4.
Test count. Task 2 adds 9, Task 4 adds 11, Task 6 adds 2, Task 7 adds 4 — 26 total, matching Step 10 of Task 7.