dsh-notes-board 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ # dsh-notes-board bundle patch:只插入一条 loader entry。
2
+ # name 必须是实际包名 —— cordis 按 name 解析包;客户端 bundle 也按包名取,
3
+ # 所以 package.json 的 name、这里的 name、client.js 里 __ModuleLoader__ 的 id 三者必须一致。
4
+ - insert:
5
+ - id: dsh-notes-board
6
+ name: dsh-notes-board
package/icon.svg ADDED
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Notes Board">
2
+ <defs>
3
+ <linearGradient id="nb-cover" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#3b82f6"/>
5
+ <stop offset="1" stop-color="#1e3a8a"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect x="6" y="6" width="52" height="52" rx="13" fill="url(#nb-cover)"/>
9
+ <path d="M22 16h14.5L45 24.5V48H22z" fill="#f8fafc" opacity="0.96"/>
10
+ <path d="M36 16v9h9" fill="none" stroke="#93c5fd" stroke-width="2.4" stroke-linejoin="round"/>
11
+ <g stroke="#1e40af" stroke-width="2.6" stroke-linecap="round">
12
+ <path d="M27.5 28h12"/>
13
+ <path d="M27.5 33.5h12"/>
14
+ <path d="M27.5 39h7.5"/>
15
+ </g>
16
+ </svg>
package/index.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * dsh-notes-board · Host 半边
3
+ *
4
+ * 只做两件事,都是只读:
5
+ * 1. 把 workspace registry 里登记的工作区列成 JSON(面板顶部的下拉框用它);
6
+ * 2. 读某个工作区 <dir>/.agents/notes 下的 Markdown 原文,按相对路径回给客户端。
7
+ *
8
+ * 解析、分类、引用入度、四个视图,全部交给原版看板的前端逻辑
9
+ * (vendor/board.html 里的 parseNote / ingestDataset),Host 不重复实现一遍。
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { readdir, readFile, stat } from 'node:fs/promises'
14
+ import { basename, join, sep } from 'node:path'
15
+ import { gzipSync } from 'node:zlib'
16
+
17
+ export const inject = ['webServer']
18
+
19
+ const BASE = '/api/dsh-notes-board'
20
+ /* 安全阀,不是业务上限:只防挂错目录(比如挂到家目录)时把进程拖死。
21
+ 真触发时响应里带 truncated:true,面板会明确提示,绝不静默截断。 */
22
+ const SAFETY_MAX_FILES = 50000
23
+ const MAX_BODY = 400 * 1024
24
+
25
+ function sendJson(req, res, code, payload) {
26
+ const raw = Buffer.from(JSON.stringify(payload), 'utf8')
27
+ const accepts = String((req && req.headers && req.headers['accept-encoding']) || '')
28
+ if (raw.length > 64 * 1024 && /\bgzip\b/.test(accepts)) {
29
+ const body = gzipSync(raw)
30
+ res.writeHead(code, {
31
+ 'content-type': 'application/json; charset=utf-8',
32
+ 'content-encoding': 'gzip',
33
+ 'content-length': body.length,
34
+ 'cache-control': 'no-store',
35
+ })
36
+ res.end(body)
37
+ return
38
+ }
39
+ res.writeHead(code, {
40
+ 'content-type': 'application/json; charset=utf-8',
41
+ 'content-length': raw.length,
42
+ 'cache-control': 'no-store',
43
+ })
44
+ res.end(raw)
45
+ }
46
+
47
+ function fail(req, res, code, message) {
48
+ sendJson(req, res, code, { ok: false, message })
49
+ }
50
+
51
+ function listWorkspaces(ctx) {
52
+ const registry = ctx.get('workspaceRegistry')
53
+ if (!registry || typeof registry.list !== 'function') return []
54
+ const seen = new Set()
55
+ const out = []
56
+ for (const workspace of registry.list()) {
57
+ const path = typeof workspace?.path === 'string' ? workspace.path : ''
58
+ if (!path || seen.has(path)) continue
59
+ seen.add(path)
60
+ out.push({
61
+ id: String(workspace.id ?? path),
62
+ title: String(workspace.title || basename(path) || path),
63
+ path,
64
+ hasNotes: existsSync(join(path, '.agents', 'notes')),
65
+ })
66
+ }
67
+ return out
68
+ }
69
+
70
+ function resolveWorkspace(ctx, idOrPath) {
71
+ const wanted = String(idOrPath || '')
72
+ if (!wanted) return undefined
73
+ const all = listWorkspaces(ctx)
74
+ return (
75
+ all.find((w) => w.id === wanted) ||
76
+ all.find((w) => w.path === wanted) ||
77
+ all.find((w) => w.title === wanted)
78
+ )
79
+ }
80
+
81
+ /** 广度优先收集 .agents/notes 下的 Markdown,跳过点开头目录 */
82
+ async function walkNotes(root) {
83
+ const found = []
84
+ const queue = [{ dir: root, depth: 0 }]
85
+ let truncated = false
86
+ while (queue.length) {
87
+ const current = queue.shift()
88
+ let entries
89
+ try {
90
+ entries = await readdir(current.dir, { withFileTypes: true })
91
+ } catch {
92
+ continue
93
+ }
94
+ for (const entry of entries) {
95
+ if (entry.name.startsWith('.')) continue
96
+ const abs = join(current.dir, entry.name)
97
+ if (entry.isDirectory()) {
98
+ if (current.depth < 5) queue.push({ dir: abs, depth: current.depth + 1 })
99
+ continue
100
+ }
101
+ if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue
102
+ if (found.length >= SAFETY_MAX_FILES) {
103
+ truncated = true
104
+ break
105
+ }
106
+ found.push(abs)
107
+ }
108
+ if (truncated) break
109
+ }
110
+ return { paths: found, truncated }
111
+ }
112
+
113
+ async function readNotes(workspace) {
114
+ const root = join(workspace.path, '.agents', 'notes')
115
+ if (!existsSync(root)) return { root, exists: false, files: [], truncated: false }
116
+ const { paths: absPaths, truncated } = await walkNotes(root)
117
+ // 中英对照的 x.md / x.zh.md 只回一份,按原版看板的规则「zh 优先」
118
+ const byCleanPath = new Map()
119
+ for (const abs of absPaths) {
120
+ const rel = abs.slice(root.length + 1).split(sep).join('/')
121
+ const isZh = /\.zh\.md$/i.test(rel)
122
+ const clean = isZh ? rel.replace(/\.zh\.md$/i, '.md') : rel
123
+ const current = byCleanPath.get(clean)
124
+ if (!current || isZh) byCleanPath.set(clean, { abs, rel, isZh })
125
+ }
126
+ const files = []
127
+ for (const [cleanRel, entry] of byCleanPath) {
128
+ try {
129
+ const info = await stat(entry.abs)
130
+ const text = await readFile(entry.abs, 'utf8')
131
+ files.push({
132
+ path: cleanRel,
133
+ raw: text.length > MAX_BODY ? text.slice(0, MAX_BODY) : text,
134
+ mtime: Math.round(info.mtimeMs / 1000),
135
+ })
136
+ } catch {
137
+ /* 单个文件读失败不影响整体 */
138
+ }
139
+ }
140
+ files.sort((a, b) => a.path.localeCompare(b.path))
141
+ return { root, exists: true, files, truncated }
142
+ }
143
+
144
+ export function apply(ctx) {
145
+ ctx.effect(() =>
146
+ ctx.webServer.register({
147
+ kind: 'exact',
148
+ path: `${BASE}/workspaces`,
149
+ handler(req, res) {
150
+ try {
151
+ sendJson(req, res, 200, { ok: true, workspaces: listWorkspaces(ctx) })
152
+ } catch (error) {
153
+ fail(req, res, 500, error instanceof Error ? error.message : String(error))
154
+ }
155
+ },
156
+ }),
157
+ )
158
+
159
+ ctx.effect(() =>
160
+ ctx.webServer.register({
161
+ kind: 'exact',
162
+ path: `${BASE}/scan`,
163
+ async handler(req, res) {
164
+ const url = new URL(req.url || '/', 'http://localhost')
165
+ const workspace = resolveWorkspace(ctx, url.searchParams.get('workspace') || '')
166
+ if (!workspace) return fail(req, res, 404, '未登记的工作区')
167
+ try {
168
+ const { root, exists, files, truncated } = await readNotes(workspace)
169
+ sendJson(req, res, 200, {
170
+ ok: true,
171
+ workspace,
172
+ root,
173
+ exists,
174
+ files,
175
+ truncated: !!truncated,
176
+ safetyLimit: SAFETY_MAX_FILES,
177
+ scannedAt: Date.now(),
178
+ })
179
+ } catch (error) {
180
+ fail(req, res, 500, error instanceof Error ? error.message : String(error))
181
+ }
182
+ },
183
+ }),
184
+ )
185
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Notes Board",
4
+ "description": "Adds a Notes Board panel to the sidebar: pick a workspace and read its .agents/notes decision records through the original agent-notes-board UI — architecture baseline (in-degree pillars), evolution timeline, pitfalls, and decision list, plus the five-section detail drawer. The Host only supplies read-only note text."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "笔记看板",
4
+ "description": "在左侧栏加一个「笔记看板」面板:切换工作区后,直接渲染该工作区 .agents/notes 的工程决策笔记——架构基线(引用入度承重墙)、演进时间线、避坑智库、决策清单四个板块,附带抽屉五节详情。界面即原版 agent-notes-board,数据改由宿主只读注入。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "dsh-notes-board",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "笔记看板:把各个工作区 .agents/notes 的工程决策笔记渲染成 DeepSeek Harness 原生面板,四板块 + 抽屉五节,支持切换工作区。",
6
+ "license": "MIT",
7
+ "author": "scccy",
8
+ "keywords": [
9
+ "dsh",
10
+ "dsh-plugin",
11
+ "deepseek-harness",
12
+ "agent-notes",
13
+ "notes",
14
+ "decision-log"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/scccy/dsh-notes-board.git"
19
+ },
20
+ "homepage": "https://github.com/scccy/dsh-notes-board#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/scccy/dsh-notes-board/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "exports": {
28
+ ".": "./index.js",
29
+ "./client": "./client.js",
30
+ "./locale/*.json": "./locale/*.json",
31
+ "./icon": "./icon.svg"
32
+ },
33
+ "files": [
34
+ "index.js",
35
+ "client.js",
36
+ "cordis.patch.yml",
37
+ "locale/*.json",
38
+ "icon.svg",
39
+ "src/**",
40
+ "scripts/**",
41
+ "vendor/**",
42
+ "README.md",
43
+ "LICENSE",
44
+ "NOTICE.md"
45
+ ],
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "platform": "web"
52
+ }
53
+ },
54
+ "scripts": {
55
+ "build": "node scripts/build-client.mjs",
56
+ "test": "node scripts/test-board.mjs"
57
+ }
6
58
  }
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 把原版看板模板(vendor/board.html)改造成能挂在 Harness 面板里的形态,
4
+ * 与 src/panel.js 拼成 client.js。
5
+ *
6
+ * 设计原则:原版逻辑一行都不重写,只做「作用域改写 + 宿主接管数据源」两类机械补丁。
7
+ * 上游更新时:替换 vendor/board.html → 重跑本脚本;补丁数量断言会立刻告诉你哪里变了。
8
+ */
9
+
10
+ import { readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
15
+ const src = readFileSync(resolve(ROOT, 'vendor/board.html'), 'utf8')
16
+ const shell = readFileSync(resolve(ROOT, 'src/panel.js'), 'utf8')
17
+
18
+ function must(condition, message) {
19
+ if (!condition) throw new Error('build failed: ' + message)
20
+ }
21
+
22
+ function replaceCounted(text, from, to, expected, label) {
23
+ const count = text.split(from).length - 1
24
+ must(count === expected, `${label}: 期望 ${expected} 处 "${from}",实际 ${count} 处(上游可能已变)`)
25
+ return text.split(from).join(to)
26
+ }
27
+
28
+ /* ── 1. 切出 CSS / 正文 / 脚本 / 字体 ─────────────────────── */
29
+
30
+ const cssMatch = /<style[^>]*>([\s\S]*?)<\/style>/.exec(src)
31
+ must(cssMatch, '找不到 <style> 块')
32
+ let css = cssMatch[1]
33
+
34
+ const bodyStart = src.indexOf('<body>')
35
+ const bodyEnd = src.indexOf('<script')
36
+ must(bodyStart >= 0 && bodyEnd > bodyStart, '找不到 <body> … <script> 之间的正文')
37
+ const bodyHtml = src.slice(bodyStart + '<body>'.length, bodyEnd).trim()
38
+
39
+ const scripts = [...src.matchAll(/<script[^>]*>([\s\S]*?)<\/script>/g)].map((m) => m[1])
40
+ const script = scripts.find((s) => s.length > 5000)
41
+ must(script, '找不到看板主脚本')
42
+
43
+ const fontMatch = /<link href="https:\/\/fonts\.googleapis\.com[^>]*>/.exec(src)
44
+ const font = fontMatch ? fontMatch[0] : ''
45
+
46
+ /* ── 2. CSS::root → :host,抽屉定位从 fixed 收到面板内 ───── */
47
+
48
+ must((css.match(/:root\s*\{/g) || []).length === 1, 'CSS 里 :root 应该只有一处')
49
+ css = css.replace(
50
+ /:root\s*\{/,
51
+ ':host{position:relative;display:block;height:100%;overflow:hidden;',
52
+ )
53
+ css = replaceCounted(css, 'position: fixed', 'position: absolute', 2, 'CSS 抽屉定位')
54
+ css +=
55
+ '\n/* ── 宿主注入 ──────────────────────────────────────────── */\n' +
56
+ '/* 原版靠浏览器默认白底,面板里没有 body,这里补上 */\n' +
57
+ '.nbx-stage{height:100%;overflow:auto;background:var(--bg-base);color:var(--text-primary);}\n' +
58
+ '/* 原版顶栏按整页宽度设计,窄面板里会被压成竖排:改成可换行 + 子项不压缩 */\n' +
59
+ 'header.hud-topbar{flex-wrap:wrap;height:auto;min-height:56px;padding:8px 16px;gap:12px;row-gap:8px;}\n' +
60
+ 'header.hud-topbar>*{flex-shrink:0;}\n' +
61
+ '.brand-text,nav.deck-tabs,.deck-tab,.sync-beacon,.telemetry-chip{white-space:nowrap;}\n' +
62
+ '.hud-grow{flex:1 1 auto;min-width:0;}\n' +
63
+ '.hud-search{width:220px;flex:0 0 auto;}\n' +
64
+ '/* 原版聚焦时把输入框撑到 320px,溢出 260px 容器、盖住右侧同步状态 */\n' +
65
+ '.hud-search input:focus{width:100%;}\n' +
66
+ '.onboarding-deck{display:none !important;}\n'
67
+
68
+ /* ── 3. JS:作用域改写 ───────────────────────────────────── */
69
+
70
+ // 先摘掉「自动读取浏览器授权目录」那段启动逻辑,数据改由宿主注入。
71
+ const bootStart = script.indexOf('if (window.__INLINE_DATA__')
72
+ const bootEnd = script.indexOf("window.addEventListener('focus'")
73
+ must(bootStart > 0 && bootEnd > bootStart, '找不到原版启动数据块')
74
+ let code =
75
+ script.slice(0, bootStart) +
76
+ '// 数据由宿主注入:React 侧拿到 .agents/notes 原文后调用 ingestFiles()\n ' +
77
+ script.slice(bootEnd)
78
+
79
+ code = replaceCounted(code, 'document.getElementById(', 'byId(', 51, 'getElementById 作用域改写')
80
+ code = replaceCounted(code, 'document.querySelectorAll(', 'root.querySelectorAll(', 3, 'querySelectorAll 作用域改写')
81
+ code = replaceCounted(code, 'document.querySelector(', 'root.querySelector(', 1, 'querySelector 作用域改写')
82
+ code = replaceCounted(code, 'window.addEventListener(', 'onWin(', 3, 'window 事件接管')
83
+ code = replaceCounted(code, 'document.addEventListener(', 'onRoot(', 2, 'document 事件接管')
84
+ code = replaceCounted(
85
+ code,
86
+ "onRoot('DOMContentLoaded', startMission);",
87
+ '/* 启动由宿主 mountBoard() 调用 startMission() */',
88
+ 1,
89
+ '移除 DOMContentLoaded 自启动',
90
+ )
91
+ code = replaceCounted(code, 'location.hash', 'panelRoute.hash', 5, 'hash 路由收进面板')
92
+ code = replaceCounted(code, 'history.replaceState', 'panelRoute.replaceState', 3, 'history 收进面板')
93
+
94
+ // 懒渲染:原版一次把四个视图全渲染出来,1300 篇时时间线单次产出近 1MB HTML。
95
+ // 改成只渲染当前 tab,切 tab 时再按需渲染 —— 首屏从四份降到一份。
96
+ code = replaceCounted(
97
+ code,
98
+ `function renderInterface() {
99
+ renderBaseline();
100
+ renderTimeline();
101
+ renderAlternatives();
102
+ renderDirectory();
103
+ }`,
104
+ `function renderView(view) {
105
+ if (view === 'timeline') renderTimeline();
106
+ else if (view === 'alternatives') renderAlternatives();
107
+ else if (view === 'directory') renderDirectory();
108
+ else renderBaseline();
109
+ }
110
+
111
+ function renderInterface() {
112
+ renderView(Core.activeTab);
113
+ }`,
114
+ 1,
115
+ '视图懒渲染',
116
+ )
117
+ code = replaceCounted(
118
+ code,
119
+ ' Core.activeTab = viewId;',
120
+ ' Core.activeTab = viewId;\n renderView(viewId);',
121
+ 1,
122
+ '切换 tab 时补渲染',
123
+ )
124
+
125
+ // KPI 卡片:原版是内联 onclick="routeToListFilter(...)"。内联处理器在「全局作用域」求值,
126
+ // 而改造后所有原版函数都收进了 factory 闭包 → 每点一次必抛 ReferenceError: routeToListFilter is not defined。
127
+ // 机械补丁:标记换成 data-kpi-filter,点击交给下面那段已有的 root 委托监听。
128
+ code = replaceCounted(
129
+ code,
130
+ '<div class="kpi-instrument" onclick="routeToListFilter(\'${escapeHtml(k.filter)}\')">',
131
+ '<div class="kpi-instrument" data-kpi-filter="${escapeHtml(k.filter)}">',
132
+ 1,
133
+ 'KPI 卡片内联 onclick → data-kpi-filter',
134
+ )
135
+ code = replaceCounted(
136
+ code,
137
+ `onRoot('click', e => {
138
+ const nodeTrigger = e.target.closest('[data-node-id]');`,
139
+ `onRoot('click', e => {
140
+ const kpiTrigger = e.target.closest('[data-kpi-filter]');
141
+ if (kpiTrigger) {
142
+ const kpiFilter = kpiTrigger.getAttribute('data-kpi-filter');
143
+ if (kpiFilter) routeToListFilter(kpiFilter);
144
+ return;
145
+ }
146
+ const nodeTrigger = e.target.closest('[data-node-id]');`,
147
+ 1,
148
+ 'KPI 卡片点击委托',
149
+ )
150
+
151
+ // 兜底断言:内联事件处理器在全局作用域求值,而本文件所有函数都在 factory 闭包里,
152
+ // 只要再出现一个就跟 routeToListFilter 一样必抛。上游新增一定要在这里被拦住。
153
+ must(
154
+ !/\son(?:click|change|input|keydown|keyup|keypress|submit|load|focus|blur|mouseover|mouseout)\s*=/.test(code),
155
+ '注入脚本里仍有内联事件处理器,改造成模块闭包后会抛 ReferenceError,请用 data-属性 + 事件委托替代',
156
+ )
157
+
158
+ /* ── 4. 拼装 client.js ───────────────────────────────────── */
159
+
160
+ let out = shell
161
+ for (const [token, value] of [
162
+ ['/*__BOARD_CSS__*/ \'\'', `/*__BOARD_CSS__*/ ${JSON.stringify(css)}`],
163
+ ['/*__BOARD_HTML__*/ \'\'', `/*__BOARD_HTML__*/ ${JSON.stringify(bodyHtml)}`],
164
+ ['/*__BOARD_FONT__*/ \'\'', `/*__BOARD_FONT__*/ ${JSON.stringify(font)}`],
165
+ ['/*__BOARD_SCRIPT__*/', code],
166
+ ]) {
167
+ must(out.includes(token), `src/panel.js 缺少占位符 ${token.slice(0, 24)}…`)
168
+ out = out.replace(token, () => value)
169
+ }
170
+
171
+ writeFileSync(resolve(ROOT, 'client.js'), out, 'utf8')
172
+ console.log(
173
+ `ok: client.js ${(out.length / 1024).toFixed(1)}KB(css ${(css.length / 1024).toFixed(1)}KB / ` +
174
+ `html ${(bodyHtml.length / 1024).toFixed(1)}KB / js ${(code.length / 1024).toFixed(1)}KB)`,
175
+ )
@@ -0,0 +1,188 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import vm from 'node:vm'
3
+
4
+ import { dirname, resolve } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'client.js')
7
+
8
+ const cache = new Map()
9
+ function makeEl(id) {
10
+ return {
11
+ id, style: {}, dataset: {}, children: [], innerHTML: '', textContent: '', value: '', className: '',
12
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false } },
13
+ appendChild() {}, removeChild() {}, append() {},
14
+ addEventListener() {}, removeEventListener() {},
15
+ querySelector(sel) { return bySel(sel) }, querySelectorAll() { return [] },
16
+ getAttribute() { return null }, setAttribute() {}, closest() { return null },
17
+ focus() {}, scrollIntoView() {}, insertAdjacentHTML() {}, remove() {},
18
+ }
19
+ }
20
+ const byId = (id) => { if (!cache.has(id)) cache.set(id, makeEl(id)); return cache.get(id) }
21
+ const bySel = (sel) => { const k = 'sel:' + sel; if (!cache.has(k)) cache.set(k, makeEl(k)); return cache.get(k) }
22
+
23
+ const listeners = []
24
+ const shadow = {
25
+ getElementById: byId,
26
+ querySelector: bySel,
27
+ querySelectorAll: () => [],
28
+ addEventListener: (t, fn) => listeners.push([t, fn]),
29
+ removeEventListener: () => {},
30
+ }
31
+
32
+ const sandbox = {
33
+ window: { __ModuleLoader__: { load(d) { sandbox.__def = d } }, addEventListener() {}, removeEventListener() {}, setInterval: () => 0, clearInterval() {} },
34
+ console, setTimeout, clearTimeout,
35
+ setInterval: () => 0, clearInterval() {},
36
+ location: { hash: '' }, history: { replaceState() {} },
37
+ document: { activeElement: null, hasFocus: () => true, addEventListener() {}, createElement: () => makeEl('tmp') },
38
+ alert: (m) => console.log('[alert]', m),
39
+ }
40
+ sandbox.globalThis = sandbox
41
+ vm.createContext(sandbox)
42
+ vm.runInContext(readFileSync(SRC, 'utf8'), sandbox, { filename: SRC })
43
+
44
+ const ReactStub = { createElement: () => null, useState: () => [null, () => {}], useEffect: () => {}, useRef: () => ({ current: null }), useCallback: (f) => f }
45
+ const plugin = sandbox.__def.factory((name) => { if (name === 'react') return ReactStub; throw new Error('unexpected require ' + name) })
46
+ plugin.apply({ slots: { inject(_n, fn) { fn() }, register() { return () => {} } } })
47
+
48
+ const A = `# Agent Note: 能力 seam 三权分立
49
+
50
+ Status: implemented
51
+
52
+ ## Problem
53
+
54
+ 各项能力关注点以不同速率变化,换沙箱实现时工具 Schema 频发破坏性变更。
55
+
56
+ ## Decision
57
+
58
+ 拆成 Service Definition / Provider / Consumer 三者独立演进。
59
+
60
+ ## Alternatives considered
61
+
62
+ ### 始终合并各角色
63
+ 否决,重新耦合了独立变化的关注点。
64
+
65
+ ## Consequences
66
+
67
+ 包数量增加;换来后端替换零破坏。
68
+ `
69
+ const B = `# Agent Note: 工具输出规范化
70
+
71
+ Status: implemented
72
+
73
+ ## Problem
74
+
75
+ 工具直接产出模型可读文本,编程式调用拿不到强类型对象。
76
+
77
+ ## Decision
78
+
79
+ 每个工具声明 Canonical Output,执行期给强类型 DTO。
80
+
81
+ ## Alternatives considered
82
+
83
+ ### 双轨返回值
84
+ 否决,双重契约增加心智负担。
85
+
86
+ ## Consequences
87
+
88
+ PTC 模式无需反解析字符串。参见 [能力 seam](../architecture/2026-10-01-a.md)。
89
+ `
90
+ const C = `# Agent Note: 笔记看板接入 GUI
91
+
92
+ Status: proposed
93
+
94
+ ## Problem
95
+
96
+ 笔记只能翻目录看。
97
+
98
+ ## Proposal
99
+
100
+ 左侧栏加一个原生面板。
101
+
102
+ ## Alternatives considered
103
+
104
+ 无
105
+
106
+ ## Acceptance criteria
107
+
108
+ 能在面板里切换工作区。
109
+
110
+ ## Risks
111
+
112
+ 只读路由的权限边界。
113
+ `
114
+ const D = `# Agent Note: 用 iframe 复用原版看板
115
+
116
+ Status: rejected
117
+
118
+ ## Problem
119
+
120
+ 想直接嵌原版 HTML。
121
+
122
+ ## Proposal
123
+
124
+ Host 提供 HTML,面板 iframe 引用。
125
+
126
+ ## Alternatives considered
127
+
128
+ ### 内联移植
129
+ 否决,工作量大。
130
+
131
+ ## Risks
132
+
133
+ 拿不到宿主主题与 locale。
134
+ `
135
+
136
+ const files = [
137
+ { path: 'implemented/architecture/2026-10-01-a.md', raw: A },
138
+ { path: 'implemented/feature/2026-10-15-b.md', raw: B },
139
+ { path: 'proposed/process/2026-11-01-c.md', raw: C },
140
+ { path: 'rejected/bug-fix/2026-11-02-d.md', raw: D },
141
+ ]
142
+
143
+ const board = plugin.mountBoard(shadow, {})
144
+ const count = board.ingest(files)
145
+ console.log('ingest 数量:', count)
146
+ console.log('总计数 chip:', byId('chip-total-count').textContent)
147
+ console.log('统计条:', byId('beacon-status').textContent)
148
+
149
+ const probe = (label, id, needle) => {
150
+ const html = byId(id).innerHTML || ''
151
+ console.log(` ${html.includes(needle) ? 'ok ' : 'MISS'} ${label} (${id}, ${html.length}B)`)
152
+ }
153
+ probe('承重墙含被引用笔记', 'pillars-vault', '能力 seam')
154
+ probe('分类阵列非空', 'matrix-chambers', '架构')
155
+ console.log(' 首屏未渲染时间线(懒渲染生效):', (byId('chrono-track').innerHTML || '').length === 0)
156
+
157
+ board.switchTab('timeline')
158
+ probe('时间线非空', 'chrono-track', '2026')
159
+ board.switchTab('alternatives')
160
+ probe('避坑智库含否决项', 'pitfalls-grid', 'iframe')
161
+ board.switchTab('directory')
162
+ probe('决策清单含标题', 'matrix-rows-stream', '能力 seam 三权分立')
163
+
164
+ console.log('注册的根监听:', listeners.map(([t]) => t))
165
+ // 抽屉:模拟点击某行 -> openConsole
166
+ const clickHandler = (listeners.find(([t]) => t === 'click') || [])[1]
167
+ if (!clickHandler) throw new Error('没有拿到 click 代理')
168
+ clickHandler({
169
+ target: { closest: (sel) => (sel === '[data-node-id]' ? { getAttribute: () => 'implemented/architecture/2026-10-01-a.md' } : null) },
170
+ })
171
+ probe('抽屉-背景', 'dr-body-problem', '换沙箱实现')
172
+ probe('抽屉-裁定', 'dr-body-decision', 'Service Definition')
173
+ probe('抽屉-备选', 'dr-body-alt', '始终合并各角色')
174
+ probe('抽屉-代价', 'dr-body-consequences', '零破坏')
175
+ console.log('抽屉标题:', byId('dr-headline').textContent)
176
+ console.log('抽屉血缘:', (byId('dr-lineage-stream').innerHTML || '').length, 'B')
177
+
178
+ // KPI 卡片:原版用内联 onclick 调 routeToListFilter,收进 factory 闭包后点击必抛
179
+ // ReferenceError。现在改成 data-kpi-filter + 事件委托,这里模拟一次点击看筛选是否生效。
180
+ clickHandler({
181
+ target: { closest: (sel) => (sel === '[data-kpi-filter]' ? { getAttribute: () => 'rejected' } : null) },
182
+ })
183
+ const kpiHtml = byId('matrix-rows-stream').innerHTML || ''
184
+ const kpiOk = kpiHtml.includes('用 iframe 复用原版看板') && !kpiHtml.includes('能力 seam')
185
+ console.log(` ${kpiOk ? 'ok ' : 'MISS'} KPI 点击 → 决策清单只剩「否决」项(${kpiHtml.length}B)`)
186
+
187
+ board.dispose()
188
+ console.log('dispose ok')