opendoor-plugin 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,132 +1,72 @@
1
1
  # opendoor — a Doorstop requirements-management plugin for PI WEB
2
2
 
3
- `opendoor` is a **paired** PI WEB plugin that surfaces
4
- [Doorstop](https://github.com/doorstop-dev/doorstop) requirements stored in
5
- the workspace next to the code they trace to: a **Requirements** workspace tab
6
- with the document tree, per-item state (reviewed/unreviewed changes, suspect
7
- links), validation findings, and one-click `doorstop` CLI actions (validate,
8
- publish, add/link/unlink, clear suspect links, review), plus agent prompts
9
- that insert requirement context into the session. When paired, the plugin's
10
- server entry runs the CLI headlessly and the captured output is displayed in
11
- the panel itself (a **status bar** at the bottom); when unpaired, every action
12
- falls back to the workspace terminal. The paired backend also recovers the
13
- reviewed version of an item file from git history, so the item detail pane
14
- can show a **Changes since review** diff — and Review can optionally record
15
- a pathspec-limited review commit (`commitAfterReview`).
3
+ `opendoor` is a PI WEB plugin that surfaces
4
+ [Doorstop](https://github.com/doorstop-dev/doorstop) requirements stored in the
5
+ workspace in a **Requirements** workspace tab with
6
+ the document tree, per-item state (reviewed/unreviewed changes, suspect links),
7
+ validation findings and `doorstop` actions, plus agent prompts
8
+ that insert requirement context into the session.
9
+ The paired backend also recovers an item file's reviewed version from git for
10
+ the **Changes since review** diff, and Review can opt in to a review commit
11
+ (`commitAfterReview`).
16
12
 
17
13
  Full feature spec: [`docs/feature-doorstop-plugin.md`](docs/feature-doorstop-plugin.md)
18
14
  (§5 architecture, §6 requirements state model, §7–§8 UI/actions).
19
15
 
20
16
  ## Status
21
17
 
22
- Requires **pi-web 1.202609.1 or newer** (browser plugin API v4 / server
23
- plugin API v3). Tested through **pi-web 1.202610.8**, which remains
24
- **additive** for this plugin — its browser v4/server v3 entries still load,
25
- with once-per-plugin deprecation diagnostics expected (see **Rollout** below
26
- for the upgrade order and the 1.202610.x sessiond-restart note). The Phase 3 pairing
27
- on top of that migration (public session metadata + session-list row
28
- labels, plan §2.1) stays on the same v4/v3 contracts. The package is one npm
29
- package (`opendoor-plugin`) that ships a PI WEB plugin AND a Pi extension:
30
- the plugin's browser/server halves below, plus the review-dispatch extension
31
- that starts labeled review conversations (`/opendoor-review`, see **Review
32
- dispatch (Pi extension)** below). The plugin loads on older hosts unchanged;
33
- only the extension's dispatch bridge, the public metadata, and the row
34
- labels need a 1.202610.x session daemon (see **Rollout**). The plugin is
35
- fully wired and green:
36
-
37
- - **Browser entry** (`src/browser/pi-web-plugin.ts`, apiVersion 4, name `Opendoor`)
38
- delegates to `createOpendoorBrowserContributions` in
39
- `src/browser/doorstop-contributions.ts`, which contributes:
40
-
41
- - **panel** `workspace.doorstop` (title **Requirements**, order 5 — deliberately
42
- below the bundled Files panel's order 10, which answers every chat file link
43
- unconditionally in the host's first-accepting-panel dispatch; this is what
44
- makes the `fileOpenQuery` claim — chat links to doorstop item files open the
45
- item's detail view in this panel instead of Files — reachable, at the cost
46
- of the Requirements tab preceding Files) — the Lit
47
- body element `<pi-web-opendoor-panel-body>`, driven by the per-workspace
48
- controller registry (`src/browser/doorstop-panel.ts`): five vertical sections —
49
- `project-actions` (title, Items/Findings toggle, Refresh / Run validation
50
- / Publish HTML), `item-list` (filters row + item rows with state chips),
51
- `item-details` (links in/out, references, extended attributes,
52
- collapsible **Changes since review** diff), `item-action-palette`
53
- (Review / Clear suspect links / Edit / Link / Unlink / Ask-agent menu,
54
- sized to the host prompt footer), and `status-bar` (status badge,
55
- duration, Dismiss button, and the auto-expanding stdout/stderr of the
56
- latest CLI run, plus the review-commit outcome line when the review
57
- requested a commit);
58
- - **actions** `view.doorstop` (mod+7) and `workspace.refresh-doorstop`
59
- (mod+shift+d);
60
- - **label** `doorstop-status` — the informational requirement-health counts
61
- (async-cache idiom, feature spec §7.4);
62
- - **session-row label** `doorstop.review` — a compact indicator on session
63
- rows carrying public `opendoor` metadata; clicking it reopens the
64
- Requirements tab on the row's machine/workspace (works for unselected and
65
- archived rows, no live Pi connection needed). See **Session metadata and
66
- session-list row labels** below.
67
-
68
- - **Server entry** (`src/server-plugin.ts`, apiVersion 3, default export `PiWebServerPlugin`)
69
- contributes a workspace provider that claims only projects whose root
70
- contains a `.doorstop.yml` (cheap `fs.access` probe — everything else stays
71
- with the bundled Git provider) and serves a six-operation backend:
72
- `doorstop.run` runs one Doorstop CLI invocation — a review request carrying
73
- the opt-in `commit: true` flag is followed by a pathspec-limited git commit
74
- of the item file (`doorstop: review <uid>`) — the read-only
75
- `doorstop.item-baseline` recovers the reviewed version of an item file from
76
- git history, and the four git actions `doorstop.git-status`,
77
- `doorstop.git-stage`, `doorstop.git-unstage`, and `doorstop.git-commit`
78
- power the panel's git strip and stage/unstage/commit controls (see **Git
79
- actions** below). `src/doorstop-backend.ts` validates each request, builds argv
80
- server-side (the browser never sends shell strings), and runs through the
81
- host's `execFile()` helper with `cwd` = the workspace path, timeouts
82
- clamped to 8.5 s (the review+git pipeline shares one deadline budget under
83
- the host's 10 s callback bound), and per-workspace run serialization. The
84
- server entry permanently declares no `requires` and no `start()`: the
85
- capability-driven `doorstop.ask-agent` dispatch it once carried was
86
- unreachable by construction on a workspace-provider-contributing entry
87
- (PI WEB rejects the provider-dependency capability cycle) and has been
88
- retired. The review dispatch now runs in the Pi extension below.
18
+ Requires **pi-web 1.202611.1 or newer** (browser and server plugin API v5).
19
+
20
+ One package (`opendoor-plugin`) ships the PI WEB plugin (both entries below)
21
+ and the Pi extension (`/opendoor-review`).
22
+
23
+ - **Browser entry** (`src/browser/pi-web-plugin.ts`, apiVersion 5, name
24
+ `Opendoor`) delegates to `createOpendoorBrowserContributions`
25
+ (`src/browser/doorstop-contributions.ts`): the **panel** `workspace.doorstop`
26
+ (title **Requirements**, order 5 — below the Files panel's order 10 so doorstop
27
+ item links reach its `fileOpenQuery` first, at the cost of the Requirements tab
28
+ preceding Files in the tab order; controller registry in
29
+ `src/browser/doorstop-panel.ts`, sections in **Using the panel**),
30
+ the **actions** `view.doorstop` (mod+7) and `workspace.refresh-doorstop`
31
+ (mod+shift+d), the **label** `doorstop-status` (requirement-health counts,
32
+ feature spec §7.4), and the **session-row label** `doorstop.review` (reopens the
33
+ Requirements tab from a row carrying public `opendoor` metadata, archived rows
34
+ included — feature spec, **Session metadata and session-list row labels**).
35
+ - **Server entry** (`src/server-plugin.ts`, apiVersion 5, default export
36
+ `PiWebServerPlugin`) contributes a workspace provider for projects whose root
37
+ has a `.doorstop.yml` (cheap `fs.access` probe; everything else stays with the
38
+ bundled Git provider) and serves six operations: `doorstop.run` (one CLI
39
+ invocation; a review carrying the opt-in `commit: true` flag is followed by a
40
+ pathspec-limited `doorstop: review <uid>` commit), the read-only
41
+ `doorstop.item-baseline` (the reviewed version, from git history), and
42
+ `doorstop.git-status` / `-stage` / `-unstage` / `-commit` (see **Git
43
+ actions**). `src/doorstop-backend.ts` validates each request, builds argv
44
+ server-side (never a browser shell string) and runs it via `execFile()`; see
45
+ its header for timeouts and per-workspace serialization. The entry declares no
46
+ `requires` and no `start()`: the retired `doorstop.ask-agent` dispatch could
47
+ not live there (provider-cycle rule) — see `src/server-plugin.ts`.
89
48
  - **Pi extension** (`src/pi-extension/opendoor-review.ts`, bundled to
90
- `dist/pi-extension/index.js` and declared by `pi.extensions`) registers
91
- `/opendoor-review <UID> [instruction…]`. It publishes the shipped public
92
- `opendoor` session metadata (`{ schema: 1, op: "review", uids }`) and asks
93
- PI WEB's own per-session dispatch bridge to start an independent
94
- conversation in the same registered project — the same spawn semantics and
95
- delegation restrictions as `spawn_session`, with model/thinking inherited
96
- from the initiating conversation. Every outcome (started / refused / no
97
- bridge) is reported as a message in the initiating conversation. This is
98
- the pathway the panel's **Start review session** entry drives (see **Review
99
- dispatch (Pi extension)** below).
100
-
101
- Dispatch (per action, in `src/browser/doorstop-panel-element.ts`): when the workspace
102
- is opendoor-owned with an active backend, the action sends a structured
103
- request via `context.peer.request("doorstop.run", …)` (the review-commit
104
- flag rides along when the setting is enabled; the "Changes since review"
105
- section fetches via `context.peer.request("doorstop.item-baseline", …)`)
106
- and the captured output appears in the panel's status bar (auto-expanded);
107
- the panel auto-rescans on completion. When the backend is absent (unpaired
108
- install, or a workspace the provider does not own), the action falls back to
109
- `terminal.runCommand()` with the same shell command, so output stays visible
110
- and auditable in the terminal.
111
-
112
- The shared types + function-signature contracts live in
113
- `src/doorstop-contract.ts`; the two backend operation contracts
114
- (`doorstop.run` and `doorstop.item-baseline`) shared by both halves, plus
115
- the four git-action contracts (`doorstop.git-status`, `doorstop.git-stage`,
116
- `doorstop.git-unstage`, `doorstop.git-commit`), live in
117
- `src/doorstop-backend-contract.ts`. Later modules import these and must not
118
- redefine them.
49
+ `dist/pi-extension/index.js`, declared by `pi.extensions`) registers
50
+ `/opendoor-review <UID> [instruction…]`: it publishes the public `opendoor`
51
+ metadata (`{ schema: 1, op: "review", uids }`) and asks PI WEB's per-session
52
+ dispatch bridge to start an independent conversation in the same project (the
53
+ `spawn_session` rules, model/thinking inherited), reporting every outcome in
54
+ the initiating conversation — the pathway **Start review session** drives.
55
+
56
+ Dispatch (`src/browser/doorstop-panel-element.ts`) uses
57
+ `context.peer.request("doorstop.run", …)` (the review-commit flag rides along
58
+ when enabled; **Changes since review** uses `doorstop.item-baseline`) with the
59
+ output in the auto-expanded status bar, or falls back to `terminal.runCommand()`
60
+ with the same shell command when unpaired. Shared types and operation contracts
61
+ live in `src/doorstop-contract.ts` and `src/doorstop-backend-contract.ts`.
119
62
 
120
63
  ## Workspace settings (`.pi-web/opendoor.json`)
121
64
 
122
- Optional per-workspace configuration, read by the plugin on every load
123
- (missing file → defaults; malformed or invalid values → warning diagnostics
124
- in the panel + defaults). Workspace-scoped settings cannot use pi-web's
125
- `plugins.<id>.settings` config (that is host-side and only reaches server
126
- entries), so this file plays the same role `.pi-web/tasks.json` plays for the
127
- bundled workspace-tasks plugin. The *server* entry's host-scoped knobs
128
- (CLI binary path, exec timeout) are configured through
129
- `plugins.opendoor.settings` instead — see **Host settings** below:
65
+ Optional per-workspace configuration, read on every plugin load (missing file →
66
+ defaults; malformed or invalid values → warning diagnostics + defaults). pi-web's
67
+ `plugins.<id>.settings` is host-side and reaches only server entries, so this
68
+ file plays the role `.pi-web/tasks.json` plays for the bundled workspace-tasks
69
+ plugin; server-entry knobs are in **Host settings** below:
130
70
 
131
71
  ```json
132
72
  {
@@ -138,228 +78,178 @@ bundled workspace-tasks plugin. The *server* entry's host-scoped knobs
138
78
  }
139
79
  ```
140
80
 
141
- - `publish.target` — path passed to `doorstop publish all`. Must be a safe
81
+ - `publish.target` — path passed to `doorstop publish all`; must be a safe
142
82
  workspace-relative path (no `..`, no absolute, no drive prefix, no leading
143
- `-`). It is shell-quoted at the terminal-command boundary when it contains
144
- characters outside the inert `\w./-` alphabet; on the backend path the
145
- target is passed as an argv element, never through a shell.
83
+ `-`). Shell-quoted at the terminal-command boundary when it contains characters
84
+ outside the inert `\w./-` alphabet; on the backend path it is an argv element,
85
+ never a shell string.
146
86
  - `discovery.excludedDirectories` — additional directory names the
147
87
  `.doorstop.yml` walk never enters (merged with the built-in
148
88
  `.git`/`node_modules` skips, max 16 names).
149
- - `showAdditionalAttribute` — array of attribute NAMES rendered in every
150
- item-list row as `key: value` (values jsonish-quoted, joined with ` · `, in
151
- the configured order). Entries may be any extended attribute key; an
152
- attribute an item does not have is skipped for that row, and no unconfigured
153
- attribute ever appears. The modeled `text` is special-cased: instead of a
154
- `text: "…"` pair in the attribute column, the item body renders raw and
155
- unquoted on its own indented second line below the row, clamped to two lines
156
- with an ellipsis (the full body lives in the detail pane); it is skipped when
157
- the item body is empty. Default `[]` leaves today's rows unchanged. Invalid
158
- entries are dropped with a warning each, duplicates are deduplicated, the
159
- list is capped at 16 names, and unknown keys are accepted (they simply match
160
- nothing).
161
- - `commitAfterReview` — opt-in boolean (default `false`): when enabled on a
162
- paired workspace, the **Review** action's `doorstop review <uid>` is
163
- followed by a pathspec-limited git commit of the item file with the
164
- conforming message `doorstop: review <uid>` (git `add` then
165
- `commit -m … -- <path>` — other staged/unstaged work is left untouched).
166
- Committing user content is strictly opt-in, and the status bar
167
- narrates every commit outcome (`commit: <sha>` / `clean` (already
168
- committed) / `skipped` (not a git repository | item file not found |
169
- review failed | deadline) / `failed` — `<stderr>`). Under the default,
170
- Review behaves exactly as before.
89
+ - `showAdditionalAttribute` — attribute NAMES rendered in every item-list row as
90
+ `key: value` (values jsonish-quoted, joined with ` · `, in configured order);
91
+ any extended attribute key works, an attribute an item lacks is skipped, and no
92
+ unconfigured attribute ever appears. The modeled `text` is special-cased: the
93
+ body renders raw and unquoted on an indented second line below the row, clamped
94
+ to two lines with an ellipsis (the full body lives in the detail pane), skipped
95
+ when empty. Default `[]` leaves today's rows unchanged. Invalid entries are
96
+ dropped with a warning each, duplicates deduplicated, capped at 16 names;
97
+ unknown keys simply match nothing.
98
+ - `commitAfterReview` — opt-in boolean (default `false`): on a paired workspace
99
+ the **Review** action's `doorstop review <uid>` is followed by a
100
+ pathspec-limited commit of the item file with the conforming message
101
+ `doorstop: review <uid>` (git `add` then `commit -m … -- <path>`; other
102
+ staged/unstaged work is untouched). Committing user content is strictly opt-in;
103
+ the status bar narrates every outcome (`commit: <sha>` / `clean` (already
104
+ committed) / `skipped` (not a git repository | item file not found | review
105
+ failed | deadline) / `failed` — `<stderr>`); Review is otherwise unchanged.
171
106
 
172
107
  ## Host settings (`plugins.opendoor.settings`)
173
108
 
174
- Optional, server-side only (requires the paired install and a sessiond
175
- restart to take effect). Parsed leniently; wrong types fall back to defaults:
109
+ Optional, server-side only (needs the paired install and a sessiond restart);
110
+ wrong types fall back to defaults:
176
111
 
177
112
  ```json
178
113
  { "doorstopPath": "/opt/venv/bin/doorstop", "gitPath": "git", "timeoutMs": 8000 }
179
114
  ```
180
115
 
181
- - `doorstopPath` — the Doorstop CLI binary. Defaults to `"doorstop"` resolved
182
- on the **sessiond host PATH** (the daemon environment, not your login shell
183
- — set this when the CLI lives in a venv or is not on the service PATH).
184
- - `gitPath` — the git binary used by the review→commit pipeline, the
185
- `doorstop.item-baseline` fetch, and the four git actions (status strip,
186
- Stage all, per-item Unstage, Git commit). Defaults to `"git"` resolved on the
187
- **sessiond host PATH** — set it when git is not on the service PATH (the
188
- same escape hatch as `doorstopPath`).
189
- - `timeoutMs` — per-run exec timeout in ms. May SHORTEN the 8 500 ms default
190
- but never exceed it (the host bounds every provider callback at 10 s, so a
191
- longer doorstop run would be killed by pi-web, not by the plugin). It also
192
- bounds every git exec of the review→commit pipeline, the baseline fetch,
193
- and the git actions, which share one deadline budget per callback.
116
+ - `doorstopPath` — the Doorstop CLI binary, default `"doorstop"`, resolved on the
117
+ **sessiond host PATH** (the daemon environment, not your login shell — set it
118
+ when the CLI lives in a venv or is off the service PATH).
119
+ - `gitPath` — the git binary for the review→commit pipeline, the
120
+ `doorstop.item-baseline` fetch and the four git actions. Default `"git"` on the
121
+ **sessiond host PATH** — the same escape hatch as `doorstopPath`.
122
+ - `timeoutMs` — per-run exec timeout in ms. May SHORTEN the 8 500 ms default but
123
+ never exceed it (the host bounds every provider callback at 10 s, so a longer run
124
+ would be killed by pi-web, not by the plugin). It also bounds every git exec of
125
+ the review→commit pipeline, the `doorstop.item-baseline` fetch and the git
126
+ actions: one deadline budget per callback.
194
127
 
195
128
  ## Using the panel
196
129
 
197
- The right-hand panel body is divided into five vertical sections, top to
198
- bottom:
199
-
200
- 1. **`project-actions`** — the project heading (the workspace's root path,
201
- basename shown, full path on hover), the **Items / Findings** toggle, the
202
- project-scoped buttons **Refresh**, **Run validation**, and **Publish
203
- HTML**, and — on paired installs — the **Git status** strip, the **Stage
204
- all** button, and the **Git commit** control (message input + button); see
205
- **Git actions** below.
206
- 2. **`item-list`** — a filters row (document chips, state filter, search)
207
- above the item rows; each row shows its level, UID, excerpt, any attributes
208
- configured through `showAdditionalAttribute`, and its state chips.
209
- 3. **`item-details`** — the selected item's full detail pane (unchanged).
210
- 4. **`item-action-palette`** — the item actions (**Review**, **Clear suspect
211
- links**, **Link** + target UID input, **Ask agent** menu), moved out of
212
- the detail pane so the row spans the panel at the same height as the
213
- center-panel chat prompt footer. A muted "Select an item…"
214
- placeholder keeps the palette at constant height while nothing is
215
- selected; the palette is hidden in the Findings view.
216
- 5. **`status-bar`** — the run status row (badge, duration, **Dismiss**),
217
- sized like the center-panel status bar, plus the captured output.
218
-
219
- - **Items / Findings toggle** in the project-actions row. Findings lists all
220
- plugin-computed findings and diagnostics (error > warning > info); a
221
- finding's UID navigates back to the item. Findings are informational — run
222
- the **Run validation** button for the authoritative `doorstop` output
223
- (paired: rendered in the panel's status bar; unpaired: in the workspace
224
- terminal).
225
- - **Status bar**: after a paired action completes, the bottom bar shows the
226
- run's status (ok / failed / killed / error) and duration, and the panel
227
- re-scans automatically. The captured stdout/stderr *auto-expands* whenever
228
- a run produces a message to show (any output, an error message, or a
229
- review-commit line) — no interaction needed; a run with nothing to display
230
- stays collapsed to its status row. **Dismiss** clears the run and collapses
231
- the bar; the next run with output re-expands it. Runs killed at the exec
232
- timeout preserve and show partial output; prefer the workspace terminal for
233
- long publishes. When a review ran with `commitAfterReview` enabled, the bar
234
- adds a `commit: …` narration line (`commit: <short-sha>` /
235
- `clean (already committed)` / `skipped (not a git repository | item file
236
- not found | review failed | deadline)` / `failed — <stderr excerpt>`); a
237
- failed or skipped commit never changes the run's ok/failed badge.
238
- - **State chips** per item (feature spec §6): inactive, non-normative,
239
- reviewed / unreviewed changes, suspect links, no child links, no links,
240
- unknown link, missing reference.
241
- - **Changes since review** (item detail pane, paired installs only): for an
242
- item with unreviewed changes and a stored `reviewed` fingerprint, a
243
- collapsed section shows what changed since the last review. Expanding it
244
- lazily fetches the reviewed version of the item file from git history via
245
- the `doorstop.item-baseline` backend operation, stamp-matches it against
246
- the stored fingerprint, and renders a text line diff plus chips for
247
- `ref`/`references`/link-UID/extended-attribute changes. When no baseline
248
- can be recovered it says so honestly ("no git history", "could not locate
249
- the reviewed version — history may have been rewritten"). The
250
- `doorstop: review <uid>` commit message is the historical anchor that the
251
- deferred browser-side git parsing of feature spec §12 could not give — the
252
- recovery runs server-side, where git lives.
253
- - **Item actions** (item-action-palette): Review (`doorstop review UID` — with
254
- `commitAfterReview` enabled and paired, followed by the pathspec-limited
255
- review commit), Clear suspect links (`doorstop clear UID [parents…]`),
256
- Edit (`doorstop edit UID`), Link/Unlink via inline UID input (validated
257
- against the Doorstop UID grammar; shell metacharacters rejected), and — on
258
- paired installs — Stage / Unstage for the selected item's own file
259
- (`doorstop.git-stage` / `doorstop.git-unstage` with just that item's
260
- path; see **Git actions** below). Each is independently disabled: Stage
261
- when the file has no unstaged (`Y`) changes, Unstage when it has nothing
262
- staged (`X`), and both while a run is in flight.
263
- - **Git actions** (paired installs only; every git control is hidden
264
- without the paired backend — terminal-fallback users commit manually as
265
- today):
266
- - **Git status strip** — a compact readout above the item list:
267
- `⎇ <branch> · <n> staged · <n> dirty · ↑<ahead> ↓<behind>` (zero/absent
268
- parts omitted; `no git` outside a repository; a retry link on fetch
269
- errors). The strip refreshes on panel load, after every run (a stage,
270
- unstage, or commit invalidates the cache), and whenever you click it.
271
- Outside a git repository the strip shows `no git` and the stage/unstage/
272
- commit controls are no-ops that report `skipped`.
273
- - **Stage all** — stages every Doorstop-managed file (the root
274
- `.doorstop.yml`, each document's config file, each item file) via the
275
- `doorstop.git-stage` backend operation. Staging is idempotent: a second
276
- click over an already-staged set reports `clean — nothing to stage`
277
- instead of a git error (staged deletions in particular are skipped
278
- rather than fatal). Disabled while a run is in progress or when the
279
- loaded index has no documents. Note: staging is only as fresh as the
280
- loaded index — files created on disk after the last **Refresh** are
281
- missed until you refresh and stage again.
282
- - **Unstage (per item)** — while an item's file has staged (index)
283
- changes, its list row shows a **minus** in place of the usual plus, and
284
- the item palette enables an **Unstage** button, both served by the
285
- `doorstop.git-unstage` operation (`git reset -q -- :(literal)<path>`
286
- server-side). The row's pointer shortcut prefers the minus when a file
287
- is both staged and further modified (`MM`), so "I staged by mistake"
288
- always surfaces a minus; the palette shows Stage and Unstage as two
289
- independently-disabled buttons in that case. Unstaging restores the
290
- index entry but never touches the working tree: a staged addition
291
- returns to `??` untracked, a staged deletion returns to an unstaged
292
- deletion. It is idempotent — a second Unstage over an already-clean path
293
- reports `clean — nothing to unstage`. Unmerged paths (`UU`, `AA`, `DD`,
294
- `DU`, `AU`, `UD`, `UA`) are excluded — a `git reset` on an unmerged path
295
- would silently resolve the conflict in the index. On unpaired installs no
296
- git control is rendered at all; while a run is in flight no minus appears
297
- and the palette buttons are disabled (the per-row affordance is also
298
- suppressed when the status file list is truncated, so stale path data is
299
- never acted on).
300
- - **Git commit** — a single-line message input plus a **Commit** button,
301
- served by the `doorstop.git-commit` operation. The commit records **the
302
- staged index** (`git commit -m <message>` with no pathspec and no
303
- add) — exactly like the git CLI. Consequences, all intended: unstaged
304
- WIP is never swept into a commit; files you staged yourself (via the
305
- git CLI or any other tool) are committed too; the strip's `staged`
306
- count tells you what a commit will record before you click. Empty or
307
- whitespace-only messages are rejected client-side (git with an empty
308
- `-m` would open an editor and hang the exec). Committing with nothing
309
- staged reports `clean — nothing staged`. Outcomes are narrated in the
310
- status bar (`staged <n> paths` / `committed <sha>` / `clean — nothing
311
- to stage` / `clean — nothing staged` / `skipped` / `failed — <stderr
312
- excerpt>`); a failing pre-commit hook surfaces its stderr in the
313
- expanded status bar body.
314
- - **Ask the agent** menu: inserts ready-made prompts (explain, fix suspect
315
- links, draft child requirement, review readiness) into the session prompt
316
- editor using the item's paths and state (feature spec §7.3). **Start
317
- review session** composes `/opendoor-review <UID>` plus the item context
318
- into that same editor and needs no paired backend: sending it lets the
319
- opendoor Pi extension start an independent review conversation whose list
320
- row carries a **`firstUid +n` label** that reopens the Requirements tab on
321
- the row's workspace — including for unselected and archived rows and with
322
- no live Pi connection — landing on the item detail pane. After the insert
323
- the panel's status bar shows a quiet `ok` **review dispatch** run row.
324
- Without the extension installed the inserted text is still a
325
- self-sufficient review instruction, so the review happens in the current
326
- conversation; a refused dispatch is reported in the chat by the extension.
327
- See **Session metadata and session-list row labels** in the feature spec.
328
- - Fingerprints and the requirement state are recomputed in the browser from
329
- the YAML files; everything mutating goes through the visible `doorstop`
330
- CLI — paired, headlessly via the server entry (output captured in the
331
- panel), or in a workspace terminal when unpaired — plus, opt-in, the
332
- narrated pathspec-limited review commit (`commitAfterReview`) — so all
333
- effects stay auditable in git.
130
+ Five vertical sections, top to bottom: `project-actions` (project heading — root
131
+ path, basename shown, full path on hover — the Items/Findings toggle, Refresh, Run
132
+ validation, Publish HTML, and on paired installs the Git strip, Stage all and
133
+ Commit), `item-list` (filters row above rows showing level, UID, excerpt,
134
+ `showAdditionalAttribute` values and state chips), `item-details` (Markdown item
135
+ text, links in/out, references, extended attributes, the Changes diff),
136
+ `item-action-palette` (Review, Clear suspect links, Link, Ask agent — spanning the
137
+ panel at chat-prompt-footer height, with the muted "Select an item…" placeholder,
138
+ hidden in the Findings view) and `status-bar` (see below).
139
+
140
+ - **Items / Findings toggle**: Findings lists plugin-computed findings and
141
+ diagnostics (error > warning > info), and a finding's UID navigates back to the
142
+ item. They are informational — **Run validation** gives the authoritative
143
+ `doorstop` output (paired: status bar; unpaired: terminal).
144
+ - **Status bar** (sized like the center-panel one): after a paired action it shows
145
+ status (ok / failed / killed / error) and duration, and the panel re-scans
146
+ automatically. Captured stdout/stderr *auto-expands* whenever there is something
147
+ to show (output, an error, a review-commit line); a run with nothing to display
148
+ stays collapsed. **Dismiss** clears the run and collapses the bar; the next run
149
+ with output re-expands it. Killed runs keep partial output (prefer the terminal
150
+ for long publishes). With `commitAfterReview` on, the bar adds the `commit: …`
151
+ line from **Workspace settings**; a failed or skipped commit never changes the
152
+ run's ok/failed badge.
153
+ - **State chips** per item (feature spec §6): inactive, non-normative, reviewed /
154
+ unreviewed changes, suspect links, no child links, no links, unknown link,
155
+ missing reference.
156
+ - **Changes since review** (item detail pane, paired installs only): an item with
157
+ unreviewed changes and a stored `reviewed` fingerprint gets a collapsed section;
158
+ expanding it fetches the reviewed item file from git history via
159
+ `doorstop.item-baseline`, stamp-matches it against the fingerprint and renders a
160
+ line diff plus chips for `ref`/`references`/link-UID/extended-attribute changes.
161
+ With no recoverable baseline it says so ("no git history", "could not locate the
162
+ reviewed version — history may have been rewritten"). The `doorstop: review
163
+ <uid>` message is the historical anchor feature spec §12's deferred browser-side
164
+ git parsing could not give; recovery runs server-side.
165
+ - **Item actions** (item-action-palette): Review (`doorstop review UID`, plus the
166
+ review commit when `commitAfterReview` is on), Clear suspect links (`doorstop
167
+ clear UID [parents…]`), Edit (`doorstop edit UID`), Link/Unlink via inline UID
168
+ input (Doorstop UID grammar enforced; shell metacharacters rejected), and on
169
+ paired installs Stage / Unstage for the item's own file (`doorstop.git-stage` /
170
+ `doorstop.git-unstage`; see **Git actions**). Each disables independently: Stage
171
+ without unstaged (`Y`) changes, Unstage with nothing staged (`X`), both mid-run.
172
+ - **Git actions** (paired only; otherwise every git control is hidden and you
173
+ commit manually):
174
+ - **Git status strip** — compact readout above the item list:
175
+ `⎇ <branch> · <n> staged · <n> dirty · ↑<ahead> ↓<behind>` (zero/absent parts
176
+ omitted, `no git` outside a repository, a retry link on fetch errors).
177
+ Refreshes on panel load, after every run (a stage, unstage or commit
178
+ invalidates the cache) and on click; outside a repository the other git
179
+ controls report `skipped`.
180
+ - **Stage all** — stages every Doorstop-managed file (root `.doorstop.yml`, each
181
+ document config, each item file) via `doorstop.git-stage`. Idempotent: a
182
+ second click over an already-staged set reports `clean — nothing to stage`
183
+ instead of a git error (staged deletions are skipped, not fatal). Disabled
184
+ during a run or with no documents in the loaded index, and only as fresh as
185
+ that index — files created after the last **Refresh** are missed until you
186
+ refresh and stage again.
187
+ - **Unstage (per item)** — a staged item file swaps its row's plus for a
188
+ **minus** and enables **Unstage** in the palette, both via
189
+ `doorstop.git-unstage` (`git reset -q -- :(literal)<path>` server-side). A file
190
+ both staged and further modified (`MM`) still shows the minus; the palette then
191
+ offers Stage and Unstage as two independently-disabled buttons. Unstaging
192
+ restores the index entry but never touches the working tree (a staged addition
193
+ returns to `??`, a staged deletion to an unstaged deletion); it is idempotent
194
+ (`clean — nothing to unstage`) and excludes unmerged paths (`UU`, `AA`, `DD`,
195
+ `DU`, `AU`, `UD`, `UA`), where `git reset` would silently resolve the conflict.
196
+ During a run no minus appears and the palette buttons are disabled; the per-row
197
+ affordance is also suppressed when the status file list is truncated, so stale
198
+ path data is never acted on.
199
+ - **Git commit** — a message input plus **Commit**, served by
200
+ `doorstop.git-commit`. It records **the staged index** (`git commit -m
201
+ <message>`, no pathspec, no add), exactly like the git CLI: unstaged WIP is
202
+ never swept in, files you staged yourself are included, and the strip's
203
+ `staged` count shows what a commit will record. Empty or whitespace-only
204
+ messages are rejected client-side (git would open an editor and hang the exec);
205
+ nothing staged reports `clean — nothing staged`. Outcomes are narrated in the
206
+ status bar (`staged <n> paths` / `committed <sha>` / `clean — nothing to stage`
207
+ / `clean — nothing staged` / `skipped` / `failed — <stderr excerpt>`), including
208
+ a failing pre-commit hook's stderr.
209
+ - **Ask the agent** menu: inserts ready-made prompts (explain, fix suspect links,
210
+ draft child requirement, review readiness) into the session prompt editor from
211
+ the item's paths and state (feature spec §7.3). **Start review session**
212
+ composes `/opendoor-review <UID>` plus the item context there and needs no
213
+ paired backend: the extension starts an independent review conversation whose
214
+ row carries a **`firstUid +n` label** reopening the Requirements tab on that
215
+ workspace (unselected and archived rows included, no live Pi connection needed),
216
+ landing on the item detail pane. After the insert the status bar shows a quiet
217
+ `ok` **review dispatch** run row. Without the extension the inserted text is
218
+ still a self-sufficient review instruction, so the review happens in the current
219
+ conversation; a refused dispatch is reported in the chat by the extension (see
220
+ **Session metadata and session-list row labels** in the feature spec).
221
+ - Fingerprints and requirement state are recomputed in the browser from the YAML
222
+ files; everything mutating goes through the visible `doorstop` CLI — paired,
223
+ headlessly via the server entry, or in a workspace terminal when unpaired —
224
+ plus, opt-in, the narrated review commit (`commitAfterReview`) — so all effects
225
+ stay auditable in git.
226
+
334
227
 
335
228
  ## Commands
336
229
 
337
230
  ```sh
338
- npm install # install dependencies
339
- npm test # vitest (unit tests under src/**/*.test.ts)
231
+ npm install # install dependencies
232
+ npm test # vitest (unit tests under src/**/*.test.ts)
340
233
  npm run test:coverage # v8 coverage report
341
- npm run typecheck # tsc --noEmit (strict)
342
- npm run build # esbuild: dist/browser/pi-web-plugin.js + dist/server-plugin.js
343
- # + dist/pi-extension/index.js, then the dist-root
344
- # package.json plugin manifest (see the build script header)
345
- npm run dev # watch mode build
234
+ npm run typecheck # tsc --noEmit (strict)
235
+ npm run build # esbuild → dist/browser, dist/server, dist/pi-extension
236
+ # + the dist-root manifest (see the build script header)
237
+ npm run dev # watch build
346
238
  ```
347
239
 
348
240
  ## Installing the plugin locally (id `opendoor`)
349
241
 
350
242
  The package carries two independently enabled halves: the **PI WEB plugin**
351
- (browser + server entries) and the **Pi extension** (`/opendoor-review`).
352
- The repository-root `package.json` is the npm-package-root manifest, so both
353
- halves come from ONE install through **Settings → Pi packages** (npm name,
354
- git/URL, or a local package source). The `ln -s dist` route below remains
355
- available for plugin-only development.
243
+ (browser + server entries) and the **Pi extension** (`/opendoor-review`). The
244
+ repository-root `package.json` is the npm-package-root manifest, so both halves
245
+ come from ONE install through **Settings → Pi packages** (npm name, git/URL or a
246
+ local source); the `ln -s dist` route below remains for plugin-only development.
356
247
 
357
248
  ### PI WEB plugin (browser + server entries)
358
249
 
359
250
  Install the package once — **Settings → Pi packages** with source
360
251
  `opendoor-plugin` (or this repository's path / git URL) — then enable
361
- **Opendoor** under **Settings → PI WEB plugins**. For a plugin-only local
362
- build the symlink route still works:
252
+ **Opendoor** under **Settings → PI WEB plugins**. Plugin-only local build:
363
253
 
364
254
  ```sh
365
255
  npm run build
@@ -367,66 +257,23 @@ ln -s "$(pwd)/dist" ~/.pi-web/plugins/opendoor
367
257
  ```
368
258
 
369
259
  Then **restart the session daemon** (server entries load at sessiond startup
370
- only; the browser bundle hot-reloads on page reload) and reload the PI WEB
371
- page:
260
+ only; the browser bundle hot-reloads on page reload) and reload the page:
372
261
 
373
262
  ```sh
374
263
  systemctl --user restart pi-web-sessiond.service
375
264
  curl -s http://127.0.0.1:8504/pi-web-plugins/manifest.json
376
265
  ```
377
266
 
378
- The manifest should list an `opendoor` plugin entry **with a
379
- `backendRevision`** (signing the paired server module). If `backendRevision`
380
- is missing, the server module failed to load — check the sessiond log; the
381
- plugin still works fully via the terminal fallback.
267
+ The manifest should list an `opendoor` entry **with a `backendRevision`** (signing
268
+ the paired server module); if it is missing, the server module failed to load
269
+ (check the sessiond log) and the plugin works fully via the terminal fallback.
382
270
 
383
271
  ### Review dispatch (Pi extension)
384
272
 
385
- Installing the package is what loads the extension; enabling the PI WEB
386
- plugin is a separate decision. After installing or updating the package on a
387
- machine, run `/reload` in each idle conversation so the `/opendoor-review`
388
- command is registered there (an ordinary Pi-resource change — no provider
389
- restart involved). The extension needs PI WEB **1.202610.x** for the
390
- session-dispatch bridge; on an older host it reports "requires PI WEB's
391
- session dispatch bridge" in the conversation and starts nothing. The panel's
392
- **Start review session** entry needs no paired backend — it only composes the
393
- command into the prompt editor.
394
-
395
- ## Rollout (pi-web 1.202609.1)
396
-
397
- pi-web 1.202609.1 is a **hard break** for plugins — there is no
398
- compatibility shim in either direction: a v4/v3 host rejects v2/v1 plugin
399
- modules, and a v2/v1 host rejects the migrated v4/v3 modules. Upgrade the
400
- opendoor package and pi-web **together**, then restart
401
- `pi-web-sessiond.service` and reload the PI WEB page. During the transition
402
- the opendoor panel is **entirely absent** (both plugin entries rejected), not
403
- degraded. Federated gateways/targets must be upgraded in lockstep.
404
-
405
- ### pi-web 1.202610.3 (and later 1.202610.x)
406
-
407
- Unlike 1.202609.1, release **1.202610.3** is **additive** for opendoor: the
408
- untouched v4/v3 plugin keeps loading on a v1.202610.3 host, with a
409
- once-per-plugin deprecation diagnostic (no removal date). No source changes
410
- were needed — the only mindful adaptation was a no-op `navigate` stub in the
411
- test fixtures (`src/test-fixtures.ts`, `src/browser/pi-web-plugin.test.ts`),
412
- because 1.202610.x made `navigate` a required member of
413
- `PluginRuntimeContext` / `WorkspacePanelContext`.
414
-
415
- Host upgrade order for 1.202610.x: refresh the pi-web checkout, then
416
- **manually restart `pi-web-sessiond.service`** — a UI/API autoreload alone
417
- is **not sufficient** (the release touched the session-serving paths
418
- heavily). The restart interrupts active sessions and terminals; reload the
419
- PI WEB page afterwards and confirm the manifest shows the opendoor entry
420
- with a `backendRevision`.
421
-
422
- #### Review dispatch and session-row labels (1.202610.x floor)
423
-
424
- The **Start review session** dispatch and the session-list row labels require
425
- a **1.202610.x** session daemon: that release introduced the public session
426
- metadata, the session-list label contribution, and the per-session dispatch
427
- bridge the extension emits on. The opendoor **server** entry declares no
428
- capabilities and loads across the whole supported range. `opendoor-plugin`
429
- **0.4.0** is the first release carrying the extension. Restart
430
- `pi-web-sessiond.service` once to load the server-side change, then run
431
- `/reload` in each idle conversation to load the extension's
432
- `/opendoor-review` command; the browser side only needs a page reload.
273
+ Installing the package loads the extension; enabling the PI WEB plugin is a
274
+ separate decision. After installing or updating the package, run `/reload` in
275
+ each idle conversation so `/opendoor-review` is registered there (an ordinary
276
+ Pi-resource change — no provider restart). The extension needs PI WEB's
277
+ session-dispatch bridge (1.202610.x); on an older host it reports "requires PI
278
+ WEB's session dispatch bridge" and starts nothing.
279
+
@@ -6562,7 +6562,8 @@ var DoorstopWorkspaceRegistry = class {
6562
6562
  /** Panel invalidation: re-run discovery + load unconditionally for the
6563
6563
  * connected workspace (paired or unpaired: the load job is browser-side, so there is no owned-workspace gate).
6564
6564
  *
6565
- * Plan Phase C step C1 — the v4 typed invalidation: `invalidation` carries
6565
+ * Plan Phase C step C1 — the typed invalidation (browser API v4,
6566
+ * unchanged on v5): `invalidation` carries
6566
6567
  * the reason (`"manual"` Refresh action / `"mutation"` a workspace file
6567
6568
  * changed / `"agent-activity"` agent work finished) and the affected
6568
6569
  * resources. The rescan stays REASON-AGNOSTIC deliberately: every reason
@@ -6819,6 +6820,10 @@ function jsonishText(value) {
6819
6820
  const json2 = JSON.stringify(value);
6820
6821
  return json2 === void 0 ? String(value) : json2;
6821
6822
  }
6823
+ var DOORSTOP_MARKDOWN_ATTRIBUTES = Object.freeze(["rationale"]);
6824
+ function isMarkdownAttribute(key, value) {
6825
+ return typeof value === "string" && DOORSTOP_MARKDOWN_ATTRIBUTES.includes(key.toLowerCase());
6826
+ }
6822
6827
  function diffValueText(value) {
6823
6828
  return value === void 0 ? "\u2014" : jsonishText(value);
6824
6829
  }
@@ -7977,6 +7982,107 @@ var detailPaneStyles = i`
7977
7982
  font-style: italic;
7978
7983
  }
7979
7984
 
7985
+ /* Plan §2.1: the item body is host-rendered Markdown. The host renderer
7986
+ * mounts its nodes in THIS shadow root (the host's
7987
+ * pi-web-content-rendering-boundary element deliberately has no shadow
7988
+ * root of its own — createRenderRoot returns the element), so the prose
7989
+ * rules below reach them. The plain-text pre-wrap on .doorstop-text is
7990
+ * not appropriate inside Markdown blocks; the boundary's own source
7991
+ * fallback sets its own pre-wrap inline.
7992
+ *
7993
+ * This element does NOT inherit host typography: the boundary only
7994
+ * avoids adding a shadow root, it does not import host theme CSS into
7995
+ * ours, so every rule the document needs is restated here. */
7996
+ .doorstop-text-markdown {
7997
+ white-space: normal;
7998
+ }
7999
+
8000
+ /* Edge trimming must reach the BLOCKS, not the wrappers: the boundary
8001
+ * element (display: contents) and its .content-rendering-body wrapper
8002
+ * sit between this element and the rendered blocks, so a direct-child
8003
+ * (> :first-child) selector only ever matched the boundary itself. No
8004
+ * matching :last-child rule: the body keeps the source-fallback span
8005
+ * AFTER the content wrapper, so the last block is never a last-child;
8006
+ * its trailing 8px margin is invisible inside the bordered box. */
8007
+ .doorstop-text-markdown :is(p, ul, ol, pre, blockquote, .table-scroll, h1, h2, h3, h4, h5, h6):first-child {
8008
+ margin-top: 0;
8009
+ }
8010
+
8011
+ .doorstop-text-markdown :is(p, ul, ol, blockquote, .table-scroll) {
8012
+ margin: 0 0 8px;
8013
+ }
8014
+
8015
+ .doorstop-text-markdown :is(ul, ol) {
8016
+ padding-left: 20px;
8017
+ }
8018
+
8019
+ .doorstop-text-markdown :is(h1, h2, h3, h4, h5, h6) {
8020
+ margin: 12px 0 6px;
8021
+ font-size: 14px;
8022
+ font-weight: 600;
8023
+ }
8024
+
8025
+ .doorstop-text-markdown code {
8026
+ border: 1px solid var(--pi-border-muted);
8027
+ border-radius: 4px;
8028
+ background: var(--pi-bg);
8029
+ padding: 0 3px;
8030
+ font-size: 12px;
8031
+ }
8032
+
8033
+ .doorstop-text-markdown pre {
8034
+ border: 1px solid var(--pi-border-muted);
8035
+ border-radius: 6px;
8036
+ padding: 8px;
8037
+ overflow-x: auto;
8038
+ }
8039
+
8040
+ .doorstop-text-markdown pre code {
8041
+ border: 0;
8042
+ background: transparent;
8043
+ padding: 0;
8044
+ }
8045
+
8046
+ .doorstop-text-markdown blockquote {
8047
+ border-left: 3px solid var(--pi-border-muted);
8048
+ padding-left: 8px;
8049
+ color: var(--pi-muted);
8050
+ }
8051
+
8052
+ /* The host wraps every table in a div.table-scroll role=region
8053
+ * tabindex=0 and styles it ONLY from host theme CSS
8054
+ * (pi-web base/formattedText.css), which does not reach this
8055
+ * unregistered shadow root. Without these two rules a wide table is
8056
+ * clipped by the pane and its focusable region is unusable, so the
8057
+ * panel restates the host's rules (pi-web 1.202611.1).
8058
+ *
8059
+ * overflow-wrap: normal undoes the anywhere inherited from
8060
+ * .doorstop-text: harmless (host-identical) in prose, but inside table
8061
+ * cells it would let every column collapse to min-content instead of
8062
+ * making the table overflow into its scroll region. */
8063
+ .doorstop-text-markdown .table-scroll {
8064
+ max-width: 100%;
8065
+ overflow-x: auto;
8066
+ overflow-y: hidden;
8067
+ overflow-wrap: normal;
8068
+ }
8069
+
8070
+ .doorstop-text-markdown .table-scroll:focus-visible {
8071
+ outline: 1px solid var(--pi-accent);
8072
+ outline-offset: 2px;
8073
+ }
8074
+
8075
+ .doorstop-text-markdown table {
8076
+ border-collapse: collapse;
8077
+ margin: 0;
8078
+ }
8079
+
8080
+ .doorstop-text-markdown :is(th, td) {
8081
+ border: 1px solid var(--pi-border-muted);
8082
+ padding: 2px 6px;
8083
+ text-align: left;
8084
+ }
8085
+
7980
8086
  .doorstop-links {
7981
8087
  display: grid;
7982
8088
  gap: 4px;
@@ -8051,6 +8157,28 @@ var detailPaneStyles = i`
8051
8157
  overflow-wrap: anywhere;
8052
8158
  }
8053
8159
 
8160
+ /* Plan plan-attribute-markdown D5: an allowlisted prose attribute
8161
+ * (initially "rationale") is host-rendered Markdown, so its row uses a
8162
+ * STACKED variant — the label above a full-width block — instead of
8163
+ * the two-column key|value grid sized for single-line <code> values.
8164
+ * The dt keeps the existing small muted label rule above.
8165
+ *
8166
+ * Nothing here restates the §2.1 prose rules: the rendered container
8167
+ * carries the shared .doorstop-text-markdown class, so block
8168
+ * margins, code/pre, blockquote and the 9bc9cc9 .table-scroll fixes
8169
+ * apply automatically. */
8170
+ .doorstop-attribute.doorstop-attribute-markdown {
8171
+ grid-template-columns: minmax(0, 1fr);
8172
+ gap: 3px;
8173
+ }
8174
+
8175
+ /* The escaped-text fallback (no host renderer) is prose too: keep its
8176
+ * line breaks. This only reaches the fallback — the markdown container
8177
+ * inside resets white-space to normal (.doorstop-text-markdown). */
8178
+ .doorstop-attribute-markdown dd {
8179
+ white-space: pre-wrap;
8180
+ }
8181
+
8054
8182
  .doorstop-findings {
8055
8183
  display: grid;
8056
8184
  gap: 4px;
@@ -8685,7 +8813,7 @@ function renderDetail(host, result) {
8685
8813
  ${item.stateKeys.map((key) => host.renderStateChip(key))}
8686
8814
  </div>
8687
8815
  <h4 class="doorstop-section-title">Text</h4>
8688
- <p class=${item.text === "" ? "doorstop-text doorstop-text-empty" : "doorstop-text"}>${item.text === "" ? "empty" : item.text}</p>
8816
+ ${renderItemText(host, item)}
8689
8817
  ${renderChangesSinceReview(host, item)}
8690
8818
  <h4 class="doorstop-section-title">Parent links</h4>
8691
8819
  ${renderLinksOut(host, item, result.index)}
@@ -8696,9 +8824,7 @@ function renderDetail(host, result) {
8696
8824
  ${Object.entries(item.attributes).length === 0 ? A : b2`
8697
8825
  <h4 class="doorstop-section-title">Extended attributes</h4>
8698
8826
  <dl class="doorstop-attributes">
8699
- ${Object.entries(item.attributes).map(
8700
- ([key, value]) => b2`<div class="doorstop-attribute"><dt>${key}</dt><dd><code>${jsonishText(value)}</code></dd></div>`
8701
- )}
8827
+ ${Object.entries(item.attributes).map(([key, value]) => renderAttribute(host, key, value))}
8702
8828
  </dl>
8703
8829
  `}
8704
8830
  <h4 class="doorstop-section-title">Findings</h4>
@@ -8711,6 +8837,31 @@ function renderDetail(host, result) {
8711
8837
  </section>
8712
8838
  `;
8713
8839
  }
8840
+ function renderItemText(host, item) {
8841
+ if (item.text === "") {
8842
+ return b2`<p class="doorstop-text doorstop-text-empty">empty</p>`;
8843
+ }
8844
+ const renderer = host.markdownRenderer;
8845
+ const machineId = host.context?.machine.id;
8846
+ if (renderer === void 0 || machineId === void 0) {
8847
+ return b2`<p class="doorstop-text">${item.text}</p>`;
8848
+ }
8849
+ return b2`<div class="doorstop-text doorstop-text-markdown">${renderer.render({
8850
+ machineId,
8851
+ text: item.text,
8852
+ allowManualPreview: true
8853
+ })}</div>`;
8854
+ }
8855
+ function renderAttribute(host, key, value) {
8856
+ if (!isMarkdownAttribute(key, value)) {
8857
+ return b2`<div class="doorstop-attribute"><dt>${key}</dt><dd><code>${jsonishText(value)}</code></dd></div>`;
8858
+ }
8859
+ const text = value.trim();
8860
+ const renderer = host.markdownRenderer;
8861
+ const machineId = host.context?.machine.id;
8862
+ const body = text === "" ? b2`<p class="doorstop-text doorstop-text-empty">empty</p>` : renderer === void 0 || machineId === void 0 ? text : b2`<div class="doorstop-text-markdown">${renderer.render({ machineId, text, allowManualPreview: true })}</div>`;
8863
+ return b2`<div class="doorstop-attribute doorstop-attribute-markdown"><dt>${key}</dt><dd>${body}</dd></div>`;
8864
+ }
8714
8865
  function renderFlags(host, item) {
8715
8866
  return b2`
8716
8867
  <div class="doorstop-flags" aria-label="Item flags">
@@ -9502,14 +9653,15 @@ function defineDoorstopPanelBodyElement() {
9502
9653
  `;
9503
9654
  }
9504
9655
  /** section-API: the baseline fetch and the review→commit pipeline live
9505
- * server-side, so this gates the sections that need them. The v4 peer
9506
- * is the exact revision-paired path to this package's own active server
9507
- * entry; v4 also removed the provider `capabilities.request` field, so
9508
- * `peer.request` presence is the paired-backend gate (a legal
9509
- * openChannel-only peer is NOT paired for these request-shaped
9510
- * sections). The opendoor provider ownership check is kept for the
9511
- * ownership-based information display (behavior-preserving Phase A;
9512
- * Phase C revisits it). */
9656
+ * server-side, so this gates the sections that need them. The peer
9657
+ * (`context.peer.request`) is the exact revision-paired path to this
9658
+ * package's own active server entry — unchanged by the v5 migration
9659
+ * (the v5 scoped `backend` is not consulted here), so `peer.request`
9660
+ * presence stays the paired-backend gate (a legal openChannel-only
9661
+ * peer is NOT paired for these request-shaped sections). The opendoor
9662
+ * provider ownership check is kept for the ownership-based
9663
+ * information display (behavior-preserving Phase A; Phase C revisits
9664
+ * it). */
9513
9665
  backendActive() {
9514
9666
  const context = this.context;
9515
9667
  return context?.peer?.request !== void 0 && context.workspace.provider?.pluginId === OPENDOOR_PLUGIN_ID;
@@ -9667,6 +9819,9 @@ function defineDoorstopPanelBodyElement() {
9667
9819
  __decorateClass([
9668
9820
  n6({ attribute: false })
9669
9821
  ], DoorstopPanelBodyElement.prototype, "context", 2);
9822
+ __decorateClass([
9823
+ n6({ attribute: false })
9824
+ ], DoorstopPanelBodyElement.prototype, "markdownRenderer", 2);
9670
9825
  __decorateClass([
9671
9826
  n6({ attribute: false })
9672
9827
  ], DoorstopPanelBodyElement.prototype, "result", 2);
@@ -9742,14 +9897,14 @@ function defineCustomElementOnce(tag, define) {
9742
9897
  // src/browser/doorstop-contributions.ts
9743
9898
  var DOORSTOP_PANEL_LOCAL_ID2 = "workspace.doorstop";
9744
9899
  var DOORSTOP_LABEL_STATE_LIMIT = 8;
9745
- function createOpendoorBrowserContributions(runtimePluginId, _html, _svg) {
9900
+ function createOpendoorBrowserContributions(runtimePluginId, _html, _svg, markdown) {
9746
9901
  const panelId = `${runtimePluginId}:${DOORSTOP_PANEL_LOCAL_ID2}`;
9747
9902
  const registry = new DoorstopWorkspaceRegistry();
9748
9903
  const labelCache = new DoorstopLabelCache();
9749
9904
  defineDoorstopPanelElements();
9750
9905
  return {
9751
9906
  actions: createDoorstopActions(panelId),
9752
- workspacePanels: [createDoorstopPanel(registry)],
9907
+ workspacePanels: [createDoorstopPanel(registry, markdown)],
9753
9908
  workspaceLabels: [createDoorstopLabel(labelCache)],
9754
9909
  sessionLabels: [createDoorstopSessionLabel(runtimePluginId)]
9755
9910
  };
@@ -9789,7 +9944,7 @@ function createDoorstopActions(panelId) {
9789
9944
  }
9790
9945
  ];
9791
9946
  }
9792
- function createDoorstopPanel(registry) {
9947
+ function createDoorstopPanel(registry, markdown) {
9793
9948
  return {
9794
9949
  id: DOORSTOP_PANEL_LOCAL_ID2,
9795
9950
  title: "Requirements",
@@ -9817,8 +9972,8 @@ function createDoorstopPanel(registry) {
9817
9972
  // terminal fallback keeps every action working; the discovery-based
9818
9973
  // empty state carries the "no documents" story (feature spec §7.1).
9819
9974
  visible: () => true,
9820
- // Plan Phase C step C1 — typed invalidation (browser API v4): declare the
9821
- // fixed workspace resources whose automatic invalidations this panel
9975
+ // Plan Phase C step C1 — typed invalidation (browser API v4, unchanged
9976
+ // on v5): declare the fixed workspace resources whose automatic invalidations this panel
9822
9977
  // receives. With `workspace.files` declared, the host calls onInvalidate
9823
9978
  // not only for the manual Refresh action (refreshWorkspacePanels) but
9824
9979
  // also when workspace files change (`reason: "mutation"` — e.g. the
@@ -9839,10 +9994,10 @@ function createDoorstopPanel(registry) {
9839
9994
  // (never loads, never renders); unknown paths and pre-first-load panels
9840
9995
  // return undefined (the host asks the next panel / serves Files).
9841
9996
  fileOpenQuery: (context, path) => registry.fileOpenQuery(context, path),
9842
- render: (context) => renderDoorstopPanel(registry, context)
9997
+ render: (context) => renderDoorstopPanel(registry, context, markdown)
9843
9998
  };
9844
9999
  }
9845
- function renderDoorstopPanel(registry, context) {
10000
+ function renderDoorstopPanel(registry, context, markdown) {
9846
10001
  const controller = registry.for(context);
9847
10002
  if (context.navigation !== void 0) {
9848
10003
  controller.applyNavigationQuery(readPanelNavigationQuery(context.navigation));
@@ -9854,6 +10009,7 @@ function renderDoorstopPanel(registry, context) {
9854
10009
  <pi-web-opendoor-panel-body
9855
10010
  .controller=${controller}
9856
10011
  .context=${context}
10012
+ .markdownRenderer=${markdown.renderer}
9857
10013
  .result=${controller.result}
9858
10014
  .loading=${controller.loading}
9859
10015
  .stale=${controller.stale}
@@ -9948,13 +10104,36 @@ function doorstopLabelKey(context) {
9948
10104
  return JSON.stringify([context.machine.id, context.workspace.projectId, context.workspace.id]);
9949
10105
  }
9950
10106
 
10107
+ // src/browser/doorstop-markdown.ts
10108
+ var DOORSTOP_MARKDOWN_CAPABILITY = {
10109
+ pluginId: "pi-web",
10110
+ id: "markdown-rendering",
10111
+ version: 1,
10112
+ parse(value) {
10113
+ if (typeof value !== "object" || value === null || !("render" in value) || typeof value.render !== "function") {
10114
+ throw new Error("Markdown rendering capability v1 is unavailable");
10115
+ }
10116
+ return value;
10117
+ }
10118
+ };
10119
+ function createDoorstopMarkdownHolder() {
10120
+ return { renderer: void 0 };
10121
+ }
10122
+
9951
10123
  // src/browser/pi-web-plugin.ts
9952
10124
  var plugin = {
9953
- apiVersion: 4,
10125
+ apiVersion: 5,
9954
10126
  name: "Opendoor",
9955
- activate: ({ runtimePluginId, html, svg }) => ({
9956
- contributions: createOpendoorBrowserContributions(runtimePluginId, html, svg)
9957
- })
10127
+ requires: [DOORSTOP_MARKDOWN_CAPABILITY],
10128
+ activate: ({ runtimePluginId, html, svg }) => {
10129
+ const markdown = createDoorstopMarkdownHolder();
10130
+ return {
10131
+ contributions: createOpendoorBrowserContributions(runtimePluginId, html, svg, markdown),
10132
+ start: ({ capabilities }) => {
10133
+ markdown.renderer = capabilities.resolve(DOORSTOP_MARKDOWN_CAPABILITY);
10134
+ }
10135
+ };
10136
+ }
9958
10137
  };
9959
10138
  var pi_web_plugin_default = plugin;
9960
10139
  export {
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opendoor-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Opendoor — a pi-web browser plugin that surfaces Doorstop requirements stored in the workspace: the document tree, per-item state (reviewed/unreviewed changes, suspect links), validation findings, and one-click doorstop CLI actions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,6 @@
26
26
  ]
27
27
  },
28
28
  "peerDependencies": {
29
- "@jmfederico/pi-web": ">=1.202609.1"
29
+ "@jmfederico/pi-web": ">=1.202611.1"
30
30
  }
31
31
  }
@@ -993,16 +993,20 @@ async function probeContainsDoorstopMarker(root, signal) {
993
993
  return walk(root, 0);
994
994
  }
995
995
  var plugin = {
996
- apiVersion: 3,
996
+ apiVersion: 5,
997
997
  name: "Opendoor",
998
998
  // Permanently no `requires`/`start()` — see the module header: the review
999
999
  // dispatch moved to the Pi extension, so this provider-contributing entry
1000
- // never approaches the host's provider-cycle rule.
1000
+ // never approaches the host's provider-cycle rule (and never declares the
1001
+ // host catalog capability, which is prohibited for provider-contributing
1002
+ // entries).
1001
1003
  activate(context) {
1002
1004
  return {
1003
1005
  workspaceProvider: createDoorstopWorkspaceProvider(context),
1004
- // v3: the six-operation dispatch lives at the activation level, not on
1005
- // the provider (`WorkspaceProvider.request` was removed).
1006
+ // v3 onward (unchanged on v5): the six-operation dispatch lives at the
1007
+ // activation level, not on the provider (`WorkspaceProvider.request`
1008
+ // was removed). The v5 context carries a `scope` union; the dispatch
1009
+ // accepts only the workspace scope and rejects the rest.
1006
1010
  peer: {
1007
1011
  request: (request) => dispatchDoorstopOperation(context, request)
1008
1012
  }
@@ -1034,25 +1038,43 @@ function createDoorstopWorkspaceProvider(context) {
1034
1038
  });
1035
1039
  }
1036
1040
  function dispatchDoorstopOperation(context, request) {
1037
- switch (request.operation) {
1041
+ const { scope, operation, input, signal } = request;
1042
+ if (scope.kind !== "workspace") {
1043
+ return Promise.reject(unsupportedPeerScopeError(operation, scope.kind));
1044
+ }
1045
+ const handlerRequest = {
1046
+ project: scope.project,
1047
+ workspace: scope.workspace,
1048
+ operation,
1049
+ input,
1050
+ signal
1051
+ };
1052
+ switch (operation) {
1038
1053
  case DOORSTOP_RUN_OPERATION:
1039
- return requestDoorstopBackend(context, request);
1054
+ return requestDoorstopBackend(context, handlerRequest);
1040
1055
  case DOORSTOP_BASELINE_OPERATION:
1041
- return requestDoorstopBaseline(context, request);
1056
+ return requestDoorstopBaseline(context, handlerRequest);
1042
1057
  case DOORSTOP_GIT_STATUS_OPERATION:
1043
- return requestDoorstopGitStatus(context, request);
1058
+ return requestDoorstopGitStatus(context, handlerRequest);
1044
1059
  case DOORSTOP_GIT_STAGE_OPERATION:
1045
- return requestDoorstopGitStage(context, request);
1060
+ return requestDoorstopGitStage(context, handlerRequest);
1046
1061
  case DOORSTOP_GIT_UNSTAGE_OPERATION:
1047
- return requestDoorstopGitUnstage(context, request);
1062
+ return requestDoorstopGitUnstage(context, handlerRequest);
1048
1063
  case DOORSTOP_GIT_COMMIT_OPERATION:
1049
- return requestDoorstopGitCommit(context, request);
1064
+ return requestDoorstopGitCommit(context, handlerRequest);
1050
1065
  default:
1051
- return Promise.reject(unsupportedBackendOperationError(request.operation));
1066
+ return Promise.reject(unsupportedBackendOperationError(operation));
1052
1067
  }
1053
1068
  }
1069
+ var peerScopeKinds = /* @__PURE__ */ new Set(["machine", "project", "workspace"]);
1070
+ function unsupportedPeerScopeError(operation, scopeKind) {
1071
+ const op = /^[A-Za-z0-9.-]+$/.test(operation) ? operation : "unknown";
1072
+ const kind = peerScopeKinds.has(scopeKind) ? scopeKind : "unknown";
1073
+ return new Error(`opendoor: ${op} peer requests require workspace scope (got ${kind})`);
1074
+ }
1054
1075
  export {
1055
1076
  createDoorstopWorkspaceProvider,
1056
1077
  server_plugin_default as default,
1057
- dispatchDoorstopOperation
1078
+ dispatchDoorstopOperation,
1079
+ unsupportedPeerScopeError
1058
1080
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opendoor-plugin",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Opendoor — a pi-web browser plugin that surfaces Doorstop requirements stored in the workspace: the document tree, per-item state (reviewed/unreviewed changes, suspect links), validation findings, and one-click doorstop CLI actions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "peerDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "*",
49
- "@jmfederico/pi-web": ">=1.202609.1"
49
+ "@jmfederico/pi-web": ">=1.202611.1"
50
50
  },
51
51
  "devDependencies": {
52
- "@jmfederico/pi-web": "1.202610.8",
52
+ "@jmfederico/pi-web": "^1.202611.1",
53
53
  "@types/js-yaml": "^4.0.9",
54
54
  "@types/node": "^24.19.2",
55
55
  "@vitest/coverage-v8": "^4.1.12",