SLOPSHOPPER

workorder-pane

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…

newpanecommandtoastnetwork
v0.5.0no licenseupdated 2026-10-08KeygripGit/claude-mods/plugins/workorder-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workorder-pane
│ ┃ Work orders ✕ › fix the failing auth test and add an audit log call │ ┃ Connect this pane to your Keygrip project │ ┃ ⏺ Read(src/auth.ts) │ ┃ Paste the project's client key ⎿ Read 6 lines │ ┃ (kg_client_…). It comes from the project's ⏺ Update(src/auth.ts) │ ┃ Settings → Client MCP access in ⎿ Added 2 lines, removed 1 line │ ┃ app.keygrip.ai, or from your Keygrip ⏺ Bash(bun test) │ ┃ contact. It is kept on this machine only. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Client key: kg_client_… ⏎ Connect ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /workorders │ ⎿ workorder-pane: Work orders pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work orders
Connect this pane to your Keygrip project 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. Client key: kg_client_… ⏎ Connect
README

workorder-pane — Keygrip work orders inside Claude Code

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

What it draws

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:

TabShows
1 Overviewmode, prepared date, keyword target, "what changed", hero image
2 Fieldseach 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 Editseach 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 Schemathe JSON-LD with the <script> wrapper stripped, a copy button
5 Verifythe post-publish steps as toggles, with a checked count
6 Notescontext changed since this version, recommended-not-applied, checked-and-left-alone
7 Previewthe 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.

Installing it (clients, and anyone outside the monorepo)

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.

Loading it from the checkout (developers)

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/

The MCP server it talks to

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).

How the preview is made

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).

Developing

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).

Not built

  • Structured JSON from the server beside the markdown, so the heading parser can go.
  • A shared render cache (R2 or Postgres) if the per-process one proves too cold across the two web boxes.
Source 3 files
hooks/register.tsx 660 lines
1import { 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 &lt;head&gt; (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}
660
hooks/parse.ts 132 lines
1// 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}
132
types/index.d.ts 18 lines
1export 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