Keygrip work orders in a pane: /workorders lists your project's deploy queue; pick one to read its fields, edits, schema and verify steps, see the page before…

A Claude Code mod (a plugin of function hooks, see docs/claude-code-mods.md) that draws the client deploy loop in a pane instead of a wall of markdown in a tool row. Tracks issue #1516.
/workorders # the queue: every work order the connected project has
/workorders 502 # straight to one order by optimization id
Queue. One row per work order from kg_workorders: id, version, mode, state, keyword. Blocked rows are dimmed and refuse to open (the server refuses them too). r refresh, s show/hide superseded versions.
Reader (pick a row). Fetches kg_fetch_workorder once and splits the document on its fixed headings. Tabs on 1–7, b back to the queue:
| Tab | Shows |
|---|---|
| 1 Overview | mode, prepared date, keyword target, "what changed", hero image |
| 2 Fields | each deployable value in a code block, a length meter against its SEO limit (red when over), unchanged when it matches the live page, a copy button |
| 3 Edits | each body edit boxed: why, where, the Find text and the Replace/Insert HTML, a copy button; a net-new order shows the full body |
| 4 Schema | the JSON-LD with the <script> wrapper stripped, a copy button |
| 5 Verify | the post-publish steps as toggles, with a checked count |
| 6 Notes | context changed since this version, recommended-not-applied, checked-and-left-alone |
| 7 Preview | the page rendered on the server by kg_workorder_preview (the first slice starts rendering the moment an order is opened, so the tab usually draws at once): p toggles live (as served now) and proposed (the work order's edits applied in the browser, removals marked with a dashed red rule; the web page the link opens marks each change in the margin and shows the work order's instruction for it on click); n/u move through 1600 px slices, r re-renders. Terminal: pixels (kitty/Ghostty). Desktop/VS Code: a half-scale JPEG inside an Svg, plus a link to the same preview as a page on app.keygrip.ai. A net-new page (no live URL yet) is shown on a template: a live page of the same site — the server picks the inventory page nearest the new URL's path, and a Template URL field under the picture swaps in any other live page on the site. live is the template as served; proposed is the template with its article thrown out and the work order's body, H1 and hero in its place, so the new page appears in the site's own layout |
Under the tabs, an Approve for publishing button (a) calls kg_workorder_approve; on an approved order it becomes Withdraw approval. The queue's state column reads approved for a stamped row (ready is a row nobody has signed off; deployed outranks both). The stamp is per version — an edit makes a new, unapproved version — and it is what a publishing step will read (#1518). Editing is not done in the pane: the reader says so and points at the loop — tell Claude what to change, it calls kg_fetch_body → kg_save_version on the client server (configure that server in the session, see below), and the new version appears in the queue.
At the bottom, a Live URL field whose submit calls kg_report_deployed with target: manual. It refuses an empty URL. Nothing else in the mod writes.
The client path is the public GitHub marketplace KeygripGit/claude-mods, published from this folder by bin/publish-mods (bump version first). The desktop app's plugin browser lists git-hosted marketplaces and showed nothing for a URL-hosted one (measured 2026-10-03), so the guide — /mods/ on the app, Desktop and Terminal sections — uses the repo for both:
claude plugin marketplace add KeygripGit/claude-mods
claude plugin install workorder-pane@keygrip
The app also serves this folder as a marketplace (web/mods.py: GET /mods/marketplace.json with archive sources, GET /mods/<name>-<version>.zip built on demand, deterministic, sha256 in the manifest) under the name keygrip-app — the no-git fallback for a terminal. The two names differ on purpose: the engine refuses to add a marketplace whose name is already declared with another source, with a plain "Failed to add marketplace" in the desktop app.
The pane talks to https://app.keygrip.ai/client/mcp itself — one JSON-RPC tools/call POST per tool over $.http.fetch, the project's kg_client_ key as the Bearer header — and asks for that key once, in the pane, keeping it in the plugin's own $.store (k on the queue changes it). It deliberately does not declare the server in the manifest: a tool call made from a button is refused by the Auto-mode permission classifier ("the request that produced this action did not ask for one"), measured on the desktop with a plugin-bundled server too, which would make every client paste an allow list; and a manifest server needs the key through /plugin configure. A direct fetch needs neither — measured under --permission-mode auto on 2026-10-03. The endpoint is stateless and answers JSON, so there is no handshake. Bump version in plugin.json with every change you want installs to pick up: an install pins to the version it saw, claude plugin update only moves when the number does, and the zip URL carries the version.
⚠️ A plugin loaded through --plugin-dir / CLAUDE_CODE_PLUGIN_DIRS overrides a marketplace install of the same name (and claude plugin install --config then reports "failed to load after install"); on a developer machine use one or the other.
CLI, one session: claude --plugin-dir app/mods/workorder-pane
CLI or desktop, always: name the folder in CLAUDE_CODE_PLUGIN_DIRS, in the process environment or the env block of ~/.claude/settings.json (the user file, never a project's).
Claude Desktop on Windows runs its own engine with its own %USERPROFILE%\.claude, so it needs a Windows path. The copy that works today:
// C:\Users\<you>\.claude\settings.json
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\<you>\\.claude\\mods\\workorder-pane" },
"permissions": { "allow": [
"mcp__keygrip-workorder__kg_workorders",
"mcp__keygrip-workorder__kg_fetch_workorder",
"mcp__keygrip-workorder__kg_articles",
"mcp__keygrip-workorder__kg_article_status",
"mcp__keygrip-workorder__kg_workorder_preview",
"mcp__keygrip-workorder__kg_fetch_body"
] }
}
(For a marketplace install the server is plugin_workorder-pane_keygrip-workorder in those rules.) The allow list matters in Auto permission mode only for the developer path above, where the tools come from a hand-configured server: the pane's MCP calls come from a button, not a prompt, so the auto-mode classifier has nothing to match them against and refuses them ("the request that produced this action did not ask for one"). Allowed tools skip the classifier. The write tools (kg_report_deployed, kg_workorder_approve, kg_save_version, kg_restore_version) are deliberately not allowed; switch the session off Auto, or approve the prompt, when you report a deploy or approve from the pane, or ask Claude to edit a page.
Sync the Windows copy from WSL after a change:
rsync -a --delete --exclude .cache --exclude '.claude-plugin/types' --exclude tsconfig.json \
app/mods/workorder-pane/ /mnt/c/Users/<you>/.claude/mods/workorder-pane/
keygrip-workorder → https://app.keygrip.ai/client/mcp with a project-scoped kg_client_ key (claude mcp add --transport http keygrip-workorder <url> -H "Authorization: Bearer kg_client_…"). The server name is what $.mcp.call is given; the desktop app needs the same server in its .claude.json. Minting the key: project settings in the app (issue #1071).
Nothing runs on the laptop. kg_workorder_preview(optimization_id, mode, slice, format, scale) on the client MCP server (and the same tool on the internal /mcp) calls GET /api/v1/optimizations/{id}/preview, which content/page_preview.py serves: the app image's own Chromium loads the run's page_url, and in proposed mode runs the work order's BodyEdits against the live DOM by the same rules a person follows in the CMS editor (find by meaning, a section runs to the next heading of the same level, edits apply in order), then screenshots the page in 1280×1600 slices. A net-new page has no live URL, so the browser loads a template instead — template_url (a live page on the same site; anything off the site is refused) or the inventory page nearest the new URL's path — and in proposed mode finds the template's content column, removes the article from its first text block onward (the H1, byline and hero above it stay), appends the work order's body there, and swaps in the new H1, hero and title. The metadata then carries template_url and page_url beside url. One call returns one slice as an image block plus a JSON block (slices, height, applied per edit, view_url). Renders are cached ten minutes per (run, version, mode) in the web process. The web twin is …/studio/runs/<id>/preview/?mode=proposed, which stacks every slice and lists each edit's outcome; that is the view_url the desktop pane links to.
Pixels in the terminal still need kitty ≥ 0.28 or Ghostty (see docs/claude-code-mods.md).
claude plugin validate app/mods/workorder-pane # manifest + what the module hooks/calls
claude plugin test app/mods/workorder-pane # hooks/*.test.tsx against the engine
The parser (hooks/parse.ts) keys on the document's headings; hooks/fixture.ts is a compact work order in that shape, modelled on optimization 502. Rules the validator enforces that cost a round each: $ may only be passed to a top-level function declaration; atom() references need literal plugin strings; a Button takes one string child or label; a fetch must start from a command or button handler, never from the render hook (a write from the draw's async chain is dropped silently).
hooks/register.tsx 660 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { Tab } from '../types'
5import { parseWorkOrder, unwrap } from './parse'
6import type { QueueRow, WorkOrder, Field } from './parse'
7
8const PANE = 'workorders'
9// The pane talks to Keygrip's client MCP endpoint itself, over the engine's fetch, with the
10// project's kg_client_ key as a header. Not through an MCP server in the manifest: a tool call
11// made from a button is refused by the Auto-mode permission classifier ("the request that
12// produced this action did not ask for one"), which would make every client paste an allow
13// list, and the key would have to be set through /plugin configure. A direct fetch needs
14// neither — the key is pasted once into the pane and kept in the plugin's own store. The
15// endpoint is stateless and answers JSON, so one POST per tool call is the whole protocol.
16const ENDPOINT = 'https://app.keygrip.ai/client/mcp'
17const KEY_STORE = 'client_key'
18let clientKey: string | null | undefined // undefined: not read yet; null: none stored
19
20async function loadKey($: EngineInterface): Promise<string | null> {
21 if (clientKey === undefined) {
22 const v = await $.store.get(KEY_STORE)
23 clientKey = typeof v === 'string' && v ? v : null
24 }
25 return clientKey
26}
27
28async function saveKey($: EngineInterface, key: string) {
29 clientKey = key || null
30 if (key) await $.store.set(KEY_STORE, key)
31 else await $.store.delete(KEY_STORE)
32 // every cache was filled under the old key
33 queue = null; queueKey = null; orders.clear(); shots.clear()
34}
35
36type ToolResult = { isError: boolean; content: { type: string; text?: string; [k: string]: unknown }[] }
37let rpcId = 0
38async function callTool($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<ToolResult> {
39 const key = await loadKey($)
40 if (!key) return { isError: true, content: [{ type: 'text', text: 'no client key: paste one into the pane first' }] }
41 const r = await $.http.fetch(ENDPOINT, {
42 method: 'POST',
43 headers: { 'Authorization': `Bearer ${key}`, 'Content-Type': 'application/json', 'Accept': 'application/json' },
44 body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method: 'tools/call', params: { name: tool, arguments: args } }),
45 })
46 if (r.status === 401 || r.status === 403) return { isError: true, content: [{ type: 'text', text: `the key was refused (HTTP ${r.status}): paste a current one (k)` }] }
47 if (!r.ok) return { isError: true, content: [{ type: 'text', text: `HTTP ${r.status} from ${ENDPOINT}: ${r.text.slice(0, 200)}` }] }
48 let body: { result?: ToolResult; error?: { message?: string } }
49 try { body = JSON.parse(r.text) } catch { return { isError: true, content: [{ type: 'text', text: `not JSON: ${r.text.slice(0, 200)}` }] } }
50 if (body.error) return { isError: true, content: [{ type: 'text', text: body.error.message ?? 'rpc error' }] }
51 const res = body.result ?? { isError: true, content: [{ type: 'text', text: 'empty result' }] }
52 return { isError: Boolean(res.isError), content: res.content ?? [] }
53}
54
55const view = atom({ plugin: 'workorder-pane', key: 'view' } as const, 'queue' as 'queue' | 'order')
56const selected = atom({ plugin: 'workorder-pane', key: 'selected' } as const, null as number | null)
57const tab = atom({ plugin: 'workorder-pane', key: 'tab' } as const, 'overview' as Tab)
58const includeSuperseded = atom({ plugin: 'workorder-pane', key: 'includeSuperseded' } as const, false)
59const checked = atom({ plugin: 'workorder-pane', key: 'checked' } as const, [] as number[])
60const tick = atom({ plugin: 'workorder-pane', key: 'tick' } as const, 0)
61const slice = atom({ plugin: 'workorder-pane', key: 'slice' } as const, 0)
62const keyPrompt = atom({ plugin: 'workorder-pane', key: 'keyPrompt' } as const, false)
63const previewMode = atom({ plugin: 'workorder-pane', key: 'previewMode' } as const, 'proposed' as 'live' | 'proposed')
64
65// Fetched data lives here, not in $.state: a work order is tens of KB. A hot reload
66// empties it and the next draw refetches, keyed by the ids $.state still holds.
67type Loaded<T> = { ok: true; data: T } | { ok: false; error: string }
68let queue: Loaded<QueueRow[]> | null = null
69let queueKey: string | null = ''
70const orders = new Map<number, Loaded<WorkOrder>>()
71// One rendered slice from kg_workorder_preview, keyed by `${id}:${mode}:${slice}:${variant}`.
72type Shot = { data: string; mime: string; meta: PreviewMeta }
73type PreviewMeta = { mode: string; url: string; width: number; height: number; slice_height: number; slices: number
74 truncated: boolean; elapsed_ms: number; applied: { id: string; kind: string; status: string; note?: string }[]
75 applied_count: number; not_found: string[]; view_url?: string; slice: number
76 // A page that is not live yet renders on a template page of the same site: `url` is the
77 // template loaded, `template_url` names it and `page_url` is the work order's own.
78 page_url?: string; template_url?: string }
79const shots = new Map<string, Loaded<Shot>>()
80// The template a net-new order is previewed on, per order, once the reader typed one; unset
81// means the server picks the inventory page nearest the new URL's path.
82const templates = new Map<number, string>()
83const shotKey = (id: number, mode: string, at: number, variant: string) => `${id}:${mode}:${at}:${variant}`
84const inflight = new Set<string>()
85
86const TABS: { id: Tab; label: string; hotkey: string }[] = [
87 { id: 'overview', label: 'Overview', hotkey: '1' },
88 { id: 'fields', label: 'Fields', hotkey: '2' },
89 { id: 'edits', label: 'Edits', hotkey: '3' },
90 { id: 'schema', label: 'Schema', hotkey: '4' },
91 { id: 'verify', label: 'Verify', hotkey: '5' },
92 { id: 'notes', label: 'Notes', hotkey: '6' },
93 { id: 'preview', label: 'Preview', hotkey: '7' },
94]
95
96// One word per row: blocked outranks everything (the server refuses the document), a
97// reported deploy outranks the approval it needed, and `approved` is the stamp a publishing
98// step reads (#1518) — a ready row is one nobody has signed off yet.
99function stateOf(r: QueueRow): string {
100 return r.blocked ? `BLOCKED(${r.blocker_count})` : r.deployed_at ? 'deployed' : r.approved_at ? 'approved' : 'ready'
101}
102
103// kg_workorder_approve, then the queue refetches so the row's state follows the server.
104async function setApproval($: EngineInterface, id: number, approved: boolean) {
105 const r = await callTool($, 'kg_workorder_approve', { optimization_id: id, approved })
106 if (r.isError) { $.ui.toast(`${approved ? 'Approve' : 'Withdraw'} failed: ${unwrap(r.content).slice(0, 120)}`); return }
107 $.ui.toast(approved ? `#${id} approved for publishing` : `#${id} approval withdrawn`)
108 queue = null
109 await update($, tick, n => (n ?? 0) + 1)
110 const superseded = await read($, includeSuperseded)
111 void loadQueue($, superseded)
112}
113
114function meter(n: number, limit: number, width = 20): string {
115 const filled = Math.min(width, Math.round((n / limit) * width))
116 return `${'#'.repeat(filled)}${'-'.repeat(width - filled)} ${n}/${limit}`
117}
118
119async function loadQueue($: EngineInterface, superseded: boolean) {
120 const key = `queue:${superseded}`
121 if (inflight.has(key)) return
122 inflight.add(key)
123 await update($, tick, n => (n ?? 0) + 1)
124 try {
125 const r = await callTool($, 'kg_workorders', { include_superseded: superseded })
126 if (r.isError) queue = { ok: false, error: unwrap(r.content) }
127 else {
128 const parsed = JSON.parse(unwrap(r.content)) as { items?: QueueRow[] }
129 queue = { ok: true, data: parsed.items ?? [] }
130 }
131 queueKey = key
132 } catch (err) {
133 queue = { ok: false, error: String(err) }
134 queueKey = key
135 } finally {
136 inflight.delete(key)
137 await update($, tick, n => (n ?? 0) + 1)
138 }
139}
140
141async function loadOrder($: EngineInterface, id: number) {
142 const key = `order:${id}`
143 if (inflight.has(key)) return
144 inflight.add(key)
145 await update($, tick, n => (n ?? 0) + 1)
146 try {
147 const r = await callTool($, 'kg_fetch_workorder', { optimization_id: id })
148 const text = unwrap(r.content)
149 orders.set(id, r.isError ? { ok: false, error: text } : { ok: true, data: parseWorkOrder(text) })
150 } catch (err) {
151 orders.set(id, { ok: false, error: String(err) })
152 } finally {
153 inflight.delete(key)
154 await update($, tick, n => (n ?? 0) + 1)
155 }
156}
157
158
159//: Under this many raw bytes the engine leaves a PNG alone (measured: a 200 KB slice arrived
160//: as PNG, a 1.1 MB one as JPEG).
161const TERMINAL_PNG_BUDGET = 400_000
162// base64 of the PNG signature 0x89 'P' 'N' 'G' \r \n 0x1a \n
163const isPng = (b64: string) => b64.startsWith('iVBORw0KGgo')
164
165type ImageBlock = { type: string; data?: string; mimeType?: string; source?: { type?: string; data?: string; media_type?: string } }
166function pictureOf(blocks: readonly { type: string }[]): { data: string; mime: string } | null {
167 for (const b of blocks as ImageBlock[]) {
168 if (b.type !== 'image') continue
169 if (b.source?.data) return { data: b.source.data, mime: b.source.media_type ?? 'image/png' }
170 if (b.data) return { data: b.data, mime: b.mimeType ?? 'image/png' }
171 }
172 return null
173}
174
175// Layer 3: the server renders the page (live, or with the edits applied in the browser)
176// and hands one slice back as an image block plus its metadata — kg_workorder_preview.
177// `variant` is `full` (PNG, for the terminal's Image) or `thumb` (half-scale JPEG, for the
178// desktop's Svg budget).
179async function loadShot($: EngineInterface, id: number, mode: string, at: number, variant: 'full' | 'thumb', refresh = false) {
180 const key = shotKey(id, mode, at, variant)
181 if (inflight.has(key)) return
182 inflight.add(key)
183 await update($, tick, n => (n ?? 0) + 1)
184 try {
185 const args: Record<string, unknown> = { optimization_id: id, mode, slice: at, refresh }
186 const template = templates.get(id)
187 if (template) args.template_url = template
188 // The engine re-encodes an image block over its own byte limit to JPEG before a plugin
189 // sees it, and the terminal's Image takes PNG only: ask the server to keep the PNG under
190 // that limit (palette, then scale). The desktop's Svg takes any picture under ~128 KB.
191 if (variant === 'thumb') Object.assign(args, { format: 'jpeg', scale: 0.5 })
192 else Object.assign(args, { format: 'png', max_bytes: TERMINAL_PNG_BUDGET })
193 const r = await callTool($, 'kg_workorder_preview', args)
194 if (r.isError) { shots.set(key, { ok: false, error: unwrap(r.content).slice(0, 400) }); return }
195 // The engine hands a plugin the result as the session stores it, which is the API shape
196 // ({ type: 'image', source: { type: 'base64', media_type, data } }); a raw MCP block
197 // ({ data, mimeType }) is accepted too, so a test or another host reads the same.
198 const picture = pictureOf(r.content)
199 const metaText = r.content.find(b => b.type === 'text' && (b.text ?? '').trim().startsWith('{'))?.text ?? '{}'
200 if (!picture) {
201 const shapes = r.content.map(b => `${b.type}{${Object.keys(b).filter(k => k !== 'type').join(',')}}`).join(' ')
202 shots.set(key, { ok: false, error: `the tool returned no image block (blocks: ${shapes || 'none'})` })
203 return
204 }
205 if (variant === 'full' && !isPng(picture.data)) {
206 const kb = Math.round((picture.data.length * 3) / 4 / 1024)
207 shots.set(key, { ok: false, error: `the engine handed the pane a ${picture.mime} of ${kb} KB, not a PNG; the terminal draws PNG only (server max_bytes=${TERMINAL_PNG_BUDGET})` })
208 return
209 }
210 shots.set(key, { ok: true, data: { ...picture, meta: JSON.parse(metaText) as PreviewMeta } })
211 } catch (err) {
212 shots.set(key, { ok: false, error: String(err) })
213 } finally {
214 inflight.delete(key)
215 await update($, tick, n => (n ?? 0) + 1)
216 }
217}
218
219// Opening an order starts the server render of its first preview slice right away, so the
220// Preview tab draws from the cache instead of making the reader wait the few seconds a
221// Chromium render takes. Only slice 0 of the current mode: one render warms the server's
222// page cache for every other slice of that mode, and the other mode is a `p` away.
223async function prefetchPreview($: EngineInterface, id: number, surface: RenderSurface) {
224 const variant = surface === 'terminal' ? 'full' : 'thumb'
225 const mode = await read($, previewMode)
226 if (!shots.has(shotKey(id, mode, 0, variant))) void loadShot($, id, mode, 0, variant)
227}
228
229export const register: Register = on => {
230 on('session.start', async ($, e, next) => {
231 await $.command.register({
232 name: 'workorders',
233 description: 'Keygrip work orders in a pane: the queue, or one order by id (/workorders 502)',
234 })
235 return next(e)
236 })
237
238 on('command.run', { command: 'workorders' }, async ($, e) => {
239 const id = Number.parseInt(e.args.trim(), 10)
240 if (Number.isFinite(id)) {
241 await update($, selected, () => id)
242 await update($, view, () => 'order')
243 await update($, tab, () => 'overview')
244 } else {
245 await update($, view, () => 'queue')
246 }
247 const superseded = await read($, includeSuperseded)
248 if (await loadKey($)) {
249 if (queue === null || queueKey !== `queue:${superseded}`) void loadQueue($, superseded)
250 if (Number.isFinite(id) && !orders.has(id)) void loadOrder($, id)
251 if (Number.isFinite(id)) void prefetchPreview($, id, 'terminal')
252 }
253 const opened = await $.ui.open({ id: PANE, title: 'Work orders' })
254 return { text: opened.isPlaced ? 'Work orders pane opened.' : `Pane not placed: ${opened.reason}` }
255 })
256
257 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258 if (e.surface === 'mobile') {
259 const { Text } = $.ui.resolve(e)
260 return <Text dimColor>The work-order pane needs a surface with Input (terminal, desktop or VS Code).</Text>
261 }
262 const { Box, Text, Button, Input, Code, Markdown, Link } = $.ui.resolve(e)
263 const [which, id, current, superseded, done, at, pmode] = await Promise.all([
264 read($, view), read($, selected), read($, tab), read($, includeSuperseded), read($, checked), read($, slice), read($, previewMode),
265 ])
266 await read($, tick)
267 const width = Math.max(20, e.props.bodyColumns - 2)
268 const surface: RenderSurface = e.surface
269
270 const copy = (text: string, what: string) => async () => {
271 const r = await $.ui.copy({ text, surface })
272 $.ui.toast(r.isCopied ? `Copied ${what}` : `Could not copy ${what}: ${r.reason}`)
273 }
274
275 // ---- key ---------------------------------------------------------------
276 // Pasted once per machine and kept in the plugin's store; `k` on the queue changes it.
277 const haveKey = await loadKey($)
278 const askKey = await read($, keyPrompt)
279 if (!haveKey || askKey) {
280 const submit = (value: string) => {
281 const k = value.trim()
282 if (!k.startsWith('kg_client_')) { $.ui.toast('A Keygrip client key starts with kg_client_'); return }
283 void (async () => {
284 await saveKey($, k)
285 await update($, keyPrompt, () => false)
286 await update($, view, () => 'queue')
287 void loadQueue($, superseded)
288 })()
289 }
290 return (
291 <Box flexDirection="column" gap={1}>
292 <Text bold>{haveKey ? 'Change the Keygrip client key' : 'Connect this pane to your Keygrip project'}</Text>
293 <Text dimColor wrap="wrap">Paste the project's client key (kg_client_…). It comes from the project's Settings → Client MCP access in app.keygrip.ai, or from your Keygrip contact. It is kept on this machine only.</Text>
294 <Input key="client-key" label="Client key" placeholder="kg_client_…" submitLabel="Connect" autoFocus onSubmit={submit} />
295 {haveKey && <Button key="keep-key" hotkey="b" onPress={() => update($, keyPrompt, () => false)}>Keep the current key</Button>}
296 </Box>
297 )
298 }
299
300 // ---- queue -------------------------------------------------------------
301 if (which === 'queue' || id === null) {
302 const key = `queue:${superseded}`
303 if (queue === null || queueKey !== key) {
304 if (inflight.has(key)) return <Text dimColor>Loading work orders…</Text>
305 return (
306 <Box flexDirection="column" gap={1}>
307 <Text dimColor>Work orders not loaded yet.</Text>
308 <Button key="load-queue" hotkey="l" onPress={() => { void loadQueue($, superseded) }}>Load</Button>
309 </Box>
310 )
311 }
312 if (!queue.ok) {
313 return (
314 <Box flexDirection="column" gap={1}>
315 <Text color="red">Could not list work orders: {queue.error}</Text>
316 <Button hotkey="r" onPress={() => { queue = null; void loadQueue($, superseded) }}>Retry</Button>
317 </Box>
318 )
319 }
320 const rows = queue.data
321 const ready = rows.filter(r => !r.blocked).length
322 const open = (row: QueueRow) => () => {
323 if (row.blocked) { $.ui.toast(`#${row.id} is blocked (${row.blocker_count}); Keygrip is resolving it`); return }
324 if (!orders.has(row.id)) void loadOrder($, row.id)
325 void prefetchPreview($, row.id, surface)
326 void (async () => {
327 await update($, selected, () => row.id)
328 await update($, tab, () => 'overview')
329 await update($, checked, () => [])
330 await update($, slice, () => 0)
331 await update($, view, () => 'order')
332 })()
333 }
334 return (
335 <Box flexDirection="column" gap={1}>
336 <Text bold>{rows.length} work orders · {ready} ready · {rows.length - ready} blocked{superseded ? ' · superseded included' : ''}</Text>
337 {surface === 'terminal' ? (
338 // Monospace: one padded line per row is the table.
339 <Box flexDirection="column">
340 <Text dimColor>{'id ver mode state keyword'}</Text>
341 {rows.map(r => {
342 const flag = stateOf(r)
343 const label = `#${String(r.id).padEnd(4)} v${String(r.version).padEnd(3)} ${r.mode.padEnd(9)} ${flag.padEnd(11)} ${r.keyword}`
344 return (
345 <Button
346 key={`row-${r.id}`}
347 plain
348 dimColor={r.blocked}
349 label={label.length > width ? label.slice(0, width - 1) + '…' : label}
350 onPress={open(r)}
351 />
352 )
353 })}
354 </Box>
355 ) : (
356 // Proportional type: padding does not line up, so the columns are Boxes of a
357 // fixed share of the width; the keyword is the button that opens the order.
358 <Box flexDirection="column">
359 <Box flexDirection="row" gap={1}>
360 <Box width="9%"><Text dimColor bold>id</Text></Box>
361 <Box width="7%"><Text dimColor bold>ver</Text></Box>
362 <Box width="13%"><Text dimColor bold>mode</Text></Box>
363 <Box width="16%"><Text dimColor bold>state</Text></Box>
364 <Box flexGrow={1}><Text dimColor bold>keyword</Text></Box>
365 </Box>
366 {rows.map(r => {
367 const flag = stateOf(r)
368 return (
369 <Box key={`qrow-${r.id}`} flexDirection="row" gap={1} alignItems="center">
370 <Box width="9%"><Text dimColor={r.blocked}>#{r.id}</Text></Box>
371 <Box width="7%"><Text dimColor={r.blocked}>v{r.version}</Text></Box>
372 <Box width="13%"><Text dimColor={r.blocked}>{r.mode === 'net_new' ? 'net new' : r.mode}</Text></Box>
373 <Box width="16%"><Text color={r.blocked ? 'red' : r.deployed_at ? 'green' : r.approved_at ? 'cyan' : undefined} dimColor={r.blocked}>{flag}</Text></Box>
374 <Box flexGrow={1}>
375 <Button key={`row-${r.id}`} plain dimColor={r.blocked} label={r.keyword} onPress={open(r)} />
376 </Box>
377 </Box>
378 )
379 })}
380 </Box>
381 )}
382 <Box gap={1}>
383 <Button hotkey="r" onPress={() => { queue = null; void loadQueue($, superseded) }}>Refresh</Button>
384 <Button key="change-key" hotkey="k" onPress={() => update($, keyPrompt, () => true)}>Key</Button>
385 <Button hotkey="s" onPress={() => { void (async () => { const next = !superseded; await update($, includeSuperseded, () => next); void loadQueue($, next) })() }}>{superseded ? 'Hide superseded' : 'Show superseded'}</Button>
386 <Button role="dismiss" onPress={() => $.ui.close({ id: PANE })}>Close</Button>
387 </Box>
388 </Box>
389 )
390 }
391
392 // ---- one order ----------------------------------------------------------
393 const loaded = orders.get(id)
394 const back = <Button key="back" hotkey="b" onPress={() => update($, view, () => 'queue')}>Back to queue</Button>
395 if (!loaded) {
396 if (inflight.has(`order:${id}`)) return <Box flexDirection="column" gap={1}><Text dimColor>Fetching work order #{id}…</Text>{back}</Box>
397 return (
398 <Box flexDirection="column" gap={1}>
399 <Text dimColor>Work order #{id} not loaded yet.</Text>
400 <Box gap={1}>
401 <Button key="load-order" hotkey="l" onPress={() => { void loadOrder($, id); void prefetchPreview($, id, surface) }}>Load</Button>
402 {back}
403 </Box>
404 </Box>
405 )
406 }
407 if (!loaded.ok) {
408 return (
409 <Box flexDirection="column" gap={1}>
410 <Text color="red">#{id}: {loaded.error}</Text>
411 <Box gap={1}>
412 <Button hotkey="r" onPress={() => { orders.delete(id); void loadOrder($, id); void prefetchPreview($, id, surface) }}>Retry</Button>
413 {back}
414 </Box>
415 </Box>
416 )
417 }
418 const wo = loaded.data
419 const row = queue?.ok ? queue.data.find(r => r.id === id) : undefined
420
421 let body
422 switch (current) {
423 case 'fields':
424 body = (
425 <Box flexDirection="column" gap={1}>
426 {wo.fields.map((f: Field) => (
427 <Box flexDirection="column">
428 <Box gap={1}>
429 <Text bold>{f.name}</Text>
430 <Text dimColor>→ {f.target}</Text>
431 </Box>
432 <Code source={f.value} />
433 <Box gap={2}>
434 {f.limit !== undefined && (
435 <Text color={f.chars > f.limit ? 'red' : 'green'}>{meter(f.chars, f.limit)}</Text>
436 )}
437 {f.unchanged && <Text dimColor>unchanged on the live page</Text>}
438 <Button plain onPress={copy(f.value, f.name)}>copy</Button>
439 </Box>
440 </Box>
441 ))}
442 {wo.fields.length === 0 && <Text dimColor>No deployable fields found.</Text>}
443 </Box>
444 )
445 break
446 case 'edits':
447 body = (
448 <Box flexDirection="column" gap={1}>
449 {wo.bodyIntro && <Markdown text={wo.bodyIntro} />}
450 {wo.fullBody !== null && (
451 <Box flexDirection="column">
452 <Box gap={2}><Text bold>BODY (full page)</Text><Button plain onPress={copy(wo.fullBody, 'the body')}>copy</Button></Box>
453 <Code source={wo.fullBody} language="html" />
454 </Box>
455 )}
456 {wo.edits.map(ed => (
457 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
458 <Text bold>Edit {ed.n} of {ed.of}: {ed.title}</Text>
459 <Text dimColor>Why: {ed.why}</Text>
460 <Text dimColor>Where: {ed.where}</Text>
461 {ed.find && <Text>Find:</Text>}
462 {ed.find && <Code source={ed.find} />}
463 <Box gap={2}>
464 <Text>{ed.kind === 'remove' ? 'Replace with: nothing; remove it.' : ed.kind === 'insert' ? 'Insert:' : 'Replace with:'}</Text>
465 {ed.replace && <Button plain onPress={copy(ed.replace, `edit ${ed.n}`)}>copy</Button>}
466 </Box>
467 {ed.replace && <Code source={ed.replace} language="html" />}
468 </Box>
469 ))}
470 {wo.edits.length === 0 && wo.fullBody === null && <Text dimColor>No body edits in this work order.</Text>}
471 </Box>
472 )
473 break
474 case 'schema':
475 body = wo.schema ? (
476 <Box flexDirection="column" gap={1}>
477 <Box gap={2}><Text bold>JSON-LD → inject into <head> (merge into an existing @graph)</Text><Button plain onPress={copy(wo.schema, 'the JSON-LD')}>copy</Button></Box>
478 <Code source={wo.schema} language="json" />
479 </Box>
480 ) : <Text dimColor>No structured data in this work order.</Text>
481 break
482 case 'verify':
483 body = (
484 <Box flexDirection="column">
485 {wo.verify.map((step, i) => (
486 <Button
487 key={`verify-${i}`}
488 plain
489 dimColor={done.includes(i)}
490 label={`${done.includes(i) ? '[x]' : '[ ]'} ${i + 1}. ${step}`}
491 onPress={() => update($, checked, list => done.includes(i) ? (list ?? []).filter(x => x !== i) : [...(list ?? []), i])}
492 />
493 ))}
494 <Text dimColor>{done.length}/{wo.verify.length} checked</Text>
495 </Box>
496 )
497 break
498 case 'notes':
499 body = (
500 <Box flexDirection="column" gap={1}>
501 {wo.contextChanged && <Markdown text={`## Context changed since this version\n\n${wo.contextChanged}`} />}
502 {wo.recommended && <Markdown text={`## Recommended, not applied\n\n${wo.recommended}`} />}
503 {wo.checked && <Markdown text={`## Checked and left alone\n\n${wo.checked}`} />}
504 {!wo.contextChanged && !wo.recommended && !wo.checked && <Text dimColor>No notes.</Text>}
505 </Box>
506 )
507 break
508 case 'preview': {
509 const variant = e.surface === 'terminal' ? 'full' : 'thumb'
510 const key = shotKey(id, pmode, at, variant)
511 const shot = shots.get(key)
512 const modeRow = (
513 <Box gap={1}>
514 <Button hotkey="p" onPress={() => { void (async () => {
515 const next = pmode === 'proposed' ? 'live' : 'proposed'
516 await update($, previewMode, () => next)
517 if (!shots.has(shotKey(id, next, at, variant))) void loadShot($, id, next, at, variant)
518 })() }}>{pmode === 'proposed' ? 'Show live' : 'Show proposed'}</Button>
519 <Button hotkey="r" onPress={() => { for (const k of [...shots.keys()]) if (k.startsWith(`${id}:`)) shots.delete(k); void loadShot($, id, pmode, at, variant, true) }}>Re-render</Button>
520 </Box>
521 )
522 // A page that is not live yet is previewed on a template: a live page of the same
523 // site, its article swapped for this work order's. The server picks the nearest
524 // inventory page unless one is typed here; a new template drops the cached shots.
525 const shownTemplate = shot?.ok ? shot.data.meta.template_url : undefined
526 const notLive = (row ? row.mode !== 'existing' : wo.mode !== 'existing') || Boolean(shownTemplate)
527 const templateRow = notLive && (
528 <Box flexDirection="column">
529 <Text dimColor>Not live yet — shown on a template page of the site{shownTemplate ? `: ${shownTemplate}` : templates.get(id) ? `: ${templates.get(id)}` : ' (the nearest page in the inventory)'}. Type another live URL on the site to use its layout instead:</Text>
530 <Input
531 key="template-url"
532 label="Template URL"
533 placeholder={shownTemplate ?? 'https://…/a-live-page-on-the-site'}
534 submitLabel="Use as template"
535 onSubmit={value => {
536 const url = value.trim()
537 if (url) templates.set(id, url); else templates.delete(id)
538 for (const k of [...shots.keys()]) if (k.startsWith(`${id}:`)) shots.delete(k)
539 void loadShot($, id, pmode, at, variant)
540 }}
541 />
542 </Box>
543 )
544 if (!shot) {
545 body = (
546 <Box flexDirection="column" gap={1}>
547 {inflight.has(key)
548 ? <Text dimColor>Rendering the {pmode} page on the server… (started when the order was opened; a few seconds)</Text>
549 : <Button key="load-preview" hotkey="l" label={`Render ${pmode} preview`} onPress={() => { void loadShot($, id, pmode, at, variant) }} />}
550 {modeRow}
551 {templateRow}
552 </Box>
553 )
554 break
555 }
556 if (!shot.ok) {
557 body = (
558 <Box flexDirection="column" gap={1}>
559 <Text color="red">Preview failed: {shot.error}</Text>
560 {modeRow}
561 {templateRow}
562 </Box>
563 )
564 break
565 }
566 const m = shot.data.meta
567 const n = Math.max(1, m.slices)
568 const i = Math.min(Math.max(0, at), n - 1)
569 const from = i * m.slice_height
570 const sliceH = Math.min(m.slice_height, m.height - from)
571 const columns = Math.max(1, Math.min(255, width))
572 const rows = Math.max(1, Math.min(255, Math.round((columns * sliceH) / m.width / 2)))
573 const caption = `${m.mode} · ${m.width}×${m.height} px · slice ${i + 1}/${n} (${from}–${from + sliceH} px)${m.truncated ? ' · page cut' : ''} · ${Math.round(m.elapsed_ms / 1000)} s`
574 const applied = m.mode === 'proposed' ? `${m.applied_count}/${m.applied.length} ${m.template_url ? 'parts placed' : 'edits applied'}${m.not_found.length ? `, not found: ${m.not_found.length}` : ''}` : ''
575 const nav = (
576 <Box gap={1}>
577 <Button hotkey="u" onPress={() => { void (async () => { const k = Math.max(0, i - 1); await update($, slice, () => k); if (!shots.has(shotKey(id, pmode, k, variant))) void loadShot($, id, pmode, k, variant) })() }}>Up</Button>
578 <Button hotkey="n" onPress={() => { void (async () => { const k = Math.min(n - 1, i + 1); await update($, slice, () => k); if (!shots.has(shotKey(id, pmode, k, variant))) void loadShot($, id, pmode, k, variant) })() }}>Down</Button>
579 </Box>
580 )
581 let picture
582 if (e.surface === 'terminal') {
583 const { Image } = $.ui.resolve(e)
584 picture = <Image source={{ png: shot.data.data }} columns={columns} rows={rows} alt={`[${m.mode} preview slice ${i + 1}/${n}: this terminal cannot draw pixels]`} />
585 } else {
586 const { Svg } = $.ui.resolve(e)
587 const W = 640, H = Math.round((640 * sliceH) / m.width)
588 const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"><image width="${W}" height="${H}" xlink:href="data:${shot.data.mime};base64,${shot.data.data}"/></svg>`
589 picture = <Svg source={svg} alt={`${m.mode} preview slice ${i + 1} of ${n}`} width={W} height={H} />
590 }
591 body = (
592 <Box flexDirection="column" gap={1}>
593 <Text dimColor wrap="truncate-end">{m.template_url ? `${m.page_url ?? ''} (on template ${m.template_url})` : m.url}</Text>
594 <Text dimColor>{caption}{applied ? ` · ${applied}` : ''}</Text>
595 {picture}
596 {m.view_url && <Link href={m.view_url}>Open this preview as a page</Link>}
597 <Box gap={1}>{nav}{modeRow}</Box>
598 {templateRow}
599 </Box>
600 )
601 break
602 }
603 default:
604 body = (
605 <Box flexDirection="column" gap={1}>
606 <Markdown text={`**Mode:** ${wo.mode} \n**Prepared:** ${wo.prepared}\n\n### Keyword target\n\n${wo.keywordTarget}\n\n### What changed\n\n${wo.whatChanged}${wo.heroImage ? `\n\n### Hero image\n\n${wo.heroImage}` : ''}`} />
607 </Box>
608 )
609 }
610
611 return (
612 <Box flexDirection="column" gap={1}>
613 <Box flexDirection="column">
614 <Text bold>#{id} · {row?.keyword ?? wo.title}</Text>
615 <Text dimColor wrap="truncate-end">{wo.title}{row ? ` · v${row.version} · ${row.mode} · ${stateOf(row)}${row.approved_at ? ` by ${row.approved_by || 'someone'}` : ''}` : ''}</Text>
616 </Box>
617 <Box gap={1} flexWrap="wrap">
618 {TABS.map(t => (
619 <Button key={`tab-${t.id}`} hotkey={t.hotkey} variant={t.id === current ? 'primary' : 'secondary'} onPress={() => {
620 if (t.id === 'preview') { const v = e.surface === 'terminal' ? 'full' : 'thumb'; if (!shots.has(shotKey(id, pmode, at, v))) void loadShot($, id, pmode, at, v) }
621 return update($, tab, () => t.id)
622 }}>{t.label}</Button>
623 ))}
624 {back}
625 </Box>
626 {body}
627 <Box flexDirection="column" borderStyle="single" borderDimColor paddingX={1}>
628 <Text dimColor>{row?.approved_at
629 ? `Approved for publishing${row.approved_by ? ` by ${row.approved_by}` : ''}. An edit makes a new, unapproved version.`
630 : 'Approve once the preview and fields look right; a publishing step reads the stamp. To change the page first, tell Claude what to change — it edits with kg_save_version and the new version appears here.'}</Text>
631 <Box gap={1}>
632 {row?.approved_at
633 ? <Button key="unapprove" hotkey="a" onPress={() => { void setApproval($, id, false) }}>Withdraw approval</Button>
634 : <Button key="approve" hotkey="a" variant="primary" onPress={() => { void setApproval($, id, true) }}>Approve for publishing</Button>}
635 </Box>
636 </Box>
637 <Box flexDirection="column" borderStyle="single" borderDimColor paddingX={1}>
638 <Text dimColor>After publishing, report where the page went (anchors the before/after report):</Text>
639 <Input
640 key="deploy-url"
641 label="Live URL"
642 placeholder={row?.page_url ?? 'https://…'}
643 submitLabel="Report deployed"
644 onSubmit={value => {
645 const url = value.trim()
646 if (!url) { $.ui.toast('Type the live URL first'); return }
647 void (async () => {
648 const r = await callTool($, 'kg_report_deployed', { optimization_id: id, url, target: 'manual' })
649 $.ui.toast(r.isError ? `Report failed: ${unwrap(r.content).slice(0, 120)}` : `Reported #${id} deployed at ${url}`)
650 queue = null
651 await update($, tick, n => (n ?? 0) + 1)
652 })()
653 }}
654 />
655 </Box>
656 </Box>
657 )
658 })
659}
660hooks/parse.ts 132 lines1// Parses one Keygrip work order (the markdown kg_fetch_workorder returns) into sections.
2// It keys on the document's fixed headings; issue #1516 asks the server for JSON so this can go.
3
4export type Field = { name: string; target: string; value: string; chars: number; unchanged: boolean; limit?: number }
5export type Edit = { n: number; of: number; title: string; why: string; where: string; find: string; replace: string; kind: 'replace' | 'remove' | 'insert' }
6export type WorkOrder = {
7 title: string
8 mode: string
9 prepared: string
10 keywordTarget: string
11 whatChanged: string
12 fields: Field[]
13 edits: Edit[]
14 bodyIntro: string
15 fullBody: string | null
16 schema: string | null
17 heroImage: string
18 recommended: string
19 checked: string
20 verify: string[]
21 reportBack: string
22 contextChanged: string
23}
24
25const LIMITS: Record<string, number> = {
26 'TITLE': 60, 'META TITLE': 60, 'META DESCRIPTION': 160, 'OG TITLE': 60, 'OG DESCRIPTION': 200,
27}
28
29function fences(text: string): string[] {
30 const out: string[] = []
31 const re = /```[a-zA-Z]*\n([\s\S]*?)\n```/g
32 let m: RegExpExecArray | null
33 while ((m = re.exec(text)) !== null) out.push(m[1] ?? '')
34 return out
35}
36
37function splitOn(text: string, marker: string): { head: string; parts: { heading: string; body: string }[] } {
38 const chunks = `\n${text}`.split(`\n${marker}`)
39 const head = (chunks[0] ?? '').replace(/^\n/, '')
40 const parts = chunks.slice(1).map(c => {
41 const nl = c.indexOf('\n')
42 return nl === -1 ? { heading: c.trim(), body: '' } : { heading: c.slice(0, nl).trim(), body: c.slice(nl + 1) }
43 })
44 return { head, parts }
45}
46
47function line(text: string, label: string): string {
48 const m = new RegExp(`\\*\\*${label}:?\\*\\*:?\\s*(.*)`).exec(text)
49 return m?.[1]?.trim() ?? ''
50}
51
52function parseEdits(body: string): { intro: string; edits: Edit[]; fullBody: string | null } {
53 const { head, parts } = splitOn(body, '#### ')
54 if (parts.length === 0) {
55 const blocks = fences(body)
56 return { intro: head.trim(), edits: [], fullBody: blocks[0] ?? null }
57 }
58 const edits = parts.map((p, i) => {
59 const m = /^Edit (\d+) of (\d+):\s*(.*)$/.exec(p.heading)
60 const title = m?.[3] ?? p.heading
61 const findIdx = p.body.indexOf('**Find')
62 const afterIdx = Math.max(p.body.indexOf('**Replace with'), p.body.indexOf('**Insert'))
63 const find = findIdx === -1 ? '' : (fences(p.body.slice(findIdx, afterIdx === -1 ? undefined : afterIdx))[0] ?? '')
64 const tail = afterIdx === -1 ? '' : p.body.slice(afterIdx)
65 const removes = /\*\*Replace with:\*\*\s*nothing/i.test(tail)
66 const replace = removes ? '' : (fences(tail)[0] ?? '')
67 const kind: Edit['kind'] = removes ? 'remove' : find ? 'replace' : 'insert'
68 return {
69 n: Number(m?.[1] ?? i + 1), of: Number(m?.[2] ?? parts.length), title,
70 why: line(p.body, 'Why'), where: line(p.body, 'Where'), find, replace, kind,
71 }
72 })
73 return { intro: head.trim(), edits, fullBody: null }
74}
75
76export function parseWorkOrder(md: string): WorkOrder {
77 const { head, parts } = splitOn(md, '## ')
78 const section = (prefix: string) => parts.find(p => p.heading.startsWith(prefix))
79 const wo: WorkOrder = {
80 title: (/^# Workorder:\s*(.*)$/m.exec(head)?.[1] ?? '').trim(),
81 mode: line(head, 'Mode'),
82 prepared: line(head, 'Prepared'),
83 keywordTarget: section('Keyword target')?.body.trim() ?? '',
84 whatChanged: section('What changed')?.body.trim() ?? '',
85 fields: [], edits: [], bodyIntro: '', fullBody: null, schema: null,
86 heroImage: section('Hero image')?.body.trim() ?? '',
87 recommended: section('Recommended')?.body.trim() ?? '',
88 checked: section('Checked and left alone')?.body.trim() ?? '',
89 verify: (section('Verify')?.body ?? '').split('\n').map(l => /^\d+\.\s+(.*)$/.exec(l)?.[1] ?? '').filter(Boolean),
90 reportBack: section('Report back')?.body.trim() ?? '',
91 contextChanged: section('Context changed')?.body.trim() ?? '',
92 }
93 const deploy = section('Deployable fields')
94 if (deploy) {
95 for (const h3 of splitOn(deploy.body, '### ').parts) {
96 const arrow = /^(.*?)\s+→\s+(.*)$/.exec(h3.heading)
97 const name = (arrow?.[1] ?? h3.heading).trim()
98 const target = (arrow?.[2] ?? '').trim()
99 if (name === 'BODY') {
100 const { intro, edits, fullBody } = parseEdits(h3.body)
101 wo.bodyIntro = intro; wo.edits = edits; wo.fullBody = fullBody
102 } else if (name.startsWith('JSON-LD')) {
103 const block = fences(h3.body)[0] ?? ''
104 wo.schema = block.replace(/^\s*<script[^>]*>\s*/i, '').replace(/\s*<\/script>\s*$/i, '')
105 } else {
106 const value = fences(h3.body)[0] ?? ''
107 const chars = Number(/`(\d+) chars`/.exec(h3.body)?.[1] ?? value.length)
108 wo.fields.push({ name, target, value, chars, unchanged: /Unchanged:/.test(h3.body), limit: LIMITS[name] })
109 }
110 }
111 }
112 return wo
113}
114
115export type QueueRow = {
116 id: number; keyword: string; page_url: string; slug: string; version: number; mode: string
117 blocked: boolean; blocker_count: number; deployed_at: string | null; deployed_url: string; status: string
118 // The approval stamp (#1518): a person said this version may be published. Per version.
119 approved_at?: string | null; approved_by?: string
120}
121
122// kg_* results arrive as a text block holding {"result": <json string or markdown>, "keygrip_tools_version": ...}.
123export function unwrap(blocks: { type: string; text?: string }[]): string {
124 const text = blocks.find(b => b.type === 'text')?.text ?? ''
125 try {
126 const outer = JSON.parse(text) as { result?: unknown }
127 if (typeof outer.result === 'string') return outer.result
128 if (outer.result !== undefined) return JSON.stringify(outer.result)
129 } catch { /* the text is the result itself */ }
130 return text
131}
132types/index.d.ts 18 lines1export type Tab = 'overview' | 'fields' | 'edits' | 'schema' | 'verify' | 'notes' | 'preview'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'workorder-pane': {
6 view: 'queue' | 'order'
7 selected: number | null
8 tab: Tab
9 includeSuperseded: boolean
10 checked: number[]
11 tick: number
12 slice: number
13 keyPrompt: boolean
14 previewMode: 'live' | 'proposed'
15 }
16 }
17}
18