@alecsibilia/luca 14.0.0-alpha.3 → 14.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +5 -2
  3. package/board/client/board-panel.tsx +85 -74
  4. package/board/client/stuck-actions.tsx +269 -0
  5. package/board/client/timeline-rows.tsx +41 -14
  6. package/board/index.server.ts +7 -1
  7. package/board/package.json +1 -1
  8. package/board/server/board-server.ts +65 -1
  9. package/board/server/board-vocabulary.ts +41 -5
  10. package/board/server/luca-version.ts +1 -1
  11. package/board/server/make-rows.ts +57 -9
  12. package/board/server/reduce-board.ts +95 -16
  13. package/board/server/reply-post.ts +407 -0
  14. package/board/shared/board-rows.ts +9 -0
  15. package/board/shared/board-rpc.ts +42 -0
  16. package/board/shared/board-state.ts +27 -0
  17. package/board/shared/reply-actions.ts +201 -0
  18. package/engine/cli/computer-adapters-real.ts +14 -2
  19. package/engine/cli/computer-adapters.ts +2 -0
  20. package/engine/cli/computer-checks.ts +51 -3
  21. package/engine/cli/doctor.ts +14 -2
  22. package/engine/cli/init.ts +17 -1
  23. package/engine/cli/luca-skills.ts +159 -0
  24. package/engine/cli/run-modes.ts +29 -5
  25. package/engine/cli/setup.ts +5 -48
  26. package/engine/cli/upgrade-command.ts +1 -0
  27. package/engine/cli/upgrade.ts +23 -6
  28. package/engine/core/already-done.ts +3 -1
  29. package/engine/core/decide.ts +1 -0
  30. package/engine/core/execute-stuck.ts +15 -1
  31. package/engine/core/execute.ts +47 -5
  32. package/engine/core/left-out.ts +120 -0
  33. package/engine/core/not-in-run.ts +7 -3
  34. package/engine/core/pull-request-text.ts +4 -1
  35. package/engine/intake/intake-checks.ts +115 -16
  36. package/engine/intake/intake-schemas.ts +37 -2
  37. package/engine/journal/journal-record.ts +31 -2
  38. package/engine/journal/replay.ts +4 -0
  39. package/engine/tracker/github-tracker.ts +196 -37
  40. package/engine/tracker/in-memory-tracker.ts +5 -2
  41. package/engine/tracker/tracker.ts +94 -2
  42. package/package.json +2 -1
  43. package/skills/luca-unstick/SKILL.md +148 -0
  44. package/skills/luca-unstick/scripts/stuck-summary.ts +1005 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.5
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.4
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.3
4
12
 
5
13
  No changes in this release.
package/README.md CHANGED
@@ -38,14 +38,16 @@ This is Luca v14, in alpha. Old Luca (v13) is still on npm; see [Coming from v13
38
38
  5. Write a spec with `/to-spec`, then split it into tickets with `/to-tickets`.
39
39
  6. In a Paseo chat, type `/luca-run <spec number>`. The board shows the run in the side panel, and the run ends in one pull request.
40
40
 
41
+ If a ticket gets stuck, Luca comments on the spec and waits for your one-word reply. Type `/luca-unstick` in Claude Code or a Paseo chat, in the repo: it reads why, says what to do, and posts the reply once you say yes.
42
+
41
43
  ## Commands
42
44
 
43
45
  | Command | What it does |
44
46
  | ------- | ------------ |
45
- | `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, and the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`). It asks before turning on Paseo's plugins, and leaves skills you already have alone. `--skip-skills` skips the skills. Inside a git repo, it then runs `luca setup` for that repo. |
47
+ | `luca init` | Sets up this computer, once (safe to run again): MuninnDB and its Claude Code entry, the board in Paseo with its engine and Bun paths, the planning skills (`to-spec`, `to-tickets`, `setup-matt-pocock-skills`, `grilling`, `domain-modeling`), and Luca's own `/luca-unstick` skill. It asks before turning on Paseo's plugins, and leaves skills you already have alone (it only replaces its own `/luca-unstick`). `--skip-skills` skips the planning skills. Inside a git repo, it then runs `luca setup` for that repo. |
46
48
  | `luca setup` | Gets the repo in this folder ready: the labels a run needs (and the `release:*` labels when the repo uses changesets), `.luca/config.json` (a new one gets the repo's GitHub name as its vault; an existing vault is kept), and a check of GitHub sub-issues and dependencies. `--base <branch>` names the base branch. |
47
49
  | `luca doctor` | Checks this computer, this repo when you're in one, and what v13 left behind. Each problem comes with its exact fix. `luca doctor --fix` fixes what's safe without asking: it never deletes (v13 files go to a dated backup folder) and never commits, and it lists the repo files to commit. |
48
- | `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then it reloads the board and says when to run `/reload-skills`. |
50
+ | `luca upgrade` | Moves to the newest Luca on your channel: `alpha` stays on `alpha`, and it never goes back to v13. It refuses while any run is going (stuck runs and limit waits count) and lists them. A run that crashed, with no engine running it, doesn't stop it: upgrade says how to resume it on the new version. Then it reloads the board, copies the new `/luca-unstick` skill, and says when to run `/reload-skills`. |
49
51
  | `luca upgrade --to <version>` | Installs that exact version, an older one too, to go back when a version breaks. |
50
52
 
51
53
  `init`, `setup`, and `upgrade` end with their part of `luca doctor`'s checks.
@@ -88,6 +90,7 @@ This is the one package that goes to npm. It's TypeScript source with no build a
88
90
  | ------ | ---------- |
89
91
  | `engine/` | The engine's source, from [`packages/engine/src`](../engine/src). Both bins point into it. |
90
92
  | `board/` | The board plugin's folder, from [`packages/board`](../board): `paseo-plugin.json`, its entry points, its client, server, and shared code, and its own `package.json`, so Paseo can install it as a folder source. |
93
+ | `skills/` | Luca's own Claude Code skills, such as `luca-unstick`, from [`packages/engine/skills`](../engine/skills). `luca init` and `luca upgrade` copy them to `~/.claude/skills`. |
91
94
  | `LICENSE` | The repo's license. |
92
95
 
93
96
  Test files and test helpers stay out. The engine and the board stay private workspace packages, and this package lists every package they import at runtime as its own dependency.
@@ -6,12 +6,7 @@ import {
6
6
  useWorkspace,
7
7
  type PluginWorkspacePanelProps,
8
8
  } from '@getpaseo/plugin/client'
9
- import {
10
- Icon,
11
- ScrollView,
12
- copyText,
13
- useToast,
14
- } from '@getpaseo/plugin/client/react-native'
9
+ import { Icon, ScrollView } from '@getpaseo/plugin/client/react-native'
15
10
  import { ExternalLink } from '@getpaseo/plugin/client/ui'
16
11
  import { useQuery } from '@tanstack/react-query'
17
12
  import { Pressable, StyleSheet, Text, View } from 'react-native'
@@ -31,6 +26,7 @@ import {
31
26
  usageLineWaitText,
32
27
  useNow,
33
28
  } from './board-look'
29
+ import { StuckActions } from './stuck-actions'
34
30
 
35
31
  import { boardReadRpc } from '../shared/board-rpc'
36
32
  import {
@@ -54,6 +50,7 @@ import {
54
50
  type TicketCard,
55
51
  type TicketStage,
56
52
  } from '../shared/board-state'
53
+ import { postedText, type PostedReply } from '../shared/reply-actions'
57
54
 
58
55
  /**
59
56
  * The side panel: one run of this workspace, top to bottom. Plan usage, a
@@ -118,6 +115,9 @@ const LENS_STAGES: { stage: LensState; title: string; icon: string }[] = [
118
115
  /** A card or stage key. */
119
116
  type Key = string
120
117
 
118
+ /** The key of the fold for tickets left out for a person (#499). */
119
+ const FOR_A_PERSON: Key = 'for_a_person'
120
+
121
121
  type DotKind = 'done' | 'current' | 'todo' | 'skip' | 'stopped'
122
122
 
123
123
  const makeStyles = ({
@@ -251,21 +251,6 @@ const makeStyles = ({
251
251
  fontSize: 12,
252
252
  fontWeight: '700',
253
253
  },
254
- replyRow: {
255
- flexDirection: 'row',
256
- flexWrap: 'wrap',
257
- alignItems: 'center',
258
- gap: 5,
259
- },
260
- reply: {
261
- color: colors.foreground,
262
- backgroundColor: colors.surface2,
263
- borderRadius: 4,
264
- paddingHorizontal: 6,
265
- paddingVertical: 2,
266
- fontSize: 12,
267
- fontFamily: MONO,
268
- },
269
254
  rule: { height: 1, backgroundColor: colors.border, marginVertical: 4 },
270
255
  reviewTitle: {
271
256
  color: colors.foreground,
@@ -436,56 +421,21 @@ const StepLine = ({
436
421
  )
437
422
  }
438
423
 
439
- /** The exact replies; tap one to copy it. */
440
- const Replies = ({
441
- replies,
442
- spec_number,
443
- styles,
444
- }: {
445
- replies: string[]
446
- spec_number: number | null
447
- styles: Styles
448
- }) => {
449
- const toast = useToast()
450
- const copy = async ({ reply }: { reply: string }) => {
451
- try {
452
- await copyText(reply)
453
- toast.show(`Copied "${reply}"`, { variant: 'success' })
454
- } catch {
455
- toast.error('Could not copy. Select the text and use Copy.')
456
- }
457
- }
458
- return (
459
- <View style={styles.replyRow}>
460
- <Text style={styles.label}>
461
- Reply on{' '}
462
- {spec_number === null
463
- ? 'the spec issue'
464
- : `spec issue #${spec_number}`}{' '}
465
- with
466
- </Text>
467
- {replies.map((reply) => (
468
- <Pressable
469
- key={reply}
470
- accessibilityRole="button"
471
- accessibilityLabel={`Copy the reply ${reply}`}
472
- onPress={() => void copy({ reply })}
473
- >
474
- <Text style={styles.reply} selectable>
475
- {reply}
476
- </Text>
477
- </Pressable>
478
- ))}
479
- </View>
480
- )
481
- }
482
-
483
424
  const NeedsYouCard = ({
484
425
  item,
426
+ run_id,
485
427
  spec_number,
428
+ posted,
429
+ chat_agent_id,
486
430
  theme,
487
431
  styles,
488
- }: { item: NeedsYou; spec_number: number | null } & Look) => (
432
+ }: {
433
+ item: NeedsYou
434
+ run_id: string
435
+ spec_number: number | null
436
+ posted: PostedReply[]
437
+ chat_agent_id: string | null
438
+ } & Look) => (
489
439
  <View style={[styles.card, styles.cardStuck]}>
490
440
  <View style={styles.cardTop}>
491
441
  <Icon
@@ -515,10 +465,13 @@ const NeedsYouCard = ({
515
465
  ))}
516
466
  </>
517
467
  ) : null}
518
- <Replies
519
- replies={item.replies}
468
+ <StuckActions
469
+ run_id={run_id}
470
+ item={item}
520
471
  spec_number={spec_number}
521
- styles={styles}
472
+ posted={posted}
473
+ chat_agent_id={chat_agent_id}
474
+ theme={theme}
522
475
  />
523
476
  </View>
524
477
  )
@@ -1063,11 +1016,12 @@ export const BoardPanel = ({
1063
1016
  )
1064
1017
  const [folded, setFolded] = useState<ReadonlySet<Key>>(
1065
1018
  () =>
1066
- new Set(
1067
- TICKET_STAGES.filter((entry) => entry.folded).map(
1019
+ new Set<Key>([
1020
+ ...TICKET_STAGES.filter((entry) => entry.folded).map(
1068
1021
  (entry) => entry.stage
1069
- )
1070
- )
1022
+ ),
1023
+ FOR_A_PERSON,
1024
+ ])
1071
1025
  )
1072
1026
  const look = { theme, styles }
1073
1027
  const state = board.data?.selected ?? null
@@ -1092,6 +1046,15 @@ export const BoardPanel = ({
1092
1046
 
1093
1047
  const { run } = state
1094
1048
  const runs = board.data?.runs ?? []
1049
+ const posted = board.data?.posted ?? []
1050
+ const chat_agent_id = board.data?.chat_agent_id ?? null
1051
+ // Replies the board posted whose stuck item is gone: say what came of them.
1052
+ const answered = posted.filter(
1053
+ (entry) =>
1054
+ !state.needs_you.some(
1055
+ (item) => item.key === entry.key && item.since === entry.since
1056
+ )
1057
+ )
1095
1058
  const card = (ticket: TicketCard) => {
1096
1059
  const key = `ticket-${ticket.number}`
1097
1060
  return (
@@ -1253,14 +1216,29 @@ export const BoardPanel = ({
1253
1216
  <View style={styles.stageBody}>
1254
1217
  {state.needs_you.map((item) => (
1255
1218
  <NeedsYouCard
1256
- key={item.key}
1219
+ key={`${item.key}-${item.since}`}
1257
1220
  item={item}
1221
+ run_id={run.run_id}
1258
1222
  spec_number={run.spec_number}
1223
+ posted={posted}
1224
+ chat_agent_id={chat_agent_id}
1259
1225
  {...look}
1260
1226
  />
1261
1227
  ))}
1262
1228
  </View>
1263
1229
  ) : null}
1230
+ {answered.length > 0 ? (
1231
+ <View style={styles.stageBody}>
1232
+ {answered.map((entry) => (
1233
+ <Text
1234
+ key={`${entry.key}-${entry.since}`}
1235
+ style={styles.muted}
1236
+ >
1237
+ {postedText({ posted: entry })}
1238
+ </Text>
1239
+ ))}
1240
+ </View>
1241
+ ) : null}
1264
1242
  </Section>
1265
1243
 
1266
1244
  {TICKET_STAGES.map((entry) => {
@@ -1293,6 +1271,39 @@ export const BoardPanel = ({
1293
1271
  )
1294
1272
  })}
1295
1273
 
1274
+ {state.for_a_person.length > 0 ? (
1275
+ <Section styles={styles}>
1276
+ <StageHeader
1277
+ title="For a person"
1278
+ hint="left out of this run: a person does these, or they wait on one"
1279
+ icon="User"
1280
+ count={state.for_a_person.length}
1281
+ color={theme.colors.foregroundMuted}
1282
+ folded={folded.has(FOR_A_PERSON)}
1283
+ onPress={() =>
1284
+ setFolded((set) =>
1285
+ toggled({ set, key: FOR_A_PERSON })
1286
+ )
1287
+ }
1288
+ {...look}
1289
+ />
1290
+ {folded.has(FOR_A_PERSON) ? null : (
1291
+ <View style={styles.stageBody}>
1292
+ {state.for_a_person.map((ticket) => (
1293
+ <Text
1294
+ key={ticket.number}
1295
+ style={styles.muted}
1296
+ numberOfLines={2}
1297
+ >
1298
+ #{ticket.number} {ticket.title} ·{' '}
1299
+ {ticket.why}
1300
+ </Text>
1301
+ ))}
1302
+ </View>
1303
+ )}
1304
+ </Section>
1305
+ ) : null}
1306
+
1296
1307
  {showsFinalReview({ state }) ? (
1297
1308
  <>
1298
1309
  <View style={styles.rule} />
@@ -0,0 +1,269 @@
1
+ import { useMemo, useState } from 'react'
2
+
3
+ import type { PluginTheme } from '@getpaseo/plugin'
4
+ import { usePaseo, useRpc } from '@getpaseo/plugin/client'
5
+ import { copyText, useToast } from '@getpaseo/plugin/client/react-native'
6
+ import { Pressable, StyleSheet, Text, View } from 'react-native'
7
+
8
+ import { MONO } from './board-look'
9
+
10
+ import { replyPostRpc } from '../shared/board-rpc'
11
+ import {
12
+ confirmReplyText,
13
+ postedFor,
14
+ postedText,
15
+ replyButtons,
16
+ unstickCommand,
17
+ type PostedReply,
18
+ type ReplyButton,
19
+ } from '../shared/reply-actions'
20
+
21
+ /**
22
+ * The actions on stuck work (#503), in the side panel and in the chat's
23
+ * stuck rows: a button per reply the engine takes, which asks to confirm
24
+ * and then posts the reply on the spec issue through `reply.post`, and
25
+ * "Help me", which sends `/luca-unstick` to a chat's agent.
26
+ */
27
+
28
+ const makeStyles = ({ theme }: { theme: PluginTheme }) => {
29
+ const { colors } = theme
30
+ return StyleSheet.create({
31
+ box: { gap: 6 },
32
+ row: {
33
+ flexDirection: 'row',
34
+ flexWrap: 'wrap',
35
+ alignItems: 'center',
36
+ gap: 6,
37
+ },
38
+ label: {
39
+ color: colors.foregroundMuted,
40
+ fontSize: 12,
41
+ fontWeight: '700',
42
+ },
43
+ text: { color: colors.foreground, fontSize: 12 },
44
+ muted: { color: colors.foregroundMuted, fontSize: 12 },
45
+ danger: { color: colors.statusDanger, fontSize: 12 },
46
+ success: { color: colors.statusSuccess, fontSize: 12 },
47
+ button: {
48
+ borderWidth: 1,
49
+ borderColor: colors.border,
50
+ backgroundColor: colors.surface2,
51
+ borderRadius: 6,
52
+ paddingHorizontal: 9,
53
+ paddingVertical: 4,
54
+ },
55
+ buttonText: {
56
+ color: colors.foreground,
57
+ fontSize: 12,
58
+ fontFamily: MONO,
59
+ },
60
+ primary: {
61
+ borderRadius: 6,
62
+ paddingHorizontal: 10,
63
+ paddingVertical: 4,
64
+ backgroundColor: colors.accent,
65
+ },
66
+ primaryText: {
67
+ color: colors.accentForeground,
68
+ fontSize: 12,
69
+ fontWeight: '700',
70
+ },
71
+ posted: { borderColor: colors.statusSuccess },
72
+ postedText: { color: colors.statusSuccess },
73
+ locked: { opacity: 0.45 },
74
+ })
75
+ }
76
+
77
+ type Styles = ReturnType<typeof makeStyles>
78
+
79
+ /** What a reply button reads, for its state. */
80
+ const buttonLabel = ({ button }: { button: ReplyButton }): string => {
81
+ switch (button.state) {
82
+ case 'sending':
83
+ return `${button.reply} · posting…`
84
+ case 'posted':
85
+ return `${button.reply} · posted`
86
+ default:
87
+ return button.reply
88
+ }
89
+ }
90
+
91
+ const ReplyButtonView = ({
92
+ button,
93
+ onPress,
94
+ styles,
95
+ }: {
96
+ button: ReplyButton
97
+ onPress: () => void
98
+ styles: Styles
99
+ }) => (
100
+ <Pressable
101
+ accessibilityRole="button"
102
+ accessibilityLabel={`Post the reply ${button.reply}`}
103
+ accessibilityState={{ disabled: button.state !== 'ready' }}
104
+ disabled={button.state !== 'ready'}
105
+ onPress={onPress}
106
+ style={[
107
+ styles.button,
108
+ button.state === 'posted' ? styles.posted : null,
109
+ button.state === 'locked' ? styles.locked : null,
110
+ ]}
111
+ >
112
+ <Text
113
+ style={[
114
+ styles.buttonText,
115
+ button.state === 'posted' ? styles.postedText : null,
116
+ ]}
117
+ >
118
+ {buttonLabel({ button })}
119
+ </Text>
120
+ </Pressable>
121
+ )
122
+
123
+ /**
124
+ * A stuck item's reply buttons and "Help me". `posted` is what the board
125
+ * says it posted (from `board.read`); a post made here shows at once too.
126
+ * `chat_agent_id` is the chat "Help me" sends `/luca-unstick` to; without
127
+ * one, the command is copied instead.
128
+ */
129
+ export const StuckActions = ({
130
+ run_id,
131
+ item,
132
+ spec_number,
133
+ posted,
134
+ chat_agent_id,
135
+ theme,
136
+ }: {
137
+ run_id: string
138
+ item: { key: string; ticket: number | null; since: string }
139
+ spec_number: number | null
140
+ posted: PostedReply[]
141
+ chat_agent_id: string | null
142
+ theme: PluginTheme
143
+ }) => {
144
+ const styles = useMemo(() => makeStyles({ theme }), [theme])
145
+ const postReply = useRpc(replyPostRpc)
146
+ const paseo = usePaseo()
147
+ const toast = useToast()
148
+ const [confirming, setConfirming] = useState<string | null>(null)
149
+ const [sending, setSending] = useState<string | null>(null)
150
+ const [mine, setMine] = useState<PostedReply | null>(null)
151
+ const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null)
152
+
153
+ const known = [...posted, ...(mine ? [mine] : [])]
154
+ const buttons = replyButtons({ item, posted: known, sending })
155
+ const done = postedFor({ item, posted: known })
156
+ const command = unstickCommand({ run_id, ticket: item.ticket })
157
+
158
+ const post = async ({ reply }: { reply: string }) => {
159
+ setConfirming(null)
160
+ setSending(reply)
161
+ setNote(null)
162
+ try {
163
+ const output = await postReply({ run_id, key: item.key, reply })
164
+ if (output.posted) setMine(output.posted)
165
+ setNote({ ok: output.ok, text: output.message })
166
+ } catch (error) {
167
+ setNote({
168
+ ok: false,
169
+ text: `Couldn't post \`${reply}\`: ${String(error)}`,
170
+ })
171
+ } finally {
172
+ setSending(null)
173
+ }
174
+ }
175
+
176
+ const helpMe = async () => {
177
+ if (chat_agent_id !== null) {
178
+ try {
179
+ await paseo.agents.ref(chat_agent_id).send(command)
180
+ toast.show(`Sent ${command} to the run's chat.`, {
181
+ variant: 'success',
182
+ })
183
+ return
184
+ } catch {
185
+ // The chat may be gone: copy it instead.
186
+ }
187
+ }
188
+ try {
189
+ await copyText(command)
190
+ toast.show(`Copied ${command}. Paste it in a chat.`, {
191
+ variant: 'success',
192
+ })
193
+ } catch {
194
+ toast.error(`Could not copy. Type ${command} in a chat.`)
195
+ }
196
+ }
197
+
198
+ return (
199
+ <View style={styles.box}>
200
+ <Text style={styles.label}>
201
+ Reply on{' '}
202
+ {spec_number === null
203
+ ? 'the spec issue'
204
+ : `spec issue #${spec_number}`}
205
+ </Text>
206
+ <View style={styles.row}>
207
+ {buttons.map((button) => (
208
+ <ReplyButtonView
209
+ key={button.reply}
210
+ button={button}
211
+ onPress={() => {
212
+ setNote(null)
213
+ setConfirming(button.reply)
214
+ }}
215
+ styles={styles}
216
+ />
217
+ ))}
218
+ </View>
219
+ {confirming !== null && done === null && sending === null ? (
220
+ <View style={styles.row}>
221
+ <Text style={styles.text}>
222
+ {confirmReplyText({ reply: confirming, spec_number })}
223
+ </Text>
224
+ <Pressable
225
+ accessibilityRole="button"
226
+ accessibilityLabel={`Yes, post ${confirming}`}
227
+ onPress={() => void post({ reply: confirming })}
228
+ style={styles.primary}
229
+ >
230
+ <Text style={styles.primaryText}>Post</Text>
231
+ </Pressable>
232
+ <Pressable
233
+ accessibilityRole="button"
234
+ accessibilityLabel="Cancel"
235
+ onPress={() => setConfirming(null)}
236
+ style={styles.button}
237
+ >
238
+ <Text style={styles.buttonText}>Cancel</Text>
239
+ </Pressable>
240
+ </View>
241
+ ) : null}
242
+ {note !== null && !(note.ok && done !== null) ? (
243
+ <Text style={note.ok ? styles.success : styles.danger}>
244
+ {note.text}
245
+ </Text>
246
+ ) : null}
247
+ {done !== null ? (
248
+ <Text style={styles.success}>
249
+ {postedText({ posted: done })}
250
+ </Text>
251
+ ) : null}
252
+ <View style={styles.row}>
253
+ <Pressable
254
+ accessibilityRole="button"
255
+ accessibilityLabel={`Help me: ${command}`}
256
+ onPress={() => void helpMe()}
257
+ style={styles.button}
258
+ >
259
+ <Text style={styles.buttonText}>Help me</Text>
260
+ </Pressable>
261
+ <Text style={styles.muted}>
262
+ {chat_agent_id === null
263
+ ? `copies ${command}`
264
+ : `sends ${command} to the run's chat`}
265
+ </Text>
266
+ </View>
267
+ </View>
268
+ )
269
+ }
@@ -20,6 +20,7 @@ import {
20
20
  usageColor,
21
21
  useNow,
22
22
  } from './board-look'
23
+ import { StuckActions } from './stuck-actions'
23
24
 
24
25
  import type { EventRow, LimitRow, RunRow, StuckRow } from '../shared/board-rows'
25
26
  import { currentStepText, USAGE_LABEL } from '../shared/board-state'
@@ -294,9 +295,14 @@ export const EventRowLine = ({
294
295
  )
295
296
  }
296
297
 
297
- /** Stuck work, with the exact replies; resolved in place. */
298
+ /**
299
+ * Stuck work, with a button per reply and "Help me", which sends
300
+ * `/luca-unstick` to this chat (#503); resolved in place. A row added
301
+ * before the buttons shows the replies as text.
302
+ */
298
303
  export const StuckRowCard = ({
299
304
  item,
305
+ agentId,
300
306
  theme,
301
307
  layout,
302
308
  }: PluginTimelineItemProps<StuckRow>) => {
@@ -333,20 +339,41 @@ export const StuckRowCard = ({
333
339
  · {line}
334
340
  </Text>
335
341
  ))}
336
- <View style={styles.replyRow}>
337
- <Text style={styles.muted}>
338
- Reply with a comment on{' '}
339
- {row.spec_number === null
340
- ? 'the spec issue'
341
- : `spec issue #${row.spec_number}`}
342
- :
343
- </Text>
344
- {row.replies.map((reply) => (
345
- <Text key={reply} style={styles.reply} selectable>
346
- {reply}
342
+ {row.run_id !== null &&
343
+ row.key !== null &&
344
+ row.since !== null ? (
345
+ <StuckActions
346
+ run_id={row.run_id}
347
+ item={{
348
+ key: row.key,
349
+ ticket: row.ticket,
350
+ since: row.since,
351
+ }}
352
+ spec_number={row.spec_number}
353
+ posted={[]}
354
+ chat_agent_id={agentId}
355
+ theme={theme}
356
+ />
357
+ ) : (
358
+ <View style={styles.replyRow}>
359
+ <Text style={styles.muted}>
360
+ Reply with a comment on{' '}
361
+ {row.spec_number === null
362
+ ? 'the spec issue'
363
+ : `spec issue #${row.spec_number}`}
364
+ :
347
365
  </Text>
348
- ))}
349
- </View>
366
+ {row.replies.map((reply) => (
367
+ <Text
368
+ key={reply}
369
+ style={styles.reply}
370
+ selectable
371
+ >
372
+ {reply}
373
+ </Text>
374
+ ))}
375
+ </View>
376
+ )}
350
377
  </>
351
378
  ) : (
352
379
  <Text style={styles.muted}>{row.resolution}</Text>
@@ -23,6 +23,7 @@ import {
23
23
  boardReadRpc,
24
24
  boardVersionRpc,
25
25
  engineEventRpc,
26
+ replyPostRpc,
26
27
  runStartRpc,
27
28
  } from './shared/board-rpc'
28
29
  import { PLUGIN_ID } from './shared/board-state'
@@ -40,7 +41,8 @@ const log = (message: string) => console.error(`[${PLUGIN_ID}] ${message}`)
40
41
  * The board plugin's daemon side: the engine settings (their usage lines
41
42
  * kept in a file for the engine), `run.start` (launch a
42
43
  * run from `/luca-run`), `engine.event` (the engine's journal records in),
43
- * and `board.read` (the side panel's poll). On start, and every 15 s after,
44
+ * `board.read` (the side panel's poll), and `reply.post` (a reply button on
45
+ * stuck work posts its word on the spec, #503). On start, and every 15 s after,
44
46
  * it checks for runs whose engine is gone and restarts the ones that can go
45
47
  * on (#375). The logic lives in `createBoardServer`; this entry only wires it
46
48
  * to Paseo.
@@ -130,6 +132,10 @@ export default function contribute(server: PluginServerContext) {
130
132
  connect({ context })
131
133
  return board.readBoard(input)
132
134
  })
135
+ server.handle(replyPostRpc, (input, context) => {
136
+ connect({ context })
137
+ return board.postReply(input)
138
+ })
133
139
  // Fixed as loaded: after an upgrade it differs until a reload.
134
140
  server.handle(boardVersionRpc, () => ({ version: LUCA_VERSION }))
135
141
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.3",
3
+ "version": "14.0.0-alpha.5",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }