jackherd 1.10.0 → 1.11.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 +19 -0
- package/README.md +17 -2
- package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
- package/dist/config.d.ts +60 -0
- package/dist/config.js +39 -4
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +4 -2
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/open-question/run.ts +8 -3
- package/kit/rules/rules/kit/pull-requests.md +2 -0
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
- package/package.json +1 -1
- package/recipes/sources.md +4 -0
- package/schemas/jackherd.schema.json +80 -0
- package/src/cli.ts +1 -0
- package/src/config/schema.ts +24 -1
- package/src/config/tones.ts +27 -0
- package/src/lane-prompt/additions.ts +14 -0
- package/src/lane-prompt/command.ts +8 -2
- package/src/lane-prompt/input.ts +44 -3
- package/src/lane-prompt/options.ts +2 -0
- package/src/mermaid/command.ts +36 -0
- package/src/mermaid/problems.ts +48 -0
- package/src/plan-format/dependencies.ts +2 -2
- package/src/plan-format/waiting.ts +15 -2
- package/src/plans/appointment-rows.ts +34 -10
- package/src/plans/ask-coordinator.ts +46 -12
- package/src/plans/ask-target.ts +13 -2
- package/src/plans/board-close.ts +85 -0
- package/src/plans/frame.ts +5 -12
- package/src/plans/git.ts +35 -0
- package/src/plans/groomed.ts +27 -0
- package/src/plans/layout-phase.ts +7 -4
- package/src/plans/layout.ts +258 -48
- package/src/plans/link-chips.ts +22 -4
- package/src/plans/live.ts +73 -5
- package/src/plans/next-rows.ts +44 -0
- package/src/plans/planned.ts +22 -0
- package/src/plans/row-actions.ts +52 -0
- package/src/plans/row-focus.ts +148 -0
- package/src/plans/row-targets.ts +7 -0
- package/src/plans/selection.ts +14 -1
- package/src/plans/settings.ts +10 -0
- package/src/plans/snapshot.ts +79 -21
- package/src/plans/source-asked.ts +19 -0
- package/src/plans/source-prefix.ts +17 -0
- package/src/plans/source-rows.ts +188 -0
- package/src/plans/texts.ts +28 -3
- package/src/plans/tones.ts +30 -0
- package/src/plans/view.ts +8 -2
- package/src/plans/waiting-click.ts +129 -24
- package/src/terminal/text.ts +8 -0
- package/src/terminal/types.ts +4 -0
- package/src/vocabulary/index.ts +10 -0
- package/src/watch/plan-badges-read.ts +9 -2
- package/src/watch/plan-badges.ts +10 -2
- package/src/watch/report-questions.ts +4 -3
- package/src/watch/source-entries.ts +55 -0
- package/src/watch/source-fields.ts +1 -0
- package/src/watch/source-statuses.ts +60 -0
- package/src/watch/source-tickets.ts +21 -1
- package/src/watch/sources.ts +41 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { twoDigits } from '../plan-format/clock'
|
|
2
|
+
import { firstTableRows, sectionLines, sectionStart } from '../plan-format/markdown'
|
|
3
|
+
import { APPOINTMENT_VOCABULARY, PLAN_LANGUAGE, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
4
|
+
|
|
5
|
+
type SectionColumns = {
|
|
6
|
+
heading: string
|
|
7
|
+
/** The header names of the two cells that identify a row, then the names that count as the status column. */
|
|
8
|
+
keys: readonly [string, string]
|
|
9
|
+
status: readonly string[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DONE_WORD: Record<PlanLanguage, string> = { [PLAN_LANGUAGE.de]: 'erledigt', [PLAN_LANGUAGE.en]: 'done' }
|
|
13
|
+
|
|
14
|
+
/** The status a closed row gets, `erledigt (User <date> <time>)`: the time comes from `now`, never from an estimate. */
|
|
15
|
+
export const doneStatus = (language: PlanLanguage, now: Date): string =>
|
|
16
|
+
`${DONE_WORD[language]} (User ${twoDigits(now.getDate())}.${twoDigits(now.getMonth() + 1)}. ${twoDigits(now.getHours())}:${twoDigits(now.getMinutes())})`
|
|
17
|
+
|
|
18
|
+
const squeezed = (text: string): string => text.replace(/\s+/g, ' ').trim()
|
|
19
|
+
|
|
20
|
+
const waitingColumns = (language: PlanLanguage): SectionColumns => {
|
|
21
|
+
const { heading, columns, statusAlso } = WAITING_VOCABULARY[language]
|
|
22
|
+
return { heading, keys: [columns.since, columns.item], status: [columns.status, ...statusAlso] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const appointmentColumns = (language: PlanLanguage): SectionColumns => {
|
|
26
|
+
const { heading, columns, statusAlso } = APPOINTMENT_VOCABULARY[language]
|
|
27
|
+
return { heading, keys: [columns.when, columns.action], status: [columns.status, ...statusAlso] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The line with the status cell (the `index`th between the bars) replaced; the other cells keep their text as written. */
|
|
31
|
+
const withStatus = (line: string, index: number, status: string): string => {
|
|
32
|
+
const parts = line.split('|')
|
|
33
|
+
if (index + 1 >= parts.length) return line
|
|
34
|
+
parts[index + 1] = ` ${status} `
|
|
35
|
+
return parts.join('|')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The board with the status cell of the first row of the section's table whose two key cells equal `keys` replaced by
|
|
40
|
+
* `status`; null when the section, a column or the row is missing, so a row the board lost since the herd drew it
|
|
41
|
+
* writes nothing.
|
|
42
|
+
*/
|
|
43
|
+
const closeRow = (
|
|
44
|
+
board: string,
|
|
45
|
+
{ heading, keys: keyNames, status: statusNames }: SectionColumns,
|
|
46
|
+
keys: readonly [string, string],
|
|
47
|
+
status: string,
|
|
48
|
+
): string | null => {
|
|
49
|
+
const start = sectionStart(board, heading)
|
|
50
|
+
if (start === -1) return null
|
|
51
|
+
const lines = sectionLines(board, heading)
|
|
52
|
+
const header = lines.find((line) => line.startsWith('|'))
|
|
53
|
+
if (!header) return null
|
|
54
|
+
const names = header
|
|
55
|
+
.slice(1, header.endsWith('|') ? -1 : undefined)
|
|
56
|
+
.split('|')
|
|
57
|
+
.map((cell) => cell.trim())
|
|
58
|
+
const positions = keyNames.map((name) => names.indexOf(name))
|
|
59
|
+
const statusAt = names.findIndex((name) => statusNames.includes(name))
|
|
60
|
+
if (positions.includes(-1) || statusAt === -1) return null
|
|
61
|
+
const row = firstTableRows(lines).find(({ cells }) =>
|
|
62
|
+
positions.every((position, index) => squeezed(cells[position] ?? '') === squeezed(keys[index])),
|
|
63
|
+
)
|
|
64
|
+
if (!row) return null
|
|
65
|
+
const all = board.split('\n')
|
|
66
|
+
const at = start + row.index
|
|
67
|
+
all[at] = withStatus(all[at], statusAt, status)
|
|
68
|
+
return all.join('\n')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The board with the waiting row `since`/`item` closed at `now`; null when the board has no such row. */
|
|
72
|
+
export const closeWaitingRow = (
|
|
73
|
+
board: string,
|
|
74
|
+
language: PlanLanguage,
|
|
75
|
+
{ since, item }: { since: string; item: string },
|
|
76
|
+
now: Date,
|
|
77
|
+
): string | null => closeRow(board, waitingColumns(language), [since, item], doneStatus(language, now))
|
|
78
|
+
|
|
79
|
+
/** The board with the appointment `when`/`action` closed at `now`; null when the board has no such row. */
|
|
80
|
+
export const closeAppointmentRow = (
|
|
81
|
+
board: string,
|
|
82
|
+
language: PlanLanguage,
|
|
83
|
+
{ when, action }: { when: string; action: string },
|
|
84
|
+
now: Date,
|
|
85
|
+
): string | null => closeRow(board, appointmentColumns(language), [when, action], doneStatus(language, now))
|
package/src/plans/frame.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { paint, visibleLength } from '../terminal/text'
|
|
2
|
+
import { focusLine, isFocused } from './row-focus'
|
|
2
3
|
import type { ClickTarget, Selection } from './selection'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -73,17 +74,9 @@ const branches = (children: Frame[]): Frame[] => {
|
|
|
73
74
|
export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* A row drawn
|
|
77
|
-
*
|
|
77
|
+
* A selectable row: drawn with the focus background and mark (`focusLine`) when selected or under the mouse, clickable
|
|
78
|
+
* across the pane on every line, `below` follows unselectable. Only the selected row holds the cursor.
|
|
78
79
|
*/
|
|
79
|
-
const highlight = (line: string, width: number): string => {
|
|
80
|
-
const bars = line.match(BAR_SEGMENT) ?? []
|
|
81
|
-
const texts = line.split(BAR_SEGMENT).map(stripAnsi)
|
|
82
|
-
const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
|
|
83
|
-
return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
|
|
87
80
|
export const rowFrame = (
|
|
88
81
|
key: string,
|
|
89
82
|
rows: string[],
|
|
@@ -92,7 +85,7 @@ export const rowFrame = (
|
|
|
92
85
|
): Frame => {
|
|
93
86
|
const selected = state.selected === key
|
|
94
87
|
return {
|
|
95
|
-
lines: [...(
|
|
88
|
+
lines: [...(isFocused(state, key) ? rows.map((row, index) => focusLine(row, width, index === 0)) : rows), ...below],
|
|
96
89
|
cursorLine: selected ? 0 : -1,
|
|
97
90
|
targets: rows.map((_, line) => ({ line, from: 0, to: width, key, wholeLine: true as const })),
|
|
98
91
|
}
|
package/src/plans/git.ts
CHANGED
|
@@ -32,6 +32,41 @@ export const showAtRef = (root: string, ref: string, path: string): string | nul
|
|
|
32
32
|
export const commitOf = (root: string, ref: string): string | null =>
|
|
33
33
|
git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
34
34
|
|
|
35
|
+
const lastChangeAt = (root: string, commit: string, path: string): number | null => {
|
|
36
|
+
const seconds = Number(git(root, ['log', '-1', '--format=%ct', commit, '--', path]))
|
|
37
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The last-change times read per `root\0commit`, by path, the commit asked last at the end; a change at a commit never moves. */
|
|
41
|
+
const cachedChanges = new Map<string, Map<string, number | null>>()
|
|
42
|
+
|
|
43
|
+
const changesOf = (root: string, commit: string): Map<string, number | null> => {
|
|
44
|
+
const key = `${root}\0${commit}`
|
|
45
|
+
const changes = cachedChanges.get(key) ?? new Map<string, number | null>()
|
|
46
|
+
cachedChanges.delete(key)
|
|
47
|
+
cachedChanges.set(key, changes)
|
|
48
|
+
const oldest = cachedChanges.keys().next().value
|
|
49
|
+
if (cachedChanges.size > CACHED_COMMITS && oldest !== undefined) cachedChanges.delete(oldest)
|
|
50
|
+
return changes
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A reader of the commit time, in epoch milliseconds, of the last commit at `ref` that changed a path; null when the
|
|
55
|
+
* ref or the file is unknown there. The commit is resolved once per reader and each answer is kept per commit, so a
|
|
56
|
+
* refresh while the ref stands costs one `git rev-parse`, and a moved ref reads afresh.
|
|
57
|
+
*/
|
|
58
|
+
export const lastChangeReaderOf = (root: string, ref: string): ((path: string) => number | null) => {
|
|
59
|
+
const commit = commitOf(root, ref)
|
|
60
|
+
if (commit === null) return () => null
|
|
61
|
+
const changes = changesOf(root, commit)
|
|
62
|
+
return (path) => {
|
|
63
|
+
if (changes.has(path)) return changes.get(path) ?? null
|
|
64
|
+
const at = lastChangeAt(root, commit, path)
|
|
65
|
+
changes.set(path, at)
|
|
66
|
+
return at
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
/** Reads files as they are at the base ref. */
|
|
36
71
|
export type BaseReader = (path: string) => string | null
|
|
37
72
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DAY_MS } from '../plan-format/clock'
|
|
2
|
+
import { APPOINTMENT_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { readAppointmentTable } from '../watch/appointments'
|
|
4
|
+
|
|
5
|
+
const GROOMING_INTERVAL_DAYS = 7
|
|
6
|
+
const STALE_FROM_DAYS = 8
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whole days since the board was last groomed, or null when the board has no open grooming appointment. The
|
|
10
|
+
* coordinator moves that appointment a week on after each grooming, so the last one was a week before it. A time
|
|
11
|
+
* before that (a board never groomed on the clock) counts as 0.
|
|
12
|
+
*/
|
|
13
|
+
export const groomedDaysAgo = (board: string, language: PlanLanguage, now: Date): number | null => {
|
|
14
|
+
const word = APPOINTMENT_VOCABULARY[language].grooming
|
|
15
|
+
const next = readAppointmentTable(board, language, now)
|
|
16
|
+
.appointments.filter(({ open, action }) => open && action.toLowerCase().startsWith(word))
|
|
17
|
+
.sort((left, right) => left.when.getTime() - right.when.getTime())[0]
|
|
18
|
+
if (!next) return null
|
|
19
|
+
const groomedAt = next.when.getTime() - GROOMING_INTERVAL_DAYS * DAY_MS
|
|
20
|
+
return Math.max(Math.floor((now.getTime() - groomedAt) / DAY_MS), 0)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The header text; `stale` from eight days on, when the herd paints it amber. */
|
|
24
|
+
export const groomedText = (days: number): { text: string; stale: boolean } => ({
|
|
25
|
+
text: days === 0 ? 'groomed today' : `groomed ${days}d ago`,
|
|
26
|
+
stale: days >= STALE_FROM_DAYS,
|
|
27
|
+
})
|
|
@@ -177,8 +177,11 @@ export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext
|
|
|
177
177
|
const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
|
|
178
178
|
fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
/** The clickable line of a phase's ticked tasks: `▸ <n> ticked · …lists them`, or `▾ …folds them` above the listed ones. */
|
|
181
|
+
const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number, listed: boolean): Frame => ({
|
|
182
|
+
lines: [
|
|
183
|
+
fit(paint.dim(`${chevronOf(listed)} ${listed ? TEXTS.tickedFold(count) : TEXTS.tickedSummary(count)}`), width),
|
|
184
|
+
],
|
|
182
185
|
cursorLine: -1,
|
|
183
186
|
targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
|
|
184
187
|
})
|
|
@@ -203,7 +206,7 @@ export const phaseChildren = (
|
|
|
203
206
|
const framesWhere = (done: boolean): Frame[] =>
|
|
204
207
|
phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
|
|
205
208
|
const tickedCount = phase.items.filter((item) => item.done).length
|
|
206
|
-
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
|
|
209
|
+
const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width, state.showDone)] : []
|
|
207
210
|
const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
|
|
208
|
-
return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) :
|
|
211
|
+
return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
|
|
209
212
|
}
|