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
|
@@ -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
|
+
}
|
package/src/terminal/herdr.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/terminal/text.ts
CHANGED
|
@@ -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}`
|
package/src/terminal/width.ts
CHANGED
|
@@ -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
|
}
|
package/src/vocabulary/index.ts
CHANGED
|
@@ -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;
|
|
119
|
-
* column may also carry a name of `statusAlso`) and the lower-case words a status
|
|
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
|
},
|
package/src/watch/command.ts
CHANGED
|
@@ -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,
|
|
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 [
|
|
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,
|
package/src/watch/lane-report.ts
CHANGED
|
@@ -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)}`
|
package/src/watch/lane-rest.ts
CHANGED
|
@@ -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
|
|
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)
|