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.
Files changed (94) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +76 -58
  3. package/dist/{chunk-RMMXD4G5.js → chunk-V4HOLGFP.js} +2 -2
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +15 -5
  6. package/dist/plan-format.d.ts +11 -6
  7. package/dist/plan-format.js +8 -2
  8. package/docs/follow-dark.svg +2 -2
  9. package/docs/follow.svg +2 -2
  10. package/docs/workflow-dark.svg +56 -64
  11. package/docs/workflow.svg +56 -64
  12. package/herdr-plugin/herdr-plugin.toml +1 -1
  13. package/install.sh +1 -1
  14. package/kit/workflow/skills/implement/SKILL.md +1 -1
  15. package/kit/workflow/skills/orchestrate/SKILL.md +1 -1
  16. package/kit/workflow/skills/orchestrate/references/draft-review.md +4 -0
  17. package/kit/workflow/skills/orchestrate/references/events.md +8 -1
  18. package/kit/workflow/skills/plan/SKILL.md +2 -0
  19. package/package.json +2 -2
  20. package/schemas/jackherd.schema.json +18 -1
  21. package/src/config/link-kinds.ts +1 -1
  22. package/src/config/schema.ts +5 -2
  23. package/src/config/tones.ts +9 -2
  24. package/src/context/fill.ts +4 -10
  25. package/src/context/session-log.ts +108 -2
  26. package/src/plan-format/lanes.ts +10 -1
  27. package/src/plan-format/phases.ts +11 -3
  28. package/src/plan-format/progress.ts +4 -2
  29. package/src/plan-format/waiting.ts +12 -0
  30. package/src/plans/active-tasks.ts +31 -8
  31. package/src/plans/appointment-rows.ts +42 -53
  32. package/src/plans/board-rows.ts +14 -0
  33. package/src/plans/calendar-days.ts +29 -0
  34. package/src/plans/click-hint.ts +94 -0
  35. package/src/plans/command.ts +8 -9
  36. package/src/plans/current-phase.ts +22 -10
  37. package/src/plans/current-task.ts +18 -3
  38. package/src/plans/frame.ts +18 -26
  39. package/src/plans/git.ts +90 -31
  40. package/src/plans/groomed.ts +13 -7
  41. package/src/plans/lane-reason.ts +61 -0
  42. package/src/plans/lane-summary.ts +75 -24
  43. package/src/plans/layout-phase.ts +123 -76
  44. package/src/plans/layout-summary.ts +133 -83
  45. package/src/plans/layout.ts +451 -345
  46. package/src/plans/legend.ts +29 -0
  47. package/src/plans/link-chips.ts +26 -8
  48. package/src/plans/live.ts +238 -84
  49. package/src/plans/next-rows.ts +5 -37
  50. package/src/plans/phase-source.ts +8 -3
  51. package/src/plans/plan-chips.ts +6 -3
  52. package/src/plans/plan-heads.ts +1 -1
  53. package/src/plans/planned.ts +4 -1
  54. package/src/plans/process-slots.ts +36 -0
  55. package/src/plans/pull-requests.ts +9 -1
  56. package/src/plans/read-cache.ts +18 -3
  57. package/src/plans/refresh-pace.ts +13 -0
  58. package/src/plans/row-focus.ts +86 -15
  59. package/src/plans/selection.ts +124 -89
  60. package/src/plans/sent-files.ts +1 -1
  61. package/src/plans/settings.ts +5 -2
  62. package/src/plans/shimmer.ts +124 -0
  63. package/src/plans/signals-read.ts +38 -10
  64. package/src/plans/slice-gate.ts +51 -0
  65. package/src/plans/snapshot.ts +185 -84
  66. package/src/plans/source-asked.ts +1 -1
  67. package/src/plans/source-rows.ts +60 -18
  68. package/src/plans/sprint-html.ts +1 -1
  69. package/src/plans/status-pace.ts +45 -0
  70. package/src/plans/subagent-tasks.ts +1 -1
  71. package/src/plans/subagents-read.ts +61 -24
  72. package/src/plans/subagents.ts +2 -2
  73. package/src/plans/symbols.ts +4 -1
  74. package/src/plans/task-brief.ts +143 -0
  75. package/src/plans/texts.ts +181 -39
  76. package/src/plans/view.ts +198 -32
  77. package/src/plans/waiting-click.ts +54 -110
  78. package/src/plans/watch-burst.ts +25 -0
  79. package/src/plans/worktree-stamp.ts +35 -0
  80. package/src/plans/your-turn-rows.ts +102 -0
  81. package/src/plans/your-turn.ts +226 -0
  82. package/src/terminal/herdr.ts +25 -5
  83. package/src/terminal/restore.ts +44 -0
  84. package/src/terminal/text.ts +2 -2
  85. package/src/terminal/width.ts +9 -0
  86. package/src/vocabulary/index.ts +6 -5
  87. package/src/watch/command.ts +28 -2
  88. package/src/watch/lane-report.ts +2 -0
  89. package/src/watch/lane-rest.ts +7 -1
  90. package/src/watch/reference-paths.ts +1 -0
  91. package/src/watch/unlinked-artifacts-read.ts +43 -0
  92. package/src/watch/unlinked-artifacts.ts +76 -0
  93. package/src/watch/watch-events.ts +12 -0
  94. package/src/watch/watcher-lock.ts +47 -4
@@ -0,0 +1,226 @@
1
+ import { DAY_MS, twoDigits } from '../plan-format/clock'
2
+ import type { WaitingItem } from '../plan-format/waiting'
3
+ import type { PlanLanguage } from '../vocabulary'
4
+ import { parseAppointmentTime } from '../watch/appointments'
5
+ import { type AppointmentRow, appointmentRows, TIME_OPEN } from './appointment-rows'
6
+ import { calendarDaysBetween } from './calendar-days'
7
+ import { plain } from './layout-phase'
8
+ import { nextLines } from './next-rows'
9
+ import { shortTitle } from './task-name'
10
+ import { waitingKey } from './waiting-click'
11
+
12
+ /** `now` for what waits on the user's answer, `planned` for what has (or still needs) a time of its own. */
13
+ export const TURN_KIND = { now: 'now', planned: 'planned' } as const
14
+
15
+ export type TurnKind = (typeof TURN_KIND)[keyof typeof TURN_KIND]
16
+
17
+ /**
18
+ * One topic of "Your turn": its text with the source's prefix, the time column (`since <day>`, `<weekday> <day> <time>`,
19
+ * `time open`), `at` the time behind it in epoch milliseconds (the since of a now row, the appointment of a planned
20
+ * one; null where none can be read) and the lanes it frees. `waiting` and `appointment` are the rows it was made of:
21
+ * they carry what its buttons close, ask and open.
22
+ */
23
+ export type TurnRow = {
24
+ key: string
25
+ kind: TurnKind
26
+ text: string
27
+ time: string
28
+ at: number | null
29
+ unblocks: readonly string[]
30
+ waiting?: WaitingItem
31
+ appointment?: AppointmentRow
32
+ }
33
+
34
+ /** "Your turn": the now rows, what blocks a lane first, then the oldest; the planned rows, those without a time first. */
35
+ export type YourTurn = { now: TurnRow[]; planned: TurnRow[] }
36
+
37
+ /** "Your turn" as the herd draws it, with the clock (epoch milliseconds) its ages and the week ahead are counted from. */
38
+ export type YourTurnBlock = { turn: YourTurn; now: number }
39
+
40
+ /** Where "Your turn" is read from: the open board rows, questions and files, the open appointments, the next lines. */
41
+ export type TurnSources = {
42
+ waiting: readonly WaitingItem[]
43
+ appointments: readonly AppointmentRow[]
44
+ next: readonly string[]
45
+ now: Date
46
+ }
47
+
48
+ /**
49
+ * What the open block shows: the now rows within the cap, the line for the rest and the rest below it once opened,
50
+ * the planned rows, how many later and those below their line once opened, and which of the two lines is open.
51
+ */
52
+ export type TurnPage = {
53
+ now: TurnRow[]
54
+ nowMore: { count: number; older: number } | null
55
+ nowRest: TurnRow[]
56
+ planned: TurnRow[]
57
+ later: number
58
+ plannedRest: TurnRow[]
59
+ opened: TurnOpened
60
+ }
61
+
62
+ /** The parts of the head's balance; the head paints what blocks a lane in the tone of what waits. */
63
+ export type TurnBalance = { blocking: string | null; oldest: string | null }
64
+
65
+ /** Whether the more line and the later line are open and show every row. */
66
+ export type TurnOpened = { allNow: boolean; allPlanned: boolean }
67
+
68
+ /** How many now rows show before `▸ <n> more`. */
69
+ export const SHOWN_NOW_ROWS = 5
70
+ /** How many planned rows show before `▸ <n> later`, and how many days ahead they may lie. */
71
+ export const SHOWN_PLANNED_ROWS = 3
72
+ const PLANNED_DAYS = 7
73
+ /** From how many calendar days back a now row counts as old in the more line. */
74
+ export const OLD_DAYS = 7
75
+ const SINCE = 'since'
76
+ const START_OF_DAY = '00:00'
77
+
78
+ const topicOf = (text: string): string => shortTitle(plain(text)).trim().toLowerCase()
79
+
80
+ const sameTopic = (left: string, right: string): boolean =>
81
+ topicOf(left) === topicOf(right) || left.trim().toLowerCase() === right.trim().toLowerCase()
82
+
83
+ /** The since of a board row, a question or a file as a time; a day without a time of day counts from its start. */
84
+ const sinceTime = (since: string, now: Date): number | null =>
85
+ (parseAppointmentTime(since, now) ?? parseAppointmentTime(`${since} ${START_OF_DAY}`, now))?.getTime() ?? null
86
+
87
+ /** `since 18.10.`: the day of the since; the since as written when it names no day. */
88
+ const sinceText = (since: string, at: number | null): string => {
89
+ if (at === null) return `${SINCE} ${since}`.trim()
90
+ const day = new Date(at)
91
+ return `${SINCE} ${twoDigits(day.getDate())}.${twoDigits(day.getMonth() + 1)}.`
92
+ }
93
+
94
+ const withPrefix = (prefix: string | undefined, text: string): string => (prefix ? `${prefix}: ${text}` : text)
95
+
96
+ const nowRowOf = (item: WaitingItem, now: Date): TurnRow => {
97
+ const at = sinceTime(item.since, now)
98
+ return {
99
+ key: waitingKey(item),
100
+ kind: TURN_KIND.now,
101
+ text: withPrefix(item.prefix, plain(item.item)),
102
+ time: sinceText(item.since, at),
103
+ at,
104
+ unblocks: item.unblocks ?? [],
105
+ waiting: item,
106
+ }
107
+ }
108
+
109
+ const plannedRowOf = (row: AppointmentRow): TurnRow => ({
110
+ key: `appointment>${row.key}`,
111
+ kind: TURN_KIND.planned,
112
+ text: row.action,
113
+ time: row.label,
114
+ at: row.at,
115
+ unblocks: [],
116
+ appointment: row,
117
+ })
118
+
119
+ const nextRowOf = (text: string): TurnRow => ({
120
+ key: `next>${text}`,
121
+ kind: TURN_KIND.planned,
122
+ text,
123
+ time: TIME_OPEN,
124
+ at: null,
125
+ unblocks: [],
126
+ })
127
+
128
+ const topicText = (row: TurnRow): string => row.waiting?.item ?? row.appointment?.action ?? row.text
129
+
130
+ const unionOf = (left: readonly string[], right: readonly string[]): string[] => [...new Set([...left, ...right])]
131
+
132
+ /** The earlier of two now rows of one topic keeps its place; it takes the other's lanes. */
133
+ const mergedNow = (kept: TurnRow, other: TurnRow): TurnRow => ({
134
+ ...kept,
135
+ unblocks: unionOf(kept.unblocks, other.unblocks),
136
+ })
137
+
138
+ /** A now row of the appointment's topic becomes the planned row, and keeps what it frees and the board row it was. */
139
+ const plannedFrom = (row: TurnRow | undefined, appointment: AppointmentRow): TurnRow => {
140
+ const planned = plannedRowOf(appointment)
141
+ return row ? { ...planned, unblocks: row.unblocks, ...(row.waiting ? { waiting: row.waiting } : {}) } : planned
142
+ }
143
+
144
+ const byBlockingThenAge = (left: TurnRow, right: TurnRow): number =>
145
+ Number(right.unblocks.length > 0) - Number(left.unblocks.length > 0) ||
146
+ (left.at ?? Number.POSITIVE_INFINITY) - (right.at ?? Number.POSITIVE_INFINITY)
147
+
148
+ const byTime = (left: TurnRow, right: TurnRow): number =>
149
+ (left.at ?? Number.NEGATIVE_INFINITY) - (right.at ?? Number.NEGATIVE_INFINITY)
150
+
151
+ const withNowRow = (rows: TurnRow[], row: TurnRow): TurnRow[] => {
152
+ const index = rows.findIndex((known) => sameTopic(topicText(known), topicText(row)))
153
+ return index === -1 ? [...rows, row] : rows.map((known, at) => (at === index ? mergedNow(known, row) : known))
154
+ }
155
+
156
+ /**
157
+ * "Your turn" from its sources, one row per topic: a topic is the short title (the text up to its first `: `), in any
158
+ * case; nothing else is guessed. Board rows, questions and files are now rows, one topic merged into the first; an
159
+ * appointment takes the now row of its topic along and is planned; a next line of a topic already shown is left out,
160
+ * one of a new topic is planned without a time.
161
+ */
162
+ export const yourTurnOf = ({ waiting, appointments, next, now }: TurnSources): YourTurn => {
163
+ let nowRows = waiting.map((item) => nowRowOf(item, now)).reduce<TurnRow[]>(withNowRow, [])
164
+ const planned: TurnRow[] = []
165
+ for (const appointment of appointments) {
166
+ if (planned.some((row) => sameTopic(topicText(row), appointment.action))) continue
167
+ const taken = nowRows.find((row) => sameTopic(topicText(row), appointment.action))
168
+ nowRows = nowRows.filter((row) => row !== taken)
169
+ planned.push(plannedFrom(taken, appointment))
170
+ }
171
+ const shown = [...nowRows, ...planned]
172
+ const nextRows = next.filter((text) => !shown.some((row) => sameTopic(topicText(row), text))).map(nextRowOf)
173
+ return { now: [...nowRows].sort(byBlockingThenAge), planned: [...planned, ...nextRows].sort(byTime) }
174
+ }
175
+
176
+ /** "Your turn" of a board: `waiting` (its open rows, the questions and the files), its open appointments and next lines. */
177
+ export const yourTurnOfBoard = (
178
+ board: string,
179
+ language: PlanLanguage,
180
+ waiting: readonly WaitingItem[],
181
+ now: number,
182
+ ): YourTurnBlock => {
183
+ const clock = new Date(now)
184
+ const appointments = appointmentRows(board, language, clock)
185
+ return { turn: yourTurnOf({ waiting, appointments, next: nextLines(board, language), now: clock }), now }
186
+ }
187
+
188
+ const isOld = (row: TurnRow, now: Date): boolean =>
189
+ row.at !== null && calendarDaysBetween(new Date(row.at), now) > OLD_DAYS
190
+
191
+ /** Within the next seven days, an overdue appointment and one without a time included: those are due now. */
192
+ const isWithinWeek = (row: TurnRow, now: Date): boolean =>
193
+ row.at === null || row.at - now.getTime() <= PLANNED_DAYS * DAY_MS
194
+
195
+ /**
196
+ * The open block's rows: at most five now rows and the rest as one line with how many of them are older than seven
197
+ * calendar days; the planned rows of the next seven days (and those without a time), at most three, the rest counted
198
+ * as later. An opened more line or later line shows the rest of its rows below itself (`nowRest`, `plannedRest`),
199
+ * never above; the counts stay.
200
+ */
201
+ export const turnPage = ({ now: nowRows, planned }: YourTurn, opened: TurnOpened, now: Date): TurnPage => {
202
+ const hidden = nowRows.slice(SHOWN_NOW_ROWS)
203
+ const soon = planned.filter((row) => isWithinWeek(row, now)).slice(0, SHOWN_PLANNED_ROWS)
204
+ return {
205
+ now: nowRows.slice(0, SHOWN_NOW_ROWS),
206
+ nowMore: hidden.length > 0 ? { count: hidden.length, older: hidden.filter((row) => isOld(row, now)).length } : null,
207
+ nowRest: opened.allNow ? hidden : [],
208
+ planned: soon,
209
+ later: planned.length - soon.length,
210
+ plannedRest: opened.allPlanned ? planned.filter((row) => !soon.includes(row)) : [],
211
+ opened,
212
+ }
213
+ }
214
+
215
+ /**
216
+ * The balance in the head, shown folded too (`4 block lanes · oldest 12 d`): how many now rows free a lane and the
217
+ * age of the oldest now row in calendar days; each part null when it has nothing to say.
218
+ */
219
+ export const turnBalance = ({ now: nowRows }: YourTurn, now: Date): TurnBalance => {
220
+ const blocking = nowRows.filter(({ unblocks }) => unblocks.length > 0).length
221
+ const times = nowRows.flatMap(({ at }) => (at === null ? [] : [at]))
222
+ return {
223
+ blocking: blocking === 0 ? null : blocking === 1 ? '1 blocks lanes' : `${blocking} block lanes`,
224
+ oldest: times.length === 0 ? null : `oldest ${calendarDaysBetween(new Date(Math.min(...times)), now)} d`,
225
+ }
226
+ }
@@ -85,6 +85,12 @@ export const renameArguments = (pane: string, label: string | null): string[] =>
85
85
  /** Runs the herdr CLI with these arguments and returns its stdout; throws when it fails. */
86
86
  export type HerdrRunner = (args: string[]) => string
87
87
 
88
+ /** As `HerdrRunner`, without holding the event loop while herdr answers. */
89
+ export type HerdrAsyncRunner = (args: string[]) => Promise<string>
90
+
91
+ const failureOf = (args: string[], stderr: string, exitCode: number | null): Error =>
92
+ new Error(`herdr ${args.slice(0, 2).join(' ')}: ${stderr.trim() || `exit ${exitCode}`}`)
93
+
88
94
  /**
89
95
  * Runs the herdr binary of `herdrBinary` (`HERDR_BIN_PATH`, else `herdr` from the PATH) and returns its stdout;
90
96
  * throws with herdr's own error text when it fails or is not running.
@@ -93,16 +99,30 @@ export const herdrRunner =
93
99
  (env: Record<string, string | undefined>): HerdrRunner =>
94
100
  (args) => {
95
101
  const result = Bun.spawnSync([herdrBinary(env), ...args], { stdout: 'pipe', stderr: 'pipe', env })
96
- if (result.exitCode !== 0) {
97
- throw new Error(
98
- `herdr ${args.slice(0, 2).join(' ')}: ${result.stderr.toString().trim() || `exit ${result.exitCode}`}`,
99
- )
100
- }
102
+ if (result.exitCode !== 0) throw failureOf(args, result.stderr.toString(), result.exitCode)
101
103
  return result.stdout.toString()
102
104
  }
103
105
 
106
+ /** As `herdrRunner`, but the answer arrives as a promise, so a live view keeps drawing while herdr answers. */
107
+ export const herdrRunnerAsync =
108
+ (env: Record<string, string | undefined>): HerdrAsyncRunner =>
109
+ async (args) => {
110
+ const child = Bun.spawn([herdrBinary(env), ...args], { stdout: 'pipe', stderr: 'pipe', env })
111
+ const [stdout, stderr, exitCode] = await Promise.all([
112
+ new Response(child.stdout).text(),
113
+ new Response(child.stderr).text(),
114
+ child.exited,
115
+ ])
116
+ if (exitCode !== 0) throw failureOf(args, stderr, exitCode)
117
+ return stdout
118
+ }
119
+
104
120
  export const runHerdr: HerdrRunner = herdrRunner(process.env)
105
121
 
122
+ /** The named agents of `herdr agent list`, read without holding the event loop; rejects as `listAgents` throws. */
123
+ export const listHerdrAgents = async (run: HerdrAsyncRunner = herdrRunnerAsync(process.env)): Promise<LaneAgent[]> =>
124
+ parseAgentList(await run(['agent', 'list']))
125
+
106
126
  /** `ownPane` is herdr's id of the pane this process runs in (`HERDR_PANE_ID`), undefined outside herdr. */
107
127
  export const createHerdrLaneStatusSource = (run: HerdrRunner, ownPane?: string): LaneStatusSource => ({
108
128
  listAgents: (adopt) => parseAgentList(run(['agent', 'list']), adopt),
@@ -0,0 +1,44 @@
1
+ /** How a full-screen view gives the terminal back: the reset sequence it writes and the raw mode it releases. */
2
+ export type TerminalRelease = { write: (text: string) => void; releaseRaw: () => void }
3
+
4
+ /** How the process ends after an error nobody caught: its exit code and what goes to stderr, null for nothing. */
5
+ export type CrashEnd = { code: number; report: string | null }
6
+
7
+ const CLOSED_TERMINAL_CODES: ReadonlySet<string> = new Set(['EIO', 'EBADF'])
8
+
9
+ const attempt = (step: () => void) => {
10
+ try {
11
+ step()
12
+ } catch {
13
+ // The terminal is gone already; there is nothing left on it to restore.
14
+ }
15
+ }
16
+
17
+ /** An error of a terminal that went away under the process: its input reads `EIO` or `EBADF`. */
18
+ export const isClosedTerminal = (error: unknown): boolean =>
19
+ typeof error === 'object' &&
20
+ error !== null &&
21
+ 'code' in error &&
22
+ typeof error.code === 'string' &&
23
+ CLOSED_TERMINAL_CODES.has(error.code)
24
+
25
+ /** A closed terminal ends quietly, code 0 and no stack; any other error ends with code 1 and its stack. */
26
+ export const crashEnd = (error: unknown): CrashEnd => {
27
+ if (isClosedTerminal(error)) return { code: 0, report: null }
28
+ return { code: 1, report: error instanceof Error ? (error.stack ?? error.message) : String(error) }
29
+ }
30
+
31
+ /**
32
+ * The restore of the terminal for every way the process ends: the first call writes `sequence` and releases raw
33
+ * mode, every later call does nothing. Each step runs even when the one before throws, and none throws: it runs
34
+ * inside `exit` and crash handlers, where the terminal may already be closed.
35
+ */
36
+ export const onceRestore = (sequence: string, release: TerminalRelease): (() => void) => {
37
+ let restored = false
38
+ return () => {
39
+ if (restored) return
40
+ restored = true
41
+ attempt(() => release.write(sequence))
42
+ attempt(release.releaseRaw)
43
+ }
44
+ }
@@ -29,8 +29,8 @@ export const INVERSE_ON = `${ESC}[7m`
29
29
  export const BADGE_TONE = { grey: '48;5;240;38;5;255', amber: '48;5;136;38;5;16', red: '48;5;124;38;5;255' } as const
30
30
 
31
31
  export type BadgeTone = (typeof BADGE_TONE)[keyof typeof BADGE_TONE]
32
- const FAINT_ON = `${ESC}[2m`
33
- const FAINT_OFF = `${ESC}[22m`
32
+ export const FAINT_ON = `${ESC}[2m`
33
+ export const FAINT_OFF = `${ESC}[22m`
34
34
  const BAR_WIDTH = 10
35
35
 
36
36
  const style = (code: string) => (text: string) => `${ESC}[${code}m${text}${RESET}`
@@ -2,6 +2,13 @@ const VARIATION_SELECTOR = 0xfe0f
2
2
  const TEXT_SELECTOR = 0xfe0e
3
3
  const ZERO_WIDTH_JOINER = 0x200d
4
4
  const COMBINING_MARKS: readonly [number, number][] = [[0x0300, 0x036f]]
5
+ /** Below the first combining mark no code point is wide or takes no cell: each one is one cell. */
6
+ const FIRST_SPECIAL = 0x0300
7
+ /** Whether every code unit of `text` lies below `FIRST_SPECIAL`: then each is one code point of one cell. */
8
+ const isNarrowOnly = (text: string): boolean => {
9
+ for (let index = 0; index < text.length; index++) if (text.charCodeAt(index) >= FIRST_SPECIAL) return false
10
+ return true
11
+ }
5
12
 
6
13
  /** Wide in East Asian Width, or emoji by default presentation: a terminal draws these in two cells. */
7
14
  const WIDE: readonly [number, number][] = [
@@ -60,6 +67,7 @@ const within = (ranges: readonly [number, number][], codePoint: number): boolean
60
67
  * variation selector (`⏸`) is one cell on its own; `textWidth` counts it as two when the selector follows.
61
68
  */
62
69
  export const cellWidth = (codePoint: number): number => {
70
+ if (codePoint < FIRST_SPECIAL) return 1
63
71
  if (codePoint === VARIATION_SELECTOR || codePoint === TEXT_SELECTOR || codePoint === ZERO_WIDTH_JOINER) return 0
64
72
  if (within(COMBINING_MARKS, codePoint)) return 0
65
73
  return within(WIDE, codePoint) ? 2 : 1
@@ -72,6 +80,7 @@ const characterWidth = (character: string, next: string | undefined): number =>
72
80
 
73
81
  /** The cells a text takes; an emoji followed by a variation selector takes two. */
74
82
  export const textWidth = (text: string): number => {
83
+ if (isNarrowOnly(text)) return text.length
75
84
  const characters = [...text]
76
85
  return characters.reduce((sum, character, index) => sum + characterWidth(character, characters[index + 1]), 0)
77
86
  }
@@ -115,27 +115,28 @@ export const NEXT_VOCABULARY: Record<PlanLanguage, { heading: string }> = {
115
115
 
116
116
  /**
117
117
  * The board section of what waits on the user's answer (a sent preview, an open question, a report to accept): its
118
- * heading, the header names of its columns (since, item, origin, status, found by name in any order; the status
119
- * column may also carry a name of `statusAlso`) and the lower-case words a status cell of a still open item starts with.
118
+ * heading, the header names of its columns (since, item, origin, unblocks, status, found by name in any order; origin
119
+ * and unblocks are optional, the status column may also carry a name of `statusAlso`) and the lower-case words a status
120
+ * cell of a still open item starts with.
120
121
  */
121
122
  export const WAITING_VOCABULARY: Record<
122
123
  PlanLanguage,
123
124
  {
124
125
  heading: string
125
- columns: { since: string; item: string; origin: string; status: string }
126
+ columns: { since: string; item: string; origin: string; unblocks: string; status: string }
126
127
  statusAlso: readonly string[]
127
128
  open: readonly string[]
128
129
  }
129
130
  > = {
130
131
  [PLAN_LANGUAGE.de]: {
131
132
  heading: 'Wartet auf dich',
132
- columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', status: 'Stand' },
133
+ columns: { since: 'Seit', item: 'Punkt', origin: 'Herkunft', unblocks: 'Gibt frei', status: 'Stand' },
133
134
  statusAlso: ['Status'],
134
135
  open: ['offen'],
135
136
  },
136
137
  [PLAN_LANGUAGE.en]: {
137
138
  heading: 'Waiting for you',
138
- columns: { since: 'Since', item: 'Item', origin: 'Origin', status: 'Status' },
139
+ columns: { since: 'Since', item: 'Item', origin: 'Origin', unblocks: 'Unblocks', status: 'Status' },
139
140
  statusAlso: [],
140
141
  open: ['open'],
141
142
  },
@@ -185,6 +185,8 @@ import { readTaskParallelInputs } from './task-parallel-read'
185
185
  import { type NoSignalState, type NoTaskSignalEvent, noTaskSignalEvents } from './task-signal-missing'
186
186
  import { signalledLanes } from './task-signal-read'
187
187
  import { type WatchTexts, watchTexts } from './texts'
188
+ import type { UnlinkedArtifactEvent, UnlinkedState } from './unlinked-artifacts'
189
+ import { readUnlinkedArtifactEvents } from './unlinked-artifacts-read'
188
190
  import { updateAnnouncer } from './update-available'
189
191
  import { waitingLine } from './waiting-line'
190
192
  import {
@@ -303,6 +305,8 @@ type Watching = {
303
305
  parallelAutoEnded: string | null
304
306
  /** Since when each lane works without a task signal and the phase last reported; persisted like the fingerprints. */
305
307
  noTaskSignals: NoSignalState
308
+ /** The open artifact sends in their grace or already reported as `unlinked artifact`; persisted like the fingerprints. */
309
+ unlinkedArtifacts: UnlinkedState
306
310
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
307
311
  approvalRequests: ApprovalRequestFile[] | null
308
312
  /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
@@ -355,6 +359,7 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
355
359
  taskParallels: watching.taskParallels,
356
360
  parallelAutoEnded: watching.parallelAutoEnded,
357
361
  noTaskSignals: watching.noTaskSignals,
362
+ unlinkedArtifacts: watching.unlinkedArtifacts,
358
363
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
359
364
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
360
365
  appointments: watching.appointments
@@ -1104,6 +1109,21 @@ const noTaskSignalEventsOf = (
1104
1109
  }
1105
1110
  }
1106
1111
 
1112
+ /**
1113
+ * This heartbeat's `unlinked artifact` events: the open artifact sends that no plan or board links after a heartbeat's
1114
+ * grace, each once; records the state in `watching`. Nothing when the records or plans cannot be read: the next
1115
+ * heartbeat reads again.
1116
+ */
1117
+ const unlinkedArtifactEventsOf = (watch: Watch, watching: Watching): UnlinkedArtifactEvent[] => {
1118
+ try {
1119
+ const { events, state } = readUnlinkedArtifactEvents(watch.root, watch.settings, watching.unlinkedArtifacts)
1120
+ watching.unlinkedArtifacts = state
1121
+ return events
1122
+ } catch {
1123
+ return []
1124
+ }
1125
+ }
1126
+
1107
1127
  /** The progress of the working lanes, with the pull requests of the last GitHub read; nothing while no lane works. */
1108
1128
  const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Record<string, LaneProgress>> => {
1109
1129
  const working = workingLanes(watching)
@@ -1112,10 +1132,14 @@ const workingProgressOf = async (watch: Watch, watching: Watching): Promise<Reco
1112
1132
  return progress
1113
1133
  }
1114
1134
 
1115
- /** The events a heartbeat of a report brings: the parallel-task proposals, then the lanes without a task signal. */
1135
+ /** The events a heartbeat of a report brings: the parallel-task proposals, the lanes without a task signal, the unlinked artifacts. */
1116
1136
  const heartbeatEventsOf = async (watch: Watch, watching: Watching, now: number): Promise<WatchEvent[]> => {
1117
1137
  const progress = await workingProgressOf(watch, watching)
1118
- return [...taskParallelEventsOf(watch, watching, now), ...noTaskSignalEventsOf(watch, watching, progress, now)]
1138
+ return [
1139
+ ...taskParallelEventsOf(watch, watching, now),
1140
+ ...noTaskSignalEventsOf(watch, watching, progress, now),
1141
+ ...unlinkedArtifactEventsOf(watch, watching),
1142
+ ]
1119
1143
  }
1120
1144
 
1121
1145
  /**
@@ -1156,6 +1180,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
1156
1180
  const events = [
1157
1181
  ...taskParallelEventsOf(watch, watching, now),
1158
1182
  ...noTaskSignalEventsOf(watch, watching, progress, now),
1183
+ ...unlinkedArtifactEventsOf(watch, watching),
1159
1184
  ]
1160
1185
  for (const event of events) console.log(`${watchEventLine(event, null)}${hintFor(watch, eventReference(event))}`)
1161
1186
  }
@@ -1254,6 +1279,7 @@ const initialWatching = (
1254
1279
  taskParallels: start.state.taskParallels,
1255
1280
  parallelAutoEnded: start.state.parallelAutoEnded,
1256
1281
  noTaskSignals: start.state.noTaskSignals,
1282
+ unlinkedArtifacts: start.state.unlinkedArtifacts,
1257
1283
  approvalRequests: null,
1258
1284
  appointments: null,
1259
1285
  reportQuestions: null,
@@ -17,6 +17,7 @@ import { SOURCE_FIELD } from './source-fields'
17
17
  import { type FieldChange, lineText, type SourceEvent } from './sources'
18
18
  import { NO_TASK_SIGNAL_EVENT, type NoTaskSignalEvent, noTaskSignalLine } from './task-signal-missing'
19
19
  import type { WatchTexts } from './texts'
20
+ import { unlinkedArtifactLine } from './unlinked-artifacts'
20
21
  import {
21
22
  type DeployEvent,
22
23
  isPullRequestEvent,
@@ -174,6 +175,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
174
175
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${phasesText(event.phases)}`
175
176
  if (event.kind === WATCH_EVENT.taskParallel) return `=== ${terminalSafe(event.line)}`
176
177
  if (event.kind === WATCH_EVENT.noTaskSignal) return `=== ${terminalSafe(noTaskSignalLine(event))}`
178
+ if (event.kind === WATCH_EVENT.unlinkedArtifact) return `=== ${unlinkedArtifactLine(event)}`
177
179
  if (event.kind === WATCH_EVENT.approval)
178
180
  return `=== ${event.kind} ${terminalSafe(event.lane)} ${event.id} ${terminalSafe(event.tool)} · ${terminalSafe(event.summary)}`
179
181
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
@@ -4,6 +4,12 @@ import { questionCount, questionSections } from './report-questions'
4
4
  const LIST_MARKER = /^(?:\d+[.)]|[-*])\s*/
5
5
  const QUESTION_LENGTH = 60
6
6
 
7
+ /** The label a rest note opens with, and the open-question count it ends with: the herd reads the count (group 1). */
8
+ export const REST_NOTE_LABEL = 'waits on: '
9
+ export const REST_QUESTION_COUNT = /, (\d+) open questions?$/
10
+
11
+ const openQuestions = (count: number): string => `, ${count} open ${count === 1 ? 'question' : 'questions'}`
12
+
7
13
  /** The first question as one line: list marker and bold out, made safe, cut. */
8
14
  const firstQuestion = (lines: readonly string[], start: number, end: number): string => {
9
15
  const body = lines
@@ -24,5 +30,5 @@ export const restNote = (reportText: string | null): string | null => {
24
30
  const [first] = sections
25
31
  if (!first) return null
26
32
  const count = questionCount(lines, sections)
27
- return `waits on: ${firstQuestion(lines, first.start, first.end)}, ${count} open ${count === 1 ? 'question' : 'questions'}`
33
+ return `${REST_NOTE_LABEL}${firstQuestion(lines, first.start, first.end)}${openQuestions(count)}`
28
34
  }
@@ -36,6 +36,7 @@ const EVENT_REFERENCES: Readonly<Record<string, Reference>> = {
36
36
  [WATCH_EVENT.laneParallel]: REFERENCE.events,
37
37
  [WATCH_EVENT.taskParallel]: REFERENCE.events,
38
38
  [WATCH_EVENT.noTaskSignal]: REFERENCE.events,
39
+ [WATCH_EVENT.unlinkedArtifact]: REFERENCE.events,
39
40
  [UPDATE_AVAILABLE_EVENT]: REFERENCE.events,
40
41
  [WATCH_EVENT.laneModel]: REFERENCE.laneSession,
41
42
  }
@@ -0,0 +1,43 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { sentLaneOf } from '../plans/sent-files'
4
+ import { readSentRecords } from '../plans/sent-files-read'
5
+ import { type PlansSettings, worktreeOf } from '../plans/settings'
6
+ import {
7
+ linkedUrlsOf,
8
+ openArtifactsOf,
9
+ type UnlinkedArtifactEvent,
10
+ type UnlinkedState,
11
+ unlinkedArtifactEvents,
12
+ } from './unlinked-artifacts'
13
+
14
+ /** The texts of every Markdown file below `directory`, boards included; none when it is missing. */
15
+ const markdownTextsUnder = (directory: string): string[] => {
16
+ if (!existsSync(directory)) return []
17
+ return readdirSync(directory, { recursive: true, encoding: 'utf8' })
18
+ .filter((path) => path.endsWith('.md'))
19
+ .map((path) => readFileSync(join(directory, path), 'utf8'))
20
+ }
21
+
22
+ /**
23
+ * This heartbeat's `unlinked artifact` events with the state to keep. The plans and boards are those of the main
24
+ * checkout, where the coordinator links its own artifacts, and of the worktrees of the lanes that sent one, which link
25
+ * theirs before the merge. Nothing is read while no artifact send is open.
26
+ */
27
+ export const readUnlinkedArtifactEvents = (
28
+ root: string,
29
+ settings: PlansSettings,
30
+ previous: UnlinkedState,
31
+ ): { events: UnlinkedArtifactEvent[]; state: UnlinkedState } => {
32
+ const records = readSentRecords(root)
33
+ const artifacts = openArtifactsOf(records)
34
+ if (artifacts.length === 0) return { events: [], state: {} }
35
+ const ids = new Set(artifacts.map(({ id }) => id))
36
+ const worktrees = join(root, settings.worktreeDirectory)
37
+ const lanes = new Set(
38
+ records.filter(({ id }) => ids.has(id)).flatMap((record) => sentLaneOf(record, worktrees) ?? []),
39
+ )
40
+ const checkouts = [root, ...[...lanes].map((lane) => worktreeOf(root, settings, lane))]
41
+ const texts = checkouts.flatMap((checkout) => markdownTextsUnder(join(checkout, settings.planDirectory)))
42
+ return unlinkedArtifactEvents(previous, artifacts, linkedUrlsOf(texts, settings.linkKinds))
43
+ }
@@ -0,0 +1,76 @@
1
+ import type { LinkKind } from '../config/link-kinds'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { readLinkChips } from '../plans/link-chips'
4
+ import type { SentFileRecord } from '../plans/sent-files'
5
+ import { isRecord } from '../shared/is-record'
6
+
7
+ export const UNLINKED_ARTIFACT_EVENT = 'unlinked artifact'
8
+
9
+ /** An open send of a published artifact: the record's id, its caption (`artifact` without one) and its address. */
10
+ export type OpenArtifact = { id: string; title: string; url: string }
11
+
12
+ /** `seen` once a heartbeat found a record's artifact unlinked (the grace), `reported` after its event. */
13
+ export const UNLINKED_STATUS = { seen: 'seen', reported: 'reported' } as const
14
+
15
+ export type UnlinkedStatus = (typeof UNLINKED_STATUS)[keyof typeof UNLINKED_STATUS]
16
+
17
+ /** Per record id; a record that was closed, or whose artifact was linked, has no entry. */
18
+ export type UnlinkedState = Record<string, UnlinkedStatus>
19
+
20
+ export type UnlinkedArtifactEvent = { kind: typeof UNLINKED_ARTIFACT_EVENT } & OpenArtifact
21
+
22
+ const UNTITLED = 'artifact'
23
+
24
+ const isArtifactAddress = (file: string): boolean => file.startsWith('https://')
25
+
26
+ /**
27
+ * The artifacts of the open sends, one per address: an artifact published again in every round of a review is one
28
+ * entry, its newest record. Sent files are no artifacts and stay out.
29
+ */
30
+ export const openArtifactsOf = (records: readonly SentFileRecord[]): OpenArtifact[] => {
31
+ const newest = new Map<string, SentFileRecord>()
32
+ for (const record of [...records].sort((left, right) => left.sentAt - right.sentAt)) {
33
+ const [url] = record.files
34
+ if (record.closedAt !== null || url === undefined || !isArtifactAddress(url)) continue
35
+ newest.delete(url)
36
+ newest.set(url, record)
37
+ }
38
+ return [...newest].map(([url, { id, caption }]) => ({ id, title: caption ?? UNTITLED, url }))
39
+ }
40
+
41
+ /**
42
+ * The addresses the texts link, read with the herd's own link detection, so what counts as linked shows as a chip.
43
+ * Read without the herd's cache: a heartbeat reads every plan, more texts than that cache keeps.
44
+ */
45
+ export const linkedUrlsOf = (texts: readonly string[], kinds: readonly LinkKind[]): Set<string> =>
46
+ new Set(texts.flatMap((text) => readLinkChips(text, kinds).map(({ url }) => url)))
47
+
48
+ /**
49
+ * One event per open artifact that no plan or board links, after the grace of one heartbeat (the link may be on its
50
+ * way), and once per record: a hint given stays given while the record is open. A linked or closed one leaves the state.
51
+ */
52
+ export const unlinkedArtifactEvents = (
53
+ previous: UnlinkedState,
54
+ artifacts: readonly OpenArtifact[],
55
+ linked: ReadonlySet<string>,
56
+ ): { events: UnlinkedArtifactEvent[]; state: UnlinkedState } => {
57
+ const events: UnlinkedArtifactEvent[] = []
58
+ const state: UnlinkedState = {}
59
+ for (const artifact of artifacts) {
60
+ if (linked.has(artifact.url)) continue
61
+ const before = previous[artifact.id]
62
+ if (before === UNLINKED_STATUS.seen) events.push({ kind: UNLINKED_ARTIFACT_EVENT, ...artifact })
63
+ state[artifact.id] = before === undefined ? UNLINKED_STATUS.seen : UNLINKED_STATUS.reported
64
+ }
65
+ return { events, state }
66
+ }
67
+
68
+ /** `unlinked artifact <id> "<title>" <url>`; the title's own double quotes become single ones, so the quotes stay its bounds. */
69
+ export const unlinkedArtifactLine = ({ id, title, url }: UnlinkedArtifactEvent): string =>
70
+ `${UNLINKED_ARTIFACT_EVENT} ${terminalSafe(id)} "${terminalSafe(title).replaceAll('"', "'")}" ${terminalSafe(url)}`
71
+
72
+ const isUnlinkedStatus = (value: unknown): value is UnlinkedStatus =>
73
+ (Object.values(UNLINKED_STATUS) as unknown[]).includes(value)
74
+
75
+ export const isUnlinkedState = (value: unknown): value is UnlinkedState =>
76
+ isRecord(value) && Object.values(value).every(isUnlinkedStatus)