@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.
- package/CHANGELOG.md +21 -0
- package/README.md +19 -3
- package/dist/artifacts/storage.js +6 -1
- package/dist/artifacts/storage.js.map +1 -1
- package/dist/cli/commands/board.js +13 -3
- package/dist/cli/commands/board.js.map +1 -1
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/commands/skills.js.map +1 -1
- package/dist/cli/commands/workspace.js +1 -1
- package/dist/cli/render.d.ts +1 -1
- package/dist/cli/render.js +1 -1
- package/dist/command-meta/registry.js +2 -1
- package/dist/command-meta/registry.js.map +1 -1
- package/dist/delivery/fresh-snapshot.d.ts +15 -0
- package/dist/delivery/fresh-snapshot.js +65 -0
- package/dist/delivery/fresh-snapshot.js.map +1 -0
- package/dist/delivery/load.d.ts +6 -2
- package/dist/delivery/load.js +34 -25
- package/dist/delivery/load.js.map +1 -1
- package/dist/delivery/mirror.d.ts +3 -0
- package/dist/delivery/mirror.js +6 -1
- package/dist/delivery/mirror.js.map +1 -1
- package/dist/generator/agents.d.ts +7 -0
- package/dist/generator/agents.js +164 -0
- package/dist/generator/agents.js.map +1 -0
- package/dist/generator/index.d.ts +1 -0
- package/dist/generator/index.js +17 -3
- package/dist/generator/index.js.map +1 -1
- package/dist/generator/templates.d.ts +12 -0
- package/dist/generator/templates.js +132 -0
- package/dist/generator/templates.js.map +1 -0
- package/dist/workspace/config.js +2 -2
- package/dist/workspace/init.js +1 -1
- package/docs/agent-targets.md +18 -5
- package/docs/cli.md +1 -1
- package/docs/delivery-board.md +10 -1
- package/docs/workflows.md +2 -2
- package/package.json +4 -1
- package/plugins/tern-spego/README.md +50 -0
- package/plugins/tern-spego/host.luau +1101 -0
- package/plugins/tern-spego/plugin.toml +12 -0
- package/skills/spego-board/SKILL.md +36 -0
- package/skills/spego-brainstorm-deep/SKILL.md +63 -0
- package/skills/spego-brainstorm-party/SKILL.md +68 -0
- package/skills/spego-change-brainstorm/SKILL.md +61 -0
- package/skills/spego-change-retro/SKILL.md +55 -0
- package/skills/spego-change-review/SKILL.md +62 -0
- package/skills/spego-change-verify-report/SKILL.md +52 -0
- package/skills/spego-create/SKILL.md +53 -0
- package/skills/spego-delete/SKILL.md +31 -0
- package/skills/spego-editorial-prose/SKILL.md +50 -0
- package/skills/spego-editorial-structure/SKILL.md +53 -0
- package/skills/spego-elicit/SKILL.md +93 -0
- package/skills/spego-epics/SKILL.md +29 -0
- package/skills/spego-groom/SKILL.md +58 -0
- package/skills/spego-help/SKILL.md +76 -0
- package/skills/spego-init/SKILL.md +31 -0
- package/skills/spego-list/SKILL.md +32 -0
- package/skills/spego-read/SKILL.md +35 -0
- package/skills/spego-record/SKILL.md +32 -0
- package/skills/spego-review-adversarial/SKILL.md +66 -0
- package/skills/spego-review-edge-cases/SKILL.md +57 -0
- package/skills/spego-skills/SKILL.md +29 -0
- package/skills/spego-sprints/SKILL.md +29 -0
- package/skills/spego-status/SKILL.md +29 -0
- package/skills/spego-sync/SKILL.md +29 -0
- package/skills/spego-tasks/SKILL.md +30 -0
- package/skills/spego-update/SKILL.md +53 -0
- package/skills/spego-view/SKILL.md +33 -0
- package/skills/spego-workflows/SKILL.md +29 -0
- package/dist/generator/claude.d.ts +0 -7
- package/dist/generator/claude.js +0 -245
- 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
|
+
})
|