SLOPSHOPPER

workflow-gates-rows

Draws the transcript row of an answer the workflow-gates mod sent as the question it answered and the answer, instead of Claude Code's framing.

newrows
A shopper browsing a rack in a slop shop
README

workflow-gates-rows

A Claude Code mod that draws the transcript row of an answer the workflow-gates mod sent as the question and the answer — Approve this task? → yes · Commit and continue to next task — and the row of a handoff's continuation as the line naming where the work went — → Discussion · note-window — in place of Claude Code's framing of a plugin's prompt. It is a plugin of its own because Claude Code skips a plugin's own render hooks on the row of a prompt that plugin submitted. It pairs a row with the record the mod leaves in the conversation's own folder as sent.json as it sends, drawing the record's line verbatim where it carries one, the handoff's; a row it cannot pair shows the answer alone. It changes only the drawing: the model reads Claude Code's framing, and ctrl+o shows the message in full.

Each line it draws is kept in that folder, ~/.config/workflows/conversations/{session-id}/rows.json (under WORKFLOWS_CONFIG_DIR where that is set, beside the workflows' system config), under the row's message id, so scrolling back and a resumed conversation draw the row the same way, wherever the session's working directory has moved. The folder goes once Claude Code has deleted the conversation's transcript, and the lines with it, with one exception: a conversation whose end the session-end hook never saw — the session that first installed the hook, or one that crashed — keeps its folder. The mod names the folder the way the workflows' engine does. A conversation that does not run the workflows has no folder, nor does one in a process that names neither a home directory nor WORKFLOWS_CONFIG_DIR: there the mod writes nothing and draws the row as Claude Code does.

Source 1 files
hooks/register.ts 152 lines
1// A plugin of its own: Claude Code skips the mod's render hooks on rows it sent.
2import type { EngineInterface, RenderPropsOf, Register } from 'claude-code'
3
4const SENDER = 'workflow-gates'
5
6const SENT = 'sent.json'
7const SPENT = 'null'
8
9const CONVERSATIONS = 'conversations'
10const ROWS = 'rows.json'
11
12const FRAMED = /sent a message:\n([\s\S]+?)\n\nThis is how Claude Code surfaces a prompt/
13
14// A gate's answer, or a handoff's continuation with the line it draws as.
15type Sent =
16  | { answer: string; question: string; label: string }
17  | { answer: string; line: string }
18
19type Rows = Record<string, unknown>
20
21const isText = (field: unknown) => typeof field === 'string'
22
23const isSent = (value: unknown): value is Sent => {
24  const sent = (value ?? {}) as Record<string, unknown>
25
26  return (
27    isText(sent.answer) &&
28    (isText(sent.line) || (isText(sent.question) && isText(sent.label)))
29  )
30}
31
32const isRows = (value: unknown): value is Rows =>
33  typeof value === 'object' && value !== null && !Array.isArray(value)
34
35// The engine names the folder so (domain/conversation.cjs), and the band with
36// it: in the system config directory, `WORKFLOWS_CONFIG_DIR` or else
37// `.config/workflows` in the home directory; none where neither is named.
38async function folderOf($: EngineInterface): Promise<string | null> {
39  const home = await $.env.get('HOME')
40  const config =
41    (await $.env.get('WORKFLOWS_CONFIG_DIR')) ||
42    (home ? `${home}/.config/workflows` : null)
43  const id = await $.session.id()
44
45  return config === null
46    ? null
47    : `${config}/${CONVERSATIONS}/${id.replace(/[^A-Za-z0-9_-]/g, '')}`
48}
49
50function lineOf(sent: Sent): string {
51  if ('line' in sent) {
52    return sent.line
53  }
54
55  const { answer, question, label } = sent
56  const answered = `${question} → ${answer}`
57
58  return label === '' || label === answer ? answered : `${answered} · ${label}`
59}
60
61function answerIn({
62  origin,
63  isExpanded,
64  text,
65}: RenderPropsOf['UserMessage']): string | null {
66  if (isExpanded || origin.kind !== 'plugin' || origin.name !== SENDER) {
67    return null
68  }
69
70  return FRAMED.exec(text)?.[1] ?? null
71}
72
73async function lastSent($: EngineInterface, folder: string): Promise<Sent | null> {
74  try {
75    const sent: unknown = JSON.parse(await $.fs.read(`${folder}/${SENT}`))
76
77    return isSent(sent) ? sent : null
78  } catch {
79    return null
80  }
81}
82
83async function keptIn($: EngineInterface, folder: string): Promise<Rows> {
84  try {
85    const rows: unknown = JSON.parse(await $.fs.read(`${folder}/${ROWS}`))
86
87    return isRows(rows) ? rows : {}
88  } catch {
89    return {}
90  }
91}
92
93async function firstLineOf(
94  $: EngineInterface,
95  requestId: string,
96  answer: string,
97): Promise<string | null> {
98  const folder = await folderOf($)
99
100  if (folder === null || !(await $.fs.exists(folder))) {
101    return null
102  }
103
104  const rows = await keptIn($, folder)
105  const kept = rows[requestId]
106
107  if (typeof kept === 'string') {
108    return kept
109  }
110
111  const sent = await lastSent($, folder)
112  const paired = sent?.answer === answer
113  const line = paired ? lineOf(sent) : answer
114
115  await $.fs.write(
116    `${folder}/${ROWS}`,
117    JSON.stringify({ ...rows, [requestId]: line }),
118  )
119
120  if (paired) {
121    await $.fs.write(`${folder}/${SENT}`, SPENT)
122  }
123
124  return line
125}
126
127export const register: Register = on => {
128  const lines = new Map<string, string | null>()
129
130  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
131    const answer = answerIn(e.props)
132
133    if (answer === null) {
134      return next(e)
135    }
136
137    let line = lines.get(e.requestId)
138
139    if (line === undefined) {
140      line = await firstLineOf($, e.requestId, answer)
141      lines.set(e.requestId, line)
142    }
143
144    if (line === null) {
145      return next(e)
146    }
147
148    // The drawing alone changes: the model reads Claude Code's framing, by design.
149    return next({ ...e, props: { ...e.props, text: line } })
150  }).catch(($, e, next) => next(e))
151}
152