jackherd 1.12.1 → 1.12.3
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 +17 -0
- package/README.md +16 -16
- package/dist/plan-format.d.ts +23 -11
- package/dist/plan-format.js +6 -3
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/merge-guard/decision.ts +35 -2
- package/kit/workflow/skills/orchestrate/SKILL.md +7 -1
- package/kit/workflow/skills/orchestrate/references/events.md +4 -2
- package/package.json +1 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/context/session-log.ts +4 -6
- package/src/plan-format/phases.ts +22 -6
- package/src/plan-format/progress.ts +5 -8
- package/src/plans/command.ts +10 -3
- package/src/plans/current-task.ts +3 -7
- package/src/plans/focus-overlay.ts +80 -0
- package/src/plans/git.ts +6 -11
- package/src/plans/herd-state.ts +201 -0
- package/src/plans/lane-reason.ts +1 -1
- package/src/plans/lane-summary.ts +6 -2
- package/src/plans/layout-phase.ts +1 -3
- package/src/plans/layout-summary.ts +1 -1
- package/src/plans/layout.ts +32 -31
- package/src/plans/link-chips.ts +7 -16
- package/src/plans/live.ts +215 -267
- package/src/plans/markdown-text.ts +4 -0
- package/src/plans/page-sync.ts +39 -0
- package/src/plans/phase-source.ts +5 -2
- package/src/plans/reader.ts +217 -0
- package/src/plans/refresh-schedule.ts +53 -0
- package/src/plans/snapshot.ts +32 -31
- package/src/plans/status-pace.ts +7 -4
- package/src/plans/subagents-read.ts +28 -12
- package/src/plans/supervisor.ts +1 -1
- package/src/plans/view.ts +9 -101
- package/src/plans/waiting-click.ts +27 -17
- package/src/plans/worked-phases.ts +1 -1
- package/src/plans/worktree-stamp.ts +12 -9
- package/src/plans/your-turn-rows.ts +33 -32
- package/src/plans/your-turn.ts +6 -4
- package/src/shared/bounded-memo.ts +19 -0
- package/src/shared/stat-cache.ts +2 -1
- package/src/task-signals/command.ts +12 -3
- package/src/terminal/lifetime.ts +52 -0
- package/src/watch/command.ts +6 -0
- package/src/watch/herd-publisher.ts +84 -0
- package/src/watch/source-entries.ts +7 -0
- package/src/watch/source-statuses.ts +2 -2
- package/src/watch/sources.ts +5 -2
- package/src/plans/next-rows.ts +0 -12
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { fit, paint } from '../terminal/text'
|
|
2
|
+
import { type AgentOf, clickHint } from './click-hint'
|
|
3
|
+
import type { Screen } from './layout'
|
|
4
|
+
import { blockRowByKey, boldBracketAt, type FocusHit, focusLine, isFocused, LINKS_KEY_PREFIX } from './row-focus'
|
|
5
|
+
import type { Selection } from './selection'
|
|
6
|
+
import { TEXTS } from './texts'
|
|
7
|
+
|
|
8
|
+
const NO_ROW = '\0'
|
|
9
|
+
const LEAD = /^ */
|
|
10
|
+
|
|
11
|
+
/** A selection that focuses no row: the screen drawn with it is the base `focusedScreen` lays the focus on. */
|
|
12
|
+
export const unfocused = (selection: Selection): Selection => ({
|
|
13
|
+
...selection,
|
|
14
|
+
selected: NO_ROW,
|
|
15
|
+
hovered: null,
|
|
16
|
+
hoveredFrom: null,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** The blanks a block puts before a row it holds (`indented`): the focus lies right of them, as narrow as the row. */
|
|
20
|
+
const leadOf = (line: string): number => LEAD.exec(line)?.[0].length ?? 0
|
|
21
|
+
|
|
22
|
+
/** `lines` with the lines of `hit` (counted from `offset`) changed by `change`, the first line told by its index. */
|
|
23
|
+
const changedHit = (
|
|
24
|
+
lines: readonly string[],
|
|
25
|
+
offset: number,
|
|
26
|
+
hit: FocusHit | undefined,
|
|
27
|
+
change: (line: string, index: number) => string,
|
|
28
|
+
): string[] =>
|
|
29
|
+
lines.map((line, at) => {
|
|
30
|
+
const index = hit ? at + offset - hit.line : -1
|
|
31
|
+
return hit && index >= 0 && index < hit.lines ? change(line, index) : line
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `screen` with the focus `state` gives laid on one region of `lines`: the focused row on the focus background and
|
|
36
|
+
* mark, a line of links under the mouse marked alone, and the square-bracket button under the mouse bold.
|
|
37
|
+
*/
|
|
38
|
+
const focusedLines = (lines: readonly string[], offset: number, screen: Screen, state: Selection, width: number) => {
|
|
39
|
+
const hitOf = (key: string | null | undefined) => (key ? screen.focusHits.find((hit) => hit.key === key) : undefined)
|
|
40
|
+
const hovered = hitOf(state.hovered)
|
|
41
|
+
const focusedKey = state.selected !== null && isFocused(state, state.selected) ? state.selected : null
|
|
42
|
+
const focusedHit = hitOf(focusedKey)
|
|
43
|
+
const gutter = focusedHit ? leadOf(lines[focusedHit.line - offset] ?? '') : 0
|
|
44
|
+
const focused = changedHit(
|
|
45
|
+
lines,
|
|
46
|
+
offset,
|
|
47
|
+
focusedHit,
|
|
48
|
+
(line, index) => `${line.slice(0, gutter)}${focusLine(line.slice(gutter), width - gutter, index === 0)}`,
|
|
49
|
+
)
|
|
50
|
+
const links = state.hovered?.startsWith(LINKS_KEY_PREFIX)
|
|
51
|
+
? changedHit(focused, offset, hovered, (line, index) => (index === 0 ? focusLine(line, width, true) : line))
|
|
52
|
+
: focused
|
|
53
|
+
const from = state.hoveredFrom
|
|
54
|
+
return from == null
|
|
55
|
+
? links
|
|
56
|
+
: changedHit(links, offset, hovered, (line, index) => (index === 0 ? boldBracketAt(line, from) : line))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The screen a full drawing with `state` gives, laid on `plain` (drawn with `unfocused`) without a new layout: a hover
|
|
61
|
+
* only moves the focus. Null where it needs the layout: with the help open, or for a row of the blocks on top, whose
|
|
62
|
+
* buttons stand only at the selection. `agentOf` says a lane's agent, for the footer's click hint.
|
|
63
|
+
*/
|
|
64
|
+
export const focusedScreen = (plain: Screen, state: Selection, width: number, agentOf: AgentOf): Screen | null => {
|
|
65
|
+
const blocks = [plain.yourTurnBlock, plain.sourceBlock]
|
|
66
|
+
const isBlockRow = (key: string | null | undefined) => !!key && blockRowByKey(blocks, key) !== null
|
|
67
|
+
if (state.showLegend || isBlockRow(state.selected) || isBlockRow(state.hovered)) return null
|
|
68
|
+
const header = focusedLines(plain.header, 0, plain, state, width)
|
|
69
|
+
const lines = focusedLines(plain.body.lines, plain.header.length, plain, state, width)
|
|
70
|
+
const selectedHit = plain.focusHits.find(({ key }) => key === state.selected)
|
|
71
|
+
const cursorLine =
|
|
72
|
+
selectedHit && selectedHit.line >= plain.header.length ? selectedHit.line - plain.header.length : -1
|
|
73
|
+
const hint = clickHint(state, (key) => blockRowByKey(blocks, key), agentOf) ?? TEXTS.hint
|
|
74
|
+
return {
|
|
75
|
+
...plain,
|
|
76
|
+
header,
|
|
77
|
+
body: { ...plain.body, lines, cursorLine },
|
|
78
|
+
footer: [...plain.footer.slice(0, -1), fit(paint.dim(hint), width)],
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/plans/git.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
2
|
import { dirname } from 'node:path'
|
|
3
|
-
import type { LaneStatus } from './layout'
|
|
4
3
|
import { createProcessSlots } from './process-slots'
|
|
5
4
|
import type { BaseRefInfo } from './sprint-page'
|
|
6
|
-
import type { BranchStatus } from './status-pace'
|
|
5
|
+
import type { BranchStatus, WorktreeStatus } from './status-pace'
|
|
7
6
|
|
|
8
7
|
/** How many git processes the herd's reads keep in flight; more end together and hold the event loop as one block. */
|
|
9
8
|
const GIT_PROCESSES = 6
|
|
@@ -176,33 +175,29 @@ export const branchStatusOf = (output: string | null): BranchStatus => {
|
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
177
|
|
|
179
|
-
/** Reads branch and changed files of a worktree; its git process starts after `pass`. */
|
|
180
|
-
export type BranchStatusReader = (worktree: string, pass: () => Promise<void>) => Promise<BranchStatus>
|
|
181
|
-
|
|
182
178
|
/**
|
|
183
179
|
* Branch and changed files of a worktree from `git status`, the dearest of the herd's git calls in a large checkout.
|
|
184
180
|
* Without optional locks: the herd never rewrites the index, which would take the lock an agent's git needs and move
|
|
185
181
|
* the index the worktree's stamp watches (`worktreeStamp`).
|
|
186
182
|
*/
|
|
187
|
-
|
|
183
|
+
const branchStatusAt = async (worktree: string, pass: () => Promise<void>): Promise<BranchStatus> =>
|
|
188
184
|
branchStatusOf(await gitAsync(worktree, ['--no-optional-locks', 'status', '--porcelain=v2', '--branch'], pass))
|
|
189
185
|
|
|
190
186
|
/**
|
|
191
187
|
* Branch, commits ahead of `baseRef` and changed files of a worktree; null and 0 when it does not exist. The two
|
|
192
188
|
* git calls run side by side and without holding the event loop, so a key pressed meanwhile is answered at once;
|
|
193
|
-
* each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block.
|
|
194
|
-
*
|
|
189
|
+
* each starts after `pass` (a `SliceGate`), so the starts of many worktrees' calls do not add up to one block. The
|
|
190
|
+
* herd keeps the answer while nothing moved (`pacedStatus`).
|
|
195
191
|
*/
|
|
196
192
|
export const gitStatusOf = async (
|
|
197
193
|
worktree: string,
|
|
198
194
|
baseRef: string,
|
|
199
195
|
pass: () => Promise<void> = passAtOnce,
|
|
200
|
-
|
|
201
|
-
): Promise<Pick<LaneStatus, 'branch' | 'ahead' | 'dirty'>> => {
|
|
196
|
+
): Promise<WorktreeStatus> => {
|
|
202
197
|
if (!existsSync(worktree)) return { branch: null, ahead: null, dirty: 0 }
|
|
203
198
|
const [count, status] = await Promise.all([
|
|
204
199
|
gitAsync(worktree, ['rev-list', '--count', `${baseRef}..HEAD`], pass),
|
|
205
|
-
|
|
200
|
+
branchStatusAt(worktree, pass),
|
|
206
201
|
])
|
|
207
202
|
const ahead = Number.parseInt(count ?? '', 10)
|
|
208
203
|
return { ...status, ahead: Number.isNaN(ahead) ? null : ahead }
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
|
|
3
|
+
import { readPrivateText } from '../shared/private-location'
|
|
4
|
+
import type { StartedKit } from '../version/started-kit'
|
|
5
|
+
import { WATCHER_FILES, watcherDirectoryOf } from '../watch/watcher-lock'
|
|
6
|
+
import type { KnownPage } from './plan-chips'
|
|
7
|
+
import { REFRESH_PACE } from './refresh-pace'
|
|
8
|
+
import type { PlansSettings } from './settings'
|
|
9
|
+
import type { Snapshot, SnapshotDocuments } from './snapshot'
|
|
10
|
+
|
|
11
|
+
/** A read the watcher published, as the herd takes it: the snapshot, the sprint page written from it, the fetch line. */
|
|
12
|
+
export type PublishedRead = { snapshot: Snapshot; page: KnownPage | null; fetchLine: string | null }
|
|
13
|
+
|
|
14
|
+
/** The snapshot as the file holds it: the pane titles as pairs, the plan texts in a file of their own (`HerdPlans`). */
|
|
15
|
+
type StoredSnapshot = Omit<Snapshot, 'titles' | 'documents'> & {
|
|
16
|
+
titles: [string, string | null][]
|
|
17
|
+
documents: Omit<SnapshotDocuments, 'plans'>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type StoredPage = Omit<KnownPage, 'anchors'> & { anchors: [string, string][] }
|
|
21
|
+
|
|
22
|
+
/** `herd-read.json`: the published read, for the herds of the board, settings and kit `key` names. */
|
|
23
|
+
export type HerdRead = {
|
|
24
|
+
key: string
|
|
25
|
+
at: number
|
|
26
|
+
plansKey: string
|
|
27
|
+
snapshot: StoredSnapshot
|
|
28
|
+
page: StoredPage | null
|
|
29
|
+
fetchLine: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `herd-plans.json`: the plan texts of the read, written only when they changed. */
|
|
33
|
+
export type HerdPlans = { plansKey: string; plans: Record<string, string> }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `herd-beat.json`: when the watcher last read with success, for which `key`; a herd trusts the read only while this
|
|
37
|
+
* is young and names its own key, so a watcher whose reads fail lets the herds read for themselves and show why.
|
|
38
|
+
*/
|
|
39
|
+
export type HerdBeat = { key: string; at: number }
|
|
40
|
+
|
|
41
|
+
/** What the herd checks before it takes the watcher's read: the beat, its own key, and whether the watcher still runs. */
|
|
42
|
+
export type Liveness = { beat: HerdBeat | null; key: string | null; watcherRunning: boolean; now: number }
|
|
43
|
+
|
|
44
|
+
export const HERD_FILES = { read: 'herd-read.json', plans: 'herd-plans.json', beat: 'herd-beat.json' } as const
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How old the watcher's last read may be before a herd reads for itself again: three of the slowest refreshes, so one
|
|
48
|
+
* slow read or a busy watcher does not flip the herd back and forth.
|
|
49
|
+
*/
|
|
50
|
+
export const PUBLISHED_MAX_AGE_MS = 3 * REFRESH_PACE.maxMs
|
|
51
|
+
|
|
52
|
+
/** Raised when the file layout changes, so a herd never takes a read of another layout. */
|
|
53
|
+
const FORMAT = 1
|
|
54
|
+
|
|
55
|
+
const hashOf = (value: unknown): string => Bun.hash(JSON.stringify(value)).toString(36)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a herd and the watcher must share for the herd to take the watcher's read: the checkout, the board, the
|
|
59
|
+
* settings and the kit that read it (its version and where it lies, so a herd running other code reads for itself).
|
|
60
|
+
*/
|
|
61
|
+
export const publishedKey = (
|
|
62
|
+
context: { root: string; sprintFile: string; settings: PlansSettings },
|
|
63
|
+
kit: StartedKit,
|
|
64
|
+
): string => hashOf([FORMAT, kit.version, kit.packageRoot, context.root, context.sprintFile, context.settings])
|
|
65
|
+
|
|
66
|
+
/** The plan texts' key, so a herd reads `herd-plans.json` again only when they changed. */
|
|
67
|
+
export const plansKeyOf = (plans: Record<string, string>): string =>
|
|
68
|
+
hashOf(Object.entries(plans).map(([href, text]) => [href, Bun.hash(text).toString(36)]))
|
|
69
|
+
|
|
70
|
+
/** The read as the file holds it. */
|
|
71
|
+
export const storedRead = (read: PublishedRead, key: string, at: number): HerdRead => {
|
|
72
|
+
const { titles, documents, ...rest } = read.snapshot
|
|
73
|
+
const { plans, ...otherDocuments } = documents
|
|
74
|
+
return {
|
|
75
|
+
key,
|
|
76
|
+
at,
|
|
77
|
+
plansKey: plansKeyOf(plans),
|
|
78
|
+
snapshot: { ...rest, titles: [...titles], documents: otherDocuments },
|
|
79
|
+
page: read.page && { ...read.page, anchors: [...read.page.anchors] },
|
|
80
|
+
fetchLine: read.fetchLine,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The snapshot with its clocks set to `now`: the sources' ages and your turn's, which the reader draws at its own
|
|
86
|
+
* time with every read.
|
|
87
|
+
*/
|
|
88
|
+
const clockedAt = <T extends Pick<Snapshot, 'sourceBlock' | 'yourTurn'>>(snapshot: T, now: number): T => {
|
|
89
|
+
const { sourceBlock, yourTurn, ...rest } = snapshot
|
|
90
|
+
return {
|
|
91
|
+
...rest,
|
|
92
|
+
...(sourceBlock ? { sourceBlock: { ...sourceBlock, now } } : {}),
|
|
93
|
+
...(yourTurn ? { yourTurn: { ...yourTurn, now } } : {}),
|
|
94
|
+
} as T
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The read from the files, the plan texts from `plans`; `now` is the time the herd draws the ages at. */
|
|
98
|
+
export const publishedRead = (stored: HerdRead, plans: Record<string, string>, now: number): PublishedRead => {
|
|
99
|
+
const { titles, documents, ...rest } = clockedAt(stored.snapshot, now)
|
|
100
|
+
return {
|
|
101
|
+
snapshot: {
|
|
102
|
+
...rest,
|
|
103
|
+
titles: new Map(titles),
|
|
104
|
+
documents: { ...documents, plans },
|
|
105
|
+
},
|
|
106
|
+
page: stored.page && { ...stored.page, anchors: new Map(stored.page.anchors) },
|
|
107
|
+
fetchLine: stored.fetchLine,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What decides whether the read changed: everything but its time and the clocks of the sources and your turn, which
|
|
113
|
+
* move with every read while the herd draws the ages at its own time.
|
|
114
|
+
*/
|
|
115
|
+
export const readContent = ({ at: _at, snapshot, ...rest }: HerdRead): string =>
|
|
116
|
+
JSON.stringify({ ...rest, snapshot: clockedAt(snapshot, 0) })
|
|
117
|
+
|
|
118
|
+
/** What the publisher wrote last: the read's content (`readContent`) and the plan texts' key; null before the first. */
|
|
119
|
+
export type Written = { content: string | null; plansKey: string | null }
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Which files a new read rewrites: the plan texts only when their key moved, the read only when its content did. The
|
|
123
|
+
* beat is written after every read that worked, whatever this says. `content` is the read's `readContent`, kept for
|
|
124
|
+
* the next comparison, so the board is serialized once per read.
|
|
125
|
+
*/
|
|
126
|
+
export const publishDecision = (
|
|
127
|
+
written: Written,
|
|
128
|
+
stored: HerdRead,
|
|
129
|
+
): { writePlans: boolean; writeRead: boolean; content: string } => {
|
|
130
|
+
const content = readContent(stored)
|
|
131
|
+
return { writePlans: stored.plansKey !== written.plansKey, writeRead: content !== written.content, content }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The whole board's read as the herd of one lane would have read it: that lane's view and pane title only, nothing
|
|
136
|
+
* planned or unplaced, and the board's notice when the lane is not on it.
|
|
137
|
+
*/
|
|
138
|
+
export const laneRead = (snapshot: Snapshot, lane: string, laneNotListed: (lane: string) => string): Snapshot => {
|
|
139
|
+
const views = snapshot.views.filter((view) => view.progress.lane.name === lane)
|
|
140
|
+
const pane = snapshot.panes?.[lane]
|
|
141
|
+
return {
|
|
142
|
+
...snapshot,
|
|
143
|
+
title: views.length === 0 ? laneNotListed(lane) : snapshot.title,
|
|
144
|
+
views,
|
|
145
|
+
documents: { ...snapshot.documents, laneOrder: snapshot.documents.laneOrder.filter((name) => name === lane) },
|
|
146
|
+
planned: [],
|
|
147
|
+
titles: new Map([...snapshot.titles].filter(([titled]) => titled === pane)),
|
|
148
|
+
panes: pane ? { [lane]: pane } : {},
|
|
149
|
+
unplaced: [],
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a herd may take the watcher's read: the watcher runs, read for the herd's key within
|
|
155
|
+
* `PUBLISHED_MAX_AGE_MS`. A herd without a key (its kit is no release) never does.
|
|
156
|
+
*/
|
|
157
|
+
export const isPublishedLive = ({ beat, key, watcherRunning, now }: Liveness): boolean =>
|
|
158
|
+
watcherRunning && key !== null && beat !== null && beat.key === key && now - beat.at <= PUBLISHED_MAX_AGE_MS
|
|
159
|
+
|
|
160
|
+
const parsed = <T>(text: string | null, isShape: (value: Record<string, unknown>) => boolean): T | null => {
|
|
161
|
+
if (text === null) return null
|
|
162
|
+
try {
|
|
163
|
+
const value: unknown = JSON.parse(text)
|
|
164
|
+
return value && typeof value === 'object' && isShape(value as Record<string, unknown>) ? (value as T) : null
|
|
165
|
+
} catch {
|
|
166
|
+
return null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isRead = (value: Record<string, unknown>) =>
|
|
171
|
+
typeof value.key === 'string' && typeof value.plansKey === 'string' && typeof value.snapshot === 'object'
|
|
172
|
+
|
|
173
|
+
const isPlans = (value: Record<string, unknown>) =>
|
|
174
|
+
typeof value.plansKey === 'string' && typeof value.plans === 'object'
|
|
175
|
+
|
|
176
|
+
const isBeat = (value: Record<string, unknown>) => typeof value.key === 'string' && typeof value.at === 'number'
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Where the herd files of the checkout at `root` lie: the watcher's directory, which only its user may read, beside the
|
|
180
|
+
* watcher's lock, which tells whether it still runs.
|
|
181
|
+
*/
|
|
182
|
+
export const herdFilesOf = (root: string): Record<keyof typeof HERD_FILES | 'lock', string> => {
|
|
183
|
+
const directory = watcherDirectoryOf(root)
|
|
184
|
+
return {
|
|
185
|
+
read: join(directory, HERD_FILES.read),
|
|
186
|
+
plans: join(directory, HERD_FILES.plans),
|
|
187
|
+
beat: join(directory, HERD_FILES.beat),
|
|
188
|
+
lock: join(directory, WATCHER_FILES.lock),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** The published read; null for a missing, damaged or foreign file. */
|
|
193
|
+
export const readHerdRead = (path: string): HerdRead | null => parsed<HerdRead>(readPrivateText(path), isRead)
|
|
194
|
+
|
|
195
|
+
export const readHerdPlans = (path: string): HerdPlans | null => parsed<HerdPlans>(readPrivateText(path), isPlans)
|
|
196
|
+
|
|
197
|
+
export const readHerdBeat = (path: string): HerdBeat | null => parsed<HerdBeat>(readPrivateText(path), isBeat)
|
|
198
|
+
|
|
199
|
+
/** Writes a file through a temporary one, so a herd woken by the change never reads half of it. */
|
|
200
|
+
export const writeHerdFile = (path: string, value: HerdRead | HerdPlans | HerdBeat): void =>
|
|
201
|
+
writePrivateFile(path, JSON.stringify(value))
|
package/src/plans/lane-reason.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SprintLane } from '../plan-format'
|
|
|
2
2
|
import { AGENT_STATUS, isRestingAgent } from '../terminal/types'
|
|
3
3
|
import { REST_QUESTION_COUNT } from '../watch/lane-rest'
|
|
4
4
|
import { waitingNote } from './lane-standing'
|
|
5
|
-
import { plain } from './
|
|
5
|
+
import { plain } from './markdown-text'
|
|
6
6
|
import { shortTitle } from './task-name'
|
|
7
7
|
import { TEXTS } from './texts'
|
|
8
8
|
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
isPhaseFinished,
|
|
3
3
|
isPhaseOpen,
|
|
4
4
|
type LaneProgress,
|
|
5
|
+
PHASE_WORK,
|
|
5
6
|
type PhaseProgress,
|
|
6
7
|
type PlanPhase,
|
|
7
8
|
type PlansByHref,
|
|
@@ -130,6 +131,9 @@ const namedIds = ({ steps }: LaneProgress, href: string): Set<string> =>
|
|
|
130
131
|
steps.flatMap(({ step }) => (step.planHref === href ? [...step.phaseIds, ...(step.namedPhaseIds ?? [])] : [])),
|
|
131
132
|
)
|
|
132
133
|
|
|
134
|
+
/** The base branch carries the phase's ticks: the worktree left them as they stand, or a run of it was merged. */
|
|
135
|
+
const baseCarriesTicks = ({ work }: PhaseProgress): boolean => work === undefined || work === PHASE_WORK.merged
|
|
136
|
+
|
|
133
137
|
/**
|
|
134
138
|
* Whether a phase is a merged leftover: an older board row named it, the newest row does not, and the base branch
|
|
135
139
|
* carries its ticks as they stand (not `touched`), so its open boxes were left open on purpose, or carries ticks of
|
|
@@ -140,7 +144,7 @@ const mergedLeftoverOf = ({ steps }: LaneProgress): IsLeftover => {
|
|
|
140
144
|
const row = [...steps].reverse().find(({ step }) => !step.withoutRow)?.step
|
|
141
145
|
if (!row) return () => false
|
|
142
146
|
const rowIds = new Set(row.namedPhaseIds ?? row.phaseIds)
|
|
143
|
-
return (phase) => (phase
|
|
147
|
+
return (phase) => baseCarriesTicks(phase) && !isNamedBy(phase.id, rowIds)
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
/**
|
|
@@ -173,7 +177,7 @@ type IsLeftover = (phase: PhaseProgress) => boolean
|
|
|
173
177
|
* its movement nor the latest movement makes it run. A leftover without that evidence still runs once it moves.
|
|
174
178
|
*/
|
|
175
179
|
const isMergedRun = (phase: PhaseProgress, isLeftover: IsLeftover): boolean =>
|
|
176
|
-
phase.
|
|
180
|
+
phase.work === PHASE_WORK.merged && isLeftover(phase)
|
|
177
181
|
|
|
178
182
|
/** What decides which open phases wait and in which order: the ids that run, and the merged leftovers. */
|
|
179
183
|
type WaitingContext = { running: ReadonlySet<string>; isLeftover: IsLeftover }
|
|
@@ -8,6 +8,7 @@ import type { ActiveTask, UnplannedTask } from './active-tasks'
|
|
|
8
8
|
import { type Frame, joinFrames, rowFrame, textFrame } from './frame'
|
|
9
9
|
import type { LaneTickets } from './lane-tickets'
|
|
10
10
|
import type { LinkChip } from './link-chips'
|
|
11
|
+
import { plain } from './markdown-text'
|
|
11
12
|
import type { PhaseModel } from './phase-models'
|
|
12
13
|
import { itemKey, type Selection, taskRestKey } from './selection'
|
|
13
14
|
import { shimmering, stillTeal } from './shimmer'
|
|
@@ -61,7 +62,6 @@ const COUNT_PLACES = 3
|
|
|
61
62
|
const COUNT_MARK_WIDTH = 2
|
|
62
63
|
const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
|
|
63
64
|
const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
|
|
64
|
-
const MARKDOWN_MARKS = /\*\*|`/g
|
|
65
65
|
const CODE_SPAN = /`([^`]+)`/g
|
|
66
66
|
|
|
67
67
|
const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
|
|
@@ -78,8 +78,6 @@ const PHASE_MARK: Record<PhaseState, string> = {
|
|
|
78
78
|
[PHASE_STATE.open]: '',
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
|
|
82
|
-
|
|
83
81
|
/** What a task reads as on one row: its short name where it carries one (`taskNameOf`), else its whole text. */
|
|
84
82
|
export const taskTitle = (text: string): string => plain(taskNameOf(text) ?? text)
|
|
85
83
|
|
|
@@ -11,13 +11,13 @@ import {
|
|
|
11
11
|
nowLine,
|
|
12
12
|
phaseChildren,
|
|
13
13
|
phaseCountCell,
|
|
14
|
-
plain,
|
|
15
14
|
subagentLine,
|
|
16
15
|
TASK_INDENT,
|
|
17
16
|
type TaskStyle,
|
|
18
17
|
taskRowFrame,
|
|
19
18
|
taskTitle,
|
|
20
19
|
} from './layout-phase'
|
|
20
|
+
import { plain } from './markdown-text'
|
|
21
21
|
import { type PhaseModel, phaseModelKey } from './phase-models'
|
|
22
22
|
import { phaseRanges } from './phase-ranges'
|
|
23
23
|
import { finishedKey, nextKey, phaseKey } from './selection'
|
package/src/plans/layout.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { TONE_ROLE } from '../config/tones'
|
|
1
|
+
import { TONE, TONE_ROLE } from '../config/tones'
|
|
2
2
|
import { CONTEXT_LEVEL, type ContextLevel, type ContextReading, contextLabel } from '../context'
|
|
3
3
|
import type { LaneProgress, PhaseProgress } from '../plan-format'
|
|
4
4
|
import { currentPhase, isPhaseOpen, phaseLabel } from '../plan-format'
|
|
5
5
|
import type { WaitingItem } from '../plan-format/waiting'
|
|
6
|
+
import { boundedMemo } from '../shared/bounded-memo'
|
|
6
7
|
import {
|
|
7
8
|
BADGE_TONE,
|
|
8
9
|
type BadgeTone,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
bar,
|
|
11
12
|
box,
|
|
12
13
|
bracket,
|
|
14
|
+
ESC,
|
|
13
15
|
fit,
|
|
14
16
|
link,
|
|
15
17
|
packGroups,
|
|
@@ -51,7 +53,6 @@ import {
|
|
|
51
53
|
type LaneContext,
|
|
52
54
|
MIN_TITLE_WIDTH,
|
|
53
55
|
nowLine,
|
|
54
|
-
plain,
|
|
55
56
|
SHIMMERING_TASK,
|
|
56
57
|
STILL_TASK,
|
|
57
58
|
type TaskStyle,
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
import { foldedTaskLine, openLaneFrame } from './layout-summary'
|
|
60
61
|
import { legendLines } from './legend'
|
|
61
62
|
import { chipKey, type LinkChip, planUrlOf } from './link-chips'
|
|
63
|
+
import { plain } from './markdown-text'
|
|
62
64
|
import type { PhaseModel } from './phase-models'
|
|
63
65
|
import { type PlannedPlan, plannedNextLabel } from './planned'
|
|
64
66
|
import { CHECKS, type ChecksState, failedTestKey, type PullRequest, pullRequestKey } from './pull-requests'
|
|
@@ -205,8 +207,6 @@ type TopBlock = {
|
|
|
205
207
|
/** What the head says after the count, painted, open and folded alike; empty for nothing. */
|
|
206
208
|
balance: string
|
|
207
209
|
markPaint: (text: string) => string
|
|
208
|
-
/** The paint of the buttons of a row: amber for "Your turn", the normal foreground colour elsewhere. */
|
|
209
|
-
buttonPaint: (text: string) => string
|
|
210
210
|
/** Whether a row is the selected one or under the mouse: it gets the background, the mark, and its buttons. */
|
|
211
211
|
focused: (key: string) => boolean
|
|
212
212
|
/** Whether all buttons of a row are bold: the arrows selected it and the mouse is not on it (the mouse bolds one). */
|
|
@@ -495,17 +495,11 @@ const TALLY_SEPARATOR = ' · '
|
|
|
495
495
|
/** How many cut chip labels the render keeps; a label is often a whole line of a plan, measured anew every frame otherwise. */
|
|
496
496
|
const KEPT_LABELS = 2000
|
|
497
497
|
|
|
498
|
-
const cutLabels =
|
|
498
|
+
const cutLabels = boundedMemo<string, string>(KEPT_LABELS)
|
|
499
499
|
|
|
500
500
|
/** The chip label cut to `CHIP_LABEL_WIDTH`, kept per label; the kept ones are dropped all at once when they are too many. */
|
|
501
|
-
const cutLabel = (label: string): string =>
|
|
502
|
-
|
|
503
|
-
if (kept !== undefined) return kept
|
|
504
|
-
if (cutLabels.size >= KEPT_LABELS) cutLabels.clear()
|
|
505
|
-
const cut = fit(plain(label), CHIP_LABEL_WIDTH)
|
|
506
|
-
cutLabels.set(label, cut)
|
|
507
|
-
return cut
|
|
508
|
-
}
|
|
501
|
+
const cutLabel = (label: string): string =>
|
|
502
|
+
cutLabels.get(label) ?? cutLabels.set(label, fit(plain(label), CHIP_LABEL_WIDTH))
|
|
509
503
|
|
|
510
504
|
/** `[🔗 label]` linked to the chip's address, `private` faint inside it for a link its plan says is private. */
|
|
511
505
|
const chipCell = ({ url, icon, label, private: hidden }: LinkChip): string =>
|
|
@@ -1012,6 +1006,9 @@ const plannedRows = (planned: PlannedPlan[], width: number, indent: string): str
|
|
|
1012
1006
|
|
|
1013
1007
|
const rule = (width: number): string => paint.dim(RULE.repeat(width))
|
|
1014
1008
|
|
|
1009
|
+
/** The one colour of the buttons of "Your turn" and "Sources" (`[ask]`, `[done]`, `[run]`, `[move]`). */
|
|
1010
|
+
const ACTION_PAINT = paintOfTone(TONE.teal)
|
|
1011
|
+
|
|
1015
1012
|
/** The blank that starts every line of a block on top: no bar, the mark of a row says what it is. */
|
|
1016
1013
|
const blockBar = (): string => ' '
|
|
1017
1014
|
|
|
@@ -1033,35 +1030,41 @@ const markedLine = (line: string, row: BlockRow, markPaint: (text: string) => st
|
|
|
1033
1030
|
return line.startsWith(row.mark) ? `${paintMark(row.mark)}${line.slice(row.mark.length)}` : line
|
|
1034
1031
|
}
|
|
1035
1032
|
|
|
1036
|
-
/**
|
|
1033
|
+
/**
|
|
1034
|
+
* The text from `from` on dimmed, without the blanks that pad it to the right edge. `from` counts in the line the row
|
|
1035
|
+
* measured it on; a line cut shorter for the buttons (`row.hot`) ends in a colour code, and a `from` past its start
|
|
1036
|
+
* would tear that code, so such a line stays undimmed from there.
|
|
1037
|
+
*/
|
|
1037
1038
|
const dimmedRest = (text: string, from: number): string => {
|
|
1038
|
-
const
|
|
1039
|
+
const code = text.indexOf(ESC)
|
|
1040
|
+
const end = code === -1 || from < code ? text.trimEnd().length : from
|
|
1039
1041
|
return from >= end ? text : `${text.slice(0, from)}${paint.dim(text.slice(from, end))}${text.slice(end)}`
|
|
1040
1042
|
}
|
|
1041
1043
|
|
|
1042
1044
|
/**
|
|
1043
1045
|
* One row as lines: the bar on each, the mark painted on the first line only, the short title in the normal colour
|
|
1044
|
-
* and the rest of an expanded row dim, linked when the row has an address. The
|
|
1045
|
-
*
|
|
1046
|
+
* and the rest of an expanded row dim, linked when the row has an address. The text takes the whole pane; only the
|
|
1047
|
+
* row under the selection or the mouse draws its buttons, in the action colour (`ACTION_PAINT`), flush right at the
|
|
1048
|
+
* edge of its first line, over the end of its text (`row.hot`) and, in "Your turn", its time.
|
|
1046
1049
|
*/
|
|
1047
|
-
const blockRowLines = (
|
|
1048
|
-
{ markPaint, buttonPaint, focused, allBold }: TopBlock,
|
|
1049
|
-
row: BlockRow,
|
|
1050
|
-
width: number,
|
|
1051
|
-
): string[] => {
|
|
1050
|
+
const blockRowLines = ({ markPaint, focused, allBold }: TopBlock, row: BlockRow, width: number): string[] => {
|
|
1052
1051
|
const hot = focused(row.key)
|
|
1053
|
-
const paintButton = (text: string) => (allBold(row.key) ? paint.bold(
|
|
1052
|
+
const paintButton = (text: string) => (allBold(row.key) ? paint.bold(ACTION_PAINT(text)) : ACTION_PAINT(text))
|
|
1054
1053
|
return row.lines.map((line, index) => {
|
|
1055
|
-
const
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
1058
|
-
const shown = row.url ? link(row.url, marked) : marked
|
|
1059
|
-
const buttons = withButton ? buttonsText(row.buttons, row.dim ? paint.dim : paintButton) : ''
|
|
1054
|
+
const covered = hot && index === 0 ? row.hot : undefined
|
|
1055
|
+
const shown = rowText(row, covered ?? line, index, markPaint)
|
|
1056
|
+
const buttons = covered === undefined ? '' : ` ${buttonsText(row.buttons, row.dim ? paint.dim : paintButton)}`
|
|
1060
1057
|
const drawn = `${blockBar()} ${shown}${buttons}`
|
|
1061
1058
|
return hot ? focusLine(drawn, width, index === 0) : drawn
|
|
1062
1059
|
})
|
|
1063
1060
|
}
|
|
1064
1061
|
|
|
1062
|
+
/** The text of a row's line `index`: dim from its `dimFrom` on, the mark painted on the first line, linked to its address. */
|
|
1063
|
+
const rowText = (row: BlockRow, text: string, index: number, markPaint: (text: string) => string): string => {
|
|
1064
|
+
const styled = dimmedRest(text, row.dimFrom[index] ?? text.length)
|
|
1065
|
+
const marked = index === 0 ? markedLine(styled, row, markPaint) : styled
|
|
1066
|
+
return row.url ? link(row.url, marked) : marked
|
|
1067
|
+
}
|
|
1065
1068
|
/** A folded block: its head alone, which keeps the count and the balance; a click matches no row and no `+<n>` line. */
|
|
1066
1069
|
const foldedBlockLines = (block: TopBlock, headKey: string, width: number): BlockLines => ({
|
|
1067
1070
|
open: false,
|
|
@@ -1132,14 +1135,13 @@ const yourTurnLines = ({ turn, now }: YourTurnBlock, state: Selection, width: nu
|
|
|
1132
1135
|
count,
|
|
1133
1136
|
balance: balanceText(turnBalance(turn, clock)),
|
|
1134
1137
|
markPaint: turnMarkPaint,
|
|
1135
|
-
buttonPaint: paintOfRole(TONE_ROLE.waiting),
|
|
1136
1138
|
focused: (key) => isFocused(state, key),
|
|
1137
1139
|
allBold: (key) => buttonsAllBold(state, key),
|
|
1138
1140
|
countPaint: turn.now.length > 0 ? paintOfRole(TONE_ROLE.waiting) : (text) => text,
|
|
1139
1141
|
moreKey: null,
|
|
1140
1142
|
hidden: 0,
|
|
1141
1143
|
expanded: false,
|
|
1142
|
-
rows: turnRows(turnPage(turn, opened, clock), width),
|
|
1144
|
+
rows: turnRows(turnPage(turn, opened, clock), width, state.expanded),
|
|
1143
1145
|
}
|
|
1144
1146
|
return blockLinesOf(block, state, TOP_BLOCK.yourTurn, width)
|
|
1145
1147
|
}
|
|
@@ -1169,7 +1171,6 @@ const sourceLines = (
|
|
|
1169
1171
|
count: null,
|
|
1170
1172
|
balance: sourceSummaryText(sourceSummaries(sources, now, newTone, asked)),
|
|
1171
1173
|
markPaint: paint.dim,
|
|
1172
|
-
buttonPaint: (text) => text,
|
|
1173
1174
|
focused: (key) => isFocused(state, key),
|
|
1174
1175
|
allBold: (key) => buttonsAllBold(state, key),
|
|
1175
1176
|
countPaint: (text) => text,
|
package/src/plans/link-chips.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { ARTIFACT_LINK_KIND, type LinkKind } from '../config/link-kinds'
|
|
2
|
+
import { type BoundedMemo, boundedMemo } from '../shared/bounded-memo'
|
|
3
|
+
import { plain } from './markdown-text'
|
|
2
4
|
|
|
3
5
|
/** A link of a plan that matches a link kind, as the sprint page shows it. */
|
|
4
6
|
export type LinkChip = { url: string; label: string; icon: string; private: boolean }
|
|
@@ -10,7 +12,6 @@ const AUTOLINK = /<(https?:\/\/[^>\s]+)>/g
|
|
|
10
12
|
const BARE_LINK = /(?<![<\w"])(https?:\/\/[^\s)>\]]+)/g
|
|
11
13
|
const LABEL_BEFORE = /\*\*([^*]+?):?\*\*:?\s*(?:[-–>]\s*)?$/
|
|
12
14
|
const PRIVATE = /\bprivat/i
|
|
13
|
-
const MARKS = /\*\*|`/g
|
|
14
15
|
const CONTROL = /\p{Cc}/gu
|
|
15
16
|
|
|
16
17
|
const linksOf = (line: string): Found[] => {
|
|
@@ -33,19 +34,14 @@ const TITLE_END = /[\s:,;.\-–>(]+$/
|
|
|
33
34
|
|
|
34
35
|
/** What a line says besides its links, without list marks, checkbox and emphasis: the title of a bare artifact link. */
|
|
35
36
|
const lineTitleOf = ({ line }: Found): string =>
|
|
36
|
-
line
|
|
37
|
-
.replace(MARKDOWN_LINK, '$1')
|
|
38
|
-
.replace(AUTOLINK, '')
|
|
39
|
-
.replace(LEFTOVER_LINK, '')
|
|
40
|
-
.replace(LIST_LEAD, '')
|
|
41
|
-
.replace(MARKS, '')
|
|
37
|
+
plain(line.replace(MARKDOWN_LINK, '$1').replace(AUTOLINK, '').replace(LEFTOVER_LINK, '').replace(LIST_LEAD, ''))
|
|
42
38
|
.replace(CONTROL, '')
|
|
43
39
|
.replace(TITLE_END, '')
|
|
44
40
|
.trim()
|
|
45
41
|
|
|
46
42
|
const labelOf = (found: Found, kind: LinkKind): string => {
|
|
47
43
|
const written = found.text ?? LABEL_BEFORE.exec(found.before)?.[1]
|
|
48
|
-
const own = written
|
|
44
|
+
const own = written && plain(written).replace(CONTROL, '').trim()
|
|
49
45
|
if (own) return own
|
|
50
46
|
return (kind.pattern === ARTIFACT_LINK_KIND.pattern && lineTitleOf(found)) || kind.label
|
|
51
47
|
}
|
|
@@ -53,7 +49,7 @@ const labelOf = (found: Found, kind: LinkKind): string => {
|
|
|
53
49
|
/** How many texts the chips are kept for, per list of kinds; the kept ones are dropped all at once when they are too many. */
|
|
54
50
|
const KEPT_TEXTS = 200
|
|
55
51
|
|
|
56
|
-
const keptChips = new WeakMap<readonly LinkKind[],
|
|
52
|
+
const keptChips = new WeakMap<readonly LinkKind[], BoundedMemo<string, LinkChip[]>>()
|
|
57
53
|
|
|
58
54
|
/** `extractLinkChips` without its cache, for a reader that goes through more texts than the cache keeps. */
|
|
59
55
|
export const readLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
|
|
@@ -81,14 +77,9 @@ export const readLinkChips = (markdown: string, kinds: readonly LinkKind[]): Lin
|
|
|
81
77
|
* and list of kinds, so a refresh and lanes sharing a plan get the same array back: callers must not change it.
|
|
82
78
|
*/
|
|
83
79
|
export const extractLinkChips = (markdown: string, kinds: readonly LinkKind[]): LinkChip[] => {
|
|
84
|
-
const byText = keptChips.get(kinds) ??
|
|
80
|
+
const byText = keptChips.get(kinds) ?? boundedMemo<string, LinkChip[]>(KEPT_TEXTS)
|
|
85
81
|
keptChips.set(kinds, byText)
|
|
86
|
-
|
|
87
|
-
if (kept) return kept
|
|
88
|
-
if (byText.size >= KEPT_TEXTS) byText.clear()
|
|
89
|
-
const chips = readLinkChips(markdown, kinds)
|
|
90
|
-
byText.set(markdown, chips)
|
|
91
|
-
return chips
|
|
82
|
+
return byText.get(markdown) ?? byText.set(markdown, readLinkChips(markdown, kinds))
|
|
92
83
|
}
|
|
93
84
|
|
|
94
85
|
const CHIP_KEY = /^chip>(https?:\/\/.+)$/
|