jackherd 1.11.1 → 1.12.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 +22 -0
- package/README.md +76 -58
- package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -5
- package/dist/plan-format.d.ts +11 -6
- package/dist/plan-format.js +8 -2
- package/docs/follow-dark.svg +2 -2
- package/docs/follow.svg +2 -2
- package/docs/workflow-dark.svg +56 -64
- package/docs/workflow.svg +56 -64
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
- package/kit/workflow/skills/orchestrate/references/events.md +8 -1
- package/kit/workflow/skills/plan/SKILL.md +2 -0
- package/package.json +2 -2
- package/schemas/jackherd.schema.json +18 -1
- package/src/config/link-kinds.ts +1 -1
- package/src/config/schema.ts +5 -2
- package/src/config/tones.ts +9 -2
- package/src/context/fill.ts +4 -10
- package/src/context/session-log.ts +108 -2
- package/src/plan-format/lanes.ts +10 -1
- package/src/plan-format/phases.ts +11 -3
- package/src/plan-format/progress.ts +4 -2
- package/src/plan-format/waiting.ts +12 -0
- package/src/plans/active-tasks.ts +31 -8
- package/src/plans/appointment-rows.ts +42 -53
- package/src/plans/board-rows.ts +14 -0
- package/src/plans/calendar-days.ts +29 -0
- package/src/plans/click-hint.ts +94 -0
- package/src/plans/command.ts +8 -9
- package/src/plans/current-phase.ts +22 -10
- package/src/plans/current-task.ts +18 -3
- package/src/plans/frame.ts +18 -26
- package/src/plans/git.ts +90 -31
- package/src/plans/groomed.ts +13 -7
- package/src/plans/lane-reason.ts +61 -0
- package/src/plans/lane-summary.ts +75 -24
- package/src/plans/layout-phase.ts +123 -76
- package/src/plans/layout-summary.ts +133 -83
- package/src/plans/layout.ts +451 -345
- package/src/plans/legend.ts +29 -0
- package/src/plans/link-chips.ts +26 -8
- package/src/plans/live.ts +238 -84
- package/src/plans/next-rows.ts +5 -37
- package/src/plans/phase-source.ts +8 -3
- package/src/plans/plan-chips.ts +6 -3
- package/src/plans/plan-heads.ts +1 -1
- package/src/plans/planned.ts +4 -1
- package/src/plans/process-slots.ts +36 -0
- package/src/plans/pull-requests.ts +9 -1
- package/src/plans/read-cache.ts +18 -3
- package/src/plans/refresh-pace.ts +13 -0
- package/src/plans/row-focus.ts +86 -15
- package/src/plans/selection.ts +124 -89
- package/src/plans/sent-files.ts +1 -1
- package/src/plans/settings.ts +5 -2
- package/src/plans/shimmer.ts +124 -0
- package/src/plans/signals-read.ts +38 -10
- package/src/plans/slice-gate.ts +51 -0
- package/src/plans/snapshot.ts +185 -84
- package/src/plans/source-asked.ts +1 -1
- package/src/plans/source-rows.ts +60 -18
- package/src/plans/sprint-html.ts +1 -1
- package/src/plans/status-pace.ts +45 -0
- package/src/plans/subagent-tasks.ts +1 -1
- package/src/plans/subagents-read.ts +61 -24
- package/src/plans/subagents.ts +2 -2
- package/src/plans/symbols.ts +4 -1
- package/src/plans/task-brief.ts +143 -0
- package/src/plans/texts.ts +181 -39
- package/src/plans/view.ts +198 -32
- package/src/plans/waiting-click.ts +54 -110
- package/src/plans/watch-burst.ts +25 -0
- package/src/plans/worktree-stamp.ts +35 -0
- package/src/plans/your-turn-rows.ts +102 -0
- package/src/plans/your-turn.ts +226 -0
- package/src/terminal/herdr.ts +25 -5
- package/src/terminal/restore.ts +44 -0
- package/src/terminal/text.ts +2 -2
- package/src/terminal/width.ts +9 -0
- package/src/vocabulary/index.ts +6 -5
- package/src/watch/command.ts +28 -2
- package/src/watch/lane-report.ts +2 -0
- package/src/watch/lane-rest.ts +7 -1
- package/src/watch/reference-paths.ts +1 -0
- package/src/watch/unlinked-artifacts-read.ts +43 -0
- package/src/watch/unlinked-artifacts.ts +76 -0
- package/src/watch/watch-events.ts +12 -0
- package/src/watch/watcher-lock.ts +47 -4
package/src/plans/snapshot.ts
CHANGED
|
@@ -24,8 +24,7 @@ import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badge
|
|
|
24
24
|
import { readWatchReading } from '../watch/plan-badges-read'
|
|
25
25
|
import type { SourceStatus } from '../watch/source-statuses'
|
|
26
26
|
import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
|
|
27
|
-
import { type
|
|
28
|
-
import { boardRowsOf } from './board-rows'
|
|
27
|
+
import { type BoardRowsReader, boardRowsOf } from './board-rows'
|
|
29
28
|
import {
|
|
30
29
|
type ActivityTimes,
|
|
31
30
|
type LaneMemory,
|
|
@@ -37,7 +36,17 @@ import {
|
|
|
37
36
|
} from './current-phase'
|
|
38
37
|
import { activityTextOf, taskSignalsOf } from './current-task'
|
|
39
38
|
import { errorText } from './error-text'
|
|
40
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
type BaseReader,
|
|
41
|
+
type BranchStatusReader,
|
|
42
|
+
baseReaderAt,
|
|
43
|
+
branchStatusAt,
|
|
44
|
+
gitStatusOf,
|
|
45
|
+
lastChangesAt,
|
|
46
|
+
refCommits,
|
|
47
|
+
refExists,
|
|
48
|
+
resolveCommit,
|
|
49
|
+
} from './git'
|
|
41
50
|
import type { PullRequestState } from './github'
|
|
42
51
|
import { groomedDaysAgo } from './groomed'
|
|
43
52
|
import {
|
|
@@ -53,26 +62,30 @@ import { laneSummary } from './lane-summary'
|
|
|
53
62
|
import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
|
|
54
63
|
import type { LaneView } from './layout'
|
|
55
64
|
import { extractLinkChips } from './link-chips'
|
|
56
|
-
import { type NextBlock, nextBlock } from './next-rows'
|
|
57
65
|
import type { Options } from './options'
|
|
58
66
|
import { laneModelsOf, type PhaseModelRecord } from './phase-models'
|
|
59
67
|
import { readPhaseModels } from './phase-models-read'
|
|
60
|
-
import {
|
|
68
|
+
import { parsedPlanPhases } from './plan-cache'
|
|
69
|
+
import { laneHeads, PULL_HEAD_NAMESPACE, planPullRequests, readerOncePerRef } from './plan-heads'
|
|
61
70
|
import { markStale, type PlannedPlan, plannedPlans } from './planned'
|
|
62
71
|
import type { PullRequest } from './pull-requests'
|
|
63
72
|
import type { ReadCache } from './read-cache'
|
|
64
73
|
import { type SentChip, sentChipsByLane } from './sent-files'
|
|
65
74
|
import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
|
|
66
75
|
import { signalledTitle } from './signalled-phase'
|
|
67
|
-
import {
|
|
76
|
+
import { readAllSignalsSliced } from './signals-read'
|
|
77
|
+
import { createSliceGate, type SliceGate } from './slice-gate'
|
|
68
78
|
import { withSourcePrefix } from './source-prefix'
|
|
69
79
|
import type { HerdSource, SourceBlock } from './source-rows'
|
|
80
|
+
import { type KeptStatus, logMark, pacedStatus } from './status-pace'
|
|
70
81
|
import type { SubagentActivity } from './subagents'
|
|
71
|
-
import {
|
|
82
|
+
import { readSubagentActivitySliced, subagentSourceOf } from './subagents-read'
|
|
72
83
|
import { boardTexts } from './texts'
|
|
73
84
|
import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
|
|
74
85
|
import { readWaitingRecords, waitingItemsOf } from './waiting-records'
|
|
75
86
|
import { withWorkedPhases } from './worked-phases'
|
|
87
|
+
import { worktreeStamp } from './worktree-stamp'
|
|
88
|
+
import { type YourTurnBlock, yourTurnOfBoard } from './your-turn'
|
|
76
89
|
|
|
77
90
|
/** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
|
|
78
91
|
export type Source = {
|
|
@@ -81,6 +94,8 @@ export type Source = {
|
|
|
81
94
|
options: Options
|
|
82
95
|
settings: PlansSettings
|
|
83
96
|
terminal: LaneStatusSource
|
|
97
|
+
/** The terminal's agents without holding the event loop; defaults to `terminal.listAgents`, which does. */
|
|
98
|
+
listAgents?: () => Promise<LaneAgent[]>
|
|
84
99
|
/** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
|
|
85
100
|
readAgentLog?: AgentLogReader
|
|
86
101
|
/** The worktrees subagents of the coordinator work in; defaults to reading them from `~/.claude/projects`. */
|
|
@@ -125,11 +140,9 @@ export type Snapshot = {
|
|
|
125
140
|
waiting?: WaitingItem[]
|
|
126
141
|
/** Whole days since the board's grooming (`groomedDaysAgo`); null when its appointment is missing. */
|
|
127
142
|
groomedDays?: number | null
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
/** The
|
|
131
|
-
appointments?: AppointmentBlock
|
|
132
|
-
/** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under the appointments. */
|
|
143
|
+
/** What is the user's turn: `waiting`, the board's open appointments and its next section, one row per topic. */
|
|
144
|
+
yourTurn?: YourTurnBlock
|
|
145
|
+
/** The sources of `watch.sources` not marked `ticket`, from the watcher's state, for the block under "Your turn". */
|
|
133
146
|
sourceBlock?: SourceBlock
|
|
134
147
|
/** The tone of each role, from the config, which `renderSnapshot` sets before it draws. */
|
|
135
148
|
tones?: PlansSettings['tones']
|
|
@@ -156,6 +169,12 @@ type LaneSources = {
|
|
|
156
169
|
/** The directory the lanes' worktrees lie in, which tells a signal's lane. */
|
|
157
170
|
worktrees: string
|
|
158
171
|
parsed?: PlanSources['parsed']
|
|
172
|
+
/** The gate each lane passes before a piece of synchronous work, so the lanes take turns with keys and drawing. */
|
|
173
|
+
gate: SliceGate
|
|
174
|
+
/** Each worktree's last `git status`, kept between the herd's reads; absent for a single read, which asks git. */
|
|
175
|
+
statuses?: Map<string, KeptStatus>
|
|
176
|
+
/** When this read happens, epoch milliseconds. */
|
|
177
|
+
now: number
|
|
159
178
|
}
|
|
160
179
|
|
|
161
180
|
/** `liveMarkdown` is the checkout's board file (the coordinator edits it before any commit), else `markdown`. */
|
|
@@ -163,6 +182,12 @@ type BoardLanes = { markdown: string; liveMarkdown: string; lanes: SprintLane[]
|
|
|
163
182
|
|
|
164
183
|
const SHOWN_NAMES = 2
|
|
165
184
|
|
|
185
|
+
/**
|
|
186
|
+
* How long a read keeps the event loop before it lets keys, mouse and drawing in: a quarter frame at 60 Hz, so a slice
|
|
187
|
+
* and the longest single step after it stay within one frame.
|
|
188
|
+
*/
|
|
189
|
+
const SLICE_MS = 4
|
|
190
|
+
|
|
166
191
|
type LaneResult = { view: LaneView; files: string[]; texts: Record<string, string>; pane: string | null }
|
|
167
192
|
|
|
168
193
|
/** The board's lanes the view shows, the running lanes it does not list, and what the lanes read from. */
|
|
@@ -177,9 +202,30 @@ type Decorations = {
|
|
|
177
202
|
restNoteOf: (lane: string) => string | null
|
|
178
203
|
}
|
|
179
204
|
|
|
205
|
+
/** What every answer line of a session log holds; the fill is read from answers only. */
|
|
206
|
+
const ANSWER_MARK = '"type":"assistant"'
|
|
207
|
+
|
|
208
|
+
const answerLines = new WeakMap<readonly string[], string[]>()
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The lines of a log that can be answers, kept per log as read: a log that did not change comes back as the same
|
|
212
|
+
* array (`followTailLines`), so its lines are not searched again, and the fill parses a few lines, not the whole tail.
|
|
213
|
+
*/
|
|
214
|
+
const answerLinesOf = (lines: readonly string[]): string[] => {
|
|
215
|
+
const kept = answerLines.get(lines)
|
|
216
|
+
if (kept) return kept
|
|
217
|
+
const found = lines.filter((line) => line.includes(ANSWER_MARK))
|
|
218
|
+
answerLines.set(lines, found)
|
|
219
|
+
return found
|
|
220
|
+
}
|
|
221
|
+
|
|
180
222
|
/** How full the agent's context is, null when the terminal gave no session or the session has no log. */
|
|
181
223
|
const contextOf = (agent: LaneAgent | undefined, source: Source, readLog: AgentLogReader): ContextReading | null => {
|
|
182
|
-
const
|
|
224
|
+
const readAnswers: AgentLogReader = (asked) => {
|
|
225
|
+
const lines = readLog(asked)
|
|
226
|
+
return lines && answerLinesOf(lines)
|
|
227
|
+
}
|
|
228
|
+
const report = contextOfAgent(agent, readAnswers, source.settings.context)
|
|
183
229
|
return report && { share: report.share, level: report.level }
|
|
184
230
|
}
|
|
185
231
|
|
|
@@ -201,17 +247,35 @@ const workText = ({ readLog, subagents }: LaneSources, lane: string, agent: Lane
|
|
|
201
247
|
return [lines ? activityTextOf(lines, WORK_ENTRIES) : '', subagents.get(lane)?.activity ?? ''].join(' ')
|
|
202
248
|
}
|
|
203
249
|
|
|
250
|
+
/**
|
|
251
|
+
* `git status` of a lane's worktree asked only when something there may have moved: its index or HEAD, its agent's
|
|
252
|
+
* log or its subagents' work (`worktreeStamp`), or the kept answer is old; git every time without kept statuses.
|
|
253
|
+
*/
|
|
254
|
+
const pacedStatusReader = (sources: LaneSources, name: string, agent: LaneAgent | undefined): BranchStatusReader => {
|
|
255
|
+
const { statuses, now } = sources
|
|
256
|
+
if (!statuses) return branchStatusAt
|
|
257
|
+
return (worktree, pass) => {
|
|
258
|
+
const activity = `${logMark(agent ? sources.readLog(agent) : null)}|${sources.subagents.get(name)?.activity ?? ''}`
|
|
259
|
+
const ask = { worktree, stamp: worktreeStamp(worktree, activity), now }
|
|
260
|
+
return pacedStatus(statuses, ask, () => branchStatusAt(worktree, pass))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
204
264
|
/** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
|
|
205
265
|
const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
|
|
206
266
|
const { source, pullRequests } = sources
|
|
207
|
-
const gitStatus = await gitStatusOf(
|
|
267
|
+
const gitStatus = await gitStatusOf(
|
|
268
|
+
worktreeOf(source.root, source.settings, name),
|
|
269
|
+
baseRefOf(source.settings),
|
|
270
|
+
sources.gate.pass,
|
|
271
|
+
pacedStatusReader(sources, name, agent),
|
|
272
|
+
)
|
|
273
|
+
await sources.gate.pass()
|
|
208
274
|
const subagent = agent ? undefined : sources.subagents.get(name)
|
|
209
|
-
const title = agent?.title ?? subagent?.description
|
|
210
275
|
return {
|
|
211
276
|
gitStatus,
|
|
212
277
|
view: {
|
|
213
278
|
status: { agent: agent?.status ?? subagent?.status ?? null, ...gitStatus },
|
|
214
|
-
...(title ? { sessionTitle: title } : {}),
|
|
215
279
|
pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
|
|
216
280
|
context: contextOf(agent, source, sources.readLog),
|
|
217
281
|
},
|
|
@@ -248,6 +312,7 @@ const laneView = async (
|
|
|
248
312
|
): Promise<LaneResult> => {
|
|
249
313
|
const { source, agents } = sources
|
|
250
314
|
const { root, settings } = source
|
|
315
|
+
await sources.gate.pass()
|
|
251
316
|
const worktree = worktreeOf(root, settings, lane.name)
|
|
252
317
|
const agent = agents.find((candidate) => candidate.name === lane.name)
|
|
253
318
|
const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
|
|
@@ -269,6 +334,7 @@ const laneView = async (
|
|
|
269
334
|
laneWorkOf(worktree, gitStatus),
|
|
270
335
|
fallbacks,
|
|
271
336
|
)
|
|
337
|
+
await sources.gate.pass()
|
|
272
338
|
const shown = withWorkedPhases(lane, phasesByHref)
|
|
273
339
|
const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
|
|
274
340
|
const laneSubagents = sources.subagents.get(lane.name)
|
|
@@ -340,19 +406,16 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
|
|
|
340
406
|
return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
|
|
341
407
|
}
|
|
342
408
|
|
|
343
|
-
/** What agents report doing
|
|
344
|
-
const readSignals = (
|
|
409
|
+
/** What agents report doing, one log per slice of `gate`; nothing when it cannot be read. */
|
|
410
|
+
const readSignals = async (
|
|
345
411
|
{ root, readSignals: read }: Source,
|
|
346
|
-
worktrees: string,
|
|
347
|
-
|
|
348
|
-
cache
|
|
349
|
-
): TaskSignal[] => {
|
|
412
|
+
context: { worktrees: string; now: number; gate: SliceGate; cache?: ReadCache },
|
|
413
|
+
): Promise<TaskSignal[]> => {
|
|
414
|
+
const { worktrees, now, gate, cache } = context
|
|
350
415
|
try {
|
|
351
|
-
return (
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at, cache?.signals))
|
|
355
|
-
)(root, worktrees, now)
|
|
416
|
+
if (read) return read(root, worktrees, now)
|
|
417
|
+
const source = { projects: defaultProjectsDirectory(), root, worktrees }
|
|
418
|
+
return await readAllSignalsSliced(source, now, cache?.signals ?? new Map(), gate.pass)
|
|
356
419
|
} catch {
|
|
357
420
|
return []
|
|
358
421
|
}
|
|
@@ -370,23 +433,23 @@ const unplacedOf = (sources: LaneSources, lanes: readonly SprintLane[], onlyLane
|
|
|
370
433
|
}
|
|
371
434
|
|
|
372
435
|
/** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
|
|
373
|
-
const readAgents = (terminal:
|
|
436
|
+
const readAgents = async ({ terminal, listAgents }: Source): Promise<{ agents: LaneAgent[]; errors: string[] }> => {
|
|
374
437
|
try {
|
|
375
|
-
return { agents: terminal.listAgents(), errors: [] }
|
|
438
|
+
return { agents: listAgents ? await listAgents() : terminal.listAgents(), errors: [] }
|
|
376
439
|
} catch (error) {
|
|
377
440
|
return { agents: [], errors: [errorText(error)] }
|
|
378
441
|
}
|
|
379
442
|
}
|
|
380
443
|
|
|
381
|
-
/** What subagents of the coordinator do in the worktrees
|
|
382
|
-
const readSubagents = (
|
|
444
|
+
/** What subagents of the coordinator do in the worktrees, one session per slice of `gate`; nothing when their logs cannot be read. */
|
|
445
|
+
const readSubagents = async (
|
|
383
446
|
{ root, settings, readSubagents: read }: Source,
|
|
384
447
|
worktrees: string,
|
|
385
|
-
|
|
448
|
+
gate: SliceGate,
|
|
449
|
+
): Promise<Map<string, SubagentActivity>> => {
|
|
386
450
|
try {
|
|
387
|
-
return (
|
|
388
|
-
|
|
389
|
-
)(root, worktrees)
|
|
451
|
+
if (read) return read(root, worktrees)
|
|
452
|
+
return await readSubagentActivitySliced(subagentSourceOf(root, worktrees, settings.skillPrefix), gate.pass)
|
|
390
453
|
} catch {
|
|
391
454
|
return new Map()
|
|
392
455
|
}
|
|
@@ -427,12 +490,13 @@ const readBoardLanes = async (
|
|
|
427
490
|
base: BaseReader,
|
|
428
491
|
boardPath: string,
|
|
429
492
|
fallbacks: Fallbacks,
|
|
493
|
+
rowsOf: BoardRowsReader,
|
|
430
494
|
): Promise<BoardLanes> => {
|
|
431
495
|
const { sprintFile, settings } = source
|
|
432
496
|
const atBase = textAtBase(base, boardPath)
|
|
433
497
|
const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
|
|
434
498
|
const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
|
|
435
|
-
return { markdown, liveMarkdown, lanes:
|
|
499
|
+
return { markdown, liveMarkdown, lanes: rowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
|
|
436
500
|
}
|
|
437
501
|
|
|
438
502
|
/** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
|
|
@@ -463,7 +527,7 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
|
|
|
463
527
|
for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
|
|
464
528
|
}
|
|
465
529
|
|
|
466
|
-
const laneSetOf = (
|
|
530
|
+
const laneSetOf = async (
|
|
467
531
|
source: Source,
|
|
468
532
|
context: {
|
|
469
533
|
base: BaseReader
|
|
@@ -471,14 +535,19 @@ const laneSetOf = (
|
|
|
471
535
|
sprintDirectory: string
|
|
472
536
|
worktrees: string
|
|
473
537
|
cache?: ReadCache
|
|
538
|
+
gate: SliceGate
|
|
474
539
|
},
|
|
475
540
|
pullRequests: PullRequestState,
|
|
476
541
|
now: number,
|
|
477
|
-
): LaneSet => {
|
|
478
|
-
const { base, board, sprintDirectory, worktrees, cache } = context
|
|
479
|
-
|
|
542
|
+
): Promise<LaneSet> => {
|
|
543
|
+
const { base, board, sprintDirectory, worktrees, cache, gate } = context
|
|
544
|
+
await gate.pass()
|
|
545
|
+
const { agents, errors } = await readAgents(source)
|
|
480
546
|
const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
|
|
481
|
-
const subagents = readSubagents(source, worktrees)
|
|
547
|
+
const subagents = await readSubagents(source, worktrees, gate)
|
|
548
|
+
const signals = await readSignals(source, { worktrees, now, gate, cache })
|
|
549
|
+
const planPulls = planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory)
|
|
550
|
+
const headCommits = planPulls.length > 0 ? await refCommits(source.root, PULL_HEAD_NAMESPACE) : new Map()
|
|
482
551
|
const listed = new Set(board.lanes.map((lane) => lane.name))
|
|
483
552
|
return {
|
|
484
553
|
lanes: board.lanes.filter(isWanted),
|
|
@@ -491,11 +560,14 @@ const laneSetOf = (
|
|
|
491
560
|
subagents,
|
|
492
561
|
readLog: onceReader(source.readAgentLog ?? agentLog),
|
|
493
562
|
pullRequests,
|
|
494
|
-
planPullRequests:
|
|
495
|
-
headReaderOf: readerOncePerRef((ref) =>
|
|
496
|
-
signals
|
|
563
|
+
planPullRequests: planPulls,
|
|
564
|
+
headReaderOf: readerOncePerRef((ref) => baseReaderAt(source.root, headCommits.get(ref) ?? null)),
|
|
565
|
+
signals,
|
|
497
566
|
worktrees,
|
|
498
567
|
parsed: cache && { entries: cache.plans, used: new Set<string>() },
|
|
568
|
+
gate,
|
|
569
|
+
statuses: cache?.statuses,
|
|
570
|
+
now,
|
|
499
571
|
},
|
|
500
572
|
errors,
|
|
501
573
|
}
|
|
@@ -535,29 +607,48 @@ const decoratedViews = (
|
|
|
535
607
|
),
|
|
536
608
|
)
|
|
537
609
|
|
|
538
|
-
/**
|
|
539
|
-
const
|
|
540
|
-
statuses
|
|
610
|
+
/** Each source with the tone and the label its config gives it. */
|
|
611
|
+
const withSourceStyle = (
|
|
612
|
+
statuses: readonly SourceStatus[],
|
|
613
|
+
{ herdSources, sourceLabels }: Pick<PlansSettings, 'herdSources' | 'sourceLabels'>,
|
|
614
|
+
): HerdSource[] =>
|
|
615
|
+
statuses.map((status) => {
|
|
616
|
+
const label = sourceLabels[status.name]
|
|
617
|
+
const tone = herdSources.find(({ name }) => name === status.name)?.tone ?? null
|
|
618
|
+
return { ...status, tone, ...(label === undefined ? {} : { label }) }
|
|
619
|
+
})
|
|
541
620
|
|
|
542
|
-
|
|
621
|
+
/**
|
|
622
|
+
* The open phases no lane with an agent leads, a plan marked stale by its last change at `baseCommit`; none while the
|
|
623
|
+
* view shows one lane.
|
|
624
|
+
*/
|
|
625
|
+
const plannedOf = async (
|
|
543
626
|
source: Source,
|
|
544
|
-
plans: Record<string, string>,
|
|
545
627
|
results: readonly LaneResult[],
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
:
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
628
|
+
context: {
|
|
629
|
+
plans: Record<string, string>
|
|
630
|
+
sprintDirectory: string
|
|
631
|
+
now: number
|
|
632
|
+
parsed: PlanSources['parsed']
|
|
633
|
+
baseCommit: string | null
|
|
634
|
+
},
|
|
635
|
+
): Promise<PlannedPlan[]> => {
|
|
636
|
+
if (source.options.lane) return []
|
|
637
|
+
const { plans, sprintDirectory, now, parsed, baseCommit } = context
|
|
638
|
+
const planned = plannedPlans({
|
|
639
|
+
plans,
|
|
640
|
+
lanes: results.map(({ view }) => ({ lane: view.progress.lane, agent: view.status.agent })),
|
|
641
|
+
sprintDirectory,
|
|
642
|
+
language: source.settings.planLanguage,
|
|
643
|
+
parse: (markdown, path) => parsedPlanPhases(parsed, path, markdown, source.settings.planLanguage),
|
|
644
|
+
})
|
|
645
|
+
const touchedAt = await lastChangesAt(
|
|
646
|
+
source.root,
|
|
647
|
+
baseCommit,
|
|
648
|
+
planned.map(({ path }) => path),
|
|
649
|
+
)
|
|
650
|
+
return markStale(planned, touchedAt, now)
|
|
651
|
+
}
|
|
561
652
|
|
|
562
653
|
/**
|
|
563
654
|
* Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
|
|
@@ -571,25 +662,27 @@ export const loadSnapshot = async (
|
|
|
571
662
|
cache?: ReadCache,
|
|
572
663
|
): Promise<Snapshot> => {
|
|
573
664
|
const { root, sprintFile, options, settings } = source
|
|
665
|
+
const gate = createSliceGate(SLICE_MS)
|
|
574
666
|
const fallbacks: Fallbacks = []
|
|
575
667
|
const baseRef = baseRefOf(settings)
|
|
576
|
-
const
|
|
668
|
+
const baseCommit = await resolveCommit(root, baseRef)
|
|
669
|
+
const base = baseReaderAt(root, baseCommit)
|
|
577
670
|
const boardPath = boardRepoPathOf(root, settings, sprintFile)
|
|
578
|
-
const board = await readBoardLanes(source, base, boardPath, fallbacks)
|
|
671
|
+
const board = await readBoardLanes(source, base, boardPath, fallbacks, cache?.boardRows ?? boardRowsOf)
|
|
579
672
|
const { markdown } = board
|
|
580
673
|
const sprintDirectory = dirname(boardPath)
|
|
581
674
|
const worktrees = join(root, settings.worktreeDirectory)
|
|
582
675
|
const now = Date.now()
|
|
583
676
|
const liveBoard = board.liveMarkdown
|
|
584
|
-
const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees, cache }, pullRequests, now)
|
|
677
|
+
const laneSet = await laneSetOf(source, { base, board, sprintDirectory, worktrees, cache, gate }, pullRequests, now)
|
|
585
678
|
const results = await laneResultsOf(laneSet, fallbacks, now, memories)
|
|
586
679
|
const { parsed } = laneSet.sources
|
|
587
|
-
if (parsed) dropUnused(parsed.entries, parsed.used)
|
|
588
680
|
const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
|
|
589
681
|
const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
|
|
590
682
|
const { ticketSources, herdSources } = settings
|
|
591
683
|
const watched =
|
|
592
684
|
ticketSources.length > 0 || herdSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
|
|
685
|
+
await gate.pass()
|
|
593
686
|
const reading = watched
|
|
594
687
|
? readWatchReading(
|
|
595
688
|
root,
|
|
@@ -600,6 +693,7 @@ export const loadSnapshot = async (
|
|
|
600
693
|
herdSources.map(({ name }) => name),
|
|
601
694
|
)
|
|
602
695
|
: null
|
|
696
|
+
await gate.pass()
|
|
603
697
|
const waitingRecords = readWaitingRecords(root)
|
|
604
698
|
const waiting = withSourcePrefix(
|
|
605
699
|
waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
|
|
@@ -609,23 +703,35 @@ export const loadSnapshot = async (
|
|
|
609
703
|
const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
|
|
610
704
|
const unknownLaneTitle =
|
|
611
705
|
options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
|
|
706
|
+
await gate.pass()
|
|
707
|
+
const views = decoratedViews(results, {
|
|
708
|
+
reading,
|
|
709
|
+
phaseModels: readPhaseModels(root),
|
|
710
|
+
sprintDirectory,
|
|
711
|
+
sentChips,
|
|
712
|
+
restNoteOf: (lane) =>
|
|
713
|
+
restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
|
|
714
|
+
})
|
|
715
|
+
await gate.pass()
|
|
716
|
+
const planned = await plannedOf(source, results, {
|
|
717
|
+
plans: documentsPlans,
|
|
718
|
+
sprintDirectory,
|
|
719
|
+
now,
|
|
720
|
+
parsed,
|
|
721
|
+
baseCommit,
|
|
722
|
+
})
|
|
723
|
+
if (parsed) dropUnused(parsed.entries, parsed.used)
|
|
724
|
+
await gate.pass()
|
|
612
725
|
return {
|
|
613
726
|
title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
|
|
614
|
-
views
|
|
615
|
-
reading,
|
|
616
|
-
phaseModels: readPhaseModels(root),
|
|
617
|
-
sprintDirectory,
|
|
618
|
-
sentChips,
|
|
619
|
-
restNoteOf: (lane) =>
|
|
620
|
-
restNote(readLaneReport(laneReportPath(worktreeOf(root, settings, lane), lane))?.text ?? null),
|
|
621
|
-
}),
|
|
727
|
+
views,
|
|
622
728
|
documents: {
|
|
623
729
|
sprint: markdown,
|
|
624
730
|
plans: documentsPlans,
|
|
625
731
|
laneOrder: [...laneSet.lanes, ...laneSet.unlisted].map((lane) => lane.name),
|
|
626
732
|
lanes: board.lanes,
|
|
627
733
|
},
|
|
628
|
-
planned
|
|
734
|
+
planned,
|
|
629
735
|
files: [sprintFile, ...results.flatMap((result) => result.files)],
|
|
630
736
|
titles: new Map(
|
|
631
737
|
results.flatMap(({ view, pane }) =>
|
|
@@ -636,13 +742,8 @@ export const loadSnapshot = async (
|
|
|
636
742
|
watch: reading?.health,
|
|
637
743
|
waiting,
|
|
638
744
|
groomedDays: groomedDaysAgo(liveBoard, settings.planLanguage, new Date(now)),
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
settings.planLanguage,
|
|
642
|
-
waiting.map(({ item }) => item),
|
|
643
|
-
),
|
|
644
|
-
appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
|
|
645
|
-
...(reading?.sources ? { sourceBlock: { sources: withSourceTones(reading.sources, herdSources), now } } : {}),
|
|
745
|
+
yourTurn: yourTurnOfBoard(liveBoard, settings.planLanguage, waiting, now),
|
|
746
|
+
...(reading?.sources ? { sourceBlock: { sources: withSourceStyle(reading.sources, settings), now } } : {}),
|
|
646
747
|
tones: settings.tones,
|
|
647
748
|
unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
|
|
648
749
|
...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
2
2
|
|
|
3
|
-
/** Whether the source `source` has an entry titled `title` that an open row of "
|
|
3
|
+
/** Whether the source `source` has an entry titled `title` that an open row of "Your turn" asks about. */
|
|
4
4
|
export type AskedCheck = (source: string, title: string) => boolean
|
|
5
5
|
|
|
6
6
|
const same = (left: string, right: string): boolean => left.trim().toLowerCase() === right.trim().toLowerCase()
|
package/src/plans/source-rows.ts
CHANGED
|
@@ -7,15 +7,24 @@ import type { AskedCheck } from './source-asked'
|
|
|
7
7
|
import { chevronOf } from './symbols'
|
|
8
8
|
import type { BlockItem } from './waiting-click'
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* A source's status with the tone its config names for the mark while its report is new (null where it names none) and
|
|
12
|
+
* the label its config gives it, absent where it gives none.
|
|
13
|
+
*/
|
|
14
|
+
export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
|
|
15
|
+
|
|
16
|
+
/** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
|
|
17
|
+
export type SourceSummary = { text: string; tone?: ToneName }
|
|
12
18
|
|
|
13
19
|
/** The sources block: the listed sources, and the clock (epoch milliseconds) their ages are counted against. */
|
|
14
20
|
export type SourceBlock = { sources: HerdSource[]; now: number }
|
|
15
21
|
|
|
16
|
-
/** What decides how the rows of the block look: the keys the user opened, and which entries a row of "
|
|
22
|
+
/** What decides how the rows of the block look: the keys the user opened, and which entries a row of "Your turn" asks about. */
|
|
17
23
|
export type SourceView = { expanded: ReadonlySet<string>; asked: AskedCheck }
|
|
18
24
|
|
|
25
|
+
/** The widths of the count column and the time column over the entries shown; the count column is 0 without counts. */
|
|
26
|
+
type EntryColumns = { count: number; time: number }
|
|
27
|
+
|
|
19
28
|
/** How many entries of an opened source show before `+<n> more`. */
|
|
20
29
|
export const SOURCE_ENTRY_ROWS = 5
|
|
21
30
|
|
|
@@ -23,7 +32,7 @@ const SOURCE_MARK = '●'
|
|
|
23
32
|
const ENTRY_MARK = '•'
|
|
24
33
|
const MORE_MARK = ' '
|
|
25
34
|
const SEPARATOR = ' · '
|
|
26
|
-
const ASKED_TEXT = '↑
|
|
35
|
+
const ASKED_TEXT = '↑ in your turn'
|
|
27
36
|
const MINUTE_MS = 60_000
|
|
28
37
|
const HOUR_MS = 60 * MINUTE_MS
|
|
29
38
|
const DAY_MS = 24 * HOUR_MS
|
|
@@ -114,10 +123,18 @@ const entryToneOf = (
|
|
|
114
123
|
return entry.isNew ? (source.tone ?? newTone) : undefined
|
|
115
124
|
}
|
|
116
125
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
const countOf = ({ count }: EntryStatus): string => (count === null ? '' : `${count}×`)
|
|
127
|
+
|
|
128
|
+
const columnsOf = (entries: readonly EntryStatus[], now: number): EntryColumns => ({
|
|
129
|
+
count: Math.max(0, ...entries.map((entry) => textWidth(countOf(entry)))),
|
|
130
|
+
time: Math.max(0, ...entries.map(({ at }) => textWidth(clockOf(at, now)))),
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/** `113× 11:11` with both columns padded, so the titles behind it start in one column whatever their width. */
|
|
134
|
+
const entryLeadOf = (entry: EntryStatus, now: number, columns: EntryColumns): string => {
|
|
135
|
+
const time = padVisible(clockOf(entry.at, now), columns.time)
|
|
136
|
+
return columns.count === 0 ? time : `${padVisible(countOf(entry), columns.count)} ${time}`
|
|
137
|
+
}
|
|
121
138
|
|
|
122
139
|
const entryItem = (
|
|
123
140
|
source: HerdSource,
|
|
@@ -125,17 +142,18 @@ const entryItem = (
|
|
|
125
142
|
view: SourceView,
|
|
126
143
|
now: number,
|
|
127
144
|
newTone: ToneName,
|
|
128
|
-
|
|
145
|
+
columns: EntryColumns,
|
|
129
146
|
): BlockItem => {
|
|
130
147
|
const isAsked = view.asked(source.name, entry.title)
|
|
131
148
|
const tone = entryToneOf(isAsked, entry, source, newTone)
|
|
132
149
|
return {
|
|
133
150
|
key: entryKey(source.name, entry),
|
|
134
151
|
mark: ENTRY_MARK,
|
|
135
|
-
lead:
|
|
136
|
-
text:
|
|
152
|
+
lead: entryLeadOf(entry, now, columns),
|
|
153
|
+
text: isAsked ? `${ASKED_TEXT} ${entry.title}` : entry.title,
|
|
137
154
|
url: entry.url,
|
|
138
155
|
ask: { kind: ASK_KIND.sourceEntry, source: source.name, title: entry.title },
|
|
156
|
+
expands: false,
|
|
139
157
|
...(tone && { tone }),
|
|
140
158
|
...(isAsked && { dim: true }),
|
|
141
159
|
}
|
|
@@ -153,10 +171,10 @@ const moreItem = (name: string, hidden: number, showsAll: boolean): BlockItem =>
|
|
|
153
171
|
const entryItems = (source: HerdSource, view: SourceView, now: number, newTone: ToneName): BlockItem[] => {
|
|
154
172
|
const showsAll = view.expanded.has(sourceMoreKey(source.name))
|
|
155
173
|
const shown = showsAll ? source.entries : source.entries.slice(0, SOURCE_ENTRY_ROWS)
|
|
156
|
-
const
|
|
174
|
+
const columns = columnsOf(shown, now)
|
|
157
175
|
const hidden = source.entries.length - SOURCE_ENTRY_ROWS
|
|
158
176
|
return [
|
|
159
|
-
...shown.map((entry) => entryItem(source, entry, view, now, newTone,
|
|
177
|
+
...shown.map((entry) => entryItem(source, entry, view, now, newTone, columns)),
|
|
160
178
|
...(hidden > 0 ? [moreItem(source.name, hidden, showsAll)] : []),
|
|
161
179
|
]
|
|
162
180
|
}
|
|
@@ -166,9 +184,10 @@ const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
|
|
|
166
184
|
/**
|
|
167
185
|
* The rows of the sources block: per source one row with its name, when it ran, since when it fails and, for a source
|
|
168
186
|
* without entries, its newest report. A source with entries is folded to that row, and a click on it opens its entries
|
|
169
|
-
* (`view.expanded` holds its key): each with
|
|
170
|
-
* entry opens its address, `[ask]` asks the coordinator
|
|
171
|
-
*
|
|
187
|
+
* (`view.expanded` holds its key): each with count and time as a fixed column, then its title, cut at the pane's edge,
|
|
188
|
+
* at most five and then `+<n> more`. An entry never expands: a click opens its address, `[ask]` asks the coordinator
|
|
189
|
+
* to look at exactly that entry. An entry that a row of "Your turn" already asks about is dim and starts with
|
|
190
|
+
* `↑ in your turn`, and it does not make the source amber.
|
|
172
191
|
* `newTone` is the tone of the role `new`, which a source without its own tone wears while it has something new.
|
|
173
192
|
*/
|
|
174
193
|
export const sourceItems = (
|
|
@@ -184,5 +203,28 @@ export const sourceItems = (
|
|
|
184
203
|
])
|
|
185
204
|
}
|
|
186
205
|
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
const entriesText = (count: number): string => `${count} ${count === 1 ? 'entry' : 'entries'}`
|
|
207
|
+
|
|
208
|
+
const summaryTextOf = (source: HerdSource, now: number, asked: AskedCheck): string => {
|
|
209
|
+
const name = source.label ?? source.name
|
|
210
|
+
if (source.failingSince !== null) return `${name} failing since ${agoOf(source.failingSince, now)}`
|
|
211
|
+
if (source.entries.length === 0) return `${name} ${source.ranAt === null ? 'not run yet' : agoOf(source.ranAt, now)}`
|
|
212
|
+
const fresh = source.entries.filter((entry) => isUnaskedNew(source.name, entry, asked)).length
|
|
213
|
+
return `${name} ${entriesText(source.entries.length)}${fresh > 0 ? `, ${fresh} new` : ''}`
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* What the folded sources block says of each source, `errors 5 entries, 1 new`, `uptime 7 h ago`: its label, then
|
|
218
|
+
* its entries and how many are new and not yet in "Your turn", or when it ran, or since when it fails. A source wears
|
|
219
|
+
* the tone of its mark (red while failing, its own or `newTone` with a new entry not in "Your turn"), else none.
|
|
220
|
+
*/
|
|
221
|
+
export const sourceSummaries = (
|
|
222
|
+
sources: readonly HerdSource[],
|
|
223
|
+
now: number,
|
|
224
|
+
newTone: ToneName,
|
|
225
|
+
asked: AskedCheck,
|
|
226
|
+
): SourceSummary[] =>
|
|
227
|
+
sources.map((source) => {
|
|
228
|
+
const tone = toneOf(source, newTone, asked)
|
|
229
|
+
return { text: summaryTextOf(source, now, asked), ...(tone && { tone }) }
|
|
230
|
+
})
|
package/src/plans/sprint-html.ts
CHANGED
|
@@ -236,7 +236,7 @@ const plannedRow = ({ title, plan, phaseIds, done, total }: PagePlanned): string
|
|
|
236
236
|
const plannedSection = ({ planned }: SprintPage): string => {
|
|
237
237
|
if (planned.length === 0) return ''
|
|
238
238
|
const phases = plannedPhaseCount(planned)
|
|
239
|
-
const summary = `<summary>○ ${escapeHtml(TEXTS.
|
|
239
|
+
const summary = `<summary>○ ${escapeHtml(TEXTS.backlog(phases, planned.length))}</summary>`
|
|
240
240
|
return `<details class="planned">${summary}<ul class="phases">${planned.map(plannedRow).join('')}</ul></details>`
|
|
241
241
|
}
|
|
242
242
|
|