- commit
- a348c85c02ede8b793157998abe0cf458c7844de
- parent
- 7fa817e87e8d1da77f342f4b1522d1552cd39a46
- author
- Ubuntu <ubuntu@srv1337636.hstgr.cloud>
| M | README.md | 59 | + |
| M | dashboard/ | 22 | +- |
| A | desktop/ | 1529 | +++++++++++++++++++ |
| A | spikes/ | 221 | +++ |
| A | tests/ | 734 | +++++++++ |
| A | tests/ | 2451 | ++++++++++++++++++++++++++++++ |
| A | tests/ | 183 | ++ |
| M | tests/ | 171 | ++ |
| M | tools.py | 56 | +- |
@@ -121,3 +121,62 @@ hermes-openspec/
121121 | `dashboard/plugin_api.py` | All backend API routes and the spec-browser logic |122122 | `dashboard/manifest.json` | Tab registration, entry points |123123 | `dashboard/dist/index.js` | Frontend: board, specs view, source dialogs, deep-linking |124+125+## Hermes Desktop Plugin126+127+A read-only OpenSpec project surface runs inside Hermes Desktop as a runtime plugin.128+129+### Install130+131+Copy `desktop/plugin.js` to the Hermes Desktop plugin directory:132+133+- **Windows:** `%LOCALAPPDATA%\hermes\desktop-plugins\openspec\plugin.js`134+- **Linux/macOS:** `~/.hermes/desktop-plugins/openspec/plugin.js`135+136+The plugin is discovered automatically on Desktop startup. No restart is required after the first install — the runtime watcher picks up the new module.137+138+### Enablement139+140+Plugins are disabled by default (`defaultEnabled: false`). Enable via the Desktop settings UI or by toggling the plugin in the Hermes configuration.141+142+### What it shows143+144+- **Source selector** with project counts (changes, ideas, specs)145+- **Source management** — add, edit, and remove registry entries (remove requires explicit confirmation), refresh, and initialize a registered repository that is missing the standard `openspec/` layout146+- **Work view** — Ideas, Draft, Todo, In Progress, Done, Archived columns with filter and archived toggle147+- **Change detail** — Proposal, Tasks, Design, Specs tabs with task progress148+- **Idea detail** — readable markdown content149+- **Specs browser** — current spec list, selected content, worktree diffs (semantic, side-by-side, raw)150+151+### Agent context resolution152+153+The `openspec_context` tool resolves registered source references to changes,154+specs, and ideas, including archived changes, and rejects ambiguous tokens155+instead of guessing.156+157+### Read-only scope158+159+OpenSpec artifacts — changes, proposals, designs, specs, ideas, tasks, and documents — are read-only in the Desktop surface: no task completion, artifact editing, or file writes are exposed, and the plugin never accesses repository files or paths client-side. The one managed surface is the source registry, mirroring the dashboard: add, edit, remove (registry entry only, after explicit confirmation — repository files are never deleted), refresh, and standard OpenSpec initialization for a registered repository missing the `openspec/` layout. These controls use only the existing dashboard source endpoints (`POST /sources`, `PUT /sources/{source_id}`, `DELETE /sources/{source_id}`, `POST /sources/{source_id}/init`); initialization may create the standard `openspec/` layout when missing and preserves existing artifacts.160+161+### Remote backend behavior162+163+All data flows through `ctx.rest()` namespace-scoped API calls. The plugin works identically when Desktop connects to a local or remote Hermes backend. No client-side code reads backend repository paths — Windows Desktop sessions never access VPS filesystem paths.164+165+### Rollback166+167+Before any separately approved deployment, capture the currently installed `plugin.js` bytes, SHA-256, byte size, and exact rollback backup path; verify the backup reproduces the recorded hash. That immediate pre-deployment backup is the only authoritative rollback baseline. Historical spike commits and prior deployment hashes are not rollback substitutes.168+169+### Tests170+171+```bash172+node tests/desktop-plugin-smoke.mjs # 681 tests: contract, helpers, source registry, UI verification173+node tests/lane-override-behavior.mjs # 23 tests: lane override/source/phase behavior174+node spikes/001-desktop-route-api/smoke.mjs # 36 tests: legacy seam validation175+PYTHONPATH=<hermes-agent repo> python3 -m pytest -q tests/ # 64 passed, 2 skipped176+```177+178+### Runtime constraints179+180+- Single uncompiled ESM file (no bundler)181+- Imports: `@hermes/plugin-sdk`, `react`, `react/jsx-runtime` only182+- No JSX syntax, new npm dependencies, direct `fetch`, or absolute API URLs@@ -185,7 +185,27 @@ def _status(has_tasks: bool, stats: Optional[dict[str, int]], *, archived: bool)
185185 186186 def _title_from_markdown(path: Path, fallback: str) -> str:187187 try:188- for line in path.read_text(encoding="utf-8", errors="replace").splitlines():188+ text = path.read_text(encoding="utf-8", errors="replace")189+ lines = text.splitlines()190+ content_start = 0191+ if lines and lines[0].strip() == "---":192+ closing_index = next(193+ (i for i, line in enumerate(lines[1:], start=1) if line.strip() == "---"),194+ None,195+ )196+ if closing_index is not None:197+ frontmatter_title: str | None = None198+ for line in lines[1:closing_index]:199+ stripped = line.strip()200+ if stripped.startswith("title:"):201+ val = stripped[6:].strip().strip('"').strip("'")202+ if val:203+ frontmatter_title = val204+ if frontmatter_title:205+ return frontmatter_title206+ content_start = closing_index + 1207+208+ for line in lines[content_start:]:189209 stripped = line.strip()190210 if stripped.startswith("# "):191211 return stripped[2:].strip() or fallback@@ -0,0 +1,1529 @@
1+/**2+ * OpenSpec Desktop runtime plugin — source registry + project surface.3+ *4+ * Registers /openspec route + sidebar nav entry. Provides source selector,5+ * work board (ideas, changes by status), change/idea detail dialogs,6+ * current specs browser, worktree diff views, and source management7+ * (add/edit/remove) via the existing backend CRUD routes.8+ *9+ * Packaging: single uncompiled ESM file. Runtime loader scans10+ * ~/.hermes/plugins/openspec/desktop/plugin.js.11+ */12+13+import {14+ Badge, Button, cn, Codicon, CopyButton, Dialog, DialogContent, EmptyState, ErrorState,15+ Input, Loader, ROUTES_AREA, SearchField, Select,16+ SelectContent, SelectItem, SelectTrigger, SelectValue,17+ SegmentedControl, SIDEBAR_NAV_AREA, Tabs, TabsList, TabsTrigger, useQuery18+} from '@hermes/plugin-sdk'19+import React from 'react'20+import { jsx, jsxs } from 'react/jsx-runtime'21+22+var SOURCE_TRIGGER_HOVER_CSS = '[data-openspec-source-trigger].desktop-input-chrome { background-color: var(--chrome-action-hover) !important; } [data-openspec-source-trigger].desktop-input-chrome:hover { background-color: var(--chrome-action-hover) !important; }'23+24+// ═══════════════════════════════════════════════════════════════════════════════25+// Pure helpers — exported as __test for headless verification26+// ═══════════════════════════════════════════════════════════════════════════════27+28+function sourcesPath() { return '/sources' }29+30+function changePath(sourceId, name) {31+ return '/sources/' + encodeURIComponent(sourceId) + '/changes/' + encodeURIComponent(name)32+}33+34+function ideaPath(sourceId, name) {35+ return '/sources/' + encodeURIComponent(sourceId) + '/ideas/' + encodeURIComponent(name)36+}37+38+function specBrowserPath(sourceId, opts) {39+ var base = '/sources/' + encodeURIComponent(sourceId) + '/spec-browser'40+ if (opts && opts.dirty) return base + '?dirty=true'41+ return base42+}43+44+function specPath(sourceId, path) {45+ return '/sources/' + encodeURIComponent(sourceId) + '/specs?path=' + encodeURIComponent(path)46+}47+48+function selectFirstValid(sources) {49+ if (!sources || sources.length === 0) return null50+ var valid = sources.filter(function(s) { return s.valid !== false })51+ if (valid.length > 0) return valid[0]52+ return sources[0]53+}54+55+function normalizeStatus(status) {56+ var s = String(status || '').toLowerCase()57+ if (s === 'in-progress' || s === 'inprogress' || s === 'in_progress') return 'in-progress'58+ if (s === 'ideas') return 'ideas'59+ if (s === 'draft') return 'draft'60+ if (s === 'todo') return 'todo'61+ if (s === 'done') return 'done'62+ if (s === 'archived') return 'archived'63+ return 'todo'64+}65+66+var STATUS_ORDER = ['ideas', 'draft', 'todo', 'in-progress', 'done', 'archived']67+68+function groupByStatus(items, showArchived) {69+ var groups = {}70+ var order = showArchived ? STATUS_ORDER : STATUS_ORDER.filter(function(s) { return s !== 'archived' })71+ for (var i = 0; i < order.length; i++) groups[order[i]] = []72+ for (var j = 0; j < (items || []).length; j++) {73+ var item = items[j]74+ var status = normalizeStatus(item.status || item.state || item.phase)75+ if (!showArchived && status === 'archived') continue76+ if (!groups[status]) groups[status] = []77+ groups[status].push(item)78+ }79+ return groups80+}81+82+function normalizeSequencePosition(sequence) {83+ var position = sequence && typeof sequence === 'object' ? sequence.position : sequence84+ return typeof position === 'number' && Number.isFinite(position) ? position : null85+}86+87+function sortItems(items) {88+ return (items || []).slice().sort(function(a, b) {89+ var sa = normalizeSequencePosition(a.sequence)90+ var sb = normalizeSequencePosition(b.sequence)91+ if (sa == null) sa = 999992+ if (sb == null) sb = 999993+ if (sa !== sb) return sa - sb94+ var ta = (a.title || a.name || '').toLowerCase()95+ var tb = (b.title || b.name || '').toLowerCase()96+ if (ta < tb) return -197+ if (ta > tb) return 198+ return 099+ })100+}101+102+function filterItems(items, query) {103+ if (!query) return items || []104+ var q = String(query).toLowerCase()105+ return (items || []).filter(function(item) {106+ var title = String(item.title || item.name || '').toLowerCase()107+ var token = String(item.token || '').toLowerCase()108+ var name = String(item.name || '').toLowerCase()109+ return title.includes(q) || token.includes(q) || name.includes(q)110+ })111+}112+113+function toggleArchived(current) { return !current }114+115+function stripFrontmatter(content) {116+ if (!content) return content117+ return content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '')118+}119+120+// ═══════════════════════════════════════════════════════════════════════════════121+// Resolver-safe identity helpers — pure, testable, canonical-aligned122+// ═══════════════════════════════════════════════════════════════════════════════123+124+function buildSourceReference(source, allSources) {125+ if (!source) return ''126+ var name = String(source.name || '').trim()127+ if (name) {128+ var count = 0129+ for (var i = 0; i < (allSources || []).length; i++) {130+ var other = allSources[i]131+ if (other && String(other.name || '').trim().toLowerCase() === name.toLowerCase()) count++132+ }133+ if (count === 1) return name134+ }135+ var token = String(source.token || '').trim()136+ if (token) return token137+ var id = String(source.id || '').trim()138+ if (id) return id139+ return ''140+}141+142+function buildItemReference(source, item, allSources) {143+ var sourceRef = buildSourceReference(source, allSources)144+ var token = item && item.token ? String(item.token).trim() : ''145+ if (!sourceRef || !token) return ''146+ return sourceRef + '/' + token147+}148+149+function hasUniqueItemToken(item, allItems) {150+ var token = item && item.token ? String(item.token).trim() : ''151+ if (!token) return false152+ var count = 0153+ for (var i = 0; i < (allItems || []).length; i++) {154+ var other = allItems[i]155+ if (other && String(other.token || '').trim() === token) count++156+ }157+ return count === 1158+}159+160+function createSessionStore() {161+ return { selectedSourceId: null, showArchived: true }162+}163+164+function resolveRetainedSource(sources, retainedId) {165+ if (!sources || sources.length === 0) return null166+ if (retainedId != null && retainedId !== '') {167+ for (var i = 0; i < sources.length; i++) {168+ if (sources[i].id === retainedId) return retainedId169+ }170+ }171+ var fallback = selectFirstValid(sources)172+ return fallback ? fallback.id : null173+}174+175+function detailTitle(loaded, summary, item, kind) {176+ if (loaded && loaded.title) return loaded.title177+ if (summary && summary.title) return summary.title178+ if (item && item.name) return item.name179+ return kind === 'idea' ? 'Untitled idea' : 'Untitled change'180+}181+182+function detailSecondaryName(loaded, summary) {183+ if (loaded && loaded.name) return loaded.name184+ if (summary && summary.name) return summary.name185+ return ''186+}187+188+function ideaSecondaryName(name) {189+ if (!name) return ''190+ return String(name).replace(/\.md$/i, '') + '.md'191+}192+193+function handleActivateKey(e, action) {194+ if (!e) return195+ if (e.key === 'Enter' || e.key === ' ') {196+ if (typeof e.preventDefault === 'function') e.preventDefault()197+ if (action) action()198+ }199+}200+201+// ═══════════════════════════════════════════════════════════════════════════════202+// Lane override helpers — pure, testable, canonical-aligned203+// ═══════════════════════════════════════════════════════════════════════════════204+205+function computeCollapsedSet(autoCollapsed, manualOverrides, statusOrder) {206+ var result = {}207+ for (var i = 0; i < statusOrder.length; i++) {208+ var s = statusOrder[i]209+ if (manualOverrides[s] !== undefined) {210+ result[s] = manualOverrides[s]211+ } else {212+ result[s] = !!autoCollapsed[s]213+ }214+ }215+ return result216+}217+218+function toggleOverride(prev, status, autoCollapsed) {219+ var next = Object.assign({}, prev)220+ var auto = !!autoCollapsed[status]221+ if (next[status] !== undefined) {222+ delete next[status]223+ } else {224+ next[status] = !auto225+ }226+ return next227+}228+229+function computeLanePhase(grouped, statusOrder) {230+ if (!grouped) return null231+ var parts = []232+ for (var i = 0; i < statusOrder.length; i++) {233+ var s = statusOrder[i]234+ var items = grouped[s] || []235+ parts.push(s + ':' + (items.length === 0 ? 'empty' : 'full'))236+ }237+ return parts.join('|')238+}239+240+function pruneStaleOverrides(prevOverrides, prevPhase, lanePhase) {241+ if (prevPhase === null || lanePhase === null || lanePhase === prevPhase) {242+ return prevOverrides243+ }244+ var before = {}245+ var prevParts = prevPhase.split('|')246+ for (var i = 0; i < prevParts.length; i++) {247+ var kv = prevParts[i].split(':')248+ before[kv[0]] = kv[1]249+ }250+ var next = Object.assign({}, prevOverrides)251+ var changed = false252+ var curParts = lanePhase.split('|')253+ for (var j = 0; j < curParts.length; j++) {254+ var ckv = curParts[j].split(':')255+ var name = ckv[0]256+ var phase = ckv[1]257+ var was = before[name]258+ if (was !== undefined && was !== phase && next[name] !== undefined) {259+ delete next[name]260+ changed = true261+ }262+ }263+ return changed ? next : prevOverrides264+}265+266+// ═══════════════════════════════════════════════════════════════════════════════267+// Lane layout helpers268+// ═══════════════════════════════════════════════════════════════════════════════269+270+var LANE_EXPANDED_WIDTH = 256271+var LANE_COLLAPSED_WIDTH = 32272+273+// ═══════════════════════════════════════════════════════════════════════════════274+// Canonical status tone mapping — aligned with kanban/types.ts COLUMN_META275+// ═══════════════════════════════════════════════════════════════════════════════276+277+var STATUS_TONE = {278+ 'ideas': 'var(--ui-text-tertiary)',279+ 'draft': '#a78bfa',280+ 'todo': 'var(--ui-text-secondary)',281+ 'in-progress': '#34d399',282+ 'done': 'var(--ui-text-tertiary)',283+ 'archived': 'var(--ui-text-quaternary)',284+}285+286+function statusTone(status) {287+ return STATUS_TONE[status] || 'var(--ui-text-secondary)'288+}289+290+function autoCollapseEmptyLanes(grouped, statusOrder) {291+ var totalItems = 0292+ for (var i = 0; i < statusOrder.length; i++) {293+ var s = statusOrder[i]294+ var items = grouped[s] || []295+ totalItems += items.length296+ }297+ var collapsed = {}298+ if (totalItems > 0) {299+ for (var j = 0; j < statusOrder.length; j++) {300+ var st = statusOrder[j]301+ var col = grouped[st] || []302+ if (col.length === 0) collapsed[st] = true303+ }304+ }305+ return collapsed306+}307+308+function laneWidth(status, collapsedSet) {309+ return (collapsedSet && collapsedSet[status]) ? LANE_COLLAPSED_WIDTH : LANE_EXPANDED_WIDTH310+}311+312+function railStyle() {313+ return {314+ width: LANE_COLLAPSED_WIDTH + 'px',315+ minWidth: LANE_COLLAPSED_WIDTH + 'px',316+ flexShrink: 0,317+ height: '100%',318+ cursor: 'pointer',319+ display: 'flex',320+ flexDirection: 'column',321+ alignItems: 'center',322+ gap: '4px',323+ borderRadius: '8px',324+ padding: '8px 0',325+ overflow: 'hidden',326+ }327+}328+329+function expandedStyle() {330+ return {331+ width: LANE_EXPANDED_WIDTH + 'px',332+ minWidth: LANE_EXPANDED_WIDTH + 'px',333+ flexShrink: 0,334+ height: '100%',335+ display: 'flex',336+ flexDirection: 'column',337+ background: 'color-mix(in srgb, var(--ui-bg-quinary) 50%, transparent)',338+ borderRadius: '8px',339+ padding: '8px',340+ overflow: 'hidden',341+ }342+}343+344+function parseTasks(content) {345+ var total = 0, done = 0346+ var sections = []347+ var lines = (content || '').split('\n')348+ var currentSection = null349+ for (var i = 0; i < lines.length; i++) {350+ var line = lines[i]351+ var headingMatch = line.match(/^#{1,6}\s+(.+)/)352+ if (headingMatch) {353+ currentSection = { title: headingMatch[1], done: 0, total: 0, tasks: [] }354+ sections.push(currentSection)355+ }356+ var taskMatch = line.match(/^[-*]\s+\[([ xX])\]\s*(.*)/)357+ if (taskMatch) {358+ var isDone = taskMatch[1] === 'x' || taskMatch[1] === 'X'359+ total++360+ if (isDone) done++361+ var task = { text: taskMatch[2], done: isDone }362+ if (!currentSection) {363+ currentSection = { title: 'Tasks', done: 0, total: 0, tasks: [] }364+ sections.push(currentSection)365+ }366+ currentSection.total++367+ if (isDone) currentSection.done++368+ currentSection.tasks.push(task)369+ }370+ }371+ return { total: total, done: done, sections: sections }372+}373+374+function renderMarkdown(content) {375+ if (!content) return []376+ var elements = []377+ var lines = content.split('\n')378+ var i = 0379+ while (i < lines.length) {380+ var line = lines[i]381+382+ // Fenced code block383+ if (line.match(/^```/)) {384+ var lang = line.slice(3).trim()385+ var codeLines = []386+ i++387+ while (i < lines.length && !lines[i].match(/^```/)) { codeLines.push(lines[i]); i++ }388+ i++ // skip closing fence389+ elements.push({ type: 'fenced', lang: lang, content: codeLines.join('\n') })390+ continue391+ }392+393+ // Heading394+ var headingMatch = line.match(/^(#{1,6})\s+(.*)/)395+ if (headingMatch) {396+ elements.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] })397+ i++; continue398+ }399+400+ // Unordered list401+ if (line.match(/^[-*]\s+/)) {402+ elements.push({ type: 'list-item', ordered: false, text: line.replace(/^[-*]\s+/, '') })403+ i++; continue404+ }405+406+ // Ordered list407+ if (line.match(/^\d+\.\s+/)) {408+ elements.push({ type: 'list-item', ordered: true, text: line.replace(/^\d+\.\s+/, '') })409+ i++; continue410+ }411+412+ // Empty line413+ if (line.trim() === '') { i++; continue }414+415+ // Paragraph416+ elements.push({ type: 'paragraph', text: line })417+ i++418+ }419+ return elements420+}421+422+function classifyState(query) {423+ if (query.isLoading) return 'loading'424+ if (query.error) return 'error'425+ if (query.data) {426+ if (Array.isArray(query.data) && query.data.length === 0) return 'empty'427+ if (query.data.sources && query.data.sources.length === 0) return 'empty'428+ if (query.data.files && query.data.files.length === 0) return 'empty'429+ if (query.data.items && Array.isArray(query.data.items) && query.data.items.length === 0) return 'empty'430+ return 'content'431+ }432+ return 'empty'433+}434+435+function classifyDiffLine(line) {436+ if (!line) return 'context'437+ if (line.startsWith('+')) return 'added'438+ if (line.startsWith('-')) return 'removed'439+ if (line.startsWith('@@')) return 'hunk'440+ return 'context'441+}442+443+function hasDiffMode(data, mode) {444+ if (!data) return false445+ if (mode === 'semantic') return !!(data.semantic_diff && (data.semantic_diff.groups || data.semantic_diff.requirements))446+ if (mode === 'split') return !!(data.before != null && data.after != null)447+ if (mode === 'raw') return !!(data.diff || data.raw_diff)448+ return false449+}450+451+function effectiveDiffMode(desired, data) {452+ if (hasDiffMode(data, desired)) return desired453+ var fallback = ['semantic', 'split', 'raw']454+ for (var i = 0; i < fallback.length; i++) {455+ if (hasDiffMode(data, fallback[i])) return fallback[i]456+ }457+ return desired458+}459+460+function trimPath(raw) {461+ return typeof raw === 'string' ? raw.trim() : ''462+}463+464+function extractApiError(err) {465+ if (!err) return 'Unknown error'466+ var candidate = typeof err === 'string' ? err : (err.message || err.detail)467+ if (typeof candidate === 'string' && /\b409\b/.test(candidate) && candidate.indexOf('Source already registered') !== -1) {468+ return 'Source already registered. Select it from the source list, or remove it before adding again.'469+ }470+ if (typeof err === 'string') return err471+ if (err.message) return err.message472+ if (err.detail) return err.detail473+ return String(err)474+}475+476+function sourceNeedsInitialization(result) {477+ var source = result && result.source478+ return !!(source && source.id && source.valid === false && source.error === 'No openspec/ directory found')479+}480+481+function initializeAddedSource(api, result) {482+ if (!sourceNeedsInitialization(result)) return Promise.resolve(result)483+ return api.initSource(result.source.id).then(function(initResult) {484+ return initResult && initResult.source && initResult.source.id ? initResult : result485+ }).catch(function(e) {486+ return Object.assign({}, result, { initError: extractApiError(e) })487+ })488+}489+490+// ═══════════════════════════════════════════════════════════════════════════════491+// API adapter — source registry mutations + read-only, namespace-scoped492+// ═══════════════════════════════════════════════════════════════════════════════493+494+function createApi(ctx) {495+ return {496+ sources: function() { return ctx.rest(sourcesPath()) },497+ change: function(sourceId, name) { return ctx.rest(changePath(sourceId, name)) },498+ idea: function(sourceId, name) { return ctx.rest(ideaPath(sourceId, name)) },499+ specBrowser: function(sourceId, opts) { return ctx.rest(specBrowserPath(sourceId, opts)) },500+ spec: function(sourceId, path) { return ctx.rest(specPath(sourceId, path)) },501+ addSource: function(path, name) {502+ return ctx.rest(sourcesPath(), { method: 'POST', body: { path: path, name: name || undefined } })503+ },504+ updateSource: function(sourceId, path, name) {505+ return ctx.rest('/sources/' + encodeURIComponent(sourceId), { method: 'PUT', body: { path: path, name: name || undefined } })506+ },507+ initSource: function(sourceId) {508+ return ctx.rest('/sources/' + encodeURIComponent(sourceId) + '/init', { method: 'POST' })509+ },510+ removeSource: function(sourceId) {511+ return ctx.rest('/sources/' + encodeURIComponent(sourceId), { method: 'DELETE' })512+ },513+ }514+}515+516+// ═══════════════════════════════════════════════════════════════════════════════517+// Markdown renderer — safe subset, no innerHTML518+// ═══════════════════════════════════════════════════════════════════════════════519+520+function isSafeUrl(url) {521+ if (!url) return false522+ // Strip leading whitespace and control characters (U+0000-U+001F, U+007F-U+009F)523+ var s = String(url).replace(/^[\s\x00-\x1f\x7f-\x9f]+/, '')524+ if (!s) return false525+ // Reject protocol-relative URLs526+ if (s.startsWith('//')) return false527+ // Allow relative paths and fragment-only links528+ if (s.startsWith('/') || s.startsWith('#') || s.startsWith('?')) return true529+ // Explicit scheme allowlist: http, https, mailto (case-insensitive)530+ var lower = s.toLowerCase()531+ if (lower.startsWith('http://') || lower.startsWith('https://') || lower.startsWith('mailto:')) return true532+ // Everything else is rejected (javascript:, data:, file:, vbscript:, etc.)533+ return false534+}535+536+function renderInline(text) {537+ if (!text) return text538+ var parts = []539+ var remaining = text540+ var regex = /(\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\))/g541+ var lastIndex = 0542+ var match543+ while ((match = regex.exec(remaining)) !== null) {544+ if (match.index > lastIndex) parts.push(remaining.slice(lastIndex, match.index))545+ if (match[2]) parts.push(jsx('strong', { style: { fontWeight: 600 }, children: match[2] }))546+ else if (match[3]) parts.push(jsx('code', { style: { background: 'var(--editor-background, #1e1e1e)', padding: '1px 4px', borderRadius: '3px', fontFamily: 'monospace', fontSize: '0.9em' }, children: match[3] }))547+ else if (match[4]) {548+ if (isSafeUrl(match[5])) {549+ parts.push(jsx('a', { href: match[5], target: '_blank', rel: 'noopener noreferrer', style: { color: 'var(--link-foreground, #4fc3f7)', textDecoration: 'underline' }, children: match[4] }))550+ } else {551+ parts.push(jsx('span', { style: { color: 'var(--muted-foreground, #888)' }, children: match[4] + ' [unsafe link]' }))552+ }553+ }554+ lastIndex = match.index + match[0].length555+ }556+ if (lastIndex < remaining.length) parts.push(remaining.slice(lastIndex))557+ return parts558+}559+560+function MarkdownElement(el) {561+ if (typeof el === 'string') return el562+ if (typeof el === 'number') return el563+ if (!el || !el.type) return null564+ switch (el.type) {565+ case 'heading':566+ return jsx('div', { style: { fontWeight: 'bold', fontSize: el.level <= 2 ? '1.1em' : '1em', margin: '8px 0 4px', color: 'var(--foreground, #e0e0e0)' }, children: renderInline(el.text) })567+ case 'paragraph':568+ return jsx('div', { style: { margin: '4px 0', color: 'var(--foreground, #e0e0e0)' }, children: renderInline(el.text) })569+ case 'list-item':570+ return jsxs('div', { style: { margin: '1px 0', paddingLeft: '16px', color: 'var(--foreground, #e0e0e0)' }, children: [571+ el.ordered ? ' ' : '\u2022 ',572+ renderInline(el.text)573+ ] })574+ case 'fenced':575+ return jsx('pre', { style: { background: 'var(--editor-background, #1e1e1e)', padding: '8px', borderRadius: '4px', margin: '4px 0', overflow: 'auto', fontFamily: 'monospace', fontSize: '0.9em', color: 'var(--foreground, #e0e0e0)' }, children: el.content })576+ default:577+ return null578+ }579+}580+581+function MarkdownView({ content }) {582+ var elements = renderMarkdown(content)583+ if (!elements || elements.length === 0) return jsx(EmptyState, { title: 'No content', description: 'This document is empty.' })584+ return jsxs('div', { 'data-selectable-text': 'true', style: { padding: '12px' }, children: elements.map(function(el, i) { return jsx(MarkdownElement, Object.assign({}, el, { key: i })) }) })585+}586+587+// ═══════════════════════════════════════════════════════════════════════════════588+// Query state wrapper589+// ═══════════════════════════════════════════════════════════════════════════════590+591+// Reusable retry error state: ErrorState with a Button child wired to onRetry.592+// Real SDK ErrorStateProps has no onRetry — canonical pattern nests Button child.593+function RetryErrorState({ title, description, onRetry }) {594+ return jsx(ErrorState, {595+ title: title,596+ description: description,597+ children: onRetry ? jsx(Button, {598+ variant: 'outline',599+ size: 'sm',600+ onClick: onRetry,601+ children: 'Retry'602+ }) : null603+ })604+}605+606+function QueryState({ query, onRetry, emptyTitle, emptyDescription, children }) {607+ var state = classifyState(query)608+ if (state === 'loading') return jsx(Loader, { type: 'lemniscate-bloom' })609+ if (state === 'error') return jsx(RetryErrorState, {610+ title: 'Request failed',611+ description: (query.error && query.error.message) || 'Could not reach the backend.',612+ onRetry: onRetry613+ })614+ if (state === 'empty') return jsx(EmptyState, { title: emptyTitle || 'Nothing found', description: emptyDescription || 'No data available.' })615+ return children616+}617+618+// ═══════════════════════════════════════════════════════════════════════════════619+// Source dialog — add/edit source registry entries620+// ═══════════════════════════════════════════════════════════════════════════════621+622+function SourceDialog({ mode, api, source, onClose, onSaved }) {623+ var initialPath = source && source.path ? source.path : ''624+ var initialName = source && source.name ? source.name : ''625+ var _p = React.useState(initialPath)626+ var _n = React.useState(initialName)627+ var _busy = React.useState(false)628+ var _err = React.useState('')629+ var path = _p[0], setPath = _p[1]630+ var name = _n[0], setName = _n[1]631+ var busy = _busy[0], setBusy = _busy[1]632+ var err = _err[0], setErr = _err[1]633+634+ var title = mode === 'edit' ? 'Edit source' : 'Add source'635+636+ function handleSave() {637+ var trimmed = trimPath(path)638+ if (!trimmed) { setErr('Path is required'); return }639+ setBusy(true); setErr('')640+ var promise = mode === 'edit' && source641+ ? api.updateSource(source.id, trimmed, trimPath(name))642+ : api.addSource(trimmed, trimPath(name))643+ if (mode === 'add') {644+ promise = promise.then(function(result) { return initializeAddedSource(api, result) })645+ }646+ promise.then(function(result) {647+ setBusy(false)648+ onSaved(result)649+ }).catch(function(e) {650+ setBusy(false)651+ setErr(extractApiError(e))652+ })653+ }654+655+ return jsx(Dialog, { open: true, onOpenChange: onClose, children: jsxs(DialogContent, { style: { padding: '16px', maxWidth: '480px' }, children: [656+ jsx('h2', { style: { fontSize: '16px', fontWeight: 600, margin: '0 0 12px', color: 'var(--foreground, #e0e0e0)' }, children: title }),657+ jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px' }, children: [658+ jsxs('div', { children: [659+ jsx('label', { style: { fontSize: '12px', fontWeight: 500, color: 'var(--muted-foreground, #888)', marginBottom: '4px', display: 'block' }, children: 'Path *' }),660+ jsx(Input, { value: path, onChange: function(e) { setPath(e.target.value) }, placeholder: '/path/to/repo', disabled: busy }),661+ ] }),662+ jsxs('div', { children: [663+ jsx('label', { style: { fontSize: '12px', fontWeight: 500, color: 'var(--muted-foreground, #888)', marginBottom: '4px', display: 'block' }, children: 'Display name' }),664+ jsx(Input, { value: name, onChange: function(e) { setName(e.target.value) }, placeholder: 'Optional display name', disabled: busy }),665+ ] }),666+ ] }),667+ err ? jsx('div', { style: { padding: '8px 12px', background: 'var(--destructive-background, #3d1a1a)', borderRadius: '4px', fontSize: '12px', color: 'var(--destructive, #f44336)', marginBottom: '12px' }, children: err }) : null,668+ jsxs('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px' }, children: [669+ jsx(Button, { variant: 'outline', size: 'sm', onClick: onClose, disabled: busy, children: 'Cancel' }),670+ jsx(Button, { size: 'sm', onClick: handleSave, disabled: busy, children: busy ? 'Saving...' : 'Save' }),671+ ] }),672+ ] }) })673+}674+675+// ═══════════════════════════════════════════════════════════════════════════════676+// Card helpers677+// ═══════════════════════════════════════════════════════════════════════════════678+679+function CardTaskFraction({ item }) {680+ var tasks = item.tasks || item.taskStats681+ if (!tasks) return null682+ var done = tasks.done != null ? tasks.done : (tasks.completed || 0)683+ var total = tasks.total != null ? tasks.total : (tasks.count || 0)684+ if (total === 0) return null685+ return jsx(Badge, { variant: 'outline', children: '✅' + done + '/' + total })686+}687+688+function CardArtifacts({ item }) {689+ var badges = []690+ if (item.hasProposal) badges.push({ label: 'proposal', color: '#60a5fa' })691+ if (item.hasTasks) badges.push({ label: 'tasks', color: '#34d399' })692+ if (item.hasDesign) badges.push({ label: 'design', color: '#a78bfa' })693+ if (item.hasSpecs) badges.push({ label: 'specs', color: '#fbbf24' })694+ if (badges.length === 0) return null695+ return jsxs('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap' },696+ children: badges.map(function(b, i) {697+ return jsx(Badge, { variant: 'secondary', style: { fontSize: '10px', color: b.color }, children: b.label }, i)698+ })699+ })700+}701+702+function BoardCard({ item, source, allSources, allItems, onSelect }) {703+ var token = item.token || item.name || ''704+ var title = item.title || item.name || 'Untitled'705+ var sequencePosition = normalizeSequencePosition(item.sequence)706+ var itemRef = buildItemReference(source, item, allSources)707+ var canCopy = !!itemRef && hasUniqueItemToken(item, allItems)708+ var onClick = function() { onSelect(item) }709+ var status = normalizeStatus(item.status || item.state || item.phase)710+ return jsxs('div', {711+ className: cn(712+ 'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) px-3 pt-3 pb-0 cursor-pointer',713+ 'hover:border-(--ui-focus-border) hover:bg-primary/[0.06] hover:shadow-md transition-colors',714+ 'focus-visible:bg-(--ui-control-hover-background)'715+ ),716+ style: { borderLeftWidth: '2px', borderLeftColor: statusTone(status) },717+ 'data-selectable-text': 'true',718+ tabIndex: 0,719+ role: 'button',720+ onClick: onClick,721+ onKeyDown: function(e) { handleActivateKey(e, onClick) },722+ children: [723+ jsxs('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }, children: [724+ jsx('div', { style: { fontWeight: 500, fontSize: '13px', color: 'var(--foreground, #e0e0e0)' }, children: title }),725+ sequencePosition != null ? jsx(Badge, { variant: 'outline', style: { fontSize: '10px', flexShrink: 0 }, children: '#' + sequencePosition }) : null726+ ] }),727+ jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '4px' }, children: [728+ jsx(CardArtifacts, { item: item }),729+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 }, children: [730+ jsx(CardTaskFraction, { item: item }),731+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginLeft: 'auto', flexShrink: 0 }, children: [732+ canCopy ? jsx(CopyButton, { appearance: 'icon', text: itemRef, label: 'Copy item reference', title: itemRef, stopPropagation: true }) : null,733+ token ? jsx('span', { style: { fontFamily: 'monospace', fontSize: '10px', color: 'var(--muted-foreground, #888)' }, children: token }) : null,734+ ] }),735+ ] }),736+ ] }),737+ ]738+ }, token || title)739+}740+741+// ═══════════════════════════════════════════════════════════════════════════════742+// Board columns743+// ═══════════════════════════════════════════════════════════════════════════════744+745+var COLUMN_LABELS = {746+ 'ideas': 'Ideas',747+ 'draft': 'Draft',748+ 'todo': 'Todo',749+ 'in-progress': 'In Progress',750+ 'done': 'Done',751+ 'archived': 'Archived',752+}753+754+function BoardColumn({ status, items, source, allSources, allItems, onSelect, collapsed, onExpand }) {755+ var label = COLUMN_LABELS[status] || status756+ var sorted = sortItems(items)757+ var count = items.length758+759+ if (collapsed) {760+ return jsx('div', {761+ style: railStyle(),762+ className: cn('focus-visible:bg-(--ui-control-hover-background) bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)] hover:bg-(--ui-bg-quinary)'),763+ tabIndex: 0,764+ role: 'button',765+ onClick: onExpand,766+ onKeyDown: function(e) { handleActivateKey(e, onExpand) },767+ title: label,768+ 'aria-label': label,769+ children: [770+ jsx('span', { style: { display: 'grid', height: '20px', placeItems: 'center', flexShrink: 0 }, children: [771+ jsx('span', { style: { width: '6px', height: '6px', borderRadius: '50%', backgroundColor: statusTone(status) } }),772+ ] }),773+ jsx('span', { style: { fontSize: '11px', fontWeight: 600, color: 'var(--ui-text-tertiary)', letterSpacing: '0.05em', textTransform: 'uppercase', whiteSpace: 'nowrap', writingMode: 'vertical-rl' }, children: label }),774+ count > 0 ? jsx('span', { style: { fontSize: '10px', color: 'var(--ui-text-quaternary)', fontVariantNumeric: 'tabular-nums' }, children: count }) : null,775+ ]776+ })777+ }778+779+ return jsxs('div', { style: expandedStyle(), 'data-lane': status, children: [780+ jsxs('button', {781+ type: 'button',782+ onClick: onExpand,783+ title: 'Collapse ' + label,784+ 'aria-label': 'Collapse ' + label,785+ className: cn(786+ 'flex w-full items-center justify-between px-1 pb-2 bg-transparent border-0 text-inherit cursor-pointer text-left',787+ 'focus-visible:bg-(--ui-control-hover-background)'788+ ),789+ style: { fontSize: 'inherit' },790+ onKeyDown: function(e) { handleActivateKey(e, onExpand) },791+ children: [792+ jsxs('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' }, children: [793+ jsx('span', { style: { width: '6px', height: '6px', borderRadius: '50%', backgroundColor: statusTone(status), flexShrink: 0 } }),794+ jsx('span', { style: { fontWeight: 600, fontSize: '12px', textTransform: 'uppercase', color: 'var(--ui-text-tertiary)', letterSpacing: '0.05em' }, children: label }),795+ count > 0 ? jsx('span', { style: { fontSize: '12px', color: 'var(--ui-text-quaternary)', fontVariantNumeric: 'tabular-nums' }, children: count }) : null,796+ ] }),797+ jsx('span', { style: { display: 'inline-flex', flexShrink: 0 }, children: jsx(Codicon, { name: 'chevron-left', size: '0.85rem' }) }),798+ ]799+ }),800+ jsxs('div', { style: { flex: 1, overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column', gap: '8px', paddingRight: '4px' }, children: sorted.map(function(item) {801+ return jsx(BoardCard, {802+ item: item,803+ source: source,804+ allSources: allSources,805+ allItems: allItems,806+ onSelect: onSelect,807+ }, item.token || item.name || item.title)808+ }) }),809+ items.length === 0 ? jsx('div', { style: { color: 'var(--muted-foreground, #666)', fontSize: '12px', padding: '12px 4px', fontStyle: 'italic' }, children: 'No items' }) : null,810+ ] })811+}812+813+// ═══════════════════════════════════════════════════════════════════════════════814+// Detail drawer815+// ═══════════════════════════════════════════════════════════════════════════════816+817+function DetailDrawer({ title, secondary, itemRef, canCopy, token, taskStats, onClose, children }) {818+ return jsxs('div', {819+ className: cn('absolute inset-y-0 right-0 z-20 flex w-[26rem] flex-col border-l border-(--ui-stroke-tertiary) bg-(--ui-bg-elevated) shadow-lg'),820+ style: { minHeight: '0' },821+ 'data-detail-drawer': 'true',822+ children: [823+ jsxs('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--ui-border, #333)', flexShrink: 0 }, children: [824+ jsxs('div', { style: { minWidth: 0 }, children: [825+ jsx('h2', { style: { fontSize: '16px', fontWeight: 600, margin: 0, color: 'var(--foreground, #e0e0e0)' }, children: title }),826+ secondary ? jsx('div', { style: { fontSize: '12px', color: 'var(--muted-foreground, #888)', fontFamily: 'monospace', marginTop: '2px' }, children: secondary }) : null,827+ ] }),828+ jsx('button', {829+ type: 'button',830+ onClick: onClose,831+ title: 'Close detail',832+ 'aria-label': 'Close detail',833+ style: { background: 'none', border: 'none', color: 'var(--muted-foreground, #888)', cursor: 'pointer', padding: '4px', flexShrink: 0 },834+ children: jsx(Codicon, { name: 'close', size: '1rem' })835+ }),836+ ] }),837+ jsxs('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', padding: '8px 16px', borderBottom: '1px solid var(--ui-border, #333)', flexShrink: 0 }, children: [838+ canCopy ? jsx(CopyButton, { appearance: 'icon', text: itemRef, label: 'Copy item reference', title: itemRef }) : null,839+ token ? jsx('span', { style: { fontSize: '12px', color: 'var(--muted-foreground, #888)', fontFamily: 'monospace' }, children: token }) : null,840+ taskStats ? jsx(Badge, { variant: 'outline', children: taskStats.done + '/' + taskStats.total + ' tasks' }) : null,841+ ] }),842+ jsx('div', { 'data-selectable-text': 'true', style: { flex: 1, overflowY: 'auto', overflowX: 'hidden', minHeight: '0', padding: '8px 0' }, children: children }),843+ ]844+ })845+}846+847+848+// ═══════════════════════════════════════════════════════════════════════════════849+// Change detail drawer850+// ═══════════════════════════════════════════════════════════════════════════════851+852+function ChangeDetailDrawer({ api, sourceId, change, source, allSources, allItems, onClose }) {853+ var query = useQuery({854+ queryKey: ['openspec', 'change', sourceId, change.name || change.token],855+ queryFn: function() { return api.change(sourceId, change.name || change.token) }856+ })857+858+ var [activeTab, setActiveTab] = React.useState(null)859+860+ var itemRef = buildItemReference(source, change, allSources)861+ var canCopy = !!itemRef && hasUniqueItemToken(change, allItems)862+ var title = query.data && query.data.title ? query.data.title : (change.title || change.name || 'Untitled change')863+ var secondary = query.data && query.data.name ? query.data.name : (change.name || '')864+ var token = change.token || change.name || ''865+866+ if (query.isLoading) return jsx(DetailDrawer, { title: title, secondary: secondary, itemRef: itemRef, canCopy: canCopy, token: token, onClose: onClose, children: jsx(Loader, { type: 'lemniscate-bloom' }) })867+ if (query.error) return jsx(DetailDrawer, { title: title, secondary: secondary, itemRef: itemRef, canCopy: canCopy, token: token, onClose: onClose, children: jsx(RetryErrorState, { title: 'Failed to load change', description: query.error.message, onRetry: function() { query.refetch() } }) })868+869+ var data = query.data || {}870+ var tabs = []871+ if (data.proposal) tabs.push({ key: 'proposal', label: 'Proposal', content: jsx(MarkdownView, { content: stripFrontmatter(data.proposal) }) })872+ if (data.tasks) tabs.push({ key: 'tasks', label: 'Tasks', content: jsx(TaskView, { content: stripFrontmatter(data.tasks) }) })873+ if (data.design) tabs.push({ key: 'design', label: 'Design', content: jsx(MarkdownView, { content: stripFrontmatter(data.design) }) })874+ if (data.specs && data.specs.length > 0) tabs.push({ key: 'specs', label: 'Specs', content: jsx(SpecsDetailView, { specs: data.specs }) })875+876+ var currentTab = activeTab || (tabs.length > 0 ? tabs[0].key : null)877+878+ // Use detailTitle/detailSecondaryName fallback helpers for consistency.879+ title = detailTitle(data, change, change, 'change')880+ secondary = detailSecondaryName(data, change)881+882+ return jsx(DetailDrawer, {883+ title: title,884+ secondary: secondary,885+ itemRef: itemRef,886+ canCopy: canCopy,887+ token: token,888+ taskStats: data.taskStats,889+ onClose: onClose,890+ children: tabs.length > 0 ? jsxs(Tabs, { value: currentTab, onValueChange: setActiveTab, children: [891+ jsx(TabsList, { children: tabs.map(function(t) { return jsx(TabsTrigger, { value: t.key, children: t.label }, t.key) }) }),892+ tabs.map(function(t) { return currentTab === t.key ? jsx('div', { key: t.key, children: t.content }) : null }),893+ ] }) : jsx(EmptyState, { title: 'No artifacts', description: 'This change has no proposal, tasks, design, or spec content.' })894+ })895+}896+897+// ═══════════════════════════════════════════════════════════════════════════════898+// Task view899+// ═══════════════════════════════════════════════════════════════════════════════900+901+function TaskView({ content }) {902+ var parsed = parseTasks(content)903+ return jsxs('div', { 'data-selectable-text': 'true', style: { padding: '12px' }, children: [904+ jsxs('div', { style: { marginBottom: '12px', color: 'var(--muted-foreground, #888)', fontSize: '13px' }, children: [905+ parsed.done + ' of ' + parsed.total + ' tasks completed',906+ ] }),907+ parsed.sections.map(function(section, i) {908+ return jsxs('div', { style: { marginBottom: '12px' }, children: [909+ jsx('div', { style: { fontWeight: 600, fontSize: '14px', marginBottom: '4px', color: 'var(--foreground, #e0e0e0)' }, children: section.title }),910+ section.tasks.map(function(task, j) {911+ return jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0', color: 'var(--foreground, #e0e0e0)' }, children: [912+ jsx('span', { style: { color: task.done ? 'var(--primary, #4caf50)' : 'var(--muted-foreground, #888)' }, children: task.done ? '\u2611' : '\u2610' }),913+ jsx('span', { style: { fontSize: '13px', textDecoration: task.done ? 'line-through' : 'none', opacity: task.done ? 0.6 : 1 }, children: task.text }),914+ ] }, j)915+ }),916+ ] }, i)917+ }),918+ parsed.sections.length === 0 ? jsx(MarkdownView, { content: content }) : null,919+ ] })920+}921+922+// ═══════════════════════════════════════════════════════════════════════════════923+// Idea detail drawer924+// ═══════════════════════════════════════════════════════════════════════════════925+926+function IdeaDetailDrawer({ api, sourceId, idea, source, allSources, allItems, onClose }) {927+ var query = useQuery({928+ queryKey: ['openspec', 'idea', sourceId, idea.name || idea.token],929+ queryFn: function() { return api.idea(sourceId, idea.name || idea.token) }930+ })931+932+ var itemRef = buildItemReference(source, idea, allSources)933+ var canCopy = !!itemRef && hasUniqueItemToken(idea, allItems)934+ var title = query.data && query.data.title ? query.data.title : (idea.title || idea.name || 'Untitled idea')935+ var secondary = ideaSecondaryName(query.data && query.data.name ? query.data.name : (idea.name || ''))936+ var token = idea.token || idea.name || ''937+938+ if (query.isLoading) return jsx(DetailDrawer, { title: title, secondary: secondary, itemRef: itemRef, canCopy: canCopy, token: token, onClose: onClose, children: jsx(Loader, { type: 'lemniscate-bloom' }) })939+ if (query.error) return jsx(DetailDrawer, { title: title, secondary: secondary, itemRef: itemRef, canCopy: canCopy, token: token, onClose: onClose, children: jsx(RetryErrorState, { title: 'Failed to load idea', description: query.error.message, onRetry: function() { query.refetch() } }) })940+941+ var data = query.data || {}942+943+ // Reconcile title/secondary with canonical fallback helpers.944+ title = detailTitle(data, idea, idea, 'idea')945+ secondary = ideaSecondaryName(detailSecondaryName(data, idea))946+947+ // Strip YAML frontmatter from content before rendering948+ var content = data.content || ''949+ var stripped = stripFrontmatter(content)950+951+ return jsx(DetailDrawer, { title: title, secondary: secondary, itemRef: itemRef, canCopy: canCopy, token: token, onClose: onClose, children: jsx(MarkdownView, { content: stripped }) })952+}953+954+// ═══════════════════════════════════════════════════════════════════════════════955+// Change spec detail — Proposed and Diff views956+// ═══════════════════════════════════════════════════════════════════════════════957+958+function DiffModePanel({ data }) {959+ var [diffMode, setDiffMode] = React.useState('semantic')960+ var effMode = effectiveDiffMode(diffMode, data)961+ return jsxs('div', { children: [962+ hasDiffMode(data, 'semantic') || hasDiffMode(data, 'split') || hasDiffMode(data, 'raw') ? jsxs('div', { style: { display: 'flex', gap: '4px', marginBottom: '8px' }, children: [963+ hasDiffMode(data, 'semantic') ? jsx(Button, { variant: effMode === 'semantic' ? 'default' : 'outline', size: 'sm', onClick: function() { setDiffMode('semantic') }, children: 'Semantic' }) : null,964+ hasDiffMode(data, 'split') ? jsx(Button, { variant: effMode === 'split' ? 'default' : 'outline', size: 'sm', onClick: function() { setDiffMode('split') }, children: 'Side-by-side' }) : null,965+ hasDiffMode(data, 'raw') ? jsx(Button, { variant: effMode === 'raw' ? 'default' : 'outline', size: 'sm', onClick: function() { setDiffMode('raw') }, children: 'Raw' }) : null,966+ ] }) : null,967+ effMode === 'semantic' && hasDiffMode(data, 'semantic') ? jsx(SemanticDiffView, { data: data.semantic_diff }) : null,968+ effMode === 'split' && hasDiffMode(data, 'split') ? jsx(SplitDiffView, { before: data.before, after: data.after }) : null,969+ effMode === 'raw' && hasDiffMode(data, 'raw') ? jsx(RawDiffView, { diff: data.diff || data.raw_diff }) : null,970+ ] })971+}972+973+function SpecsDetailView({ specs }) {974+ var [selectedIdx, setSelectedIdx] = React.useState(0)975+976+ if (!specs || specs.length === 0) return jsx(EmptyState, { title: 'No specs', description: 'No spec changes in this change.' })977+ var spec = specs[selectedIdx] || specs[0]978+979+ return jsxs('div', { 'data-selectable-text': 'true', style: { padding: '12px' }, children: [980+ specs.length > 1 ? jsxs('div', { style: { marginBottom: '8px' }, children: [981+ jsx('select', {982+ value: selectedIdx,983+ onChange: function(e) { setSelectedIdx(parseInt(e.target.value, 10)) },984+ style: { padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--ui-border, #333)', background: 'var(--input-background, #2a2a2a)', color: 'var(--foreground, #e0e0e0)' },985+ children: specs.map(function(s, i) { return jsx('option', { value: i, children: s.path || s.title || ('Spec ' + (i + 1)) }, i) })986+ })987+ ] }) : null,988+ spec.path ? jsx('div', { style: { fontSize: '12px', color: 'var(--muted-foreground, #888)', marginBottom: '8px', fontFamily: 'monospace' }, children: spec.path }) : null,989+ spec.status ? jsx(Badge, { variant: 'outline', style: { marginBottom: '8px' }, children: spec.status }) : null,990+ jsx(DiffModePanel, { data: spec }),991+ spec.content ? jsx(MarkdownView, { content: stripFrontmatter(spec.content) }) : null,992+ ] })993+}994+995+// ═══════════════════════════════════════════════════════════════════════════════996+// Diff views997+// ═══════════════════════════════════════════════════════════════════════════════998+999+function normalizeSemanticGroups(data) {1000+ if (!data) return []1001+ // Legacy flat groups array (data.groups)1002+ if (data.groups && Array.isArray(data.groups)) return data.groups1003+ // Backend contract: {status, requirements: {added, modified, removed, unchanged}}1004+ var reqs = data.requirements1005+ if (!reqs || typeof reqs !== 'object' || Array.isArray(reqs)) return []1006+ var items = []1007+ var added = reqs.added || []1008+ var modified = reqs.modified || []1009+ var removed = reqs.removed || []1010+ for (var i = 0; i < added.length; i++) {1011+ var a = added[i]1012+ items.push({ label: 'Added', name: a.name || '', description: a.description || '', color: 'var(--primary, #4caf50)' })1013+ }1014+ for (var j = 0; j < modified.length; j++) {1015+ var m = modified[j]1016+ var beforeDesc = m.before && typeof m.before === 'object' ? (m.before.description || '') : String(m.before || '')1017+ var afterDesc = m.after && typeof m.after === 'object' ? (m.after.description || '') : String(m.after || '')1018+ items.push({ label: 'Modified', name: m.name || '', description: '', before: beforeDesc, after: afterDesc, color: 'var(--warning, #ff9800)' })1019+ }1020+ for (var k = 0; k < removed.length; k++) {1021+ var r = removed[k]1022+ items.push({ label: 'Removed', name: r.name || '', description: r.description || '', color: 'var(--destructive, #f44336)' })1023+ }1024+ return items1025+}1026+1027+function SemanticDiffView({ data }) {1028+ if (!data) return null1029+ var items = normalizeSemanticGroups(data)1030+ return jsxs('div', { style: { padding: '8px' }, children: [1031+ jsx('div', { style: { fontWeight: 600, marginBottom: '8px', color: 'var(--foreground, #e0e0e0)' }, children: 'Semantic Changes' }),1032+ items.map(function(item, i) {1033+ return jsxs('div', { style: { marginBottom: '8px', padding: '8px', background: 'var(--card-background, #1a1a1a)', borderRadius: '4px', border: '1px solid var(--ui-border, #333)' }, children: [1034+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }, children: [1035+ jsx('span', { style: { fontSize: '10px', fontWeight: 600, padding: '1px 5px', borderRadius: '3px', background: item.color, color: '#fff' }, children: item.label }),1036+ item.name ? jsx('span', { style: { fontWeight: 500, color: 'var(--foreground, #e0e0e0)' }, children: item.name }) : null,1037+ ] }),1038+ item.description ? jsx('div', { style: { fontSize: '12px', color: 'var(--muted-foreground, #888)', marginBottom: '2px' }, children: item.description }) : null,1039+ item.before != null && item.before !== '' ? jsx('div', { style: { color: 'var(--destructive, #f44336)', fontSize: '12px', marginBottom: '2px' }, children: '- ' + item.before }) : null,1040+ item.after != null && item.after !== '' ? jsx('div', { style: { color: 'var(--primary, #4caf50)', fontSize: '12px' }, children: '+ ' + item.after }) : null,1041+ ] }, i)1042+ }),1043+ ] })1044+}1045+1046+function SplitDiffView({ before, after }) {1047+ return jsxs('div', { style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', padding: '8px' }, children: [1048+ jsxs('div', { children: [1049+ jsx('div', { style: { fontWeight: 500, marginBottom: '4px', color: 'var(--muted-foreground, #888)', fontSize: '12px' }, children: 'Before' }),1050+ jsx('pre', { style: { fontSize: '12px', fontFamily: 'monospace', whiteSpace: 'pre-wrap', padding: '8px', background: 'var(--editor-background, #1e1e1e)', borderRadius: '4px', color: 'var(--foreground, #e0e0e0)', margin: 0 }, children: before || '' }),1051+ ] }),1052+ jsxs('div', { children: [1053+ jsx('div', { style: { fontWeight: 500, marginBottom: '4px', color: 'var(--muted-foreground, #888)', fontSize: '12px' }, children: 'After' }),1054+ jsx('pre', { style: { fontSize: '12px', fontFamily: 'monospace', whiteSpace: 'pre-wrap', padding: '8px', background: 'var(--editor-background, #1e1e1e)', borderRadius: '4px', color: 'var(--foreground, #e0e0e0)', margin: 0 }, children: after || '' }),1055+ ] }),1056+ ] })1057+}1058+1059+function RawDiffView({ diff }) {1060+ if (!diff) return null1061+ var lines = Array.isArray(diff) ? diff : String(diff).split('\n')1062+ return jsxs('pre', { style: { fontSize: '12px', fontFamily: 'monospace', whiteSpace: 'pre-wrap', padding: '8px', background: 'var(--editor-background, #1e1e1e)', borderRadius: '4px', margin: 0 }, children: lines.map(function(line, i) {1063+ var cls = classifyDiffLine(line)1064+ var style = { display: 'block', padding: '1px 4px' }1065+ if (cls === 'added') style.color = 'var(--primary, #4caf50)'1066+ else if (cls === 'removed') style.color = 'var(--destructive, #f44336)'1067+ else if (cls === 'hunk') style.color = 'var(--muted-foreground, #888)'1068+ else style.color = 'var(--foreground, #e0e0e0)'1069+ return jsx('span', { style: style, children: line + '\n' }, i)1070+ }) })1071+}1072+1073+// ═══════════════════════════════════════════════════════════════════════════════1074+// Work view1075+// ═══════════════════════════════════════════════════════════════════════════════1076+1077+function WorkView({ api, source, sources, onSelectItem, showArchived, onToggleArchived }) {1078+ var ideas = (source.openspec && source.openspec.ideas) || []1079+ var changes = (source.openspec && source.openspec.changes) || []1080+ var allItems = ideas.map(function(idea) {1081+ return Object.assign({}, idea, { _type: 'idea' })1082+ }).concat(changes.map(function(change) {1083+ return Object.assign({}, change, { _type: 'change' })1084+ }))1085+1086+ var [filter, setFilter] = React.useState('')1087+ var [manualOverrides, setManualOverrides] = React.useState({})1088+ var prevLanePhase = React.useRef(null)1089+1090+ var filtered = filterItems(allItems, filter)1091+ var visibleStatuses = showArchived ? STATUS_ORDER : STATUS_ORDER.filter(function(s) { return s !== 'archived' })1092+ var grouped = groupByStatus(filtered, showArchived)1093+ var autoCollapsed = autoCollapseEmptyLanes(grouped, visibleStatuses)1094+1095+ // Lane phase tracking — prune overrides when empty/full flips1096+ var lanePhase = computeLanePhase(grouped, visibleStatuses)1097+ React.useEffect(function() {1098+ if (lanePhase === null || lanePhase === prevLanePhase.current) return1099+ var prev = prevLanePhase.current1100+ prevLanePhase.current = lanePhase1101+ if (prev === null) return1102+ setManualOverrides(function(prevOverrides) {1103+ return pruneStaleOverrides(prevOverrides, prev, lanePhase)1104+ })1105+ }, [lanePhase])1106+1107+ var collapsedSet = computeCollapsedSet(autoCollapsed, manualOverrides, visibleStatuses)1108+1109+ function toggleCollapse(status) {1110+ setManualOverrides(function(prev) {1111+ return toggleOverride(prev, status, autoCollapsed)1112+ })1113+ }1114+1115+ return jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', height: '100%', overflow: 'hidden', minWidth: 0, minHeight: 0 }, children: [1116+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', padding: '0 4px', flexShrink: 0 }, children: [1117+ jsx('span', { style: { display: 'inline-flex', alignItems: 'center', color: 'var(--muted-foreground, #888)', lineHeight: 1 }, title: 'Filter', 'aria-label': 'Filter', children: jsx(Codicon, { name: 'filter', size: '0.85rem' }) }),1118+ jsx(Input, { placeholder: 'title, token, or name...', value: filter, onChange: function(e) { setFilter(e.target.value) }, style: { flex: 1 } }),1119+ jsx(Button, { variant: 'outline', size: 'sm', type: 'button', 'aria-pressed': showArchived, style: { backgroundColor: showArchived ? 'var(--ui-control-active-background)' : undefined }, onClick: function() { onToggleArchived(!showArchived) }, children: [showArchived ? '\u2713' : null, ' ', 'Archived'] }),1120+ ] }),1121+ jsx('div', { style: { display: 'flex', gap: '8px', flex: 1, overflowX: 'auto', overflowY: 'hidden', minWidth: 0, minHeight: 0, paddingBottom: '8px' }, children: visibleStatuses.map(function(status) {1122+ var items = grouped[status] || []1123+ return jsx(BoardColumn, {1124+ status: status,1125+ items: items,1126+ source: source,1127+ allSources: sources,1128+ allItems: allItems,1129+ onSelect: onSelectItem,1130+ collapsed: !!collapsedSet[status],1131+ onExpand: function() { toggleCollapse(status) },1132+ }, status)1133+ }) }),1134+ ] })1135+}1136+1137+// ═══════════════════════════════════════════════════════════════════════════════1138+// Specs view1139+// ═══════════════════════════════════════════════════════════════════════════════1140+1141+function SpecsView({ api, sourceId }) {1142+ var [worktree, setWorktree] = React.useState(false)1143+1144+ var query = useQuery({1145+ queryKey: ['openspec', 'spec-browser', sourceId, worktree],1146+ queryFn: function() { return api.specBrowser(sourceId, { dirty: worktree }) },1147+ })1148+1149+ var [selectedFile, setSelectedFile] = React.useState(null)1150+1151+ // Reset selected file when source changes — component remounts via key prop1152+ React.useEffect(function() {1153+ setSelectedFile(null)1154+ }, [sourceId])1155+1156+ // Gate spec query on file selection1157+ var specQuery = useQuery({1158+ queryKey: ['openspec', 'spec', sourceId, selectedFile],1159+ queryFn: function() { return api.spec(sourceId, selectedFile) },1160+ enabled: !!selectedFile && !worktree,1161+ })1162+1163+ var files = (query.data && query.data.files) || []1164+ var branch = query.data && query.data.branch1165+ var changedCount = query.data && query.data.changedCount != null ? query.data.changedCount : files.length1166+1167+ return jsxs('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px', height: '100%' }, children: [1168+ jsxs('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '0 4px' }, children: [1169+ jsx(Button, { variant: worktree ? 'outline' : 'default', size: 'sm', onClick: function() { setWorktree(false); setSelectedFile(null) }, children: 'Current' }),1170+ jsx(Button, { variant: worktree ? 'default' : 'outline', size: 'sm', onClick: function() { setWorktree(true); setSelectedFile(null) }, children: 'Worktree' }),1171+ branch ? jsx('span', { style: { fontSize: '12px', color: 'var(--muted-foreground, #888)' }, children: branch }) : null,1172+ worktree ? jsx(Badge, { variant: 'outline', children: changedCount + ' changed' }) : null,1173+ ] }),1174+ query.isLoading ? jsx(Loader, { type: 'lemniscate-bloom' }) : null,1175+ query.error ? jsx(RetryErrorState, { title: 'Failed to load specs', description: query.error.message, onRetry: function() { query.refetch() } }) : null,1176+ !query.isLoading && !query.error && files.length === 0 ? jsx(EmptyState, {1177+ title: worktree ? 'No spec changes between HEAD and worktree' : 'No specs found',1178+ description: worktree ? 'The working tree matches HEAD for all spec files.' : 'No spec files are registered for this source.',1179+ }) : null,1180+ !query.isLoading && files.length > 0 ? jsxs('div', { style: { display: 'flex', gap: '12px', flex: 1, overflow: 'hidden' }, children: [1181+ jsxs('div', { style: { width: '240px', flexShrink: 0, overflow: 'auto' }, children: [1182+ files.map(function(file) {1183+ var path = file.path || file.name || file1184+ var isSelected = selectedFile === path1185+ return jsxs('div', {1186+ className: cn(1187+ 'px-3 py-2 cursor-pointer text-sm rounded-md',1188+ isSelected ? 'bg-(--ui-selected-background, #2a2a2a) text-foreground' : 'text-muted-foreground hover:bg-(--ui-hover-background, #1a1a1a)'1189+ ),1190+ onClick: function() { setSelectedFile(path) },1191+ children: [1192+ jsx('div', { style: { fontFamily: 'monospace', fontSize: '12px' }, children: path }),1193+ file.status ? jsx(Badge, { variant: 'outline', style: { fontSize: '10px', marginTop: '2px' }, children: file.status }) : null,1194+ ]1195+ }, path)1196+ }),1197+ ] }),1198+ jsxs('div', { style: { flex: 1, overflow: 'auto' }, children: [1199+ worktree && selectedFile ? jsx(WorktreeDetailView, { sourceId: sourceId, file: selectedFile, files: files }) : null,1200+ !worktree && selectedFile && specQuery.isLoading ? jsx(Loader, { type: 'lemniscate-bloom' }) : null,1201+ !worktree && selectedFile && specQuery.error ? jsx(RetryErrorState, { title: 'Failed to load spec', description: specQuery.error.message, onRetry: function() { specQuery.refetch() } }) : null,1202+ !worktree && selectedFile && !specQuery.isLoading && !specQuery.error ? jsx(MarkdownView, { content: stripFrontmatter(specQuery.data && specQuery.data.content) }) : null,1203+ !selectedFile ? jsx(EmptyState, { title: 'Select a file', description: 'Choose a spec file from the list to view its content.' }) : null,1204+ ] }),1205+ ] }) : null,1206+ ] })1207+}1208+1209+function WorktreeDetailView({ sourceId, file, files }) {1210+ var fileInfo = files.find(function(f) { return (f.path || f.name || f) === file }) || {}1211+1212+ var data = fileInfo1213+1214+ return jsxs('div', { style: { padding: '12px' }, children: [1215+ jsxs('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }, children: [1216+ jsx('h3', { style: { fontSize: '14px', fontWeight: 500, margin: 0, color: 'var(--foreground, #e0e0e0)' }, children: file }),1217+ fileInfo.status ? jsx(Badge, { variant: 'outline', children: fileInfo.status }) : null,1218+ ] }),1219+ jsx(DiffModePanel, { data: data }),1220+ !hasDiffMode(data, 'semantic') && !hasDiffMode(data, 'split') && !hasDiffMode(data, 'raw') && data.content ? jsx(MarkdownView, { content: stripFrontmatter(data.content) }) : null,1221+ !hasDiffMode(data, 'semantic') && !hasDiffMode(data, 'split') && !hasDiffMode(data, 'raw') && !data.content ? jsx(EmptyState, { title: 'No diff data', description: 'No diff representation available for this file.' }) : null,1222+ ] })1223+}1224+1225+// ═══════════════════════════════════════════════════════════════════════════════1226+// Main page1227+// ═══════════════════════════════════════════════════════════════════════════════1228+1229+function OpenSpecPage({ api, sessionStore }) {1230+ var sourcesQuery = useQuery({1231+ queryKey: ['openspec', 'sources'],1232+ queryFn: function() { return api.sources() },1233+ })1234+1235+ var [selectedSourceId, setSelectedSourceId] = React.useState(sessionStore ? sessionStore.selectedSourceId : null)1236+ var [view, setView] = React.useState('work')1237+ var [showArchived, setShowArchived] = React.useState(sessionStore ? sessionStore.showArchived : true)1238+ var [selectedItem, setSelectedItem] = React.useState(null) // {item, sourceId, type}1239+ var [sourceDialog, setSourceDialog] = React.useState(null) // {mode: 'add'|'edit'} or null1240+ var [removeConfirm, setRemoveConfirm] = React.useState(null) // source object or null1241+ var [removeBusy, setRemoveBusy] = React.useState(false)1242+ var [removeError, setRemoveError] = React.useState('')1243+ var [initBusy, setInitBusy] = React.useState(false)1244+ var [initError, setInitError] = React.useState('')1245+1246+ // Resolve selection after refreshed /sources data: restore the retained source1247+ // while its ID remains present (including invalid sources), otherwise fall back1248+ // to first-valid / first-returned; update the registration-scope store both ways.1249+ // The effect is gated on data arrival: during the loading window1250+ // sourcesQuery.data is still undefined, and resolving against the pre-data1251+ // empty list would wipe a retained selection (both React state and the1252+ // registration-scope store) before refreshed /sources data ever arrives. The1253+ // retained ID survives the loading render, so resolution — and the null rule1254+ // for genuinely empty results — only ever runs against the refreshed list.1255+ var sources = (sourcesQuery.data && sourcesQuery.data.sources) || []1256+ var previousResolvedInvalid = React.useRef(false)1257+ React.useEffect(function() {1258+ if (sourcesQuery.data === undefined) return1259+ var next = resolveRetainedSource(sources, selectedSourceId)1260+ var resolved = sources.find(function(s) { return s.id === next }) || null1261+ var resolvedInvalid = resolved && resolved.valid === false1262+ if (next === selectedSourceId) {1263+ if (resolvedInvalid && !previousResolvedInvalid.current) {1264+ setSelectedItem(null)1265+ }1266+ previousResolvedInvalid.current = !!resolvedInvalid1267+ if (sessionStore && next !== null) sessionStore.selectedSourceId = next1268+ return1269+ }1270+ setSelectedSourceId(next)1271+ setSelectedItem(null)1272+ previousResolvedInvalid.current = !!resolvedInvalid1273+ if (sessionStore) sessionStore.selectedSourceId = next1274+ }, [sources, sourcesQuery.data, selectedSourceId])1275+1276+ // Close detail on explicit source change by user1277+ React.useEffect(function() {1278+ setSelectedItem(null)1279+ }, [selectedSourceId])1280+1281+ var selectedSource = sources.find(function(s) { return s.id === selectedSourceId }) || null1282+ var sourceRef = buildSourceReference(selectedSource, sources)1283+ var allItems = []1284+ if (selectedSource && selectedSource.openspec) {1285+ var loadedIdeas = selectedSource.openspec.ideas || []1286+ var loadedChanges = selectedSource.openspec.changes || []1287+ allItems = loadedIdeas.map(function(idea) {1288+ return Object.assign({}, idea, { _type: 'idea' })1289+ }).concat(loadedChanges.map(function(change) {1290+ return Object.assign({}, change, { _type: 'change' })1291+ }))1292+ }1293+1294+ function handleSelectSource(v) {1295+ setSelectedSourceId(v)1296+ if (sessionStore) sessionStore.selectedSourceId = v1297+ }1298+ function handleToggleArchived(v) {1299+ setShowArchived(v)1300+ if (sessionStore) sessionStore.showArchived = v1301+ }1302+1303+ if (sourcesQuery.isLoading) return jsx(Loader, { type: 'lemniscate-bloom' })1304+ if (sourcesQuery.error) return jsx(RetryErrorState, { title: 'Failed to load sources', description: sourcesQuery.error.message, onRetry: function() { sourcesQuery.refetch() } })1305+1306+ var onSelectItem = function(item) {1307+ setSelectedItem({ item: item, sourceId: selectedSourceId, type: item._type || 'change' })1308+ }1309+1310+ function handleAddSource() { setSourceDialog({ mode: 'add' }) }1311+ function handleEditSource() { if (selectedSource) setSourceDialog({ mode: 'edit' }) }1312+ function handleInitSource() {1313+ if (!selectedSource || initBusy) return1314+ setInitBusy(true); setInitError('')1315+ api.initSource(selectedSource.id).then(function() {1316+ setInitBusy(false)1317+ sourcesQuery.refetch()1318+ }).catch(function(e) {1319+ setInitBusy(false)1320+ setInitError(extractApiError(e))1321+ })1322+ }1323+ function handleRemoveSource() {1324+ if (!selectedSource) return1325+ setRemoveConfirm(selectedSource)1326+ setRemoveError('')1327+ }1328+ function confirmRemove() {1329+ if (!removeConfirm) return1330+ setRemoveBusy(true); setRemoveError('')1331+ api.removeSource(removeConfirm.id).then(function() {1332+ setRemoveBusy(false); setRemoveConfirm(null)1333+ setSelectedSourceId(null)1334+ sourcesQuery.refetch()1335+ }).catch(function(e) {1336+ setRemoveBusy(false)1337+ setRemoveError(extractApiError(e))1338+ })1339+ }1340+ function onSourceSaved(result) {1341+ setSourceDialog(null)1342+ setInitError(result && result.initError ? result.initError : '')1343+ var refresh = sourcesQuery.refetch()1344+ if (result && result.source && result.source.id) {1345+ var selectReturnedSource = function() {1346+ setSelectedSourceId(result.source.id)1347+ if (sessionStore) sessionStore.selectedSourceId = result.source.id1348+ }1349+ if (refresh && typeof refresh.then === 'function') {1350+ refresh.then(selectReturnedSource, selectReturnedSource)1351+ } else {1352+ selectReturnedSource()1353+ }1354+ }1355+ }1356+1357+ return jsxs('div', { className: cn('relative flex flex-col h-full overflow-hidden'), children: [1358+ jsx('style', { children: SOURCE_TRIGGER_HOVER_CSS }),1359+1360+ // Header1361+ jsxs('div', { style: { padding: '6px 16px', borderBottom: '1px solid var(--ui-border, #333)', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'nowrap' }, children: [1362+ jsx('h1', { style: { fontSize: '13px', fontWeight: 600, margin: 0, color: 'var(--foreground, #e0e0e0)' }, children: 'OpenSpec' }),1363+ selectedSource && selectedSource.path ? jsx('span', { style: { minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: 'monospace', fontSize: '11px', color: 'var(--muted-foreground, #888)' }, title: selectedSource.path, children: selectedSource.path }) : null,1364+ ] }),1365+1366+ // Source controls and path line1367+ jsxs('div', { style: { padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px', color: 'var(--muted-foreground, #888)', borderBottom: '1px solid var(--ui-border, #333)' }, children: [1368+ selectedSource ? jsx(CopyButton, { appearance: 'icon', text: sourceRef, label: 'Copy source reference', title: sourceRef, disabled: !sourceRef }) : null,1369+ jsx(Select, {1370+ value: selectedSourceId || undefined,1371+ onValueChange: handleSelectSource,1372+ children: [1373+ jsx(SelectTrigger, { className: 'bg-(--ui-bg-quinary) transition-colors hover:bg-primary/[0.06] hover:bg-primary/[0.06]!', 'data-openspec-source-trigger': 'true', style: { minWidth: '120px', flexShrink: 1 }, children: jsx(SelectValue, { placeholder: 'Select source' }) }),1374+ jsx(SelectContent, { children: sources.map(function(s) {1375+ return jsx(SelectItem, { value: s.id, children: (s.name || s.id) + (s.valid === false ? ' (invalid)' : '') }, s.id)1376+ }) })1377+ ]1378+ }),1379+ jsx(Button, { variant: 'outline', size: 'sm', style: { padding: '4px', minWidth: 'auto', flexShrink: 0 }, title: 'Refresh sources', 'aria-label': 'Refresh sources', onClick: function() { sourcesQuery.refetch() }, children: jsx(Codicon, { name: 'refresh', size: '0.85rem' }) }),1380+ jsx(Button, { variant: 'ghost', size: 'sm', style: { padding: '2px 6px', fontSize: '11px', minWidth: 'auto', flexShrink: 0, whiteSpace: 'nowrap' }, title: 'Add source', 'aria-label': 'Add source', onClick: handleAddSource, children: ['+', ' Add'] }),1381+ selectedSource ? jsx(Button, { variant: 'ghost', size: 'sm', style: { padding: '2px 6px', fontSize: '11px', minWidth: 'auto', flexShrink: 0, whiteSpace: 'nowrap' }, title: 'Edit source', 'aria-label': 'Edit source', onClick: handleEditSource, children: ['\u270E', ' Edit'] }) : null,1382+ selectedSource ? jsx(Button, { variant: 'ghost', size: 'sm', style: { padding: '2px 6px', fontSize: '11px', minWidth: 'auto', flexShrink: 0, whiteSpace: 'nowrap' }, title: 'Delete source', 'aria-label': 'Delete source', onClick: handleRemoveSource, children: ['\u2715', ' Delete'] }) : null,1383+ ] }),1384+1385+ // View switch1386+ jsxs('div', { style: { padding: '8px 16px', borderBottom: '1px solid var(--ui-border, #333)', display: 'flex', alignItems: 'center', gap: '12px' }, children: [1387+ jsx(SegmentedControl, {1388+ value: view,1389+ onChange: setView,1390+ options: [1391+ { id: 'work', label: 'Work' },1392+ { id: 'specs', label: 'Specs' },1393+ ]1394+ }),1395+ selectedSource && selectedSource.openspec && selectedSource.openspec.counts ? jsxs('div', { style: { display: 'flex', gap: '4px', fontSize: '10px', color: 'var(--muted-foreground, #888)' }, children: [1396+ selectedSource.openspec.counts.changes != null ? jsx(Badge, { variant: 'outline', children: selectedSource.openspec.counts.changes + ' changes' }) : null,1397+ selectedSource.openspec.counts.ideas != null ? jsx(Badge, { variant: 'outline', children: selectedSource.openspec.counts.ideas + ' ideas' }) : null,1398+ selectedSource.openspec.counts.specs != null ? jsx(Badge, { variant: 'outline', children: selectedSource.openspec.counts.specs + ' specs' }) : null,1399+ ] }) : null,1400+ ] }),1401+1402+ // Invalid source warning1403+ selectedSource && selectedSource.valid === false ? jsx('div', { style: { padding: '12px 16px', background: 'var(--warning-background, #fff3cd)', borderRadius: '4px', margin: '8px 16px', fontSize: '13px' }, children: jsxs('div', { children: [1404+ jsxs('div', { children: [1405+ jsx('strong', { children: 'Invalid source' }),1406+ selectedSource.error ? ': ' + selectedSource.error : '',1407+ selectedSource.path ? jsxs('div', { style: { fontFamily: 'monospace', fontSize: '11px', marginTop: '4px', opacity: 0.7 }, children: ['Path: ', selectedSource.path] }) : null,1408+ ] }),1409+ selectedSource.error === 'No openspec/ directory found' ? jsx(Button, { size: 'sm', onClick: handleInitSource, disabled: initBusy, style: { marginTop: '10px' }, children: initBusy ? 'Initializing...' : 'Initialize OpenSpec' }) : null,1410+ initError ? jsx('div', { style: { marginTop: '8px', color: 'var(--destructive, #f44336)', fontSize: '12px' }, children: initError }) : null,1411+ ] }) }) : null,1412+1413+ // View body1414+ jsx('div', { style: { flex: 1, overflow: 'hidden', padding: '12px 16px' }, children: selectedSource && selectedSource.valid !== false ? (1415+ view === 'work'1416+ ? jsx(WorkView, { key: selectedSource.id, api: api, source: selectedSource, sources: sources, showArchived: showArchived, onToggleArchived: handleToggleArchived, onSelectItem: onSelectItem })1417+ : jsx(SpecsView, { key: selectedSource.id, api: api, sourceId: selectedSource.id })1418+ ) : selectedSource && selectedSource.valid === false ? null : jsx(EmptyState, {1419+ title: sources.length === 0 ? 'No sources registered' : 'Select a source',1420+ description: sources.length === 0 ? 'Add a source to get started.' : 'Choose a source to view its OpenSpec project.'1421+ }) }),1422+1423+ // Detail drawer — synchronous gate: reject stale item from different source1424+ selectedItem && selectedItem.sourceId === selectedSourceId && selectedItem.type === 'change' && selectedSource && selectedSource.valid !== false ? jsx(ChangeDetailDrawer, { api: api, sourceId: selectedSourceId, change: selectedItem.item, source: selectedSource, allSources: sources, allItems: allItems, onClose: function() { setSelectedItem(null) } }) : null,1425+ selectedItem && selectedItem.sourceId === selectedSourceId && selectedItem.type === 'idea' && selectedSource && selectedSource.valid !== false ? jsx(IdeaDetailDrawer, { api: api, sourceId: selectedSourceId, idea: selectedItem.item, source: selectedSource, allSources: sources, allItems: allItems, onClose: function() { setSelectedItem(null) } }) : null,1426+1427+ // Source add/edit dialog1428+ sourceDialog ? jsx(SourceDialog, { mode: sourceDialog.mode, api: api, source: sourceDialog.mode === 'edit' ? selectedSource : null, onClose: function() { setSourceDialog(null) }, onSaved: onSourceSaved }) : null,1429+1430+ // Remove source confirmation dialog1431+ removeConfirm ? jsx(Dialog, { open: true, onOpenChange: function() { if (!removeBusy) { setRemoveConfirm(null); setRemoveError('') } }, children: jsxs(DialogContent, { style: { padding: '16px', maxWidth: '400px' }, children: [1432+ jsx('h2', { style: { fontSize: '16px', fontWeight: 600, margin: '0 0 8px', color: 'var(--foreground, #e0e0e0)' }, children: 'Remove source' }),1433+ jsx('p', { style: { fontSize: '13px', color: 'var(--muted-foreground, #888)', margin: '0 0 12px' }, children: 'Remove source "' + (removeConfirm.name || removeConfirm.id) + '"? This unregisters the source from OpenSpec but does not delete any files.' }),1434+ removeError ? jsx('div', { style: { padding: '8px 12px', background: 'var(--destructive-background, #3d1a1a)', borderRadius: '4px', fontSize: '12px', color: 'var(--destructive, #f44336)', marginBottom: '12px' }, children: removeError }) : null,1435+ jsxs('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: '8px' }, children: [1436+ jsx(Button, { variant: 'outline', size: 'sm', onClick: function() { setRemoveConfirm(null); setRemoveError('') }, disabled: removeBusy, children: 'Cancel' }),1437+ jsx(Button, { variant: 'destructive', size: 'sm', onClick: confirmRemove, disabled: removeBusy, children: removeBusy ? 'Removing...' : 'Remove' }),1438+ ] }),1439+ ] }) }) : null,1440+ ] })1441+}1442+1443+// ═══════════════════════════════════════════════════════════════════════════════1444+// Plugin definition1445+// ═══════════════════════════════════════════════════════════════════════════════1446+1447+var plugin = {1448+ id: 'openspec',1449+ name: 'OpenSpec',1450+ description: 'OpenSpec source registry + project surface — sources, changes, ideas, specs, and diffs.',1451+ register: function(ctx) {1452+ var api = createApi(ctx)1453+ var sessionStore = createSessionStore()1454+ ctx.registerMany([1455+ {1456+ id: 'page',1457+ area: ROUTES_AREA,1458+ data: { path: '/openspec' },1459+ render: function() { return jsx(OpenSpecPage, { api: api, sessionStore: sessionStore }) }1460+ },1461+ {1462+ id: 'nav',1463+ area: SIDEBAR_NAV_AREA,1464+ order: 50,1465+ data: { codicon: 'list-tree', label: 'OpenSpec', path: '/openspec' }1466+ }1467+ ])1468+ }1469+}1470+1471+export default plugin1472+export const __test = Object.freeze({1473+ sourcesPath: sourcesPath,1474+ changePath: changePath,1475+ ideaPath: ideaPath,1476+ specBrowserPath: specBrowserPath,1477+ specPath: specPath,1478+ selectFirstValid: selectFirstValid,1479+ normalizeStatus: normalizeStatus,1480+ groupByStatus: groupByStatus,1481+ normalizeSequencePosition: normalizeSequencePosition,1482+ sortItems: sortItems,1483+ filterItems: filterItems,1484+ toggleArchived: toggleArchived,1485+ parseTasks: parseTasks,1486+ renderMarkdown: renderMarkdown,1487+ classifyState: classifyState,1488+ classifyDiffLine: classifyDiffLine,1489+ hasDiffMode: hasDiffMode,1490+ normalizeSemanticGroups: normalizeSemanticGroups,1491+ effectiveDiffMode: effectiveDiffMode,1492+ isSafeUrl: isSafeUrl,1493+ renderInline: renderInline,1494+ MarkdownElement: MarkdownElement,1495+ RetryErrorState: RetryErrorState,1496+ autoCollapseEmptyLanes: autoCollapseEmptyLanes,1497+ laneWidth: laneWidth,1498+ railStyle: railStyle,1499+ expandedStyle: expandedStyle,1500+ LANE_EXPANDED_WIDTH: LANE_EXPANDED_WIDTH,1501+ LANE_COLLAPSED_WIDTH: LANE_COLLAPSED_WIDTH,1502+ STATUS_TONE: STATUS_TONE,1503+ statusTone: statusTone,1504+ computeCollapsedSet: computeCollapsedSet,1505+ toggleOverride: toggleOverride,1506+ computeLanePhase: computeLanePhase,1507+ pruneStaleOverrides: pruneStaleOverrides,1508+ BoardColumn: BoardColumn,1509+ BoardCard: BoardCard,1510+ CardArtifacts: CardArtifacts,1511+ CardTaskFraction: CardTaskFraction,1512+ DetailDrawer: DetailDrawer,1513+ WorkView: WorkView,1514+ stripFrontmatter: stripFrontmatter,1515+ buildSourceReference: buildSourceReference,1516+ buildItemReference: buildItemReference,1517+ hasUniqueItemToken: hasUniqueItemToken,1518+ createSessionStore: createSessionStore,1519+ resolveRetainedSource: resolveRetainedSource,1520+ detailTitle: detailTitle,1521+ detailSecondaryName: detailSecondaryName,1522+ ideaSecondaryName: ideaSecondaryName,1523+ handleActivateKey: handleActivateKey,1524+ trimPath: trimPath,1525+ extractApiError: extractApiError,1526+ sourceNeedsInitialization: sourceNeedsInitialization,1527+ initializeAddedSource: initializeAddedSource,1528+ createApi: createApi,1529+})@@ -0,0 +1,221 @@
1+2+/**3+ * Headless smoke harness for desktop/plugin.js4+ *5+ * Verifies:6+ * 1. ESM parsing (no CommonJS, valid imports)7+ * 2. No JSX syntax outside jsx()/jsxs() calls8+ * 3. Allowed imports only (@hermes/plugin-sdk, react, react/jsx-runtime)9+ * 4. Default export is valid HermesPlugin (id + register)10+ * 5. ROUTES_AREA + SIDEBAR_NAV_AREA contributions registered11+ * 6. Path is /openspec, sidebar nav has label + codicon12+ * 7. ctx.rest('/sources') invocation (static + dynamic)13+ * 8. Loading/error/empty state transitions14+ *15+ * Run: node spikes/001-desktop-route-api/smoke.mjs16+ * Exit 0 = pass, exit 1 = failure.17+ */18+19+import { readFileSync } from 'node:fs'20+import { execSync } from 'node:child_process'21+import { dirname, resolve } from 'node:path'22+import { fileURLToPath } from 'node:url'23+24+const __dirname = dirname(fileURLToPath(import.meta.url))25+const pluginPath = resolve(__dirname, '../../desktop/plugin.js')26+const source = readFileSync(pluginPath, 'utf8')27+28+let passed = 029+let failed = 030+31+function ok(name) { passed++; console.log(` PASS ${name}`) }32+function fail(name, d) { failed++; console.error(` FAIL ${name}${d ? ': ' + d : ''}`) }33+function check(c, n, d) { c ? ok(n) : fail(n, d) }34+35+console.log('=== Smoke harness: desktop/plugin.js ===\n')36+37+// ── 1. ESM structure ───────────────────────────────────────────────────────38+console.log('[1] ESM structure')39+check(source.includes('export default'), 'Has default export')40+check(source.includes('import {'), 'Has named imports')41+check(!source.includes('module.exports'), 'No CommonJS exports')42+check(!source.includes('require('), 'No require() calls')43+44+// ── 2. No raw JSX ──────────────────────────────────────────────────────────45+console.log('\n[2] No raw JSX')46+{47+ const bad = source.split('\n').filter(l => {48+ const t = l.trim()49+ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return false50+ if (t.includes('jsx(') || t.includes('jsxs(')) return false51+ const stripped = t.replace(/['"`](?:[^'"`\\]|\\.)*['"`]/g, '')52+ return /<[A-Z]/.test(stripped)53+ })54+ check(bad.length === 0, 'No raw JSX outside jsx()/jsxs()', bad.length ? `Line: ${bad[0].trim()}` : undefined)55+}56+57+// ── 3. Allowed imports ─────────────────────────────────────────────────────58+console.log('\n[3] Allowed imports')59+{60+ const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(m => m[1])61+ const allowed = new Set(['@hermes/plugin-sdk', 'react', 'react/jsx-runtime'])62+ const bad = specs.filter(s => !allowed.has(s))63+ check(specs.length > 0, `Found ${specs.length} import(s)`)64+ check(bad.length === 0, 'No disallowed imports', bad.length ? `Bad: ${bad.join(', ')}` : undefined)65+}66+67+// ── 4. Dynamic import — plugin identity ────────────────────────────────────68+console.log('\n[4] Dynamic import: plugin identity')69+70+// Build child code as array to avoid template literal escaping issues71+const childLines = [72+ "import { readFileSync } from 'node:fs'",73+ "import { pathToFileURL } from 'node:url'",74+ "",75+ "const src = readFileSync(" + JSON.stringify(pluginPath) + ", 'utf8')",76+ "",77+ "const sdkShim = 'export const cn=(...a)=>a.filter(Boolean).join(\" \");'",78+ " + 'export const ROUTES_AREA=\"routes\";'",79+ " + 'export const SIDEBAR_NAV_AREA=\"sidebar.nav\";'",80+ " + 'export const useQuery=()=>({data:null,isLoading:true,error:null});'",81+ " + 'export const Loader=()=>null;'",82+ " + 'export const EmptyState=()=>null;'",83+ " + 'export const ErrorState=()=>null;' + ' export const Badge=()=>null;' + ' export const Button=()=>null;' + ' export const Codicon=()=>null;' + ' export const CopyButton=()=>null;' + ' export const Dialog=()=>null;' + ' export const DialogContent=()=>null;' + ' export const Input=()=>null;' + ' export const ScrollArea=()=>null;' + ' export const SearchField=()=>null;' + ' export const Select=()=>null;' + ' export const SelectContent=()=>null;' + ' export const SelectItem=()=>null;' + ' export const SelectTrigger=()=>null;' + ' export const SelectValue=()=>null;' + ' export const SegmentedControl=()=>null;' + ' export const Tabs=()=>null;' + ' export const TabsList=()=>null;' + ' export const TabsTrigger=()=>null;'",84+ "",85+ "const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'",86+ " + 'export function useEffect(f,d){return undefined};'",87+ " + 'export function useRef(v){return{current:v}};'",88+ " + 'export function useCallback(f,d){return f};'",89+ " + 'export function useMemo(f,d){return f()};'",90+ " + 'export function useContext(c){return undefined};'",91+ " + 'export default {}'",92+ "",93+ "const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:\"jsx\"}}'",94+ " + 'export function jsxs(c,p){return{component:c,props:p,_t:\"jsxs\"}}'",95+ "",96+ "const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)",97+ "const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)",98+ "const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)",99+ "const blobSrc = src",100+ " .replace(/from\\s+['\"]@hermes\\/plugin-sdk['\"]/g, \"from '\" + sdkUrl + \"'\")",101+ " .replace(/from\\s+['\"]react\\/jsx-runtime['\"]/g, \"from '\" + jsxUrl + \"'\")",102+ " .replace(/from\\s+['\"]react['\"]/g, \"from '\" + reactUrl + \"'\")",103+ "",104+ "const blobUrl = 'data:text/javascript,' + encodeURIComponent(blobSrc)",105+ "const mod = await import(blobUrl)",106+ "const p = mod.default",107+ "",108+ "const results = {",109+ " id: p && p.id,",110+ " hasRegister: typeof (p && p.register) === 'function',",111+ " hasName: typeof (p && p.name) === 'string',",112+ " description: p && p.description,",113+ "}",114+ "",115+ "const contribs = []",116+ "let restCalls = []",117+ "",118+ "const mockCtx = {",119+ " register(c) { contribs.push(c) },",120+ " registerMany(arr) { for (const c of arr) contribs.push(c) },",121+ " rest(path) { restCalls.push(path); return Promise.resolve({ sources: [] }) }",122+ "}",123+ "",124+ "try {",125+ " p.register(mockCtx)",126+ "} catch(e) {",127+ " results.registerError = e.message",128+ "}",129+ "",130+ "results.contribCount = contribs.length",131+ "results.restCalls = restCalls",132+ "results.routeContrib = contribs.find(c => c.area === 'routes')",133+ "results.navContrib = contribs.find(c => c.area === 'sidebar.nav')",134+ "results.hasRender = typeof (results.routeContrib && results.routeContrib.render) === 'function'",135+ "",136+ "if (results.hasRender) {",137+ " try {",138+ " const rendered = results.routeContrib.render()",139+ " results.renderReturned = rendered != null",140+ " results.renderIsJsx = rendered && (rendered._t === 'jsx' || rendered._t === 'jsxs')",141+ " } catch(e) {",142+ " results.renderError = e.message",143+ " }",144+ "}",145+ "",146+ "process.stdout.write(JSON.stringify(results))",147+]148+149+const childPath = resolve(__dirname, '_smoke_child.mjs')150+import { writeFileSync, unlinkSync } from 'node:fs'151+writeFileSync(childPath, childLines.join('\n'))152+153+try {154+ const raw = execSync(`node ${childPath}`, { encoding: 'utf8', timeout: 15000 }).trim()155+ const r = JSON.parse(raw)156+157+ console.log(` id=${r.id} register=${r.hasRegister} name="${r.description}"`)158+ check(r.id === 'openspec', 'Plugin id is "openspec"', r.id ? `Got: ${r.id}` : 'null')159+ check(r.hasRegister, 'Has register() function')160+ check(r.hasName, 'Has name property')161+ check(!r.registerError, 'register() runs without error', r.registerError)162+163+ // [5] Contributions164+ console.log(`\n[5] Contributions: ${r.contribCount} registered`)165+ check(r.contribCount >= 2, 'At least 2 contributions')166+ check(r.routeContrib !== undefined, 'Has ROUTES_AREA contribution')167+ check(r.navContrib !== undefined, 'Has SIDEBAR_NAV_AREA contribution')168+169+ // [6] Path and nav metadata170+ console.log('\n[6] Path and nav metadata')171+ const rp = r.routeContrib && r.routeContrib.data && r.routeContrib.data.path172+ check(rp === '/openspec', `Route path "/openspec"`, rp ? `Got: ${rp}` : 'missing')173+ check(r.navContrib && r.navContrib.data && r.navContrib.data.label === 'OpenSpec', 'Sidebar nav label "OpenSpec"')174+ check(r.navContrib && r.navContrib.data && r.navContrib.data.codicon != null, 'Sidebar nav has codicon')175+ check(r.navContrib && r.navContrib.data && r.navContrib.data.path === '/openspec', 'Sidebar nav path /openspec')176+177+ // [7] Render function178+ console.log('\n[7] Render function')179+ check(r.hasRender, 'Route contribution has render()')180+ check(r.renderReturned === true, 'render() returns a value', r.renderError || 'returned null')181+ check(r.renderIsJsx === true, 'render() returns jsx descriptor',182+ !r.renderIsJsx && r.renderReturned ? `Got type: ${typeof r.renderReturned}` : undefined)183+184+} catch (e) {185+ fail('Dynamic import', e.message)186+} finally {187+ try { unlinkSync(childPath) } catch {}188+}189+190+// ── 8. ctx.rest('/sources') — static analysis ──────────────────────────────191+console.log('\n[8] ctx.rest("/sources") invocation')192+check(source.includes("api.sources()"), "Calls api.sources()")193+check(!source.includes("fetch('"), 'No direct fetch() calls')194+check(!source.includes('fetch("/'), 'No direct fetch() calls')195+check(!source.includes('window.fetch'), 'No window.fetch calls')196+197+// Verify the rest call is inside the render path (useQuery queryFn)198+const restInQueryFn = /queryFn.*api\.sources/.test(source)199+ || /api\.sources[\s\S]*queryFn/.test(source)200+ || source.includes("queryFn: function() { return api.sources() }")201+check(restInQueryFn, 'api.sources() is in useQuery queryFn')202+203+// ── 9. Loading/error/empty state transitions ───────────────────────────────204+console.log('\n[9] Rendering transitions')205+check(source.includes('isLoading'), 'Checks isLoading state')206+check(source.includes('error'), 'Checks error state')207+check(source.includes('Loader'), 'Uses Loader component')208+check(source.includes('ErrorState'), 'Uses ErrorState component')209+check(source.includes('EmptyState'), 'Uses EmptyState component')210+211+// ── 10. Source quality ──────────────────────────────────────────────────────212+console.log('\n[10] Source quality')213+check(!source.includes('console.log'), 'No debug console.log')214+check(!source.includes('// TODO'), 'No TODO comments')215+check(!source.includes('alert('), 'No alert() calls')216+check(source.includes("id: 'openspec'"), 'Plugin id is "openspec"')217+check(source.includes("name: 'OpenSpec'"), 'Plugin name is "OpenSpec"')218+219+// ── Summary ─────────────────────────────────────────────────────────────────220+console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`)221+process.exit(failed > 0 ? 1 : 0)@@ -0,0 +1,734 @@
1+2+/**3+ * Helper module for dynamic import shim generation.4+ * Called by desktop-plugin-smoke.mjs to avoid template literal escaping issues.5+ */6+import { readFileSync } from 'node:fs'7+import { dirname, resolve } from 'node:path'8+import { fileURLToPath } from 'node:url'9+10+const __dirname = dirname(fileURLToPath(import.meta.url))11+const pluginPath = resolve(__dirname, '../desktop/plugin.js')12+const src = readFileSync(pluginPath, 'utf8')13+14+// Shim @hermes/plugin-sdk15+const sdkExports = [16+ 'cn', 'Codicon', 'useQuery',17+ 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',18+ 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',19+ 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',20+ 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'21+].map(n => 'export const ' + n + '=(...a)=>a').join(';')22+// ROUTES_AREA and SIDEBAR_NAV_AREA are string constants, not functions23+const sdkShim = sdkExports24+ + ';export const ROUTES_AREA="routes"'25+ + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'26+ + ';export default {}'27+28+// Shim react29+const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'30+ + 'export function useEffect(f,d){return undefined};'31+ + 'export function useRef(v){return{current:v}};'32+ + 'export function useCallback(f,d){return f};'33+ + 'export function useMemo(f,d){return f()};'34+ + 'export function useContext(c){return undefined};'35+ + 'export default {}'36+37+// Shim react/jsx-runtime38+const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:"jsx"}}'39+ + 'export function jsxs(c,p){return{component:c,props:p,_t:"jsxs"}}'40+ + 'export function Fragment(p){return p&&p.children}'41+42+const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)43+const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)44+const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)45+46+const blobSrc = src47+ .replace(/from\s+['"]@hermes\/plugin-sdk['"]/g, "from '" + sdkUrl + "'")48+ .replace(/from\s+['"]react\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")49+ .replace(/from\s+['"]react['"]/g, "from '" + reactUrl + "'")50+51+const blobUrl = 'data:text/javascript,' + encodeURIComponent(blobSrc)52+const mod = await import(blobUrl)53+const plugin = mod.default54+const __test = mod.__test55+56+// Simulate register()57+const contribs = []58+let restCalls = []59+const mockCtx = {60+ register(c) { contribs.push(c) },61+ registerMany(arr) { for (const c of arr) contribs.push(c) },62+ rest(path, opts) { restCalls.push({ path: path, opts: opts || null }); return Promise.resolve({}) }63+}64+65+try {66+ plugin.register(mockCtx)67+} catch(e) {68+ // register may fail if __test not available69+}70+71+// Export results as JSON72+const routeContrib = contribs.find(c => c.area === 'routes')73+let renderResult = null74+if (routeContrib && typeof routeContrib.render === 'function') {75+ try {76+ const rendered = routeContrib.render()77+ renderResult = { returned: rendered != null, isJsx: rendered && (rendered._t === 'jsx' || rendered._t === 'jsxs') }78+ } catch(e) {79+ renderResult = { error: e.message }80+ }81+}82+const results = {83+ id: plugin && plugin.id,84+ name: plugin && plugin.name,85+ hasRegister: typeof (plugin && plugin.register) === 'function',86+ contribCount: contribs.length,87+ routeContrib: routeContrib,88+ routeContribHasRender: routeContrib && typeof routeContrib.render === 'function',89+ renderResult: renderResult,90+ navContrib: contribs.find(c => c.area === 'sidebar.nav'),91+ restCalls: restCalls,92+ __testKeys: __test ? Object.keys(__test) : [],93+ __testAvailable: !!__test,94+}95+96+// Test __test functions if available97+if (__test) {98+ try {99+ results.pathBuilders = {}100+101+ if (__test.sourcesPath) results.pathBuilders.sources = __test.sourcesPath()102+ if (__test.changePath) {103+ results.pathBuilders.change = __test.changePath('src-1', 'my-change')104+ results.pathBuilders.changeSpecial = __test.changePath('src-1', 'feat/with spaces & special')105+ }106+ if (__test.ideaPath) {107+ results.pathBuilders.idea = __test.ideaPath('src-1', 'my-idea')108+ results.pathBuilders.ideaSpecial = __test.ideaPath('src-1', 'feat/with spaces & special')109+ }110+ if (__test.specBrowserPath) {111+ results.pathBuilders.specBrowser = __test.specBrowserPath('src-1')112+ results.pathBuilders.specBrowserDirty = __test.specBrowserPath('src-1', { dirty: true })113+ }114+ if (__test.specPath) {115+ results.pathBuilders.spec = __test.specPath('src-1', 'specs/readme.md')116+ results.pathBuilders.specSpecial = __test.specPath('src-1', 'specs/with spaces.md')117+ }118+119+ // Source selection120+ results.sourceSelection = {}121+ if (__test.selectFirstValid) {122+ const sources = [123+ { id: 'src-1', valid: false, error: 'bad' },124+ { id: 'src-2', valid: true, name: 'good' },125+ { id: 'src-3', valid: true, name: 'also-good' }126+ ]127+ results.sourceSelection.firstValid = __test.selectFirstValid(sources)128+ const allInvalid = [129+ { id: 'src-1', valid: false, error: 'bad' },130+ { id: 'src-2', valid: false, error: 'worse' }131+ ]132+ results.sourceSelection.allInvalid = __test.selectFirstValid(allInvalid)133+ results.sourceSelection.noSources = __test.selectFirstValid([])134+ }135+136+ // Board helpers137+ results.board = {}138+ if (__test.normalizeStatus) {139+ results.board.normalizeInProgress = __test.normalizeStatus('in-progress')140+ results.board.normalizeDraft = __test.normalizeStatus('draft')141+ results.board.normalizeArchived = __test.normalizeStatus('archived')142+ results.board.normalizeIdeas = __test.normalizeStatus('ideas')143+ results.board.normalizeUnknown = __test.normalizeStatus('something-new')144+ }145+ if (__test.groupByStatus) {146+ const items = [147+ { name: 'a', status: 'draft', token: 'A', sequence: 2 },148+ { name: 'b', status: 'in-progress', token: 'B', sequence: 1 },149+ { name: 'c', status: 'done', token: 'C', sequence: 3 },150+ { name: 'd', status: 'archived', token: 'D', sequence: 0 },151+ { name: 'idea', status: 'ideas', token: 'IDEA', sequence: 4 },152+ ]153+ const groups = __test.groupByStatus(items, false)154+ results.board.groupKeys = Object.keys(groups)155+ results.board.groupCounts = {}156+ for (const k in groups) results.board.groupCounts[k] = groups[k].length157+ }158+ if (__test.sortItems) {159+ const sortItems = [160+ { name: 'b', sequence: 2, title: 'Alpha' },161+ { name: 'a', sequence: 1, title: 'Beta' },162+ { name: 'c', sequence: 1, title: 'Alpha' },163+ ]164+ results.board.sorted = __test.sortItems(sortItems).map(i => i.name)165+ }166+ if (__test.filterItems) {167+ const filterItems = [168+ { name: 'alpha-change', title: 'Alpha Feature', token: 'ALPHA' },169+ { name: 'beta-fix', title: 'Beta Bugfix', token: 'BETA' },170+ { name: 'gamma-refactor', title: 'Gamma Refactor', token: 'GAMMA' },171+ ]172+ results.board.filteredAlpha = __test.filterItems(filterItems, 'alpha').map(i => i.name)173+ results.board.filteredBeta = __test.filterItems(filterItems, 'BETA').map(i => i.name)174+ results.board.filteredEmpty = __test.filterItems(filterItems, 'delta').map(i => i.name)175+ results.board.filteredClear = __test.filterItems(filterItems, '').map(i => i.name)176+ }177+ if (__test.toggleArchived) {178+ results.board.withArchived = __test.toggleArchived(true)179+ results.board.withoutArchived = __test.toggleArchived(false)180+ }181+182+ // in_progress (underscore) grouping — backend emits this spelling183+ if (__test.groupByStatus && __test.normalizeStatus) {184+ var inProgressItems = [185+ { name: 'item-a', status: 'in_progress', token: 'A' },186+ { name: 'item-b', status: 'in-progress', token: 'B' },187+ { name: 'item-c', status: 'inprogress', token: 'C' },188+ ]189+ var ipGroups = __test.groupByStatus(inProgressItems, false)190+ results.board.inProgressGroupCount = (ipGroups['in-progress'] || []).length191+ results.board.inProgressNormalized = __test.normalizeStatus('in_progress')192+ }193+194+ // Task parser195+ results.tasks = {}196+ if (__test.parseTasks) {197+ const taskContent = '## Section 1\n- [x] Done task\n- [ ] Pending task\n- [x] Another done\n\n## Section 2\n- [ ] Open task\n'198+ const parsed = __test.parseTasks(taskContent)199+ results.tasks.total = parsed.total200+ results.tasks.done = parsed.done201+ results.tasks.sections = parsed.sections202+ }203+204+ // Markdown parser205+ results.markdown = {}206+ if (__test.renderMarkdown) {207+ const md = '# Heading\n\nA paragraph with **bold** and `code`.\n\n- item 1\n- item 2'208+ const rendered = __test.renderMarkdown(md)209+ results.markdown.isArray = Array.isArray(rendered)210+ results.markdown.length = rendered && rendered.length211+ }212+213+ // State classifier214+ if (__test.classifyState) {215+ results.markdown.stateLoading = __test.classifyState({ isLoading: true, error: null, data: null })216+ results.markdown.stateError = __test.classifyState({ isLoading: false, error: new Error('fail'), data: null })217+ results.markdown.stateEmpty = __test.classifyState({ isLoading: false, error: null, data: { items: [] } })218+ results.markdown.stateContent = __test.classifyState({ isLoading: false, error: null, data: { items: [1] } })219+ }220+221+ // Diff helpers222+ results.diff = {}223+ if (__test.classifyDiffLine) {224+ results.diff.added = __test.classifyDiffLine('+added line')225+ results.diff.removed = __test.classifyDiffLine('-removed line')226+ results.diff.hunk = __test.classifyDiffLine('@@ -1,3 +1,4 @@')227+ results.diff.context = __test.classifyDiffLine(' unchanged line')228+ }229+ if (__test.hasDiffMode) {230+ results.diff.hasSemantic = __test.hasDiffMode({ semantic_diff: { groups: [] } }, 'semantic')231+ results.diff.hasSplit = __test.hasDiffMode({ before: 'a', after: 'b' }, 'split')232+ results.diff.hasRaw = __test.hasDiffMode({ diff: '+line' }, 'raw')233+ results.diff.noSemantic = __test.hasDiffMode({ before: 'a', after: 'b' }, 'semantic')234+ }235+236+ // --- Behavioral fixture tests: backend contract alignment ---237+238+ // 1. Semantic groups normalization — exact backend shape from spec_parser.py239+ results.semanticGroups = {}240+ if (__test.normalizeSemanticGroups) {241+ // Backend returns {status, requirements: {added, modified, removed, unchanged}}242+ var backendDiff = {243+ status: 'modified',244+ requirements: {245+ added: [{ name: 'Auth', description: 'User auth via OAuth', scenarios: [] }],246+ modified: [{247+ name: 'Data Model',248+ before: { description: 'Old schema with 3 fields', scenarios: [] },249+ after: { description: 'New schema with 5 fields', scenarios: [] },250+ scenarios_added: [],251+ scenarios_modified: [],252+ scenarios_removed: [],253+ }],254+ removed: [{ name: 'Legacy', description: 'Deprecated endpoint', scenarios: [] }],255+ unchanged: ['Logging'],256+ }257+ }258+ var groups = __test.normalizeSemanticGroups(backendDiff)259+ results.semanticGroups.isArray = Array.isArray(groups)260+ results.semanticGroups.length = groups.length261+ // Verify labels262+ results.semanticGroups.addedLabel = groups[0] && groups[0].label263+ results.semanticGroups.modifiedLabel = groups[1] && groups[1].label264+ results.semanticGroups.removedLabel = groups[2] && groups[2].label265+ // Verify no [object Object] in descriptions266+ results.semanticGroups.noObjectString = groups.every(function(g) {267+ return !String(g.description || '').includes('[object Object]')268+ && !String(g.before || '').includes('[object Object]')269+ && !String(g.after || '').includes('[object Object]')270+ })271+ // Verify names preserved272+ results.semanticGroups.addedName = groups[0] && groups[0].name273+ results.semanticGroups.modifiedName = groups[1] && groups[1].name274+ results.semanticGroups.removedName = groups[2] && groups[2].name275+ // Verify before/after extracted from object-valued fields276+ results.semanticGroups.beforeDesc = groups[1] && groups[1].before277+ results.semanticGroups.afterDesc = groups[1] && groups[1].after278+ // Verify crash: old code did data.requirements.map() which throws279+ results.semanticGroups.noCrash = true280+ try {281+ // This is what the old code did — should have thrown282+ backendDiff.requirements.map(function() {})283+ results.semanticGroups.oldCodeWouldThrow = false284+ } catch (e) {285+ results.semanticGroups.oldCodeWouldThrow = true286+ }287+ }288+289+ // 2. Card artifacts from has* booleans — exact shape from plugin_api.py290+ results.cardArtifacts = {}291+ if (__test.hasDiffMode) {292+ // Backend source summary shape from plugin_api.py:218-241293+ var changeSummary = {294+ id: 'src-1/my-change',295+ token: 'my-change',296+ name: 'my-change',297+ title: 'My Change',298+ status: 'in-progress',299+ hasProposal: true,300+ hasTasks: true,301+ hasDesign: false,302+ hasSpecs: true,303+ taskStats: { total: 5, done: 2 },304+ }305+ // Test that has* booleans are the source of truth (not artifacts/artifactNames)306+ results.cardArtifacts.hasProposal = changeSummary.hasProposal === true307+ results.cardArtifacts.hasTasks = changeSummary.hasTasks === true308+ results.cardArtifacts.hasDesign = changeSummary.hasDesign === false309+ results.cardArtifacts.hasSpecs = changeSummary.hasSpecs === true310+ results.cardArtifacts.noArtifactsField = changeSummary.artifacts === undefined311+ results.cardArtifacts.noArtifactNamesField = changeSummary.artifactNames === undefined312+ }313+314+ // 3. effectiveDiffMode fallback315+ results.effectiveDiffMode = {}316+ if (__test.effectiveDiffMode) {317+ // When desired mode is available, return it318+ results.effectiveDiffMode.semanticAvailable = __test.effectiveDiffMode('semantic', { semantic_diff: { requirements: {} } })319+ // When semantic unavailable but split available, fallback to split320+ results.effectiveDiffMode.fallbackToSplit = __test.effectiveDiffMode('semantic', { before: 'a', after: 'b' })321+ // When only raw available, fallback to raw322+ results.effectiveDiffMode.fallbackToRaw = __test.effectiveDiffMode('semantic', { diff: '+line' })323+ // When nothing available, return desired (no crash)324+ results.effectiveDiffMode.nothingAvailable = __test.effectiveDiffMode('semantic', {})325+ // Split desired but only raw available326+ results.effectiveDiffMode.splitToRaw = __test.effectiveDiffMode('split', { diff: '+line' })327+ }328+329+ // RetryErrorState wrapper — verify structure via __test330+ results.retryErrorState = null331+ if (__test.RetryErrorState) {332+ // Call RetryErrorState with a test callback and title/description333+ var testCallback = function() { return 'retried' }334+ var descriptor = __test.RetryErrorState({ title: 'Error', description: 'fail', onRetry: testCallback })335+ if (descriptor && descriptor._t) {336+ var res = { rootIsErrorState: false, hasButtonChild: false, buttonOnClickIsFunction: false, buttonLabel: null, onClickInvokesCallback: false }337+ // Root should be ErrorState — the shim stores the function reference338+ // (not the string 'ErrorState'), so verify it's a function339+ res.rootIsErrorState = typeof descriptor.component === 'function'340+ // Children should contain a Button341+ var children = descriptor.props && descriptor.props.children342+ if (children) {343+ var childArr = Array.isArray(children) ? children : [children]344+ for (var ci = 0; ci < childArr.length; ci++) {345+ var child = childArr[ci]346+ if (child && child._t && child.component && typeof child.component === 'function') {347+ res.hasButtonChild = true348+ res.buttonLabel = child.props && child.props.children349+ var onClick = child.props && child.props.onClick350+ res.buttonOnClickIsFunction = typeof onClick === 'function'351+ if (res.buttonOnClickIsFunction) {352+ var result = onClick()353+ res.onClickInvokesCallback = result === 'retried'354+ }355+ break356+ }357+ }358+ }359+ results.retryErrorState = res360+ }361+ }362+363+ // Lane layout helpers364+ results.laneLayout = {}365+ if (__test.autoCollapseEmptyLanes) {366+ var groupedMixed = { 'ideas': [], 'draft': [{ name: 'a' }], 'todo': [{ name: 'b' }], 'in-progress': [], 'done': [], 'archived': [] }367+ var collapsed = __test.autoCollapseEmptyLanes(groupedMixed, ['ideas', 'draft', 'todo', 'in-progress', 'done', 'archived'])368+ results.laneLayout.collapseEmptyWhenWork = !!collapsed['ideas']369+ results.laneLayout.expandNonEmpty = !collapsed['draft']370+ results.laneLayout.expandNonEmptyTodo = !collapsed['todo']371+ results.laneLayout.collapseInProgress = !!collapsed['in-progress']372+ results.laneLayout.collapseDone = !!collapsed['done']373+374+ // Board with no work: nothing collapses375+ var allEmpty = { 'ideas': [], 'draft': [], 'todo': [], 'in-progress': [], 'done': [], 'archived': [] }376+ var noCollapse = __test.autoCollapseEmptyLanes(allEmpty, ['ideas', 'draft', 'todo', 'in-progress', 'done', 'archived'])377+ var anyCollapsed = false378+ for (var k in noCollapse) { if (noCollapse[k]) anyCollapsed = true }379+ results.laneLayout.noWorkNoCollapse = !anyCollapsed380+ }381+ if (__test.laneWidth) {382+ results.laneLayout.expandedWidth = __test.laneWidth('draft', {})383+ results.laneLayout.collapsedWidth = __test.laneWidth('ideas', { 'ideas': true })384+ results.laneLayout.noOverridesExpanded = __test.laneWidth('draft', { 'ideas': true })385+ }386+ if (__test.LANE_EXPANDED_WIDTH) {387+ results.laneLayout.expandedIs256 = __test.LANE_EXPANDED_WIDTH === 256388+ }389+ if (__test.LANE_COLLAPSED_WIDTH) {390+ results.laneLayout.collapsedIs32 = __test.LANE_COLLAPSED_WIDTH === 32391+ }392+ if (__test.railStyle) {393+ var rs = __test.railStyle('Ideas')394+ results.laneLayout.railWidth = rs && rs.width395+ results.laneLayout.railFlexShrink = rs && rs.flexShrink396+ results.laneLayout.railCursor = rs && rs.cursor397+ }398+ if (__test.expandedStyle) {399+ var es = __test.expandedStyle()400+ results.laneLayout.expandedWidthPx = es && es.width401+ results.laneLayout.expandedFlexShrink = es && es.flexShrink402+ results.laneLayout.expandedOverflow = es && es.overflow403+ results.laneLayout.expandedHasBg = es && es.background && es.background.includes('color-mix')404+ results.laneLayout.expandedHasRounded = es && es.borderRadius === '8px'405+ results.laneLayout.expandedHasPadding = es && es.padding === '8px'406+ }407+408+ // STATUS_TONE mapping409+ results.statusTone = {}410+ if (__test.STATUS_TONE && __test.statusTone) {411+ results.statusTone.ideas = __test.statusTone('ideas')412+ results.statusTone.draft = __test.statusTone('draft')413+ results.statusTone.todo = __test.statusTone('todo')414+ results.statusTone.inProgress = __test.statusTone('in-progress')415+ results.statusTone.done = __test.statusTone('done')416+ results.statusTone.archived = __test.statusTone('archived')417+ results.statusTone.unknown = __test.statusTone('unknown-status')418+ results.statusTone.ideasIsVar = __test.STATUS_TONE['ideas'] === 'var(--ui-text-tertiary)'419+ results.statusTone.draftIsPurple = __test.STATUS_TONE['draft'] === '#a78bfa'420+ results.statusTone.todoIsVar = __test.STATUS_TONE['todo'] === 'var(--ui-text-secondary)'421+ results.statusTone.inProgressIsGreen = __test.STATUS_TONE['in-progress'] === '#34d399'422+ results.statusTone.doneIsVar = __test.STATUS_TONE['done'] === 'var(--ui-text-tertiary)'423+ results.statusTone.archivedIsVar = __test.STATUS_TONE['archived'] === 'var(--ui-text-quaternary)'424+ }425+426+ // URL safety427+ results.urlSafety = {}428+ if (__test.isSafeUrl) {429+ results.urlSafety.httpsAllowed = __test.isSafeUrl('https://example.com')430+ results.urlSafety.httpAllowed = __test.isSafeUrl('http://example.com')431+ results.urlSafety.relativeAllowed = __test.isSafeUrl('/path/to/page')432+ results.urlSafety.fragmentAllowed = __test.isSafeUrl('#section')433+ results.urlSafety.queryAllowed = __test.isSafeUrl('?key=val')434+ results.urlSafety.mailtoAllowed = __test.isSafeUrl('mailto:user@example.com')435+ results.urlSafety.javascriptBlocked = !__test.isSafeUrl('javascript:alert(1)')436+ results.urlSafety.dataBlocked = !__test.isSafeUrl('data:text/html,<script>')437+ results.urlSafety.fileBlocked = !__test.isSafeUrl('file:///etc/passwd')438+ results.urlSafety.vbscriptBlocked = !__test.isSafeUrl('vbscript:MsgBox(1)')439+ results.urlSafety.emptyBlocked = !__test.isSafeUrl('')440+ results.urlSafety.nullBlocked = !__test.isSafeUrl(null)441+ // Whitespace/control obfuscation tests442+ results.urlSafety.wsJavascriptBlocked = !__test.isSafeUrl(' javascript:alert(1)')443+ results.urlSafety.tabJavascriptBlocked = !__test.isSafeUrl('\tjavascript:alert(1)')444+ results.urlSafety.newlineJavascriptBlocked = !__test.isSafeUrl('\njavascript:alert(1)')445+ results.urlSafety.controlJavascriptBlocked = !__test.isSafeUrl('\x00javascript:alert(1)')446+ results.urlSafety.uppercaseJavascriptBlocked = !__test.isSafeUrl('JAVASCRIPT:alert(1)')447+ results.urlSafety.mixedCaseDataBlocked = !__test.isSafeUrl('DaTa:text/html,<script>')448+ }449+450+ // __test is frozen451+ results.testFrozen = Object.isFrozen(__test)452+453+ // renderInline returns JSX (not plain objects)454+ results.inlineJsx = {}455+ if (__test.renderMarkdown && __test.MarkdownElement && __test.renderInline) {456+ // Parse markdown into blocks457+ var inlineResult = __test.renderMarkdown('Text with **bold** and `code` and [link](https://example.com)')458+ results.inlineJsx.isArray = Array.isArray(inlineResult)459+ results.inlineJsx.hasParagraph = inlineResult.some(function(el) { return el && el.type === 'paragraph' })460+461+ // EXECUTABLE RENDER CHECK: invoke MarkdownElement on each parsed block462+ // and verify it returns JSX descriptors (with _t property from jsx shim), not raw parser objects463+ var allBlocksAreJsx = true464+ var anyBlockFailed = false465+ for (var bi = 0; bi < inlineResult.length; bi++) {466+ var block = inlineResult[bi]467+ if (!block || !block.type) continue468+ var rendered = __test.MarkdownElement(block)469+ // JSX shim returns {component, props, _t: "jsx"} — parser objects have no _t470+ if (!rendered || typeof rendered !== 'object' || !rendered._t) {471+ allBlocksAreJsx = false472+ anyBlockFailed = true473+ break474+ }475+ }476+ results.inlineJsx.blocksRenderToJsx = allBlocksAreJsx477+ results.inlineJsx.anyBlockFailed = anyBlockFailed478+479+ // EXECUTABLE RENDER CHECK: invoke renderInline directly on inline text480+ // and verify each inline element returns JSX, not plain objects481+ var inlineParts = __test.renderInline('Hello **bold** world')482+ results.inlineJsx.inline = {}483+ results.inlineJsx.inline.isArray = Array.isArray(inlineParts)484+ var inlineAllJsx = true485+ if (Array.isArray(inlineParts)) {486+ for (var ii = 0; ii < inlineParts.length; ii++) {487+ var part = inlineParts[ii]488+ // Strings are fine (plain text between markers)489+ if (typeof part === 'string') continue490+ // JSX elements must have _t property491+ if (!part || typeof part !== 'object' || !part._t) {492+ inlineAllJsx = false493+ break494+ }495+ }496+ }497+ results.inlineJsx.inline.allJsx = inlineAllJsx498+499+ // Mixed bold/code/link rendering — verify each inline type produces JSX500+ var mixedParts = __test.renderInline('**bold** and `code` and [click](https://x.com)')501+ results.inlineJsx.mixed = {}502+ if (Array.isArray(mixedParts)) {503+ var jsxParts = mixedParts.filter(function(p) { return p && typeof p === 'object' && p._t })504+ results.inlineJsx.mixed.count = jsxParts.length505+ // Should have 3 JSX elements: strong, code, a506+ results.inlineJsx.mixed.hasThree = jsxParts.length === 3507+ // Verify component types508+ var componentNames = jsxParts.map(function(p) {509+ return p.props && p.props.children ? (typeof p.props.children === 'string' ? p.props.children.substring(0, 10) : '?') : '?'510+ })511+ results.inlineJsx.mixed.componentChildren = componentNames512+ }513+ }514+515+ } catch (e) {516+ results.__testError = e.message517+ }518+519+ // ═══════════════════════════════════════════════════════════════════════════════520+ // Lane override behavior tests521+ // ═══════════════════════════════════════════════════════════════════════════════522+ try {523+ var SO = ['todo', 'in-progress', 'done']524+525+ // 1. computeCollapsedSet526+ results.collapsedSet = {}527+ if (__test.computeCollapsedSet) {528+ var auto = { todo: true } // todo empty, in-progress & done have work529+ var empty = __test.computeCollapsedSet(auto, {}, SO)530+ results.collapsedSet.autoOnlyEmpty = empty.todo === true531+ results.collapsedSet.autoOnlyPopulated = empty['in-progress'] === false532+ var withOverride = __test.computeCollapsedSet(auto, { todo: false }, SO)533+ results.collapsedSet.overrideEmpty = withOverride.todo === false534+ var withOverrideFull = __test.computeCollapsedSet(auto, { 'in-progress': true }, SO)535+ results.collapsedSet.overridePopulated = withOverrideFull['in-progress'] === true536+ }537+538+ // 2. toggleOverride539+ results.toggle = {}540+ if (__test.toggleOverride) {541+ var autoMap = { todo: true } // todo is auto-collapsed (empty lane)542+ var t1 = __test.toggleOverride({}, 'todo', autoMap)543+ results.toggle.firstToggleEmpty = t1.todo544+ var t2 = __test.toggleOverride(t1, 'todo', autoMap)545+ results.toggle.secondToggleEmpty = t2.todo546+547+ var autoMapFull = { 'in-progress': false } // in-progress is auto-expanded (has work)548+ var t3 = __test.toggleOverride({}, 'in-progress', autoMapFull)549+ results.toggle.firstTogglePopulated = t3['in-progress']550+ var t4 = __test.toggleOverride(t3, 'in-progress', autoMapFull)551+ results.toggle.secondTogglePopulated = t4['in-progress']552+ }553+554+ // 3. computeLanePhase555+ results.lanePhase = {}556+ if (__test.computeLanePhase) {557+ var g1 = { todo: [], 'in-progress': [{x:1}], done: [] }558+ results.lanePhase.empty = __test.computeLanePhase(g1, ['todo', 'in-progress', 'done'])559+ var g2 = { todo: [{x:1}], 'in-progress': [], done: [{x:1}] }560+ results.lanePhase.full = __test.computeLanePhase(g2, ['todo', 'in-progress', 'done'])561+ results.lanePhase.nullGrouped = __test.computeLanePhase(null, SO)562+ }563+564+ // 4. pruneStaleOverrides565+ results.prune = {}566+ if (__test.pruneStaleOverrides) {567+ // Phase changed on override lane → pruned568+ var prev1 = 'todo:empty|in-progress:full|done:empty'569+ var cur1 = 'todo:full|in-progress:full|done:empty' // todo flipped empty→full570+ var pruned1 = __test.pruneStaleOverrides({ todo: false, done: true }, prev1, cur1)571+ results.prune.changedLanePruned = pruned1.todo === undefined && pruned1.done === true572+573+ // Phase unchanged → kept574+ var prev2 = 'todo:empty|in-progress:full|done:empty'575+ var cur2 = 'todo:empty|in-progress:full|done:empty'576+ var pruned2 = __test.pruneStaleOverrides({ todo: false }, prev2, cur2)577+ results.prune.unchangedLaneKept = pruned2.todo === false578+579+ // Phase changed but no override → harmless580+ var prev3 = 'todo:empty|in-progress:full'581+ var cur3 = 'todo:full|in-progress:full'582+ var pruned3 = __test.pruneStaleOverrides({}, prev3, cur3)583+ results.prune.changedNoOverride = Object.keys(pruned3).length === 0584+585+ // Null previous phase → no pruning586+ var pruned4 = __test.pruneStaleOverrides({ todo: false }, null, 'todo:full|in-progress:full')587+ results.prune.nullPrevPhase = pruned4.todo === false588+589+ // Mixed: two overrides, only affected one pruned590+ var prev5 = 'todo:empty|in-progress:full|done:empty'591+ var cur5 = 'todo:full|in-progress:full|done:full' // todo and done flipped592+ var pruned5 = __test.pruneStaleOverrides({ todo: false, 'in-progress': true, done: true }, prev5, cur5)593+ results.prune.mixedPruning = pruned5.todo === undefined && pruned5['in-progress'] === true && pruned5.done === undefined594+ }595+596+ // 5. Toggle integration597+ results.toggleIntegration = {}598+ if (__test.toggleOverride && __test.computeCollapsedSet) {599+ var autoEmpty = { todo: true }600+ var autoFull = { 'in-progress': false }601+602+ // Toggle auto-collapsed twice → back to auto603+ var o1 = __test.toggleOverride({}, 'todo', autoEmpty)604+ var o2 = __test.toggleOverride(o1, 'todo', autoEmpty)605+ var cs1 = __test.computeCollapsedSet(autoEmpty, o2, ['todo'])606+ results.toggleIntegration.autoCollapsedDeleted = cs1.todo === true // auto wins607+608+ // Toggle auto-expanded twice → back to auto609+ var o3 = __test.toggleOverride({}, 'in-progress', autoFull)610+ var o4 = __test.toggleOverride(o3, 'in-progress', autoFull)611+ var cs2 = __test.computeCollapsedSet(autoFull, o4, ['in-progress'])612+ results.toggleIntegration.autoExpandedDeleted = cs2['in-progress'] === false // auto wins613+614+ // Partial toggle: only one lane, other preserved615+ var o5 = __test.toggleOverride({}, 'todo', autoEmpty)616+ var cs3 = __test.computeCollapsedSet(autoEmpty, o5, ['todo', 'in-progress'])617+ results.toggleIntegration.partialToggleKept = o5.todo === false && cs3['in-progress'] === false618+ }619+620+ // 6. trimPath621+ results.trimPath = {}622+ if (__test.trimPath) {623+ results.trimPath.basicTrim = __test.trimPath(' /path/to/repo ')624+ results.trimPath.noTrim = __test.trimPath('/path/to/repo')625+ results.trimPath.emptyString = __test.trimPath('')626+ results.trimPath.whitespaceOnly = __test.trimPath(' ')627+ results.trimPath.nonString = __test.trimPath(null)628+ results.trimPath.number = __test.trimPath(42)629+ }630+631+ // 7. extractApiError632+ results.extractApiError = {}633+ if (__test.extractApiError) {634+ results.extractApiError.nullInput = __test.extractApiError(null)635+ results.extractApiError.stringInput = __test.extractApiError('bad request')636+ results.extractApiError.messageObj = __test.extractApiError({ message: 'not found' })637+ results.extractApiError.detailObj = __test.extractApiError({ detail: 'conflict' })638+ results.extractApiError.duplicateSource = __test.extractApiError({ message: "Error invoking remote method 'hermes:api': Error: 409: {\"detail\":\"Source already registered\"}" })639+ results.extractApiError.fallback = __test.extractApiError({ code: 500 })640+ }641+642+ // 8. Source mutation adapter — create a fresh api via register and test mutation methods643+ results.sourceMutation = {}644+ try {645+ // Create a fresh mock ctx to capture the api object646+ let capturedApi = null647+ const testCtx = {648+ register() {},649+ registerMany(arr) {650+ for (const c of arr) {651+ if (c.render) {652+ // Intercept render to capture the api from its closure653+ const origRender = c.render654+ c.render = function() {655+ // The api is created in register() and passed to OpenSpecPage656+ // We can't directly access it, so we'll test via __test657+ return origRender()658+ }659+ }660+ }661+ },662+ rest(path, opts) {663+ results.sourceMutation.lastRestPath = path664+ results.sourceMutation.lastRestOpts = opts || null665+ return Promise.resolve({ ok: true })666+ }667+ }668+ plugin.register(testCtx)669+670+ // Test addSource path and body via a direct rest mock671+ let addRestCalls = []672+ const addCtx = {673+ register() {},674+ registerMany() {},675+ rest(path, opts) { addRestCalls.push({ path, opts }); return Promise.resolve({}) }676+ }677+ results.sourceMutation.addSourcePath = __test.sourcesPath ? __test.sourcesPath() : null678+ results.sourceMutation.updatePathEncodes = __test.changePath ? __test.changePath('src-1', 'test') : null679+ if (__test.createApi) {680+ const initCalls = []681+ const initApi = __test.createApi({ rest(path, opts) {682+ initCalls.push({ path, opts })683+ return Promise.resolve({ ok: true })684+ } })685+ await initApi.initSource('source id')686+ results.sourceMutation.initSourcePath = initCalls[0] && initCalls[0].path687+ results.sourceMutation.initSourceMethod = initCalls[0] && initCalls[0].opts && initCalls[0].opts.method688+ results.sourceMutation.initSourceBody = initCalls[0] && initCalls[0].opts && initCalls[0].opts.body689+ results.sourceMutation.needsInit = __test.sourceNeedsInitialization({ source: { id: 'os-1', valid: false, error: 'No openspec/ directory found' } })690+ results.sourceMutation.validNeedsNoInit = __test.sourceNeedsInitialization({ source: { id: 'os-1', valid: true, error: null } })691+ // Exact endpoint allowlist: the four dashboard source endpoints only,692+ // with encoded identifiers, exact methods, and object request bodies.693+ const mutCalls = []694+ const mutApi = __test.createApi({ rest(path, opts) {695+ mutCalls.push({ path, opts })696+ return Promise.resolve({ ok: true, source: { id: 'src-1' } })697+ } })698+ await mutApi.sources()699+ await mutApi.addSource('/repos/acme', 'Acme')700+ await mutApi.addSource('/repos/bare')701+ await mutApi.updateSource('src id/with slash', '/repos/acme-new', 'Acme New')702+ await mutApi.initSource('src id')703+ await mutApi.removeSource('src id')704+ const addCall = mutCalls.find((c) => c.path === '/sources' && c.opts && c.opts.method === 'POST' && c.opts.body && c.opts.body.name === 'Acme')705+ const addBareCall = mutCalls.find((c) => c.path === '/sources' && c.opts && c.opts.method === 'POST' && c.opts.body && c.opts.body.path === '/repos/bare')706+ const updateCall = mutCalls.find((c) => c.path === '/sources/src%20id%2Fwith%20slash' && c.opts && c.opts.method === 'PUT')707+ const removeCall = mutCalls.find((c) => c.path === '/sources/src%20id' && c.opts && c.opts.method === 'DELETE')708+ const sourcesCall = mutCalls.find((c) => c.path === '/sources' && !c.opts)709+ results.sourceMutation.addCallPath = addCall && addCall.path710+ results.sourceMutation.addCallMethod = addCall && addCall.opts.method711+ results.sourceMutation.addCallBody = addCall && addCall.opts.body712+ results.sourceMutation.addBareCallBody = addBareCall && addBareCall.opts.body713+ results.sourceMutation.updateCallPath = updateCall && updateCall.path714+ results.sourceMutation.updateCallMethod = updateCall && updateCall.opts.method715+ results.sourceMutation.updateCallBody = updateCall && updateCall.opts.body716+ results.sourceMutation.removeCallPath = removeCall && removeCall.path717+ results.sourceMutation.removeCallMethod = removeCall && removeCall.opts.method718+ results.sourceMutation.removeCallBody = removeCall && removeCall.opts.body719+ results.sourceMutation.sourcesCallMethod = sourcesCall && sourcesCall.opts ? sourcesCall.opts.method : undefined720+ const apiKeys = Object.keys(mutApi).sort()721+ results.sourceMutation.mutationMethods = ['addSource', 'updateSource', 'initSource', 'removeSource'].filter((k) => apiKeys.includes(k))722+ results.sourceMutation.allowlistExact = JSON.stringify(apiKeys) === JSON.stringify(['addSource', 'change', 'idea', 'initSource', 'removeSource', 'sources', 'spec', 'specBrowser', 'updateSource'].sort())723+ results.sourceMutation.noGenericRequest = !apiKeys.includes('request')724+ }725+ } catch (e) {726+ results.sourceMutation.error = e.message727+ }728+729+ } catch (e) {730+ results.__laneOverrideError = e.message731+ }732+}733+734+process.stdout.write(JSON.stringify(results))@@ -0,0 +1,2451 @@
1+2+/**3+ * Headless smoke harness for desktop/plugin.js — full read-surface verification.4+ *5+ * Covers OpenSpec tasks 2.1–7.6:6+ * 2.1 Contract: plugin id, route, sidebar, imports, no JSX, no fetch7+ * 2.2 Path builders: encoded REST paths, no malformed output8+ * 3.1 Source selection helpers9+ * 4.1 Board helpers: status, sort, filter, archived toggle, artifact/task metadata10+ * 5.1 Adapter/tab/task helpers: detail paths, conditional tabs, task parser11+ * 6.1 Spec-browser helpers: current/dirty paths, availability flags12+ * 7.1–7.6 Verification commands13+ *14+ * Run: node tests/desktop-plugin-smoke.mjs15+ * Exit 0 = pass, exit 1 = failure.16+ */17+18+import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'19+import { execSync } from 'node:child_process'20+import { dirname, resolve, join } from 'node:path'21+import { tmpdir } from 'node:os'22+import { fileURLToPath } from 'node:url'23+24+const __dirname = dirname(fileURLToPath(import.meta.url))25+const pluginPath = resolve(__dirname, '../desktop/plugin.js')26+const source = readFileSync(pluginPath, 'utf8')27+28+let passed = 029+let failed = 030+const failures = []31+32+function ok(name) { passed++; console.log(' PASS ' + name) }33+function fail(name, d) {34+ failed++35+ const msg = ' FAIL ' + name + (d != null ? ': ' + d : '')36+ console.error(msg)37+ failures.push({ name, detail: d })38+}39+function check(c, n, d) { c ? ok(n) : fail(n, d) }40+41+console.log('=== Smoke harness: desktop/plugin.js (full read surface) ===\n')42+43+// ═══════════════════════════════════════════════════════════════════════════════44+// 2.1 Contract: plugin id, route, sidebar, allowed imports, no JSX, no fetch45+// ═══════════════════════════════════════════════════════════════════════════════46+console.log('[2.1] Contract: plugin registration and API boundary')47+48+// ESM structure49+check(source.includes('export default'), 'Has default export')50+check(source.includes('import {'), 'Has named imports')51+check(!source.includes('module.exports'), 'No CommonJS exports')52+check(!source.includes('require('), 'No require() calls')53+54+// No raw JSX55+{56+ const bad = source.split('\n').filter(function(l) {57+ const t = l.trim()58+ if (t.startsWith('//') || t.startsWith('*') || t.startsWith('/*')) return false59+ if (t.includes('jsx(') || t.includes('jsxs(')) return false60+ const stripped = t.replace(/['"`](?:[^'"`\\]|\\.)*['"`]/g, '')61+ return /<[A-Z]/.test(stripped)62+ })63+ check(bad.length === 0, 'No raw JSX outside jsx()/jsxs()', bad.length ? 'Line: ' + bad[0].trim() : undefined)64+}65+66+// Allowed imports only67+{68+ const specs = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(function(m) { return m[1] })69+ const allowed = new Set(['@hermes/plugin-sdk', 'react', 'react/jsx-runtime'])70+ const bad = specs.filter(function(s) { return !allowed.has(s) })71+ check(specs.length > 0, 'Found ' + specs.length + ' import(s)')72+ check(bad.length === 0, 'No disallowed imports', bad.length ? 'Bad: ' + bad.join(', ') : undefined)73+}74+75+// No direct fetch76+check(!source.includes("fetch('"), 'No direct fetch() calls')77+check(!source.includes('fetch("/'), 'No direct fetch() calls')78+check(!source.includes('window.fetch'), 'No window.fetch calls')79+80+// No mutation verbs in API adapter81+check(!source.includes('ctx.rest.post'), 'No ctx.rest.post')82+check(!source.includes('ctx.rest.put'), 'No ctx.rest.put')83+check(!source.includes('ctx.rest.delete'), 'No ctx.rest.delete')84+check(!source.includes('ctx.rest.patch'), 'No ctx.rest.patch')85+86+// No absolute API URLs87+check(!source.match(/https?:\/\/[^\s'"]+/), 'No absolute HTTP URLs in source')88+89+// No dangerouslySetInnerHTML90+check(!source.includes('dangerouslySetInnerHTML'), 'No dangerouslySetInnerHTML')91+92+// ═══════════════════════════════════════════════════════════════════════════════93+// Dynamic import with shims — run helper module94+// ═══════════════════════════════════════════════════════════════════════════════95+console.log('\n[2.2–6.1] Dynamic import and __test execution')96+97+const helperPath = resolve(__dirname, '_dynamic_import_helper.mjs')98+99+let r100+try {101+ const raw = execSync('node ' + helperPath, { encoding: 'utf8', timeout: 30000 }).trim()102+ r = JSON.parse(raw)103+} catch (e) {104+ fail('Dynamic import and __test execution', (e.message || '').slice(0, 500))105+ r = {}106+}107+108+// ═══════════════════════════════════════════════════════════════════════════════109+// Identity/session/interaction driver — self-contained module for fixtures that110+// exercise __test helpers and component descriptors (tasks 1.1/1.2/1.4/1.5/1.6).111+// The driver is written to a temp file and executed by node; it builds the same112+// SDK/react shims as _dynamic_import_helper.mjs, so descriptors carry real113+// component references and can be walked structurally.114+// ═══════════════════════════════════════════════════════════════════════════════115+116+function runIdentityDriver() {117+ const driverPath = join(tmpdir(), 'openspec-identity-' + process.pid + '-' + Math.random().toString(36).slice(2) + '.mjs')118+ try {119+ writeFileSync(driverPath, IDENTITY_DRIVER, 'utf8')120+ const raw = execSync('node ' + driverPath + ' ' + pluginPath, { encoding: 'utf8', timeout: 30000 }).trim()121+ return JSON.parse(raw)122+ } catch (e) {123+ return { __driverError: (e.message || '').slice(0, 800) }124+ } finally {125+ try { rmSync(driverPath, { force: true }) } catch (e) { /* ignore */ }126+ }127+}128+129+const IDENTITY_DRIVER = `130+import { readFileSync } from 'node:fs'131+const pluginSrc = readFileSync(process.argv[2], 'utf8')132+133+const sdkExports = [134+ 'Codicon', 'useQuery',135+ 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',136+ 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',137+ 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',138+ 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'139+].map(n => 'export const ' + n + '=(...a)=>a').join(';')140+const cnShim = 'export function cn(...a){ return a.filter(Boolean).join(" ") }'141+const sdkShim = sdkExports142+ + ';' + cnShim143+ + ';export const ROUTES_AREA="routes"'144+ + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'145+ + ';export default {}'146+const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'147+ + 'export function useEffect(f,d){return undefined};'148+ + 'export function useRef(v){return{current:v}};'149+ + 'export function useCallback(f,d){return f};'150+ + 'export function useMemo(f,d){return f()};'151+ + 'export function useContext(c){return undefined};'152+ + 'export default { useState, useEffect, useRef, useCallback, useMemo, useContext }'153+const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:"jsx"}}'154+ + 'export function jsxs(c,p){return{component:c,props:p,_t:"jsxs"}}'155+ + 'export function Fragment(p){return p&&p.children}'156+157+const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)158+const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)159+const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)160+const sdkMod = await import(sdkUrl)161+162+const blobSrc = pluginSrc163+ .replace(/from\\s+['"]@hermes\\/plugin-sdk['"]/g, "from '" + sdkUrl + "'")164+ .replace(/from\\s+['"]react\\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")165+ .replace(/from\\s+['"]react['"]/g, "from '" + reactUrl + "'")166+const mod = await import('data:text/javascript,' + encodeURIComponent(blobSrc))167+const t = mod.__test168+const plugin = mod.default169+170+function walk(node, visit) {171+ if (!node || typeof node !== 'object') return172+ if (node._t) visit(node)173+ var ch = node.props && node.props.children174+ if (Array.isArray(ch)) { for (var i = 0; i < ch.length; i++) walk(ch[i], visit) }175+ else if (ch && typeof ch === 'object') walk(ch, visit)176+}177+function walkDeep(node, visit) {178+ if (!node || typeof node !== 'object') return179+ visit(node)180+ var ch = node.props && node.props.children181+ if (Array.isArray(ch)) { for (var i = 0; i < ch.length; i++) walkDeep(ch[i], visit) }182+ else if (ch && typeof ch === 'object') walkDeep(ch, visit)183+}184+function collectButtons(node) {185+ var out = []186+ walk(node, function(n) { if (n.component === sdkMod.CopyButton) out.push(n.props) })187+ return out188+}189+function findByStyle(node, styleKey, styleValue) {190+ var out = []191+ walk(node, function(n) {192+ var st = n.props && n.props.style193+ if (st && st[styleKey] === styleValue) out.push(n)194+ })195+ return out196+}197+198+const results = {}199+200+// ---- 1.1 source references ----201+const SRC = { id: 'src-ivault', token: 'os_8e5049', name: 'ivault', path: '/repos/ivault' }202+const SRC_DUP = { id: 'src-other', token: 'os_dup01', name: 'Ivault', path: '/repos/other' }203+const SRC_BLANK = { id: 'src-blank', token: 'os_8e5049', name: ' ', path: '/repos/blank' }204+const SRC_ID_ONLY = { id: 'src-id-only', token: '', name: '', path: '/repos/id' }205+const SRC_NOTHING = { id: '', token: '', name: '', path: '/repos/nothing' }206+const SRC_UNIQUE = { id: 'src-unique', token: 'os_u1', name: 'ivault', path: '/repos/u' }207+const SRC_OTHER = { id: 'src-o2', token: 'os_u2', name: 'other', path: '/repos/o' }208+results.src = {}209+results.src.unique = t.buildSourceReference ? t.buildSourceReference(SRC_UNIQUE, [SRC_UNIQUE, SRC_OTHER]) : null210+results.src.caseInsensitiveDup = t.buildSourceReference ? t.buildSourceReference(SRC, [SRC, SRC_DUP]) : null211+results.src.blankName = t.buildSourceReference ? t.buildSourceReference(SRC_BLANK, [SRC_BLANK]) : null212+results.src.tokenFallback = t.buildSourceReference ? t.buildSourceReference(SRC_ID_ONLY, [SRC_ID_ONLY]) : null213+results.src.unavailable = t.buildSourceReference ? t.buildSourceReference(SRC_NOTHING, [SRC_NOTHING]) : null214+215+// ---- 1.2 item references ----216+const ITEM_ACTIVE = { token: 'os_fd3445', name: 'hermes-desktop-ui-corrections', status: 'in-progress' }217+const ITEM_ARCHIVED = { token: 'os_fd3445', name: 'archived-change', status: 'archived' }218+const ITEM_IDEA = { token: 'os_1655dd', name: 'hermes-agentmail-inbox-integration', status: 'ideas' }219+const ITEM_NO_TOKEN = { name: 'no-token-change' }220+const ITEM_DUP_A = { token: 'os_dup', name: 'dup-a' }221+const ITEM_DUP_B = { token: 'os_dup', name: 'dup-b' }222+results.item = {}223+results.item.active = t.buildItemReference ? t.buildItemReference(SRC, ITEM_ACTIVE, [SRC]) : null224+results.item.archived = t.buildItemReference ? t.buildItemReference(SRC, ITEM_ARCHIVED, [SRC]) : null225+results.item.idea = t.buildItemReference ? t.buildItemReference(SRC, ITEM_IDEA, [SRC]) : null226+results.item.missingToken = t.buildItemReference ? t.buildItemReference(SRC, ITEM_NO_TOKEN, [SRC]) : null227+results.item.missingSourceRef = t.buildItemReference ? t.buildItemReference(SRC_NOTHING, ITEM_ACTIVE, [SRC]) : null228+results.item.literalNoEncoding = t.buildItemReference ? t.buildItemReference({ id: 'x', token: 'os_1', name: 'My Source' }, { token: 'tok/a b' }, [{ id: 'x', token: 'os_1', name: 'My Source' }]) : null229+results.item.uniqueToken = t.hasUniqueItemToken ? t.hasUniqueItemToken(ITEM_DUP_A, [ITEM_DUP_A]) : null230+results.item.duplicateToken = t.hasUniqueItemToken ? t.hasUniqueItemToken(ITEM_DUP_A, [ITEM_DUP_A, ITEM_DUP_B]) : null231+results.item.blankToken = t.hasUniqueItemToken ? t.hasUniqueItemToken({ token: '' }, []) : null232+233+// ---- 1.4 session store ----234+results.session = {}235+const freshStore = t.createSessionStore ? t.createSessionStore() : null236+results.session.defaultArchived = !!(freshStore && freshStore.showArchived === true)237+results.session.defaultSelected = !!(freshStore && freshStore.selectedSourceId === null)238+const contribs = []239+const regCtx = {240+ register: function(c) { contribs.push(c) },241+ registerMany: function(arr) { for (var i = 0; i < arr.length; i++) contribs.push(arr[i]) },242+ rest: function() { return Promise.resolve({}) }243+}244+plugin.register(regCtx)245+const route = contribs.find(function(c) { return c.area === 'routes' })246+const firstRender = route && typeof route.render === 'function' ? route.render() : null247+const storeA = firstRender && firstRender.props && firstRender.props.sessionStore248+results.session.storeWired = !!storeA249+if (storeA) {250+ storeA.selectedSourceId = 'os_retained'251+ storeA.showArchived = false252+ const secondRender = route.render()253+ const storeB = secondRender && secondRender.props && secondRender.props.sessionStore254+ results.session.sameStoreAcrossRenders = storeB === storeA255+ results.session.retainedMutated = !!(storeB && storeB.selectedSourceId === 'os_retained' && storeB.showArchived === false)256+}257+results.session.fallback = {}258+const FS = [ { id: 'a', valid: false, name: 'bad' }, { id: 'b', valid: true, name: 'good' } ]259+results.session.fallback.retainedPresentInvalid = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'a') : null260+results.session.fallback.retainedPresentValid = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'b') : null261+results.session.fallback.retainedAbsent = t.resolveRetainedSource ? t.resolveRetainedSource(FS, 'zzz') : null262+results.session.fallback.allInvalidFirstReturned = t.resolveRetainedSource ? t.resolveRetainedSource([{ id: 'x', valid: false }], 'zzz') : null263+results.session.fallback.noSources = t.resolveRetainedSource ? t.resolveRetainedSource([], 'zzz') : null264+265+// ---- 1.5 detail fallbacks + card descriptors ----266+results.detail = {}267+results.detail.titleLoaded = t.detailTitle ? t.detailTitle({ title: 'Loaded' }, { title: 'Summary' }, { name: 'Item' }, 'change') : null268+results.detail.titleSummary = t.detailTitle ? t.detailTitle({}, { title: 'Summary' }, { name: 'Item' }, 'change') : null269+results.detail.titleItem = t.detailTitle ? t.detailTitle({}, {}, { name: 'Item' }, 'change') : null270+results.detail.titleUntitledChange = t.detailTitle ? t.detailTitle({}, {}, {}, 'change') : null271+results.detail.titleUntitledIdea = t.detailTitle ? t.detailTitle({}, {}, {}, 'idea') : null272+results.detail.secondaryLoaded = t.detailSecondaryName ? t.detailSecondaryName({ name: 'Loaded' }, { name: 'Summary' }) : null273+results.detail.secondarySummary = t.detailSecondaryName ? t.detailSecondaryName({}, { name: 'Summary' }) : null274+results.detail.secondaryMissing = t.detailSecondaryName ? t.detailSecondaryName({}, {}) : null275+results.detail.ideaFilename = t.ideaSecondaryName ? t.ideaSecondaryName('hermes-agentmail-inbox-integration') : null276+results.detail.ideaFilenameNoDup = t.ideaSecondaryName ? t.ideaSecondaryName('already.md') : null277+results.detail.ideaFilenameMissing = t.ideaSecondaryName ? t.ideaSecondaryName('') : null278+279+const CARD_ITEM = { token: 'os_fd3445', name: 'hermes-desktop-ui-corrections', title: 'UI Fixes', status: 'in-progress', hasProposal: true, hasTasks: true, hasDesign: false, hasSpecs: true, taskStats: { done: 2, total: 5 }, sequence: 3 }280+const CARD_ALL = [CARD_ITEM, ITEM_IDEA]281+const cardNode = t.BoardCard ? t.BoardCard({ item: CARD_ITEM, source: SRC, allSources: [SRC], allItems: CARD_ALL, onSelect: function() {} }) : null282+results.card = {}283+if (cardNode) {284+ const cbs = collectButtons(cardNode)285+ results.card.copyCount = cbs.length286+ results.card.copyText = cbs[0] && cbs[0].text287+ results.card.copyLabel = cbs[0] && cbs[0].label288+ results.card.copyTitle = cbs[0] && cbs[0].title289+ results.card.copyStopProp = !!(cbs[0] && cbs[0].stopPropagation === true)290+ results.card.tabIndexZero = cardNode.props.tabIndex === 0291+ results.card.roleButton = cardNode.props.role === 'button'292+ results.card.hasKeyDown = typeof cardNode.props.onKeyDown === 'function'293+ results.card.rightGroupAuto = findByStyle(cardNode, 'marginLeft', 'auto').length > 0294+ results.card.tokenVisible = JSON.stringify(cardNode).includes('os_fd3445')295+ results.card.leftGroupHasArtifacts = (function() {296+ var found = false297+ walk(cardNode, function(n) { if (n.component && n.component.name === 'CardArtifacts') found = true })298+ return found299+ })()300+ results.card.leftGroupHasFraction = (function() {301+ var found = false302+ walk(cardNode, function(n) { if (n.component && n.component.name === 'CardTaskFraction') found = true })303+ return found304+ })()305+ results.card.taskFraction = t.CardTaskFraction ? JSON.stringify(t.CardTaskFraction({ item: CARD_ITEM })).includes('✅2/5') : null306+ results.card.progressLeft = (function() {307+ var meta = cardNode.props && cardNode.props.children && cardNode.props.children[1]308+ var rows = meta && meta.props && meta.props.children309+ var footer = Array.isArray(rows) ? rows[1] : null310+ var footerChildren = footer && footer.props && footer.props.children311+ return Array.isArray(footerChildren) && footerChildren[0] && footerChildren[0].component && footerChildren[0].component.name === 'CardTaskFraction'312+ })()313+ results.card.identityRight = (function() {314+ var meta = cardNode.props && cardNode.props.children && cardNode.props.children[1]315+ var rows = meta && meta.props && meta.props.children316+ var footer = Array.isArray(rows) ? rows[1] : null317+ var footerChildren = footer && footer.props && footer.props.children318+ var identity = Array.isArray(footerChildren) ? footerChildren[1] : null319+ return !!(identity && identity.props && identity.props.style && identity.props.style.marginLeft === 'auto')320+ })()321+ results.card.leftBadges = t.CardArtifacts ? JSON.stringify(t.CardArtifacts({ item: CARD_ITEM })).includes('proposal') && JSON.stringify(t.CardArtifacts({ item: CARD_ITEM })).includes('tasks') : null322+ results.card.statusBorder = !!(cardNode.props.style && cardNode.props.style.borderLeftColor)323+ results.card.hoverExactCard = (function() {324+ var cnv = cardNode && cardNode.props && cardNode.props.className325+ if (!cnv) return false326+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)327+ return cls.includes('hover:bg-primary/[0.06]')328+ })()329+ results.card.hoverGenericAbsent = (function() {330+ var cnv = cardNode && cardNode.props && cardNode.props.className331+ if (!cnv) return true332+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)333+ return !cls.includes('--ui-hover-background')334+ })()335+ results.card.baseBgClass = (function() {336+ var cnv = cardNode && cardNode.props && cardNode.props.className337+ if (!cnv) return false338+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)339+ return cls.includes('bg-(--ui-bg-elevated)')340+ })()341+ results.card.noInlineBg = !(cardNode && cardNode.props && cardNode.props.style && cardNode.props.style.background)342+ results.card.focusWashExact = (function() {343+ var cnv = cardNode && cardNode.props && cardNode.props.className344+ if (!cnv) return false345+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)346+ return cls.includes('focus-visible:bg-(--ui-control-hover-background)')347+ })()348+ results.card.noDeadOutline = (function() {349+ var cnv = cardNode && cardNode.props && cardNode.props.className350+ if (!cnv) return true351+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)352+ return !cls.includes('focus-visible:outline-2') && !cls.includes('focus-visible:outline-(--ui-focus-border)')353+ })()354+}355+356+// ---- 1.5b backend sequence metadata compatibility ----357+// The dashboard source summary carries sequence metadata as an object whose358+// ordering value is sequence.position. The Desktop consumer must normalize359+// that boundary for both sorting and card badges, while ignoring malformed360+// metadata rather than rendering [object Object].361+results.sequence = {}362+if (t.sortItems) {363+ const backendSequenceItems = [364+ { name: 'late', title: 'Late', sequence: { position: 2 } },365+ { name: 'early', title: 'Early', sequence: { position: 1 } },366+ ]367+ results.sequence.sorted = t.sortItems(backendSequenceItems).map(function(i) { return i.name })368+}369+if (t.normalizeSequencePosition) {370+ results.sequence.objectPosition = t.normalizeSequencePosition({ position: 7 })371+ results.sequence.malformedPosition = t.normalizeSequencePosition({ position: '7' })372+ results.sequence.missingPosition = t.normalizeSequencePosition({})373+}374+if (t.BoardCard) {375+ const sequenceCard = t.BoardCard({376+ item: { token: 'seq', name: 'sequence-card', title: 'Sequence Card', sequence: { position: 7 } },377+ source: SRC,378+ allSources: [SRC],379+ allItems: [{ token: 'seq' }],380+ onSelect: function() {},381+ })382+ const sequenceBadges = []383+ walk(sequenceCard, function(n) {384+ if (n.component === sdkMod.Badge && n.props) sequenceBadges.push(n.props.children)385+ })386+ results.sequence.backendBadge = sequenceBadges.includes('#7')387+388+ const malformedSequenceCard = t.BoardCard({389+ item: { token: 'bad-seq', name: 'malformed-sequence', title: 'Malformed Sequence', sequence: { position: '7' } },390+ source: SRC,391+ allSources: [SRC],392+ allItems: [{ token: 'bad-seq' }],393+ onSelect: function() {},394+ })395+ const malformedSequenceBadges = []396+ walk(malformedSequenceCard, function(n) {397+ if (n.component === sdkMod.Badge && n.props) malformedSequenceBadges.push(n.props.children)398+ })399+ results.sequence.malformedBadgeCount = malformedSequenceBadges.length400+}401+402+const SPARSE_ITEM = { token: 'os_x1', name: 'bare-card' }403+const sparseNode = t.BoardCard ? t.BoardCard({ item: SPARSE_ITEM, source: SRC, allSources: [SRC], allItems: [SPARSE_ITEM], onSelect: function() {} }) : null404+results.sparse = {}405+if (sparseNode) {406+ const scbs = collectButtons(sparseNode)407+ results.sparse.copyText = scbs[0] && scbs[0].text408+ results.sparse.noPlaceholderFraction = !JSON.stringify(sparseNode).includes('0/0')409+ results.sparse.rightGroupAuto = findByStyle(sparseNode, 'marginLeft', 'auto').length > 0410+ results.sparse.tokenVisible = JSON.stringify(sparseNode).includes('os_x1')411+}412+413+const dupNode = t.BoardCard ? t.BoardCard({ item: ITEM_DUP_A, source: SRC, allSources: [SRC], allItems: [ITEM_DUP_A, ITEM_DUP_B], onSelect: function() {} }) : null414+results.dupCard = {}415+if (dupNode) {416+ results.dupCard.copySuppressed = collectButtons(dupNode).length === 0417+ results.dupCard.tokenStillVisible = JSON.stringify(dupNode).includes('os_dup')418+}419+420+// ---- 1.6 keyboard + collapsed rail ----421+results.keyboard = {}422+let enterCount = 0, spaceCount = 0, otherCount = 0, spacePrevented = false423+if (t.handleActivateKey) {424+ t.handleActivateKey({ key: 'Enter', preventDefault: function() {} }, function() { enterCount++ })425+ t.handleActivateKey({ key: ' ', preventDefault: function() { spacePrevented = true } }, function() { spaceCount++ })426+ t.handleActivateKey({ key: 'a' }, function() { otherCount++ })427+}428+results.keyboard.enterActivatesOnce = enterCount === 1429+results.keyboard.spaceActivatesOnce = spaceCount === 1430+results.keyboard.spacePreventsDefault = spacePrevented === true431+results.keyboard.otherKeysIgnored = otherCount === 0432+433+const railNode = t.BoardColumn ? t.BoardColumn({ status: 'todo', items: [], source: SRC, allSources: [SRC], allItems: [], onSelect: function() {}, collapsed: true, onExpand: function() {} }) : null434+results.rail = {}435+if (railNode) {436+ results.rail.tabIndexZero = railNode.props.tabIndex === 0437+ results.rail.roleButton = railNode.props.role === 'button'438+ results.rail.hasKeyDown = typeof railNode.props.onKeyDown === 'function'439+ results.rail.dotTonePresent = JSON.stringify(railNode).includes('var(--ui-text-secondary)')440+ results.rail.hoverExactRail = (function() {441+ var cnv = railNode && railNode.props && railNode.props.className442+ if (!cnv) return false443+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)444+ return cls.includes('hover:bg-(--ui-bg-quinary)')445+ })()446+ results.rail.hoverGenericAbsent = (function() {447+ var cnv = railNode && railNode.props && railNode.props.className448+ if (!cnv) return true449+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)450+ return !cls.includes('--ui-hover-background')451+ })()452+ results.rail.baseBgClass = (function() {453+ var cnv = railNode && railNode.props && railNode.props.className454+ if (!cnv) return false455+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)456+ return cls.includes('bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]')457+ })()458+ results.rail.noInlineBg = !(railNode && railNode.props && railNode.props.style && railNode.props.style.background)459+ results.rail.focusWashExact = (function() {460+ var cnv = railNode && railNode.props && railNode.props.className461+ if (!cnv) return false462+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)463+ return cls.includes('focus-visible:bg-(--ui-control-hover-background)')464+ })()465+ results.rail.noDeadOutline = (function() {466+ var cnv = railNode && railNode.props && railNode.props.className467+ if (!cnv) return true468+ var cls = Array.isArray(cnv) ? cnv.join(' ') : String(cnv)469+ return !cls.includes('focus-visible:outline-2') && !cls.includes('focus-visible:outline-(--ui-focus-border)')470+ })()471+}472+473+// ---- lane/drawer interaction descriptors ----474+results.expandedHeader = {}475+if (t.BoardColumn) {476+ const headerItems = [{ name: 'item-a', status: 'todo', token: 'a' }]477+ let activated = 0478+ const headerDescriptor = t.BoardColumn({479+ status: 'todo',480+ items: headerItems,481+ source: SRC,482+ allSources: [SRC],483+ allItems: headerItems,484+ onSelect: function() {},485+ collapsed: false,486+ onExpand: function() { activated++ },487+ })488+ var firstButton = null489+ walk(headerDescriptor, function(n) {490+ if (n.component === 'button' && !firstButton) firstButton = n491+ })492+ if (firstButton) {493+ results.expandedHeader.rootIsButton = true494+ results.expandedHeader.typeButton = firstButton.props && firstButton.props.type === 'button'495+ results.expandedHeader.labelStartsCollapse = !!(firstButton.props && firstButton.props['aria-label'] && firstButton.props['aria-label'].startsWith('Collapse'))496+ results.expandedHeader.titleStartsCollapse = !!(firstButton.props && firstButton.props.title && firstButton.props.title.startsWith('Collapse'))497+ results.expandedHeader.hasKeyDown = !!(firstButton.props && typeof firstButton.props.onKeyDown === 'function')498+ results.expandedHeader.hasClick = !!(firstButton.props && typeof firstButton.props.onClick === 'function')499+ results.expandedHeader.focusWashExact = (function() {500+ var className = firstButton.props && firstButton.props.className501+ return !!className && String(className).includes('focus-visible:bg-(--ui-control-hover-background)')502+ })()503+ results.expandedHeader.hasChevronLeft = false504+ results.expandedHeader.hasMinus = false505+ walk(firstButton, function(n) {506+ if (n.component === sdkMod.Codicon) {507+ if (n.props && n.props.name === 'chevron-left') results.expandedHeader.hasChevronLeft = true508+ if (n.props && n.props.name === 'dash') results.expandedHeader.hasMinus = true509+ }510+ })511+ var keyDown = firstButton.props.onKeyDown512+ var click = firstButton.props.onClick513+ function makeKeyEvent(key) {514+ return { key: key, defaultPrevented: false, preventDefault: function() { this.defaultPrevented = true } }515+ }516+ var enterEvent = makeKeyEvent('Enter')517+ keyDown(enterEvent)518+ results.expandedHeader.enterActivatesOnce = activated === 1519+ activated = 0520+ var spaceEvent = makeKeyEvent(' ')521+ keyDown(spaceEvent)522+ results.expandedHeader.spaceActivatesOnce = activated === 1523+ results.expandedHeader.spacePreventsDefault = !!spaceEvent.defaultPrevented524+ activated = 0525+ var otherEvent = makeKeyEvent('Tab')526+ keyDown(otherEvent)527+ results.expandedHeader.otherKeysIgnored = activated === 0528+ activated = 0529+ click()530+ results.expandedHeader.clickActivatesOnce = activated === 1531+ }532+}533+534+results.detailDrawer = {}535+if (t.DetailDrawer) {536+ const drawerDescriptor = t.DetailDrawer({537+ title: 'Title',538+ secondary: 'secondary',539+ itemRef: 'src/item',540+ canCopy: true,541+ token: 'item',542+ onClose: function() {},543+ })544+ if (drawerDescriptor && drawerDescriptor._t) {545+ results.detailDrawer.rootExists = true546+ results.detailDrawer.isAbsoluteRight = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('absolute inset-y-0 right-0'))547+ results.detailDrawer.hasLeftBorder = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('border-l'))548+ results.detailDrawer.widthIsCompact = !!(drawerDescriptor.props && String(drawerDescriptor.props.className).includes('w-[26rem]'))549+ var closeBtn = null550+ var copyBtn = null551+ walk(drawerDescriptor, function(n) {552+ if (n.component === 'button' && !closeBtn) closeBtn = n553+ if (n.component === sdkMod.CopyButton && !copyBtn) copyBtn = n554+ })555+ results.detailDrawer.hasCloseButton = !!closeBtn556+ results.detailDrawer.closeLabel = closeBtn && closeBtn.props && closeBtn.props['aria-label']557+ results.detailDrawer.copyLabel = copyBtn && copyBtn.props && copyBtn.props.label558+ results.detailDrawer.copyStopProp = !!(copyBtn && copyBtn.props && copyBtn.props.stopPropagation)559+ }560+}561+562+results.sourceInvalidation = {}563+if (t.resolveRetainedSource) {564+ const invalidSource = { id: 'src-a', valid: false, error: 'No openspec/ directory found' }565+ results.sourceInvalidation.retainedSameId = t.resolveRetainedSource([invalidSource], 'src-a')566+ results.sourceInvalidation.idPreservedWhenInvalid = results.sourceInvalidation.retainedSameId === 'src-a'567+}568+569+// ---- 1.4b retained-source restoration across the loading window ----570+// Executes the real OpenSpecPage body (not just descriptors) with controllable571+// useQuery/useState/useEffect shims to simulate the cold-cache remount sequence:572+// mount with retained id while sourcesQuery.data is undefined (loading),573+// then refreshed /sources data arrives.574+const shimHooks = globalThis.__openspecHooks = { hookIdx: 0, stateReg: [], effects: [], lastDeps: [] }575+const queryBox = globalThis.__openspecQuery = { data: undefined, isLoading: true, error: null }576+577+const sdkShim2 = [578+ 'cn', 'Codicon', 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',579+ 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',580+ 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',581+ 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'582+].map(n => 'export const ' + n + '=(...a)=>a').join(';')583+ + ';export const useQuery=(o)=>globalThis.__openspecQuery'584+ + ';export const ROUTES_AREA="routes"'585+ + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'586+ + ';export default {}'587+const reactShim2 = 'export function useState(v){const i=globalThis.__openspecHooks.hookIdx++;if(globalThis.__openspecHooks.stateReg[i]===undefined)globalThis.__openspecHooks.stateReg[i]={v:v};const box=globalThis.__openspecHooks.stateReg[i];const set=function(nv){box.v=typeof nv==="function"?nv(box.v):nv};return[box.v,set]};'588+ + 'export function useEffect(f,d){globalThis.__openspecHooks.effects.push({f:f,d:d});return undefined};'589+ + 'export function useRef(v){return{current:v}};'590+ + 'export function useCallback(f,d){return f};'591+ + 'export function useMemo(f,d){return f()};'592+ + 'export function useContext(c){return undefined};'593+ + 'export default {useState:useState,useEffect:useEffect,useRef:useRef,useCallback:useCallback,useMemo:useMemo,useContext:useContext}'594+595+const sdkUrl2 = 'data:text/javascript,' + encodeURIComponent(sdkShim2)596+const reactUrl2 = 'data:text/javascript,' + encodeURIComponent(reactShim2)597+const sdkMod2 = await import(sdkUrl2)598+const blobSrc2 = pluginSrc599+ .replace(/from\\s+['"]@hermes\\/plugin-sdk['"]/g, "from '" + sdkUrl2 + "'")600+ .replace(/from\\s+['"]react\\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")601+ .replace(/from\\s+['"]react['"]/g, "from '" + reactUrl2 + "'")602+603+results.mount = {}604+try {605+ const mod2 = await import('data:text/javascript,' + encodeURIComponent(blobSrc2))606+ const plugin2 = mod2.default607+ const contribs2 = []608+ plugin2.register({ register: function(c) { contribs2.push(c) }, registerMany: function(arr) { for (var i = 0; i < arr.length; i++) contribs2.push(arr[i]) }, rest: function() { return Promise.resolve({}) } })609+ const route2 = contribs2.find(function(c) { return c.area === 'routes' })610+ const page2 = route2.render()611+ const OpenSpecPage2 = page2.component612+ const api2 = {613+ sources: function() { return queryBox.data ? queryBox.data : { sources: [] } },614+ initSource: function() { return Promise.resolve({}) },615+ removeSource: function() { return Promise.resolve({}) },616+ updateSource: function() { return Promise.resolve({}) }617+ }618+619+ function sameDeps(a, b) {620+ if (a === b) return true621+ if (!a || !b || a.length !== b.length) return false622+ for (var i = 0; i < a.length; i++) { if (!Object.is(a[i], b[i])) return false }623+ return true624+ }625+ function renderPage(store) {626+ shimHooks.hookIdx = 0627+ const effStart = shimHooks.effects.length628+ const node = OpenSpecPage2({ api: api2, sessionStore: store })629+ for (var i = effStart; i < shimHooks.effects.length; i++) {630+ const e = shimHooks.effects[i]631+ const prev = shimHooks.lastDeps[i]632+ if (prev === undefined || !sameDeps(prev, e.d)) e.f()633+ }634+ shimHooks.lastDeps = shimHooks.effects.map(function(e) { return e.d })635+ return node636+ }637+ function mountSequence(retainedId, phases) {638+ shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []639+ const store = t.createSessionStore()640+ store.selectedSourceId = retainedId641+ const out = {}642+ for (var p = 0; p < phases.length; p++) {643+ queryBox.data = phases[p].data644+ queryBox.isLoading = phases[p].isLoading !== false645+ queryBox.error = phases[p].error || null646+ const node = renderPage(store)647+ out['phase' + (p + 1)] = {648+ store: store.selectedSourceId,649+ state: shimHooks.stateReg[0] ? shimHooks.stateReg[0].v : undefined,650+ component: node && node.component ? (node.component.name || 'anon') : 'none'651+ }652+ }653+ return out654+ }655+656+ const SRC_A = { id: 'src-a', token: 'os_a', name: 'alpha', valid: false }657+ const SRC_B = { id: 'src-b', token: 'os_b', name: 'beta', valid: true }658+ const SRC_C = { id: 'src-c', token: 'os_c', name: 'gamma', valid: false }659+ const populated = { sources: [SRC_A, SRC_B] }660+ const populatedWithC = { sources: [SRC_A, SRC_B, SRC_C] }661+ const empty = { sources: [] }662+663+ // Retained valid source survives the loading window and stays selected.664+ results.mount.retainedValid = mountSequence('src-b', [665+ { data: undefined, isLoading: true }, { data: populated, isLoading: false }666+ ])667+ // Registered invalid source remains selected after data arrival.668+ results.mount.retainedInvalid = mountSequence('src-c', [669+ { data: undefined, isLoading: true }, { data: populatedWithC, isLoading: false }670+ ])671+ // Absent retained source falls back to first valid against the refreshed list.672+ results.mount.retainedAbsent = mountSequence('zzz', [673+ { data: undefined, isLoading: true }, { data: populated, isLoading: false }674+ ])675+ // Genuinely empty refreshed result resolves to null — only after data arrives.676+ results.mount.genuinelyEmpty = mountSequence('src-b', [677+ { data: undefined, isLoading: true }, { data: empty, isLoading: false }678+ ])679+680+ // First-source registration must remain reachable from a successful empty681+ // registry result; loading and error states still take their existing paths.682+ results.mount.emptySource = {}683+ try {684+ shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []685+ const emptyStore = t.createSessionStore()686+ queryBox.data = empty687+ queryBox.isLoading = false688+ queryBox.error = null689+ const emptyNode = renderPage(emptyStore)690+ const addButtons = []691+ walk(emptyNode, function(n) {692+ if (n.component === sdkMod2.Button && n.props && n.props['aria-label'] === 'Add source') addButtons.push(n)693+ })694+ results.mount.emptySource.addButtonCount = addButtons.length695+ results.mount.emptySource.addButtonReachable = addButtons.length === 1696+ } catch (e) {697+ results.mount.emptySource.__error = (e && e.message ? e.message : String(e)).slice(0, 800)698+ }699+700+ // Add succeeds before conditional initialization fails: retain the701+ // registered source, surface the initialization error, and make one init702+ // request only (the user can recover from the refreshed invalid row).703+ results.sourceAdd = {}704+ if (t.initializeAddedSource) {705+ const initCalls = []706+ const registered = { source: { id: 'src-added', valid: false, error: 'No openspec/ directory found' } }707+ const recovered = await t.initializeAddedSource({708+ initSource: function(sourceId) {709+ initCalls.push(sourceId)710+ return Promise.reject(new Error('initialization denied'))711+ },712+ }, registered)713+ results.sourceAdd.initFailureSourceId = recovered && recovered.source && recovered.source.id714+ results.sourceAdd.initFailureError = recovered && recovered.initError715+ results.sourceAdd.initFailureCalls = initCalls716+ results.sourceAdd.initFailureSingleAttempt = initCalls.length === 1 && initCalls[0] === 'src-added'717+718+ // Exercise OpenSpecPage's saved-result handoff: refresh first, then719+ // restore the returned selection while retaining the init error state.720+ const priorRefetch = queryBox.refetch721+ let parentRefetchCalls = 0722+ const registeredSource = { id: 'src-added', valid: false, error: 'No openspec/ directory found' }723+ queryBox.data = populated724+ queryBox.isLoading = false725+ queryBox.error = null726+ queryBox.refetch = function() {727+ parentRefetchCalls++728+ queryBox.data = { sources: [SRC_A, SRC_B, registeredSource] }729+ return Promise.resolve({ data: queryBox.data })730+ }731+ shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []732+ const parentStore = t.createSessionStore()733+ parentStore.selectedSourceId = SRC_B.id734+ const parentNode = renderPage(parentStore)735+ let addButton = null736+ walk(parentNode, function(n) {737+ if (!addButton && n.component === sdkMod2.Button && n.props && n.props['aria-label'] === 'Add source') addButton = n738+ })739+ if (!addButton || typeof addButton.props.onClick !== 'function') throw new Error('Add source control was not executable')740+ addButton.props.onClick()741+ const dialogNode = renderPage(parentStore)742+ let savedDialog = null743+ walk(dialogNode, function(n) {744+ if (!savedDialog && n.component && n.component.name === 'SourceDialog') savedDialog = n745+ })746+ if (!savedDialog || typeof savedDialog.props.onSaved !== 'function') throw new Error('SourceDialog saved handoff was not reachable')747+ savedDialog.props.onSaved({ source: registeredSource, initError: 'initialization denied' })748+ await new Promise(function(resolve) { setTimeout(resolve, 0) })749+ renderPage(parentStore)750+ results.sourceAdd.parentRefetchCalls = parentRefetchCalls751+ results.sourceAdd.parentSelectedSourceId = parentStore.selectedSourceId752+ results.sourceAdd.parentStateSelectedSourceId = shimHooks.stateReg[0] && shimHooks.stateReg[0].v753+ results.sourceAdd.parentInitError = shimHooks.stateReg[9] && shimHooks.stateReg[9].v754+ queryBox.refetch = priorRefetch755+ }756+757+ // ---- 3.6 remediation: source copy control immediately before source selector ----758+ results.sourceRow = {}759+ try {760+ shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []761+ const store3 = t.createSessionStore()762+ store3.selectedSourceId = SRC_B.id763+ queryBox.data = populated764+ queryBox.isLoading = false765+ queryBox.error = null766+ const rowNode = renderPage(store3)767+ const ordered = []768+ walk(rowNode, function(n) { ordered.push(n) })769+ const copyIdx = ordered.findIndex(function(n) { return n.component === sdkMod2.CopyButton && n.props && n.props.label === 'Copy source reference' })770+ const selectorIdx = ordered.findIndex(function(n) { return n.component === sdkMod2.Select && n.props && n.props.value === SRC_B.id })771+ const selectorTrigger = ordered.find(function(n) { return n.component === sdkMod2.SelectTrigger })772+ results.sourceRow.copyIndex = copyIdx773+ results.sourceRow.selectorIndex = selectorIdx774+ results.sourceRow.copyImmediatelyBeforeSelector = copyIdx >= 0 && selectorIdx >= 0 && copyIdx + 1 === selectorIdx775+ const selectorClassName = selectorTrigger && selectorTrigger.props && selectorTrigger.props.className776+ results.sourceRow.selectorHasImportantHover = String(selectorClassName || '').includes('hover:bg-primary/[0.06]!')777+ results.sourceRow.selectorHasExactHoverPair = String(selectorClassName || '').includes('transition-colors hover:bg-primary/[0.06]')778+ results.sourceRow.selectorHasHoverMarker = !!(selectorTrigger && selectorTrigger.props && selectorTrigger.props['data-openspec-source-trigger'] === 'true')779+ const scopedWashStyle = ordered.find(function(n) {780+ return n.component === 'style' && n.props && String(n.props.children || '').includes('[data-openspec-source-trigger].desktop-input-chrome:hover')781+ })782+ results.sourceRow.scopedHoverWash = !!(scopedWashStyle && String(scopedWashStyle.props.children || '').includes('background-color: var(--chrome-action-hover) !important;'))783+ results.sourceRow.selectorNoInlineBackground = !(selectorTrigger && selectorTrigger.props && selectorTrigger.props.style && selectorTrigger.props.style.background)784+ } catch (e) {785+ results.sourceRow.__error = (e && e.message ? e.message : String(e)).slice(0, 800)786+ }787+ // ---- 3.7 remediation: invalid source transition clears stale detail selection ----788+ results.detailInvalidation = {}789+ try {790+ const validSrcD = { id: 'src-b', token: 'os_b', name: 'beta', valid: true, openspec: { changes: [{ token: 'ch-1', name: 'Change 1', status: 'todo' }], ideas: [] } }791+ const invalidSrcD = { id: 'src-b', token: 'os_b', name: 'beta', valid: false, error: 'No openspec/ directory found' }792+ shimHooks.hookIdx = 0; shimHooks.stateReg = []; shimHooks.effects = []; shimHooks.lastDeps = []793+ const storeD = t.createSessionStore()794+ storeD.selectedSourceId = 'src-b'795+ // Cold mount: loading, then valid data arrives796+ queryBox.data = undefined; queryBox.isLoading = true797+ renderPage(storeD)798+ queryBox.data = { sources: [validSrcD] }; queryBox.isLoading = false799+ renderPage(storeD)800+ // selectedItem is the 4th useState call in OpenSpecPage (index 3).801+ if (!shimHooks.stateReg[3]) throw new Error('selectedItem state slot not found')802+ shimHooks.stateReg[3].v = { item: { token: 'ch-1', name: 'Change 1', _type: 'change' }, sourceId: 'src-b', type: 'change' }803+ const nodeWithDetail = renderPage(storeD)804+ var detailBefore = 0805+ walkDeep(nodeWithDetail, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailBefore++ })806+ results.detailInvalidation.detailOpenBefore = detailBefore > 0807+ // Transition retained source to invalid: resolution effect must clear selectedItem808+ queryBox.data = { sources: [invalidSrcD] }809+ const nodeAfterInvalid = renderPage(storeD)810+ var detailAfterInvalid = 0811+ walkDeep(nodeAfterInvalid, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailAfterInvalid++ })812+ results.detailInvalidation.detailClosedAfterInvalid = detailAfterInvalid === 0813+ results.detailInvalidation.selectedItemCleared = shimHooks.stateReg[3].v === null814+ // Recover validity: drawer must stay closed because state was cleared815+ queryBox.data = { sources: [validSrcD] }816+ const nodeAfterRevalid = renderPage(storeD)817+ var detailAfterRevalid = 0818+ walkDeep(nodeAfterRevalid, function(n) { if (n.component && (n.component.name === 'ChangeDetailDrawer' || n.component.name === 'IdeaDetailDrawer')) detailAfterRevalid++ })819+ results.detailInvalidation.detailStaysClosedAfterRevalidation = detailAfterRevalid === 0820+ } catch (e) {821+ results.detailInvalidation.__error = (e && e.message ? e.message : String(e)).slice(0, 800)822+ }823+} catch (e) {824+ results.mount.__error = (e && e.message ? e.message : String(e)).slice(0, 800)825+}826+827+// ---- WorkView archived-button descriptor/behavior ----828+results.archivedButton = {}829+if (t.WorkView) {830+ const SRC_W = { id: 'src-w', token: 'os_w', name: 'worksource', valid: true, openspec: { changes: [], ideas: [] } }831+ let toggledTo = null832+ const archivedBtnNode = t.WorkView({833+ api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},834+ showArchived: false,835+ onToggleArchived: function(v) { toggledTo = v }836+ })837+ let archivedButton = null838+ walk(archivedBtnNode, function(n) {839+ if (n.component === sdkMod.Button && !archivedButton) archivedButton = n840+ })841+ if (archivedButton) {842+ const abProps = archivedButton.props843+ results.archivedButton.isButton = true844+ results.archivedButton.variantOutline = abProps.variant === 'outline'845+ results.archivedButton.sizeSm = abProps.size === 'sm'846+ results.archivedButton.typeButton = abProps.type === 'button'847+ results.archivedButton.noCustomKeyHandler = typeof abProps.onKeyDown !== 'function'848+ results.archivedButton.ariaPressedFalse = abProps['aria-pressed'] === false849+ results.archivedButton.inactiveHasNoActiveWash = !(abProps.style && abProps.style.backgroundColor)850+ results.archivedButton.labelIncludesArchived = (abProps.children || []).some(function(c) { return c === 'Archived' })851+ results.archivedButton.noCheckboxSibling = (function() {852+ let found = false853+ walk(archivedBtnNode, function(n) { if (n.component === 'input' && n.props && n.props.type === 'checkbox') found = true })854+ return !found855+ })()856+ if (typeof abProps.onClick === 'function') {857+ abProps.onClick()858+ results.archivedButton.clickTogglesOnce = toggledTo === true859+ }860+ // Active state with showArchived=true: verify pressed styling and second click toggles off861+ toggledTo = null862+ const activeNode = t.WorkView({863+ api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},864+ showArchived: true,865+ onToggleArchived: function(v) { toggledTo = v }866+ })867+ let activeBtn = null868+ walk(activeNode, function(n) { if (n.component === sdkMod.Button && !activeBtn) activeBtn = n })869+ if (activeBtn) {870+ const activeProps = activeBtn.props871+ results.archivedButton.ariaPressedTrue = activeProps['aria-pressed'] === true872+ results.archivedButton.activeWash = !!(activeProps.style && activeProps.style.backgroundColor === 'var(--ui-control-active-background)')873+ results.archivedButton.activeCheckWhenPressed = (activeProps.children || []).some(function(c) { return c === '\u2713' })874+ if (typeof activeProps.onClick === 'function') {875+ activeProps.onClick()876+ results.archivedButton.secondClickTogglesOnce = toggledTo === false877+ }878+ }879+880+ // Native <button> activation dispatches one click for Enter and Space;881+ // exercise that browser-level equivalent against the same click handler.882+ function nativeButtonKey(buttonProps, key) {883+ if (!buttonProps || buttonProps.type !== 'button' || typeof buttonProps.onClick !== 'function') return false884+ if (key !== 'Enter' && key !== ' ') return false885+ buttonProps.onClick({ type: 'click', detail: 0 })886+ return true887+ }888+ let enterCalls = 0889+ let enterValue = null890+ const enterNode = t.WorkView({891+ api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},892+ showArchived: false,893+ onToggleArchived: function(v) { enterCalls++; enterValue = v }894+ })895+ let enterBtn = null896+ walk(enterNode, function(n) { if (n.component === sdkMod.Button && !enterBtn) enterBtn = n })897+ const enterActivated = nativeButtonKey(enterBtn && enterBtn.props, 'Enter')898+ results.archivedButton.enterTogglesOnce = enterActivated && enterCalls === 1 && enterValue === true899+900+ let spaceCalls = 0901+ let spaceValue = null902+ const spaceNode = t.WorkView({903+ api: {}, source: SRC_W, sources: [SRC_W], onSelectItem: function() {},904+ showArchived: false,905+ onToggleArchived: function(v) { spaceCalls++; spaceValue = v }906+ })907+ let spaceBtn = null908+ walk(spaceNode, function(n) { if (n.component === sdkMod.Button && !spaceBtn) spaceBtn = n })909+ const spaceActivated = nativeButtonKey(spaceBtn && spaceBtn.props, ' ')910+ results.archivedButton.spaceTogglesOnce = spaceActivated && spaceCalls === 1 && spaceValue === true911+ }912+}913+914+console.log(JSON.stringify(results))915+`916+917+const identity = runIdentityDriver()918+919+// Contract checks920+console.log(' Plugin identity')921+check(r.id === 'openspec', 'Plugin id is "openspec"', r.id ? 'Got: ' + r.id : 'null')922+check(r.name === 'OpenSpec', 'Plugin name is "OpenSpec"', r.name)923+check(r.hasRegister, 'Has register() function')924+925+console.log('\n Contributions')926+check(r.contribCount >= 2, 'At least 2 contributions (got ' + r.contribCount + ')')927+check(r.routeContrib !== undefined, 'Has ROUTES_AREA contribution')928+check(r.navContrib !== undefined, 'Has SIDEBAR_NAV_AREA contribution')929+930+console.log('\n Route and sidebar metadata')931+var rp = r.routeContrib && r.routeContrib.data && r.routeContrib.data.path932+check(rp === '/openspec', 'Route path "/openspec"', rp ? 'Got: ' + rp : 'missing')933+check(r.navContrib && r.navContrib.data && r.navContrib.data.label === 'OpenSpec', 'Sidebar nav label "OpenSpec"')934+check(r.navContrib && r.navContrib.data && r.navContrib.data.codicon != null, 'Sidebar nav has codicon')935+check(r.navContrib && r.navContrib.data && r.navContrib.data.path === '/openspec', 'Sidebar nav path /openspec')936+937+console.log('\n Render function')938+var hasRender = r.routeContribHasRender === true939+check(hasRender, 'Route contribution has render()')940+if (r.renderResult) {941+ check(!r.renderResult.error, 'render() executes without error', r.renderResult.error)942+ check(r.renderResult.returned === true, 'render() returns a value')943+ check(r.renderResult.isJsx === true, 'render() returns jsx descriptor')944+} else if (hasRender) {945+ fail('render() execution', 'renderResult not captured')946+}947+948+// ═══════════════════════════════════════════════════════════════════════════════949+// 2.2 Path builder verification950+// ═══════════════════════════════════════════════════════════════════════════════951+console.log('\n[2.2] Path builders (from __test)')952+953+check(r.__testAvailable, '__test export is available')954+if (r.__testAvailable) {955+ var pb = r.pathBuilders || {}956+ check(pb.sources === '/sources', 'sourcesPath() returns "/sources"', pb.sources)957+958+ check(pb.change === '/sources/src-1/changes/my-change',959+ 'changePath(src-1, my-change) encodes correctly', pb.change)960+ check(pb.changeSpecial === '/sources/src-1/changes/feat%2Fwith%20spaces%20%26%20special',961+ 'changePath encodes special characters', pb.changeSpecial)962+963+ check(pb.idea === '/sources/src-1/ideas/my-idea',964+ 'ideaPath(src-1, my-idea) encodes correctly', pb.idea)965+ check(pb.ideaSpecial === '/sources/src-1/ideas/feat%2Fwith%20spaces%20%26%20special',966+ 'ideaPath encodes special characters', pb.ideaSpecial)967+968+ check(pb.specBrowser === '/sources/src-1/spec-browser',969+ 'specBrowserPath(src-1) returns clean path', pb.specBrowser)970+ check(pb.specBrowserDirty === '/sources/src-1/spec-browser?dirty=true',971+ 'specBrowserPath dirty adds query param', pb.specBrowserDirty)972+973+ check(pb.spec === '/sources/src-1/specs?path=specs%2Freadme.md',974+ 'specPath encodes path parameter', pb.spec)975+ check(pb.specSpecial === '/sources/src-1/specs?path=specs%2Fwith%20spaces.md',976+ 'specPath encodes special chars in path', pb.specSpecial)977+978+ // Verify no malformed paths979+ check(!String(pb.sources).includes('undefined'), 'sources path has no undefined')980+ check(!String(pb.change).includes('undefined'), 'change path has no undefined')981+ check(!String(pb.idea).includes('undefined'), 'idea path has no undefined')982+ check(!String(pb.spec).includes('undefined'), 'spec path has no undefined')983+} else {984+ fail('__test path builders', '__test not available')985+}986+987+// ═══════════════════════════════════════════════════════════════════════════════988+// 3.1 Source selection helpers989+// ═══════════════════════════════════════════════════════════════════════════════990+console.log('\n[3.1] Source selection helpers')991+992+if (r.__testAvailable) {993+ var ss = r.sourceSelection || {}994+ check(ss.firstValid && ss.firstValid.id === 'src-2', 'selectFirstValid picks first valid source',995+ ss.firstValid && ss.firstValid.id ? 'Got: ' + ss.firstValid.id : 'null')996+ check(ss.allInvalid && ss.allInvalid.id === 'src-1', 'selectFirstValid falls back to first when all invalid',997+ ss.allInvalid && ss.allInvalid.id ? 'Got: ' + ss.allInvalid.id : 'null')998+ check(ss.noSources === null || ss.noSources === undefined, 'selectFirstValid returns null for empty array',999+ JSON.stringify(ss.noSources))1000+} else {1001+ fail('Source selection helpers', '__test not available')1002+}1003+1004+// ═══════════════════════════════════════════════════════════════════════════════1005+// 4.1 Board helpers1006+// ═══════════════════════════════════════════════════════════════════════════════1007+console.log('\n[4.1] Board helpers')1008+1009+if (r.__testAvailable) {1010+ var bd = r.board || {}1011+1012+ // Status normalization1013+ console.log(' Status normalization')1014+ check(bd.normalizeInProgress === 'in-progress', 'normalizeStatus("in-progress") = "in-progress"',1015+ String(bd.normalizeInProgress))1016+ check(bd.normalizeDraft === 'draft', 'normalizeStatus("draft") = "draft"',1017+ String(bd.normalizeDraft))1018+ check(bd.normalizeArchived === 'archived', 'normalizeStatus("archived") = "archived"',1019+ String(bd.normalizeArchived))1020+ check(bd.normalizeIdeas === 'ideas', 'normalizeStatus("ideas") = "ideas"',1021+ String(bd.normalizeIdeas))1022+ check(bd.normalizeUnknown != null, 'normalizeStatus handles unknown status',1023+ String(bd.normalizeUnknown))1024+1025+ // Group by status1026+ console.log(' Group by status')1027+ check(Array.isArray(bd.groupKeys) && bd.groupKeys.length >= 4, 'groupByStatus returns groups',1028+ 'Got ' + (bd.groupKeys && bd.groupKeys.length) + ' groups: ' + (bd.groupKeys && bd.groupKeys.join(', ')))1029+ check(bd.groupCounts && bd.groupCounts['in-progress'] === 1, 'Group "in-progress" has 1 item',1030+ JSON.stringify(bd.groupCounts && bd.groupCounts['in-progress']))1031+ check(bd.groupCounts && bd.groupCounts.ideas === 1, 'Group "ideas" has 1 item',1032+ String(bd.groupCounts && bd.groupCounts.ideas))1033+ check(bd.groupCounts && bd.groupCounts.draft === 1, 'Group "draft" has 1 item',1034+ JSON.stringify(bd.groupCounts && bd.groupCounts.draft))1035+ check(bd.groupCounts && bd.groupCounts.done === 1, 'Group "done" has 1 item',1036+ JSON.stringify(bd.groupCounts && bd.groupCounts.done))1037+1038+ // in_progress (underscore) grouping — backend emits this spelling1039+ console.log(' in_progress grouping')1040+ check(bd.inProgressGroupCount === 3, 'All in_progress/in-progress/inprogress variants group to "in-progress"',1041+ 'Got ' + bd.inProgressGroupCount)1042+ check(bd.inProgressNormalized === 'in-progress', 'normalizeStatus("in_progress") = "in-progress"',1043+ JSON.stringify(bd.inProgressNormalized))1044+1045+ // Sort1046+ console.log(' Sort')1047+ check(Array.isArray(bd.sorted) && bd.sorted.length === 3, 'sortItems returns sorted array',1048+ JSON.stringify(bd.sorted))1049+ check(bd.sorted && bd.sorted[0] === 'c', 'sortItems: sequence 1, title Alpha first', JSON.stringify(bd.sorted))1050+1051+ // Backend-shaped sequence metadata: the producer returns {position, ...},1052+ // not a number. Verify the consumer boundary drives both ordering and UI.1053+ const seq = identity && identity.sequence ? identity.sequence : {}1054+ check(Array.isArray(seq.sorted) && seq.sorted.join(',') === 'early,late',1055+ 'sortItems orders backend sequence.position metadata', JSON.stringify(seq.sorted))1056+ check(seq.objectPosition === 7, 'sequence.position normalizes to numeric position', JSON.stringify(seq.objectPosition))1057+ check(seq.malformedPosition === null && seq.missingPosition === null,1058+ 'Malformed or absent sequence metadata is ignored', JSON.stringify({ malformed: seq.malformedPosition, missing: seq.missingPosition }))1059+ check(seq.backendBadge === true, 'BoardCard renders backend sequence.position badge', JSON.stringify(seq.backendBadge))1060+ check(seq.malformedBadgeCount === 0, 'BoardCard omits malformed sequence badge', JSON.stringify(seq.malformedBadgeCount))1061+1062+ // Filter1063+ console.log(' Filter')1064+ check(bd.filteredAlpha && bd.filteredAlpha.length === 1 && bd.filteredAlpha[0] === 'alpha-change',1065+ 'filterItems by title substring', JSON.stringify(bd.filteredAlpha))1066+ check(bd.filteredBeta && bd.filteredBeta.length === 1 && bd.filteredBeta[0] === 'beta-fix',1067+ 'filterItems by token', JSON.stringify(bd.filteredBeta))1068+ check(bd.filteredEmpty && bd.filteredEmpty.length === 0, 'filterItems no match returns empty',1069+ JSON.stringify(bd.filteredEmpty))1070+ check(bd.filteredClear && bd.filteredClear.length === 3, 'filterItems empty string returns all',1071+ JSON.stringify(bd.filteredClear))1072+1073+ // Archived toggle1074+ console.log(' Archived toggle')1075+ check(bd.withArchived === false, 'toggleArchived(true) = false', JSON.stringify(bd.withArchived))1076+ check(bd.withoutArchived === true, 'toggleArchived(false) = true', JSON.stringify(bd.withoutArchived))1077+} else {1078+ fail('Board helpers', '__test not available')1079+}1080+1081+// ═══════════════════════════════════════════════════════════════════════════════1082+// 5.1 Adapter and detail helpers1083+// ═══════════════════════════════════════════════════════════════════════════════1084+console.log('\n[5.1] Detail helpers')1085+1086+if (r.__testAvailable) {1087+ // Task parser1088+ var t = r.tasks || {}1089+ console.log(' Task parser')1090+ check(t.total === 4, 'parseTasks total count = 4', JSON.stringify(t.total))1091+ check(t.done === 2, 'parseTasks done count = 2', JSON.stringify(t.done))1092+ check(t.sections != null, 'parseTasks returns sections', JSON.stringify(t.sections))1093+1094+ // Markdown parser1095+ var md = r.markdown || {}1096+ console.log(' Markdown parser')1097+ check(md.isArray === true, 'renderMarkdown returns array', JSON.stringify(md.isArray))1098+ check(md.length > 0, 'renderMarkdown produces elements', JSON.stringify(md.length))1099+1100+ // State classifier1101+ console.log(' State classifier')1102+ check(md.stateLoading === 'loading', 'classifyState loading', JSON.stringify(md.stateLoading))1103+ check(md.stateError === 'error', 'classifyState error', JSON.stringify(md.stateError))1104+ check(md.stateEmpty === 'empty', 'classifyState empty', JSON.stringify(md.stateEmpty))1105+ check(md.stateContent === 'content', 'classifyState content', JSON.stringify(md.stateContent))1106+} else {1107+ fail('Detail helpers', '__test not available')1108+}1109+1110+// ═══════════════════════════════════════════════════════════════════════════════1111+// 6.1 Spec-browser helpers1112+// ═══════════════════════════════════════════════════════════════════════════════1113+console.log('\n[6.1] Spec-browser helpers')1114+1115+if (r.__testAvailable) {1116+ var df = r.diff || {}1117+1118+ console.log(' Diff line classifier')1119+ check(df.added === 'added', 'classifyDiffLine("+...") = "added"', JSON.stringify(df.added))1120+ check(df.removed === 'removed', 'classifyDiffLine("-...") = "removed"', JSON.stringify(df.removed))1121+ check(df.hunk === 'hunk', 'classifyDiffLine("@@...") = "hunk"', JSON.stringify(df.hunk))1122+ check(df.context === 'context', 'classifyDiffLine(" ...") = "context"', JSON.stringify(df.context))1123+1124+ console.log(' Diff mode availability')1125+ check(df.hasSemantic === true, 'hasDiffMode semantic available', JSON.stringify(df.hasSemantic))1126+ check(df.hasSplit === true, 'hasDiffMode split available', JSON.stringify(df.hasSplit))1127+ check(df.hasRaw === true, 'hasDiffMode raw available', JSON.stringify(df.hasRaw))1128+ check(df.noSemantic === false, 'hasDiffMode semantic unavailable when absent', JSON.stringify(df.noSemantic))1129+1130+ // URL safety1131+ console.log(' URL safety')1132+ var us = r.urlSafety || {}1133+ check(us.httpsAllowed === true, 'isSafeUrl allows https', JSON.stringify(us.httpsAllowed))1134+ check(us.httpAllowed === true, 'isSafeUrl allows http', JSON.stringify(us.httpAllowed))1135+ check(us.relativeAllowed === true, 'isSafeUrl allows relative paths', JSON.stringify(us.relativeAllowed))1136+ check(us.fragmentAllowed === true, 'isSafeUrl allows fragment links', JSON.stringify(us.fragmentAllowed))1137+ check(us.queryAllowed === true, 'isSafeUrl allows query-only links', JSON.stringify(us.queryAllowed))1138+ check(us.mailtoAllowed === true, 'isSafeUrl allows mailto', JSON.stringify(us.mailtoAllowed))1139+ check(us.javascriptBlocked === true, 'isSafeUrl blocks javascript:', JSON.stringify(us.javascriptBlocked))1140+ check(us.dataBlocked === true, 'isSafeUrl blocks data:', JSON.stringify(us.dataBlocked))1141+ check(us.fileBlocked === true, 'isSafeUrl blocks file:', JSON.stringify(us.fileBlocked))1142+ check(us.vbscriptBlocked === true, 'isSafeUrl blocks vbscript:', JSON.stringify(us.vbscriptBlocked))1143+ check(us.emptyBlocked === true, 'isSafeUrl blocks empty string', JSON.stringify(us.emptyBlocked))1144+ check(us.nullBlocked === true, 'isSafeUrl blocks null', JSON.stringify(us.nullBlocked))1145+ // Whitespace/control obfuscation1146+ check(us.wsJavascriptBlocked === true, 'isSafeUrl blocks whitespace-prefixed javascript:',1147+ JSON.stringify(us.wsJavascriptBlocked))1148+ check(us.tabJavascriptBlocked === true, 'isSafeUrl blocks tab-prefixed javascript:',1149+ JSON.stringify(us.tabJavascriptBlocked))1150+ check(us.newlineJavascriptBlocked === true, 'isSafeUrl blocks newline-prefixed javascript:',1151+ JSON.stringify(us.newlineJavascriptBlocked))1152+ check(us.controlJavascriptBlocked === true, 'isSafeUrl blocks control-char-prefixed javascript:',1153+ JSON.stringify(us.controlJavascriptBlocked))1154+ check(us.uppercaseJavascriptBlocked === true, 'isSafeUrl blocks JAVASCRIPT: (uppercase)',1155+ JSON.stringify(us.uppercaseJavascriptBlocked))1156+ check(us.mixedCaseDataBlocked === true, 'isSafeUrl blocks DaTa: (mixed case)',1157+ JSON.stringify(us.mixedCaseDataBlocked))1158+1159+ // __test is frozen1160+ console.log(' __test frozen')1161+ check(r.testFrozen === true, '__test object is frozen', JSON.stringify(r.testFrozen))1162+1163+ // renderInline returns JSX (not plain objects) — verified via executable render checks1164+ console.log(' renderInline executable checks')1165+ var ix = r.inlineJsx || {}1166+ check(ix.isArray === true, 'renderMarkdown returns array for inline test')1167+ check(ix.hasParagraph === true, 'renderMarkdown produces paragraph with inline text')1168+ // EXECUTABLE: MarkdownElement returns JSX descriptors, not parser objects1169+ check(ix.blocksRenderToJsx === true, 'MarkdownElement renders all blocks to JSX (not parser objects)',1170+ ix.anyBlockFailed ? 'Block failed to render' : undefined)1171+ // EXECUTABLE: renderInline returns JSX elements for inline markers1172+ check(ix.inline && ix.inline.isArray === true, 'renderInline returns array')1173+ check(ix.inline && ix.inline.allJsx === true, 'renderInline returns JSX elements (not plain objects)')1174+ // EXECUTABLE: mixed bold/code/link each produce JSX1175+ check(ix.mixed && ix.mixed.hasThree === true, 'renderInline produces 3 JSX elements for bold+code+link',1176+ ix.mixed ? 'Got ' + ix.mixed.count : 'no data')1177+} else {1178+ fail('Spec-browser helpers', '__test not available')1179+}1180+1181+// ═══════════════════════════════════════════════════════════════════════════════1182+// Behavioral fixture tests — backend contract alignment1183+// ═══════════════════════════════════════════════════════════════════════════════1184+console.log('\n[Behavioral] Backend contract fixture tests')1185+1186+if (r.__testAvailable) {1187+ // 1. Semantic groups normalization1188+ var sg = r.semanticGroups || {}1189+ console.log(' Semantic groups normalization')1190+ check(sg.isArray === true, 'normalizeSemanticGroups returns array', JSON.stringify(sg.isArray))1191+ check(sg.length === 3, 'normalizeSemanticGroups returns 3 items (added+modified+removed)', 'Got ' + sg.length)1192+ check(sg.addedLabel === 'Added', 'First item labeled "Added"', JSON.stringify(sg.addedLabel))1193+ check(sg.modifiedLabel === 'Modified', 'Second item labeled "Modified"', JSON.stringify(sg.modifiedLabel))1194+ check(sg.removedLabel === 'Removed', 'Third item labeled "Removed"', JSON.stringify(sg.removedLabel))1195+ check(sg.noObjectString === true, 'No [object Object] in descriptions/before/after',1196+ !sg.noObjectString ? 'Found [object Object]' : undefined)1197+ check(sg.addedName === 'Auth', 'Added item preserves name', JSON.stringify(sg.addedName))1198+ check(sg.modifiedName === 'Data Model', 'Modified item preserves name', JSON.stringify(sg.modifiedName))1199+ check(sg.removedName === 'Legacy', 'Removed item preserves name', JSON.stringify(sg.removedName))1200+ check(sg.beforeDesc === 'Old schema with 3 fields', 'Modified before extracted from object', JSON.stringify(sg.beforeDesc))1201+ check(sg.afterDesc === 'New schema with 5 fields', 'Modified after extracted from object', JSON.stringify(sg.afterDesc))1202+ check(sg.noCrash === true, 'normalizeSemanticGroups does not crash on backend shape')1203+ check(sg.oldCodeWouldThrow === true, 'Old code (data.requirements.map) would throw on real backend shape')1204+1205+ // 2. Card artifacts from has* booleans1206+ var ca = r.cardArtifacts || {}1207+ console.log(' Card artifacts from has* booleans')1208+ check(ca.hasProposal === true, 'hasProposal boolean present', JSON.stringify(ca.hasProposal))1209+ check(ca.hasTasks === true, 'hasTasks boolean present', JSON.stringify(ca.hasTasks))1210+ check(ca.hasDesign === true, 'hasDesign boolean present', JSON.stringify(ca.hasDesign))1211+ check(ca.hasSpecs === true, 'hasSpecs boolean present', JSON.stringify(ca.hasSpecs))1212+ check(ca.noArtifactsField === true, 'No artifacts field on source summary')1213+ check(ca.noArtifactNamesField === true, 'No artifactNames field on source summary')1214+1215+ // 3. effectiveDiffMode fallback1216+ var ed = r.effectiveDiffMode || {}1217+ console.log(' Effective diff mode fallback')1218+ check(ed.semanticAvailable === 'semantic', 'Returns semantic when available', JSON.stringify(ed.semanticAvailable))1219+ check(ed.fallbackToSplit === 'split', 'Falls back to split when semantic unavailable', JSON.stringify(ed.fallbackToSplit))1220+ check(ed.fallbackToRaw === 'raw', 'Falls back to raw when only raw available', JSON.stringify(ed.fallbackToRaw))1221+ check(ed.nothingAvailable === 'semantic', 'Returns desired when nothing available', JSON.stringify(ed.nothingAvailable))1222+ check(ed.splitToRaw === 'raw', 'Split desired falls back to raw', JSON.stringify(ed.splitToRaw))1223+} else {1224+ fail('Backend contract fixture tests', '__test not available')1225+}1226+1227+// ═══════════════════════════════════════════════════════════════════════════════1228+// Canonical status tone mapping and style shape tests1229+// ═══════════════════════════════════════════════════════════════════════════════1230+console.log('\n[Canonical] Status tone mapping and style shape')1231+1232+if (r.__testAvailable) {1233+ var st = r.statusTone || {}1234+ console.log(' STATUS_TONE mapping')1235+ check(st.ideas === 'var(--ui-text-tertiary)', 'ideas -> triage tone', JSON.stringify(st.ideas))1236+ check(st.draft === '#a78bfa', 'draft -> scheduled tone', JSON.stringify(st.draft))1237+ check(st.todo === 'var(--ui-text-secondary)', 'todo -> todo tone', JSON.stringify(st.todo))1238+ check(st.inProgress === '#34d399', 'in-progress -> running tone', JSON.stringify(st.inProgress))1239+ check(st.done === 'var(--ui-text-tertiary)', 'done -> done tone', JSON.stringify(st.done))1240+ check(st.archived === 'var(--ui-text-quaternary)', 'archived -> archived tone', JSON.stringify(st.archived))1241+ check(st.unknown === 'var(--ui-text-secondary)', 'statusTone falls back to secondary for unknown', JSON.stringify(st.unknown))1242+1243+ console.log(' STATUS_TONE values')1244+ check(st.ideasIsVar === true, 'ideas tone uses var(--ui-text-tertiary)', JSON.stringify(st.ideasIsVar))1245+ check(st.draftIsPurple === true, 'draft tone is #a78bfa', JSON.stringify(st.draftIsPurple))1246+ check(st.todoIsVar === true, 'todo tone uses var(--ui-text-secondary)', JSON.stringify(st.todoIsVar))1247+ check(st.inProgressIsGreen === true, 'in-progress tone is #34d399', JSON.stringify(st.inProgressIsGreen))1248+ check(st.doneIsVar === true, 'done tone uses var(--ui-text-tertiary)', JSON.stringify(st.doneIsVar))1249+ check(st.archivedIsVar === true, 'archived tone uses var(--ui-text-quaternary)', JSON.stringify(st.archivedIsVar))1250+1251+ // Style shape checks1252+ var ll = r.laneLayout || {}1253+ console.log(' Canonical lane style shape')1254+ check(ll.expandedHasBg === true, 'Expanded lane uses color-mix canonical wash', JSON.stringify(ll.expandedHasBg))1255+ check(ll.expandedHasRounded === true, 'Expanded lane has 8px border-radius', JSON.stringify(ll.expandedHasRounded))1256+ check(ll.expandedHasPadding === true, 'Expanded lane has 8px padding', JSON.stringify(ll.expandedHasPadding))1257+ check(ll.railWidth === '32px', 'Rail width is 32px', JSON.stringify(ll.railWidth))1258+ check(ll.railFlexShrink === 0, 'Rail flex-shrink is 0', JSON.stringify(ll.railFlexShrink))1259+1260+ // Static source checks for canonical styling1261+ console.log(' Canonical source patterns')1262+ check(source.includes('statusTone'), 'statusTone function used in source')1263+ check(source.includes('STATUS_TONE'), 'STATUS_TONE map present in source')1264+ check(source.includes("borderLeftColor: statusTone(status)"), 'BoardCard uses statusTone for left border color')1265+ check(source.includes("borderLeftWidth: '2px'"), 'BoardCard has 2px left border')1266+ check(!source.includes("background: 'var(--ui-bg-elevated)'"), 'BoardCard has no inline elevated background (class-based surface)')1267+ check(source.includes('bg-(--ui-bg-elevated)'), 'BoardCard base class is exact compiled bg-(--ui-bg-elevated)')1268+ check(source.includes('border-(--ui-stroke-tertiary)'), 'BoardCard uses canonical stroke-tertiary border class')1269+ check(source.includes("backgroundColor: statusTone(status)"), 'Lane/rail status dot uses statusTone')1270+ check(!source.includes('#1a1a2e'), 'No hardcoded blue rail background')1271+} else {1272+ fail('Canonical status tone mapping', '__test not available')1273+}1274+1275+// ═══════════════════════════════════════════════════════════════════════════════1276+// Focused descriptor tests — BoardColumn and BoardCard contracts (source-level)1277+// ═══════════════════════════════════════════════════════════════════════════════1278+console.log('\n[Descriptor] BoardColumn and BoardCard contracts')1279+1280+// --- Collapsed rail ---1281+console.log(' Collapsed rail')1282+check(source.includes("writingMode: 'vertical-rl'"), 'Collapsed rail label uses writingMode vertical-rl')1283+check(source.includes("display: 'grid'") && source.includes("height: '20px'"), 'Status dot occupies 20px grid header slot')1284+check(source.includes("fontVariantNumeric: 'tabular-nums'"), 'Bare count uses tabular-nums')1285+check(!source.includes("justifyContent: 'center'"), 'Rail has no justifyContent center (top-flowing)')1286+1287+// --- Expanded lane header ---1288+console.log(' Expanded lane header')1289+check(!source.includes("'(' + count + ')'"), 'Expanded header count has no parentheses (bare)')1290+check(source.includes("fontVariantNumeric: 'tabular-nums'"), 'Expanded header count uses tabular-nums')1291+// NEW: lane-drawer-interactions contract assertions (source-level, RED before implementation)1292+check(source.includes("name: 'chevron-left'"), 'Expanded lane header uses Codicon chevron-left')1293+{1294+const colStart = source.indexOf('function BoardColumn')1295+const colEnd = source.indexOf('\nfunction ', colStart + 1)1296+const colSec = source.slice(colStart, colEnd > 0 ? colEnd : source.length)1297+// The expanded header must be one native <button> spanning the full header.1298+const headerButtonMatches = colSec.match(/jsx\(\s*['"]button['"]\s*,\s*\{/g) || colSec.match(/jsxs\(\s*['"]button['"]\s*,\s*\{/g) || colSec.match(/jsx\('button',\s*\{/g) || colSec.match(/jsxs\('button',\s*\{/g)1299+check(headerButtonMatches && headerButtonMatches.length === 1, 'Expanded header is a single native button', JSON.stringify(headerButtonMatches))1300+check(colSec.includes("type: 'button'"), 'Expanded header button has type=button')1301+check(colSec.includes('handleActivateKey'), 'Expanded header button uses handleActivateKey for keyboard')1302+check(colSec.includes("'aria-label': 'Collapse '"), 'Expanded header button has accessible collapse label')1303+check(colSec.includes("title: 'Collapse '"), 'Expanded header button has collapse title')1304+check(!colSec.includes("'\\u2013'"), 'Expanded header does not render raw minus glyph')1305+check(colSec.match(/onClick:\s*onExpand[^}]*title:/) || colSec.match(/title:[^}]*onClick:\s*onExpand/), 'Expanded header button uses native button activation')1306+}1307+// NEW: detail drawer contract assertions (source-level)1308+{1309+ const pageStart = source.indexOf('function OpenSpecPage')1310+ const pageEnd = source.indexOf('\nfunction ', pageStart + 1)1311+ const pageSec = source.slice(pageStart, pageEnd > 0 ? pageEnd : source.length)1312+ check(pageSec.includes('DetailDrawer'), 'OpenSpecPage renders a DetailDrawer for selected items')1313+ check(source.includes('className: cn(\'absolute inset-y-0 right-0'), 'Drawer root is positioned absolute inset-y-0 right-0')1314+ check(!pageSec.includes('ChangeDetailDialog') && !pageSec.includes('IdeaDetailDialog'), 'Change/Idea detail is not wrapped in Dialog')1315+ check(!pageSec.includes('jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DetailDrawer'), 'Page does not wrap drawer in Dialog')1316+ check(pageSec.includes('selectedItem && selectedItem.sourceId === selectedSourceId'), 'Detail visibility is still gated on selected source')1317+ check(pageSec.includes('setSelectedItem(null)'), 'Close control clears selected item')1318+ check(source.includes("'aria-label': 'Close detail'"), 'Drawer has accessible close button label')1319+ check(source.includes('w-[26rem]'), 'Drawer uses Kanban-aligned compact width')1320+ check(source.includes('border-l'), 'Drawer has left border')1321+ check(source.includes("overflowY: 'auto'"), 'Drawer body supports vertical scrolling')1322+}1323+// NEW: detail drawer class tokens are present on the DetailDrawer root1324+{1325+ const ddStart = source.indexOf('function DetailDrawer')1326+ const ddEnd = source.indexOf('\nfunction ', ddStart + 1)1327+ const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)1328+ check(ddSec.includes('w-[26rem]'), 'Drawer root uses compact width class')1329+ check(ddSec.includes('border-l'), 'Drawer root has left border class')1330+}1331+// NEW: source-management dialogs remain modal1332+{1333+ const pageStart = source.indexOf('function OpenSpecPage')1334+ const pageEnd = source.indexOf('\nfunction ', pageStart + 1)1335+ const pageSec = source.slice(pageStart, pageEnd > 0 ? pageEnd : source.length)1336+ check(pageSec.includes("jsx(SourceDialog") || pageSec.includes("jsxs(SourceDialog"), 'Source add/edit dialog is still rendered')1337+ check(pageSec.includes("jsx(Dialog, { open: true, onOpenChange: function() { if (!removeBusy)"), 'Remove source confirmation remains a modal Dialog')1338+ check(!pageSec.includes('SourceDialog') || pageSec.includes('SourceDialog') && (pageSec.includes('jsx(SourceDialog') || pageSec.includes('jsxs(SourceDialog')), 'SourceDialog is rendered directly (not inside drawer)')1339+}1340+// NEW: detail component names are drawer-specific1341+{1342+ check(source.includes('function ChangeDetailDrawer'), 'Change detail component exists')1343+ check(source.includes('function IdeaDetailDrawer'), 'Idea detail component exists')1344+ check(!source.includes('function ChangeDetailDialog'), 'Change detail component renamed to drawer')1345+ check(!source.includes('function IdeaDetailDialog'), 'Idea detail component renamed to drawer')1346+ // Loading/error detail states used Dialog previously; ensure they are gone.1347+ check(!source.includes("jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DialogContent, { children: jsx(Loader"), 'Detail loading no longer uses modal Dialog')1348+ check(!source.includes("jsx(Dialog, { open: true, onOpenChange: onClose, children: jsx(DialogContent, { children: jsx(RetryErrorState"), 'Detail error no longer uses modal Dialog')1349+}1350+1351+// --- BoardCard ---1352+console.log(' BoardCard')1353+check(source.includes('border-(--ui-stroke-tertiary)'), 'Card uses canonical stroke-tertiary border class')1354+check(!source.includes("background: 'var(--ui-bg-elevated)'"), 'Card background is class-based (no inline elevated surface)')1355+check(source.includes('bg-(--ui-bg-elevated)'), 'Card base class is exact compiled bg-(--ui-bg-elevated)')1356+check(source.includes("borderLeftWidth: '2px'"), 'Card has 2px tone left border')1357+check(source.includes("borderLeftColor: statusTone(status)"), 'Card left border uses statusTone')1358+{1359+ var bcStart = source.indexOf('function BoardCard')1360+ var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1361+ var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1362+ check(bcSection.includes('px-3 pt-3 pb-0'), 'Card removes excess bottom padding')1363+ check(!bcSection.includes('pb-2'), 'Card no longer uses previous bottom padding')1364+ check(bcSection.includes("flexDirection: 'column'"), 'Card footer uses explicit stacked rows')1365+ check(source.includes("children: '✅' + done + '/' + total"), 'Card task fraction uses compact checkmark format')1366+}1367+1368+// --- BoardCard sequence badge guard ---1369+console.log(' BoardCard sequence badge guard')1370+{1371+ // Extract the BoardCard function body to test sequence handling1372+ var bcStart = source.indexOf('function BoardCard')1373+ var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1374+ var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1375+1376+ // The consumer boundary normalizes backend objects and rejects non-finite1377+ // or malformed values — no [object Object] substring anywhere.1378+ check(bcSection.includes('normalizeSequencePosition'), 'Sequence badge uses sequence-position normalization')1379+ check(source.includes('function normalizeSequencePosition'), 'Sequence normalization helper validates numeric positions')1380+1381+ // Verify the guard pattern is correct by testing the expression directly1382+ var seqGuard = function(seq) {1383+ return typeof seq === 'number' && Number.isFinite(seq) ? '#' + seq : null1384+ }1385+ check(seqGuard(42) === '#42', 'sequence=42 renders #42')1386+ check(seqGuard(0) === '#0', 'sequence=0 renders #0')1387+ check(seqGuard(-3) === '#-3', 'sequence=-3 renders #-3')1388+ check(seqGuard(NaN) === null, 'sequence=NaN renders null')1389+ check(seqGuard(Infinity) === null, 'sequence=Infinity renders null')1390+ check(seqGuard({}) === null, 'sequence={} renders null (no [object Object])')1391+ check(seqGuard([]) === null, 'sequence=[] renders null')1392+ check(seqGuard('5') === null, 'sequence="5" renders null (string)')1393+ check(seqGuard(null) === null, 'sequence=null renders null')1394+ check(seqGuard(undefined) === null, 'sequence=undefined renders null')1395+}1396+1397+// ═══════════════════════════════════════════════════════════════════════════════1398+// Static analysis checks — SDK usage patterns1399+// ═══════════════════════════════════════════════════════════════════════════════1400+console.log('\n[Static] Source analysis')1401+1402+// Loading/error/empty state transitions1403+check(source.includes('isLoading'), 'Checks isLoading state')1404+check(source.includes('error'), 'Checks error state')1405+check(source.includes('Loader'), 'Uses Loader component')1406+check(source.includes('ErrorState'), 'Uses ErrorState component')1407+check(source.includes('EmptyState'), 'Uses EmptyState component')1408+1409+// Source quality1410+check(!source.includes('console.log'), 'No debug console.log')1411+check(!source.includes('// TODO'), 'No TODO comments')1412+check(!source.includes('alert('), 'No alert() calls')1413+1414+// Retry handlers on error states1415+check(source.includes('query.refetch()'), 'query.refetch() retry handler present')1416+check(source.includes('specQuery.refetch()'), 'specQuery.refetch() retry handler present')1417+// CRITICAL: No ErrorState uses onRetry (SDK does not accept it)1418+// Retry is handled by RetryErrorState wrapper with Button child1419+1420+// Stale detail guard — item bound to source at click1421+check(source.includes('selectedItem.sourceId === selectedSourceId'), 'Detail dialog has source-matching gate')1422+check(source.includes('{ item: item, sourceId: selectedSourceId, type:'), 'onSelectItem binds item to source')1423+check(!source.includes('detailType'), 'Old detailType state removed')1424+1425+// normalizeSemanticGroups exists and handles backend shape1426+check(source.includes('function normalizeSemanticGroups'), 'normalizeSemanticGroups function defined')1427+check(source.includes('data.requirements'), 'Reads data.requirements (backend contract)')1428+check(source.includes('reqs.added'), 'Extracts added array from requirements')1429+check(source.includes('reqs.modified'), 'Extracts modified array from requirements')1430+check(source.includes('reqs.removed'), 'Extracts removed array from requirements')1431+1432+// effectiveDiffMode fallback1433+check(source.includes('function effectiveDiffMode'), 'effectiveDiffMode function defined')1434+1435+// CardArtifacts reads has* booleans1436+check(source.includes('item.hasProposal'), 'CardArtifacts reads hasProposal')1437+check(source.includes('item.hasTasks'), 'CardArtifacts reads hasTasks')1438+check(source.includes('item.hasDesign'), 'CardArtifacts reads hasDesign')1439+check(source.includes('item.hasSpecs'), 'CardArtifacts reads hasSpecs')1440+check(!source.includes('item.artifacts || item.artifactNames'), 'Old artifacts/artifactNames pattern removed')1441+1442+// ═══════════════════════════════════════════════════════════════════════════════1443+// Structural pattern checks — SDK compliance1444+// ═══════════════════════════════════════════════════════════════════════════════1445+console.log('\n[Structural] SDK usage pattern checks')1446+1447+// 1. DialogContent: must be imported and used inside Dialog (source + remove confirm)1448+check(source.includes('DialogContent'), 'DialogContent is imported from SDK')1449+{1450+ // Find DialogContent usage inside Dialog children (not just import). There1451+ // must be exactly two modal usages: SourceDialog and remove confirmation.1452+ const jsxMatches = source.match(/jsx\(DialogContent/g) || []1453+ const jsxsMatches = source.match(/jsxs\(DialogContent/g) || []1454+ const dialogContentUsage = jsxMatches.concat(jsxsMatches)1455+ check(dialogContentUsage.length === 2,1456+ 'DialogContent used in exactly two modal dialogs (source dialog and remove confirmation)',1457+ dialogContentUsage ? 'Found ' + dialogContentUsage.length + ' usages' : 'No usages found')1458+}1459+1460+// 2. Select compound pattern: SelectTrigger, SelectValue, SelectContent, SelectItem1461+check(source.includes('SelectTrigger'), 'SelectTrigger imported from SDK')1462+check(source.includes('SelectValue'), 'SelectValue imported from SDK')1463+check(source.includes('SelectContent'), 'SelectContent imported from SDK')1464+check(source.includes('SelectItem'), 'SelectItem imported from SDK')1465+// Verify Select.Item is NOT used (old pattern)1466+check(!source.includes('Select.Item'), 'No Select.Item (old pattern removed)')1467+// Verify SelectTrigger is used in render1468+{1469+ const selectTriggerUsage = source.match(/jsx\(SelectTrigger/g)1470+ check(selectTriggerUsage && selectTriggerUsage.length >= 1,1471+ 'SelectTrigger used in render', selectTriggerUsage ? 'Found' : 'Not found')1472+}1473+1474+// 3. SegmentedControl: options+value+onChange pattern (no SegmentedControl.Item)1475+check(source.includes('onChange: setView'), 'SegmentedControl uses onChange prop')1476+check(source.includes('options:'), 'SegmentedControl uses options prop')1477+check(!source.includes('SegmentedControl.Item'), 'No SegmentedControl.Item (old pattern removed)')1478+check(!source.includes('onValueChange: setView'), 'SegmentedControl does not use onValueChange')1479+1480+// 4. Tabs: TabsList + TabsTrigger (no Tabs.Content, no Tabs.List, no Tabs.Trigger)1481+check(source.includes('TabsList'), 'TabsList imported from SDK')1482+check(source.includes('TabsTrigger'), 'TabsTrigger imported from SDK')1483+check(!source.includes('Tabs.Content'), 'No Tabs.Content (not exported by SDK)')1484+check(!source.includes('Tabs.List'), 'No Tabs.List (use TabsList instead)')1485+check(!source.includes('Tabs.Trigger'), 'No Tabs.Trigger (use TabsTrigger instead)')1486+1487+// 5. WorktreeDetailView: no useQuery, reads from files row1488+{1489+ const wtdStart = source.indexOf('function WorktreeDetailView')1490+ // Scope to just this function (until next function definition)1491+ const nextFn = source.indexOf('\nfunction ', wtdStart + 1)1492+ const wtdSection = source.slice(wtdStart, nextFn > 0 ? nextFn : source.length)1493+ check(!wtdSection.includes('useQuery'), 'WorktreeDetailView does not use useQuery')1494+ check(wtdSection.includes('fileInfo') || wtdSection.includes('files.find'),1495+ 'WorktreeDetailView reads from files row directly')1496+}1497+1498+// 6. SpecsView: resets selectedFile on sourceId change1499+{1500+ const specsStart = source.indexOf('function SpecsView')1501+ const nextFnSpecs = source.indexOf('\nfunction ', specsStart + 1)1502+ const specsSection = source.slice(specsStart, nextFnSpecs > 0 ? nextFnSpecs : source.length)1503+ check(specsSection.includes('setSelectedFile(null)'),1504+ 'SpecsView resets selectedFile on sourceId change')1505+ // Verify the effect depends on sourceId1506+ const effectMatch = specsSection.match(/useEffect\([\s\S]*?\[[^\]]*sourceId/)1507+ check(effectMatch, 'Reset effect depends on sourceId')1508+}1509+1510+// 6b. SpecsView: source-matching gate — component remounts via key prop1511+{1512+ const specsStart = source.indexOf('function SpecsView')1513+ const nextFnSpecs = source.indexOf('\nfunction ', specsStart + 1)1514+ const specsSection = source.slice(specsStart, nextFnSpecs > 0 ? nextFnSpecs : source.length)1515+ check(!specsSection.includes('selectedSourceId'),1516+ 'SpecsView no longer tracks selectedSourceId (removed dead code)')1517+ check(!specsSection.match(/enabled:.*selectedSourceId\s*===\s*sourceId/),1518+ 'specQuery enabled no longer gates on selectedSourceId match')1519+ check(!specsSection.includes('setSelectedSourceId'),1520+ 'SpecsView no longer calls setSelectedSourceId (removed dead code)')1521+}1522+1523+// 7. renderInline returns JSX elements, not plain objects1524+check(source.includes("jsx('strong'") || source.includes('jsx("strong"'),1525+ 'renderInline produces JSX <strong> for bold')1526+check(source.includes("jsx('code'") || source.includes('jsx("code"'),1527+ 'renderInline produces JSX <code> for inline code')1528+check(source.includes("jsx('a'") || source.includes('jsx("a"'),1529+ 'renderInline produces JSX <a> for links')1530+check(!source.includes("{ type: 'bold'"), 'renderInline does not return plain bold object')1531+check(!source.includes("{ type: 'code'"), 'renderInline does not return plain code object')1532+check(!source.includes("{ type: 'link'"), 'renderInline does not return plain link object')1533+1534+// 8. URL safety: isSafeUrl blocks dangerous schemes1535+check(source.includes('isSafeUrl'), 'isSafeUrl function defined')1536+check(source.includes('javascript:'), 'isSafeUrl checks javascript: scheme')1537+check(source.includes('data:'), 'isSafeUrl checks data: scheme')1538+check(source.includes('file:'), 'isSafeUrl checks file: scheme')1539+1540+// 9. __test is frozen1541+check(source.includes('Object.freeze'), '__test export uses Object.freeze')1542+1543+// 10. DialogContent in Dialog loading/error paths (not just content)1544+{1545+ const dialogLoadingMatch = source.match(/Dialog.*open.*onOpenChange.*DialogContent/)1546+ check(dialogLoadingMatch, 'Dialog loading state wraps content in DialogContent')1547+}1548+1549+// 11. Dialog close button / scrollbar overlap fix1550+// ChangeDetailDrawer and IdeaDetailDrawer bodies scroll independently so modal1551+// close-button clearance is no longer relevant, but the legacy detail modal1552+// section checks are retained under the new names.1553+ // The body uses DetailDrawer, whose content region has overflowY auto.1554+ check(source.includes('function DetailDrawer'), 'DetailDrawer component exists')1555+ check(source.includes("overflowY: 'auto'"), 'Drawer content region has overflowY auto')1556+// No scrollable DialogContent should use overflow: 'auto' (causes close X / scrollbar overlap)1557+{1558+ var overflowAutoMatches = source.match(/DialogContent[^}]*overflow:\s*'auto'/g)1559+ check(!overflowAutoMatches, 'No DialogContent uses overflow: auto (causes close/scrollbar overlap)')1560+}1561+1562+// ═══════════════════════════════════════════════════════════════════════════════1563+// SDK contract: ErrorState retry wrapper and CopyButton text prop1564+// ═══════════════════════════════════════════════════════════════════════════════1565+console.log('\n[SDK] ErrorState contract: no onRetry prop')1566+1567+// CRITICAL: ErrorState does NOT accept onRetry — must not appear in any jsx() call1568+// Use non-greedy [^\n]* to avoid matching nested jsx(Button, { onClick: onRetry })1569+{1570+ var errorStateOnRetry = source.match(/jsx\(ErrorState[^\n]*onRetry/g)1571+ check(!errorStateOnRetry, 'No ErrorState call passes onRetry prop',1572+ errorStateOnRetry ? 'Found ' + errorStateOnRetry.length + ' onRetry in ErrorState' : undefined)1573+}1574+1575+// RetryErrorState must exist as a reusable wrapper1576+check(source.includes('function RetryErrorState'), 'RetryErrorState function defined')1577+check(source.includes('RetryErrorState'), 'RetryErrorState is used in source')1578+1579+// RetryErrorState renders ErrorState as root, Button as child with onClick1580+// Verify via __test export: RetryErrorState descriptor structure1581+if (r.__testAvailable && r.retryErrorState) {1582+ var res = r.retryErrorState1583+ console.log(' RetryErrorState descriptor')1584+ check(res.rootIsErrorState === true, 'RetryErrorState renders ErrorState (function component) as root')1585+ check(res.hasButtonChild === true, 'RetryErrorState has Button child')1586+ check(res.buttonOnClickIsFunction === true, 'Button child onClick is a function')1587+ check(res.buttonLabel === 'Retry', 'Button child label is "Retry"', JSON.stringify(res.buttonLabel))1588+ // Execute onClick and verify it invokes the callback1589+ check(res.onClickInvokesCallback === true, 'Button onClick invokes the supplied retry callback')1590+}1591+1592+console.log('\n[SDK] CopyButton contract: text prop, not value')1593+1594+// CRITICAL: CopyButton requires text, not value1595+{1596+ var copyButtonValue = source.match(/jsx\(CopyButton[^)]*\bvalue:/g)1597+ check(!copyButtonValue, 'No CopyButton call uses value prop',1598+ copyButtonValue ? 'Found ' + copyButtonValue.length + ' value props on CopyButton' : undefined)1599+}1600+{1601+ var copyButtonText = source.match(/jsx\(CopyButton[^)]*\btext:/g)1602+ check(copyButtonText && copyButtonText.length >= 2, 'At least 2 CopyButton calls use text prop',1603+ copyButtonText ? 'Found ' + copyButtonText.length : 'None found')1604+}1605+1606+// Board-card CopyButton must have stopPropagation: true (inside clickable card)1607+{1608+ var bcStart = source.indexOf('function BoardCard')1609+ var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1610+ var bcSection = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1611+ check(bcSection.includes('stopPropagation: true'), 'BoardCard CopyButton has stopPropagation: true')1612+ check(bcSection.includes('text: itemRef'), 'BoardCard CopyButton uses text prop (not value)')1613+ check(!bcSection.includes('value: itemRef'), 'BoardCard CopyButton does not use value prop')1614+}1615+1616+// Detail CopyButton should NOT have stopPropagation (not inside clickable card)1617+{1618+ var cdStart = source.indexOf('function ChangeDetailDrawer')1619+ var cdEnd = source.indexOf('\nfunction IdeaDetailDrawer', cdStart + 1)1620+ var cdSection = source.slice(cdStart, cdEnd > 0 ? cdEnd : source.length)1621+}1622+1623+// Drawer class tokens are present on the DetailDrawer root1624+{1625+ const ddStart = source.indexOf('function DetailDrawer')1626+ const ddEnd = source.indexOf('\nfunction ', ddStart + 1)1627+ const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)1628+ check(ddSec.includes('w-[26rem]'), 'Drawer uses Kanban-aligned compact width')1629+ check(ddSec.includes('border-l'), 'Drawer has left border')1630+}1631+1632+// ═══════════════════════════════════════════════════════════════════════════════1633+// Lane layout: horizontal overflow, expanded/collapsed, auto-collapse1634+// ═══════════════════════════════════════════════════════════════════════════════1635+console.log('\n[Lane] Layout constants and helpers')1636+1637+if (r.__testAvailable) {1638+ var ll = r.laneLayout || {}1639+1640+ // Width constants1641+ console.log(' Width constants')1642+ check(ll.expandedIs256 === true, 'LANE_EXPANDED_WIDTH is 256', JSON.stringify(ll.expandedIs256))1643+ check(ll.collapsedIs32 === true, 'LANE_COLLAPSED_WIDTH is 32', JSON.stringify(ll.collapsedIs32))1644+1645+ // laneWidth helper1646+ console.log(' laneWidth helper')1647+ check(ll.expandedWidth === 256, 'laneWidth returns 256 when not collapsed', JSON.stringify(ll.expandedWidth))1648+ check(ll.collapsedWidth === 32, 'laneWidth returns 32 when collapsed', JSON.stringify(ll.collapsedWidth))1649+ check(ll.noOverridesExpanded === 256, 'laneWidth returns 256 when status not in overrides', JSON.stringify(ll.noOverridesExpanded))1650+1651+ // railStyle returns correct collapsed dimensions1652+ console.log(' railStyle (collapsed rail)')1653+ check(ll.railWidth === '32px', 'railStyle width is 32px', JSON.stringify(ll.railWidth))1654+ check(ll.railFlexShrink === 0, 'railStyle flexShrink is 0', JSON.stringify(ll.railFlexShrink))1655+ check(ll.railCursor === 'pointer', 'railStyle cursor is pointer', JSON.stringify(ll.railCursor))1656+1657+ // expandedStyle returns correct dimensions1658+ console.log(' expandedStyle (expanded lane)')1659+ check(ll.expandedWidthPx === '256px', 'expandedStyle width is 256px', JSON.stringify(ll.expandedWidthPx))1660+ check(ll.expandedFlexShrink === 0, 'expandedStyle flexShrink is 0', JSON.stringify(ll.expandedFlexShrink))1661+ check(ll.expandedOverflow === 'hidden', 'expandedStyle overflow is hidden', JSON.stringify(ll.expandedOverflow))1662+1663+ // autoCollapseEmptyLanes1664+ console.log(' autoCollapseEmptyLanes')1665+ check(ll.collapseEmptyWhenWork === true, 'Collapses empty lanes when board has work', JSON.stringify(ll.collapseEmptyWhenWork))1666+ check(ll.expandNonEmpty === true, 'Does not collapse non-empty lanes', JSON.stringify(ll.expandNonEmpty))1667+ check(ll.expandNonEmptyTodo === true, 'Does not collapse non-empty todo', JSON.stringify(ll.expandNonEmptyTodo))1668+ check(ll.collapseInProgress === true, 'Collapses empty in-progress when work exists', JSON.stringify(ll.collapseInProgress))1669+ check(ll.collapseDone === true, 'Collapses empty done when work exists', JSON.stringify(ll.collapseDone))1670+ check(ll.noWorkNoCollapse === true, 'No collapse when board has no work', JSON.stringify(ll.noWorkNoCollapse))1671+} else {1672+ fail('Lane layout helpers', '__test not available')1673+}1674+1675+console.log('\n[Lane] Static source checks')1676+1677+// Lane strip owns horizontal overflow1678+check(source.includes('overflowX'), 'Lane strip has overflowX style')1679+check(source.includes("'auto'"), 'overflowX uses auto value')1680+1681+// Lane strip constrains width1682+check(source.includes('minWidth: 0'), 'Lane strip has minWidth: 0 to prevent flex blowout')1683+check(source.includes('minHeight: 0'), 'Lane strip has minHeight: 0')1684+1685+// Expanded lane fixed width1686+check(source.includes('256') || source.includes('LANE_EXPANDED_WIDTH'), 'Expanded lane uses 256px width')1687+1688+// Collapsed rail width1689+check(source.includes("'32px'") || source.includes('LANE_COLLAPSED_WIDTH'), 'Collapsed rail uses 32px width')1690+1691+// Internal vertical card scrolling1692+check(source.includes('overflowY'), 'Cards container has overflowY for vertical scroll')1693+1694+// BoardColumn accepts collapsed and onExpand props1695+{1696+ var bcStart = source.indexOf('function BoardColumn')1697+ var bcEnd = source.indexOf('\nfunction ', bcStart + 1)1698+ var bcSec = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)1699+ check(bcSec.includes('collapsed'), 'BoardColumn accepts collapsed prop')1700+ check(bcSec.includes('onExpand'), 'BoardColumn accepts onExpand prop')1701+ check(bcSec.includes('railStyle'), 'BoardColumn renders rail when collapsed')1702+ check(bcSec.includes('expandedStyle'), 'BoardColumn renders expanded style when not collapsed')1703+ check(bcSec.includes('data-lane'), 'Expanded lane sets data-lane attribute')1704+}1705+1706+// WorkView manages collapse state1707+{1708+ var wvStart = source.indexOf('function WorkView')1709+ var wvEnd = source.indexOf('\nfunction ', wvStart + 1)1710+ var wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)1711+ check(wvSec.includes('manualOverrides'), 'WorkView tracks manual collapse overrides')1712+ check(wvSec.includes('autoCollapseEmptyLanes'), 'WorkView uses autoCollapseEmptyLanes')1713+ check(wvSec.includes('computeCollapsedSet'), 'WorkView uses computeCollapsedSet helper')1714+ check(wvSec.includes('toggleOverride'), 'WorkView uses toggleOverride helper')1715+ check(wvSec.includes('computeLanePhase'), 'WorkView uses computeLanePhase for phase tracking')1716+ check(wvSec.includes('pruneStaleOverrides'), 'WorkView uses pruneStaleOverrides for phase pruning')1717+ check(wvSec.includes('prevLanePhase'), 'WorkView tracks previous lane phase')1718+ check(wvSec.includes('overflowX'), 'WorkView lane strip has overflowX')1719+ check(!wvSec.includes('ScrollArea'), 'WorkView no longer uses ScrollArea')1720+ check(wvSec.includes("name: 'filter'"), 'WorkView filter renders canonical filter Codicon')1721+ check(wvSec.includes("'aria-label': 'Filter'"), 'WorkView filter keeps accessible label')1722+}1723+1724+// WorkView source keying1725+{1726+ var wvkPattern = /key:\s*selectedSource\.id/1727+ check(wvkPattern.test(source), 'WorkView render has key={selectedSource.id} for source identity')1728+}1729+1730+// ═══════════════════════════════════════════════════════════════════════════════1731+// Source action icons wiring — adapter, dialog, helpers1732+// ═══════════════════════════════════════════════════════════════════════════════1733+console.log('\n[Source] Source mutation adapter and helpers')1734+1735+// Static source checks: createApi mutation methods exist1736+check(source.includes('addSource'), 'createApi has addSource method')1737+check(source.includes('updateSource'), 'createApi has updateSource method')1738+check(source.includes('initSource'), 'createApi has initSource method')1739+check(source.includes('removeSource'), 'createApi has removeSource method')1740+1741+// Mutation methods use ctx.rest with method and body1742+check(source.includes("method: 'POST'"), 'addSource sends POST')1743+check(source.includes("method: 'PUT'"), 'updateSource sends PUT')1744+check(source.includes("'/sources/' + encodeURIComponent(sourceId) + '/init', { method: 'POST'"), 'initSource sends POST')1745+check(source.includes("method: 'DELETE'"), 'removeSource sends DELETE')1746+1747+// Desktop ctx.rest forwards request bodies as objects, not pre-serialized JSON strings1748+check(source.includes("body: { path: path, name: name || undefined }"), 'Mutation methods pass object request bodies')1749+check(!source.includes("body: JSON.stringify({ path: path, name: name || undefined })"), 'Mutation methods do not pre-serialize request bodies')1750+1751+// URL encoding in mutation paths1752+check(source.includes("'/sources/' + encodeURIComponent(sourceId) + '/init'"), 'initSource uses encoded source init path')1753+check(source.includes("'/sources/' + encodeURIComponent(sourceId)"), 'Mutation paths encode sourceId')1754+1755+// Pure helpers exist1756+check(source.includes('function trimPath'), 'trimPath function defined')1757+check(source.includes('function extractApiError'), 'extractApiError function defined')1758+1759+// trimPath behavior1760+if (r.__testAvailable && r.trimPath) {1761+ console.log(' trimPath helper')1762+ check(r.trimPath.basicTrim === '/path/to/repo', 'trimPath trims whitespace', JSON.stringify(r.trimPath.basicTrim))1763+ check(r.trimPath.noTrim === '/path/to/repo', 'trimPath passes clean strings', JSON.stringify(r.trimPath.noTrim))1764+ check(r.trimPath.emptyString === '', 'trimPath returns empty for empty string', JSON.stringify(r.trimPath.emptyString))1765+ check(r.trimPath.whitespaceOnly === '', 'trimPath returns empty for whitespace-only', JSON.stringify(r.trimPath.whitespaceOnly))1766+ check(r.trimPath.nonString === '', 'trimPath returns empty for null', JSON.stringify(r.trimPath.nonString))1767+ check(r.trimPath.number === '', 'trimPath returns empty for number', JSON.stringify(r.trimPath.number))1768+} else {1769+ fail('trimPath helper', '__test not available')1770+}1771+1772+// extractApiError behavior1773+if (r.__testAvailable && r.extractApiError) {1774+ console.log(' extractApiError helper')1775+ check(r.extractApiError.nullInput === 'Unknown error', 'extractApiError handles null', JSON.stringify(r.extractApiError.nullInput))1776+ check(r.extractApiError.stringInput === 'bad request', 'extractApiError passes strings', JSON.stringify(r.extractApiError.stringInput))1777+ check(r.extractApiError.messageObj === 'not found', 'extractApiError extracts .message', JSON.stringify(r.extractApiError.messageObj))1778+ check(r.extractApiError.detailObj === 'conflict', 'extractApiError extracts .detail', JSON.stringify(r.extractApiError.detailObj))1779+ check(r.extractApiError.duplicateSource === 'Source already registered. Select it from the source list, or remove it before adding again.', 'extractApiError explains duplicate source', JSON.stringify(r.extractApiError.duplicateSource))1780+ check(r.extractApiError.fallback === '[object Object]', 'extractApiError falls back to String()', JSON.stringify(r.extractApiError.fallback))1781+} else {1782+ fail('extractApiError helper', '__test not available')1783+}1784+1785+// Source dialog component1786+check(source.includes('function SourceDialog'), 'SourceDialog component defined')1787+check(source.includes("'Add source'") || source.includes('"Add source"'), 'SourceDialog or button has Add source text')1788+check(source.includes("'Edit source'") || source.includes('"Edit source"'), 'SourceDialog or button has Edit source text')1789+1790+check(source.includes('handleInitSource'), 'Invalid source has initialize handler')1791+check(source.includes('Initialize OpenSpec'), 'Invalid source has initialize action')1792+check(source.includes('initBusy'), 'OpenSpecPage tracks initialization state')1793+check(source.includes('initError'), 'OpenSpecPage tracks initialization errors')1794+check(source.includes('Path is required'), 'SourceDialog validates required path')1795+check(source.includes('function sourceNeedsInitialization'), 'Source add detects missing OpenSpec layout')1796+check(source.includes('api.initSource(result.source.id)'), 'Source add initializes missing OpenSpec layout')1797+check(source.includes("source.error === 'No openspec/ directory found'"), 'Source add limits initialization to missing layout')1798+1799+// Source dialog uses Dialog/DialogContent from SDK1800+{1801+ var sdStart = source.indexOf('function SourceDialog')1802+ var sdEnd = source.indexOf('\nfunction ', sdStart + 1)1803+ var sdSection = source.slice(sdStart, sdEnd > 0 ? sdEnd : source.length)1804+ check(sdSection.includes('jsx(Dialog') || sdSection.includes('jsxs(Dialog'), 'SourceDialog renders Dialog')1805+ check(sdSection.includes('jsx(DialogContent') || sdSection.includes('jsxs(DialogContent'), 'SourceDialog renders DialogContent')1806+ check(sdSection.includes('jsx(Input'), 'SourceDialog renders Input fields')1807+ check(sdSection.includes('jsx(Button'), 'SourceDialog renders Button controls')1808+ check(sdSection.includes('disabled: busy'), 'SourceDialog disables controls while busy')1809+}1810+1811+// onClick handlers wired on source action buttons1812+check(source.includes('onClick: handleEditSource'), 'Edit button has onClick: handleEditSource')1813+check(source.includes('onClick: handleRemoveSource'), 'Remove button has onClick: handleRemoveSource')1814+check(source.includes('onClick: handleAddSource'), 'Add button has onClick: handleAddSource')1815+1816+// Visible source action labels and requested order1817+{1818+ var addAction = source.indexOf("children: ['+', ' Add']")1819+ var editAction = source.indexOf("children: ['\\u270E', ' Edit']")1820+ var deleteAction = source.indexOf("children: ['\\u2715', ' Delete']")1821+ check(addAction > 0, 'Add source action has visible Add label')1822+ check(editAction > 0, 'Edit source action has visible Edit label')1823+ check(deleteAction > 0, 'Delete source action has visible Delete label')1824+ check(addAction > 0 && editAction > addAction && deleteAction > editAction, 'Source actions order is Add, Edit, Delete')1825+}1826+1827+// Filter icon replaces the text label while retaining an accessible name1828+check(source.includes("name: 'filter'"), 'Work filter uses canonical filter Codicon')1829+check(source.includes("'aria-label': 'Filter'"), 'Work filter icon has accessible label')1830+check(!source.includes("children: 'Filter'"), 'Work filter no longer renders Filter text')1831+1832+// Accessible labels on source action buttons1833+check(source.includes("'aria-label': 'Edit source'"), 'Edit button has aria-label')1834+check(source.includes("'aria-label': 'Delete source'"), 'Delete button has aria-label')1835+check(source.includes("'aria-label': 'Add source'"), 'Add button has aria-label')1836+1837+// State management for source dialog and remove confirmation1838+check(source.includes('sourceDialog'), 'OpenSpecPage tracks sourceDialog state')1839+check(source.includes('removeConfirm'), 'OpenSpecPage tracks removeConfirm state')1840+check(source.includes('removeBusy'), 'OpenSpecPage tracks removeBusy state')1841+check(source.includes('removeError'), 'OpenSpecPage tracks removeError state')1842+1843+// Remove confirmation dialog1844+check(source.includes('Remove source'), 'Remove confirmation dialog exists')1845+check(source.includes('unregisters the source'), 'Remove confirmation explains registry-only removal')1846+1847+// Source mutation adapter __test results1848+if (r.sourceMutation) {1849+ console.log(' Source mutation adapter')1850+ check(r.sourceMutation.addSourcePath === '/sources', 'addSource path is /sources', JSON.stringify(r.sourceMutation.addSourcePath))1851+ check(r.sourceMutation.initSourcePath === '/sources/source%20id/init', 'initSource path encodes source ID', JSON.stringify(r.sourceMutation.initSourcePath))1852+ check(r.sourceMutation.initSourceMethod === 'POST', 'initSource uses POST', JSON.stringify(r.sourceMutation.initSourceMethod))1853+ check(r.sourceMutation.initSourceBody === undefined, 'initSource has no request body', JSON.stringify(r.sourceMutation.initSourceBody))1854+ check(r.sourceMutation.needsInit === true, 'missing OpenSpec layout triggers initialization', JSON.stringify(r.sourceMutation.needsInit))1855+ check(r.sourceMutation.validNeedsNoInit === false, 'valid source does not trigger initialization', JSON.stringify(r.sourceMutation.validNeedsNoInit))1856+ check(!r.sourceMutation.error, 'No error in source mutation tests', r.sourceMutation.error)1857+}1858+1859+// Exact endpoint allowlist — dynamic adapter evidence for all four mutation endpoints1860+if (r.sourceMutation) {1861+ console.log(' Source mutation endpoint allowlist')1862+ check(r.sourceMutation.addCallPath === '/sources', 'addSource dynamic path is /sources', JSON.stringify(r.sourceMutation.addCallPath))1863+ check(r.sourceMutation.addCallMethod === 'POST', 'addSource dynamic method is POST', JSON.stringify(r.sourceMutation.addCallMethod))1864+ check(r.sourceMutation.addCallBody && r.sourceMutation.addCallBody.path === '/repos/acme', 'addSource dynamic body carries path', JSON.stringify(r.sourceMutation.addCallBody))1865+ check(r.sourceMutation.addCallBody && r.sourceMutation.addCallBody.name === 'Acme', 'addSource dynamic body carries optional name', JSON.stringify(r.sourceMutation.addCallBody))1866+ check(r.sourceMutation.addBareCallBody && r.sourceMutation.addBareCallBody.name === undefined, 'addSource omits name when absent', JSON.stringify(r.sourceMutation.addBareCallBody))1867+ check(r.sourceMutation.updateCallPath === '/sources/src%20id%2Fwith%20slash', 'updateSource dynamic path encodes sourceId', JSON.stringify(r.sourceMutation.updateCallPath))1868+ check(r.sourceMutation.updateCallMethod === 'PUT', 'updateSource dynamic method is PUT', JSON.stringify(r.sourceMutation.updateCallMethod))1869+ check(r.sourceMutation.updateCallBody && r.sourceMutation.updateCallBody.path === '/repos/acme-new', 'updateSource dynamic body carries path', JSON.stringify(r.sourceMutation.updateCallBody))1870+ check(r.sourceMutation.updateCallBody && r.sourceMutation.updateCallBody.name === 'Acme New', 'updateSource dynamic body carries name', JSON.stringify(r.sourceMutation.updateCallBody))1871+ check(r.sourceMutation.removeCallPath === '/sources/src%20id', 'removeSource dynamic path encodes sourceId', JSON.stringify(r.sourceMutation.removeCallPath))1872+ check(r.sourceMutation.removeCallMethod === 'DELETE', 'removeSource dynamic method is DELETE', JSON.stringify(r.sourceMutation.removeCallMethod))1873+ check(r.sourceMutation.removeCallBody === undefined, 'removeSource sends no request body (registry row only)', JSON.stringify(r.sourceMutation.removeCallBody))1874+ check(r.sourceMutation.sourcesCallMethod === undefined, 'sources() refresh stays a plain GET /sources', JSON.stringify(r.sourceMutation.sourcesCallMethod))1875+ check(r.sourceMutation.allowlistExact === true, 'createApi exposes exactly the four source-mutation methods', JSON.stringify(r.sourceMutation.mutationMethods))1876+ check(r.sourceMutation.noGenericRequest === true, 'createApi has no generic arbitrary request helper', JSON.stringify(r.sourceMutation.mutationMethods))1877+} else {1878+ fail('Source mutation endpoint allowlist', '__test not available')1879+}1880+1881+// ═══════════════════════════════════════════════════════════════════════════════1882+// [Source] Registry UI wiring — cancel, error preservation, refresh/selection1883+// ═══════════════════════════════════════════════════════════════════════════════1884+console.log('\n[Source] Registry UI wiring — cancel, errors, refresh, read-only boundary')1885+1886+// Cancellation never issues a request1887+{1888+ var sdCancelStart = source.indexOf('function SourceDialog')1889+ var sdCancelEnd = source.indexOf('\nfunction ', sdCancelStart + 1)1890+ var sdCancelSec = source.slice(sdCancelStart, sdCancelEnd > 0 ? sdCancelEnd : source.length)1891+ check(sdCancelSec.includes('onClick: onClose'), 'SourceDialog Cancel closes without an API call')1892+}1893+check(source.includes("onClick: function() { setRemoveConfirm(null); setRemoveError('') }"), 'Remove-confirm Cancel closes without an API call')1894+1895+// Backend errors preserve registry state and selection — errors render, no optimistic deletion1896+check(source.includes('setErr(extractApiError(e))'), 'Add/edit failure shows backend error and keeps dialog open')1897+check(source.includes('setRemoveError(extractApiError(e))'), 'Remove failure shows backend error and keeps confirmation open')1898+check(source.includes('setInitError(extractApiError(e))'), 'Initialize failure shows backend error without losing selection')1899+1900+// Successful operations refresh /sources and restore selection where possible1901+{1902+ var osStart = source.indexOf('function onSourceSaved')1903+ var osEnd = source.indexOf('\n function ', osStart + 1)1904+ var osSec = source.slice(osStart, osEnd > 0 ? osEnd : source.length)1905+ check(osSec.includes('sourcesQuery.refetch()'), 'Add/edit success refreshes the source list')1906+ check(osSec.includes('setSelectedSourceId(result.source.id)'), 'Add/edit success restores the returned source selection')1907+ check(osSec.includes('result.initError'), 'Add init failure retains the backend error for display')1908+ check(osSec.includes('refresh.then'), 'Add/edit selection waits for refreshed registry data')1909+}1910+1911+// Add-success/init-failure recovery keeps the registered row truthful and1912+// performs no implicit duplicate initialization retry.1913+{1914+ const addState = identity && identity.sourceAdd ? identity.sourceAdd : {}1915+ check(addState.initFailureSourceId === 'src-added', 'Init failure preserves the registered source id', JSON.stringify(addState))1916+ check(addState.initFailureError === 'initialization denied', 'Init failure retains the backend error', JSON.stringify(addState))1917+ check(addState.initFailureSingleAttempt === true, 'Init failure performs one initialization attempt without duplicate retry', JSON.stringify(addState))1918+ check(addState.parentRefetchCalls === 1, 'Add recovery refreshes the source registry once', JSON.stringify(addState))1919+ check(addState.parentSelectedSourceId === 'src-added' && addState.parentStateSelectedSourceId === 'src-added',1920+ 'Add recovery preserves the registered source selection', JSON.stringify(addState))1921+ check(addState.parentInitError === 'initialization denied', 'Add recovery retains the initialization error in page state', JSON.stringify(addState))1922+}1923+{1924+ var hiStart = source.indexOf('function handleInitSource')1925+ var hiEnd = source.indexOf('\n function ', hiStart + 1)1926+ var hiSec = source.slice(hiStart, hiEnd > 0 ? hiEnd : source.length)1927+ check(hiSec.includes('api.initSource(selectedSource.id)'), 'Initialize uses the selected source id')1928+ check(hiSec.includes('sourcesQuery.refetch()'), 'Initialize success refreshes the source list')1929+}1930+{1931+ var crStart = source.indexOf('function confirmRemove')1932+ var crEnd = source.indexOf('\n function ', crStart + 1)1933+ var crSec = source.slice(crStart, crEnd > 0 ? crEnd : source.length)1934+ check(crSec.includes('api.removeSource(removeConfirm.id)'), 'Confirmed remove calls only the registry-row endpoint')1935+ check(crSec.includes('sourcesQuery.refetch()'), 'Remove success refreshes the source list')1936+}1937+check(source.includes("onClick: function() { sourcesQuery.refetch() }"), 'Refresh button refetches GET /sources')1938+1939+// Artifact/task read-only boundary — no mutation surface beyond the four source endpoints1940+check(!source.includes('api.setTaskStatus'), 'No task-status mutation method')1941+check(!source.includes('api.completeTask'), 'No task-completion method')1942+check(!source.includes('api.updateChange'), 'No change-edit method')1943+check(!source.includes('api.editIdea'), 'No idea-edit method')1944+check(!source.includes('api.writeFile'), 'No file-write method')1945+check(!source.includes("method: 'PATCH'"), 'No PATCH verb anywhere in the plugin')1946+check(!source.includes('node:fs'), 'No node:fs import')1947+check(!source.includes('child_process'), 'No child_process usage')1948+1949+// ═══════════════════════════════════════════════════════════════════════════════1950+// [Identity 1.1] Resolver-safe source references1951+// ═══════════════════════════════════════════════════════════════════════════════1952+console.log('\n[Identity 1.1] Resolver-safe source references')1953+1954+check(source.includes('function buildSourceReference'), 'buildSourceReference helper defined')1955+check(source.includes('buildSourceReference: buildSourceReference'), 'buildSourceReference exported via __test')1956+1957+{1958+ const s = identity && identity.src ? identity.src : {}1959+ check(s.unique === 'ivault', 'Unique source name copies the vanity name', JSON.stringify(s.unique))1960+ check(s.caseInsensitiveDup === 'os_8e5049', 'Case-insensitive duplicate name falls back to stable token', JSON.stringify(s.caseInsensitiveDup))1961+ check(s.blankName === 'os_8e5049', 'Blank source name falls back to stable token', JSON.stringify(s.blankName))1962+ check(s.tokenFallback === 'src-id-only', 'Missing name falls back to stable id', JSON.stringify(s.tokenFallback))1963+ check(s.unavailable === '', 'Unavailable reference returns empty string (no malformed copy)', JSON.stringify(s.unavailable))1964+}1965+1966+// Reference helpers never construct paths or encode1967+{1968+ const start = source.indexOf('function buildSourceReference')1969+ const end = source.indexOf('function computeCollapsedSet')1970+ const refSec = source.slice(start, end > start ? end : source.length)1971+ check(refSec.length > 0, 'Reference helper section present')1972+ check(!refSec.includes('.path'), 'Reference helpers never use source.path', refSec.includes('.path') ? 'found .path usage' : undefined)1973+ check(!refSec.includes('encodeURIComponent'), 'Reference helpers perform no URL encoding')1974+}1975+1976+// ═══════════════════════════════════════════════════════════════════════════════1977+// [Identity 1.2] Resolver-safe item references1978+// ═══════════════════════════════════════════════════════════════════════════════1979+console.log('\n[Identity 1.2] Resolver-safe item references')1980+1981+{1982+ const it = identity && identity.item ? identity.item : {}1983+ check(it.active === 'ivault/os_fd3445', 'Active change reference is source-qualified', JSON.stringify(it.active))1984+ check(it.archived === 'ivault/os_fd3445', 'Archived change reference is identical (no archive segment)', JSON.stringify(it.archived))1985+ check(it.idea === 'ivault/os_1655dd', 'Idea reference is source-qualified', JSON.stringify(it.idea))1986+ check(it.missingToken === '', 'Missing item token produces no reference', JSON.stringify(it.missingToken))1987+ check(it.missingSourceRef === '', 'Unavailable source reference produces no item reference', JSON.stringify(it.missingSourceRef))1988+ check(it.literalNoEncoding === 'My Source/tok/a b', 'Names and tokens preserved literally without encoding', JSON.stringify(it.literalNoEncoding))1989+ check(it.uniqueToken === true, 'Unique item token is copy-available', JSON.stringify(it.uniqueToken))1990+ check(it.duplicateToken === false, 'Duplicate item token is not copy-available', JSON.stringify(it.duplicateToken))1991+ check(it.blankToken === false, 'Blank item token is not copy-available', JSON.stringify(it.blankToken))1992+}1993+1994+// ═══════════════════════════════════════════════════════════════════════════════1995+// [Session 1.4] Registration-scope navigation state1996+// ═══════════════════════════════════════════════════════════════════════════════1997+console.log('\n[Session 1.4] Registration-scope session store')1998+1999+{2000+ const ses = identity && identity.session ? identity.session : {}2001+ check(ses.defaultArchived === true, 'Fresh session store defaults Archived to true', JSON.stringify(ses.defaultArchived))2002+ check(ses.defaultSelected === true, 'Fresh session store defaults selectedSourceId to null', JSON.stringify(ses.defaultSelected))2003+ check(ses.storeWired === true, 'register() passes session store into route render', JSON.stringify(ses.storeWired))2004+ check(ses.sameStoreAcrossRenders === true, 'Second route render reads same registration-scope store', JSON.stringify(ses.sameStoreAcrossRenders))2005+ check(ses.retainedMutated === true, 'Store mutations survive route-local state discard', JSON.stringify(ses.retainedMutated))2006+ const fb = ses.fallback || {}2007+ check(fb.retainedPresentInvalid === 'a', 'Retained invalid source stays selected while registered', JSON.stringify(fb.retainedPresentInvalid))2008+ check(fb.retainedPresentValid === 'b', 'Retained valid source stays selected', JSON.stringify(fb.retainedPresentValid))2009+ check(fb.retainedAbsent === 'b', 'Absent retained source falls back to first valid', JSON.stringify(fb.retainedAbsent))2010+ check(fb.allInvalidFirstReturned === 'x', 'All-invalid sources fall back to first returned', JSON.stringify(fb.allInvalidFirstReturned))2011+ check(fb.noSources === null, 'No sources resolve to null selection', JSON.stringify(fb.noSources))2012+}2013+2014+// ═══════════════════════════════════════════════════════════════════════════════2015+// [Session 1.4b] Retained-source restoration across the loading window2016+// ═══════════════════════════════════════════════════════════════════════════════2017+console.log('\n[Session 1.4b] Retained-source restoration across the loading window')2018+2019+{2020+ const m = identity && identity.mount ? identity.mount : {}2021+ check(m.__error === undefined, 'Mount-sequence simulation executed without driver error', m.__error || '')2022+ const rv = m.retainedValid || {}2023+ check(rv.phase1 && rv.phase1.store === 'src-b', 'Loading window does not wipe retained selection (store)', JSON.stringify(rv.phase1))2024+ check(rv.phase1 && rv.phase1.state === 'src-b', 'Loading window does not wipe retained selection (state)', JSON.stringify(rv.phase1))2025+ check(rv.phase1 && rv.phase1.component === 'Loader', 'Loading phase renders the loader', JSON.stringify(rv.phase1))2026+ check(rv.phase2 && rv.phase2.store === 'src-b', 'Retained valid source survives remount after data arrival', JSON.stringify(rv.phase2))2027+ check(rv.phase2 && rv.phase2.state === 'src-b', 'Retained valid source stays selected in React state', JSON.stringify(rv.phase2))2028+ const ri = m.retainedInvalid || {}2029+ check(ri.phase2 && ri.phase2.store === 'src-c', 'Registered invalid source remains selected after data arrival', JSON.stringify(ri.phase2))2030+ check(ri.phase2 && ri.phase2.state === 'src-c', 'Registered invalid source stays selected in React state', JSON.stringify(ri.phase2))2031+ const ra = m.retainedAbsent || {}2032+ check(ra.phase2 && ra.phase2.store === 'src-b', 'Absent retained source falls back to first valid against refreshed list', JSON.stringify(ra.phase2))2033+ check(ra.phase2 && ra.phase2.state === 'src-b', 'Absent-retained fallback lands in React state too', JSON.stringify(ra.phase2))2034+ const ge = m.genuinelyEmpty || {}2035+ check(ge.phase1 && ge.phase1.store === 'src-b', 'Retained id survives the loading render before empty result', JSON.stringify(ge.phase1))2036+ check(ge.phase2 && ge.phase2.store === null, 'Genuinely empty refreshed result resolves to null selection', JSON.stringify(ge.phase2))2037+ check(ge.phase2 && ge.phase2.state === null, 'Empty-result null resolution lands in React state too', JSON.stringify(ge.phase2))2038+ const emptySource = m.emptySource || {}2039+ check(emptySource.__error === undefined, 'Empty-source render simulation executed without driver error', emptySource.__error || '')2040+ check(emptySource.addButtonReachable === true, 'First-source Add control is reachable from empty registry state', JSON.stringify(emptySource))2041+}2042+2043+// Static wiring — store created in register(), owned by OpenSpecPage2044+{2045+ const regStart = source.indexOf('register: function')2046+ const regEnd = source.indexOf('\n}', regStart + 1)2047+ const regSec = source.slice(regStart, regEnd > 0 ? regEnd : source.length)2048+ check(regSec.includes('createSessionStore'), 'register() creates the session store')2049+ check(regSec.includes('sessionStore'), 'register() passes session store to OpenSpecPage')2050+}2051+{2052+ const ppStart = source.indexOf('function OpenSpecPage')2053+ const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2054+ const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2055+ check(ppSec.includes('sessionStore.selectedSourceId'), 'OpenSpecPage initializes selection from session store')2056+ check(ppSec.includes('sessionStore.showArchived'), 'OpenSpecPage initializes Archived from session store')2057+ check(ppSec.includes('sessionStore.selectedSourceId ='), 'Explicit source selection writes session store')2058+ check(ppSec.includes('sessionStore.showArchived ='), 'Archived toggle writes session store')2059+ check(ppSec.includes('resolveRetainedSource'), 'OpenSpecPage applies retained-source fallback rules')2060+ check(ppSec.includes('sourcesQuery.data === undefined'), 'Resolution effect is gated on data arrival (loading window safe)')2061+ check(ppSec.includes('sourcesQuery.data, selectedSourceId'), 'Resolution effect re-runs when refreshed data arrives')2062+ check(ppSec.includes('onToggleArchived'), 'WorkView receives controlled Archived toggle prop')2063+}2064+2065+// ═══════════════════════════════════════════════════════════════════════════════2066+// [Identity 1.5] Copy controls, detail identity, footer layout2067+// ═══════════════════════════════════════════════════════════════════════════════2068+console.log('\n[Identity 1.5] Copy controls, detail identity, footer layout')2069+2070+{2071+ const dt = identity && identity.detail ? identity.detail : {}2072+ check(dt.titleLoaded === 'Loaded', 'Detail title prefers loaded detail title', JSON.stringify(dt.titleLoaded))2073+ check(dt.titleSummary === 'Summary', 'Detail title falls back to selected summary title', JSON.stringify(dt.titleSummary))2074+ check(dt.titleItem === 'Item', 'Detail title falls back to item name', JSON.stringify(dt.titleItem))2075+ check(dt.titleUntitledChange === 'Untitled change', 'Change title falls back to "Untitled change"', JSON.stringify(dt.titleUntitledChange))2076+ check(dt.titleUntitledIdea === 'Untitled idea', 'Idea title falls back to "Untitled idea"', JSON.stringify(dt.titleUntitledIdea))2077+ check(dt.secondaryLoaded === 'Loaded', 'Secondary identity prefers loaded detail name', JSON.stringify(dt.secondaryLoaded))2078+ check(dt.secondarySummary === 'Summary', 'Secondary identity falls back to summary name', JSON.stringify(dt.secondarySummary))2079+ check(dt.secondaryMissing === '', 'Missing secondary identity is omitted', JSON.stringify(dt.secondaryMissing))2080+ check(dt.ideaFilename === 'hermes-agentmail-inbox-integration.md', 'Idea secondary identity appends .md exactly once', JSON.stringify(dt.ideaFilename))2081+ check(dt.ideaFilenameNoDup === 'already.md', 'Existing .md suffix is not duplicated', JSON.stringify(dt.ideaFilenameNoDup))2082+ check(dt.ideaFilenameMissing === '', 'Missing idea identity is omitted', JSON.stringify(dt.ideaFilenameMissing))2083+}2084+2085+{2086+ const cd = identity && identity.card ? identity.card : {}2087+ check(cd.copyCount === 1, 'Card renders exactly one copy control when available', JSON.stringify(cd.copyCount))2088+ check(cd.copyText === 'ivault/os_fd3445', 'Card CopyButton.text equals canonical item reference', JSON.stringify(cd.copyText))2089+ check(cd.copyLabel === 'Copy item reference', 'Card copy accessible label is "Copy item reference"', JSON.stringify(cd.copyLabel))2090+ check(cd.copyTitle === 'ivault/os_fd3445', 'Card copy tooltip equals exact clipboard payload', JSON.stringify(cd.copyTitle))2091+ check(cd.copyStopProp === true, 'Card copy stops propagation inside clickable card', JSON.stringify(cd.copyStopProp))2092+ check(cd.rightGroupAuto === true, 'Identity group pinned lower-right with margin-left auto', JSON.stringify(cd.rightGroupAuto))2093+ check(cd.tokenVisible === true, 'Short token visible as metadata on card', JSON.stringify(cd.tokenVisible))2094+ check(cd.leftGroupHasArtifacts === true, 'Artifact badges occupy left footer group', JSON.stringify(cd.leftGroupHasArtifacts))2095+ check(cd.leftGroupHasFraction === true, 'Task fraction remains rendered in card footer', JSON.stringify(cd.leftGroupHasFraction))2096+ check(cd.progressLeft === true, 'Task fraction aligns on left side of card footer', JSON.stringify(cd.progressLeft))2097+ check(cd.identityRight === true, 'Copy and token group remains on right side of card footer', JSON.stringify(cd.identityRight))2098+ check(cd.leftBadges === true, 'Populated card renders proposal/tasks badges', JSON.stringify(cd.leftBadges))2099+ check(cd.taskFraction === true, 'Task fraction stays in populated footer', JSON.stringify(cd.taskFraction))2100+ check(cd.statusBorder === true, 'Status-colored border preserved on card', JSON.stringify(cd.statusBorder))2101+ check(cd.hoverExactCard === true, 'Card hover uses exact bundled-Kanban utility hover:bg-primary/[0.06]', JSON.stringify(cd.hoverExactCard))2102+ check(cd.hoverGenericAbsent === true, 'Card hover no longer uses generic --ui-hover-background', JSON.stringify(cd.hoverGenericAbsent))2103+}2104+2105+{2106+ const sp = identity && identity.sparse ? identity.sparse : {}2107+ check(sp.copyText === 'ivault/os_x1', 'Sparse card keeps canonical copy reference', JSON.stringify(sp.copyText))2108+ check(sp.noPlaceholderFraction === true, 'Sparse footer has no placeholder task fraction', JSON.stringify(sp.noPlaceholderFraction))2109+ check(sp.rightGroupAuto === true, 'Sparse identity group stays lower-right', JSON.stringify(sp.rightGroupAuto))2110+ check(sp.tokenVisible === true, 'Sparse card keeps visible token metadata', JSON.stringify(sp.tokenVisible))2111+}2112+2113+{2114+ const dc = identity && identity.dupCard ? identity.dupCard : {}2115+ check(dc.copySuppressed === true, 'Duplicate item token suppresses copy control', JSON.stringify(dc.copySuppressed))2116+ check(dc.tokenStillVisible === true, 'Visible identity remains when copy suppressed', JSON.stringify(dc.tokenStillVisible))2117+}2118+2119+// Detail drawer and source header wiring2120+{2121+ var cdStart = source.indexOf('function ChangeDetailDrawer')2122+ var cdEnd = source.indexOf('\nfunction IdeaDetailDrawer', cdStart + 1)2123+ var cdSection = source.slice(cdStart, cdEnd > 0 ? cdEnd : source.length)2124+ check(cdSection.includes('itemRef'), 'ChangeDetailDrawer supplies itemRef to DetailDrawer')2125+ check(cdSection.includes('buildItemReference'), 'Change detail uses canonical item reference')2126+ check(cdSection.includes('hasUniqueItemToken'), 'Change detail guards copy on unique token')2127+ check(cdSection.includes('detailTitle'), 'Change detail uses title fallback')2128+ check(cdSection.includes('detailSecondaryName'), 'Change detail shows secondary folder identity')2129+}2130+{2131+ const idStart = source.indexOf('function IdeaDetailDrawer')2132+ const idEnd = source.indexOf('\nfunction ', idStart + 1)2133+ const idSec = source.slice(idStart, idEnd > 0 ? idEnd : source.length)2134+ check(idSec.includes('itemRef'), 'IdeaDetailDrawer supplies itemRef to DetailDrawer')2135+ check(idSec.includes('buildItemReference'), 'Idea detail uses canonical item reference')2136+ check(idSec.includes('ideaSecondaryName'), 'Idea detail appends .md exactly once')2137+ check(idSec.includes('detailTitle'), 'Idea detail uses title fallback')2138+}2139+{2140+ const ddStart = source.indexOf('function DetailDrawer')2141+ const ddEnd = source.indexOf('\nfunction ', ddStart + 1)2142+ const ddSec = source.slice(ddStart, ddEnd > 0 ? ddEnd : source.length)2143+ check(ddSec.includes("'Copy item reference'"), 'DetailDrawer copy control label is "Copy item reference"')2144+ check(ddSec.includes('data-detail-drawer'), 'DetailDrawer root carries data-detail-drawer marker')2145+ check(ddSec.includes('absolute inset-y-0 right-0'), 'DetailDrawer root is absolute right overlay')2146+ check(ddSec.includes('w-[26rem]'), 'DetailDrawer uses Kanban-aligned compact width')2147+ check(ddSec.includes('border-l'), 'DetailDrawer has left border')2148+ check(ddSec.includes("'Close detail'"), 'DetailDrawer close button has accessible label')2149+}2150+{2151+ const ppStart = source.indexOf('function OpenSpecPage')2152+ const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2153+ const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2154+ check(ppSec.includes("'Copy source reference'"), 'Source copy label is "Copy source reference"')2155+ check(ppSec.includes('buildSourceReference'), 'Source header uses canonical source reference')2156+ check(ppSec.includes('disabled:'), 'Unavailable source copy is disabled rather than malformed')2157+ check(ppSec.includes("name: 'refresh'"), 'Source refresh control uses canonical refresh Codicon')2158+ check(ppSec.includes("'aria-label': 'Refresh sources'"), 'Source refresh icon keeps accessible label')2159+ check(!ppSec.includes("children: 'Refresh'"), 'Source refresh control no longer renders Refresh text')2160+ check(!ppSec.includes("children: 'Source'"), 'Header no longer renders redundant Source label')2161+ check(!ppSec.includes("children: selectedSource.name || selectedSource.id"), 'Source row no longer duplicates selected source name')2162+ check(!ppSec.includes("children: shortToken"), 'Source row no longer renders redundant source id')2163+ const headerStart = ppSec.indexOf('// Header')2164+ const sourceControlsStart = ppSec.indexOf('// Source controls')2165+ const viewSwitchStart = ppSec.indexOf('// View switch')2166+ const headerSec = ppSec.slice(headerStart, sourceControlsStart)2167+ const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2168+ check(headerSec.includes('children: selectedSource.path'), 'Project path renders beside OpenSpec header')2169+ check(!sourceControlsSec.includes('children: selectedSource.path'), 'Project path is not duplicated in source controls row')2170+ check(sourceControlsSec.includes("className: 'bg-(--ui-bg-quinary) transition-colors hover:bg-primary/[0.06] hover:bg-primary/[0.06]!'"), 'Source selector has canonical visible wash and hover')2171+}2172+2173+// [Visual polish] Project-bar hover wash and Archived button2174+{2175+ const ppStart = source.indexOf('function OpenSpecPage')2176+ const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2177+ const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2178+ const sourceControlsStart = ppSec.indexOf('// Source controls')2179+ const viewSwitchStart = ppSec.indexOf('// View switch')2180+ const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2181+ check(sourceControlsSec.includes("SelectTrigger"), 'Project bar still uses SelectTrigger for source selection')2182+ check(sourceControlsSec.includes('bg-(--ui-bg-quinary)'), 'Project bar keeps base bg-(--ui-bg-quinary)')2183+ check(sourceControlsSec.includes('hover:bg-primary/[0.06]'), 'Project bar has canonical hover wash hover:bg-primary/[0.06]')2184+ check(sourceControlsSec.includes('transition-colors'), 'Project bar hover is paired with transition-colors')2185+}2186+{2187+ const wvStart = source.indexOf('function WorkView')2188+ const wvEnd = source.indexOf('\nfunction ', wvStart + 1)2189+ const wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)2190+ check(wvSec.includes("jsx(Button, { variant: 'outline', size: 'sm', type: 'button', 'aria-pressed': showArchived"), 'WorkView uses outlined sm Button with aria-pressed for Archived')2191+ check(wvSec.includes("backgroundColor: showArchived ? 'var(--ui-control-active-background)' : undefined"), 'Archived button active wash uses canonical control token')2192+ check(wvSec.includes("onClick: function() { onToggleArchived(!showArchived) }"), 'Archived button toggles through existing handler')2193+ check(wvSec.includes("children: [showArchived ? '\\u2713' : null, ' ', 'Archived']"), 'Archived button renders checkmark conditional on showArchived plus label')2194+ check(!wvSec.includes("type: 'checkbox'"), 'WorkView no longer contains a checkbox input')2195+ check(!wvSec.includes("jsx('label'"), 'WorkView no longer wraps Archived in a separate label element')2196+}2197+2198+check(!source.includes('navigator.clipboard'), 'No custom Clipboard API added (SDK CopyButton feedback retained)')2199+2200+if (r.__testAvailable) {2201+ const ab = identity && identity.archivedButton ? identity.archivedButton : {}2202+ console.log(' Archived button descriptor')2203+ check(ab.isButton === true, 'Archived control is an SDK Button', JSON.stringify(ab.isButton))2204+ check(ab.variantOutline === true, 'Archived button uses variant outline', JSON.stringify(ab.variantOutline))2205+ check(ab.sizeSm === true, 'Archived button uses size sm', JSON.stringify(ab.sizeSm))2206+ check(ab.typeButton === true, 'Archived control is an explicit native button', JSON.stringify(ab.typeButton))2207+ check(ab.noCustomKeyHandler === true, 'Archived control preserves native keyboard activation', JSON.stringify(ab.noCustomKeyHandler))2208+ check(ab.ariaPressedFalse === true, 'aria-pressed reflects false when showArchived is false', JSON.stringify(ab.ariaPressedFalse))2209+ check(ab.ariaPressedTrue === true, 'aria-pressed reflects true when showArchived is true', JSON.stringify(ab.ariaPressedTrue))2210+ check(ab.inactiveHasNoActiveWash === true, 'Archived button is not washed when unpressed', JSON.stringify(ab.inactiveHasNoActiveWash))2211+ check(ab.activeWash === true, 'Archived button gets canonical active wash when pressed', JSON.stringify(ab.activeWash))2212+ check(ab.labelIncludesArchived === true, 'Archived button contains visible "Archived" label', JSON.stringify(ab.labelIncludesArchived))2213+ check(ab.activeCheckWhenPressed === true, 'Archived button shows checkmark when pressed', JSON.stringify(ab.activeCheckWhenPressed))2214+ check(ab.noCheckboxSibling === true, 'No separate checkbox remains in WorkView', JSON.stringify(ab.noCheckboxSibling))2215+ check(ab.clickTogglesOnce === true, 'Click toggles showArchived from false to true once', JSON.stringify(ab.clickTogglesOnce))2216+ check(ab.secondClickTogglesOnce === true, 'Second click toggles showArchived from true to false once', JSON.stringify(ab.secondClickTogglesOnce))2217+ check(ab.enterTogglesOnce === true, 'Native Enter activation toggles Archived once', JSON.stringify(ab.enterTogglesOnce))2218+ check(ab.spaceTogglesOnce === true, 'Native Space activation toggles Archived once', JSON.stringify(ab.spaceTogglesOnce))2219+} else {2220+ fail('Archived button descriptor', '__test not available')2221+}2222+2223+// Static proof: WorkView archived button wired through existing handler2224+{2225+ const wvStart = source.indexOf('function WorkView')2226+ const wvEnd = source.indexOf('\nfunction ', wvStart + 1)2227+ const wvSec = source.slice(wvStart, wvEnd > 0 ? wvEnd : source.length)2228+ check(wvSec.includes('onToggleArchived(!showArchived)'), 'Archived button calls existing onToggleArchived handler')2229+}2230+2231+// [Visual polish] Project-bar hover wash — static contract check2232+{2233+ const ppStart = source.indexOf('function OpenSpecPage')2234+ const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2235+ const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2236+ const sourceControlsStart = ppSec.indexOf('// Source controls')2237+ const viewSwitchStart = ppSec.indexOf('// View switch')2238+ const sourceControlsSec = ppSec.slice(sourceControlsStart, viewSwitchStart)2239+ check(sourceControlsSec.includes('bg-(--ui-bg-quinary)'), 'Project bar keeps base bg-(--ui-bg-quinary)')2240+ check(sourceControlsSec.includes('transition-colors hover:bg-primary/[0.06]'), 'Project bar has exact canonical hover pair')2241+ check(sourceControlsSec.includes('hover:bg-primary/[0.06]!'), 'Project bar important hover override beats unlayered input chrome')2242+ check(sourceControlsSec.includes("'data-openspec-source-trigger': 'true'"), 'Project bar carries scoped hover marker')2243+ check(source.includes('[data-openspec-source-trigger].desktop-input-chrome {'), 'Project bar has scoped ambient selector for runtime CSS')2244+ check(source.includes('[data-openspec-source-trigger].desktop-input-chrome:hover'), 'Project bar has scoped hover selector for runtime CSS')2245+ check(source.includes('background-color: var(--chrome-action-hover) !important;'), 'Project bar scoped wash uses canonical control hover token')2246+ check(!sourceControlsSec.includes("style: { minWidth: '120px', flexShrink: 1, background"), 'Project bar has no inline background override')2247+}2248+2249+// ═══════════════════════════════════════════════════════════════════════════════2250+// [Interaction 1.6] Keyboard activation, hover/focus markers2251+// ═══════════════════════════════════════════════════════════════════════════════2252+console.log('\n[Interaction 1.6] Keyboard activation and interaction markers')2253+2254+{2255+ const kb = identity && identity.keyboard ? identity.keyboard : {}2256+ check(kb.enterActivatesOnce === true, 'Enter activates action once', JSON.stringify(kb.enterActivatesOnce))2257+ check(kb.spaceActivatesOnce === true, 'Space activates action once on keydown', JSON.stringify(kb.spaceActivatesOnce))2258+ check(kb.spacePreventsDefault === true, 'Space prevents default scrolling', JSON.stringify(kb.spacePreventsDefault))2259+ check(kb.otherKeysIgnored === true, 'Unrelated keys do nothing', JSON.stringify(kb.otherKeysIgnored))2260+}2261+{2262+ const cd = identity && identity.card ? identity.card : {}2263+ check(cd.tabIndexZero === true, 'Card is keyboard-addressable (tabIndex 0)', JSON.stringify(cd.tabIndexZero))2264+ check(cd.roleButton === true, 'Card has button semantics', JSON.stringify(cd.roleButton))2265+ check(cd.hasKeyDown === true, 'Card wires keydown handler', JSON.stringify(cd.hasKeyDown))2266+}2267+{2268+ const rl = identity && identity.rail ? identity.rail : {}2269+ check(rl.tabIndexZero === true, 'Collapsed rail is keyboard-addressable (tabIndex 0)', JSON.stringify(rl.tabIndexZero))2270+ check(rl.roleButton === true, 'Collapsed rail has button semantics', JSON.stringify(rl.roleButton))2271+ check(rl.hasKeyDown === true, 'Collapsed rail wires keydown handler', JSON.stringify(rl.hasKeyDown))2272+ check(rl.dotTonePresent === true, 'Status dot preserved on collapsed rail', JSON.stringify(rl.dotTonePresent))2273+ check(rl.hoverExactRail === true, 'Collapsed rail hover uses exact bundled-Kanban utility hover:bg-(--ui-bg-quinary)', JSON.stringify(rl.hoverExactRail))2274+ check(rl.hoverGenericAbsent === true, 'Collapsed rail hover no longer uses generic --ui-hover-background', JSON.stringify(rl.hoverGenericAbsent))2275+}2276+// Static interaction markers — hover/focus-visible + shared handler + status preservation2277+{2278+ const bcStart = source.indexOf('function BoardCard')2279+ const bcEnd = source.indexOf('\nfunction ', bcStart + 1)2280+ const bcSec = source.slice(bcStart, bcEnd > 0 ? bcEnd : source.length)2281+ check(bcSec.includes('focus-visible'), 'Card has focus-visible marker')2282+ check(bcSec.includes('hover:'), 'Card has hover marker')2283+ check(bcSec.includes('hover:bg-primary/[0.06]'), 'Card hover marker is exact bundled-Kanban utility hover:bg-primary/[0.06]')2284+ check(!bcSec.includes('--ui-hover-background'), 'Card has no generic --ui-hover-background hover marker')2285+ check(bcSec.includes('tabIndex: 0'), 'Card sets tabIndex 0')2286+ check(bcSec.includes('onKeyDown'), 'Card wires onKeyDown')2287+ check(bcSec.includes('handleActivateKey'), 'Card uses shared Enter/Space handler')2288+ check(bcSec.includes('borderLeftColor: statusTone(status)'), 'Card keeps status-colored left border')2289+}2290+{2291+ const colStart = source.indexOf('function BoardColumn')2292+ const colEnd = source.indexOf('\nfunction ', colStart + 1)2293+ const colSec = source.slice(colStart, colEnd > 0 ? colEnd : source.length)2294+ check(colSec.includes('focus-visible'), 'Collapsed rail has focus-visible marker')2295+ check(colSec.includes('hover:bg-(--ui-bg-quinary)'), 'Collapsed rail hover marker is exact bundled-Kanban utility hover:bg-(--ui-bg-quinary)')2296+ check(!colSec.includes('--ui-hover-background'), 'Collapsed rail has no generic --ui-hover-background hover marker')2297+ check(colSec.includes('tabIndex: 0'), 'Rail sets tabIndex 0')2298+ check(colSec.includes('onKeyDown'), 'Rail wires onKeyDown')2299+ check(colSec.includes('handleActivateKey'), 'Rail uses shared Enter/Space handler')2300+ check(colSec.includes('backgroundColor: statusTone(status)'), 'Rail keeps status dot')2301+ // Expanded header is the same full-width native button with focus-visible.2302+ check(colSec.includes("'Collapse ' + label"), 'Expanded header button has collapse label/title')2303+ check(colSec.includes("name: 'chevron-left'"), 'Expanded header uses chevron-left Codicon')2304+ check(colSec.includes('type: \'button\''), 'Expanded header uses native button')2305+ check(colSec.includes('focus-visible:bg-(--ui-control-hover-background)'), 'Expanded header has visible focus wash')2306+}2307+// --- CSS-cascade base backgrounds: railStyle loses inline background, rail uses compiled class ---2308+{2309+ const railFnStart = source.indexOf('function railStyle')2310+ const railFnEnd = source.indexOf('\n}', railFnStart + 1)2311+ const railFnSec = source.slice(railFnStart, railFnEnd > 0 ? railFnEnd : source.length)2312+ check(!railFnSec.includes('background'), 'railStyle has no inline background (class-based wash)')2313+ check(source.includes('bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]'),2314+ 'Collapsed rail base class is exact compiled bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]')2315+ check(source.includes("background: 'color-mix(in srgb, var(--ui-bg-quinary) 50%, transparent)'"),2316+ 'Expanded lane keeps inline color-mix background')2317+}2318+2319+// ═══════════════════════════════════════════════════════════════════════════════2320+// [Lane/drawer interaction] Expanded header activation behavior2321+// ═══════════════════════════════════════════════════════════════════════════════2322+console.log('\n[Lane/drawer interaction] Expanded header activation')2323+2324+if (r.__testAvailable) {2325+ const expandedHeader = identity.expandedHeader || {}2326+ console.log(' Expanded header descriptor')2327+ check(expandedHeader.rootIsButton === true, 'Expanded lane header root is a native button', JSON.stringify(expandedHeader.rootIsButton))2328+ check(expandedHeader.typeButton === true, 'Expanded header button type is button', JSON.stringify(expandedHeader.typeButton))2329+ check(expandedHeader.labelStartsCollapse === true, 'Expanded header label starts with "Collapse"', JSON.stringify(expandedHeader.labelStartsCollapse))2330+ check(expandedHeader.hasChevronLeft === true, 'Expanded header renders chevron-left Codicon', JSON.stringify(expandedHeader.hasChevronLeft))2331+ check(expandedHeader.hasMinus === false, 'Expanded header does not render raw minus glyph', JSON.stringify(expandedHeader.hasMinus))2332+ check(expandedHeader.clickActivatesOnce === true, 'Click activates collapse once', JSON.stringify(expandedHeader.clickActivatesOnce))2333+ check(expandedHeader.enterActivatesOnce === true, 'Enter activates collapse once', JSON.stringify(expandedHeader.enterActivatesOnce))2334+ check(expandedHeader.spaceActivatesOnce === true, 'Space activates collapse once', JSON.stringify(expandedHeader.spaceActivatesOnce))2335+ check(expandedHeader.spacePreventsDefault === true, 'Space prevents default scrolling on header', JSON.stringify(expandedHeader.spacePreventsDefault))2336+ check(expandedHeader.otherKeysIgnored === true, 'Unrelated keys ignored on header', JSON.stringify(expandedHeader.otherKeysIgnored))2337+ check(expandedHeader.focusWashExact === true, 'Expanded header descriptor exposes exact focus wash class', JSON.stringify(expandedHeader.focusWashExact))2338+} else {2339+ fail('Expanded header activation behavior', '__test not available')2340+}2341+2342+if (r.__testAvailable) {2343+ const drawer = identity.detailDrawer || {}2344+ console.log(' DetailDrawer descriptor')2345+ check(drawer.rootExists === true, 'DetailDrawer root exists', JSON.stringify(drawer.rootExists))2346+ check(drawer.isAbsoluteRight === true, 'DetailDrawer root is positioned absolute right', JSON.stringify(drawer.isAbsoluteRight))2347+ check(drawer.hasLeftBorder === true, 'DetailDrawer has left border', JSON.stringify(drawer.hasLeftBorder))2348+ check(drawer.widthIsCompact === true, 'DetailDrawer width is compact (26rem)', JSON.stringify(drawer.widthIsCompact))2349+ check(drawer.hasCloseButton === true, 'DetailDrawer has close button', JSON.stringify(drawer.hasCloseButton))2350+ check(drawer.closeLabel === 'Close detail', 'DetailDrawer close button label is "Close detail"', JSON.stringify(drawer.closeLabel))2351+ check(drawer.copyLabel === 'Copy item reference', 'DetailDrawer copy button label is "Copy item reference"', JSON.stringify(drawer.copyLabel))2352+ check(drawer.copyStopProp === false, 'DetailDrawer copy button does not stop propagation (not inside clickable card)', JSON.stringify(drawer.copyStopProp))2353+} else {2354+ fail('DetailDrawer render/descriptor', '__test not available')2355+}2356+2357+// ═══════════════════════════════════════════════════════════════════════════════2358+// [Source invalidation] Stale detail cleared when retained source becomes invalid2359+// ═══════════════════════════════════════════════════════════════════════════════2360+console.log('\n[Source invalidation] Stale detail cleared on invalid transition')2361+2362+if (r.__testAvailable) {2363+ const inv = identity.sourceInvalidation || {}2364+ check(inv.idPreservedWhenInvalid === true, 'Retained source id is preserved even when invalid', JSON.stringify(inv.idPreservedWhenInvalid))2365+ const di = identity && identity.detailInvalidation ? identity.detailInvalidation : {}2366+ check(di.__error === undefined, 'Detail invalidation lifecycle executed without error', di.__error || '')2367+ check(di.detailOpenBefore === true, 'Detail opens after selecting an item on valid source', JSON.stringify(di))2368+ check(di.detailClosedAfterInvalid === true, 'Detail closes when retained source becomes invalid', JSON.stringify(di))2369+ check(di.selectedItemCleared === true, 'selectedItem state is cleared on invalidity transition', JSON.stringify(di))2370+ check(di.detailStaysClosedAfterRevalidation === true, 'Detail stays closed after source recovers (selectedItem was cleared)', JSON.stringify(di))2371+} else {2372+ fail('Source invalidation helper', '__test not available')2373+}2374+2375+// Static proof: invalid-source cleanup effect uses validity-aware transition2376+{2377+ const ppStart = source.indexOf('function OpenSpecPage')2378+ const ppEnd = source.indexOf('\nfunction ', ppStart + 1)2379+ const ppSec = source.slice(ppStart, ppEnd > 0 ? ppEnd : source.length)2380+ check(ppSec.includes('previousResolvedInvalid'), 'OpenSpecPage tracks previous resolved validity state')2381+ check(ppSec.includes('resolvedInvalid'), 'OpenSpecPage computes resolved-invalid flag')2382+ check(ppSec.includes('setSelectedItem(null)'), 'OpenSpecPage clears selectedItem on invalidity transition')2383+}2384+2385+// ═══════════════════════════════════════════════════════════════════════════════2386+// [Remediation 3.6] Source copy-before-name ordering (deep-rendered)2387+// ═══════════════════════════════════════════════════════════════════════════════2388+console.log('\n[Remediation 3.6] Source copy control order')2389+2390+{2391+ const sr = identity && identity.sourceRow ? identity.sourceRow : {}2392+ check(sr.__error === undefined, 'Source-row deep render executed without error', sr.__error || '')2393+ check(sr.copyImmediatelyBeforeSelector === true, 'Source CopyButton renders immediately before source selector',2394+ 'copyIndex=' + sr.copyIndex + ' selectorIndex=' + sr.selectorIndex)2395+ check(sr.selectorHasExactHoverPair === true, 'Source trigger keeps exact transition-colors + hover pair', JSON.stringify(sr.selectorHasExactHoverPair))2396+ check(sr.selectorHasImportantHover === true, 'Source trigger important hover override is present', JSON.stringify(sr.selectorHasImportantHover))2397+ check(sr.selectorHasHoverMarker === true, 'Source trigger carries scoped hover marker', JSON.stringify(sr.selectorHasHoverMarker))2398+ check(sr.scopedHoverWash === true, 'Source trigger has runtime scoped hover wash rule', JSON.stringify(sr.scopedHoverWash))2399+ check(sr.selectorNoInlineBackground === true, 'Source trigger has no inline background override', JSON.stringify(sr.selectorNoInlineBackground))2400+}2401+2402+// ════════════════════════════════════════════════════════════════════════════════2403+// [Remediation 3.10] CSS-cascade base backgrounds (deep-rendered)2404+// ════════════════════════════════════════════════════════════════════════════════2405+console.log('\n[Remediation 3.10] CSS-cascade base backgrounds')2406+2407+{2408+ const cd = identity && identity.card ? identity.card : {}2409+ check(cd.baseBgClass === true, 'Card base class is exact compiled bg-(--ui-bg-elevated)', JSON.stringify(cd.baseBgClass))2410+ check(cd.noInlineBg === true, 'Card node has no inline style.background', JSON.stringify(cd.noInlineBg))2411+ check(cd.hoverExactCard === true, 'Card hover remains exact hover:bg-primary/[0.06]', JSON.stringify(cd.hoverExactCard))2412+}2413+{2414+ const rl = identity && identity.rail ? identity.rail : {}2415+ check(rl.baseBgClass === true, 'Collapsed rail base class is exact compiled bg-[color-mix(in_srgb,var(--ui-bg-quinary)_50%,transparent)]',2416+ JSON.stringify(rl.baseBgClass))2417+ check(rl.noInlineBg === true, 'Collapsed rail node has no inline style.background', JSON.stringify(rl.noInlineBg))2418+ check(rl.hoverExactRail === true, 'Collapsed rail hover remains exact hover:bg-(--ui-bg-quinary)', JSON.stringify(rl.hoverExactRail))2419+}2420+2421+// ═══════════════════════════════════════════════════════════════════════════════2422+// [Remediation 3.14] Keyboard-focus wash (deep-rendered)2423+// ═══════════════════════════════════════════════════════════════════════════════2424+console.log('\n[Remediation 3.14] Visible keyboard focus wash')2425+2426+{2427+ const cd = identity && identity.card ? identity.card : {}2428+ check(cd.focusWashExact === true, 'Card focus wash is exact compiled focus-visible:bg-(--ui-control-hover-background)',2429+ JSON.stringify(cd.focusWashExact))2430+ check(cd.noDeadOutline === true, 'Card no longer relies on focus-visible:outline-2 + focus-visible:outline-(--ui-focus-border)',2431+ JSON.stringify(cd.noDeadOutline))2432+}2433+{2434+ const rl = identity && identity.rail ? identity.rail : {}2435+ check(rl.focusWashExact === true, 'Collapsed rail focus wash is exact compiled focus-visible:bg-(--ui-control-hover-background)',2436+ JSON.stringify(rl.focusWashExact))2437+ check(rl.noDeadOutline === true, 'Collapsed rail no longer relies on focus-visible:outline-2 + focus-visible:outline-(--ui-focus-border)',2438+ JSON.stringify(rl.noDeadOutline))2439+}2440+2441+// ═══════════════════════════════════════════════════════════════════════════════2442+// Summary2443+// ═══════════════════════════════════════════════════════════════════════════════2444+console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===')2445+if (failures.length > 0) {2446+ console.log('\nFailed tests:')2447+ for (var i = 0; i < failures.length; i++) {2448+ console.log(' - ' + failures[i].name + (failures[i].detail ? ': ' + failures[i].detail : ''))2449+ }2450+}2451+process.exit(failed > 0 ? 1 : 0)@@ -0,0 +1,183 @@
1+2+/**3+ * Lane override behavior tests — toggle semantics, phase pruning, source keying.4+ *5+ * These test pure helpers that must be added to plugin.js __test export:6+ * - computeCollapsedSet(autoCollapsed, manualOverrides, statusOrder)7+ * - toggleOverride(prev, status, autoCollapsed) → next overrides8+ * - computeLanePhase(grouped, statusOrder) → phase signature string9+ * - pruneStaleOverrides(prevOverrides, prevPhase, lanePhase) → pruned overrides10+ * - workViewIsSourceKeyed → true (static check)11+ *12+ * Run: node tests/lane-override-behavior.mjs13+ * Exit 0 = pass, exit 1 = failure.14+ */15+16+import { readFileSync } from 'node:fs'17+import { execSync } from 'node:child_process'18+import { dirname, resolve } from 'node:path'19+import { fileURLToPath } from 'node:url'20+21+const __dirname = dirname(fileURLToPath(import.meta.url))22+const pluginPath = resolve(__dirname, '../desktop/plugin.js')23+const source = readFileSync(pluginPath, 'utf8')24+25+let passed = 026+let failed = 027+const failures = []28+29+function ok(name) { passed++; console.log(' PASS ' + name) }30+function fail(name, d) {31+ failed++32+ const msg = ' FAIL ' + name + (d ? ': ' + d : '')33+ console.error(msg)34+ failures.push({ name, detail: d })35+}36+function check(c, n, d) { c ? ok(n) : fail(n, d) }37+38+console.log('=== Lane override behavior tests ===\n')39+40+// ═══════════════════════════════════════════════════════════════════════════════41+// 0. Static source checks — WorkView is keyed by selectedSource.id42+// ═══════════════════════════════════════════════════════════════════════════════43+console.log('[0] WorkView source keying (static)')44+45+// The WorkView render call must have a key prop bound to selectedSource.id46+// Look for the pattern: jsx(WorkView, {..., key: selectedSource.id}) or47+// key={selectedSource.id} near the WorkView usage48+{49+ const workViewKeyPattern = /key:\s*selectedSource\.id/50+ check(workViewKeyPattern.test(source), 'WorkView render has key={selectedSource.id}',51+ !workViewKeyPattern.test(source) ? 'Missing key prop on WorkView jsx() call' : undefined)52+}53+54+// ═══════════════════════════════════════════════════════════════════════════════55+// Load dynamic helpers56+// ═══════════════════════════════════════════════════════════════════════════════57+console.log('\n[Dynamic] Load __test exports')58+59+const helperPath = resolve(__dirname, '_dynamic_import_helper.mjs')60+61+let r62+try {63+ const raw = execSync('node ' + helperPath, { encoding: 'utf8', timeout: 30000 }).trim()64+ r = JSON.parse(raw)65+} catch (e) {66+ fail('Dynamic import', (e.message || '').slice(0, 500))67+ r = {}68+}69+70+if (!r.__testAvailable) {71+ fail('__test available', '__test not available — cannot run behavioral tests')72+ console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===')73+ process.exit(failed > 0 ? 1 : 0)74+}75+76+// ═══════════════════════════════════════════════════════════════════════════════77+// 1. computeCollapsedSet — merge auto with manual overrides78+// ═══════════════════════════════════════════════════════════════════════════════79+console.log('\n[1] computeCollapsedSet')80+81+const cs = r.collapsedSet || {}82+check(cs.autoOnlyEmpty !== undefined, 'computeCollapsedSet produces results')83+84+// Empty overrides → auto rules85+check(cs.autoOnlyEmpty === true, 'Empty lane collapsed when board has work, no override',86+ JSON.stringify(cs.autoOnlyEmpty))87+check(cs.autoOnlyPopulated === true, 'Populated lane expanded when board has work, no override',88+ JSON.stringify(cs.autoOnlyPopulated))89+90+// Manual override wins over auto91+check(cs.overrideEmpty === true, 'Override overrides auto-collapse for empty lane (user expanded)',92+ JSON.stringify(cs.overrideEmpty))93+check(cs.overridePopulated === true, 'Override overrides auto-expand for populated lane (user collapsed)',94+ JSON.stringify(cs.overridePopulated))95+96+// ═══════════════════════════════════════════════════════════════════════════════97+// 2. toggleOverride — minimal deviation storage98+// ═══════════════════════════════════════════════════════════════════════════════99+console.log('\n[2] toggleOverride (minimal deviation)')100+101+const to = r.toggle || {}102+check(typeof to.firstToggleEmpty === 'boolean', 'toggleOverride returns boolean for empty lane')103+check(to.firstToggleEmpty === false, 'First toggle on auto-collapsed empty lane: stores false (expanded)',104+ JSON.stringify(to.firstToggleEmpty))105+check(to.secondToggleEmpty === undefined || to.secondToggleEmpty === null,106+ 'Second toggle on previously-toggled empty lane: deletes override (back to auto)',107+ JSON.stringify(to.secondToggleEmpty))108+109+check(to.firstTogglePopulated === true, 'First toggle on auto-expanded populated lane: stores true (collapsed)',110+ JSON.stringify(to.firstTogglePopulated))111+check(to.secondTogglePopulated === undefined || to.secondTogglePopulated === null,112+ 'Second toggle on previously-toggled populated lane: deletes override (back to auto)',113+ JSON.stringify(to.secondTogglePopulated))114+115+// ═══════════════════════════════════════════════════════════════════════════════116+// 3. computeLanePhase — phase signature string117+// ═══════════════════════════════════════════════════════════════════════════════118+console.log('\n[3] computeLanePhase')119+120+const lp = r.lanePhase || {}121+check(typeof lp.empty === 'string', 'computeLanePhase returns string')122+check(lp.empty === 'todo:empty|in-progress:full|done:empty',123+ 'Phase signature for empty todo, populated in-progress, empty done',124+ JSON.stringify(lp.empty))125+check(lp.full === 'todo:full|in-progress:empty|done:full',126+ 'Phase signature for populated todo, empty in-progress, populated done',127+ JSON.stringify(lp.full))128+check(lp.nullGrouped === null || lp.nullGrouped === undefined,129+ 'computeLanePhase returns null for null grouped input',130+ JSON.stringify(lp.nullGrouped))131+132+// ═══════════════════════════════════════════════════════════════════════════════133+// 4. pruneStaleOverrides — phase-change pruning134+// ═══════════════════════════════════════════════════════════════════════════════135+console.log('\n[4] pruneStaleOverrides')136+137+const po = r.prune || {}138+139+// Phase changed on a lane with override → override pruned140+check(po.changedLanePruned === true, 'Phase change on override lane prunes that override',141+ JSON.stringify(po.changedLanePruned))142+143+// Phase unchanged on a lane with override → override preserved144+check(po.unchangedLaneKept === true, 'Phase unchanged on override lane preserves override',145+ JSON.stringify(po.unchangedLaneKept))146+147+// Phase changed on a lane WITHOUT override → nothing changes148+check(po.changedNoOverride === true, 'Phase change on non-overridden lane is harmless',149+ JSON.stringify(po.changedNoOverride))150+151+// Null previous phase (first render) → no pruning152+check(po.nullPrevPhase === true, 'Null previous phase (first render) does not prune',153+ JSON.stringify(po.nullPrevPhase))154+155+// Multiple overrides, mixed phase changes → only affected ones pruned156+check(po.mixedPruning === true, 'Mixed phase changes: only changed-lane overrides pruned, others kept',157+ JSON.stringify(po.mixedPruning))158+159+// ═══════════════════════════════════════════════════════════════════════════════160+// 5. Toggle semantic integration — second toggle deletes override161+// ═══════════════════════════════════════════════════════════════════════════════162+console.log('\n[5] Toggle integration (toggle → toggle → auto restored)')163+164+const ti = r.toggleIntegration || {}165+check(ti.autoCollapsedDeleted === true,166+ 'Toggle auto-collapsed empty lane twice: override deleted, auto collapsed again',167+ JSON.stringify(ti.autoCollapsedDeleted))168+check(ti.autoExpandedDeleted === true,169+ 'Toggle auto-expanded populated lane twice: override deleted, auto expanded again',170+ JSON.stringify(ti.autoExpandedDeleted))171+check(ti.partialToggleKept === true,172+ 'Toggle only one lane: other lane overrides preserved',173+ JSON.stringify(ti.partialToggleKept))174+175+// ═══════════════════════════════════════════════════════════════════════════════176+// Summary177+// ═══════════════════════════════════════════════════════════════════════════════178+console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ===')179+if (failed > 0) {180+ console.log('\nFailures:')181+ failures.forEach(function(f) { console.log(' - ' + f.name + (f.detail ? ': ' + f.detail : '')) })182+}183+process.exit(failed > 0 ? 1 : 0)@@ -98,6 +98,93 @@ def test_dashboard_scan_attaches_sequence_without_openspec_files(tmp_path, monke
9898 assert not (openspec_root / "alpha" / ".sequence").exists()9999 100100 101+def test_title_from_markdown_prefers_closed_frontmatter_title(tmp_path):102+ path = tmp_path / "proposal.md"103+ path.write_text(104+ "---\n"105+ "title: Human-readable title\n"106+ "created: 2026-08-21\n"107+ "---\n"108+ "# Heading fallback\n",109+ encoding="utf-8",110+ )111+112+ assert plugin_api._title_from_markdown(path, "change-id") == "Human-readable title"113+114+115+def test_title_from_markdown_uses_h1_without_frontmatter(tmp_path):116+ path = tmp_path / "proposal.md"117+ path.write_text("# Heading title\n\nBody\n", encoding="utf-8")118+119+ assert plugin_api._title_from_markdown(path, "change-id") == "Heading title"120+121+122+def test_title_from_markdown_uses_identifier_without_title_or_h1(tmp_path):123+ path = tmp_path / "proposal.md"124+ path.write_text("Body without a title\n", encoding="utf-8")125+126+ assert plugin_api._title_from_markdown(path, "change-id") == "change-id"127+128+129+def test_title_from_markdown_ignores_nonleading_frontmatter_looking_block(tmp_path):130+ path = tmp_path / "proposal.md"131+ path.write_text(132+ "# Real heading\n"133+ "\n"134+ "---\n"135+ "title: Not metadata\n"136+ "---\n",137+ encoding="utf-8",138+ )139+140+ assert plugin_api._title_from_markdown(path, "change-id") == "Real heading"141+142+143+def test_title_from_markdown_falls_back_after_unclosed_frontmatter(tmp_path):144+ path = tmp_path / "proposal.md"145+ path.write_text(146+ "---\n"147+ "title: Unusable because the block is unclosed\n"148+ "# Heading fallback\n",149+ encoding="utf-8",150+ )151+152+ assert plugin_api._title_from_markdown(path, "change-id") == "Heading fallback"153+154+155+def test_change_summary_exposes_frontmatter_title(tmp_path):156+ change_dir = tmp_path / "change-id"157+ change_dir.mkdir()158+ (change_dir / "proposal.md").write_text(159+ "---\n"160+ "title: Change summary title\n"161+ "---\n"162+ "## Summary\n",163+ encoding="utf-8",164+ )165+166+ summary = plugin_api._change_summary(change_dir, "ivault")167+168+ assert summary is not None169+ assert summary["title"] == "Change summary title"170+171+172+def test_idea_summary_exposes_frontmatter_title(tmp_path):173+ idea_path = tmp_path / "idea-id.md"174+ idea_path.write_text(175+ "---\n"176+ "title: Idea summary title\n"177+ "---\n"178+ "## Summary\n",179+ encoding="utf-8",180+ )181+182+ summary = plugin_api._idea_summary(idea_path, "ivault")183+184+ assert summary is not None185+ assert summary["title"] == "Idea summary title"186+187+101188 def test_change_sequence_tool_declares_order_and_dependencies(tmp_path, monkeypatch):102189 monkeypatch.setattr(registry, "db_path", lambda: tmp_path / "openspec.db")103190 monkeypatch.setattr(tools, "_registry_module", lambda: registry)@@ -127,3 +214,87 @@ def test_change_sequence_tool_declares_order_and_dependencies(tmp_path, monkeypa
127214 context = json.loads(tools.openspec_context({"identifier": "demo"}))128215 final = next(item for item in context["changes"] if item["name"] == "final-proof")129216 assert final["sequence"]["dependsOn"] == ["phase-one", "phase-two"]217+218+219+# ---------------------------------------------------------------------------220+# Idea resolution (ivault/os_fd3445 tasks 1.3): openspec_context must list and221+# resolve registered idea references using the same source-qualified shape as222+# changes, and must fail explicitly on cross-kind token ambiguity.223+# ---------------------------------------------------------------------------224+225+226+def _register_demo_source(tmp_path, monkeypatch):227+ """Isolate registry DB to tmp and point tools at the real registry module."""228+ monkeypatch.setattr(registry, "db_path", lambda: tmp_path / "openspec.db")229+ monkeypatch.setattr(tools, "_registry_module", lambda: registry)230+ registry.add_source(str(tmp_path), "demo")231+232+233+def _write_idea(tmp_path, stem: str, body: str = "Idea body text"):234+ ideas_root = tmp_path / "openspec" / "ideas"235+ ideas_root.mkdir(parents=True, exist_ok=True)236+ idea_file = ideas_root / f"{stem}.md"237+ idea_file.write_text(f"---\ntitle: {stem.title()}\n---\n{body}\n", encoding="utf-8")238+ return idea_file239+240+241+def test_openspec_context_lists_ideas_for_bare_source(tmp_path, monkeypatch):242+ _register_demo_source(tmp_path, monkeypatch)243+ _write_idea(tmp_path, "hermes-agentmail-inbox-integration")244+245+ result = json.loads(tools.openspec_context({"identifier": "demo"}))246+247+ assert result["ok"] is True248+ ideas = result["ideas"]249+ assert len(ideas) == 1250+ idea = ideas[0]251+ assert idea["name"] == "hermes-agentmail-inbox-integration"252+ assert idea["filename"] == "hermes-agentmail-inbox-integration.md"253+ assert idea["token"] == "os_1655dd"254+255+256+def test_openspec_context_resolves_idea_by_token(tmp_path, monkeypatch):257+ _register_demo_source(tmp_path, monkeypatch)258+ _write_idea(tmp_path, "hermes-agentmail-inbox-integration")259+260+ result = json.loads(tools.openspec_context({"identifier": "demo/os_1655dd"}))261+262+ assert result["ok"] is True263+ idea = result["idea"]264+ assert idea["name"] == "hermes-agentmail-inbox-integration"265+ assert idea["filename"] == "hermes-agentmail-inbox-integration.md"266+ assert idea["token"] == "os_1655dd"267+ assert "Idea body text" in idea["content"]268+269+270+def test_openspec_context_resolves_idea_by_literal_stem(tmp_path, monkeypatch):271+ _register_demo_source(tmp_path, monkeypatch)272+ _write_idea(tmp_path, "hermes-agentmail-inbox-integration")273+274+ result = json.loads(275+ tools.openspec_context({"identifier": "demo/hermes-agentmail-inbox-integration"})276+ )277+278+ assert result["ok"] is True279+ idea = result["idea"]280+ assert idea["name"] == "hermes-agentmail-inbox-integration"281+ assert idea["filename"] == "hermes-agentmail-inbox-integration.md"282+ assert idea["token"] == "os_1655dd"283+284+285+def test_openspec_context_reports_cross_kind_ambiguity(tmp_path, monkeypatch):286+ _register_demo_source(tmp_path, monkeypatch)287+ # Change folder and idea file share the same stem → identical derived token.288+ change_dir = tmp_path / "openspec" / "changes" / "same-artifact"289+ change_dir.mkdir(parents=True)290+ (change_dir / "proposal.md").write_text("# Same Artifact\n", encoding="utf-8")291+ _write_idea(tmp_path, "same-artifact")292+ token = registry.change_token("same-artifact")293+294+ result = json.loads(tools.openspec_context({"identifier": f"demo/{token}"}))295+296+ assert result["ok"] is False297+ error = result["error"].lower()298+ assert "ambiguous" in error299+ for kind in ("change", "spec", "idea"):300+ assert kind in error@@ -227,6 +227,24 @@ def _resolve_spec(root: Path, registry, spec_ref: str) -> tuple[str | None, Path
227227 return None, None228228 229229 230+def _iter_ideas(root: Path):231+ """Yield every ``.md`` idea file under ``openspec/ideas/`` (sorted)."""232+ ideas_root = root / "openspec" / "ideas"233+ if ideas_root.is_dir():234+ for child in sorted(ideas_root.iterdir()):235+ if child.is_file() and child.suffix.lower() == ".md":236+ yield child237+238+239+def _resolve_idea(root: Path, registry, idea_ref: str) -> Path | None:240+ """Map an idea token (``os_xxx``) — or a literal idea stem (legacy) — to the241+ matching idea file. Returns ``None`` when nothing matches."""242+ for idea in _iter_ideas(root):243+ if registry.change_token(idea.stem) == idea_ref or idea.stem == idea_ref:244+ return idea245+ return None246+247+230248 def openspec_context(args: dict, **kwargs) -> str:231249 """Resolve a copyable identifier (``puzzletea`` or ``puzzletea/os_xxx``) into232250 repo path + (optionally) the change's proposal/tasks/design/specs content."""@@ -267,8 +285,31 @@ def openspec_context(args: dict, **kwargs) -> str:
267285 268286 if change_ref:269287 change_dir, archived = _resolve_change(repo_path, registry, change_ref)288+ spec_rel, spec_path = _resolve_spec(repo_path, registry, change_ref)289+ idea_path = _resolve_idea(repo_path, registry, change_ref)290+ matches = sum(291+ 1 for matched in (change_dir is not None, spec_rel is not None, idea_path is not None) if matched292+ )293+ if matches > 1:294+ return json.dumps({295+ "ok": False,296+ "name": name,297+ "path": str(repo_path),298+ "error": (299+ f"Reference '{change_ref}' is ambiguous: it matches more than one of the supported "300+ "artifact kinds (change, spec, idea). Use a literal change name, spec path, or idea "301+ "name instead."302+ ),303+ })304+ if idea_path is not None:305+ result["idea"] = {306+ "name": idea_path.stem,307+ "filename": idea_path.name,308+ "token": registry.change_token(idea_path.stem),309+ "content": _read_doc(idea_path),310+ }311+ return json.dumps(result)270312 if change_dir is None:271- spec_rel, spec_path = _resolve_spec(repo_path, registry, change_ref)272313 if spec_rel and spec_path:273314 result["spec"] = {274315 "path": spec_rel,@@ -280,7 +321,7 @@ def openspec_context(args: dict, **kwargs) -> str:
280321 "ok": False,281322 "name": name,282323 "path": str(repo_path),283- "error": f"No change or spec matching '{change_ref}' under openspec/.",324+ "error": f"No change, spec, or idea matching '{change_ref}' under openspec/.",284325 })285326 specs_root = change_dir / "specs"286327 specs = []@@ -316,11 +357,18 @@ def openspec_context(args: dict, **kwargs) -> str:
316357 {"path": rel, "token": registry.change_token(f"spec:{rel}")}317358 for rel, _path in _iter_specs(repo_path)318359 ]360+ ideas = [361+ {"name": idea.stem, "filename": idea.name, "token": registry.change_token(idea.stem)}362+ for idea in _iter_ideas(repo_path)363+ ]319364 result["changes"] = changes320365 result["specs"] = specs366+ result["ideas"] = ideas321367 result["hint"] = (322- "Active changes are listed in 'changes'; current specs are listed in 'specs'. Re-call with identifier "323- f"'{name}/<token>' to load a specific change or spec, or use workdir with other openspec_* tools."368+ "Active changes are listed in 'changes'; current specs are listed in 'specs'; ideas are "369+ "listed in 'ideas'. Re-call with identifier "370+ f"'{name}/<token>' to load a specific change, spec, or idea, or use workdir with other "371+ "openspec_* tools."324372 )325373 326374 return json.dumps(result)