From 5de50b155ac90653e72a4319b22fdf2a7115f01f Mon Sep 17 00:00:00 2001 From: meels Date: Tue, 28 Jul 2026 15:20:55 +0200 Subject: [PATCH] chore: renormalize line endings under the new .gitattributes Line-ending only. Verified: 3667 insertions against 3667 deletions with zero content difference under --ignore-cr-at-eol. --- .obsidian/plugins/webinar-dash/main.js | 1202 +++--- .obsidian/plugins/webinar-dash/manifest.json | 18 +- .obsidian/plugins/webinar-dash/styles.css | 478 +-- .../webinar-dash/test/coverage.test.js | 244 +- .../webinar-dash/test/pipeline.test.js | 218 +- .../plugins/webinar-dash/test/safety.test.js | 94 +- CLAUDE.md | 496 +-- .../plans/2026-07-28-webinar-dashboard.md | 3490 ++++++++--------- .../2026-07-28-webinar-dashboard-design.md | 512 +-- index.md | 178 +- log.md | 314 +- wiki/script-coverage.md | 90 +- 12 files changed, 3667 insertions(+), 3667 deletions(-) diff --git a/.obsidian/plugins/webinar-dash/main.js b/.obsidian/plugins/webinar-dash/main.js index d5f5464..a64dbc6 100644 --- a/.obsidian/plugins/webinar-dash/main.js +++ b/.obsidian/plugins/webinar-dash/main.js @@ -1,601 +1,601 @@ -"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(/(? 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"]); - -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) { - const pane = container.createDiv({ cls: "wd-pane" }); - 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); - }); - } - - async renderAll(root, cfg) { - root.empty(); - 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, isSafeFilename, -}; +"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(/(? 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"]); + +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) { + const pane = container.createDiv({ cls: "wd-pane" }); + 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); + }); + } + + async renderAll(root, cfg) { + root.empty(); + 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, isSafeFilename, +}; diff --git a/.obsidian/plugins/webinar-dash/manifest.json b/.obsidian/plugins/webinar-dash/manifest.json index de1ae92..b3322a9 100644 --- a/.obsidian/plugins/webinar-dash/manifest.json +++ b/.obsidian/plugins/webinar-dash/manifest.json @@ -1,9 +1,9 @@ -{ - "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 -} +{ + "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 +} diff --git a/.obsidian/plugins/webinar-dash/styles.css b/.obsidian/plugins/webinar-dash/styles.css index 4df15b2..a68c3d6 100644 --- a/.obsidian/plugins/webinar-dash/styles.css +++ b/.obsidian/plugins/webinar-dash/styles.css @@ -1,239 +1,239 @@ -.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`. - ------------------------------------------------------------------ */ -.markdown-preview-sizer:has(.webinar-dash), -.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) { - max-width: none; -} - -.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); -} - -/* Collapse to one column 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 below 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); } -} - -@media (max-width: 820px) { - .wd-grid { grid-template-columns: minmax(0, 1fr); } -} - -.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; 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; - 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); } - -.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); -} +.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`. + ------------------------------------------------------------------ */ +.markdown-preview-sizer:has(.webinar-dash), +.markdown-source-view.mod-cm6 .cm-sizer:has(.webinar-dash) { + max-width: none; +} + +.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); +} + +/* Collapse to one column 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 below 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); } +} + +@media (max-width: 820px) { + .wd-grid { grid-template-columns: minmax(0, 1fr); } +} + +.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; 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; + 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); } + +.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); +} diff --git a/.obsidian/plugins/webinar-dash/test/coverage.test.js b/.obsidian/plugins/webinar-dash/test/coverage.test.js index 22ff85f..c5f57f2 100644 --- a/.obsidian/plugins/webinar-dash/test/coverage.test.js +++ b/.obsidian/plugins/webinar-dash/test/coverage.test.js @@ -1,122 +1,122 @@ -"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, []); -}); +"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, []); +}); diff --git a/.obsidian/plugins/webinar-dash/test/pipeline.test.js b/.obsidian/plugins/webinar-dash/test/pipeline.test.js index 61b39cb..b1f86cf 100644 --- a/.obsidian/plugins/webinar-dash/test/pipeline.test.js +++ b/.obsidian/plugins/webinar-dash/test/pipeline.test.js @@ -1,109 +1,109 @@ -"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); -}); +"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); +}); diff --git a/.obsidian/plugins/webinar-dash/test/safety.test.js b/.obsidian/plugins/webinar-dash/test/safety.test.js index 419dc1b..a9eb557 100644 --- a/.obsidian/plugins/webinar-dash/test/safety.test.js +++ b/.obsidian/plugins/webinar-dash/test/safety.test.js @@ -1,47 +1,47 @@ -"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", - "ab.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); -}); +"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", + "ab.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); +}); diff --git a/CLAUDE.md b/CLAUDE.md index fc27eef..ac3114e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,248 +1,248 @@ -# LLM Wiki Schema (Operational Contract) - -You are the dedicated maintainer of this vault as a persistent, compounding wiki. - -## Mission - -Maintain a high-signal personal knowledge base where: - -- `raw/` contains immutable source materials. -- `wiki/` contains LLM-authored, continuously maintained markdown pages. -- `index.md` is the content catalog. -- `log.md` is an append-only chronological operation log. - -The user curates sources and asks questions. You perform all wiki maintenance. - -## Non-Negotiable Rules - -1. Never modify files inside `raw/`. -2. Always update `index.md` and `log.md` after any ingest/query/lint operation that changes the wiki. -3. Prefer editing existing pages over creating duplicates. -4. Use Obsidian-style wiki links (`[[page-name]]`) for internal references. -5. Record contradictions and superseded claims explicitly (do not silently overwrite history). -6. Keep claims attributable: include source links to `wiki/sources/*` pages. -7. Do not leave orphan pages intentionally; add at least one inbound and one outbound link when possible. -8. Keep writing concise, structured, and diff-friendly. -9. Every wiki page carries exactly one page-type hashtag (see Tagging Rules) matching its folder. - -## Folder Convention - -```text -raw/ - sources/ # immutable source markdown/text/pdf exports - assets/ # immutable local images/files referenced by raw sources - -wiki/ - overview.md # top-level synthesis and navigation - script-coverage.md # machine-maintained: concept coverage vs the webinar script - sources/ # one summary page per ingested raw source - entities/ # people, orgs, projects, places, tools - concepts/ # themes, ideas, methods, frameworks - timelines/ # optional chronological reconstructions - comparisons/ # side-by-side analyses - queries/ # durable outputs created from Q&A sessions - lint-reports/ # periodic health-check reports -``` - -## File Naming Rules - -- Use kebab-case for file names. -- Prefix source summary pages with date: `YYYY-MM-DD-title.md`. -- Prefer stable canonical pages: - - `wiki/entities/.md` - - `wiki/concepts/.md` -- If a page name collides, merge instead of creating suffixes unless truly distinct. - -## Tagging Rules - -Every page in `wiki/` starts with its page-type hashtag on its own line, directly after the `# ` heading (Obsidian inline tag format): - -```text -# <Title> - -#<type-tag> - -## <first section> -``` - -Folder → required tag: - -| Folder / file | Tag | -| ---------------------- | -------------- | -| `wiki/overview.md` | `#overview` | -| `wiki/script-coverage.md` | `#coverage` | -| `wiki/sources/*` | `#source` | -| `wiki/entities/*` | `#entity` | -| `wiki/concepts/*` | `#concept` | -| `wiki/timelines/*` | `#timeline` | -| `wiki/comparisons/*` | `#comparison` | -| `wiki/queries/*` | `#query` | -| `wiki/lint-reports/*` | `#lint-report` | - -- Exactly one page-type tag per page; it must match the page's folder. -- Additional topical tags are allowed on the same line after the type tag (e.g. `#concept #ai-tooling`), but the type tag comes first. -- When creating any new page, add the tag immediately; when moving a page between folders, update the tag in the same operation. - -## Required Page Templates - -### 1) Source Summary (`wiki/sources/*.md`) - -Must include sections: - -- `# <title>` -- `#source` tag line (per Tagging Rules) -- `## Source Metadata` (date, raw path, source type, ingestion date) -- `## Core Claims` -- `## Key Evidence / Details` -- `## Connections` (links to entities/concepts/comparisons) -- `## Open Questions` -- `## Change Impact on Wiki` (what pages were updated and why) - -### 2) Entity / Concept Page - -Must include: - -- `# <name>` -- `#entity` or `#concept` tag line (per Tagging Rules) -- `## Summary` -- `## Current Understanding` -- `## Evidence` (bullets with links to `wiki/sources/*`) -- `## Related Pages` -- `## Contradictions / Uncertainty` -- `## Next Questions` - -### 3) Query Output (`wiki/queries/*.md`) - -Must include: - -- `#query` tag line (per Tagging Rules) -- Question asked -- Answer -- Evidence trail (links) -- Follow-up questions -- Whether this output changed existing pages - -### 4) Coverage File (`wiki/script-coverage.md`) - -Machine-read by the dashboard plugin, which parses the table positionally. The -shape below is required exactly — a deviation renders as parse errors, not as a -best-effort read. - -- `# Script coverage` -- `#coverage` tag line -- `## Metadata`, containing these two lines verbatim in this form: - - `- **Script:** ` followed by the raw script path in backticks - - `- **Last synced:** ` followed by a `YYYY-MM-DD` date -- `## Coverage`, containing one markdown table with exactly these four columns in - this order: `Concept`, `Status`, `Station`, `Pinned` - - **Concept** — an Obsidian wikilink to a page in `wiki/concepts/` - - **Status** — exactly one of `covered`, `partial`, `absent`, lowercase - - **Station** — one of the seven station names spelled exactly as listed in - Workflow D, a comma-separated list of them, the lowercase word `all`, or an - em dash `—` when the status is `absent` - - **Pinned** — the lowercase word `yes`, or empty -- `## Notes` and `## Related Pages` are free prose and are not parsed. - -The parser reads every markdown table in the file, so do not add a second table. - -## Standard Workflows - -### Workflow A: Ingest One Source - -When user says "ingest <source>": - -1. Read the raw source from `raw/sources/` (and `raw/assets/` references if needed). -2. Extract key claims, facts, entities, concepts, uncertainty. -3. Create/update one source summary in `wiki/sources/`. -4. Update relevant `wiki/entities/*` and `wiki/concepts/*` pages. -5. Update `wiki/overview.md` synthesis if this source materially changes understanding. -6. Update `index.md`. -7. Append ingest entry to `log.md`. -8. Report what changed, what is uncertain, and suggested next source/questions. -9. If this ingest created or modified any page in `wiki/concepts/`, run Workflow D - (Sync Script Coverage) before reporting. - -### Workflow B: Answer Query - -1. Read `index.md` first. -2. Select relevant pages and synthesize answer with page citations. -3. If answer has durable value, save to `wiki/queries/YYYY-MM-DD-<slug>.md`. -4. If answer reveals new synthesis, update affected concept/entity pages. -5. Update `index.md` and append query entry in `log.md` when files changed. - -### Workflow C: Lint Wiki - -Run periodic health checks for: - -- contradiction detection across pages -- stale claims superseded by newer sources -- orphan pages / weak linking -- high-mention concepts lacking dedicated pages -- missing evidence links -- missing or folder-mismatched page-type tags (per Tagging Rules) - -Write report to `wiki/lint-reports/YYYY-MM-DD-lint.md`, then update `index.md` and `log.md`. - -### 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 — `Chat box`, `ReAct`, `Tools`, `Memory`, - `Skills`, `Process`, `OS` — spelled exactly as written here, or a - comma-separated list of them, or the lowercase word `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. - A pinned row whose concept page no longer exists is still removed — step 4 - protects the user's judgement about a live concept, not a dangling row. -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. - -## Citation & Evidence Policy - -- Prefer citing wiki source summaries (`wiki/sources/*.md`) rather than raw files in normal answers. -- If citing raw material directly, also reflect it into a source summary page. -- Mark uncertain or contested claims with `Status: tentative` in relevant section. - -## Update Policy - -- Never perform silent large rewrites. -- Preserve meaningful prior interpretations; move outdated material under a "Superseded" note when needed. -- Keep sections ordered consistently for predictable diffs. - -## Operational Commands (Natural Language) - -Supported intents: - -- "ingest path-or-title" -- "query: question" -- "lint wiki" -- "show recent changes" -- "suggest next sources" -- "sync script coverage" - -Always execute intents according to workflows above. - -## Session Start Checklist - -At start of every session: - -1. Read `index.md` and the latest section of `log.md`. -2. Identify last completed operation and current open questions. -3. Continue from prior state without resetting conventions. +# LLM Wiki Schema (Operational Contract) + +You are the dedicated maintainer of this vault as a persistent, compounding wiki. + +## Mission + +Maintain a high-signal personal knowledge base where: + +- `raw/` contains immutable source materials. +- `wiki/` contains LLM-authored, continuously maintained markdown pages. +- `index.md` is the content catalog. +- `log.md` is an append-only chronological operation log. + +The user curates sources and asks questions. You perform all wiki maintenance. + +## Non-Negotiable Rules + +1. Never modify files inside `raw/`. +2. Always update `index.md` and `log.md` after any ingest/query/lint operation that changes the wiki. +3. Prefer editing existing pages over creating duplicates. +4. Use Obsidian-style wiki links (`[[page-name]]`) for internal references. +5. Record contradictions and superseded claims explicitly (do not silently overwrite history). +6. Keep claims attributable: include source links to `wiki/sources/*` pages. +7. Do not leave orphan pages intentionally; add at least one inbound and one outbound link when possible. +8. Keep writing concise, structured, and diff-friendly. +9. Every wiki page carries exactly one page-type hashtag (see Tagging Rules) matching its folder. + +## Folder Convention + +```text +raw/ + sources/ # immutable source markdown/text/pdf exports + assets/ # immutable local images/files referenced by raw sources + +wiki/ + overview.md # top-level synthesis and navigation + script-coverage.md # machine-maintained: concept coverage vs the webinar script + sources/ # one summary page per ingested raw source + entities/ # people, orgs, projects, places, tools + concepts/ # themes, ideas, methods, frameworks + timelines/ # optional chronological reconstructions + comparisons/ # side-by-side analyses + queries/ # durable outputs created from Q&A sessions + lint-reports/ # periodic health-check reports +``` + +## File Naming Rules + +- Use kebab-case for file names. +- Prefix source summary pages with date: `YYYY-MM-DD-title.md`. +- Prefer stable canonical pages: + - `wiki/entities/<name>.md` + - `wiki/concepts/<concept>.md` +- If a page name collides, merge instead of creating suffixes unless truly distinct. + +## Tagging Rules + +Every page in `wiki/` starts with its page-type hashtag on its own line, directly after the `# <title>` heading (Obsidian inline tag format): + +```text +# <Title> + +#<type-tag> + +## <first section> +``` + +Folder → required tag: + +| Folder / file | Tag | +| ---------------------- | -------------- | +| `wiki/overview.md` | `#overview` | +| `wiki/script-coverage.md` | `#coverage` | +| `wiki/sources/*` | `#source` | +| `wiki/entities/*` | `#entity` | +| `wiki/concepts/*` | `#concept` | +| `wiki/timelines/*` | `#timeline` | +| `wiki/comparisons/*` | `#comparison` | +| `wiki/queries/*` | `#query` | +| `wiki/lint-reports/*` | `#lint-report` | + +- Exactly one page-type tag per page; it must match the page's folder. +- Additional topical tags are allowed on the same line after the type tag (e.g. `#concept #ai-tooling`), but the type tag comes first. +- When creating any new page, add the tag immediately; when moving a page between folders, update the tag in the same operation. + +## Required Page Templates + +### 1) Source Summary (`wiki/sources/*.md`) + +Must include sections: + +- `# <title>` +- `#source` tag line (per Tagging Rules) +- `## Source Metadata` (date, raw path, source type, ingestion date) +- `## Core Claims` +- `## Key Evidence / Details` +- `## Connections` (links to entities/concepts/comparisons) +- `## Open Questions` +- `## Change Impact on Wiki` (what pages were updated and why) + +### 2) Entity / Concept Page + +Must include: + +- `# <name>` +- `#entity` or `#concept` tag line (per Tagging Rules) +- `## Summary` +- `## Current Understanding` +- `## Evidence` (bullets with links to `wiki/sources/*`) +- `## Related Pages` +- `## Contradictions / Uncertainty` +- `## Next Questions` + +### 3) Query Output (`wiki/queries/*.md`) + +Must include: + +- `#query` tag line (per Tagging Rules) +- Question asked +- Answer +- Evidence trail (links) +- Follow-up questions +- Whether this output changed existing pages + +### 4) Coverage File (`wiki/script-coverage.md`) + +Machine-read by the dashboard plugin, which parses the table positionally. The +shape below is required exactly — a deviation renders as parse errors, not as a +best-effort read. + +- `# Script coverage` +- `#coverage` tag line +- `## Metadata`, containing these two lines verbatim in this form: + - `- **Script:** ` followed by the raw script path in backticks + - `- **Last synced:** ` followed by a `YYYY-MM-DD` date +- `## Coverage`, containing one markdown table with exactly these four columns in + this order: `Concept`, `Status`, `Station`, `Pinned` + - **Concept** — an Obsidian wikilink to a page in `wiki/concepts/` + - **Status** — exactly one of `covered`, `partial`, `absent`, lowercase + - **Station** — one of the seven station names spelled exactly as listed in + Workflow D, a comma-separated list of them, the lowercase word `all`, or an + em dash `—` when the status is `absent` + - **Pinned** — the lowercase word `yes`, or empty +- `## Notes` and `## Related Pages` are free prose and are not parsed. + +The parser reads every markdown table in the file, so do not add a second table. + +## Standard Workflows + +### Workflow A: Ingest One Source + +When user says "ingest <source>": + +1. Read the raw source from `raw/sources/` (and `raw/assets/` references if needed). +2. Extract key claims, facts, entities, concepts, uncertainty. +3. Create/update one source summary in `wiki/sources/`. +4. Update relevant `wiki/entities/*` and `wiki/concepts/*` pages. +5. Update `wiki/overview.md` synthesis if this source materially changes understanding. +6. Update `index.md`. +7. Append ingest entry to `log.md`. +8. Report what changed, what is uncertain, and suggested next source/questions. +9. If this ingest created or modified any page in `wiki/concepts/`, run Workflow D + (Sync Script Coverage) before reporting. + +### Workflow B: Answer Query + +1. Read `index.md` first. +2. Select relevant pages and synthesize answer with page citations. +3. If answer has durable value, save to `wiki/queries/YYYY-MM-DD-<slug>.md`. +4. If answer reveals new synthesis, update affected concept/entity pages. +5. Update `index.md` and append query entry in `log.md` when files changed. + +### Workflow C: Lint Wiki + +Run periodic health checks for: + +- contradiction detection across pages +- stale claims superseded by newer sources +- orphan pages / weak linking +- high-mention concepts lacking dedicated pages +- missing evidence links +- missing or folder-mismatched page-type tags (per Tagging Rules) + +Write report to `wiki/lint-reports/YYYY-MM-DD-lint.md`, then update `index.md` and `log.md`. + +### 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 — `Chat box`, `ReAct`, `Tools`, `Memory`, + `Skills`, `Process`, `OS` — spelled exactly as written here, or a + comma-separated list of them, or the lowercase word `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. + A pinned row whose concept page no longer exists is still removed — step 4 + protects the user's judgement about a live concept, not a dangling row. +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. + +## Citation & Evidence Policy + +- Prefer citing wiki source summaries (`wiki/sources/*.md`) rather than raw files in normal answers. +- If citing raw material directly, also reflect it into a source summary page. +- Mark uncertain or contested claims with `Status: tentative` in relevant section. + +## Update Policy + +- Never perform silent large rewrites. +- Preserve meaningful prior interpretations; move outdated material under a "Superseded" note when needed. +- Keep sections ordered consistently for predictable diffs. + +## Operational Commands (Natural Language) + +Supported intents: + +- "ingest path-or-title" +- "query: question" +- "lint wiki" +- "show recent changes" +- "suggest next sources" +- "sync script coverage" + +Always execute intents according to workflows above. + +## Session Start Checklist + +At start of every session: + +1. Read `index.md` and the latest section of `log.md`. +2. Identify last completed operation and current open questions. +3. Continue from prior state without resetting conventions. diff --git a/docs/superpowers/plans/2026-07-28-webinar-dashboard.md b/docs/superpowers/plans/2026-07-28-webinar-dashboard.md index 4384297..9391cdc 100644 --- a/docs/superpowers/plans/2026-07-28-webinar-dashboard.md +++ b/docs/superpowers/plans/2026-07-28-webinar-dashboard.md @@ -1,1745 +1,1745 @@ -# 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.js` is loaded by Obsidian verbatim. No TypeScript, no esbuild, no `npm install`. -- **No dependencies.** Tests use Node's built-in `node:test` and `node:assert/strict` only. -- **`isDesktopOnly: true`** is mandatory in `manifest.json` — the plugin uses `child_process`, which Obsidian only provides on desktop. -- **`minAppVersion`: `"1.5.0"`.** -- **Relative `require()` between plugin files is unsupported.** All code lives in one `main.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-dash` code-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. - -```bash -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. - -```gitignore -.obsidian/workspace.json -.obsidian/workspace-mobile.json -.obsidian/cache -``` - -- [ ] **Step 3: Commit the vault as it stands** - -```bash -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. - -```json -{ - "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.js` with 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__`. - -```js -"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`** - -````markdown -# 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** - -```bash -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 | null` - - `derivePipeline({ rawFiles, sourcePages }) => { processed, unprocessed, orphaned }` - - `rawFiles` items: `{ path: string, name: string, size: number }` - - `sourcePages` items: `{ path: string, name: string, rawPath: string | null }` - - `processed` items: `rawFiles` item 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. - -```js -"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** - -```bash -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: - -```js -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`: - -```js -module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATIONS, DEFAULTS }; -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -```bash -node --test test/pipeline.test.js -``` - -Expected: PASS, 9 tests. - -- [ ] **Step 6: Commit** - -```bash -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`, `extractRawPath` from Task 2; `parseConfig` from Task 1 -- Produces: - - `async readPipeline(app, cfg) => { processed, unprocessed, orphaned }` as a method on the plugin class - - `renderLeftPane(container, pipeline, onIngest)` where `onIngest` is `(file, rowEl) => void`; Task 6 supplies the real handler, this task passes a no-op - - CSS class root `.webinar-dash` and the shared primitives `.wd-eyebrow`, `.wd-row`, `.wd-btn`, `.wd-mono` - -- [ ] **Step 1: Create `styles.css` with 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. - -```css -.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. - -```js -// 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. - -```js -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 unprocessed` listing `Agentic Engineering, explained by a 10x developer.md` (15,020 B), `Webinar Plan - From Chat Box to Your Own OS.md` (17,177 B), and `Webinar script.md` (15,841 B), each with a red **Ingest** button that does nothing yet. -- `Ingested — 9` listing the source summaries newest first, starting with `2026-07-24`. -- No orphaned block. - -- [ ] **Step 5: Run the existing tests to confirm nothing regressed** - -```bash -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** - -```bash -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: `STATIONS` from Task 1 -- Produces: - - `parseCoverageTable(markdown) => { meta, rows, errors }` - - `meta`: `{ script: string | null, lastSynced: string | null }` - - `rows` items: `{ concept: string, status: "covered"|"partial"|"absent", stations: string[], pinned: boolean, line: number }` - - `errors` items: `{ line: number, text: string, reason: string }` - - `groupByStation(rows) => Array<{ station: string, rows: Row[] }>` ordered `All stations`, then the seven stations, then `No station`; empty groups omitted - -- [ ] **Step 1: Write the failing tests** - -Create `.obsidian/plugins/webinar-dash/test/coverage.test.js`. - -```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** - -```bash -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. - -```js -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** - -```js -module.exports.__test__ = { - parseConfig, extractRawPath, derivePipeline, - parseCoverageTable, groupByStation, - STATIONS, DEFAULTS, -}; -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -```bash -node --test test/coverage.test.js -``` - -Expected: PASS, 11 tests. - -- [ ] **Step 6: Commit** - -```bash -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.md` conforming to that format, with one row per page in `wiki/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. - -```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 | | -| [[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: - -```text - 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: - -```text -| `wiki/script-coverage.md` | `#coverage` | -``` - -- [ ] **Step 4: Add Workflow D to `CLAUDE.md`** - -Append to `## Standard Workflows`, after Workflow C: - -```markdown -### 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: - -```markdown -- "sync script coverage" -``` - -- [ ] **Step 6: Update `index.md`** - -Add a section after `## Sources`, and correct the stale not-yet-ingested line. - -```markdown -## 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: - -```markdown -**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`** - -```markdown -## 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** - -```bash -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** - -```bash -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`, `groupByStation` from Task 4; `addIcon` from Task 3; `wiki/script-coverage.md` from Task 5 -- Produces: - - `reconcileConcepts(rows, conceptNames) => { rows, unsynced, stale }` where `unsynced` is concept names with no row and `stale` is rows whose concept page is gone - - `renderRightPane(container, parsed, reconciled)` - -- [ ] **Step 1: Add the reconcile helper and its tests** - -Append to `.obsidian/plugins/webinar-dash/test/coverage.test.js`: - -```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** - -```bash -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 `reconcileConcepts` and export it** - -Insert into `main.js` below `groupByStation`: - -```js -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** - -```bash -node --test test/coverage.test.js -``` - -Expected: PASS, 13 tests. - -- [ ] **Step 5: Add the coverage styles** - -Append to `styles.css`: - -```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`: - -```js -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: - -```js - 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. - -```js - 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** - -```bash -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`'s `onIngest(file, rowEl)` callback from Task 3 -- Produces: - - `isSafeFilename(name) => boolean` - - `runIngest(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. - -```js -"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); -}); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -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`: - -```js -// `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, plus every control character. -// -// `^` is cmd.exe's escape character and belongs to the same class Node escapes -// in its CVE-2024-27980 mitigation for exactly this spawn-through-.cmd shape. -// Control characters are rejected because a NUL byte makes spawn() throw -// synchronously, which would otherwise strand the row mid-run. -// -// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they are safe -// inside double quotes and appear in real filenames in this vault. (`!` would -// matter only under `setlocal enabledelayedexpansion`, which is not in play.) -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; -} -``` - -Add `isSafeFilename` to the `__test__` export object. - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -node --test test/safety.test.js -``` - -Expected: PASS, 5 tests. - -- [ ] **Step 5: Add ingest-state styles** - -Append to `styles.css`: - -```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 `runIngest` on 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`. - -```js -Add the in-flight registry as the first line of `onload()`, before the code-block -processor is registered. It lives on the plugin instance so it survives the row -re-renders that a DOM-scoped guard cannot: - -```js - async onload() { - this.running = new Set(); - // ... existing registerMarkdownCodeBlockProcessor call follows unchanged -``` - -Then add both methods to the class: - -```js - vaultPath() { - const adapter = this.app.vault.adapter; - if (typeof adapter.getBasePath === "function") return adapter.getBasePath(); - return null; - } - - runIngest(file, rowEl) { - const Notice = OB ? OB.Notice : null; - const notify = (msg) => { if (Notice) new Notice(msg); }; - - // In-flight state is keyed on the file, not the row. Obsidian rebuilds the - // row on every re-render, so a row-scoped guard would let a re-render hand - // out a fresh row whose guard is unset — and a second click would then run - // a second unsupervised agent against the same file, concurrently writing - // the same wiki pages as the first. - if (this.running.has(file.path)) { - notify(`Already ingesting ${file.name}.`); - return; - } - - 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; - } - - // A retry reuses the same row. Clear the previous run's status and output - // so they are replaced rather than stacked on top of each other. - rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); - - const button = rowEl.querySelector("button"); - if (button) button.disabled = true; - this.running.add(file.path); - - const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); - const output = rowEl.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, so a long or noisy - // run cannot grow this string without limit. - let buffered = ""; - const append = (text) => { - buffered = (buffered + text).slice(-8000); - output.setText(buffered); - output.scrollTop = output.scrollHeight; - }; - - // Single exit path: every way this run can end clears the timer, releases - // the file, and re-enables the button. - const finish = (cls, label) => { - window.clearInterval(timer); - this.running.delete(file.path); - status.className = `wd-status ${cls}`; - status.setText(label); - if (button) button.disabled = false; - }; - - let child; - try { - child = spawn("claude", ["-p", `ingest "${file.name}"`], { - cwd: base, - shell: true, - }); - } catch (err) { - // spawn() throws synchronously for some argument shapes. Without this the - // timer would run forever and the row would stay disabled until reload. - append(`\nCould not start claude: ${err.message}`); - finish("wd-status-failed", "failed"); - return; - } - - // Decode as UTF-8 across chunk boundaries. Raw Buffer chunks split wherever - // the OS buffer ends, and this vault's output is full of Cyrillic and em - // dashes that would otherwise decode as replacement characters. - 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`); - notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); - } else { - finish("wd-status-failed", `failed - exit ${code}`); - } - }); - } -``` - -- [ ] **Step 7: Pass the real handler into the left pane** - -In the code-block processor, replace the no-op: - -```js - 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. - -```bash -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: - -```bash -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** - -```bash -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, 27 tests across three files. - -- [ ] **Step 11: Commit** - -```bash -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 5 — 27 total, matching Step 10 of Task 7. +# 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.js` is loaded by Obsidian verbatim. No TypeScript, no esbuild, no `npm install`. +- **No dependencies.** Tests use Node's built-in `node:test` and `node:assert/strict` only. +- **`isDesktopOnly: true`** is mandatory in `manifest.json` — the plugin uses `child_process`, which Obsidian only provides on desktop. +- **`minAppVersion`: `"1.5.0"`.** +- **Relative `require()` between plugin files is unsupported.** All code lives in one `main.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-dash` code-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. + +```bash +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. + +```gitignore +.obsidian/workspace.json +.obsidian/workspace-mobile.json +.obsidian/cache +``` + +- [ ] **Step 3: Commit the vault as it stands** + +```bash +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. + +```json +{ + "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.js` with 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__`. + +```js +"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`** + +````markdown +# 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** + +```bash +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 | null` + - `derivePipeline({ rawFiles, sourcePages }) => { processed, unprocessed, orphaned }` + - `rawFiles` items: `{ path: string, name: string, size: number }` + - `sourcePages` items: `{ path: string, name: string, rawPath: string | null }` + - `processed` items: `rawFiles` item 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. + +```js +"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** + +```bash +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: + +```js +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`: + +```js +module.exports.__test__ = { parseConfig, extractRawPath, derivePipeline, STATIONS, DEFAULTS }; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +node --test test/pipeline.test.js +``` + +Expected: PASS, 9 tests. + +- [ ] **Step 6: Commit** + +```bash +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`, `extractRawPath` from Task 2; `parseConfig` from Task 1 +- Produces: + - `async readPipeline(app, cfg) => { processed, unprocessed, orphaned }` as a method on the plugin class + - `renderLeftPane(container, pipeline, onIngest)` where `onIngest` is `(file, rowEl) => void`; Task 6 supplies the real handler, this task passes a no-op + - CSS class root `.webinar-dash` and the shared primitives `.wd-eyebrow`, `.wd-row`, `.wd-btn`, `.wd-mono` + +- [ ] **Step 1: Create `styles.css` with 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. + +```css +.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. + +```js +// 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. + +```js +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 unprocessed` listing `Agentic Engineering, explained by a 10x developer.md` (15,020 B), `Webinar Plan - From Chat Box to Your Own OS.md` (17,177 B), and `Webinar script.md` (15,841 B), each with a red **Ingest** button that does nothing yet. +- `Ingested — 9` listing the source summaries newest first, starting with `2026-07-24`. +- No orphaned block. + +- [ ] **Step 5: Run the existing tests to confirm nothing regressed** + +```bash +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** + +```bash +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: `STATIONS` from Task 1 +- Produces: + - `parseCoverageTable(markdown) => { meta, rows, errors }` + - `meta`: `{ script: string | null, lastSynced: string | null }` + - `rows` items: `{ concept: string, status: "covered"|"partial"|"absent", stations: string[], pinned: boolean, line: number }` + - `errors` items: `{ line: number, text: string, reason: string }` + - `groupByStation(rows) => Array<{ station: string, rows: Row[] }>` ordered `All stations`, then the seven stations, then `No station`; empty groups omitted + +- [ ] **Step 1: Write the failing tests** + +Create `.obsidian/plugins/webinar-dash/test/coverage.test.js`. + +```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** + +```bash +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. + +```js +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** + +```js +module.exports.__test__ = { + parseConfig, extractRawPath, derivePipeline, + parseCoverageTable, groupByStation, + STATIONS, DEFAULTS, +}; +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +```bash +node --test test/coverage.test.js +``` + +Expected: PASS, 11 tests. + +- [ ] **Step 6: Commit** + +```bash +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.md` conforming to that format, with one row per page in `wiki/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. + +```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 | | +| [[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: + +```text + 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: + +```text +| `wiki/script-coverage.md` | `#coverage` | +``` + +- [ ] **Step 4: Add Workflow D to `CLAUDE.md`** + +Append to `## Standard Workflows`, after Workflow C: + +```markdown +### 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: + +```markdown +- "sync script coverage" +``` + +- [ ] **Step 6: Update `index.md`** + +Add a section after `## Sources`, and correct the stale not-yet-ingested line. + +```markdown +## 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: + +```markdown +**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`** + +```markdown +## 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** + +```bash +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** + +```bash +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`, `groupByStation` from Task 4; `addIcon` from Task 3; `wiki/script-coverage.md` from Task 5 +- Produces: + - `reconcileConcepts(rows, conceptNames) => { rows, unsynced, stale }` where `unsynced` is concept names with no row and `stale` is rows whose concept page is gone + - `renderRightPane(container, parsed, reconciled)` + +- [ ] **Step 1: Add the reconcile helper and its tests** + +Append to `.obsidian/plugins/webinar-dash/test/coverage.test.js`: + +```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** + +```bash +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 `reconcileConcepts` and export it** + +Insert into `main.js` below `groupByStation`: + +```js +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** + +```bash +node --test test/coverage.test.js +``` + +Expected: PASS, 13 tests. + +- [ ] **Step 5: Add the coverage styles** + +Append to `styles.css`: + +```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`: + +```js +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: + +```js + 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. + +```js + 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** + +```bash +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`'s `onIngest(file, rowEl)` callback from Task 3 +- Produces: + - `isSafeFilename(name) => boolean` + - `runIngest(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. + +```js +"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); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +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`: + +```js +// `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, plus every control character. +// +// `^` is cmd.exe's escape character and belongs to the same class Node escapes +// in its CVE-2024-27980 mitigation for exactly this spawn-through-.cmd shape. +// Control characters are rejected because a NUL byte makes spawn() throw +// synchronously, which would otherwise strand the row mid-run. +// +// Apostrophes, spaces, cyrillic, em dashes and `!` stay allowed — they are safe +// inside double quotes and appear in real filenames in this vault. (`!` would +// matter only under `setlocal enabledelayedexpansion`, which is not in play.) +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; +} +``` + +Add `isSafeFilename` to the `__test__` export object. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +node --test test/safety.test.js +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Add ingest-state styles** + +Append to `styles.css`: + +```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 `runIngest` on 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`. + +```js +Add the in-flight registry as the first line of `onload()`, before the code-block +processor is registered. It lives on the plugin instance so it survives the row +re-renders that a DOM-scoped guard cannot: + +```js + async onload() { + this.running = new Set(); + // ... existing registerMarkdownCodeBlockProcessor call follows unchanged +``` + +Then add both methods to the class: + +```js + vaultPath() { + const adapter = this.app.vault.adapter; + if (typeof adapter.getBasePath === "function") return adapter.getBasePath(); + return null; + } + + runIngest(file, rowEl) { + const Notice = OB ? OB.Notice : null; + const notify = (msg) => { if (Notice) new Notice(msg); }; + + // In-flight state is keyed on the file, not the row. Obsidian rebuilds the + // row on every re-render, so a row-scoped guard would let a re-render hand + // out a fresh row whose guard is unset — and a second click would then run + // a second unsupervised agent against the same file, concurrently writing + // the same wiki pages as the first. + if (this.running.has(file.path)) { + notify(`Already ingesting ${file.name}.`); + return; + } + + 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; + } + + // A retry reuses the same row. Clear the previous run's status and output + // so they are replaced rather than stacked on top of each other. + rowEl.querySelectorAll(".wd-status, .wd-output").forEach((el) => el.remove()); + + const button = rowEl.querySelector("button"); + if (button) button.disabled = true; + this.running.add(file.path); + + const status = rowEl.createSpan({ cls: "wd-status wd-status-running", text: "running 0s" }); + const output = rowEl.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, so a long or noisy + // run cannot grow this string without limit. + let buffered = ""; + const append = (text) => { + buffered = (buffered + text).slice(-8000); + output.setText(buffered); + output.scrollTop = output.scrollHeight; + }; + + // Single exit path: every way this run can end clears the timer, releases + // the file, and re-enables the button. + const finish = (cls, label) => { + window.clearInterval(timer); + this.running.delete(file.path); + status.className = `wd-status ${cls}`; + status.setText(label); + if (button) button.disabled = false; + }; + + let child; + try { + child = spawn("claude", ["-p", `ingest "${file.name}"`], { + cwd: base, + shell: true, + }); + } catch (err) { + // spawn() throws synchronously for some argument shapes. Without this the + // timer would run forever and the row would stay disabled until reload. + append(`\nCould not start claude: ${err.message}`); + finish("wd-status-failed", "failed"); + return; + } + + // Decode as UTF-8 across chunk boundaries. Raw Buffer chunks split wherever + // the OS buffer ends, and this vault's output is full of Cyrillic and em + // dashes that would otherwise decode as replacement characters. + 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`); + notify(`Ingested ${file.name}. Reopen the dashboard to refresh.`); + } else { + finish("wd-status-failed", `failed - exit ${code}`); + } + }); + } +``` + +- [ ] **Step 7: Pass the real handler into the left pane** + +In the code-block processor, replace the no-op: + +```js + 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. + +```bash +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: + +```bash +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** + +```bash +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, 27 tests across three files. + +- [ ] **Step 11: Commit** + +```bash +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 5 — 27 total, matching Step 10 of Task 7. diff --git a/docs/superpowers/specs/2026-07-28-webinar-dashboard-design.md b/docs/superpowers/specs/2026-07-28-webinar-dashboard-design.md index 477195b..25fc39f 100644 --- a/docs/superpowers/specs/2026-07-28-webinar-dashboard-design.md +++ b/docs/superpowers/specs/2026-07-28-webinar-dashboard-design.md @@ -1,256 +1,256 @@ -# 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. +# 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. diff --git a/index.md b/index.md index 7e69d5c..e078de1 100644 --- a/index.md +++ b/index.md @@ -1,89 +1,89 @@ -# Wiki Index - -Content catalog for this vault. Updated after every ingest / query / lint operation that changes the wiki. - -See [[overview]] for the top-level synthesis and navigation. - -Every wiki page carries a page-type tag under its H1 (`#source`, `#entity`, `#concept`, `#timeline`, `#comparison`, `#query`, `#lint-report`, `#overview`) — see "Tagging Rules" in `CLAUDE.md`. - -## Sources - -- [[2026-07-14-everything-we-knew-about-software-has-changed]] — Theo Browne (AIE): model eras, think wider, code as throwaway _(raw: Everything we knew about software has changed.md)_ -- [[2026-07-14-gap-between-ai-users-irreversible]] — Allie Miller: personal AI OS, foundation docs, skills, proactive workflows _(raw: In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md)_ -- [[2026-07-14-sebastian-eugene-interview]] — Sebastian + Eugene: harness, team collapse, connections, enterprise reality _(raw: sebastian interview - conclusions and insights.md)_ -- [[2026-07-14-skills-based-on-git]] — Konstantin (Sber): git skills as agent memory, harness, agent loops _(raw: Скиллы на базе git — новая память AI-агентов.md)_ -- [[2026-07-14-nina-interview]] — Eugene + Nina (HR): transcript > summary, friction not resistance, skills as handoff _(raw: Nina interview.md)_ -- [[2026-07-14-yulia-interview]] — Eugene + Yulia (HR lead): levels of AI usage, candidate knowledge base, solve-first _(raw: Yulia interview.md)_ -- [[2026-07-21-larysa-interview]] — Eugene + Larysa (BA/PM): memory loss, integration dead-ends, less room for imagination _(raw: Larysa interview.md)_ -- [[2026-07-22-ai-is-stupid]] — YouTube short (author unknown): "stupid AI" = model minus context minus harness; Nobel-vs-employee analogy _(raw: ИИ глупый!.md)_ -- [[2026-07-24-youre-reading-way-too-much-code]] — Theo Browne (video): make more cheap code, four tiers of code, 100:1 slop-to-ship verification _(raw: You're reading way too much code.md)_ - -**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` - -## Coverage - -- [[script-coverage]] — every concept vs `raw/sources/Webinar script.md`; 5 covered, 4 partial, 10 absent - -## Entities - -### People -- [[theo-browne]] — developer/educator (t3.gg); "think wider", "make more cheap code" -- [[allie-miller]] — ex-Amazon AI leader; personal AI OS -- [[sebastian]] — founder of Virtido; enterprise/connections lens -- [[eugene]] — developer, harness-builder, webinar author; interviewer (likely vault owner, tentative) -- [[konstantin]] — Sber/GigaChat R&D; skills-as-memory -- [[nina]] — HR recruiter at Virtido; webinar-audience proxy, use-case supplier -- [[yulia]] — HR/recruiting lead; webinar organizer (name/affiliation tentative) -- [[larysa]] — technical BA/PM, ex-mobile dev; advanced user blocked by memory + integrations - -### Tools / Orgs -- [[claude-code]] — reference harness (all sources) -- [[hermes]] — skills-first, self-curating harness -- [[virtido]] — Sebastian's outsourcing company; its HR team is the webinar audience -- [[inspectron]] — Eugene's employer (Edge Compute / IoT) - -## Concepts - -**Machine side** -- [[harness]] — universal agent = LLM + small toolset + loop -- [[skills-as-memory]] — git skills (SKILL.md + tools + data) as agent memory -- [[evolution-of-agent-tooling]] — tools → MCP → skills -- [[agentic-loops]] — inner (ReAct) / outer (Ralph) / meta -- [[context-as-scarce-resource]] — the binding constraint; the "smart zone" -- [[personal-ai-operating-system]] — Allie's context docs + skills + proactive workflows -- [[levels-of-ai-usage]] — Eugene's ladder: chatbot → … → CLAUDE.md + skills (the non-programmer ceiling) -- [[solve-first-then-skillify]] — solve the task once, then freeze it into a skill -- [[integration-dead-ends]] — agent starts work against connectors the user's account doesn't have -- [[leave-less-room-for-imagination]] — every gap in a spec gets filled, invisibly; tighten it - -**Human side** -- [[product-ownership]] — own outcomes, frame problems not tickets -- [[connections-as-moat]] — in-person relationships as the last non-commoditized asset -- [[network-from-a-standing-start]] — tentative from-zero networking protocol (v0, to be validated) -- [[seniority-and-the-junior-squeeze]] — judgment as risk-reduction -- [[decoupling-identity-from-profession]] — separate who you are from what you do - -**Strategy side** -- [[think-wider-not-bigger]] — breadth over depth; match ambition to the model -- [[code-as-throwaway]] — cost of code → zero -- [[make-more-cheap-code]] — Theo: four tiers of code; generate never-shipped slop to verify/explore; there's always another layer -- [[enterprise-ai-reality]] — compliance lock-down; the company-managed-harness market - -## Timelines - -- [[ai-agent-evolution]] — agent/tooling timeline + Theo's model eras - -## Comparisons - -- [[theo-konstantin-allie]] — Theo vs Konstantin vs Allie: three lenses (strategy / engineering / personal-OS) on the same shift; shared markdown-as-unit and system-over-model, differing altitude - -## Queries - -- [[2026-07-14-best-first-skill-for-beginner]] — best first skill for a Claude beginner: skill-creator (meta) + tone-of-voice/anti-AI-language; foundation docs first -- [[2026-07-14-network-from-standing-start]] — network-from-zero: tentative protocol + Sebastian round-2 interview instrument (10 questions) -- [[2026-07-22-webinar-theses]] — 14 candidate theses for the webinar, grouped spine / stakes / obstacles / method / tensions -- [[2026-07-24-non-engineer-throwaway-verification]] — non-engineer analog of throwaway verification code: generated checks not content; checker skills; drift as diagnostic - -## Lint Reports - -_None yet._ +# Wiki Index + +Content catalog for this vault. Updated after every ingest / query / lint operation that changes the wiki. + +See [[overview]] for the top-level synthesis and navigation. + +Every wiki page carries a page-type tag under its H1 (`#source`, `#entity`, `#concept`, `#timeline`, `#comparison`, `#query`, `#lint-report`, `#overview`) — see "Tagging Rules" in `CLAUDE.md`. + +## Sources + +- [[2026-07-14-everything-we-knew-about-software-has-changed]] — Theo Browne (AIE): model eras, think wider, code as throwaway _(raw: Everything we knew about software has changed.md)_ +- [[2026-07-14-gap-between-ai-users-irreversible]] — Allie Miller: personal AI OS, foundation docs, skills, proactive workflows _(raw: In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md)_ +- [[2026-07-14-sebastian-eugene-interview]] — Sebastian + Eugene: harness, team collapse, connections, enterprise reality _(raw: sebastian interview - conclusions and insights.md)_ +- [[2026-07-14-skills-based-on-git]] — Konstantin (Sber): git skills as agent memory, harness, agent loops _(raw: Скиллы на базе git — новая память AI-агентов.md)_ +- [[2026-07-14-nina-interview]] — Eugene + Nina (HR): transcript > summary, friction not resistance, skills as handoff _(raw: Nina interview.md)_ +- [[2026-07-14-yulia-interview]] — Eugene + Yulia (HR lead): levels of AI usage, candidate knowledge base, solve-first _(raw: Yulia interview.md)_ +- [[2026-07-21-larysa-interview]] — Eugene + Larysa (BA/PM): memory loss, integration dead-ends, less room for imagination _(raw: Larysa interview.md)_ +- [[2026-07-22-ai-is-stupid]] — YouTube short (author unknown): "stupid AI" = model minus context minus harness; Nobel-vs-employee analogy _(raw: ИИ глупый!.md)_ +- [[2026-07-24-youre-reading-way-too-much-code]] — Theo Browne (video): make more cheap code, four tiers of code, 100:1 slop-to-ship verification _(raw: You're reading way too much code.md)_ + +**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` + +## Coverage + +- [[script-coverage]] — every concept vs `raw/sources/Webinar script.md`; 5 covered, 4 partial, 10 absent + +## Entities + +### People +- [[theo-browne]] — developer/educator (t3.gg); "think wider", "make more cheap code" +- [[allie-miller]] — ex-Amazon AI leader; personal AI OS +- [[sebastian]] — founder of Virtido; enterprise/connections lens +- [[eugene]] — developer, harness-builder, webinar author; interviewer (likely vault owner, tentative) +- [[konstantin]] — Sber/GigaChat R&D; skills-as-memory +- [[nina]] — HR recruiter at Virtido; webinar-audience proxy, use-case supplier +- [[yulia]] — HR/recruiting lead; webinar organizer (name/affiliation tentative) +- [[larysa]] — technical BA/PM, ex-mobile dev; advanced user blocked by memory + integrations + +### Tools / Orgs +- [[claude-code]] — reference harness (all sources) +- [[hermes]] — skills-first, self-curating harness +- [[virtido]] — Sebastian's outsourcing company; its HR team is the webinar audience +- [[inspectron]] — Eugene's employer (Edge Compute / IoT) + +## Concepts + +**Machine side** +- [[harness]] — universal agent = LLM + small toolset + loop +- [[skills-as-memory]] — git skills (SKILL.md + tools + data) as agent memory +- [[evolution-of-agent-tooling]] — tools → MCP → skills +- [[agentic-loops]] — inner (ReAct) / outer (Ralph) / meta +- [[context-as-scarce-resource]] — the binding constraint; the "smart zone" +- [[personal-ai-operating-system]] — Allie's context docs + skills + proactive workflows +- [[levels-of-ai-usage]] — Eugene's ladder: chatbot → … → CLAUDE.md + skills (the non-programmer ceiling) +- [[solve-first-then-skillify]] — solve the task once, then freeze it into a skill +- [[integration-dead-ends]] — agent starts work against connectors the user's account doesn't have +- [[leave-less-room-for-imagination]] — every gap in a spec gets filled, invisibly; tighten it + +**Human side** +- [[product-ownership]] — own outcomes, frame problems not tickets +- [[connections-as-moat]] — in-person relationships as the last non-commoditized asset +- [[network-from-a-standing-start]] — tentative from-zero networking protocol (v0, to be validated) +- [[seniority-and-the-junior-squeeze]] — judgment as risk-reduction +- [[decoupling-identity-from-profession]] — separate who you are from what you do + +**Strategy side** +- [[think-wider-not-bigger]] — breadth over depth; match ambition to the model +- [[code-as-throwaway]] — cost of code → zero +- [[make-more-cheap-code]] — Theo: four tiers of code; generate never-shipped slop to verify/explore; there's always another layer +- [[enterprise-ai-reality]] — compliance lock-down; the company-managed-harness market + +## Timelines + +- [[ai-agent-evolution]] — agent/tooling timeline + Theo's model eras + +## Comparisons + +- [[theo-konstantin-allie]] — Theo vs Konstantin vs Allie: three lenses (strategy / engineering / personal-OS) on the same shift; shared markdown-as-unit and system-over-model, differing altitude + +## Queries + +- [[2026-07-14-best-first-skill-for-beginner]] — best first skill for a Claude beginner: skill-creator (meta) + tone-of-voice/anti-AI-language; foundation docs first +- [[2026-07-14-network-from-standing-start]] — network-from-zero: tentative protocol + Sebastian round-2 interview instrument (10 questions) +- [[2026-07-22-webinar-theses]] — 14 candidate theses for the webinar, grouped spine / stakes / obstacles / method / tensions +- [[2026-07-24-non-engineer-throwaway-verification]] — non-engineer analog of throwaway verification code: generated checks not content; checker skills; drift as diagnostic + +## Lint Reports + +_None yet._ diff --git a/log.md b/log.md index 9313e90..171c562 100644 --- a/log.md +++ b/log.md @@ -1,157 +1,157 @@ -# Operation Log - -Append-only chronological record of wiki operations. Newest entries at the bottom. - -Entry format: - -``` -## YYYY-MM-DD — <operation> -- Intent: ingest | query | lint | maintenance | sync -- Input: <source path / question / scope> -- Pages changed: [[...]], [[...]] -- Notes: <what changed, uncertainty, next steps> -``` - ---- - -## 2026-07-14 — maintenance -- Intent: maintenance -- Input: Initialize vault folder structure. -- Pages changed: created `index.md`, `log.md`, `wiki/overview.md`; created `raw/{sources,assets}` and `wiki/{sources,entities,concepts,timelines,comparisons,queries,lint-reports}`. -- Notes: Empty vault scaffolded per the LLM Wiki Schema in `CLAUDE.md`. No sources ingested yet. Ready for first "ingest <source>". - -## 2026-07-14 — ingest (batch, 4 sources) -- Intent: ingest -- Input: The 4 talk/interview docs (user-selected scope; "Ideas for webinar" + "HR Contacts" left as raw reference). - - `raw/sources/Everything we knew about software has changed.md` - - `raw/sources/In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md` - - `raw/sources/sebastian interview - conclusions and insights.md` - - `raw/sources/Скиллы на базе git — новая память AI-агентов.md` -- Pages created — sources (4): [[2026-07-14-everything-we-knew-about-software-has-changed]], [[2026-07-14-gap-between-ai-users-irreversible]], [[2026-07-14-sebastian-eugene-interview]], [[2026-07-14-skills-based-on-git]]. -- Pages created — entities (8): [[theo-browne]], [[allie-miller]], [[sebastian]], [[eugene]], [[konstantin]], [[claude-code]], [[hermes]], [[virtido]]. -- Pages created — concepts (13): [[harness]], [[skills-as-memory]], [[evolution-of-agent-tooling]], [[agentic-loops]], [[context-as-scarce-resource]], [[personal-ai-operating-system]], [[product-ownership]], [[connections-as-moat]], [[seniority-and-the-junior-squeeze]], [[decoupling-identity-from-profession]], [[think-wider-not-bigger]], [[code-as-throwaway]], [[enterprise-ai-reality]]. -- Pages created — timelines (1): [[ai-agent-evolution]]. -- Pages updated: [[overview]] (added the cross-source through-line + agree/diverge), `index.md` (full catalog). -- Notes: Strong cross-source convergence (skills-as-memory: Konstantin ↔ Allie; connections: Sebastian ↔ Allie; code-as-throwaway: Theo ↔ Sebastian). Recorded contradictions (Eugene vs Sebastian on BYO- vs company-managed harness, online vs in-person networking, OSS motivation). Tentative claims flagged: Theo's model-era names, Eugene = vault owner, Hermes/Ralph heuristics, Allie's self-learning-models prediction. -- Next: consider ingesting "Ideas for webinar" (connects corpus → webinar plan) and "HR Contacts" (the HR-search demo); consider a `wiki/comparisons/` page for the BYO-vs-managed-harness debate; run "lint wiki" to check link health. - -## 2026-07-14 — maintenance (tagging convention) -- Intent: maintenance -- Input: user request — add per-type hashtags to the wiki schema and existing pages. -- Pages changed: `CLAUDE.md` (new "Tagging Rules" section, rule 9, templates + lint checklist updated); all 26 wiki pages received a page-type tag line under their H1 — [[overview]] `#overview`, 4 source pages `#source`, 8 entity pages `#entity`, 13 concept pages `#concept`, [[ai-agent-evolution]] `#timeline`; `index.md` (noted the convention). -- Notes: Tag mapping is folder-driven (`#source`, `#entity`, `#concept`, `#timeline`, `#comparison`, `#query`, `#lint-report`, `#overview`). Exactly one type tag per page, first on the tag line; topical tags may follow. Lint now checks for missing/mismatched type tags. - -## 2026-07-14 -- query -- Intent: query -- Input: "what would be the best claude skill to give to a claude beginner user?" -- Pages changed: created [[2026-07-14-best-first-skill-for-beginner]]; updated [[skills-as-memory]] (Next Questions item answered + linked), `index.md` (Queries section). -- Notes: Answer synthesized from Allie Miller (skill-creator, tone-of-voice/anti-AI-language starter set, foundation-docs-first) and Konstantin (>5-tool-calls auto-creation heuristic). Follow-up: contents of a good tone-of-voice SKILL.md; foundation-doc templates. - -## 2026-07-14 — maintenance (authorized raw edit) -- Intent: maintenance -- Input: user explicitly authorized editing `raw/sources/Webinar script.md` this session (exception to rule 1). -- Pages changed: `raw/sources/Webinar script.md` — completed the dangling Tools→Memory bridge line, wrote the full **# Memory** section (amnesia demo → notebook tool → close/reopen payoff → system-prompt reveal → user.md beat → tiny-notebook limitation as the Skills hook), and renamed "Markus" → "Marcus" to match the demo app's seeded mail (`Marcus Hale`). -- Notes: Memory section is grounded in the actual mini-game behavior (Level 04 in `D:\Projects\Names\Webinars\From_chat_to_os`): frozen snapshot per session (hence the mandatory close/reopen), `memory.md` + `user.md` on disk, ~2200-char budget motivating Skills. No wiki pages changed. Next: draft **# Skills** section (open with on-demand loading answering the phone-book limitation). - -## 2026-07-14 — maintenance (authorized raw edit, Skills section) -- Intent: maintenance -- Input: user request — draft the **# Skills** section in `raw/sources/Webinar script.md` (edit permission granted this session). Corrected flow per user: presenter dictates the operations step by step first, *then* asks AI to package the procedure as a skill. -- Pages changed: `raw/sources/Webinar script.md` — full Skills section (step-by-step dictation → "I was the recipe" → save-as-skill → SKILL.md on disk → fresh-session one-liner → progressive-disclosure reveal → portability beat → "I'm still the alarm clock" hook into Process). -- Notes: Grounded in the app's Skills level (Level 05): `save_skill`/`load_skill`, real `skills/<name>/SKILL.md`, live menu (names+descriptions only) rebuilt per window. No wiki pages changed. Next: draft **# Process** (Level 06 — spawn_process scheduler answers the alarm-clock limitation). - -## 2026-07-14 — maintenance (authorized raw edit, Process section) -- Intent: maintenance -- Input: user request — draft the **# Process** section in `raw/sources/Webinar script.md` (session edit permission). -- Pages changed: `raw/sources/Webinar script.md` — full Process section (goal not task → spawn_process with pid → hands-off heartbeats → Override slider makes the cube move by itself → ps/kill → reveal: the shell authored the worker's prompt (AI managing AI) → closing arc: same model all six levels, only the harness grew; harness is unique to you; "from a chat box to your own Agentic Operating System"). Also two-branch rule fixes earlier this session (Memory convention + Skills dictation). -- Notes: Grounded in Level 06 design (`2026-07-11-process-level-design.md`): single ~5s scheduler, stateless ticks, spawn/list/stop syscalls, live −5…+5 weather offset. The "same model, growing harness" close absorbs the `_ideas:` note at the top of the script. Script now has all sections drafted (Intro → Process). - -## 2026-07-14 — maintenance (authorized raw edit, OS section) -- Intent: maintenance -- Input: user request — add an **# OS** section to `raw/sources/Webinar script.md` (session edit permission): typing removed, mouse-only interaction with the agent. -- Pages changed: `raw/sources/Webinar script.md` — Process now ends with a "still a terminal / most people never will type" hook; new OS section (one-button app → rule baked in → 5-second checkbox → appliance → "the agent became a program / the chat box dissolved into the OS"); the "same model, growing harness" closing arc moved from Process into OS, with the new final callback "a button that already knows what the email said." -- Notes: Grounded in the OS app design (`2026-07-12-os-app-design.md`): tile 07, status bar mirrors the agent's closing sentence, shared scheduler slot, Override interplay, rule baked into the prompt. Script ladder now: Chat box → ReAct → Tools → Memory → Skills → Process → OS. - -## 2026-07-14 — maintenance (authorized raw edit, OS reframing) -- Intent: maintenance -- Input: user correction — the webinar's core idea is "make tools for yourself," not "become a product." -- Pages changed: `raw/sources/Webinar script.md` — Process→OS hook now ends "…and become a tool? A small one. Made for exactly one person."; OS landing gained the intro callback (weekends burned on internal tools → this one took an evening, asked-for not written) and "It dissolved — into the operating system. Into little tools you make for yourself."; closing arc adds "You don't buy it. You build it — one small tool at a time." -- Notes: The OS section now closes the loop with the Intro's internal-tools passion. No wiki pages changed. - -## 2026-07-14 — ingest (batch, 2 sources: Nina + Yulia interviews) -- Intent: ingest -- Input: `raw/sources/Nina interview.md`, `raw/sources/Yulia interview.md` (both are conclusions docs auto-generated by Eugene's record→diarize→transcribe→summarize tool). -- Pages created — sources (2): [[2026-07-14-nina-interview]], [[2026-07-14-yulia-interview]]. -- Pages created — entities (3): [[nina]] (HR recruiter, Virtido), [[yulia]] (HR lead; name/affiliation tentative, inferred from filename + Nina reference), [[inspectron]] (Eugene's employer). -- Pages created — concepts (2): [[levels-of-ai-usage]] (chatbot → memory → Claude Code → CLAUDE.md → skills → KB → RAG; non-programmer ceiling = CLAUDE.md + skills), [[solve-first-then-skillify]] (solve once, then freeze into a skill; ~3-message and >5-tool-call heuristics). -- Pages updated: [[eugene]] (identity evidence strengthened — webinar author/presenter, Inspectron, $200 max plan, tool builder; still tentative), [[virtido]] (HR team = webinar audience, pain points, tooling), [[skills-as-memory]] (handoff/de-risking payoff, ~3-message heuristic, method link), [[connections-as-moat]] (Eugene independently converges: "the human's role is just to be human"), [[overview]] (6 sources, demand-side bullet, agree/diverge, navigation, open questions), `index.md`. -- Notes: Strong convergence — Eugene ↔ Sebastian/Allie on human-connection residual despite their networking-tactics disagreement; Eugene's ~3-message heuristic ↔ Konstantin's >5-tool-call heuristic. New cross-interview finding: adoption blocked by friction, not resistance. Tentative flags: Yulia's name/affiliation; Eugene's price-rise prediction; Inspectron employment-vs-contracting. Skills-IP-vs-work-product dispute (Eugene vs Sebastian) recorded, unresolved. -- Next: ingest "Ideas for webinar" / "Webinar Plan" / "Webinar script" to connect corpus → deliverable; interview Larysa/Larisa (PM use cases, twice flagged); consider a comparison page "personal vs employer ownership of skills". - -## 2026-07-14 — query (network from a standing start) -- Intent: query -- Input: "how does an individual actually build a network from a standing start?" + "go" on the recommended plan (tentative protocol + Sebastian follow-up instrument). -- Pages created: [[network-from-a-standing-start]] (concept, Status: tentative — 5-step protocol: recurring venues → cadence → lead with humanness → engineer second meetings → track second meetings/let referrals work; contested anti-tactics list; webinar flagged as live case study), [[2026-07-14-network-from-standing-start]] (query — 10-question Sebastian round-2 interview instrument in 3 blocks: bootstrap biography / mechanics / falsification of "Big zero"). -- Pages updated: [[connections-as-moat]] (open question → protocol + validation plan), [[overview]] (vault-level open question annotated), `index.md` (Concepts + Queries sections). -- Notes: Corpus cannot answer the question directly — protocol is inferred from Sebastian's principles, Allie's prediction, and Eugene's convergent thesis; every claim marked tentative/contested. Key falsification target: whether Sebastian ever had a true standing start (prior-job network as seed). Deep-research complement (weak ties, mere exposure, from-zero playbooks, scoped to test "Big zero") described but not run. -- Next: run the round-2 Sebastian interview and ingest it; decide on the deep-research run; track webinar → paid-HR-build chain as case-study evidence. - -## 2026-07-16 — query → comparison -- Intent: query -- Input: "Compare Theo's, Konstantin's ('Sonstantine') and Allie's approaches — what they have in common, what they have different?" -- Pages created: [[theo-konstantin-allie]] (first `wiki/comparisons/` page — three-lens side-by-side: at-a-glance table, 6 shared points, differences by altitude/framing/code/learning/register, complementary tensions). -- Pages updated: [[theo-browne]], [[konstantin]], [[allie-miller]] (added inbound "Comparison:" links to avoid orphan); `index.md` (Comparisons section — first entry). -- Notes: Synthesized from the 3 source summaries + entity pages + [[skills-as-memory]]. Common ground: bottleneck moved model→human system; markdown/skills as the atomic unit; infrastructure compounds; system-ready to absorb new models; context as scarce resource; shared [[claude-code]]. Differences framed as *altitude* (Theo strategy/psychology · Konstantin engineering · Allie individual-productivity), not direction — corpus disagreements lie elsewhere. Recorded the throwaway-code (Theo) vs persist-in-git (Konstantin) tension as complementary (artifact vs capability). No new contradictions. Saved as a comparison rather than a query page since the request was explicitly a side-by-side. -- Next: reusable starter template (foundation docs / good SKILL.md) still unresolved across all three — webinar-relevant; consider whether Konstantin's meta-loop (wipe-and-restart) tensions with "persist everything". - -## 2026-07-16 — maintenance (table fix) -- Intent: maintenance -- Input: user report — the table in [[ai-agent-evolution]] "looks broken". -- Pages changed: [[ai-agent-evolution]] — repaired the "Agent architecture & tooling (Konstantin)" table; escaped the `|` inside aliased wiki links (`[[harness\|harnesses]]`, `[[skills-as-memory\|skills]]`, `[[agentic-loops\|agent loops]]`) so Obsidian no longer reads them as column separators, and removed the two phantom trailing columns the unescaped pipes had forced into the header/separator. Now a clean 3-column table (Period / Milestone / Significance). -- Notes: Content unchanged — formatting-only fix. The Theo Browne table was already valid. No `index.md` change (no catalog/structural change). Root cause: piped `[[page|alias]]` links inside GFM/Obsidian table cells require the pipe escaped as `\|`. - -## 2026-07-21 — ingest (Larysa interview) -- Intent: ingest -- Input: "ingest Larysa interview.md" → `raw/sources/Larysa interview.md` (auto-generated interview conclusions doc; Eugene × Larysa, technical BA/PM and ex-mobile dev). -- Pages created: [[2026-07-21-larysa-interview]] (source), [[larysa]] (entity), [[integration-dead-ends]] (concept — agent begins work against connectors gated by account tier / paid seat / missing API; corpus offers no fix, only up-front verification), [[leave-less-room-for-imagination]] (concept — under-specified prompts drift and the collateral damage is *invisible*; skills as the constraint; includes the 4.7-over-4.8 model-choice corollary). -- Pages updated: [[skills-as-memory]] (new "negative case" section — built-in memory as anti-feature, the demand-side reason skills exist; contradiction logged vs Allie), [[harness]] (consolidation-over-tool-hopping section + "before and after" claim marked self-reported), [[code-as-throwaway]] (auth/payments trust carve-out; "safe from 4.6 on"), [[levels-of-ai-usage]] (Larysa as proof the rungs are skippable — technically advanced, architecturally stuck), [[claude-code]] (practitioner-limits section: memory, connectors, 4.6/4.7/4.8, emulator), [[solve-first-then-skillify]], [[personal-ai-operating-system]] (cross-ref to Eugene's convergent OS framing), [[eugene]], [[yulia]] (Larisa open question partially resolved), [[virtido]] (audience extends beyond HR; ClickUp/Figma/Teams/Slack), [[overview]] (6→7 sources, demand-side paragraph, two new divergences, two new vault-level open questions), `index.md`. -- Notes: Closes the "Larysa/Larisa not yet interviewed — PM use cases missing" gap flagged in both HR interviews. Key reframe for the webinar: this source moves the diagnosis from *friction* (Nina/Yulia: people would adopt if it were simple) to *structural walls* for users already past friction — memory, entitlements, drift. Two genuine corpus contradictions recorded rather than smoothed: memory-as-anti-feature (Eugene) vs Allie's untroubled use of persistent context docs, and tight-spec vs [[think-wider-not-bigger]]. Also corrected `index.md`: `HR Contacts.md` was listed as not-yet-ingested but does not exist in `raw/sources/` — removed from the list. -- Next: remaining un-ingested raw are the three webinar-deliverable docs (`Ideas for webinar.md`, `Webinar Plan - From Chat Box to Your Own OS.md`, `Webinar script.md`) — ingesting them would connect the corpus to the actual deliverable and settle whether the script keeps the [[levels-of-ai-usage]] rung order. Candidate query: does the skills rung actually answer Larysa's memory complaint, or is hers a cross-project problem skills don't solve? A first lint pass is also overdue (no lint reports exist; 7 sources, 17 concepts). - -## 2026-07-22 — query (webinar theses) -- Intent: query -- Input: "What theses can I suggest for the webinar ('from chatbox to your own agentic operating system') based on what you already have?" -- Pages changed: created [[2026-07-22-webinar-theses]]; updated `index.md` (Queries section). -- Notes: 14 candidate theses synthesized from [[overview]], the 7 source summaries, and direct reads of the three raw webinar-deliverable docs (Plan, script, Ideas — still raw-only, cited as raw per policy). Grouped: spine (harness-not-model, app-you-open→OS, skills-as-memory, context scarcity, build-not-buy) / stakes (cost→zero, irreversible gap, connections rise) / obstacles (friction-not-resistance, structural walls, less-room-for-imagination) / method (solve-first-then-skillify, research-vs-judgment, walk-in-a-week) / honest tensions (3 recorded contradictions offered as Q&A ammo). Recommended 7-thesis subset for the 30-min format noted in the query page. No concept/entity pages changed. -- Next: ingest the three deliverable docs so theses can cite wiki sources instead of raw; decide whether the "obstacles" theses earn a station on the spine. - -## 2026-07-22 — ingest (ИИ глупый! short) -- Intent: ingest -- Input: "ingest \"ИИ глупый!\"" → `raw/sources/ИИ глупый!.md` (conclusions doc for a 1:28 Russian YouTube Short, author unknown; file was added mid-session after the first lookup found nothing). -- Pages created: [[2026-07-22-ai-is-stupid]] (source). -- Pages updated: [[harness]] (business-facing "engineering wrapper" definition + model+context+harness formula, flagged as webinar language), [[context-as-scarce-resource]] (supply-side facet: "intelligence without context loses to context without intelligence", Nobel-vs-employee analogy; RAG-vs-skills tension logged under Contradictions), [[overview]] (7→8 sources, convergence one-liner, navigation), `index.md` (Sources entry; `my theses.md` added to the not-yet-ingested list). -- Notes: No new concepts — the short *restates* the vault's machine-side spine for a business audience, which makes it webinar-quotable rather than novel. One tension recorded, not smoothed: it recommends RAG/long-term memory as context mechanisms where Konstantin argues skills beat RAG (possibly audience-driven — business data vs procedures). Authorship unknown and marked tentative; its framing matches the corpus closely enough that a connection to someone already in the corpus is worth checking. All ×10…×1000 gain claims are rhetorical, not measured. -- Next: `my theses.md` is new in raw and un-ingested (six webinar thesis one-liners) — small, high-relevance ingest candidate; the three webinar-deliverable docs remain pending; first lint pass still overdue (8 sources, 17 concepts, 0 lint reports). - -## 2026-07-24 — ingest (You're reading way too much code) -- Intent: ingest -- Input: "ingest \"You're reading way too much code\"" → `raw/sources/You're reading way too much code.md` (conclusions doc for Theo Browne's 24:11 video; his second source in the vault). -- Pages created: [[2026-07-24-youre-reading-way-too-much-code]] (source), [[make-more-cheap-code]] (concept — ship/no-ship line, four tiers of code A–D, 100–10,000:1 slop-to-ship verification ratio, always-another-layer, dumb-model agents as API usability testers, reading-costs-attention economics). -- Pages updated: [[code-as-throwaway]] (discipline section; evidence; its "what is the durable artifact" open question partially answered — the verification harness is now a named first-class output), [[theo-browne]] (second talk, recurring author-move noted, self-reported ratios flagged tentative), [[leave-less-room-for-imagination]] (new logged tension: Theo/Dax agent-diff-summaries vs Eugene's invisible-drift claim), [[2026-07-14-everything-we-knew-about-software-has-changed]] (reciprocal related-source link), [[overview]] (8→9 sources; verifying-half of spine given its method; new divergence line; 17→18 concepts), `index.md` (Sources, People, Concepts entries). -- Notes: The source is a *defense with discipline*, not a reversal — Theo explicitly rejects shipping unreviewed slop, which pre-empts the obvious objection to [[code-as-throwaway]]. Strong webinar relevance: "attention, not generation, is the bottleneck" is the engineer-side twin of [[context-as-scarce-resource]], and the tier framework gives audiences a non-binary answer to "can I trust AI code?". One genuine tension recorded rather than smoothed (summaries-hide-drift). Shao's 80%-into-harnesses ratio and all of Theo's daily-line counts are self-reported/uncited — marked tentative. Open framework gap flagged: skills/CLAUDE.md files don't fit the A–D spectrum. -- Next: candidate query — what is the non-engineer's analog of throwaway verification code (HR/BA audience)? `my theses.md` and the three webinar-deliverable docs remain un-ingested; first lint pass still overdue (9 sources, 18 concepts, 0 lint reports). - -## 2026-07-24 — query (non-engineer throwaway verification) -- Intent: query -- Input: "query: what is the non-engineer's analog of throwaway verification code?" (the candidate query flagged in the previous ingest). -- Pages created: [[2026-07-24-non-engineer-throwaway-verification]] (query — answer: disposable AI work that attacks/misreads/simulates the deliverable before a human sees it; one-to-one mapping table from Theo's engineer patterns to HR/BA use cases; "checker skills" as the second species of skill; tier-D-stays-human caveat since non-engineer verification bottoms out in judgment, not tests). -- Pages updated: [[make-more-cheap-code]] (next-question marked answered, link added), [[leave-less-room-for-imagination]] (new next-question: drift harnessed as a sandboxed ambiguity diagnostic — synthesis, tentative), `index.md` (Queries section). -- Notes: Pure synthesis, no new source — every mapped pattern is grounded in corpus use cases (Nina/Yulia job descriptions, candidate KB, sourcing; Larysa spec ambiguity; Allie's anti-AI-language checker-skill precedent). Key reframes with durable value: (1) the fresh-agent misread test inverts [[leave-less-room-for-imagination]] — drift becomes a diagnostic when sandboxed; (2) [[solve-first-then-skillify]] populates skills in two species, producers and checkers. Flagged honestly: the whole mapping is argument-by-analogy with no measured claim; Nina's transcript-beats-summary finding is a standing counterweight to summary-based review. -- Next: decide whether "checker skills" earns a slide on the webinar's skills rung; the webinar-deliverable docs (`Ideas`, `Plan`, `script`, `my theses.md`) remain un-ingested; first lint pass still overdue (9 sources, 18 concepts, 2026-07-24 now has 1 query, 0 lint reports). - -## 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 the grouping in index.md; 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. +# Operation Log + +Append-only chronological record of wiki operations. Newest entries at the bottom. + +Entry format: + +``` +## YYYY-MM-DD — <operation> +- Intent: ingest | query | lint | maintenance | sync +- Input: <source path / question / scope> +- Pages changed: [[...]], [[...]] +- Notes: <what changed, uncertainty, next steps> +``` + +--- + +## 2026-07-14 — maintenance +- Intent: maintenance +- Input: Initialize vault folder structure. +- Pages changed: created `index.md`, `log.md`, `wiki/overview.md`; created `raw/{sources,assets}` and `wiki/{sources,entities,concepts,timelines,comparisons,queries,lint-reports}`. +- Notes: Empty vault scaffolded per the LLM Wiki Schema in `CLAUDE.md`. No sources ingested yet. Ready for first "ingest <source>". + +## 2026-07-14 — ingest (batch, 4 sources) +- Intent: ingest +- Input: The 4 talk/interview docs (user-selected scope; "Ideas for webinar" + "HR Contacts" left as raw reference). + - `raw/sources/Everything we knew about software has changed.md` + - `raw/sources/In 1 Year, the Gap Between AI Users and Everyone Else Will Be Irreversible.md` + - `raw/sources/sebastian interview - conclusions and insights.md` + - `raw/sources/Скиллы на базе git — новая память AI-агентов.md` +- Pages created — sources (4): [[2026-07-14-everything-we-knew-about-software-has-changed]], [[2026-07-14-gap-between-ai-users-irreversible]], [[2026-07-14-sebastian-eugene-interview]], [[2026-07-14-skills-based-on-git]]. +- Pages created — entities (8): [[theo-browne]], [[allie-miller]], [[sebastian]], [[eugene]], [[konstantin]], [[claude-code]], [[hermes]], [[virtido]]. +- Pages created — concepts (13): [[harness]], [[skills-as-memory]], [[evolution-of-agent-tooling]], [[agentic-loops]], [[context-as-scarce-resource]], [[personal-ai-operating-system]], [[product-ownership]], [[connections-as-moat]], [[seniority-and-the-junior-squeeze]], [[decoupling-identity-from-profession]], [[think-wider-not-bigger]], [[code-as-throwaway]], [[enterprise-ai-reality]]. +- Pages created — timelines (1): [[ai-agent-evolution]]. +- Pages updated: [[overview]] (added the cross-source through-line + agree/diverge), `index.md` (full catalog). +- Notes: Strong cross-source convergence (skills-as-memory: Konstantin ↔ Allie; connections: Sebastian ↔ Allie; code-as-throwaway: Theo ↔ Sebastian). Recorded contradictions (Eugene vs Sebastian on BYO- vs company-managed harness, online vs in-person networking, OSS motivation). Tentative claims flagged: Theo's model-era names, Eugene = vault owner, Hermes/Ralph heuristics, Allie's self-learning-models prediction. +- Next: consider ingesting "Ideas for webinar" (connects corpus → webinar plan) and "HR Contacts" (the HR-search demo); consider a `wiki/comparisons/` page for the BYO-vs-managed-harness debate; run "lint wiki" to check link health. + +## 2026-07-14 — maintenance (tagging convention) +- Intent: maintenance +- Input: user request — add per-type hashtags to the wiki schema and existing pages. +- Pages changed: `CLAUDE.md` (new "Tagging Rules" section, rule 9, templates + lint checklist updated); all 26 wiki pages received a page-type tag line under their H1 — [[overview]] `#overview`, 4 source pages `#source`, 8 entity pages `#entity`, 13 concept pages `#concept`, [[ai-agent-evolution]] `#timeline`; `index.md` (noted the convention). +- Notes: Tag mapping is folder-driven (`#source`, `#entity`, `#concept`, `#timeline`, `#comparison`, `#query`, `#lint-report`, `#overview`). Exactly one type tag per page, first on the tag line; topical tags may follow. Lint now checks for missing/mismatched type tags. + +## 2026-07-14 -- query +- Intent: query +- Input: "what would be the best claude skill to give to a claude beginner user?" +- Pages changed: created [[2026-07-14-best-first-skill-for-beginner]]; updated [[skills-as-memory]] (Next Questions item answered + linked), `index.md` (Queries section). +- Notes: Answer synthesized from Allie Miller (skill-creator, tone-of-voice/anti-AI-language starter set, foundation-docs-first) and Konstantin (>5-tool-calls auto-creation heuristic). Follow-up: contents of a good tone-of-voice SKILL.md; foundation-doc templates. + +## 2026-07-14 — maintenance (authorized raw edit) +- Intent: maintenance +- Input: user explicitly authorized editing `raw/sources/Webinar script.md` this session (exception to rule 1). +- Pages changed: `raw/sources/Webinar script.md` — completed the dangling Tools→Memory bridge line, wrote the full **# Memory** section (amnesia demo → notebook tool → close/reopen payoff → system-prompt reveal → user.md beat → tiny-notebook limitation as the Skills hook), and renamed "Markus" → "Marcus" to match the demo app's seeded mail (`Marcus Hale`). +- Notes: Memory section is grounded in the actual mini-game behavior (Level 04 in `D:\Projects\Names\Webinars\From_chat_to_os`): frozen snapshot per session (hence the mandatory close/reopen), `memory.md` + `user.md` on disk, ~2200-char budget motivating Skills. No wiki pages changed. Next: draft **# Skills** section (open with on-demand loading answering the phone-book limitation). + +## 2026-07-14 — maintenance (authorized raw edit, Skills section) +- Intent: maintenance +- Input: user request — draft the **# Skills** section in `raw/sources/Webinar script.md` (edit permission granted this session). Corrected flow per user: presenter dictates the operations step by step first, *then* asks AI to package the procedure as a skill. +- Pages changed: `raw/sources/Webinar script.md` — full Skills section (step-by-step dictation → "I was the recipe" → save-as-skill → SKILL.md on disk → fresh-session one-liner → progressive-disclosure reveal → portability beat → "I'm still the alarm clock" hook into Process). +- Notes: Grounded in the app's Skills level (Level 05): `save_skill`/`load_skill`, real `skills/<name>/SKILL.md`, live menu (names+descriptions only) rebuilt per window. No wiki pages changed. Next: draft **# Process** (Level 06 — spawn_process scheduler answers the alarm-clock limitation). + +## 2026-07-14 — maintenance (authorized raw edit, Process section) +- Intent: maintenance +- Input: user request — draft the **# Process** section in `raw/sources/Webinar script.md` (session edit permission). +- Pages changed: `raw/sources/Webinar script.md` — full Process section (goal not task → spawn_process with pid → hands-off heartbeats → Override slider makes the cube move by itself → ps/kill → reveal: the shell authored the worker's prompt (AI managing AI) → closing arc: same model all six levels, only the harness grew; harness is unique to you; "from a chat box to your own Agentic Operating System"). Also two-branch rule fixes earlier this session (Memory convention + Skills dictation). +- Notes: Grounded in Level 06 design (`2026-07-11-process-level-design.md`): single ~5s scheduler, stateless ticks, spawn/list/stop syscalls, live −5…+5 weather offset. The "same model, growing harness" close absorbs the `_ideas:` note at the top of the script. Script now has all sections drafted (Intro → Process). + +## 2026-07-14 — maintenance (authorized raw edit, OS section) +- Intent: maintenance +- Input: user request — add an **# OS** section to `raw/sources/Webinar script.md` (session edit permission): typing removed, mouse-only interaction with the agent. +- Pages changed: `raw/sources/Webinar script.md` — Process now ends with a "still a terminal / most people never will type" hook; new OS section (one-button app → rule baked in → 5-second checkbox → appliance → "the agent became a program / the chat box dissolved into the OS"); the "same model, growing harness" closing arc moved from Process into OS, with the new final callback "a button that already knows what the email said." +- Notes: Grounded in the OS app design (`2026-07-12-os-app-design.md`): tile 07, status bar mirrors the agent's closing sentence, shared scheduler slot, Override interplay, rule baked into the prompt. Script ladder now: Chat box → ReAct → Tools → Memory → Skills → Process → OS. + +## 2026-07-14 — maintenance (authorized raw edit, OS reframing) +- Intent: maintenance +- Input: user correction — the webinar's core idea is "make tools for yourself," not "become a product." +- Pages changed: `raw/sources/Webinar script.md` — Process→OS hook now ends "…and become a tool? A small one. Made for exactly one person."; OS landing gained the intro callback (weekends burned on internal tools → this one took an evening, asked-for not written) and "It dissolved — into the operating system. Into little tools you make for yourself."; closing arc adds "You don't buy it. You build it — one small tool at a time." +- Notes: The OS section now closes the loop with the Intro's internal-tools passion. No wiki pages changed. + +## 2026-07-14 — ingest (batch, 2 sources: Nina + Yulia interviews) +- Intent: ingest +- Input: `raw/sources/Nina interview.md`, `raw/sources/Yulia interview.md` (both are conclusions docs auto-generated by Eugene's record→diarize→transcribe→summarize tool). +- Pages created — sources (2): [[2026-07-14-nina-interview]], [[2026-07-14-yulia-interview]]. +- Pages created — entities (3): [[nina]] (HR recruiter, Virtido), [[yulia]] (HR lead; name/affiliation tentative, inferred from filename + Nina reference), [[inspectron]] (Eugene's employer). +- Pages created — concepts (2): [[levels-of-ai-usage]] (chatbot → memory → Claude Code → CLAUDE.md → skills → KB → RAG; non-programmer ceiling = CLAUDE.md + skills), [[solve-first-then-skillify]] (solve once, then freeze into a skill; ~3-message and >5-tool-call heuristics). +- Pages updated: [[eugene]] (identity evidence strengthened — webinar author/presenter, Inspectron, $200 max plan, tool builder; still tentative), [[virtido]] (HR team = webinar audience, pain points, tooling), [[skills-as-memory]] (handoff/de-risking payoff, ~3-message heuristic, method link), [[connections-as-moat]] (Eugene independently converges: "the human's role is just to be human"), [[overview]] (6 sources, demand-side bullet, agree/diverge, navigation, open questions), `index.md`. +- Notes: Strong convergence — Eugene ↔ Sebastian/Allie on human-connection residual despite their networking-tactics disagreement; Eugene's ~3-message heuristic ↔ Konstantin's >5-tool-call heuristic. New cross-interview finding: adoption blocked by friction, not resistance. Tentative flags: Yulia's name/affiliation; Eugene's price-rise prediction; Inspectron employment-vs-contracting. Skills-IP-vs-work-product dispute (Eugene vs Sebastian) recorded, unresolved. +- Next: ingest "Ideas for webinar" / "Webinar Plan" / "Webinar script" to connect corpus → deliverable; interview Larysa/Larisa (PM use cases, twice flagged); consider a comparison page "personal vs employer ownership of skills". + +## 2026-07-14 — query (network from a standing start) +- Intent: query +- Input: "how does an individual actually build a network from a standing start?" + "go" on the recommended plan (tentative protocol + Sebastian follow-up instrument). +- Pages created: [[network-from-a-standing-start]] (concept, Status: tentative — 5-step protocol: recurring venues → cadence → lead with humanness → engineer second meetings → track second meetings/let referrals work; contested anti-tactics list; webinar flagged as live case study), [[2026-07-14-network-from-standing-start]] (query — 10-question Sebastian round-2 interview instrument in 3 blocks: bootstrap biography / mechanics / falsification of "Big zero"). +- Pages updated: [[connections-as-moat]] (open question → protocol + validation plan), [[overview]] (vault-level open question annotated), `index.md` (Concepts + Queries sections). +- Notes: Corpus cannot answer the question directly — protocol is inferred from Sebastian's principles, Allie's prediction, and Eugene's convergent thesis; every claim marked tentative/contested. Key falsification target: whether Sebastian ever had a true standing start (prior-job network as seed). Deep-research complement (weak ties, mere exposure, from-zero playbooks, scoped to test "Big zero") described but not run. +- Next: run the round-2 Sebastian interview and ingest it; decide on the deep-research run; track webinar → paid-HR-build chain as case-study evidence. + +## 2026-07-16 — query → comparison +- Intent: query +- Input: "Compare Theo's, Konstantin's ('Sonstantine') and Allie's approaches — what they have in common, what they have different?" +- Pages created: [[theo-konstantin-allie]] (first `wiki/comparisons/` page — three-lens side-by-side: at-a-glance table, 6 shared points, differences by altitude/framing/code/learning/register, complementary tensions). +- Pages updated: [[theo-browne]], [[konstantin]], [[allie-miller]] (added inbound "Comparison:" links to avoid orphan); `index.md` (Comparisons section — first entry). +- Notes: Synthesized from the 3 source summaries + entity pages + [[skills-as-memory]]. Common ground: bottleneck moved model→human system; markdown/skills as the atomic unit; infrastructure compounds; system-ready to absorb new models; context as scarce resource; shared [[claude-code]]. Differences framed as *altitude* (Theo strategy/psychology · Konstantin engineering · Allie individual-productivity), not direction — corpus disagreements lie elsewhere. Recorded the throwaway-code (Theo) vs persist-in-git (Konstantin) tension as complementary (artifact vs capability). No new contradictions. Saved as a comparison rather than a query page since the request was explicitly a side-by-side. +- Next: reusable starter template (foundation docs / good SKILL.md) still unresolved across all three — webinar-relevant; consider whether Konstantin's meta-loop (wipe-and-restart) tensions with "persist everything". + +## 2026-07-16 — maintenance (table fix) +- Intent: maintenance +- Input: user report — the table in [[ai-agent-evolution]] "looks broken". +- Pages changed: [[ai-agent-evolution]] — repaired the "Agent architecture & tooling (Konstantin)" table; escaped the `|` inside aliased wiki links (`[[harness\|harnesses]]`, `[[skills-as-memory\|skills]]`, `[[agentic-loops\|agent loops]]`) so Obsidian no longer reads them as column separators, and removed the two phantom trailing columns the unescaped pipes had forced into the header/separator. Now a clean 3-column table (Period / Milestone / Significance). +- Notes: Content unchanged — formatting-only fix. The Theo Browne table was already valid. No `index.md` change (no catalog/structural change). Root cause: piped `[[page|alias]]` links inside GFM/Obsidian table cells require the pipe escaped as `\|`. + +## 2026-07-21 — ingest (Larysa interview) +- Intent: ingest +- Input: "ingest Larysa interview.md" → `raw/sources/Larysa interview.md` (auto-generated interview conclusions doc; Eugene × Larysa, technical BA/PM and ex-mobile dev). +- Pages created: [[2026-07-21-larysa-interview]] (source), [[larysa]] (entity), [[integration-dead-ends]] (concept — agent begins work against connectors gated by account tier / paid seat / missing API; corpus offers no fix, only up-front verification), [[leave-less-room-for-imagination]] (concept — under-specified prompts drift and the collateral damage is *invisible*; skills as the constraint; includes the 4.7-over-4.8 model-choice corollary). +- Pages updated: [[skills-as-memory]] (new "negative case" section — built-in memory as anti-feature, the demand-side reason skills exist; contradiction logged vs Allie), [[harness]] (consolidation-over-tool-hopping section + "before and after" claim marked self-reported), [[code-as-throwaway]] (auth/payments trust carve-out; "safe from 4.6 on"), [[levels-of-ai-usage]] (Larysa as proof the rungs are skippable — technically advanced, architecturally stuck), [[claude-code]] (practitioner-limits section: memory, connectors, 4.6/4.7/4.8, emulator), [[solve-first-then-skillify]], [[personal-ai-operating-system]] (cross-ref to Eugene's convergent OS framing), [[eugene]], [[yulia]] (Larisa open question partially resolved), [[virtido]] (audience extends beyond HR; ClickUp/Figma/Teams/Slack), [[overview]] (6→7 sources, demand-side paragraph, two new divergences, two new vault-level open questions), `index.md`. +- Notes: Closes the "Larysa/Larisa not yet interviewed — PM use cases missing" gap flagged in both HR interviews. Key reframe for the webinar: this source moves the diagnosis from *friction* (Nina/Yulia: people would adopt if it were simple) to *structural walls* for users already past friction — memory, entitlements, drift. Two genuine corpus contradictions recorded rather than smoothed: memory-as-anti-feature (Eugene) vs Allie's untroubled use of persistent context docs, and tight-spec vs [[think-wider-not-bigger]]. Also corrected `index.md`: `HR Contacts.md` was listed as not-yet-ingested but does not exist in `raw/sources/` — removed from the list. +- Next: remaining un-ingested raw are the three webinar-deliverable docs (`Ideas for webinar.md`, `Webinar Plan - From Chat Box to Your Own OS.md`, `Webinar script.md`) — ingesting them would connect the corpus to the actual deliverable and settle whether the script keeps the [[levels-of-ai-usage]] rung order. Candidate query: does the skills rung actually answer Larysa's memory complaint, or is hers a cross-project problem skills don't solve? A first lint pass is also overdue (no lint reports exist; 7 sources, 17 concepts). + +## 2026-07-22 — query (webinar theses) +- Intent: query +- Input: "What theses can I suggest for the webinar ('from chatbox to your own agentic operating system') based on what you already have?" +- Pages changed: created [[2026-07-22-webinar-theses]]; updated `index.md` (Queries section). +- Notes: 14 candidate theses synthesized from [[overview]], the 7 source summaries, and direct reads of the three raw webinar-deliverable docs (Plan, script, Ideas — still raw-only, cited as raw per policy). Grouped: spine (harness-not-model, app-you-open→OS, skills-as-memory, context scarcity, build-not-buy) / stakes (cost→zero, irreversible gap, connections rise) / obstacles (friction-not-resistance, structural walls, less-room-for-imagination) / method (solve-first-then-skillify, research-vs-judgment, walk-in-a-week) / honest tensions (3 recorded contradictions offered as Q&A ammo). Recommended 7-thesis subset for the 30-min format noted in the query page. No concept/entity pages changed. +- Next: ingest the three deliverable docs so theses can cite wiki sources instead of raw; decide whether the "obstacles" theses earn a station on the spine. + +## 2026-07-22 — ingest (ИИ глупый! short) +- Intent: ingest +- Input: "ingest \"ИИ глупый!\"" → `raw/sources/ИИ глупый!.md` (conclusions doc for a 1:28 Russian YouTube Short, author unknown; file was added mid-session after the first lookup found nothing). +- Pages created: [[2026-07-22-ai-is-stupid]] (source). +- Pages updated: [[harness]] (business-facing "engineering wrapper" definition + model+context+harness formula, flagged as webinar language), [[context-as-scarce-resource]] (supply-side facet: "intelligence without context loses to context without intelligence", Nobel-vs-employee analogy; RAG-vs-skills tension logged under Contradictions), [[overview]] (7→8 sources, convergence one-liner, navigation), `index.md` (Sources entry; `my theses.md` added to the not-yet-ingested list). +- Notes: No new concepts — the short *restates* the vault's machine-side spine for a business audience, which makes it webinar-quotable rather than novel. One tension recorded, not smoothed: it recommends RAG/long-term memory as context mechanisms where Konstantin argues skills beat RAG (possibly audience-driven — business data vs procedures). Authorship unknown and marked tentative; its framing matches the corpus closely enough that a connection to someone already in the corpus is worth checking. All ×10…×1000 gain claims are rhetorical, not measured. +- Next: `my theses.md` is new in raw and un-ingested (six webinar thesis one-liners) — small, high-relevance ingest candidate; the three webinar-deliverable docs remain pending; first lint pass still overdue (8 sources, 17 concepts, 0 lint reports). + +## 2026-07-24 — ingest (You're reading way too much code) +- Intent: ingest +- Input: "ingest \"You're reading way too much code\"" → `raw/sources/You're reading way too much code.md` (conclusions doc for Theo Browne's 24:11 video; his second source in the vault). +- Pages created: [[2026-07-24-youre-reading-way-too-much-code]] (source), [[make-more-cheap-code]] (concept — ship/no-ship line, four tiers of code A–D, 100–10,000:1 slop-to-ship verification ratio, always-another-layer, dumb-model agents as API usability testers, reading-costs-attention economics). +- Pages updated: [[code-as-throwaway]] (discipline section; evidence; its "what is the durable artifact" open question partially answered — the verification harness is now a named first-class output), [[theo-browne]] (second talk, recurring author-move noted, self-reported ratios flagged tentative), [[leave-less-room-for-imagination]] (new logged tension: Theo/Dax agent-diff-summaries vs Eugene's invisible-drift claim), [[2026-07-14-everything-we-knew-about-software-has-changed]] (reciprocal related-source link), [[overview]] (8→9 sources; verifying-half of spine given its method; new divergence line; 17→18 concepts), `index.md` (Sources, People, Concepts entries). +- Notes: The source is a *defense with discipline*, not a reversal — Theo explicitly rejects shipping unreviewed slop, which pre-empts the obvious objection to [[code-as-throwaway]]. Strong webinar relevance: "attention, not generation, is the bottleneck" is the engineer-side twin of [[context-as-scarce-resource]], and the tier framework gives audiences a non-binary answer to "can I trust AI code?". One genuine tension recorded rather than smoothed (summaries-hide-drift). Shao's 80%-into-harnesses ratio and all of Theo's daily-line counts are self-reported/uncited — marked tentative. Open framework gap flagged: skills/CLAUDE.md files don't fit the A–D spectrum. +- Next: candidate query — what is the non-engineer's analog of throwaway verification code (HR/BA audience)? `my theses.md` and the three webinar-deliverable docs remain un-ingested; first lint pass still overdue (9 sources, 18 concepts, 0 lint reports). + +## 2026-07-24 — query (non-engineer throwaway verification) +- Intent: query +- Input: "query: what is the non-engineer's analog of throwaway verification code?" (the candidate query flagged in the previous ingest). +- Pages created: [[2026-07-24-non-engineer-throwaway-verification]] (query — answer: disposable AI work that attacks/misreads/simulates the deliverable before a human sees it; one-to-one mapping table from Theo's engineer patterns to HR/BA use cases; "checker skills" as the second species of skill; tier-D-stays-human caveat since non-engineer verification bottoms out in judgment, not tests). +- Pages updated: [[make-more-cheap-code]] (next-question marked answered, link added), [[leave-less-room-for-imagination]] (new next-question: drift harnessed as a sandboxed ambiguity diagnostic — synthesis, tentative), `index.md` (Queries section). +- Notes: Pure synthesis, no new source — every mapped pattern is grounded in corpus use cases (Nina/Yulia job descriptions, candidate KB, sourcing; Larysa spec ambiguity; Allie's anti-AI-language checker-skill precedent). Key reframes with durable value: (1) the fresh-agent misread test inverts [[leave-less-room-for-imagination]] — drift becomes a diagnostic when sandboxed; (2) [[solve-first-then-skillify]] populates skills in two species, producers and checkers. Flagged honestly: the whole mapping is argument-by-analogy with no measured claim; Nina's transcript-beats-summary finding is a standing counterweight to summary-based review. +- Next: decide whether "checker skills" earns a slide on the webinar's skills rung; the webinar-deliverable docs (`Ideas`, `Plan`, `script`, `my theses.md`) remain un-ingested; first lint pass still overdue (9 sources, 18 concepts, 2026-07-24 now has 1 query, 0 lint reports). + +## 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 the grouping in index.md; 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. diff --git a/wiki/script-coverage.md b/wiki/script-coverage.md index 1e58577..c707295 100644 --- a/wiki/script-coverage.md +++ b/wiki/script-coverage.md @@ -1,45 +1,45 @@ -# 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` +# 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`