@zhuk/spego 0.28.0 → 0.30.0

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 (73) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +19 -3
  3. package/dist/artifacts/storage.js +6 -1
  4. package/dist/artifacts/storage.js.map +1 -1
  5. package/dist/cli/commands/board.js +13 -3
  6. package/dist/cli/commands/board.js.map +1 -1
  7. package/dist/cli/commands/skills.js +2 -2
  8. package/dist/cli/commands/skills.js.map +1 -1
  9. package/dist/cli/commands/workspace.js +1 -1
  10. package/dist/cli/render.d.ts +1 -1
  11. package/dist/cli/render.js +1 -1
  12. package/dist/command-meta/registry.js +2 -1
  13. package/dist/command-meta/registry.js.map +1 -1
  14. package/dist/delivery/fresh-snapshot.d.ts +15 -0
  15. package/dist/delivery/fresh-snapshot.js +65 -0
  16. package/dist/delivery/fresh-snapshot.js.map +1 -0
  17. package/dist/delivery/load.d.ts +6 -2
  18. package/dist/delivery/load.js +34 -25
  19. package/dist/delivery/load.js.map +1 -1
  20. package/dist/delivery/mirror.d.ts +3 -0
  21. package/dist/delivery/mirror.js +6 -1
  22. package/dist/delivery/mirror.js.map +1 -1
  23. package/dist/generator/agents.d.ts +7 -0
  24. package/dist/generator/agents.js +164 -0
  25. package/dist/generator/agents.js.map +1 -0
  26. package/dist/generator/index.d.ts +1 -0
  27. package/dist/generator/index.js +17 -3
  28. package/dist/generator/index.js.map +1 -1
  29. package/dist/generator/templates.d.ts +12 -0
  30. package/dist/generator/templates.js +132 -0
  31. package/dist/generator/templates.js.map +1 -0
  32. package/dist/workspace/config.js +2 -2
  33. package/dist/workspace/init.js +1 -1
  34. package/docs/agent-targets.md +18 -5
  35. package/docs/cli.md +1 -1
  36. package/docs/delivery-board.md +10 -1
  37. package/docs/workflows.md +2 -2
  38. package/package.json +4 -1
  39. package/plugins/tern-spego/README.md +50 -0
  40. package/plugins/tern-spego/host.luau +1101 -0
  41. package/plugins/tern-spego/plugin.toml +12 -0
  42. package/skills/spego-board/SKILL.md +36 -0
  43. package/skills/spego-brainstorm-deep/SKILL.md +63 -0
  44. package/skills/spego-brainstorm-party/SKILL.md +68 -0
  45. package/skills/spego-change-brainstorm/SKILL.md +61 -0
  46. package/skills/spego-change-retro/SKILL.md +55 -0
  47. package/skills/spego-change-review/SKILL.md +62 -0
  48. package/skills/spego-change-verify-report/SKILL.md +52 -0
  49. package/skills/spego-create/SKILL.md +53 -0
  50. package/skills/spego-delete/SKILL.md +31 -0
  51. package/skills/spego-editorial-prose/SKILL.md +50 -0
  52. package/skills/spego-editorial-structure/SKILL.md +53 -0
  53. package/skills/spego-elicit/SKILL.md +93 -0
  54. package/skills/spego-epics/SKILL.md +29 -0
  55. package/skills/spego-groom/SKILL.md +58 -0
  56. package/skills/spego-help/SKILL.md +76 -0
  57. package/skills/spego-init/SKILL.md +31 -0
  58. package/skills/spego-list/SKILL.md +32 -0
  59. package/skills/spego-read/SKILL.md +35 -0
  60. package/skills/spego-record/SKILL.md +32 -0
  61. package/skills/spego-review-adversarial/SKILL.md +66 -0
  62. package/skills/spego-review-edge-cases/SKILL.md +57 -0
  63. package/skills/spego-skills/SKILL.md +29 -0
  64. package/skills/spego-sprints/SKILL.md +29 -0
  65. package/skills/spego-status/SKILL.md +29 -0
  66. package/skills/spego-sync/SKILL.md +29 -0
  67. package/skills/spego-tasks/SKILL.md +30 -0
  68. package/skills/spego-update/SKILL.md +53 -0
  69. package/skills/spego-view/SKILL.md +33 -0
  70. package/skills/spego-workflows/SKILL.md +29 -0
  71. package/dist/generator/claude.d.ts +0 -7
  72. package/dist/generator/claude.js +0 -245
  73. package/dist/generator/claude.js.map +0 -1
@@ -0,0 +1,1101 @@
1
+ -- Host half of the Spego Tern plugin: a read-only delivery board block.
2
+
3
+ local STREAM_CAP = 16 * 1024 * 1024
4
+
5
+ type State = {
6
+ pane: number,
7
+ cx: any,
8
+ root: string?,
9
+ rootError: string?,
10
+ board: any?,
11
+ loadError: string?,
12
+ stale: boolean,
13
+ loading: boolean,
14
+ boardGen: number,
15
+ boardPending: boolean,
16
+ taskGen: number,
17
+ tasks: { any }?,
18
+ taskError: string?,
19
+ taskChangeId: string?,
20
+ selectedChangeId: string?,
21
+ selectedSection: string?,
22
+ detailsOpen: boolean,
23
+ showArchived: boolean,
24
+ showClosed: boolean,
25
+ }
26
+
27
+ local panes: { [number]: State } = {}
28
+
29
+ local function isAbsolute(path: string?): boolean
30
+ if path == nil then
31
+ return false
32
+ end
33
+ return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil
34
+ end
35
+
36
+ local function keyed(node: any, id: string): any
37
+ if node.p == nil then
38
+ node.p = {}
39
+ end
40
+ node.p.key = id
41
+ return node
42
+ end
43
+
44
+ local function displayStatus(status: any): string
45
+ if status == "completed" then
46
+ return "archived"
47
+ end
48
+ return tostring(status)
49
+ end
50
+
51
+ -- Legend order, icons and chip tones mirror src/cli/commands/board.ts.
52
+ local STATUS_LEGEND_ORDER = { "backlog", "in-progress", "done", "completed", "blocked", "paused", "unknown" }
53
+ local STATUS_ICONS = {
54
+ backlog = "○",
55
+ ["in-progress"] = "▶",
56
+ done = "✓",
57
+ completed = "🗑︎",
58
+ blocked = "×",
59
+ paused = "■",
60
+ unknown = "?",
61
+ }
62
+ local STATUS_LABELS = {
63
+ backlog = "backlog",
64
+ ["in-progress"] = "in-progress",
65
+ done = "done",
66
+ completed = "archived",
67
+ blocked = "blocked",
68
+ paused = "paused",
69
+ unknown = "unknown",
70
+ }
71
+ local STATUS_STYLE = {
72
+ backlog = "muted",
73
+ ["in-progress"] = "pending",
74
+ done = "success",
75
+ completed = "muted",
76
+ blocked = "error",
77
+ paused = "muted",
78
+ unknown = nil,
79
+ }
80
+ local PLAN_STYLE = { planned = "accent", planning = "info" }
81
+ local PLAN_ICONS = { planned = "●", planning = "◐" }
82
+
83
+ local function isSatisfied(status: any): boolean
84
+ return status == "done" or status == "completed"
85
+ end
86
+
87
+ local function statusSpan(ch: any): any
88
+ local key = STATUS_STYLE[ch.status] ~= nil and ch.status or "unknown"
89
+ if ch.status == "unknown" then
90
+ key = "unknown"
91
+ end
92
+ return tern.ui.span(STATUS_ICONS[key], STATUS_STYLE[key])
93
+ end
94
+
95
+ local function planSpan(ch: any): any
96
+ if isSatisfied(ch.status) then
97
+ return tern.ui.span("—", "muted")
98
+ end
99
+ local st = ch.planState
100
+ if st == "planned" or st == "planning" then
101
+ return tern.ui.span(PLAN_ICONS[st], PLAN_STYLE[st])
102
+ end
103
+ return tern.ui.span("—", "muted")
104
+ end
105
+
106
+ local function formatHours(hours: number): string
107
+ return string.format("%g", math.floor(hours * 100 + 0.5) / 100)
108
+ end
109
+
110
+ local function tasksSpan(ch: any): any
111
+ if type(ch.taskCount) == "number" and type(ch.tasksDone) == "number" then
112
+ return tern.ui.span(string.format("%d/%d", ch.tasksDone, ch.taskCount), "num")
113
+ end
114
+ return tern.ui.span("—", "muted")
115
+ end
116
+
117
+ local function hoursSpan(ch: any): any
118
+ if type(ch.flowEstimate) ~= "number" then
119
+ return tern.ui.span("?", "muted")
120
+ end
121
+ local mark = ch.rung == "planned" and "†" or (ch.rung == "observed" and "*" or "")
122
+ return tern.ui.span(formatHours(ch.flowEstimate) .. mark, "num")
123
+ end
124
+
125
+ local function signalsSpan(ch: any): any
126
+ local parts: { string } = {}
127
+ if type(ch.blockers) == "table" and #ch.blockers > 0 then
128
+ table.insert(parts, #ch.blockers .. " blk")
129
+ end
130
+ if type(ch.gaps) == "table" and #ch.gaps > 0 then
131
+ table.insert(parts, #ch.gaps .. " gap")
132
+ end
133
+ if type(ch.missing) == "table" and #ch.missing > 0 then
134
+ table.insert(parts, #ch.missing .. " mis")
135
+ end
136
+ if #parts == 0 then
137
+ return tern.ui.span("—", "muted")
138
+ end
139
+ return tern.ui.span(table.concat(parts, " · "), nil)
140
+ end
141
+
142
+ local function warningText(w: any): string
143
+ if type(w) == "string" then
144
+ return w
145
+ end
146
+ local code = tostring(w.code or w.flag or "warning")
147
+ local msg = w.message or w.note or w.reason
148
+ if msg then
149
+ return code .. ": " .. tostring(msg)
150
+ end
151
+ return code
152
+ end
153
+
154
+ local function isAdapterUnavailable(board: any): boolean
155
+ for _, w in board.warnings or {} do
156
+ if type(w) == "table" and w.code == "adapter-unavailable" then
157
+ return true
158
+ end
159
+ if w == "adapter-unavailable" then
160
+ return true
161
+ end
162
+ end
163
+ return false
164
+ end
165
+
166
+ local function sprintFinished(sprint: any): boolean
167
+ if not (sprint.complete == true or sprint.status == "closed") then
168
+ return false
169
+ end
170
+ local changes = sprint.changes or {}
171
+ if #changes == 0 then
172
+ return false
173
+ end
174
+ for _, ch in changes do
175
+ if ch.status ~= "done" and ch.status ~= "completed" then
176
+ return false
177
+ end
178
+ end
179
+ return true
180
+ end
181
+
182
+ local function paneLive(pane: number): boolean
183
+ for _, info in tern.pane.list() do
184
+ if info.pane == pane then
185
+ return true
186
+ end
187
+ end
188
+ return false
189
+ end
190
+
191
+ -- Every place a change id appears, in payload order: {ch, section} rows.
192
+ local function occurrencesOf(board: any, id: string?): { any }
193
+ local found: { any } = {}
194
+ if board == nil or type(id) ~= "string" then
195
+ return found
196
+ end
197
+ for _, sprint in board.sprints do
198
+ for _, ch in sprint.changes or {} do
199
+ if ch.id == id then
200
+ table.insert(found, { ch = ch, section = "sprint:" .. tostring(sprint.slug) })
201
+ end
202
+ end
203
+ end
204
+ for _, ch in board.ungrouped do
205
+ if ch.id == id then
206
+ table.insert(found, { ch = ch, section = "ungrouped" })
207
+ end
208
+ end
209
+ return found
210
+ end
211
+
212
+ -- Keep the selection on the same change id across a refresh, preferring the
213
+ -- previously selected section, otherwise the first occurrence in payload order.
214
+ local function resolveSelection(st: State)
215
+ if st.board == nil or st.selectedChangeId == nil then
216
+ st.selectedChangeId = nil
217
+ st.selectedSection = nil
218
+ return
219
+ end
220
+ local found = occurrencesOf(st.board, st.selectedChangeId)
221
+ if #found == 0 then
222
+ st.selectedChangeId = nil
223
+ st.selectedSection = nil
224
+ return
225
+ end
226
+ local pick = found[1]
227
+ for _, occ in found do
228
+ if occ.section == st.selectedSection then
229
+ pick = occ
230
+ break
231
+ end
232
+ end
233
+ st.selectedSection = pick.section
234
+ end
235
+
236
+ local function selectedChange(st: State): any?
237
+ local found = occurrencesOf(st.board, st.selectedChangeId)
238
+ for _, occ in found do
239
+ if occ.section == st.selectedSection then
240
+ return occ.ch
241
+ end
242
+ end
243
+ return found[1] and found[1].ch or nil
244
+ end
245
+
246
+ -- Task summaries load only for an active, non-archived change with a known
247
+ -- readable, non-empty plan.
248
+ local function taskEligible(ch: any): boolean
249
+ if type(ch) ~= "table" or ch.archived == true then
250
+ return false
251
+ end
252
+ if ch.status == "done" or ch.status == "completed" then
253
+ return false
254
+ end
255
+ if type(ch.taskCount) ~= "number" or ch.taskCount <= 0 then
256
+ return false
257
+ end
258
+ if type(ch.slug) ~= "string" or ch.slug == "" then
259
+ return false
260
+ end
261
+ return true
262
+ end
263
+
264
+ -- Column layout mirrors the CLI board: hours only when the payload carries
265
+ -- estimates; low priorities hide signals, then hours, on narrow panes.
266
+ local function boardTable(changes: { any }, section: string, priced: boolean): any
267
+ local cols: { any } = {
268
+ { id = "id", head = "id" },
269
+ { id = "change", head = "change", grow = 1 },
270
+ { id = "status", head = "status" },
271
+ { id = "tasks", head = "tasks", align = "end" },
272
+ { id = "plan", head = "plan" },
273
+ }
274
+ if priced then
275
+ table.insert(cols, { id = "hours", head = "hours", align = "end", priority = -1 })
276
+ end
277
+ table.insert(cols, { id = "signals", head = "signals", priority = -2 })
278
+ local rows: { any } = {}
279
+ for _, ch in changes do
280
+ local cells: { any } = {
281
+ tern.ui.span(tostring(ch.id or "?"), "muted"),
282
+ tern.ui.span(tostring(ch.slug or "?"), nil),
283
+ statusSpan(ch),
284
+ tasksSpan(ch),
285
+ planSpan(ch),
286
+ }
287
+ if priced then
288
+ table.insert(cells, 6, hoursSpan(ch))
289
+ end
290
+ table.insert(cells, signalsSpan(ch))
291
+ table.insert(rows, cells)
292
+ end
293
+ return tern.ui.table(cols, rows)
294
+ end
295
+
296
+ -- Legend names every status the rendered rows use, in the CLI's order, then
297
+ -- the plan states in use on unsatisfied rows. nil when there is nothing.
298
+ local function legendNode(changes: { any }): any?
299
+ local statuses: { [string]: boolean } = {}
300
+ local plans: { [string]: boolean } = {}
301
+ for _, ch in changes do
302
+ local key = STATUS_LABELS[ch.status] and ch.status or "unknown"
303
+ statuses[key] = true
304
+ if not isSatisfied(ch.status) and (ch.planState == "planned" or ch.planState == "planning") then
305
+ plans[ch.planState] = true
306
+ end
307
+ end
308
+ local spans: { any } = {}
309
+ for _, status in STATUS_LEGEND_ORDER do
310
+ if statuses[status] then
311
+ table.insert(spans, tern.ui.span(STATUS_ICONS[status] .. " " .. STATUS_LABELS[status], "muted"))
312
+ table.insert(spans, " ")
313
+ end
314
+ end
315
+ for _, plan in { "planned", "planning" } do
316
+ if plans[plan] then
317
+ table.insert(spans, tern.ui.span(PLAN_ICONS[plan] .. " " .. plan, "muted"))
318
+ table.insert(spans, " ")
319
+ end
320
+ end
321
+ if #spans == 0 then
322
+ return nil
323
+ end
324
+ spans[#spans] = nil
325
+ return tern.ui.text(spans)
326
+ end
327
+
328
+ -- Sprint head: title strong, then status, dates and the sprint-scope signals
329
+ -- the CLI's sprintTitle carries.
330
+ local function sectionHeadSpans(sprint: any): { any }
331
+ local spans: { any } = { tern.ui.span(tostring(sprint.title or sprint.slug or "?"), "strong") }
332
+ local function add(text: string)
333
+ table.insert(spans, tern.ui.span(" · " .. text, "muted"))
334
+ end
335
+ add(displayStatus(sprint.status))
336
+ if type(sprint.startDate) == "string" and type(sprint.endDate) == "string" then
337
+ add(sprint.startDate .. " → " .. sprint.endDate)
338
+ end
339
+ if type(sprint.flowTotal) == "number" then
340
+ local unpriced = (sprint.unpricedPending or 0) > 0 and "+?" or ""
341
+ add(formatHours(sprint.flowTotal) .. unpriced .. "h")
342
+ end
343
+ if type(sprint.missing) == "table" and #sprint.missing > 0 then
344
+ add(#sprint.missing .. " mis")
345
+ end
346
+ return spans
347
+ end
348
+
349
+ local function sectionHead(title: string, detail: string?): any
350
+ local spans: { any } = { tern.ui.span(title, "strong") }
351
+ if detail then
352
+ table.insert(spans, tern.ui.span(" " .. detail, "muted"))
353
+ end
354
+ return spans
355
+ end
356
+
357
+ local function validateBoard(v: any): string?
358
+ if type(v) ~= "table" then
359
+ return "board payload is not an object"
360
+ end
361
+ if type(v.sprints) ~= "table" then
362
+ return "board payload has no sprints array"
363
+ end
364
+ if type(v.ungrouped) ~= "table" then
365
+ return "board payload has no ungrouped array"
366
+ end
367
+ for _, sprint in v.sprints do
368
+ if type(sprint) ~= "table" then
369
+ return "sprint row is not an object"
370
+ end
371
+ for _, ch in sprint.changes or {} do
372
+ if type(ch) ~= "table" or type(ch.id) ~= "string" then
373
+ return "sprint change row is malformed"
374
+ end
375
+ end
376
+ end
377
+ for _, ch in v.ungrouped do
378
+ if type(ch) ~= "table" or type(ch.id) ~= "string" then
379
+ return "ungrouped change row is malformed"
380
+ end
381
+ end
382
+ return nil
383
+ end
384
+
385
+ local function startLoad(st: State)
386
+ if st.root == nil or st.loading then
387
+ return
388
+ end
389
+ st.loading = true
390
+ st.boardGen += 1
391
+ st.taskGen += 1
392
+ st.tasks = nil
393
+ st.taskError = nil
394
+ st.taskChangeId = nil
395
+ local gen = st.boardGen
396
+ local pane = st.pane
397
+ local root = st.root
398
+ local argv = { "spego", "--json", "board", "--fresh" }
399
+ if st.showArchived then
400
+ table.insert(argv, "--archived")
401
+ end
402
+ table.insert(argv, "--cwd")
403
+ table.insert(argv, root)
404
+ local ok = pcall(tern.process.run, argv, { cwd = root, timeout_ms = 10000 }, function(result)
405
+ local cur = panes[pane]
406
+ if cur == nil or cur ~= st or cur.boardGen ~= gen or cur.root ~= root or not paneLive(pane) then
407
+ panes[pane] = nil
408
+ return
409
+ end
410
+ local err: string?
411
+ if result.timed_out then
412
+ err = "spego board timed out after 10s"
413
+ elseif result.status ~= 0 then
414
+ local detail = result.stderr:gsub("^%s+", ""):gsub("%s+$", "")
415
+ if detail == "" then
416
+ err = string.format("spego board exited with status %d", result.status)
417
+ else
418
+ err = detail
419
+ end
420
+ elseif #result.stdout >= STREAM_CAP or #result.stderr >= STREAM_CAP then
421
+ err = "spego board output reached the 16 MiB stream cap"
422
+ else
423
+ local decodeOk: boolean
424
+ local decoded: any
425
+ decodeOk, decoded, err = pcall(tern.json.decode, result.stdout)
426
+ if not decodeOk then
427
+ err = "spego board printed malformed JSON: " .. tostring(decoded)
428
+ decoded = nil
429
+ elseif err ~= nil then
430
+ err = "spego board printed malformed JSON: " .. tostring(err)
431
+ decoded = nil
432
+ else
433
+ err = validateBoard(decoded)
434
+ if err == nil then
435
+ cur.board = decoded
436
+ cur.loadError = nil
437
+ cur.stale = false
438
+ cur.tasks = nil
439
+ cur.taskError = nil
440
+ cur.taskChangeId = nil
441
+ end
442
+ end
443
+ end
444
+ if err ~= nil then
445
+ if cur.board ~= nil then
446
+ cur.stale = true
447
+ cur.loadError = err
448
+ else
449
+ cur.board = nil
450
+ cur.stale = false
451
+ cur.loadError = err
452
+ end
453
+ end
454
+ cur.loading = false
455
+ cur.boardGen += 1
456
+ local pending = cur.boardPending
457
+ cur.boardPending = false
458
+ if err == nil then
459
+ resolveSelection(cur)
460
+ if cur.detailsOpen then
461
+ startTasks(cur)
462
+ end
463
+ end
464
+ pcall(function()
465
+ cur.cx:render()
466
+ end)
467
+ if pending and cur.root ~= nil then
468
+ startLoad(cur)
469
+ end
470
+ end)
471
+ if not ok then
472
+ st.loading = false
473
+ st.boardGen += 1
474
+ if st.board ~= nil then
475
+ st.stale = true
476
+ else
477
+ st.board = nil
478
+ end
479
+ st.loadError = "could not start spego (is it on the daemon's PATH?)"
480
+ end
481
+ end
482
+
483
+ local function startTasks(st: State)
484
+ local ch = selectedChange(st)
485
+ if not st.detailsOpen or st.board == nil or ch == nil then
486
+ return
487
+ end
488
+ st.taskGen += 1
489
+ st.tasks = nil
490
+ st.taskError = nil
491
+ st.taskChangeId = nil
492
+ if type(ch.taskCount) ~= "number" then
493
+ return
494
+ end
495
+ if ch.taskCount == 0 then
496
+ -- Known-empty plan: empty task list without a process.
497
+ st.tasks = {}
498
+ st.taskChangeId = ch.id
499
+ return
500
+ end
501
+ if not taskEligible(ch) then
502
+ return
503
+ end
504
+ st.taskChangeId = ch.id
505
+ local slug = ch.slug
506
+ local gen = st.taskGen
507
+ local pane = st.pane
508
+ local root = st.root :: string
509
+ local changeId = ch.id :: string
510
+ local argv = { "spego", "--json", "tasks", slug, "--cwd", root }
511
+ local ok = pcall(tern.process.run, argv, { cwd = root, timeout_ms = 10000 }, function(result)
512
+ local cur = panes[pane]
513
+ if cur == nil or cur ~= st or cur.taskGen ~= gen or cur.root ~= root or not paneLive(pane) then
514
+ return
515
+ end
516
+ if cur.selectedChangeId ~= changeId then
517
+ return
518
+ end
519
+ local err: string?
520
+ local decoded: any
521
+ if result.timed_out then
522
+ err = "spego tasks timed out after 10s"
523
+ elseif result.status ~= 0 then
524
+ local detail = result.stderr:gsub("^%s+", ""):gsub("%s+$", "")
525
+ err = detail ~= "" and detail
526
+ or string.format("spego tasks exited with status %d", result.status)
527
+ elseif #result.stdout >= STREAM_CAP or #result.stderr >= STREAM_CAP then
528
+ err = "spego tasks output reached the 16 MiB stream cap"
529
+ else
530
+ local decodeOk
531
+ decodeOk, decoded = pcall(tern.json.decode, result.stdout)
532
+ if not decodeOk then
533
+ err = "spego tasks printed malformed JSON: " .. tostring(decoded)
534
+ decoded = nil
535
+ elseif type(decoded) ~= "table" then
536
+ err = "spego tasks payload is not an array"
537
+ decoded = nil
538
+ end
539
+ end
540
+ if err == nil then
541
+ cur.tasks = decoded
542
+ cur.taskError = nil
543
+ else
544
+ cur.tasks = nil
545
+ cur.taskError = err
546
+ end
547
+ pcall(function()
548
+ cur.cx:render()
549
+ end)
550
+ end)
551
+ if not ok then
552
+ st.tasks = nil
553
+ st.taskError = "could not start spego tasks (is it on the daemon's PATH?)"
554
+ end
555
+ end
556
+
557
+ local function detailRow(label: string, value: string, key: string): any
558
+ return keyed(tern.ui.text({
559
+ tern.ui.span(label .. ": ", "strong"),
560
+ tern.ui.span(value, "muted"),
561
+ }), key)
562
+ end
563
+
564
+ local function renderDetails(st: State): any?
565
+ local ch = selectedChange(st)
566
+ if ch == nil then
567
+ return nil
568
+ end
569
+ local children: { any } = {
570
+ keyed(tern.ui.text({
571
+ tern.ui.span("Details: ", "strong"),
572
+ tern.ui.span(tostring(ch.title or ch.id or "?"), nil),
573
+ }), "d-title"),
574
+ }
575
+ table.insert(children, detailRow("slug", tostring(ch.slug or "-"), "d-slug"))
576
+ table.insert(children, detailRow("status", displayStatus(ch.status), "d-status"))
577
+ if ch.group ~= nil then
578
+ table.insert(children, detailRow("track", tostring(ch.group), "d-track"))
579
+ end
580
+ if type(ch.planState) == "string" then
581
+ table.insert(children, detailRow("plan state", ch.planState, "d-plan"))
582
+ end
583
+ if type(ch.taskCount) == "number" and type(ch.tasksDone) == "number" then
584
+ table.insert(children, detailRow("progress", string.format("%d/%d tasks", ch.tasksDone, ch.taskCount), "d-progress"))
585
+ else
586
+ table.insert(children, keyed(tern.ui.text({ tern.ui.span("progress: unavailable", "muted") }), "d-progress"))
587
+ end
588
+ if type(ch.flowTotal) == "number" then
589
+ table.insert(children, detailRow("flow actuals", string.format("%.1fh", ch.flowTotal), "d-flow"))
590
+ end
591
+ if type(ch.actualsTotal) == "number" then
592
+ table.insert(children, detailRow("actuals total", string.format("%.1fh", ch.actualsTotal), "d-actuals"))
593
+ end
594
+ if type(ch.flowEstimate) == "number" then
595
+ table.insert(children, detailRow("flow estimate", string.format("%.1fh", ch.flowEstimate), "d-fest"))
596
+ end
597
+ if type(ch.humanEstimate) == "number" then
598
+ table.insert(children, detailRow("human estimate", string.format("%.1fh", ch.humanEstimate), "d-hest"))
599
+ end
600
+ if type(ch.blockers) == "table" and #ch.blockers > 0 then
601
+ table.insert(children, detailRow("blockers", table.concat(ch.blockers, ", "), "d-blockers"))
602
+ end
603
+ if type(ch.missing) == "table" and #ch.missing > 0 then
604
+ table.insert(children, detailRow("missing artifacts", table.concat(ch.missing, ", "), "d-missing"))
605
+ end
606
+ if type(ch.gaps) == "table" and #ch.gaps > 0 then
607
+ local gapChildren: { any } = {}
608
+ for i, gap in ch.gaps do
609
+ table.insert(
610
+ gapChildren,
611
+ keyed(tern.ui.text({ tern.ui.span("- " .. tostring(gap), nil) }), "d-gap" .. i)
612
+ )
613
+ end
614
+ table.insert(children, keyed(tern.ui.section(sectionHead("Gaps"), gapChildren), "d-gaps"))
615
+ end
616
+ if type(ch.warnings) == "table" and #ch.warnings > 0 then
617
+ local warnChildren: { any } = {}
618
+ for i, w in ch.warnings do
619
+ table.insert(
620
+ warnChildren,
621
+ keyed(tern.ui.text({ tern.ui.span("- " .. warningText(w), "muted") }), "d-warn" .. i)
622
+ )
623
+ end
624
+ table.insert(children, keyed(tern.ui.section(sectionHead("Warnings"), warnChildren), "d-warnings"))
625
+ end
626
+
627
+ -- Task list: known-empty plan shows an empty list with no process; an
628
+ -- eligible plan loads lazily; ineligible plans keep board-only details.
629
+ local taskChildren: { any } = {}
630
+ if type(ch.taskCount) == "number" then
631
+ if st.taskChangeId == ch.id and st.taskError ~= nil then
632
+ table.insert(taskChildren, keyed(tern.ui.text({ tern.ui.span(st.taskError, "error") }), "task-err"))
633
+ elseif type(st.tasks) == "table" and st.taskChangeId == ch.id then
634
+ if #st.tasks == 0 then
635
+ table.insert(taskChildren, keyed(tern.ui.text({ tern.ui.span("no tasks", "muted") }), "task-empty"))
636
+ else
637
+ for i, task in st.tasks do
638
+ local label = tostring(task.title or task.externalId or "?")
639
+ local status = tostring(task.status or "?")
640
+ local spans: { any }
641
+ if status == "done" then
642
+ spans = { tern.ui.span(label, "muted"), tern.ui.span(" " .. status, "muted") }
643
+ else
644
+ spans = { tern.ui.span(label, nil), tern.ui.span(" " .. status, "muted") }
645
+ end
646
+ -- externalId can repeat, so keys use the list occurrence.
647
+ table.insert(taskChildren, keyed(tern.ui.text(spans), "task:" .. i))
648
+ end
649
+ end
650
+ elseif not taskEligible(ch) then
651
+ table.insert(
652
+ taskChildren,
653
+ keyed(
654
+ tern.ui.text({ tern.ui.span("tasks not loaded (archived or finished change)", "muted") }),
655
+ "task-ineligible"
656
+ )
657
+ )
658
+ else
659
+ table.insert(taskChildren, keyed(tern.ui.text({ tern.ui.span("tasks: loading…", "muted") }), "task-loading"))
660
+ end
661
+ else
662
+ table.insert(
663
+ taskChildren,
664
+ keyed(tern.ui.text({ tern.ui.span("task plan unavailable - board details only", "muted") }), "task-none")
665
+ )
666
+ end
667
+ table.insert(children, keyed(tern.ui.section(sectionHead("Tasks"), taskChildren), "d-tasks"))
668
+
669
+ table.insert(
670
+ children,
671
+ keyed(tern.ui.el("button", { text = "Close details", id = "close-details" }), "close-details")
672
+ )
673
+ return keyed(tern.ui.section(sectionHead("Change details"), children), "details")
674
+ end
675
+
676
+ local function renderBoard(st: State, cx: any): any
677
+ local children: { any } = {}
678
+
679
+ -- Centered title, the way the CLI centers `📋 Delivery board` over its
680
+ -- panel width: the emoji counts as two terminal cells.
681
+ local title = "📋 Delivery board"
682
+ local paneCols = type(cx) == "table" and type(cx.cols) == "number" and cx.cols or 0
683
+ local pad = string.rep(" ", math.max(0, math.floor((paneCols - 17) / 2)))
684
+ table.insert(children, keyed(tern.ui.text({ tern.ui.span(pad .. title, "strong") }), "title"))
685
+
686
+ if st.board ~= nil then
687
+ local board = st.board
688
+ local priced = false
689
+ local function scanPriced(changes: any)
690
+ for _, ch in changes or {} do
691
+ if type(ch.flowEstimate) == "number" then
692
+ priced = true
693
+ end
694
+ end
695
+ end
696
+ for _, sprint in board.sprints do
697
+ scanPriced(sprint.changes)
698
+ end
699
+ scanPriced(board.ungrouped)
700
+
701
+ local visible: { any } = {}
702
+ local hidden = 0
703
+ local hiddenArchived = 0
704
+ local anyChange = false
705
+
706
+ -- Controls: interactive only. Table rows carry no per-row actions in
707
+ -- Tern, so a change is selected through the Details menu listing every
708
+ -- rendered change.
709
+ local controls: { any } = {
710
+ keyed(tern.ui.el("button", { text = "Refresh", id = "refresh" }), "refresh"),
711
+ keyed(tern.ui.el("button", { text = "Archived: " .. (st.showArchived and "on" or "off"), id = "toggle-archived" }), "toggle-archived"),
712
+ keyed(tern.ui.el("button", { text = "Finished sprints: " .. (st.showClosed and "shown" or "hidden"), id = "toggle-closed" }), "toggle-closed"),
713
+ }
714
+ local menuEntries: { string } = {}
715
+ for _, sprint in board.sprints do
716
+ local finished = sprintFinished(sprint)
717
+ if finished and not st.showClosed then
718
+ if #(sprint.changes or {}) > 0 then
719
+ hidden += 1
720
+ end
721
+ anyChange = anyChange or #(sprint.changes or {}) > 0
722
+ else
723
+ for _, ch in sprint.changes or {} do
724
+ table.insert(visible, ch)
725
+ table.insert(menuEntries, "open " .. tostring(ch.id) .. " " .. tostring(ch.slug or ch.id))
726
+ end
727
+ end
728
+ end
729
+ local ungroupedRows: { any } = {}
730
+ local ungroupedCount = 0
731
+ for _, ch in board.ungrouped do
732
+ if ch.archived == true and not st.showArchived then
733
+ hiddenArchived += 1
734
+ else
735
+ ungroupedCount += 1
736
+ table.insert(visible, ch)
737
+ table.insert(ungroupedRows, ch)
738
+ table.insert(menuEntries, "open " .. tostring(ch.id) .. " " .. tostring(ch.slug or ch.id))
739
+ end
740
+ end
741
+ if #menuEntries > 0 then
742
+ local button = tern.ui.el("button", { text = "Details", id = "open-details" })
743
+ local p: { [string]: any } = button.p or {}
744
+ p.actions = { menu = menuEntries }
745
+ button.p = p
746
+ table.insert(controls, keyed(button, "open-details"))
747
+ end
748
+ if st.loading then
749
+ table.insert(controls, keyed(tern.ui.text({ tern.ui.span("loading…", "strong") }), "loading"))
750
+ end
751
+ table.insert(children, keyed(tern.ui.row(controls), "controls"))
752
+
753
+ local legend = legendNode(visible)
754
+ if legend ~= nil then
755
+ table.insert(children, keyed(legend, "legend"))
756
+ end
757
+
758
+ for _, sprint in board.sprints do
759
+ local finished = sprintFinished(sprint)
760
+ if finished and not st.showClosed then
761
+ -- counted above
762
+ else
763
+ local section = "sprint:" .. tostring(sprint.slug)
764
+ local card = tern.ui.card(sectionHeadSpans(sprint), {
765
+ boardTable(sprint.changes or {}, section, priced),
766
+ })
767
+ local p: { [string]: any } = card.p or {}
768
+ p.key = section
769
+ if finished then
770
+ p.tone = "muted"
771
+ end
772
+ card.p = p
773
+ table.insert(children, card)
774
+ end
775
+ end
776
+ if ungroupedCount > 0 then
777
+ local card = tern.ui.card(sectionHead("Ungrouped"), {
778
+ boardTable(ungroupedRows, "ungrouped", priced),
779
+ })
780
+ local p: { [string]: any } = card.p or {}
781
+ p.key = "ungrouped"
782
+ card.p = p
783
+ table.insert(children, card)
784
+ end
785
+ anyChange = anyChange or ungroupedCount > 0
786
+
787
+ local hasSignal = false
788
+ local sawObserved = false
789
+ local sawPlanned = false
790
+ for _, ch in visible do
791
+ local signals = (type(ch.blockers) == "table" and #ch.blockers or 0)
792
+ + (type(ch.gaps) == "table" and #ch.gaps or 0)
793
+ + (type(ch.missing) == "table" and #ch.missing or 0)
794
+ hasSignal = hasSignal or signals > 0
795
+ sawObserved = sawObserved or ch.rung == "observed"
796
+ sawPlanned = sawPlanned or ch.rung == "planned"
797
+ end
798
+
799
+ if board.warnings ~= nil and #board.warnings > 0 then
800
+ local warnRows: { any } = {}
801
+ for i, w in board.warnings do
802
+ local code: string
803
+ local message: string
804
+ if type(w) == "table" then
805
+ code = tostring(w.code or "warning")
806
+ message = warningText(w)
807
+ else
808
+ code = "warning"
809
+ message = warningText(w)
810
+ end
811
+ table.insert(warnRows, {
812
+ tern.ui.span(code, "code"),
813
+ tern.ui.span(message, nil),
814
+ })
815
+ end
816
+ if isAdapterUnavailable(board) then
817
+ table.insert(warnRows, {
818
+ tern.ui.span("adapter-unavailable", "code"),
819
+ tern.ui.span(
820
+ "delivery adapter unavailable - mirror data may be incomplete; this is not an empty workspace",
821
+ nil
822
+ ),
823
+ })
824
+ end
825
+ local warnTable = tern.ui.table({
826
+ { id = "code", head = "code" },
827
+ { id = "message", head = "message", grow = 1 },
828
+ }, warnRows)
829
+ table.insert(children, keyed(tern.ui.card(sectionHead("Warnings"), { warnTable }), "warnings"))
830
+ end
831
+
832
+ if not anyChange and #(board.warnings or {}) == 0 then
833
+ table.insert(
834
+ children,
835
+ keyed(tern.ui.text({ tern.ui.span("no changes on the board", "muted") }), "empty")
836
+ )
837
+ end
838
+ if hidden > 0 then
839
+ local noun = hidden == 1 and "sprint" or "sprints"
840
+ table.insert(
841
+ children,
842
+ keyed(
843
+ tern.ui.text({ tern.ui.span(hidden .. " closed " .. noun .. " hidden (--closed to show).", "muted") }),
844
+ "hidden"
845
+ )
846
+ )
847
+ end
848
+ if hiddenArchived > 0 then
849
+ table.insert(
850
+ children,
851
+ keyed(
852
+ tern.ui.text({ tern.ui.span(hiddenArchived .. " archived ungrouped change(s) hidden", "muted") }),
853
+ "hidden-archived"
854
+ )
855
+ )
856
+ end
857
+ if hasSignal then
858
+ table.insert(
859
+ children,
860
+ keyed(tern.ui.text({ tern.ui.span("Detail: spego board --gaps", "muted") }), "detail-hint")
861
+ )
862
+ end
863
+ if sawObserved then
864
+ table.insert(
865
+ children,
866
+ keyed(tern.ui.text({ tern.ui.span("* observed - median of recorded runs", "muted") }), "observed-hint")
867
+ )
868
+ end
869
+ if sawPlanned then
870
+ table.insert(
871
+ children,
872
+ keyed(
873
+ tern.ui.text({ tern.ui.span("† planned - the plan's chunk count at the measured cost per chunk", "muted") }),
874
+ "planned-hint"
875
+ )
876
+ )
877
+ end
878
+ if board.next ~= nil and type(board.next) == "table" then
879
+ table.insert(children, keyed(tern.ui.text({
880
+ tern.ui.span(
881
+ "Suggestion: " .. tostring(board.next.change or "?") .. " in " .. tostring(board.next.sprint or "?")
882
+ .. " - " .. tostring(board.next.reason or "") .. ".",
883
+ "muted"
884
+ ),
885
+ }), "next"))
886
+ else
887
+ table.insert(
888
+ children,
889
+ keyed(
890
+ tern.ui.text({ tern.ui.span("Suggestion: groom pending changes into an active sprint-plan.", "muted") }),
891
+ "next"
892
+ )
893
+ )
894
+ end
895
+ if st.detailsOpen then
896
+ local details = renderDetails(st)
897
+ if details ~= nil then
898
+ table.insert(children, details)
899
+ end
900
+ end
901
+ elseif st.loadError ~= nil then
902
+ table.insert(children, keyed(tern.ui.text({ tern.ui.span(st.loadError, "strong error") }), "error"))
903
+ table.insert(
904
+ children,
905
+ keyed(tern.ui.text({ tern.ui.span("press r or the Refresh button to retry", "muted") }), "retry")
906
+ )
907
+ end
908
+
909
+ return tern.ui.col(children)
910
+ end
911
+
912
+ tern.block.define("board", {
913
+ init = function(cx, _args, saved): any
914
+ local st: State = {
915
+ pane = cx.pane,
916
+ cx = cx,
917
+ root = nil,
918
+ rootError = nil,
919
+ board = nil,
920
+ loadError = nil,
921
+ stale = false,
922
+ loading = false,
923
+ boardGen = 0,
924
+ boardPending = false,
925
+ taskGen = 0,
926
+ tasks = nil,
927
+ taskError = nil,
928
+ taskChangeId = nil,
929
+ selectedChangeId = nil,
930
+ selectedSection = nil,
931
+ detailsOpen = false,
932
+ showArchived = false,
933
+ showClosed = false,
934
+ }
935
+ if type(saved) == "table" and saved.version == 1 then
936
+ if type(saved.selectedChangeId) == "string" then
937
+ st.selectedChangeId = saved.selectedChangeId
938
+ end
939
+ if type(saved.selectedSection) == "string" then
940
+ st.selectedSection = saved.selectedSection
941
+ end
942
+ if type(saved.detailsOpen) == "boolean" then
943
+ st.detailsOpen = saved.detailsOpen
944
+ end
945
+ if type(saved.showArchived) == "boolean" then
946
+ st.showArchived = saved.showArchived
947
+ end
948
+ if type(saved.showClosed) == "boolean" then
949
+ st.showClosed = saved.showClosed
950
+ end
951
+ if isAbsolute(saved.root) then
952
+ st.root = saved.root
953
+ end
954
+ end
955
+ if st.root == nil and isAbsolute(cx.cwd) then
956
+ st.root = cx.cwd
957
+ end
958
+ if st.root == nil then
959
+ st.rootError = "Spego needs the block opened from the project root: "
960
+ .. "the pane's working directory is missing or relative"
961
+ if type(cx.cwd) == "string" then
962
+ st.rootError = st.rootError .. " (cwd: " .. cx.cwd .. ")"
963
+ end
964
+ else
965
+ startLoad(st)
966
+ end
967
+ panes[cx.pane] = st
968
+ return st
969
+ end,
970
+
971
+ view = function(state, cx)
972
+ if state.rootError ~= nil then
973
+ return {
974
+ main = tern.ui.col({
975
+ keyed(tern.ui.text({ tern.ui.span("Spego workspace error", "strong error") }), "head"),
976
+ keyed(tern.ui.text({ tern.ui.span(state.rootError, nil) }), "msg"),
977
+ keyed(
978
+ tern.ui.text({ tern.ui.span(
979
+ "close this block and reopen it from the spego project root",
980
+ "muted"
981
+ ) }),
982
+ "hint"
983
+ ),
984
+ }),
985
+ }
986
+ end
987
+ local main = renderBoard(state, cx)
988
+ if state.stale and state.board ~= nil then
989
+ main = tern.ui.col({
990
+ keyed(tern.ui.text({ tern.ui.span(
991
+ "last refresh failed - showing the previous board: " .. tostring(state.loadError),
992
+ "strong error"
993
+ ) }), "stale"),
994
+ main,
995
+ })
996
+ end
997
+ return { main = main }
998
+ end,
999
+
1000
+ title = function(state)
1001
+ if state.root ~= nil then
1002
+ return "Spego Delivery Board"
1003
+ end
1004
+ return "Spego Delivery Board (no workspace)"
1005
+ end,
1006
+
1007
+ key = function(state, key, _cx): boolean?
1008
+ if state.rootError ~= nil then
1009
+ return false
1010
+ end
1011
+ if key.name == "r" then
1012
+ startLoad(state)
1013
+ return true
1014
+ elseif key.name == "a" then
1015
+ state.showArchived = not state.showArchived
1016
+ if state.loading then
1017
+ state.boardPending = true
1018
+ else
1019
+ startLoad(state)
1020
+ end
1021
+ return true
1022
+ elseif key.name == "c" then
1023
+ state.showClosed = not state.showClosed
1024
+ return true
1025
+ elseif key.name == "escape" then
1026
+ if state.detailsOpen then
1027
+ state.detailsOpen = false
1028
+ state.taskGen += 1
1029
+ return true
1030
+ end
1031
+ end
1032
+ return false
1033
+ end,
1034
+
1035
+ event = function(state, ev, _cx)
1036
+ if state.rootError ~= nil then
1037
+ return
1038
+ end
1039
+ if ev.ev ~= "action" then
1040
+ return
1041
+ end
1042
+ local act = ev.act or ev.id
1043
+ if act == "refresh" then
1044
+ startLoad(state)
1045
+ elseif act == "toggle-archived" then
1046
+ state.showArchived = not state.showArchived
1047
+ if state.loading then
1048
+ state.boardPending = true
1049
+ else
1050
+ startLoad(state)
1051
+ end
1052
+ elseif act == "toggle-closed" then
1053
+ state.showClosed = not state.showClosed
1054
+ elseif act == "select" then
1055
+ local _, _, section, id = tostring(ev.value or ""):find("^(.-)\t(.+)$")
1056
+ if id ~= nil then
1057
+ state.selectedChangeId = id
1058
+ state.selectedSection = section
1059
+ state.detailsOpen = true
1060
+ state.taskGen += 1
1061
+ state.tasks = nil
1062
+ state.taskError = nil
1063
+ state.taskChangeId = nil
1064
+ startTasks(state)
1065
+ end
1066
+ elseif act == "close-details" then
1067
+ state.detailsOpen = false
1068
+ state.taskGen += 1
1069
+ else
1070
+ -- The Details menu sends "open <changeId> <slug>" as one action.
1071
+ local rest = tostring(act):match("^open (%S+ .+)$")
1072
+ if rest ~= nil then
1073
+ local id = rest:match("^(%S+)")
1074
+ if id ~= nil then
1075
+ state.selectedChangeId = id
1076
+ state.selectedSection = occurrencesOf(state.board, id)[1]
1077
+ and occurrencesOf(state.board, id)[1].section
1078
+ or state.selectedSection
1079
+ state.detailsOpen = true
1080
+ state.taskGen += 1
1081
+ state.tasks = nil
1082
+ state.taskError = nil
1083
+ state.taskChangeId = nil
1084
+ startTasks(state)
1085
+ end
1086
+ end
1087
+ end
1088
+ end,
1089
+
1090
+ save = function(state)
1091
+ return {
1092
+ version = 1,
1093
+ root = state.root,
1094
+ selectedChangeId = state.selectedChangeId,
1095
+ selectedSection = state.selectedSection,
1096
+ detailsOpen = state.detailsOpen,
1097
+ showArchived = state.showArchived,
1098
+ showClosed = state.showClosed,
1099
+ }
1100
+ end,
1101
+ })