Build a Signature domain from your data and documents, and ask it questions answered by proven SQL run on your own machine.

Builds a Signature domain from your data and documents, and answers your questions about it with SQL that Signature writes and your own machine runs. Answers print in your terminal; Claude never sees them.
claude plugin marketplace add proof-tldr/signature-plugin
claude plugin install signature@signature
Claude Code asks for the API key Signature gave you, which opens one domain, and keeps it in your system keychain. Then ask Claude to set up Signature. There is nothing else to install: the plugin fetches what it needs on first start.
See docs/setup-ux.md for the experience and docs/vision.md for why.
| Path | Holds |
|---|---|
plugins/signature/.mcp.json | Starts the server through bin/signature with your key |
plugins/signature/bin/signature | The launcher: runs the server on its own pinned Python, fetching uv if the machine has none |
plugins/signature/hooks/ | Draws each Signature call and result as a card, and each answer as a table shown only to you (register.js, loaded by hooks.json); /signature-answer shows the last answer in full |
plugins/signature/skills/setup/SKILL.md | /signature:setup, the setup flow Claude follows |
plugins/signature/server/ | The MCP server, a Python package (below) |
plugins/signature/web/ | The browser pages: a React app (Vite, Tailwind, React Flow with ELK for the map) built into the server package |
docs/backend-contract.md | Every Signature API operation the plugin uses, and which are not built yet |
In plugins/signature/server/src/signature_plugin/:
| Module | Does |
|---|---|
server.py | The MCP tools, each one step of the flow |
backend.py | Signature's REST API, bound to the key's one domain |
sources.py | Which files and databases the customer added; passwords in the keychain, or a file only the user can read where there is none |
progress.py | The build, conversation and question numbers the server remembers, so Claude never handles Signature's ids |
local_data.py | Opens every source in one locked, read-only DuckDB; reports structure; runs Signature's SQL |
pages.py, web/ | Serves the built web app on 127.0.0.1, with each page's data and the customer's decision as JSON |
examples.py | Real records and pairs from the customer's data, found locally, shown beside each item on the review page |
settings.py | Where Signature is, the key that opens the customer's domain, and the plugin's own folder |
fake_backend.py | A stand-in Signature implementing the contract, for development and rehearsal |
The browser pages, from plugins/signature/web:
npm install
npm run dev # the pages with a sample domain at http://localhost:5173 (?page=connect for the other)
npm test # browser tests, in the Chrome on this machine
npm run build # type-checks, then builds into ../server/src/signature_plugin/web, which is committed
Rebuild after changing the pages: the plugin ships the built files, so customers never run npm.
Every change bumps version in plugins/signature/.claude-plugin/plugin.json and in plugins/signature/server/pyproject.toml together (then uv lock); tests/test_version.py holds them equal. Claude Code reloads an installed plugin only when its version changes.
The server, from plugins/signature/server:
uv sync
uv run pytest # the suite; database tests need SIGNATURE_TEST_POSTGRES / SIGNATURE_TEST_MYSQL
uv run ruff check . && uv run ruff format --check . && uv run pyright
SIGNATURE_TEST_POSTGRES=host:port:database:user:password (and SIGNATURE_TEST_MYSQL) runs the tests that connect a real database through the browser page.
uv run python evals/run.py runs the evaluations: realistic customer requests through headless Claude Code against the stand-in, checked against what Claude should and should not do, with tool calls, errors, time and cost for each. They use model credits, so they are run by hand when tools or their descriptions change.
To try the whole thing in Claude Code before Signature's backend has every endpoint, run the stand-in and point the plugin at it:
uv run signature-fake-backend --port 8790
SIGNATURE_API_URL=http://127.0.0.1:8790 claude --plugin-dir plugins/signature
Without SIGNATURE_API_URL, the plugin uses Signature's API at https://d2378glmrsgsno.cloudfront.net. The stand-in accepts any key.
hooks/register.js 196 lines1// Signature's look in Claude Code: every Signature tool call and result is drawn as one kind of card, a mark and a
2// headline with quiet detail under it, and an answer as a table the customer reads at a glance. Claude is never
3// given an answer's rows, only that it was drawn, so it has nothing to restate or reason from: the proven answer is
4// the whole answer. Claude sees Signature's tools from the start. Signature's work in progress shows its stage under
5// the prompt, and a build is announced when it ends.
6
7import { toolName, payloadOf, TOOL_PREFIX } from './cards.js'
8import { answerCard, answerPane, csvOf } from './answers.js'
9import { resultCard, callLine } from './tools.js'
10import { atom, read, update } from 'claude-code'
11import { BOARD, READ_EVERY_MS, boardFolders, boardOf, endingOf, runningWork } from './activity.js'
12import { ACCENT, MARK } from './cards.js'
13
14const PANE = 'signature-answer'
15const ASK = TOOL_PREFIX + 'ask_question'
16const DRAWN = "Signature has drawn this result for the customer under the call, which overrides the result's note: "
17 + 'do not show, restate, point to or comment on it. End your reply here, writing nothing more about it.'
18const NO_ANSWER = 'Signature has drawn why it has no answer for the customer under the call, which overrides the '
19 + "result's note: do not restate it. Offer, in one sentence, to ask it again more simply."
20const WITHHELD = "Signature drew its proven answer for the customer under this call. Its rows are not given to you: "
21 + 'do not show, restate, guess at or comment on the answer, and end your reply here. Never work it out from the '
22 + 'data yourself or offer to, even if the customer says they cannot see it: tell them it is under the call. A '
23 + 'question about the answer is a new question for ask_question.'
24// The answers kept for drawing, the most recent last; an older answer is drawn as no longer kept.
25const KEPT_ANSWERS = 50
26// The last answer drawn, which /signature-answer opens in full.
27let lastAnswer = null
28
29/** What Claude is given in place of a drawn answer: the question, how many rows it has, and to stop there. */
30function outline(answer) {
31 return JSON.stringify({ state: 'drawn', question: answer.question, row_count: answer.row_count, note: WITHHELD })
32}
33
34/** Keeps the answer an ask_question call came to, which its card is drawn from since Claude is given only its outline. */
35async function keep($, toolUseId, answer) {
36 const kept = (await $.store.get('answers')) ?? []
37 await $.store.set('answers', [...kept.filter(([id]) => id !== toolUseId), [toolUseId, answer]].slice(-KEPT_ANSWERS))
38}
39
40/** What a Signature call came to, as data: for an answer, the one kept when it ran; null when there is nothing. */
41async function resultOf($, tool, toolUseId, output) {
42 const kept = tool === 'ask_question' ? (await $.store.get('answers')) ?? [] : []
43 return kept.find(([id]) => id === toolUseId)?.[1] ?? payloadOf(output)
44}
45
46/** What came of a Signature call, drawn as its card; an answer is kept for /signature-answer. */
47function outcomeOf(elements, tool, result) {
48 if (tool !== 'ask_question') return resultCard(elements, tool, result)
49 if (result.state === 'answered') lastAnswer = result
50 return answerCard(elements, result)
51}
52
53/** A Signature call in a folded run and what it came to: data, or null while it runs or when it failed. */
54async function finishedResult($, call) {
55 const tool = toolName(call.tool)
56 return call.isRunning || call.isErrored ? null : resultOf($, tool, call.tool_use_id, call.output)
57}
58
59/** A Signature call and what came of it, as a folded run shows it. */
60function drawnCall(elements, call, result, index) {
61 const tool = toolName(call.tool)
62 const outcome = result === null ? null : outcomeOf(elements, tool, result)
63 return elements.Box({
64 key: call.tool_use_id ?? 'call-' + index,
65 flexDirection: 'column',
66 children: [callLine(elements, tool, call.input, call.isRunning), ...(outcome === null ? [] : [outcome])],
67 })
68}
69
70/** The server's build board, or null when no folder it may use holds one. */
71async function readBoard($) {
72 const [chosen, home, xdg, localAppData] = await Promise.all([
73 $.env.get('SIGNATURE_DATA_DIR'), $.env.get('HOME'), $.env.get('XDG_DATA_HOME'), $.env.get('LOCALAPPDATA'),
74 ])
75 for (const folder of boardFolders({ chosen, home, xdg, localAppData })) {
76 const path = `${folder}/${BOARD}`
77 if (!(await $.fs.exists(path))) continue
78 const [text, stat] = await Promise.all([$.fs.read(path), $.fs.stat(path)])
79 return boardOf(text, stat.mtimeMs)
80 }
81 return null
82}
83
84// What Signature is doing for the customer now, drawn above the prompt; null when it is doing nothing.
85const working = atom({ plugin: 'signature', key: 'working' }, null)
86
87/** For the rest of the session, keeps the stage of Signature's running work above the prompt and announces a build
88 * seen running once it ends, so the customer can look away while Signature works. */
89function watchActivity($) {
90 $.clock.every(READ_EVERY_MS, async () => {
91 const board = await readBoard($)
92 const work = runningWork(board, await $.clock.now())
93 const wasWorking = (await read($, working)) !== null
94 await update($, working, () => work)
95 const ending = work === null && wasWorking ? endingOf(board) : null
96 if (ending !== null) $.ui.toast(ending, { timeoutMs: 15_000 })
97 })
98}
99
100/** The band above the prompt: the mark and Signature, each stage it has been through ticked, the one it is in
101 * highlighted, and how long it has run. */
102function workingBand({ Box, Text }, work) {
103 return Box({
104 flexDirection: 'row',
105 columnGap: 1,
106 children: [
107 Text({ color: ACCENT, bold: true, children: [MARK] }),
108 Text({ bold: true, children: ['Signature'] }),
109 ...work.done.map((stage) => Text({ key: stage, dimColor: true, children: [`${stage} ✓ ·`] })),
110 Text({ key: 'now', color: ACCENT, children: [`${work.stage}…`] }),
111 ...(work.elapsed === null ? [] : [Text({ key: 'elapsed', dimColor: true, children: [work.elapsed] })]),
112 ],
113 })
114}
115
116export function register(on) {
117 on('session.start', async ($, e, next) => {
118 await $.command.register({
119 name: 'signature-answer',
120 description: "Open Signature's last answer in full: every row, and a copy as CSV",
121 })
122 watchActivity($)
123 return next(e)
124 })
125
126 on('command.run', { command: 'signature-answer' }, async ($) => {
127 if (lastAnswer === null) return { text: 'Signature has not answered a question in this session yet.' }
128 await $.ui.open({ id: PANE, title: 'Signature answer', focus: true, closeOnEscape: true })
129 return {}
130 })
131
132 // Without this module, Claude is told to show each answer itself, since nothing else would. With it, an answer is
133 // kept here and drawn, and Claude is given only its outline.
134 on('tool.call', { tool: ASK }, async ($, e, next) => {
135 const called = await next(e)
136 const answer = called.deny === undefined ? payloadOf(called.result) : null
137 if (answer === null) return called
138 if (answer.state !== 'answered') return { ...called, context: [...(called.context ?? []), NO_ANSWER] }
139 await keep($, e.tool_use_id, answer)
140 return { ...called, result: outline(answer) }
141 })
142
143 on('tool.call', { tool: TOOL_PREFIX + 'get_status' }, async ($, e, next) => {
144 const called = await next(e)
145 return called.deny === undefined ? { ...called, context: [...(called.context ?? []), DRAWN] } : called
146 })
147
148 // Claude sees Signature's few tools from the start rather than finding them behind a search: asking Signature is
149 // how the customer's data questions are answered, and a tool behind a search loses to reading the files directly.
150 on('tool.describe', { tool: new RegExp(`^${TOOL_PREFIX}`) }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
151
152 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
153 const tool = toolName(e.props.tool)
154 if (tool === null) return next(e)
155 return callLine($.ui.resolve(e), tool, e.props.input, e.props.isRunning)
156 })
157
158 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
159 const tool = toolName(e.props.tool)
160 if (tool === null || e.props.isErrored) return next(e)
161 const result = await resultOf($, tool, e.props.tool_use_id, e.props.output)
162 if (result === null) return next(e)
163 return outcomeOf($.ui.resolve(e), tool, result) ?? next(e)
164 })
165
166 // Claude Code folds a run of calls into one line, which would hide what Signature did. A run of Signature's own
167 // calls is drawn call by call instead, each with what came of it; a run mixing in other tools keeps Claude Code's
168 // line, with each Signature call drawn under it.
169 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
170 const calls = e.props.calls
171 const elements = $.ui.resolve(e)
172 if (calls.length > 0 && calls.every((call) => toolName(call.tool) !== null)) {
173 const results = await Promise.all(calls.map((call) => finishedResult($, call)))
174 return elements.Box({ flexDirection: 'column', rowGap: 1, children: calls.map((call, index) => drawnCall(elements, call, results[index], index)) })
175 }
176 const ours = calls.filter((call) => toolName(call.tool) !== null)
177 if (ours.length === 0) return next(e)
178 const results = await Promise.all(ours.map((call) => finishedResult($, call)))
179 return elements.Box({ flexDirection: 'column', rowGap: 1, children: [await next(e), ...ours.map((call, index) => drawnCall(elements, call, results[index], index))] })
180 })
181
182 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183 const work = await read($, working)
184 return work === null ? next(e) : workingBand($.ui.resolve(e), work)
185 })
186
187 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
188 if (e.requestId !== PANE || lastAnswer === null) return next(e)
189 const answer = lastAnswer
190 return answerPane($.ui.resolve(e), answer, async () => {
191 await $.ui.copy({ text: csvOf(answer) })
192 await $.ui.toast('Copied the answer as CSV')
193 })
194 })
195}
196hooks/cards.js 61 lines1// The one look every Signature card shares: a mark in the accent colour and a bold headline, with quiet detail
2// lines under it. Elements come from the site being drawn ($.ui.resolve), so a card is drawn alike in the terminal
3// and the Desktop app.
4
5export const MARK = '◆'
6export const ACCENT = '#7C8CF8'
7export const TOOL_PREFIX = 'mcp__plugin_signature_signature__'
8
9/** The Signature tool a call names, or null for anyone else's tool. */
10export function toolName(tool) {
11 return typeof tool === 'string' && tool.startsWith(TOOL_PREFIX) ? tool.slice(TOOL_PREFIX.length) : null
12}
13
14/** A Signature tool's result as data: its structured result, or the JSON its text holds; null when it has neither. */
15export function payloadOf(output) {
16 const structured = output?.structuredContent ?? null
17 if (structured !== null && typeof structured === 'object') return unwrapped(structured)
18 const blocks = Array.isArray(output) ? output : Array.isArray(output?.content) ? output.content : []
19 const text = blocks.find((block) => block?.type === 'text')?.text ?? (typeof output === 'string' ? output : null)
20 if (text === null) return null
21 try {
22 return unwrapped(JSON.parse(text))
23 } catch {
24 return null
25 }
26}
27
28// A tool whose result is not an object, such as a list, is wrapped as { result }.
29function unwrapped(value) {
30 return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 1
31 && 'result' in value ? value.result : value
32}
33
34/** A card: the mark, a bold headline, then each detail on its own quiet line. `tone` colours the headline. */
35export function card({ Box, Text }, headline, details = [], tone = undefined) {
36 return Box({
37 flexDirection: 'column',
38 children: [
39 Box({
40 flexDirection: 'row',
41 columnGap: 1,
42 children: [
43 Text({ color: ACCENT, bold: true, children: [MARK] }),
44 Text({ bold: true, color: tone, wrap: 'wrap', children: [headline] }),
45 ],
46 }),
47 ...details.map((detail) => line({ Text, Box }, detail)),
48 ],
49 })
50}
51
52/** One quiet line under a card's headline, indented past the mark. */
53export function line({ Box, Text }, text) {
54 return Box({ paddingLeft: 2, children: [Text({ dimColor: true, wrap: 'wrap', children: [text] })] })
55}
56
57/** A count with its noun, `1 table`, `3 columns`. */
58export function counted(count, noun) {
59 return `${count} ${noun}${count === 1 ? '' : 's'}`
60}
61hooks/answers.js 160 lines1// An answer as the customer reads it: the rows the proven query found on their data, as a table with readable
2// headers and tidy numbers. The transcript shows the first rows; the /signature-answer pane shows every row and
3// copies them as CSV.
4
5import { card, line, ACCENT, MARK, counted } from './cards.js'
6
7const INLINE_ROWS = 12
8// The widest a text cell is drawn, so that one long value cannot push the other columns off the screen.
9const CELL_WIDTH = 40
10
11/** What is drawn under an ask_question call, whose line already shows the question: the answer, or why there is none. */
12export function answerCard(elements, answer) {
13 switch (answer.state) {
14 case 'answered':
15 return answeredCard(elements, answer)
16 case 'unanswerable':
17 return card(elements, "Signature can't answer this from your domain", [answer.reason ?? ''], 'warning')
18 case 'unproven':
19 // The reason is the checker's own words, for Claude; the customer is told what to do about it.
20 return card(elements, "Signature couldn't prove an answer, so nothing ran", ['Asking it more simply, one part at a time, usually works.'], 'warning')
21 case 'drawn':
22 return card(elements, 'This answer is no longer kept', ['Ask the question again to see it.'])
23 case 'stale':
24 return card(elements, 'Your data changed shape since the domain was published', ['Build and publish it again to ask about it.'], 'warning')
25 default:
26 return card(elements, 'Signature has no answer', [answer.reason ?? ''])
27 }
28}
29
30function answeredCard(elements, answer) {
31 const { Box } = elements
32 const shown = sortedRows(answer.rows).slice(0, INLINE_ROWS)
33 const more = answer.row_count - shown.length
34 return Box({
35 flexDirection: 'column',
36 children: [
37 Box({ paddingLeft: 2, paddingBottom: 1, children: [answerBody(elements, answer, shown)] }),
38 line(elements, footer(answer, more)),
39 ],
40 })
41}
42
43/** Whether the answer is one value, such as a count, rather than rows. */
44function isSingleValue(answer) {
45 return answer.row_count === 1 && answer.columns.length === 1
46}
47
48/** The answer's rows as it shows them: a single value on its own, any other result as a table. */
49function answerBody(elements, answer, rows) {
50 if (rows.length === 0) return elements.Text({ dimColor: true, children: ['No rows match.'] })
51 if (isSingleValue(answer)) return elements.Text({ bold: true, children: [cellText(rows[0][0])] })
52 return tableOf(elements, answer.columns, rows)
53}
54
55/** The line under every answer: that it was proven and ran on the customer's data, and how many rows it has. */
56function provenLine(answer) {
57 return ['✓ Proven by Signature', 'ran on your data', ...(isSingleValue(answer) ? [] : [counted(answer.row_count, 'row')])].join(' · ')
58}
59
60/** The proven line under an answer in the transcript, pointing to the pane when the card leaves rows out. */
61function footer(answer, more) {
62 return provenLine(answer) + (more > 0 ? ` · /signature-answer for all ${answer.row_count}` : '')
63}
64
65/** The pane /signature-answer opens: the question, every row, and a button that copies them as CSV. */
66export function answerPane(elements, answer, copy) {
67 const { Box, Text, Button } = elements
68 const rows = sortedRows(answer.rows)
69 return Box({
70 flexDirection: 'column',
71 rowGap: 1,
72 children: [
73 Box({
74 flexDirection: 'row',
75 columnGap: 1,
76 children: [Text({ color: ACCENT, bold: true, children: [MARK] }), Text({ bold: true, wrap: 'wrap', children: [answer.question] })],
77 }),
78 answerBody(elements, answer, rows),
79 Text({ dimColor: true, children: [footerOfPane(answer)] }),
80 Button({ key: 'copy-csv', label: 'Copy as CSV', hotkey: 'c', plain: true, onPress: copy }),
81 ],
82 })
83}
84
85function footerOfPane(answer) {
86 const cut = answer.truncated ? ` · the first ${counted(answer.rows.length, 'row')} of a longer result` : ''
87 return provenLine(answer) + cut
88}
89
90/** The answer's rows as CSV, under its headers, values as they are. */
91export function csvOf(answer) {
92 const field = (value) => {
93 const text = value === null || value === undefined ? '' : String(value)
94 return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text
95 }
96 return [answer.columns, ...answer.rows].map((row) => row.map(field).join(',')).join('\n')
97}
98
99// ---- reading, rows and cells ---------------------------------------------------------------------------------
100
101/** The rows in a stable order a person can scan: by the first column, then the next. */
102function sortedRows(rows) {
103 return [...rows].sort((left, right) => {
104 for (let index = 0; index < left.length; index += 1) {
105 const order = compared(left[index], right[index])
106 if (order !== 0) return order
107 }
108 return 0
109 })
110}
111
112function compared(left, right) {
113 if (left === right) return 0
114 if (left === null || left === undefined) return 1
115 if (right === null || right === undefined) return -1
116 if (typeof left === 'number' && typeof right === 'number') return left - right
117 return String(left).localeCompare(String(right))
118}
119
120/** The rows as a table, one column of cells under each header: text aligned left, numbers right. */
121function tableOf({ Box, Text }, columns, rows) {
122 return Box({
123 flexDirection: 'row',
124 columnGap: 3,
125 children: columns.map((name, index) => {
126 const numeric = rows.some((row) => typeof row[index] === 'number')
127 && rows.every((row) => row[index] === null || typeof row[index] === 'number')
128 return Box({
129 key: `column-${index}`,
130 flexDirection: 'column',
131 alignItems: numeric ? 'flex-end' : 'flex-start',
132 children: [
133 Text({ key: 'heading', bold: true, children: [heading(name)] }),
134 ...rows.map((row, at) => Text({ key: `row-${at}`, wrap: 'truncate-end', children: [cellText(row[index])] })),
135 ],
136 })
137 }),
138 })
139}
140
141/** A column name as words: `average_rating` reads `Average rating`, `c0` reads `Value`. */
142function heading(name) {
143 if (/^c\d+$/.test(name)) return 'Value'
144 const words = String(name).replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_\-.]+/g, ' ').trim().toLowerCase()
145 return words.charAt(0).toUpperCase() + words.slice(1)
146}
147
148/** A value as the table shows it: whole numbers grouped by thousands, others to two places, absence as a dash. */
149function cellText(value) {
150 if (value === null || value === undefined) return '—'
151 if (typeof value === 'boolean') return value ? 'yes' : 'no'
152 if (typeof value === 'number') {
153 return Number.isInteger(value)
154 ? value.toLocaleString('en-US')
155 : value.toLocaleString('en-US', { maximumFractionDigits: 2 })
156 }
157 const text = String(value).replaceAll('\n', ' ')
158 return text.length > CELL_WIDTH ? text.slice(0, CELL_WIDTH - 1) + '…' : text
159}
160hooks/tools.js 152 lines1// Every Signature tool's call and result in the house style: a call as one line saying what Signature is doing, and
2// its result as a card saying what came of it, in the customer's words rather than the tool's data.
3
4import { card, counted, ACCENT, MARK } from './cards.js'
5
6/** The line a Signature tool call is drawn as while it runs: the mark, then what is happening. Once it is done its card
7 * says what came of it, so the line goes, except a question's, which heads its answer; a status check, Claude's own
8 * bearings, is never drawn. */
9export function callLine({ Box, Text }, tool, input, isRunning) {
10 if (tool === 'get_status' || (!isRunning && tool !== 'ask_question')) return Box({})
11 return Box({
12 flexDirection: 'row',
13 columnGap: 1,
14 children: [
15 Text({ color: ACCENT, bold: true, children: [MARK] }),
16 Text({ children: ['Signature'] }),
17 Text({ dimColor: true, wrap: 'truncate-end', children: [doing(tool, input ?? {}) + (isRunning ? '…' : '')] }),
18 ],
19 })
20}
21
22function doing(tool, input) {
23 switch (tool) {
24 case 'get_status':
25 return 'checking where your domain stands'
26 case 'add_data_files':
27 return `adding ${listed((input.paths ?? []).map(fileName))}`
28 case 'connect_database':
29 return 'connecting a database'
30 case 'remove_source':
31 return `removing ${input.name ?? 'a source'}`
32 case 'set_up':
33 return 'setting up your domain'
34 case 'build':
35 return 'building your domain'
36 case 'answer_questions':
37 return `answering ${counted((input.answers ?? []).length, 'question')}`
38 case 'wait_for_build':
39 return 'waiting for the build'
40 case 'review':
41 return 'opening the review'
42 case 'ask_question':
43 return `asking “${input.question ?? ''}”`
44 default:
45 return tool.replaceAll('_', ' ')
46 }
47}
48
49/** The card a Signature tool's result is drawn as, or null where the default drawing says it as well. */
50export function resultCard(elements, tool, result) {
51 switch (tool) {
52 case 'get_status':
53 return statusCard(elements, result)
54 case 'add_data_files':
55 return addedCard(elements, Array.isArray(result) ? result : [result])
56 case 'connect_database':
57 return 'source' in result ? addedCard(elements, [result]) : noticeCard(elements, result)
58 case 'remove_source':
59 return typeof result === 'string' ? card(elements, result) : null
60 case 'set_up':
61 return result.state === 'declined' ? card(elements, 'Setup stopped') : buildCard(elements, result)
62 case 'build':
63 case 'answer_questions':
64 case 'wait_for_build':
65 return buildCard(elements, result)
66 case 'review':
67 return reviewCard(elements, result)
68 default:
69 return null
70 }
71}
72
73/** Where the domain stands; nothing for a domain not yet started, which has nothing to say at the start of a setup. */
74function statusCard(elements, status) {
75 const sources = status.sources ?? []
76 const questions = status.open_questions ?? []
77 if (sources.length === 0 && questions.length === 0 && !status.published) return elements.Box({})
78 return card(elements, `${status.domain} · ${status.published ? 'published' : 'not published yet'}`, [
79 sources.length === 0 ? 'No data added yet' : `Data: ${listed(sources.map((source) => source.name))}`,
80 ...(questions.length > 0 ? [`Signature is waiting on ${counted(questions.length, 'answer')} from you`] : []),
81 ])
82}
83
84/** What was added, on one line: the sources, then how many tables and columns they hold together. */
85function addedCard(elements, added) {
86 if (added.length === 0) return card(elements, 'Nothing new to add')
87 const { Box, Text } = elements
88 const tables = added.reduce((sum, entry) => sum + entry.tables, 0)
89 const columns = added.reduce((sum, entry) => sum + entry.columns, 0)
90 return Box({
91 flexDirection: 'row',
92 columnGap: 1,
93 children: [
94 Text({ color: ACCENT, bold: true, children: [MARK] }),
95 Text({ bold: true, children: [`Added ${listed(added.map((entry) => entry.source))}`] }),
96 Text({ dimColor: true, children: [`· ${counted(tables, 'table')}, ${counted(columns, 'column')}`] }),
97 ],
98 })
99}
100
101function buildCard(elements, progress) {
102 const questions = progress.open_questions ?? []
103 switch (progress.state) {
104 case 'built':
105 return card(elements, 'Built your domain', progress.reply ? [progress.reply] : [])
106 case 'questions':
107 return card(elements, `Signature has ${counted(questions.length, 'question')}`,
108 questions.map((question) => `${question.number}. ${question.question}`))
109 case 'replied':
110 return card(elements, 'Signature replied', progress.reply ? [progress.reply] : [])
111 case 'building':
112 return card(elements, 'Still building', ['Signature is still working; this picks up where it left off.'])
113 case 'failed':
114 return card(elements, "The build didn't finish", progress.reply ? [progress.reply] : [], 'error')
115 default:
116 return null
117 }
118}
119
120function reviewCard(elements, reviewed) {
121 switch (reviewed.state) {
122 case 'published':
123 return card(elements, 'Published', [readiness(reviewed.note ?? '')])
124 case 'changes_requested':
125 return card(elements, 'Changes sent', ['Signature is rebuilding with what you asked for.'])
126 default:
127 return noticeCard(elements, reviewed)
128 }
129}
130
131function readiness(note) {
132 if (note.includes('could not get ready')) return note.split('. Tell the customer')[0]
133 if (note.includes('still getting ready')) return 'Signature is still getting ready to answer; a question asked now waits for it.'
134 return 'Ready for your questions.'
135}
136
137function noticeCard(elements, result) {
138 if (result.state === 'waiting') return card(elements, 'Waiting for you in the browser')
139 if (result.state === 'declined') return card(elements, 'The page was not opened')
140 return null
141}
142
143function fileName(path) {
144 return String(path).split('/').filter(Boolean).pop() ?? String(path)
145}
146
147/** Names as a sentence lists them: `a`, `a and b`, `a, b and c`. */
148function listed(names) {
149 if (names.length <= 1) return names[0] ?? 'nothing'
150 return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
151}
152hooks/activity.js 63 lines1// What Signature is doing for the customer, a build or a question, as they see it while it runs and when a build
2// ends. The server keeps it in activity.json at the top of its folder, rewritten every second while the work runs and
3// left holding how it ended. register.js reads that file on a timer; this module says where the file may be and what
4// to show from it.
5
6export const BOARD = 'activity.json'
7export const READ_EVERY_MS = 1000
8// Running work the server has not rewritten for this long is no longer followed: its call ended or the server
9// stopped. It is not shown until a call follows it again.
10const STALE_AFTER_MS = 10_000
11const ENDINGS = {
12 built: 'Signature finished building. The review is next.',
13 questions: 'Signature finished building and has questions for you.',
14 replied: 'Signature replied without building yet.',
15 failed: "Signature's build did not finish.",
16}
17
18/** The folders the server may keep activity.json in, from the environment: platformdirs' user data folder for
19 * 'signature-plugin' on each platform, unless SIGNATURE_DATA_DIR names another. This mirrors settings.py because
20 * Claude Code gives hooks no plugin data folder and no channel from the MCP server to say where its folder is. */
21export function boardFolders({ chosen, home, xdg, localAppData }) {
22 if (chosen) return [chosen]
23 return [
24 home && `${home}/Library/Application Support/signature-plugin`,
25 xdg && `${xdg}/signature-plugin`,
26 home && `${home}/.local/share/signature-plugin`,
27 localAppData && `${localAppData}/signature-plugin/signature-plugin`,
28 ].filter(Boolean)
29}
30
31/** The build board in a file's text, with when the file was written, or null when the text is not whole JSON. */
32export function boardOf(text, writtenAt) {
33 try {
34 return { ...JSON.parse(text), writtenAt }
35 } catch {
36 return null // caught mid-write; the next read sees it whole
37 }
38}
39
40/** What Signature is doing now, as the band above the prompt shows it: the stages it has been through, the one it is
41 * in, and how long it has run when the board says; null when the board shows no work the server is following. */
42export function runningWork(board, now) {
43 if (board?.state !== 'running' || now - board.writtenAt >= STALE_AFTER_MS) return null
44 const stage = board.stage ?? 'Building'
45 const started = Date.parse(board.started_at ?? '')
46 return {
47 done: (board.stages ?? []).filter((label) => label !== stage),
48 stage,
49 elapsed: Number.isNaN(started) ? null : elapsed(now - started),
50 }
51}
52
53/** What to tell the customer about how a build ended, or null when the board holds no build's ending. */
54export function endingOf(board) {
55 return ENDINGS[board?.state] ?? null
56}
57
58/** Minutes and seconds, as the status line shows how long the work has run. */
59function elapsed(ms) {
60 const seconds = Math.floor(ms / 1000)
61 return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
62}
63types/index.d.ts 11 lines1// The values Signature's hooks keep for the session.
2
3/** What Signature is doing now: the stages it has been through, the one it is in, and how long it has run when known. */
4export type Work = { done: string[]; stage: string; elapsed: string | null }
5
6declare module 'claude-code' {
7 interface PluginState {
8 signature: { working: Work | null }
9 }
10}
11