Line-ending only. Verified: 3667 insertions against 3667 deletions with zero content difference under --ignore-cr-at-eol.
1746 lines
65 KiB
Markdown
1746 lines
65 KiB
Markdown
# 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.
|