Obsidian Workspace — project-scoped vault productivity (capture / notes / PM). Skills own folder layout and templates; /obw:pm splits a spec into blocking…

Project-scoped Obsidian vault productivity for Claude Code — quick capture, long-form notes, and project management. Skills own folder layout + file templates + PM conventions, while the /issue Claude Mod lists a view of the project's dashboard in a pane; vault I/O runs through the Obsidian CLI, deferring to the official obsidian:obsidian-cli skill for syntax. Each skill file is kept small so it doesn't burn your context budget.
Plugin identifier: obw (skills invoked as /obw:<name> or via natural language).
| Skill | Purpose |
|---|---|
/obw:init | Pick a vault, write .obsidian.yaml, install starter templates, bootstrap the project workspace, migrate an older layout |
/obw:jot <text> | Quick capture (timestamped bullet to today's daily note) or long-form note — triages by input shape |
/obw:pm [intent] | Task / document lifecycle, project-scoped; split a spec into blocking tickets |
obsidian CLI, run directly in the main context (no sub-agent). This plugin does not duplicate CLI syntax; it defers to the official obsidian:obsidian-cli skill and obsidian help.daily:append.task, doc) live in your vault's Obsidian Templates folder. On /obw:init the plugin copies starter files from templates/ only if the same name doesn't already exist — it never overwrites your edits..base files — core in Obsidian 1.9+) generated from plugin-internal templates via shell substitution, so contents never enter Claude's context.Run /issue <view> to list a view from the configured project's dashboard.base, created by /obw:pm, then select one to read its title, status, priority, and markdown body in the same pane. /issue with no argument opens the All Tasks view as an arrow-key list under the view picker: cards grouped by status (todo, in-progress, blocked, done), each heading with its card count, each row reading [H|M|L] <title> <due> <tags> sorted by priority, and done folded at the start. Click the list to give it the keys; ↑↓ move, PgUp/PgDn page, → or Enter opens a card or unfolds a heading, and ← goes back to the list or folds a heading. The list needs the terminal or desktop; elsewhere All Tasks falls back to the flat list other views use. Other views stay flat lists. A dashboard without All Tasks opens Active and says to run /obw:pm refresh dashboard. /issue <card> opens that card directly. The pane reports missing configuration, unavailable CLI output, and missing cards in place without adding vault content to the conversation.
A thin rule sets the card off from the list. Status and priority are coloured labels, followed by an AC <checked>/<total> count when the card's Acceptance Criteria section has checkboxes. Errors are drawn in red; progress and empty-list notices stay dim.
Mermaid blocks in the card body are drawn in the pane as text diagrams by uvx termaid@0.9.0, which needs uv. uv is optional: without it, a block stays the code block it is in the card. A block also stays a code block when its diagram type is not supported, when it starts with a %% comment or --- frontmatter, or when termaid fails, prints nothing, or takes longer than 5 s. The card is drawn first and each diagram replaces its code block when it is ready, so the first run may show the code block until uv has fetched termaid.
A shown card has an Open in browser Button that renders the card body, Mermaid included, through the viz plugin's render.sh. The Button appears only when viz is installed, found through installed_plugins.json under $CLAUDE_CONFIG_DIR or ~/.claude, and only in the terminal. Opening the browser uses macOS open; over SSH the pane shows the page's URL instead. The pane reports where the card was rendered, or why it was not. The page opens on the machine running Claude Code: when you reach the session through a terminal multiplexer or relay that does not set the SSH variables (herdr, for example), the browser opens on that host, not on the device you are looking at.
To read the page from another device on your tailnet, proxy viz's port once with tailscale serve --bg --https=18090 18090. After each render the pane reads tailscale serve status --json and, when a mapping covers the page's port, adds a Tailnet: https://<machine>.<tailnet>.ts.net:18090/… line. The pane never creates a mapping itself; without one it shows only the opened page.
Test the plugin with:
claude plugin test obsidian-workspace
The pane needs Claude Code 2.1.287 or later, where Claude Mods are on by default. Built and tested against Claude Code 2.1.287.
A session is bound to a task card by a file at ~/.claude-mobile/launches/<session_id>.json (cardPath, vault, project, createdAt, plus paneId from cc-mobile), or under $OBW_LAUNCHES_DIR when that is set. Three things write it: cc-mobile when it starts a session for a card, the Bind this session Button under a card opened with /issue <card>, and Claude setting a pm/<project>/tasks/ card's status to in-progress once the CLI confirms it. The Unbind this session Button, or a confirmed done of the bound card, removes it. While bound, the band above the prompt shows the card's name, title and AC x/y, reread after each turn. Three hooks in hooks/hooks.json run hooks/writeback.sh on every prompt, stop and permission prompt of every session; a session without that file leaves in a few milliseconds and touches nothing. For a bound session they write to the card:
session and appends 工作中 to ## Agent;pm/<project>/runs/<card>.md, linked from that line;需要核准.Writes go through obsidian eval and are read back; a failed write is logged to ~/.claude-mobile/writeback.log and never blocks the session.
obsidian plugin (from the obsidian-skills marketplace) — declared as a plugin dependency, so it auto-installs with this plugin as long as that marketplace is added (claude plugin marketplace add)obsidian-cli installed and enabled. The plugin's name is obsidian-cli but the executable it installs is obsidian (invoked as obsidian vault=<name> ...). This is not the unrelated standalone obsidian-cli binary by Yakitrak./obw:pm — task / doc templates)/obw:jot quick capture)/obw:pm dashboards — bundled in Obsidian 1.9+)/obw:init, which reads Obsidian's vault list and Templates settings with it)/obw:pm refresh dashboard, which compares dashboards through uv run --with pyyaml; optional for the /issue pane, where it draws Mermaid blocks as text diagrams through uvx termaid@0.9.0 — without it they show as code blocks)/issue pane's Open in browser Button — without it the Button is not drawn)/issue pane; the skills do not need it)/plugin install obsidian-workspace
Vault operations shell out to the obsidian CLI plus a few Unix helpers. To avoid repeated permission prompts, add these to user settings.json (~/.claude/settings.json) once:
{
"permissions": {
"allow": [
"Bash(obsidian:*)",
"Bash(cat:*)",
"Bash(jq:*)",
"Bash(cp:*)",
"Bash(sed:*)"
]
}
}
Or run /fewer-permission-prompts after a stuck /obw:init and it will scan transcripts and propose the same list.
Run /obw:init in a project root. The generated .obsidian.yaml:
vault: MyVault
note:
default_folder: Inbox
filename_strategy: title # title | slug | timestamp-title
pm:
project: my-project # Omit this section to disable /obw:pm
Daily note folder / filename / template are not in .obsidian.yaml — they come from Obsidian's Daily Notes settings.
/obw:pm)pm/
├── dashboard.base # Cross-project dashboard (optional, Bases)
└── {project}/
├── dashboard.base # Project dashboard (Bases)
├── tasks/ # Active tasks
│ └── archive/ # Completed tasks
└── docs/ # Docs
Every project gets tasks/, docs/, and dashboard.base — /obw:init creates all three up front, so a project is never a bare folder.
Upgrading a vault from before 0.9: re-run /obw:init. It detects the old layout and offers, each separately, to move archive/ into tasks/archive/ (through the CLI, so links follow), backfill the title property on existing notes, and regenerate the dashboards with the new views. Existing filenames are never renamed.
A plugin update can change dashboard views, formulas, columns, and filters. Run /obw:pm refresh dashboard to bring in All Tasks (and any other template change) on an existing vault. The refresh compares dashboard.base with every template version as parsed YAML. A dashboard identical to one of them is regenerated at once; one with hand edits lists them, warns that regenerating overwrites them, and asks first.
All notes are kebab-cased (Implement Auth → implement-auth.md), for both /obw:jot notes and /obw:pm tasks / docs. Because Obsidian's {{title}} resolves to the filename, the human-readable title lives in the title property — that is what the dashboards display.
Dashboards and searches depend on these frontmatter fields. If you edit the installed templates, keep the field names.
title, type: task, status (todo / in-progress / blocked / done), priority (high / medium / low), project, due (date), tags (list), parent (link), blocked_by (list of links), related (list of links), session (the Claude session UUID bound to the task), created, completed; the task body's ## Agent section holds one line per event of that sessiontitle, type: doc, project, created, updatedTasks link to each other by wikilink through three properties — blocked_by, related, and parent (epic → subtask). There is no separate issue ID: the kebab filename is the handle, and Obsidian rewrites links when a note is renamed.
Only one direction is stored. What a task blocks, and what its subtasks are, come from Obsidian's backlinks pane — a blocks field alongside blocked_by would only drift. Adding a blocker sets status: blocked; archiving a task lists whatever still depends on it and asks before unblocking. Ticket splitting writes its blocking edges to blocked_by; the unblocked frontier is a search excluding -[blocked_by: wikilinks, not a view.
/obw:jot #worklog 完成 API 重構 PR,等 review
/obw:jot API Redesign Proposal --folder Architecture --tag design
/obw:pm add task implement-auth, high priority, due 2026-05-01
/obw:pm implement-auth is blocked by db-migration
/obw:pm implement-auth is done, archive it
/obw:pm split this spec into tickets
/obw:pm refresh dashboard
Ticket splitting adapted from mattpocock/skills to-tickets (MIT, Copyright (c) 2026 Matt Pocock), commit 3cca18b368ae95cdbdebbff572ccafa662551015. Upstream's wide refactor expand-contract sequencing is not imported: the pm skill keeps the three vertical-slice rules plus prefactor and maps blocking edges onto existing blocked_by.
hooks/register.ts 729 lines1import type { On } from 'claude-code'
2import { bounded, MAX_CHARS } from './bounds.ts'
3import { configOf } from './config.ts'
4import { isBadCardName, dashboardPath, taskFolder } from './argv.ts'
5import { baseQueryArgv, cardPathArgv, viewsArgv } from './base-argv.ts'
6import type { Scope } from './base-argv.ts'
7import { baseQueryOutput, viewsOutput, readOutput, OBSIDIAN_TIMEOUT_MS } from './cli-output.ts'
8import type { Run } from './cli-output.ts'
9import { cardName, listRows, resolveArgument, rowNamed, rowSlug, rowsOutside } from './rows.ts'
10import { COUNT_VIEW, missingViewHint, missingViewText } from './counts.ts'
11import { listGroups } from './list.ts'
12import { boardSize } from './board-size.ts'
13import { BOARD_KEY, PANE_ID, boardMessage } from './board-message.ts'
14import type { Card } from './board.ts'
15import { acLabel, headerOf } from './card.ts'
16import type { CardHeader } from './card.ts'
17import { vizManifestPath, vizInstallPath, renderTarget, renderArgv, renderOutcome, RENDER_TIMEOUT_MS, isLoopbackUrl, tailnetUrl, SERVE_STATUS_ARGV, SERVE_TIMEOUT_MS } from './viz.ts'
18import { splitFences, termaidHeaderAllowed, diagramOutcome, TERMAID_ARGV, TERMAID_TIMEOUT_MS } from './mermaid.ts'
19import type { Segment } from './mermaid.ts'
20import { priorityColor, statusColor, RED } from './style.ts'
21import { bindingAction, bindingOf, bindingPath, sameBinding, statusChanges } from './bind.ts'
22import type { Binding } from './bind.ts'
23
24const PANE = { id: PANE_ID, title: 'obw issue', focus: true, closeOnEscape: true } as const
25
26type Browser = { kind: 'rendering' } | ReturnType<typeof renderOutcome> | (Extract<ReturnType<typeof renderOutcome>, { kind: 'opened' }> & { tailnet: string })
27
28// The card region under the list has its own state, so a card's outcome never replaces the list's message.
29// `segments` splits the clipped body once; `diagrams` is indexed by a mermaid block's position in `segments`.
30type CardRegion =
31 | { kind: 'loading'; path: string }
32 | { kind: 'error'; message: string }
33 | {
34 kind: 'shown'
35 path: string
36 header: CardHeader
37 body: string
38 segments: Segment[]
39 vizRoot: string | null
40 browser: Browser | null
41 diagrams: (string | undefined)[]
42 bindLine: Line | null
43 }
44
45type Shown = Extract<CardRegion, { kind: 'shown' }>
46
47type Line = { kind: 'error' | 'notice'; text: string }
48
49// Where the shown card was asked for: `list` is a row opened inside the All Tasks Client, drawn there as plain text.
50type Origin = 'argument' | 'list'
51
52type PlacedCard = CardRegion & { origin: Origin }
53
54// `loading` cannot be read off the message: the loading notice and the empty-view notice are both notices.
55type PaneState = {
56 message: Line | null
57 hint: string | null
58 listing: Line | null
59 loading: boolean
60 scope: Scope | null
61 views: string[]
62 chosen: string | null
63 cards: ReturnType<typeof listRows>
64 groups: ReturnType<typeof listGroups> | null
65 selected: string | null
66 card: PlacedCard | null
67}
68
69const LOADING: PaneState = {
70 message: { kind: 'notice', text: 'Reading the vault…' },
71 hint: null,
72 listing: null,
73 loading: true,
74 scope: null,
75 views: [],
76 chosen: null,
77 cards: [],
78 groups: null,
79 selected: null,
80 card: null,
81}
82
83let state: PaneState = LOADING
84// A CLI call can settle after a newer /issue or card read started; only the latest request writes the state.
85let requests = 0
86
87// A binding file may carry no project, which a card path can still name.
88type Stored = Omit<Binding, 'project'> & { project: string | null }
89type Bound = Stored & { card: { title: string | null; ac: string | null } | null }
90
91// The session's binding as loaded from its file; null while the session has none.
92let bound: Bound | null = null
93
94async function runProcess($: any, argv: string[], timeoutMs = OBSIDIAN_TIMEOUT_MS, stdin?: string): Promise<Run> {
95 try {
96 const result = await $.process.run(argv, { ...(stdin === undefined ? {} : { stdin }), timeoutMs })
97 return { kind: 'exited', exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }
98 } catch {
99 return { kind: 'rejected' }
100 }
101}
102
103function invalidate($: any) {
104 $.ui.invalidate('ui.render')
105}
106
107function showMessage($: any, request: number, message: string) {
108 if (request === requests) {
109 state = { ...LOADING, loading: false, message: { kind: 'error', text: message } }
110 invalidate($)
111 }
112}
113
114function showCard($: any, request: number, name: string, card: PlacedCard) {
115 if (request === requests) {
116 state = { ...state, selected: name, card }
117 invalidate($)
118 }
119}
120
121// Any fault while looking for viz only leaves viz unfound; the card still draws.
122async function findViz($: any): Promise<string | null> {
123 try {
124 const path = vizManifestPath(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
125 return path ? vizInstallPath(await $.fs.read(path)) : null
126 } catch {
127 return null
128 }
129}
130
131async function show($: any, path: string, origin: Origin = 'argument') {
132 const { scope } = state
133 if (!scope) return
134 const request = ++requests
135 const put = (card: CardRegion) => showCard($, request, path, { ...card, origin })
136 const built = cardPathArgv(scope, path)
137 if (!('argv' in built)) return put({ kind: 'error', message: `"${path}" is not a card path.` })
138 put({ kind: 'loading', path })
139 const output = readOutput(await runProcess($, built.argv))
140 if (output.kind === 'error') return put({ kind: 'error', message: output.message })
141 // The Client draws a card as plain text: it has no Button to open the browser and no room for diagrams.
142 const vizRoot = origin === 'list' ? null : await findViz($)
143 const header = headerOf(output.frontmatter)
144 const segments = splitFences(bounded(output.body).text)
145 put({ kind: 'shown', path, header, body: output.body, segments, vizRoot, browser: null, diagrams: [], bindLine: null })
146 if (origin === 'list') return
147 // termaid never holds /issue: an offline uvx can take seconds, and the card is already drawn.
148 void drawDiagrams($, request, segments).catch(() => {})
149}
150
151// One run at a time: a rejected run means uvx fails for every block, and a superseded read never draws.
152async function drawDiagrams($: any, request: number, segments: Segment[]) {
153 for (const [index, segment] of segments.entries()) {
154 if (segment.kind !== 'mermaid' || !termaidHeaderAllowed(segment.source) || request !== requests) continue
155 const run = await runProcess($, TERMAID_ARGV, TERMAID_TIMEOUT_MS, segment.source)
156 if (run.kind === 'rejected') return
157 const diagram = diagramOutcome(run)
158 if (diagram !== null) {
159 patchShown($, request, card => {
160 const diagrams = [...card.diagrams]
161 diagrams[index] = diagram
162 return { ...card, diagrams }
163 })
164 }
165 }
166}
167
168// A diagram or press result writes only under the card read it came from: a newer read, even of the same card, drops it.
169function patchShown($: any, request: number, patch: (card: Shown) => Shown) {
170 const card = state.card
171 if (request === requests && card?.kind === 'shown') {
172 state = { ...state, card: { ...patch(card), origin: card.origin } }
173 invalidate($)
174 }
175}
176
177function showBrowser($: any, request: number, browser: Browser) {
178 patchShown($, request, card => ({ ...card, browser }))
179}
180
181async function openInBrowser($: any) {
182 const card = state.card
183 if (card?.kind !== 'shown' || !card.vizRoot || !state.scope) return
184 const request = requests
185 const target = renderTarget(rowSlug(state.scope.project, card.path))
186 showBrowser($, request, { kind: 'rendering' })
187 try {
188 await $.fs.write(target.file, card.body)
189 } catch (error) {
190 return showBrowser($, request, { kind: 'error', message: `Could not write ${target.file}: ${reasonOf(error)}` })
191 }
192 const outcome = renderOutcome(await runProcess($, renderArgv(card.vizRoot, target), RENDER_TIMEOUT_MS))
193 showBrowser($, request, outcome)
194 if (outcome.kind !== 'opened' || !outcome.url || !isLoopbackUrl(outcome.url)) return
195 const status = await runProcess($, SERVE_STATUS_ARGV, SERVE_TIMEOUT_MS)
196 const tailnet = status.kind === 'exited' && status.exitCode === 0 ? tailnetUrl(outcome.url, status.stdout) : null
197 if (tailnet) showBrowser($, request, { ...outcome, tailnet })
198}
199
200async function bindShown($: any) {
201 const card = state.card
202 const scope = state.scope
203 if (card?.kind !== 'shown' || !scope) return
204 const request = requests
205 const outcome = await writeBinding($, { cardPath: card.path, vault: scope.vault, project: scope.project })
206 patchShown($, request, card => ({ ...card, bindLine: outcome.ok ? null : { kind: 'error', text: `Could not bind: ${outcome.reason}` } }))
207}
208
209async function unbindShown($: any) {
210 const request = requests
211 const outcome = await removeBinding($)
212 patchShown($, request, card => ({ ...card, bindLine: outcome.ok ? null : { kind: 'error', text: `Could not unbind: ${outcome.reason}` } }))
213}
214
215// A block without a drawn diagram stays inside the markdown around it, so a pending or failed block reads as code.
216function bodyParts(segments: Segment[], diagrams: (string | undefined)[]): ({ markdown: string } | { diagram: string })[] {
217 if (!diagrams.some(Boolean)) return [{ markdown: segments.map(segment => segment.text).join('') }]
218 const parts: ({ markdown: string } | { diagram: string })[] = []
219 let markdown = ''
220 for (const [index, segment] of segments.entries()) {
221 const diagram = diagrams[index]
222 if (diagram === undefined) markdown += segment.text
223 else {
224 if (markdown) parts.push({ markdown })
225 markdown = ''
226 parts.push({ diagram })
227 }
228 }
229 if (markdown) parts.push({ markdown })
230 return parts
231}
232
233function browserLines(browser: Browser | null): Line[] {
234 const notice = (text: string): Line => ({ kind: 'notice', text })
235 if (browser?.kind === 'error') return [{ kind: 'error', text: browser.message }]
236 if (browser?.kind === 'rendering') return [notice('Rendering in the browser…')]
237 if (browser?.kind !== 'opened') return []
238 // Over SSH render.sh opens nothing and prints a URL instead; a local render opens its loopback URL.
239 if (browser.url && !isLoopbackUrl(browser.url)) return [`Rendered: ${browser.path}`, `URL: ${browser.url}`].map(notice)
240 const tailnet = 'tailnet' in browser ? [`Tailnet: ${browser.tailnet}`] : []
241 return [`Opened in the browser: ${browser.path}`, ...tailnet].map(notice)
242}
243
244type Config = (Scope & { path: string }) | { error: string }
245
246function configPathIn(dir: string) {
247 return dir === '/' ? '/.obsidian.yaml' : `${dir}/.obsidian.yaml`
248}
249
250function parentOf(dir: string) {
251 const slash = dir.lastIndexOf('/')
252 return slash > 0 ? dir.slice(0, slash) : '/'
253}
254
255async function readConfig($: any, path: string): Promise<Config> {
256 let text: string
257 try {
258 text = await $.fs.read(path)
259 } catch {
260 return { error: `Could not read ${path}.` }
261 }
262 const { vault, project } = configOf(text)
263 if (!vault && !project) return { error: `${path} has no vault or pm.project.` }
264 if (!vault) return { error: `${path} has no vault.` }
265 if (!project) return { error: `${path} has no pm.project.` }
266 return { vault, project, path }
267}
268
269// `fs.exists` never rejects on the host, so a rejection here is a fault to show, not an absent file.
270async function resolveConfig($: any): Promise<Config> {
271 const cwd = await $.session.cwd()
272 for (let dir = cwd; ; dir = parentOf(dir)) {
273 const path = configPathIn(dir)
274 if (await $.fs.exists(path)) return readConfig($, path)
275 if (dir === '/') return { error: `No .obsidian.yaml in ${cwd} or any directory above it.` }
276 }
277}
278
279const NO_BINDING_DIR = 'neither OBW_LAUNCHES_DIR nor HOME is set.'
280const RELATIVE_BINDING_DIR = 'OBW_LAUNCHES_DIR or HOME is not an absolute path.'
281
282async function bindingFile($: any): Promise<{ path: string | null; reason: string }> {
283 const launchesDir = await $.env.get('OBW_LAUNCHES_DIR')
284 const home = await $.env.get('HOME')
285 const path = bindingPath(launchesDir || undefined, home || undefined, await $.session.id())
286 return { path, reason: launchesDir || home ? RELATIVE_BINDING_DIR : NO_BINDING_DIR }
287}
288
289function clearBinding($: any) {
290 if (!bound) return
291 bound = null
292 invalidate($)
293}
294
295// The card read patches only the binding it was started for: a later binding, or none, drops it.
296async function showBinding($: any, record: Stored) {
297 const mine: Bound = { ...record, card: bound && sameBinding(bound, record) ? bound.card : null }
298 bound = mine
299 invalidate($)
300 if (!record.project) return
301 const built = cardPathArgv({ vault: record.vault, project: record.project }, record.cardPath)
302 if (!('argv' in built)) return
303 const output = readOutput(await runProcess($, built.argv))
304 if (bound !== mine) return
305 if (output.kind !== 'card') {
306 bound = { ...mine, card: null }
307 invalidate($)
308 return
309 }
310 const title = headerOf(output.frontmatter).title
311 bound = { ...mine, card: { title: title ? bounded(title).text || null : null, ac: acLabel(output.body) } }
312 invalidate($)
313}
314
315async function loadBinding($: any) {
316 const { path } = await bindingFile($)
317 if (!path) return
318 if (!(await $.fs.exists(path))) return clearBinding($)
319 const record = bindingOf(await $.fs.read(path))
320 if (!record) return clearBinding($)
321 await showBinding($, record)
322}
323
324type Outcome = { ok: true } | { ok: false; reason: string }
325
326async function writeBinding($: any, card: Binding): Promise<Outcome> {
327 try {
328 const { path, reason } = await bindingFile($)
329 if (!path) return { ok: false, reason }
330 const createdAt = new Date(await $.clock.now()).toISOString()
331 await $.fs.write(path, `${JSON.stringify({ cardPath: card.cardPath, vault: card.vault, project: card.project, createdAt })}\n`)
332 } catch (error) {
333 return { ok: false, reason: reasonOf(error) }
334 }
335 await showBinding($, card).catch(() => {})
336 return { ok: true }
337}
338
339// What Claude's Bash result says: the tool's stdout, or the text the model reads when stdout is not a string.
340function outputOf(result: any): string {
341 const stdout = result?.result?.stdout
342 if (typeof stdout === 'string') return stdout
343 return typeof result?.text === 'string' ? result.text : ''
344}
345
346async function removeBinding($: any): Promise<Outcome> {
347 let file: { path: string | null; reason: string }
348 try {
349 file = await bindingFile($)
350 } catch (error) {
351 return { ok: false, reason: reasonOf(error) }
352 }
353 const { path } = file
354 if (!path) return { ok: false, reason: file.reason }
355 const run = await runProcess($, ['rm', '-f', path])
356 if (run.kind === 'rejected') return { ok: false, reason: 'rm did not run.' }
357 if (run.exitCode !== 0) return { ok: false, reason: run.stderr.trim() || `rm exited ${run.exitCode}` }
358 clearBinding($)
359 return { ok: true }
360}
361
362async function followStatus($: any, command: unknown, output: string) {
363 if (typeof command !== 'string') return
364 const changes = statusChanges(command, output)
365 if (!changes.length) return
366 let config: Scope | null = null
367 if (changes.some(change => change.vault === null || 'file' in change.target)) {
368 const found = await resolveConfig($)
369 if (!('error' in found)) config = { vault: found.vault, project: found.project }
370 }
371 const action = bindingAction(changes, config, bound)
372 if (action?.kind === 'bind') await writeBinding($, action.card)
373 else if (action?.kind === 'unbind') await removeBinding($)
374}
375
376// The only writer of this hint: a dashboard the CLI could not read may simply not exist yet, while a
377// configuration fault names the file it read and says nothing about /obw:pm.
378function pmHint(project: string) {
379 return `If ${dashboardPath(project)} is missing, run /obw:pm to create it.`
380}
381
382// Rows the dashboard sent that the pane cannot open are their own outcome, not an empty view.
383function outsideNotice(count: number, chosen: string, project: string) {
384 return count === 1
385 ? `1 row of the ${chosen} view is not a card under pm/${project} and was left out.`
386 : `${count} rows of the ${chosen} view are not cards under pm/${project} and were left out.`
387}
388
389// A dashboard whose views were renamed or reordered may have no Active view; its own first view is then the one to open.
390function defaultView(names: string[]) {
391 if (names.includes(COUNT_VIEW)) return COUNT_VIEW
392 return !names.length || names.includes('Active') ? 'Active' : names[0]
393}
394
395// The argument names a row wherever the view put it; a name no row carries falls back to the task folder.
396function cardPathIn(cards: ReturnType<typeof listRows>, project: string, name: string) {
397 return rowNamed(cards, project, name) ?? `${taskFolder(project)}${name}.md`
398}
399
400// A card argument stays on the view the pane is already showing, so the row the user is looking at is the row it opens.
401function shownView(chosen: string | null, names: string[]) {
402 return chosen && names.includes(chosen) ? chosen : defaultView(names)
403}
404
405function reasonOf(error: unknown) {
406 return error instanceof Error ? error.message : String(error)
407}
408
409async function openView($: any, scope: Scope, views: string[], chosen: string, named: string | null, request = ++requests, listing: Line | null = null) {
410 state = { ...LOADING, scope, views, chosen, listing }
411 invalidate($)
412 const built = baseQueryArgv(scope, chosen)
413 if (!('argv' in built)) return showMessage($, request, `"${chosen}" is not a view.`)
414 const result = baseQueryOutput(await runProcess($, built.argv))
415 if (request !== requests) return
416 if (result.kind === 'error') {
417 state = { ...state, loading: false, message: { kind: 'error', text: result.message }, hint: missingViewHint(scope.project, result.message) ?? pmHint(scope.project) }
418 invalidate($)
419 return
420 }
421 const cards = result.kind === 'rows' ? listRows(scope.project, result.rows) : []
422 const outside = result.kind === 'rows' ? rowsOutside(scope.project, result.rows) : 0
423 const message: Line | null = outside
424 ? { kind: 'notice', text: outsideNotice(outside, chosen, scope.project) }
425 : !cards.length
426 ? { kind: 'notice', text: `No cards in the ${chosen} view of pm/${scope.project}.` }
427 : null
428 state = {
429 message,
430 hint: null,
431 listing,
432 loading: false,
433 scope,
434 views,
435 chosen,
436 cards,
437 groups: chosen === COUNT_VIEW && result.kind === 'rows' ? listGroups(scope.project, result.rows) : null,
438 selected: null,
439 card: null,
440 }
441 invalidate($)
442 if (named) await show($, cardPathIn(cards, scope.project, named))
443}
444
445// A pick is taken from a drawing the pane has already replaced: a later /issue whose config resolution
446// failed leaves no scope, and there is nothing to query then. Writing the loading state and failing on
447// the way to the CLI would strand the pane on "Reading the vault…" with no picker to come back through.
448function pickView($: any, name: string) {
449 const { scope, views } = state
450 if (!scope) return
451 void openView($, scope, views, name, null).catch(() => {})
452}
453
454async function openIssue($: any, request: number, argument: string, chosen: string | null) {
455 // A name holding `/` can still be a view name, which only the dashboard's listing can tell; every other bad name is refused here.
456 if (argument && !argument.includes('/') && isBadCardName(argument)) return showMessage($, request, `"${argument}" is not a card name.`)
457 let config: Config
458 try {
459 config = await resolveConfig($)
460 } catch (error) {
461 return showMessage($, request, `Could not look for .obsidian.yaml: ${reasonOf(error)}`)
462 }
463 if ('error' in config) return showMessage($, request, config.error)
464 const list = viewsArgv(config)
465 if (!('argv' in list)) {
466 return showMessage(
467 $,
468 request,
469 list.refused === 'vault'
470 ? `${config.path}: vault "${config.vault}" is not a vault name.`
471 : `${config.path}: pm.project "${config.project}" cannot name a folder under pm/.`,
472 )
473 }
474 const listing = viewsOutput(await runProcess($, list.argv))
475 if (request !== requests) return
476 const names = listing.kind === 'views' ? listing.views : []
477 // Prefixed: beside a list the query did draw, the CLI's bare complaint reads as a contradiction.
478 const listingLine: Line | null =
479 listing.kind === 'error'
480 ? { kind: 'error', text: `The dashboard's views could not be listed: ${listing.message}` }
481 : names.includes(COUNT_VIEW)
482 ? null
483 : { kind: 'notice', text: missingViewText(config.project) }
484 const resolved = resolveArgument(argument, names)
485 if (resolved.kind === 'card') {
486 if (isBadCardName(resolved.card)) return showMessage($, request, `"${resolved.card}" is not a card name.`)
487 if (!names.length) {
488 state = {
489 ...LOADING,
490 loading: false,
491 scope: config,
492 views: [],
493 chosen: null,
494 message: listing.kind === 'error' ? { kind: 'error', text: listing.message } : null,
495 hint: listing.kind === 'error' ? pmHint(config.project) : null,
496 }
497 return show($, `${taskFolder(config.project)}${resolved.card}.md`)
498 }
499 return openView($, config, names, shownView(chosen, names), resolved.card, request, listingLine)
500 }
501 return openView($, config, names, resolved.kind === 'view' ? resolved.view : defaultView(names), null, request, listingLine)
502}
503
504function clipNotice(clipped: { text: string; clippedFrom: number | null }) {
505 return clipped.clippedFrom === null ? null : `Clipped: showing ${clipped.text.length} of ${clipped.clippedFrom} characters.`
506}
507
508// The Client module draws its props as given, so every vault string is bounded here.
509function boardCard(card: CardRegion): Card {
510 const safe = (text: string) => bounded(text).text
511 if (card.kind === 'loading') return { kind: 'loading', name: safe(cardName(card.path)) }
512 if (card.kind === 'error') return { kind: 'error', message: safe(card.message) }
513 const { title, status, priority } = card.header
514 const body = bounded(card.body)
515 return {
516 kind: 'shown',
517 path: card.path,
518 title: safe(title ?? cardName(card.path)),
519 status: safe(status ?? '—'),
520 priority: safe(priority ?? '—'),
521 ac: acLabel(card.body),
522 clip: clipNotice(body),
523 body: body.text,
524 }
525}
526
527// vscode and mobile draw a Client as an empty Box without complaint, so only the surface can say whether it will show.
528function hasClient(surface: string) {
529 return surface === 'terminal' || surface === 'desktop'
530}
531
532async function drawPane($: any, e: any) {
533 const { Box, Text, Select, Markdown, Button, Code, Client } = await $.ui.resolve(e)
534 const safe = (text: string) => bounded(text).text
535 const dim = (text: string) => Text({ dimColor: true, children: [safe(text)] })
536 const red = (text: string) => Text({ color: RED, children: [safe(text)] })
537 const line = ({ kind, text }: Line) => (kind === 'error' ? red(text) : dim(text))
538 const span = (text: string, color?: string) => Text({ ...(color ? { color } : {}), children: [safe(text)] })
539 const children: any[] = []
540 if (state.listing) children.push(line(state.listing))
541 if (!state.loading && state.views.length && state.scope && state.chosen) {
542 children.push(
543 Select({
544 key: 'views',
545 options: state.views.map(name => ({ value: safe(name), label: safe(name) })),
546 value: safe(state.chosen),
547 onSelect: (name: string) => pickView($, name),
548 }),
549 )
550 }
551 const board = hasClient(e.surface) && state.groups && state.groups.groups.length ? state.groups : null
552 if (board) {
553 const { rows, columns } = boardSize({
554 bodyRows: e.props?.scroll?.bodyRows,
555 bodyColumns: e.props?.bodyColumns,
556 siblings: [state.listing, state.message].flatMap((line) => (line ? [safe(line.text)] : [])),
557 argumentCard: state.card?.origin === 'argument',
558 })
559 const listCard = state.card?.origin === 'list' ? boardCard(state.card) : null
560 const boardProps = listCard ? { groups: [], hidden: 0, card: listCard } : { groups: board.groups, hidden: board.hidden, card: null }
561 children.push(Client({ key: BOARD_KEY, module: './board.ts', width: columns, height: rows, props: { rows, columns, ...boardProps } }))
562 } else if (state.cards.length) {
563 children.push(
564 Select({
565 key: 'cards',
566 options: state.cards.map(card => ({ value: safe(card.path), label: safe(card.label) })),
567 ...(state.selected ? { value: safe(state.selected) } : {}),
568 onSelect: (path: string) => {
569 void show($, path).catch(() => {})
570 },
571 }),
572 )
573 }
574 if (state.message) children.push(line(state.message))
575 if (state.hint) children.push(dim(state.hint))
576 const card = state.card
577 if (!card || (board && card.origin === 'list')) return Box({ flexDirection: 'column', children })
578 const columns = e.props?.bodyColumns
579 const ruleWidth = Number.isInteger(columns) && columns > 0 ? Math.min(columns, MAX_CHARS) : 40
580 const region: any[] = [dim('─'.repeat(ruleWidth))]
581 if (card.kind === 'loading') region.push(dim(`Reading ${cardName(card.path)}…`))
582 if (card.kind === 'error') region.push(red(card.message))
583 if (card.kind === 'shown') {
584 const { title, status, priority } = card.header
585 const body = bounded(card.body)
586 const ac = acLabel(card.body)
587 region.push(Text({ bold: true, children: [safe(title ?? cardName(card.path))] }))
588 region.push(
589 Text({
590 children: [
591 dim('status: '),
592 span(status ?? '—', statusColor(status)),
593 dim(' · priority: '),
594 span(priority ?? '—', priorityColor(priority)),
595 ...(ac ? [dim(' · '), span(ac)] : []),
596 ],
597 }),
598 )
599 if (e.surface === 'terminal' && card.origin === 'argument') {
600 region.push(Button({ key: 'bind', label: 'Bind this session', onPress: () => { void bindShown($).catch(() => {}) } }))
601 if (bound && state.scope && sameBinding(bound, { cardPath: card.path, vault: state.scope.vault })) {
602 region.push(Button({ key: 'unbind', label: 'Unbind this session', onPress: () => { void unbindShown($).catch(() => {}) } }))
603 }
604 if (card.bindLine) region.push(line(card.bindLine))
605 }
606 // Only the terminal can run render.sh: `process` is CLI only.
607 if (card.vizRoot && e.surface === 'terminal') {
608 region.push(
609 Button({
610 key: 'open-in-browser',
611 label: 'Open in browser',
612 onPress: () => {
613 void openInBrowser($).catch(() => {})
614 },
615 }),
616 ...browserLines(card.browser).map(line),
617 )
618 }
619 const bodyClip = clipNotice(body)
620 if (bodyClip) region.push(dim(bodyClip))
621 for (const part of bodyParts(card.segments, card.diagrams)) {
622 if ('markdown' in part) region.push(Markdown({ text: safe(part.markdown) }))
623 else {
624 const diagram = bounded(part.diagram)
625 const diagramClip = clipNotice(diagram)
626 if (diagramClip) region.push(dim(diagramClip))
627 region.push(Code({ source: diagram.text, wrap: 'truncate-end' }))
628 }
629 }
630 }
631 // A blank row and a thin rule set the card off from the list above it.
632 children.push(Box({ flexDirection: 'column', marginTop: 1, children: region }))
633 return Box({ flexDirection: 'column', children })
634}
635
636export function register(on: On) {
637 on('session.start', async ($, e, next) => {
638 try {
639 await $.command.register({
640 name: 'issue',
641 description: 'Show an obw dashboard view or card in a pane',
642 argumentHint: '[view|card]',
643 immediate: true,
644 })
645 } catch {
646 // A refused /issue must not stop the session.
647 }
648 void loadBinding($).catch(() => {})
649 return next(e)
650 })
651
652 on('command.run', { command: 'issue' }, async ($, e) => {
653 const argument = (e.args ?? '').trim()
654 const chosen = state.chosen
655 const request = ++requests
656 state = LOADING
657 try {
658 await $.ui.open(PANE)
659 } catch {
660 return { text: 'obw: the /issue pane could not open.' }
661 }
662 await openIssue($, request, argument, chosen)
663 // Card text never goes into the result: the model would read it.
664 return {}
665 })
666
667 on('ui.message', async ($, e, next) => {
668 const listDrawn = hasClient(e.surface) && state.groups && state.chosen === COUNT_VIEW && state.card?.origin !== 'list'
669 const listed = listDrawn ? state.groups!.groups.flatMap((group) => group.rows.map((row) => row.path)) : null
670 const message = boardMessage(e, listed)
671 if (!message) return next(e)
672 if (message.kind === 'open') void show($, message.path, 'list').catch(() => {})
673 else if (state.card?.origin === 'list') {
674 // A read still running for the card left behind must not bring it back.
675 ++requests
676 state = { ...state, selected: null, card: null }
677 invalidate($)
678 }
679 return {}
680 })
681
682 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
683 if (e.requestId !== PANE.id) return next(e)
684 try {
685 return await drawPane($, e)
686 } catch {
687 try {
688 const { Text } = await $.ui.resolve(e)
689 return Text({ dimColor: true, children: ['obw: the card could not be drawn.'] })
690 } catch {
691 return next(e)
692 }
693 }
694 })
695 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
696 const result = await next(e)
697 void followStatus($, e.command, outputOf(result)).catch(() => {})
698 return result
699 })
700
701 on('turn.complete', async ($, e, next) => {
702 const result = await next(e)
703 if (e.agentId === undefined) void loadBinding($).catch(() => {})
704 return result
705 })
706
707 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
708 if (!bound || e.props?.hasSurvey) return next(e)
709 const beneath = await next(e)
710 try {
711 const { Box, Text } = await $.ui.resolve(e)
712 const { cardPath, card } = bound
713 const title = card?.title
714 const line = Text({
715 wrap: 'truncate-end',
716 children: [
717 Text({ color: 'success', children: ['●'] }),
718 ` ${bounded(cardName(cardPath)).text}`,
719 ...(title ? [` ${title}`] : []),
720 ...(card?.ac ? [` ${card.ac}`] : []),
721 ],
722 })
723 return Box({ flexDirection: 'column', children: [line, beneath] })
724 } catch {
725 return beneath
726 }
727 })
728}
729hooks/bounds.ts 13 lines1const CONTROLS_BUT_TAB_AND_NEWLINE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
2
3export const MAX_CHARS = 10000
4
5export function bounded(text: string, max = MAX_CHARS) {
6 const clean = text.replace(CONTROLS_BUT_TAB_AND_NEWLINE, '')
7 if (clean.length <= max) return { text: clean, clippedFrom: null }
8 let end = max
9 const last = clean.charCodeAt(end - 1)
10 if (last >= 0xd800 && last <= 0xdbff) end -= 1
11 return { text: clean.slice(0, end), clippedFrom: clean.length }
12}
13hooks/config.ts 27 lines1// A quoted value keeps any `#` inside its quotes and drops a comment after them.
2const QUOTED = /^(["'])(.*)\1\s*(#.*)?$/
3
4export function valueOf(raw: string) {
5 const trimmed = raw.trim()
6 const quoted = QUOTED.exec(trimmed)
7 const value = quoted ? quoted[2] : trimmed.replace(/(^|\s+)#.*$/, '')
8 return value || undefined
9}
10
11export function configOf(text: string): { vault?: string; project?: string } {
12 let vault: string | undefined
13 let project: string | undefined
14 let inPm = false
15 for (const raw of text.split('\n')) {
16 const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
17 if (/^\s*(#|$)/.test(line)) continue
18 if (/^\S/.test(line)) {
19 inPm = /^pm:(\s+#.*)?\s*$/.test(line)
20 if (/^vault:/.test(line)) vault = valueOf(line.slice('vault:'.length))
21 } else if (inPm && /^\s+project:/.test(line)) {
22 project = valueOf(line.replace(/^\s+project:/, ''))
23 }
24 }
25 return { vault, project }
26}
27hooks/argv.ts 19 lines1const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/
2
3// A name that would empty, widen or escape the path it is put into.
4export function isBadCardName(value: string) {
5 return !value || value === '.' || value === '..' || value.includes('/') || CONTROLS.test(value)
6}
7
8export function projectRoot(project: string) {
9 return `pm/${project}/`
10}
11
12export function dashboardPath(project: string) {
13 return `${projectRoot(project)}dashboard.base`
14}
15
16export function taskFolder(project: string) {
17 return `${projectRoot(project)}tasks/`
18}
19hooks/base-argv.ts 52 lines1import { isBadCardName, dashboardPath, projectRoot } from './argv.ts'
2import { bounded } from './bounds.ts'
3
4type ArgvResult = { argv: string[] } | { refused: 'vault' | 'project' | 'view' | 'path' }
5
6export type Scope = { vault: string; project: string }
7
8function isBadProject(value: string) {
9 return isBadCardName(value) || /[\[\]"\s]/.test(value)
10}
11
12function isBadVault(value: string) {
13 return !value || bounded(value).text !== value
14}
15
16function isBadView(value: string) {
17 return !value || bounded(value).text !== value || /[\t\n\r]/.test(value)
18}
19
20function scopeRefusal(scope: Scope): { refused: 'vault' | 'project' } | null {
21 if (isBadVault(scope.vault)) return { refused: 'vault' }
22 if (isBadProject(scope.project)) return { refused: 'project' }
23 return null
24}
25
26export function baseQueryArgv(scope: Scope, view: string): ArgvResult {
27 const refused = scopeRefusal(scope)
28 if (refused) return refused
29 if (isBadView(view)) return { refused: 'view' }
30 return { argv: ['obsidian', `vault=${scope.vault}`, 'base:query', `path=${dashboardPath(scope.project)}`, `view=${view}`, 'format=json'] }
31}
32
33export function isBadCardPath(project: string, path: string) {
34 const root = projectRoot(project)
35 return !path.startsWith(root) || !path.endsWith('.md') || path.endsWith('/.md') ||
36 bounded(path).text !== path || /[\t\n\r]/.test(path) ||
37 path.split('/').some(segment => segment === '.' || segment === '..' || isBadCardName(segment))
38}
39
40export function cardPathArgv(scope: Scope, path: string): ArgvResult {
41 const refused = scopeRefusal(scope)
42 if (refused) return refused
43 if (isBadCardPath(scope.project, path)) return { refused: 'path' }
44 return { argv: ['obsidian', `vault=${scope.vault}`, 'read', `path=${path}`] }
45}
46
47export function viewsArgv(scope: Scope): ArgvResult {
48 const refused = scopeRefusal(scope)
49 if (refused) return refused
50 return { argv: ['obsidian', `vault=${scope.vault}`, 'read', `path=${dashboardPath(scope.project)}`] }
51}
52hooks/cli-output.ts 125 lines1import { valueOf } from './config.ts'
2
3export type Run = { kind: 'exited'; exitCode: number; stdout: string; stderr: string } | { kind: 'rejected' }
4
5export const OBSIDIAN_TIMEOUT_MS = 10_000
6
7const NOT_RUN = `The obsidian CLI did not run: it is not on PATH, or it did not answer within ${OBSIDIAN_TIMEOUT_MS / 1000} s.`
8
9// Anything that is not a known success shape is shown as the CLI printed it.
10// The CLI prints its own errors on stdout with exit 0, so stdout is read first there;
11// a non-zero exit is the shell's failure and its complaint is on stderr.
12function errorMessage(run: Run) {
13 if (run.kind === 'rejected') return NOT_RUN
14 const [first, second] = run.exitCode === 0 ? [run.stdout, run.stderr] : [run.stderr, run.stdout]
15 return first.trim() || second.trim() || `obsidian exited ${run.exitCode} with no output.`
16}
17
18type BaseRow = { path: string; status: string | null; priority: string | null; title: string | null; due: string | null; tags: string | null }
19
20const text = (value: unknown) => (typeof value === 'string' ? value : null)
21
22function tagsOf(value: unknown) {
23 if (typeof value === 'string') return value
24 if (!Array.isArray(value)) return null
25 return value.filter((tag) => typeof tag === 'string').join(', ') || null
26}
27
28export function baseQueryOutput(run: Run): { kind: 'rows'; rows: BaseRow[] } | { kind: 'empty' } | { kind: 'error'; message: string } {
29 if (run.kind !== 'exited' || run.exitCode !== 0) return { kind: 'error', message: errorMessage(run) }
30 try {
31 const parsed = JSON.parse(run.stdout)
32 if (!Array.isArray(parsed) || !parsed.every((row) => row && typeof row === 'object' && typeof row.path === 'string')) {
33 return { kind: 'error', message: errorMessage(run) }
34 }
35 const rows = parsed.map((row) => ({ path: row.path, status: text(row.status), priority: text(row.priority), title: text(row.title), due: text(row.due), tags: tagsOf(row.tags) }))
36 return rows.length ? { kind: 'rows', rows } : { kind: 'empty' }
37 } catch {
38 return { kind: 'error', message: errorMessage(run) }
39 }
40}
41
42const DRAWABLE = /^[^\x00-\x08\x0b-\x1f\x7f-\x9f\t\n\r]{1,10000}$/
43
44// A listing is the dashboard file's top-level views: list; any other stdout is the CLI
45// saying something else, which is shown rather than read as "this dashboard has no view".
46// Items whose names the pane could not draw are dropped; if every item is undrawable the
47// listing is an error. Only a views: block with no item is empty.
48function viewsFrom(stdout: string): { kind: 'views'; views: string[] } | { kind: 'empty' } | null {
49 let inViews = false
50 let sawViews = false
51 let itemIndent: number | null = null
52 let keyCol: number | null = null
53 let started = false
54 let current: string | undefined
55 const names: (string | undefined)[] = []
56
57 const finish = () => {
58 if (started) names.push(current)
59 current = undefined
60 started = false
61 }
62
63 for (const raw of stdout.split('\n')) {
64 const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
65 if (/^\s*(#|$)/.test(line)) continue
66
67 if (/^\S/.test(line) && !line.startsWith('- ')) {
68 if (inViews) finish()
69 inViews = false
70 if (/^views:/.test(line)) {
71 const rest = line.slice('views:'.length).trim()
72 if (rest !== '' && rest !== '[]' && !rest.startsWith('#')) return null
73 sawViews = true
74 inViews = true
75 itemIndent = null
76 keyCol = null
77 }
78 continue
79 }
80
81 if (!inViews) continue
82
83 const item = /^(\s*)-(\s+)(.*)$/.exec(line)
84 if (item) {
85 const indent = item[1].length
86 if (itemIndent === null) itemIndent = indent
87 if (indent === itemIndent) {
88 finish()
89 started = true
90 keyCol = indent + 1 + item[2].length
91 if (item[3].startsWith('name:')) current = valueOf(item[3].slice('name:'.length))
92 continue
93 }
94 }
95
96 if (keyCol !== null && started && line.slice(0, keyCol).trim() === '' && line.slice(keyCol).startsWith('name:') && current === undefined) {
97 current = valueOf(line.slice(keyCol + 'name:'.length))
98 }
99 }
100 finish()
101 if (!sawViews) return null
102 const views = names.flatMap((name) => (name && DRAWABLE.test(name) ? [name] : []))
103 if (views.length) return { kind: 'views', views }
104 return names.length ? null : { kind: 'empty' }
105}
106
107export function viewsOutput(run: Run): { kind: 'views'; views: string[] } | { kind: 'empty' } | { kind: 'error'; message: string } {
108 if (run.kind !== 'exited' || run.exitCode !== 0) return { kind: 'error', message: errorMessage(run) }
109 return viewsFrom(run.stdout) ?? { kind: 'error', message: errorMessage(run) }
110}
111
112function noteOf(stdout: string): { frontmatter: string; body: string } | null {
113 if (!stdout.startsWith('---\n')) return null
114 const lines = stdout.split('\n')
115 const close = lines.indexOf('---', 1)
116 if (close < 0) return null
117 return { frontmatter: lines.slice(1, close).join('\n'), body: lines.slice(close + 1).join('\n') }
118}
119
120export function readOutput(run: Run): { kind: 'card'; frontmatter: string; body: string } | { kind: 'error'; message: string } {
121 const note = run.kind === 'exited' && run.exitCode === 0 ? noteOf(run.stdout) : null
122 if (!note) return { kind: 'error', message: errorMessage(run) }
123 return { kind: 'card', ...note }
124}
125hooks/rows.ts 61 lines1import { projectRoot, taskFolder } from './argv.ts'
2import { isBadCardPath } from './base-argv.ts'
3import { bounded } from './bounds.ts'
4
5type Row = { path: string; status?: string | null }
6type ListRow = { path: string; label: string; status: string | null }
7
8export function resolveArgument(argument: string, views: string[]): { kind: 'none' } | { kind: 'view'; view: string } | { kind: 'card'; card: string } {
9 if (!argument.trim()) return { kind: 'none' }
10 return views.includes(argument) ? { kind: 'view', view: argument } : { kind: 'card', card: argument }
11}
12
13export function cardName(path: string) {
14 return path.slice(path.lastIndexOf('/') + 1, -'.md'.length)
15}
16
17// The row an argument names: a view can list a card from any folder under the project, so the name the
18// pane drew is the one to match. Two folders can spell one name, and the task folder keeps it.
19export function rowNamed(rows: ListRow[], project: string, name: string): string | null {
20 const named = rows.filter((row) => cardName(row.path) === name)
21 const task = `${taskFolder(project)}${name}.md`
22 if (named.some((row) => row.path === task)) return task
23 return named.length ? named[0].path : null
24}
25
26export function keptRows<R extends Row>(project: string, rows: R[]): R[] {
27 const paths = new Set<string>()
28 return rows.filter((row) => {
29 if (paths.has(row.path) || isBadCardPath(project, row.path)) return false
30 paths.add(row.path)
31 return true
32 })
33}
34
35export function listRows(project: string, rows: Row[]): ListRow[] {
36 const grouped = new Map<string, ListRow[]>()
37 const withoutStatus: ListRow[] = []
38 for (const row of keptRows(project, rows)) {
39 const status = typeof row.status === 'string' ? row.status : null
40 const name = cardName(row.path)
41 const listed = { path: row.path, label: bounded(status ? `${status} · ${name}` : name).text, status }
42 if (status === null) withoutStatus.push(listed)
43 else {
44 const group = grouped.get(status)
45 if (group) group.push(listed)
46 else grouped.set(status, [listed])
47 }
48 }
49 return [...grouped.values()].flat().concat(withoutStatus)
50}
51
52// Rows the dashboard sent that no card path can be built from: a filter widened past the project, or a row that is not a note.
53// Counted by path, as the list itself is: a path the view sent twice is one row left out, not two.
54export function rowsOutside(project: string, rows: Row[]) {
55 return new Set(rows.filter((row) => isBadCardPath(project, row.path)).map((row) => row.path)).size
56}
57
58export function rowSlug(project: string, path: string) {
59 return path.slice(projectRoot(project).length, -'.md'.length).replaceAll('/', '-')
60}
61hooks/counts.ts 41 lines1import { keptRows, rowsOutside } from './rows.ts'
2
3export const COUNT_VIEW = 'All Tasks'
4
5type Row = { path: string; status?: string | null; priority?: string | null }
6
7export function isMissing(value: string | null | undefined): value is null | undefined | '' {
8 return typeof value !== 'string' || value === ''
9}
10
11function tally(values: (string | null | undefined)[]) {
12 const counts = new Map<string, number>()
13 let missing = 0
14 for (const value of values) {
15 if (isMissing(value)) {
16 missing++
17 continue
18 }
19 counts.set(value, (counts.get(value) ?? 0) + 1)
20 }
21 return { values: [...counts].map(([value, count]) => ({ value, count })), missing }
22}
23
24export function countRows(project: string, rows: Row[]) {
25 const counted = keptRows(project, rows)
26 return {
27 total: counted.length,
28 outside: rowsOutside(project, rows),
29 status: tally(counted.map((row) => row.status)),
30 priority: tally(counted.map((row) => row.priority)),
31 }
32}
33
34export function missingViewText(project: string) {
35 return `pm/${project}/dashboard.base has no ${COUNT_VIEW} view. Run /obw:pm refresh dashboard to regenerate it from the plugin template; hand edits to that file are overwritten.`
36}
37
38export function missingViewHint(project: string, message: string) {
39 return message.split('\n', 1)[0] === `Error: View not found: ${COUNT_VIEW}` ? missingViewText(project) : null
40}
41hooks/list.ts 86 lines1import { bounded } from './bounds.ts'
2import { countRows, isMissing } from './counts.ts'
3import { cardName, keptRows } from './rows.ts'
4import { PRIORITY_ORDER, STATUS_ORDER } from './style.ts'
5
6type Row = { path: string; status?: string | null; priority?: string | null; title?: string | null; due?: string | null; tags?: string | null }
7
8export type BoardRow = { path: string; badge: 'H' | 'M' | 'L' | ' '; title: string; due: string; tags: string }
9export type BoardGroup = { status: string | null; count: number; rows: BoardRow[] }
10
11const BADGES: Record<string, BoardRow['badge']> = { high: 'H', medium: 'M', low: 'L' }
12// Caps chosen so the worst-case list, every character JSON-escaped, serializes under the engine's 100,000-character props bound.
13const MAX_SHOWN = 100
14const MAX_PATH = 200
15const MAX_TITLE = 80
16const MAX_TAGS = 48
17const MAX_DUE = 16
18const MAX_STATUS = 32
19
20// bounded keeps tab and newline; either would break a list line in two or skew its columns.
21const oneLine = (text: string, max: number) => bounded(text, max).text.replace(/[\t\n]+/g, ' ')
22
23// A cut status ends in …, so two statuses sharing a capped prefix are not taken for one.
24function statusLabel(status: string) {
25 return bounded(status, MAX_STATUS).clippedFrom === null ? oneLine(status, MAX_STATUS) : `${oneLine(status, MAX_STATUS - 1)}…`
26}
27
28function statusCount(counted: ReturnType<typeof countRows>, status: string | null) {
29 return status === null ? counted.status.missing : counted.status.values.find((entry) => entry.value === status)!.count
30}
31
32function byPriority<R extends Row>(rows: R[]) {
33 const buckets = new Map<string | null, R[]>()
34 const extras: string[] = []
35 for (const row of rows) {
36 const priority = isMissing(row.priority) ? null : row.priority
37 if (!buckets.has(priority)) {
38 buckets.set(priority, [])
39 if (priority !== null && !PRIORITY_ORDER.includes(priority)) extras.push(priority)
40 }
41 buckets.get(priority)!.push(row)
42 }
43 return [...PRIORITY_ORDER.filter((priority) => buckets.has(priority)), ...extras, ...(buckets.has(null) ? [null] : [])].flatMap(
44 (priority) => buckets.get(priority)!,
45 )
46}
47
48function boardRow(row: Row): BoardRow {
49 return {
50 path: row.path,
51 badge: (typeof row.priority === 'string' && Object.hasOwn(BADGES, row.priority) && BADGES[row.priority]) || ' ',
52 title: oneLine(row.title ?? '', MAX_TITLE) || oneLine(cardName(row.path), MAX_TITLE),
53 due: oneLine(row.due ?? '', MAX_DUE),
54 tags: oneLine(row.tags ?? '', MAX_TAGS),
55 }
56}
57
58export function listGroups(project: string, rows: Row[]): { groups: BoardGroup[]; hidden: number } {
59 const counted = countRows(project, rows)
60 const kept = keptRows(project, rows)
61 const named = counted.status.values.map((entry) => entry.value)
62 const statuses: (string | null)[] = [
63 ...STATUS_ORDER.filter((status) => named.includes(status)),
64 ...named.filter((status) => !STATUS_ORDER.includes(status)),
65 ...(counted.status.missing > 0 ? [null] : []),
66 ]
67 const groups: BoardGroup[] = []
68 let shown = 0
69 let hidden = 0
70 for (const status of statuses) {
71 const ofStatus = byPriority(kept.filter((row) => (status === null ? isMissing(row.status) : row.status === status)))
72 const drawn: BoardRow[] = []
73 for (const row of ofStatus) {
74 if (row.path.length > MAX_PATH || shown >= MAX_SHOWN) hidden++
75 else {
76 drawn.push(boardRow(row))
77 shown++
78 }
79 }
80 if (drawn.length) {
81 groups.push({ status: status === null ? null : statusLabel(status), count: statusCount(counted, status), rows: drawn })
82 }
83 }
84 return { groups, hidden }
85}
86hooks/board-size.ts 20 lines1import { displayWidth } from './width.ts'
2
3export const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
4const positive = (value: unknown, fallback: number) => (Number.isInteger(value) && (value as number) > 0 ? (value as number) : fallback)
5
6// The one row reserved is the collapsed views Select, assumed to draw on a single line.
7export function boardSize({ bodyRows, bodyColumns, siblings, argumentCard }: {
8 bodyRows: unknown
9 bodyColumns: unknown
10 siblings: string[]
11 argumentCard: boolean
12}) {
13 const columns = clamp(positive(bodyColumns, 80), 20, 300)
14 const siblingRows = siblings
15 .flatMap((line) => line.split('\n'))
16 .reduce((sum, segment) => sum + Math.max(1, Math.ceil(displayWidth(segment) / columns)), 0)
17 const rows = clamp(positive(bodyRows, 24) - 1 - siblingRows, 3, 150)
18 return { rows: argumentCard ? Math.min(rows, 8) : rows, columns }
19}
20hooks/board-message.ts 21 lines1export const PANE_ID = 'obw-issue'
2export const BOARD_KEY = 'board'
3
4export type BoardMessage = { kind: 'open'; path: string } | { kind: 'back' }
5
6// A post is code's word, not the engine's: only the exact open or back shape from the pane's own list is acted on.
7export function boardMessage(e: { requestId: string; element: string; data: unknown }, listed: string[] | null): BoardMessage | null {
8 if (e.requestId !== PANE_ID) return null
9 if (e.element !== BOARD_KEY) return null
10 const data = e.data
11 if (typeof data !== 'object' || data === null) return null
12 const keys = Object.keys(data)
13 if (keys.length !== 1) return null
14 const fields = data as Record<string, unknown>
15 if (keys[0] === 'back') return fields.back === true ? { kind: 'back' } : null
16 // Back needs no list on screen: the card view that posts it replaces the list.
17 if (listed === null) return null
18 const path = listed.find((path) => path === fields.open)
19 return path === undefined ? null : { kind: 'open', path }
20}
21hooks/board.ts 160 lines1import type { ClientSurface } from 'claude-code'
2import { clamp } from './board-size.ts'
3import { RED } from './style.ts'
4import { displayWidth, fitWidth } from './width.ts'
5import type { BoardGroup as Group, BoardRow } from './list.ts'
6
7export type Card =
8 | { kind: 'loading'; name: string }
9 | { kind: 'error'; message: string }
10 | { kind: 'shown'; path: string; title: string; status: string; priority: string; ac: string | null; clip: string | null; body: string }
11type Props = { rows: number; columns: number; groups: Group[]; hidden: number; card: Card | null }
12type State = { cursor: number; top: number; collapsed: string[]; scroll: number; scrollPath: string | null }
13type Item = { kind: 'heading'; group: Group; key: string; open: boolean } | { kind: 'row'; row: BoardRow }
14
15const MISSING = '—'
16const TAGS_MAX = 24
17const START: State = { cursor: 0, top: 0, collapsed: ['done'], scroll: 0, scrollPath: null }
18
19// The first line drawn: the stored one, moved just enough to keep the cursor in the window.
20const topFor = (cursor: number, top: number, window: number) => Math.min(Math.max(top, cursor - window + 1), cursor)
21
22// How far a key moves the list cursor or the card body; null for a key that moves neither.
23function step(key: string, window: number) {
24 return key === 'down' ? 1 : key === 'up' ? -1 : key === 'pagedown' ? window : key === 'pageup' ? -window : null
25}
26
27// A fold is kept under its heading's key. A bounded status never holds \0, so neither the missing-status
28// group nor a status that reads the same as an earlier one once capped shares a key with another group.
29function keysOf(groups: Group[]) {
30 const seen = new Map<string | null, number>()
31 return groups.map((group) => {
32 const repeat = seen.get(group.status) ?? 0
33 seen.set(group.status, repeat + 1)
34 return (group.status ?? '\0') + '\0'.repeat(repeat)
35 })
36}
37
38function itemsOf(groups: Group[], collapsed: string[]) {
39 const items: Item[] = []
40 const keys = keysOf(groups)
41 for (const [index, group] of groups.entries()) {
42 const key = keys[index]
43 const open = !collapsed.includes(key)
44 items.push({ kind: 'heading', group, key, open })
45 if (open) for (const row of group.rows) items.push({ kind: 'row', row })
46 }
47 return items
48}
49
50function rowLine(columns: number, groups: Group[]) {
51 const rows = groups.flatMap((group) => group.rows)
52 const dueW = Math.max(0, ...rows.map((row) => displayWidth(row.due)))
53 const tagsW = Math.min(Math.max(0, ...rows.map((row) => displayWidth(row.tags))), TAGS_MAX)
54 const titleW = Math.max(10, columns - 6 - (dueW ? dueW + 2 : 0) - (tagsW ? tagsW + 2 : 0))
55 return (row: BoardRow) =>
56 (
57 ` [${row.badge}] ${fitWidth(row.title, titleW)}` +
58 (dueW ? ` ${fitWidth(row.due, dueW)}` : '') +
59 (tagsW ? ` ${fitWidth(row.tags, tagsW)}` : '')
60 ).trimEnd()
61}
62
63// The body's lines, each hard-wrapped at `columns` cells; an empty body has none.
64function wrapped(body: string, columns: number) {
65 if (body === '') return []
66 // A terminal draws a tab several cells wide, but displayWidth counts it as one.
67 return body.replaceAll('\t', ' ').split('\n').flatMap((text) => {
68 const lines = ['']
69 let used = 0
70 for (const char of text) {
71 const width = displayWidth(char)
72 if (used + width > columns && used > 0) {
73 lines.push('')
74 used = 0
75 }
76 lines[lines.length - 1] += char
77 used += width
78 }
79 return lines
80 })
81}
82
83export default function Board(props: Props, surface: ClientSurface<State>) {
84 const { Box, Text } = surface.elements
85 const st = surface.state ?? START
86 const column = (children: any[]) => Box({ flexDirection: 'column', children })
87
88 if (props.card) {
89 const card = props.card
90 const toList = ({ key }: { key: string }) => {
91 if (key === 'left') surface.post({ back: true })
92 }
93 if (card.kind !== 'shown') surface.onKey(toList)
94 const back = Text({ dimColor: true, children: ['← list'] })
95 if (card.kind === 'loading') return column([back, Text({ dimColor: true, children: [`Reading ${card.name}…`] })])
96 if (card.kind === 'error') return column([back, Text({ color: RED, children: [card.message] })])
97 // One line each, so the body window below them is exactly what the counter says.
98 const fitted = (text: string) => fitWidth(text, props.columns).trimEnd()
99 const header = [
100 Text({ bold: true, children: [fitted(card.title)] }),
101 Text({ children: [fitted([card.status, card.priority, ...(card.ac ? [card.ac] : [])].join(' · '))] }),
102 ...(card.clip ? [Text({ dimColor: true, children: [fitted(card.clip)] })] : []),
103 ]
104 const lines = wrapped(card.body, props.columns)
105 const window = Math.max(1, props.rows - 1 - header.length)
106 const last = Math.max(0, lines.length - window)
107 const scroll = st.scrollPath === card.path ? Math.min(st.scroll, last) : 0
108 const scrollTo = (to: number) => surface.setState({ ...st, scroll: clamp(to, 0, last), scrollPath: card.path })
109 surface.onKey(({ key }) => {
110 const by = step(key, window)
111 if (by !== null) scrollTo(scroll + by)
112 else toList({ key })
113 })
114 return column([
115 Text({ dimColor: true, children: [`↑↓ scroll ← list ${lines.length ? scroll + 1 : 0}/${lines.length}`] }),
116 ...header,
117 ...lines.slice(scroll, scroll + window).map((text) => Text({ children: [text] })),
118 ])
119 }
120
121 const items = itemsOf(props.groups, st.collapsed)
122 if (items.length === 0) {
123 surface.onKey(() => {})
124 return column([Text({ dimColor: true, children: ['No cards.'] })])
125 }
126
127 const window = Math.max(1, props.rows - 1 - (props.hidden > 0 ? 1 : 0))
128 const cursor = Math.min(st.cursor, items.length - 1)
129 const top = topFor(cursor, st.top, window)
130 const move = (to: number) => {
131 const next = clamp(to, 0, items.length - 1)
132 surface.setState({ ...st, cursor: next, top: topFor(next, top, window) })
133 }
134 const fold = (collapsed: string[]) => surface.setState({ ...st, cursor, top, collapsed })
135 surface.onKey(({ key }) => {
136 const item = items[cursor]
137 const by = step(key, window)
138 if (by !== null) move(cursor + by)
139 else if (item.kind === 'row') {
140 if (key === 'left') move(items.findLastIndex((it, i) => i < cursor && it.kind === 'heading'))
141 else if (key === 'right' || key === 'return') surface.post({ open: item.row.path })
142 } else if ((key === 'right' || key === 'return') && !item.open) fold(st.collapsed.filter((key) => key !== item.key))
143 else if (key === 'left' && item.open) fold([...st.collapsed, item.key])
144 })
145
146 const line = rowLine(props.columns, props.groups)
147 const drawn = items.slice(top, top + window).map((item, i) =>
148 Text({
149 ...(top + i === cursor ? { inverse: true } : {}),
150 wrap: 'truncate-end',
151 children: [item.kind === 'heading' ? `${item.open ? '▾' : '▸'} ${item.group.status ?? MISSING} ${item.group.count}` : line(item.row)],
152 }),
153 )
154 return column([
155 Text({ dimColor: true, children: [`↑↓ move → open ← back PgUp/PgDn page ${cursor + 1}/${items.length}`] }),
156 ...drawn,
157 ...(props.hidden > 0 ? [Text({ dimColor: true, children: [`${props.hidden} more not shown`] })] : []),
158 ])
159}
160