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:
7
.gitattributes
vendored
7
.gitattributes
vendored
@@ -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
3
.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.obsidian/workspace.json
|
||||
.obsidian/workspace-mobile.json
|
||||
.obsidian/cache
|
||||
3
.obsidian/community-plugins.json
vendored
3
.obsidian/community-plugins.json
vendored
@@ -1,3 +0,0 @@
|
||||
[
|
||||
"webinar-dash"
|
||||
]
|
||||
660
.obsidian/plugins/webinar-dash/main.js
vendored
660
.obsidian/plugins/webinar-dash/main.js
vendored
@@ -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,
|
||||
};
|
||||
9
.obsidian/plugins/webinar-dash/manifest.json
vendored
9
.obsidian/plugins/webinar-dash/manifest.json
vendored
@@ -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
|
||||
}
|
||||
283
.obsidian/plugins/webinar-dash/styles.css
vendored
283
.obsidian/plugins/webinar-dash/styles.css
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
122
.obsidian/plugins/webinar-dash/test/coverage.test.js
vendored
122
.obsidian/plugins/webinar-dash/test/coverage.test.js
vendored
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
109
.obsidian/plugins/webinar-dash/test/pipeline.test.js
vendored
109
.obsidian/plugins/webinar-dash/test/pipeline.test.js
vendored
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user