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.
Files changed (64) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +17 -2
  3. package/dist/{chunk-5MQBCUQ5.js → chunk-RMMXD4G5.js} +9 -2
  4. package/dist/config.d.ts +60 -0
  5. package/dist/config.js +39 -4
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +4 -2
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/open-question/run.ts +8 -3
  11. package/kit/rules/rules/kit/pull-requests.md +2 -0
  12. package/kit/workflow/skills/follow/SKILL.md +3 -3
  13. package/kit/workflow/skills/orchestrate/SKILL.md +10 -2
  14. package/package.json +1 -1
  15. package/recipes/sources.md +4 -0
  16. package/schemas/jackherd.schema.json +80 -0
  17. package/src/cli.ts +1 -0
  18. package/src/config/schema.ts +24 -1
  19. package/src/config/tones.ts +27 -0
  20. package/src/lane-prompt/additions.ts +14 -0
  21. package/src/lane-prompt/command.ts +8 -2
  22. package/src/lane-prompt/input.ts +44 -3
  23. package/src/lane-prompt/options.ts +2 -0
  24. package/src/mermaid/command.ts +36 -0
  25. package/src/mermaid/problems.ts +48 -0
  26. package/src/plan-format/dependencies.ts +2 -2
  27. package/src/plan-format/waiting.ts +15 -2
  28. package/src/plans/appointment-rows.ts +34 -10
  29. package/src/plans/ask-coordinator.ts +46 -12
  30. package/src/plans/ask-target.ts +13 -2
  31. package/src/plans/board-close.ts +85 -0
  32. package/src/plans/frame.ts +5 -12
  33. package/src/plans/git.ts +35 -0
  34. package/src/plans/groomed.ts +27 -0
  35. package/src/plans/layout-phase.ts +7 -4
  36. package/src/plans/layout.ts +258 -48
  37. package/src/plans/link-chips.ts +22 -4
  38. package/src/plans/live.ts +73 -5
  39. package/src/plans/next-rows.ts +44 -0
  40. package/src/plans/planned.ts +22 -0
  41. package/src/plans/row-actions.ts +52 -0
  42. package/src/plans/row-focus.ts +148 -0
  43. package/src/plans/row-targets.ts +7 -0
  44. package/src/plans/selection.ts +14 -1
  45. package/src/plans/settings.ts +10 -0
  46. package/src/plans/snapshot.ts +79 -21
  47. package/src/plans/source-asked.ts +19 -0
  48. package/src/plans/source-prefix.ts +17 -0
  49. package/src/plans/source-rows.ts +188 -0
  50. package/src/plans/texts.ts +28 -3
  51. package/src/plans/tones.ts +30 -0
  52. package/src/plans/view.ts +8 -2
  53. package/src/plans/waiting-click.ts +129 -24
  54. package/src/terminal/text.ts +8 -0
  55. package/src/terminal/types.ts +4 -0
  56. package/src/vocabulary/index.ts +10 -0
  57. package/src/watch/plan-badges-read.ts +9 -2
  58. package/src/watch/plan-badges.ts +10 -2
  59. package/src/watch/report-questions.ts +4 -3
  60. package/src/watch/source-entries.ts +55 -0
  61. package/src/watch/source-fields.ts +1 -0
  62. package/src/watch/source-statuses.ts +60 -0
  63. package/src/watch/source-tickets.ts +21 -1
  64. 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))
@@ -1,4 +1,5 @@
1
- import { BAR_SEGMENT, INVERSE_ON, paint, RESET, stripAnsi, visibleLength } from '../terminal/text'
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 inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
77
- * its filled and empty blocks would trade places with the background and the bar would be unreadable.
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: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
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
- const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
181
- lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
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) : summary)]
211
+ return [...framesWhere(false), ...deferred, ...summary, ...(state.showDone ? framesWhere(true) : [])]
209
212
  }