jackherd 1.9.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 (228) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +65 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-RMMXD4G5.js} +18 -6
  5. package/dist/config.d.ts +99 -4
  6. package/dist/config.js +105 -29
  7. package/dist/plan-format.d.ts +51 -6
  8. package/dist/plan-format.js +49 -7
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +57 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/rules/rules/kit/pull-requests.md +2 -0
  50. package/kit/workflow/skills/follow/SKILL.md +3 -10
  51. package/kit/workflow/skills/implement/SKILL.md +8 -6
  52. package/kit/workflow/skills/orchestrate/SKILL.md +218 -433
  53. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  54. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  55. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  56. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  57. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  58. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  59. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  60. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  61. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  62. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  64. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  65. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  66. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  67. package/kit/workflow/skills/plan/SKILL.md +6 -5
  68. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  69. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  70. package/package.json +3 -1
  71. package/recipes/README.md +2 -1
  72. package/recipes/sources.md +5 -1
  73. package/schemas/jackherd.schema.json +139 -5
  74. package/src/check/agent-text-reviewers.ts +23 -0
  75. package/src/check/command.ts +2 -0
  76. package/src/check/plan-structure-check.ts +49 -2
  77. package/src/check/plan.ts +8 -1
  78. package/src/check/schema.ts +9 -0
  79. package/src/check/skill-budget-defaults.ts +55 -0
  80. package/src/check/skill-budgets.ts +185 -0
  81. package/src/cli.ts +2 -0
  82. package/src/config/auto-window.ts +8 -0
  83. package/src/config/link-kinds.ts +7 -1
  84. package/src/config/schema.ts +82 -25
  85. package/src/config/tones.ts +27 -0
  86. package/src/lane/close-board.ts +106 -0
  87. package/src/lane/close-check.ts +183 -0
  88. package/src/lane/close-command-line.ts +11 -0
  89. package/src/lane/close-command.ts +217 -0
  90. package/src/lane/close-options.ts +43 -0
  91. package/src/lane/close-survey.ts +83 -0
  92. package/src/lane/close.ts +141 -0
  93. package/src/lane/command.ts +13 -0
  94. package/src/lane-prompt/additions.ts +14 -0
  95. package/src/lane-prompt/build.ts +74 -0
  96. package/src/lane-prompt/command.ts +93 -0
  97. package/src/lane-prompt/input.ts +157 -0
  98. package/src/lane-prompt/options.ts +70 -0
  99. package/src/lane-prompt/texts.ts +101 -0
  100. package/src/mermaid/command.ts +36 -0
  101. package/src/mermaid/problems.ts +48 -0
  102. package/src/plan-format/clock.ts +8 -0
  103. package/src/plan-format/dependencies.ts +9 -6
  104. package/src/plan-format/index.ts +1 -0
  105. package/src/plan-format/short-names.ts +71 -0
  106. package/src/plan-format/waiting.ts +22 -4
  107. package/src/plans/appointment-rows.ts +83 -0
  108. package/src/plans/ask-coordinator.ts +111 -0
  109. package/src/plans/ask-target.ts +36 -0
  110. package/src/plans/board-close.ts +85 -0
  111. package/src/plans/child-stop.ts +51 -0
  112. package/src/plans/closable-records.ts +34 -0
  113. package/src/plans/command.ts +3 -2
  114. package/src/plans/fetch.ts +26 -3
  115. package/src/plans/frame.ts +5 -12
  116. package/src/plans/git.ts +54 -5
  117. package/src/plans/groomed.ts +27 -0
  118. package/src/plans/herd-claim.ts +173 -0
  119. package/src/plans/herd-memory.ts +36 -25
  120. package/src/plans/lane-plans.ts +44 -13
  121. package/src/plans/lane-summary.ts +253 -45
  122. package/src/plans/layout-phase.ts +36 -11
  123. package/src/plans/layout-summary.ts +89 -18
  124. package/src/plans/layout.ts +502 -98
  125. package/src/plans/link-chips.ts +23 -4
  126. package/src/plans/live.ts +178 -23
  127. package/src/plans/next-rows.ts +44 -0
  128. package/src/plans/phase-ids.ts +8 -0
  129. package/src/plans/phase-source.ts +15 -0
  130. package/src/plans/plan-cache.ts +28 -0
  131. package/src/plans/plan-heads.ts +54 -0
  132. package/src/plans/planned.ts +22 -0
  133. package/src/plans/pull-requests.ts +17 -2
  134. package/src/plans/question-records.ts +48 -0
  135. package/src/plans/questions-read.ts +13 -0
  136. package/src/plans/read-cache.ts +8 -0
  137. package/src/plans/record-id.ts +4 -0
  138. package/src/plans/restart-decision.ts +13 -4
  139. package/src/plans/row-actions.ts +52 -0
  140. package/src/plans/row-focus.ts +148 -0
  141. package/src/plans/row-targets.ts +7 -0
  142. package/src/plans/selection.ts +111 -18
  143. package/src/plans/sent-files-read.ts +6 -15
  144. package/src/plans/sent-files.ts +7 -14
  145. package/src/plans/settings.ts +12 -1
  146. package/src/plans/signalled-phase.ts +42 -0
  147. package/src/plans/signals-read.ts +12 -4
  148. package/src/plans/snapshot.ts +162 -81
  149. package/src/plans/source-asked.ts +19 -0
  150. package/src/plans/source-prefix.ts +17 -0
  151. package/src/plans/source-rows.ts +188 -0
  152. package/src/plans/sprint-export.ts +11 -11
  153. package/src/plans/sprint-page.ts +5 -5
  154. package/src/plans/subagent-phases.ts +95 -0
  155. package/src/plans/subagent-tasks.ts +51 -0
  156. package/src/plans/subagents-read.ts +66 -17
  157. package/src/plans/subagents.ts +64 -6
  158. package/src/plans/supervisor.ts +14 -4
  159. package/src/plans/symbols.ts +3 -0
  160. package/src/plans/task-name.ts +34 -0
  161. package/src/plans/texts.ts +48 -6
  162. package/src/plans/tones.ts +30 -0
  163. package/src/plans/view.ts +25 -11
  164. package/src/plans/waiting-click.ts +295 -35
  165. package/src/plans/waiting-records.ts +39 -0
  166. package/src/plans/waiting-window.ts +8 -0
  167. package/src/preview/command.ts +25 -3
  168. package/src/shared/private-location.ts +89 -0
  169. package/src/shared/stat-cache.ts +48 -0
  170. package/src/sync/kit-source.ts +29 -10
  171. package/src/sync/kit.ts +2 -0
  172. package/src/sync/manifest.ts +2 -0
  173. package/src/sync/plan-ask-rules.ts +94 -0
  174. package/src/sync/plan-hooks.ts +2 -0
  175. package/src/sync/render.ts +3 -3
  176. package/src/sync/settings-hooks.ts +4 -1
  177. package/src/sync/sync-plan.ts +1 -0
  178. package/src/sync/sync.ts +24 -3
  179. package/src/task-signals/claude-read.ts +27 -10
  180. package/src/terminal/text.ts +16 -3
  181. package/src/terminal/types.ts +4 -0
  182. package/src/version/skill-diff.ts +46 -0
  183. package/src/vocabulary/index.ts +26 -6
  184. package/src/waiting/command.ts +22 -7
  185. package/src/watch/appointments.ts +1 -2
  186. package/src/watch/asking-lanes.ts +40 -0
  187. package/src/watch/base-tip.ts +11 -0
  188. package/src/watch/command.ts +330 -105
  189. package/src/watch/heartbeat-progress.ts +6 -18
  190. package/src/watch/kit-update.ts +28 -9
  191. package/src/watch/lane-name.ts +6 -0
  192. package/src/watch/lane-progress.ts +40 -8
  193. package/src/watch/lane-report-file.ts +20 -5
  194. package/src/watch/lane-report.ts +32 -4
  195. package/src/watch/lane-sources.ts +31 -3
  196. package/src/watch/main-checkout.ts +34 -0
  197. package/src/watch/plan-badges-read.ts +22 -9
  198. package/src/watch/plan-badges.ts +10 -2
  199. package/src/watch/pull-requests.ts +6 -2
  200. package/src/watch/reference-paths.ts +87 -0
  201. package/src/watch/report-questions.ts +4 -3
  202. package/src/watch/running-subagents.ts +43 -0
  203. package/src/watch/skill-change.ts +124 -32
  204. package/src/watch/source-change.ts +48 -0
  205. package/src/watch/source-entries.ts +55 -0
  206. package/src/watch/source-fields.ts +1 -0
  207. package/src/watch/source-statuses.ts +60 -0
  208. package/src/watch/source-tickets.ts +21 -1
  209. package/src/watch/sources.ts +47 -4
  210. package/src/watch/stream-output.ts +4 -3
  211. package/src/watch/task-parallel-auto.ts +208 -0
  212. package/src/watch/task-parallel-data-paths.ts +46 -0
  213. package/src/watch/task-parallel-input.ts +5 -15
  214. package/src/watch/task-parallel-read.ts +3 -1
  215. package/src/watch/task-parallel.ts +36 -7
  216. package/src/watch/task-signal-missing.ts +69 -0
  217. package/src/watch/task-signal-read.ts +29 -0
  218. package/src/watch/texts.ts +10 -0
  219. package/src/watch/update-available.ts +6 -3
  220. package/src/watch/watch-events.ts +23 -0
  221. package/src/watch/watcher-lock.ts +25 -29
  222. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  223. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  224. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  225. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  226. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  227. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  228. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,76 +1,336 @@
1
- import type { WaitingItem } from '../plan-format/waiting'
2
- import { fit, wrap } from '../terminal/text'
1
+ import type { ToneName } from '../config/tones'
2
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
3
+ import { fit, padVisible, wrap } from '../terminal/text'
3
4
  import { textWidth } from '../terminal/width'
5
+ import { ASK_KIND, type AskTarget } from './ask-target'
4
6
  import { plain } from './layout-phase'
7
+ import { DONE_KIND, type DoneTarget } from './row-targets'
5
8
  import { MOUSE_BUTTON, type MouseEvent } from './selection'
9
+ import { shortTitle } from './task-name'
6
10
 
7
- /** How many for-you rows the herd draws; the rest is one `+<n>` line. */
8
- export const SHOWN_WAITING = 5
11
+ /**
12
+ * One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
13
+ * with `mark` and a blank and, for a row with `[ask]`, is padded to leave the button its cells), whether its text is wider than the pane (`cut`), whether it is expanded, and the address a
14
+ * click opens.
15
+ */
16
+ export type BlockRow = {
17
+ key: string
18
+ mark: string
19
+ lines: string[]
20
+ cut: boolean
21
+ expanded: boolean
22
+ url: string | null
23
+ /** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
24
+ ask: AskTarget | null
25
+ /** The buttons at the right edge of the first line, `[ask]` first, each with the screen column (1-based) it starts in. */
26
+ buttons: PlacedButton[]
27
+ /** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
28
+ preview: string
29
+ /** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
30
+ dimFrom: number[]
31
+ /** The tone of the mark when it differs from the block's; absent for most rows. */
32
+ tone?: ToneName
33
+ /** The key in the expanded set that a click on the row flips, whatever its text or address; absent for most rows. */
34
+ toggle?: string
35
+ /** The row is dim from its text on; absent for most rows. */
36
+ dim?: boolean
37
+ }
38
+
39
+ /**
40
+ * What a click does: expand or fold the row (its key), open an address, ask the coordinator (to put the row to the
41
+ * user, to run an appointment now, to ask for a new time), close the row (`done`), or nothing (null).
42
+ */
43
+ export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget } | { done: DoneTarget }
44
+
45
+ /** A button of a row: its label as drawn and what a click on it does. */
46
+ export type BlockButton = { label: string; click: BlockClick }
47
+
48
+ /** A button with the screen column (1-based) its label starts in. */
49
+ export type PlacedButton = BlockButton & { column: number }
50
+
51
+ /**
52
+ * One row of a block of the herd before it is drawn: the key it expands by, the `mark` that starts it (kept apart so
53
+ * the block can paint it), its text, and what a click may do.
54
+ */
55
+ export type BlockItem = {
56
+ key: string
57
+ mark: string
58
+ /** A column before the text, padded by the caller to the widest of the block (the time of an appointment). */
59
+ lead?: string
60
+ text: string
61
+ /** What the row shows while folded; the whole `text` when absent. */
62
+ title?: string
63
+ url: string | null
64
+ ask: AskTarget | null
65
+ /** The buttons after `[ask]`, in the order they are drawn. */
66
+ buttons?: readonly BlockButton[]
67
+ tone?: ToneName
68
+ /** The key in the expanded set that a click on the row flips, whatever its text or address. */
69
+ toggle?: string
70
+ /** The row is dim from its text on (an entry already asked about). */
71
+ dim?: boolean
72
+ }
73
+
74
+ /**
75
+ * What a click on a block is matched against, built from the block as it was drawn: `firstLine` is the screen line
76
+ * (0-based) of the first row, the line above it is the block's head; `rows` and `moreKey` are empty and null while the
77
+ * block is folded; `keys` are all its points, what a fold of the block remembers.
78
+ */
79
+ export type BlockHit = {
80
+ firstLine: number
81
+ rows: readonly BlockRow[]
82
+ moreKey: string | null
83
+ keys: readonly string[]
84
+ }
85
+
86
+ /** How many rows a block of the herd (for-you, appointments) draws; the rest is one `+<n>` line. */
87
+ export const SHOWN_BLOCK_ROWS = 5
88
+ /** The key in the herd's expanded set that shows every point of the for-you block instead of the first five. */
89
+ export const WAITING_MORE_KEY = 'more>waiting'
90
+ /** The same for the appointment block. */
91
+ export const APPOINTMENT_MORE_KEY = 'more>appointment'
92
+ /** The same for the next block. */
93
+ export const NEXT_MORE_KEY = 'more>next'
94
+ /** The same for the sources block. */
95
+ export const SOURCE_MORE_KEY = 'more>source'
9
96
  /** The bar and the blank that start every line of a for-you row. */
10
97
  export const WAITING_MARK = '▌'
11
98
 
99
+ /** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
100
+ export const ASK_BUTTON = '[ask]'
101
+ /** The button that closes a row: the board row gets its status, a sent file or a question its record. */
102
+ export const DONE_BUTTON = '[done]'
103
+ /** The appointment buttons that ask the coordinator to run the row now, and to ask the user for a new time. */
104
+ export const RUN_BUTTON = '[run]'
105
+ export const MOVE_BUTTON = '[move]'
106
+ const ASK_GAP = 1
107
+ const BUTTON_GAP = 1
12
108
  const FILE_MARK = '📎'
13
109
  const QUESTION_MARK = '?'
14
110
  const OPENABLE = /^(https?|file):\/\//
15
111
 
112
+ /** The key of a row in the herd's expanded set: the record's id, else its text. */
113
+ export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
114
+
115
+ /** The keys of all open points, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
116
+ export const waitingKeys = (items: readonly WaitingItem[]): string[] => items.map(waitingKey)
117
+
118
+ const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
119
+
120
+ const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
121
+
122
+ /** The buttons of an item, `[ask]` first when the item can be asked. */
123
+ const buttonsOf = ({ ask, buttons = [] }: Pick<BlockItem, 'ask' | 'buttons'>): BlockButton[] => [
124
+ ...(ask ? [{ label: ASK_BUTTON, click: { ask } }] : []),
125
+ ...buttons,
126
+ ]
127
+
128
+ const buttonsWidth = (buttons: readonly BlockButton[]): number =>
129
+ buttons.reduce((sum, { label }) => sum + textWidth(label), 0) + Math.max(buttons.length - 1, 0) * BUTTON_GAP
130
+
131
+ /** The cells a row's text may take and the buttons with the screen columns they start in; none for a row without one. */
132
+ const buttonLayout = (items: readonly BlockButton[], width: number): { room: number; placed: PlacedButton[] } => {
133
+ if (items.length === 0) return { room: roomOf(width), placed: [] }
134
+ const room = Math.max(roomOf(width) - (buttonsWidth(items) + ASK_GAP), 1)
135
+ let column = BAR_WIDTH + room + ASK_GAP + 1
136
+ const placed = items.map((button) => {
137
+ const at = column
138
+ column += textWidth(button.label) + BUTTON_GAP
139
+ return { ...button, column: at }
140
+ })
141
+ return { room, placed }
142
+ }
143
+
144
+ /** The labels of a row's buttons as drawn after its text, one blank apart, each painted with `paintLabel`. */
145
+ export const buttonsText = (
146
+ buttons: readonly BlockButton[],
147
+ paintLabel: (label: string) => string = (label) => label,
148
+ ): string => buttons.map(({ label }) => paintLabel(label)).join(' ')
149
+
150
+ const askOf = (item: WaitingItem): AskTarget | null => {
151
+ if (item.kind === WAITING_KIND.file) return null
152
+ if (item.kind === WAITING_KIND.question) return item.id ? { kind: ASK_KIND.question, id: item.id } : null
153
+ return { kind: ASK_KIND.boardRow, since: item.since, item: item.item }
154
+ }
155
+
156
+ /** The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the column of the text. */
157
+ const hangingLines = (start: string, text: string, room: number): string[] => {
158
+ const hang = textWidth(start)
159
+ const [first, ...rest] = wrap(text, Math.max(room - hang, 1))
160
+ return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
161
+ }
162
+
16
163
  /**
17
- * One for-you row as drawn: its plain text lines (more than one while it is expanded), whether its text is wider than
18
- * the pane (`cut`), whether it is expanded, and the address a click opens.
164
+ * Per line, the index where the part behind the short title starts (the line's length while none of it is on the
165
+ * line): the title may take several lines, and the rest starts on the line where it ends. `titleLength` is 0 for a
166
+ * row without a short title.
19
167
  */
20
- export type WaitingRow = { key: string; lines: string[]; cut: boolean; expanded: boolean; url: string | null }
168
+ const dimStarts = (lines: readonly string[], start: string, titleLength: number): number[] => {
169
+ let remaining = titleLength
170
+ return lines.map((line, index) => {
171
+ if (titleLength === 0) return line.length
172
+ const from = index === 0 ? start.length : textWidth(start)
173
+ const content = line.length - from
174
+ if (remaining > content) {
175
+ remaining -= content + 1
176
+ return line.length
177
+ }
178
+ const dimFrom = from + Math.max(remaining, 0)
179
+ remaining = 0
180
+ return dimFrom
181
+ })
182
+ }
21
183
 
22
- /** What a click does: expand or fold the row (its key), open an address, or nothing (null). */
23
- export type WaitingClick = { toggle: string } | { open: string }
184
+ /** What `[done]` closes: the record of a file or a question, else the row of the board; none for a record-less question. */
185
+ const doneOf = (item: WaitingItem): DoneTarget | null => {
186
+ if (item.id) return { kind: DONE_KIND.record, id: item.id }
187
+ return item.kind === undefined ? { kind: DONE_KIND.boardRow, since: item.since, item: item.item } : null
188
+ }
24
189
 
25
- /** The key of a row in the herd's expanded set: the record's id, else its text. */
26
- export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
190
+ const doneButtons = (item: WaitingItem): BlockButton[] => {
191
+ const done = doneOf(item)
192
+ return done ? [{ label: DONE_BUTTON, click: { done } }] : []
193
+ }
194
+
195
+ const withPrefix = (prefix: string | undefined, text: string): string => (prefix ? `${prefix}: ${text}` : text)
27
196
 
28
- const roomOf = (width: number): number => Math.max(width - textWidth(`${WAITING_MARK} `), 1)
197
+ const itemOf = (item: WaitingItem): BlockItem => ({
198
+ buttons: doneButtons(item),
199
+ key: waitingKey(item),
200
+ mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
201
+ text: withPrefix(item.prefix, plain(item.item)),
202
+ title: withPrefix(item.prefix, shortTitle(plain(item.item))),
203
+ url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
204
+ ask: askOf(item),
205
+ })
29
206
 
30
- const rowOf = (item: WaitingItem, expandedKeys: ReadonlySet<string>, width: number): WaitingRow => {
31
- const text = `${item.kind === 'file' ? FILE_MARK : QUESTION_MARK} ${plain(item.item)}`
32
- const room = roomOf(width)
33
- const cut = textWidth(text) > room
34
- const key = waitingKey(item)
207
+ const rowOf = (item: BlockItem, expandedKeys: ReadonlySet<string>, width: number): BlockRow => {
208
+ const { key, mark, lead, text, title = text, url, ask, tone, toggle, dim } = item
209
+ const { room, placed } = buttonLayout(buttonsOf(item), width)
210
+ const leadText = lead === undefined ? '' : `${lead} `
211
+ const start = `${mark} ${leadText}`
212
+ const cut = title !== text || textWidth(`${start}${text}`) > room
35
213
  const expanded = cut && expandedKeys.has(key)
36
- const url = item.kind === 'file' && item.url && OPENABLE.test(item.url) ? item.url : null
37
- return { key, lines: expanded ? wrap(text, room) : [fit(text, room)], cut, expanded, url }
214
+ const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
215
+ const lines = placed.length > 0 ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
216
+ const lineParts = [first, ...rest]
217
+ const dimFrom = dim
218
+ ? lineParts.map((_, index) => (index === 0 ? mark.length + 1 : 0))
219
+ : dimStarts(lineParts, start, expanded && title !== text ? title.length : 0)
220
+ const preview = `${leadText}${title}`
221
+ return {
222
+ key,
223
+ mark,
224
+ lines,
225
+ cut,
226
+ expanded,
227
+ url,
228
+ ask,
229
+ buttons: placed,
230
+ preview,
231
+ dimFrom,
232
+ ...(tone && { tone }),
233
+ ...(toggle && { toggle }),
234
+ ...(dim && { dim }),
235
+ }
38
236
  }
39
237
 
40
- /** The rows the herd draws for what waits on the user: the first five, each folded to one line unless expanded. */
238
+ /**
239
+ * The rows a block of the herd draws: the first five, or all of them while `moreKey` is in the expanded set; each
240
+ * folded to one line unless expanded.
241
+ */
242
+ export const blockRows = (
243
+ items: readonly BlockItem[],
244
+ expandedKeys: ReadonlySet<string>,
245
+ width: number,
246
+ moreKey: string,
247
+ ): BlockRow[] =>
248
+ (expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
249
+
250
+ /** Every item as a row, for a block that limits its rows itself (the sources block, five entries per source). */
251
+ export const allBlockRows = (
252
+ items: readonly BlockItem[],
253
+ expandedKeys: ReadonlySet<string>,
254
+ width: number,
255
+ ): BlockRow[] => items.map((item) => rowOf(item, expandedKeys, width))
256
+
257
+ /** The rows the herd draws for what waits on the user. */
41
258
  export const waitingRows = (
42
259
  items: readonly WaitingItem[],
43
260
  expandedKeys: ReadonlySet<string>,
44
261
  width: number,
45
- ): WaitingRow[] => items.slice(0, SHOWN_WAITING).map((item) => rowOf(item, expandedKeys, width))
262
+ ): BlockRow[] => blockRows(items.map(itemOf), expandedKeys, width, WAITING_MORE_KEY)
46
263
 
47
- const rowAt = (rows: readonly WaitingRow[], firstLine: number, line: number): WaitingRow | null => {
264
+ /** The key of the `+<n>` line of a block with `count` points, null while the block has no more than it shows. */
265
+ export const moreKeyOf = (count: number, key: string): string | null => (count > SHOWN_BLOCK_ROWS ? key : null)
266
+
267
+ const rowAt = (
268
+ rows: readonly BlockRow[],
269
+ firstLine: number,
270
+ line: number,
271
+ ): { row: BlockRow; onFirstLine: boolean } | null => {
48
272
  let first = firstLine
49
273
  for (const row of rows) {
50
- if (line < first + row.lines.length) return line >= first ? row : null
274
+ if (line < first + row.lines.length) return line >= first ? { row, onFirstLine: line === first } : null
51
275
  first += row.lines.length
52
276
  }
53
277
  return null
54
278
  }
55
279
 
56
- const clickOn = ({ key, cut, expanded, url }: WaitingRow): WaitingClick | null => {
280
+ const buttonAt = ({ buttons }: BlockRow, column: number | undefined): PlacedButton | undefined =>
281
+ column === undefined
282
+ ? undefined
283
+ : buttons.find((button) => column >= button.column && column < button.column + textWidth(button.label))
284
+
285
+ const clickOn = (row: BlockRow, onFirstLine: boolean, column: number | undefined): BlockClick | null => {
286
+ const { key, cut, expanded, url, toggle } = row
287
+ const button = onFirstLine ? buttonAt(row, column) : undefined
288
+ if (button) return button.click
289
+ if (toggle) return { toggle }
57
290
  if (url) return cut && !expanded ? { toggle: key } : { open: url }
58
291
  return cut ? { toggle: key } : null
59
292
  }
60
293
 
61
294
  /**
62
- * What a plain left click on the for-you block does. A row that is cut off expands on the first click; then a
63
- * question folds on the next click and a file or Artifact opens. A row that fits opens its file on the first click, a
64
- * question does nothing. The `+<n>` line, other lines, a click with a modifier (ctrl is left to the terminal's own link
65
- * opening), a release and the wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as
66
- * `renderScreen` returns it.
295
+ * What Enter does on a row: the question is asked where the row has an `[ask]` button, else the row opens its address,
296
+ * expands when cut off, or does nothing. Never a `[done]` or `[run]`: those close or start something, so only a click on
297
+ * the button does that.
67
298
  */
68
- export const waitingClick = (
69
- rows: readonly WaitingRow[],
70
- firstLine: number,
299
+ export const mainClickOf = (row: BlockRow): BlockClick | null => {
300
+ const ask = row.buttons.find((button) => button.label === ASK_BUTTON)
301
+ return ask ? ask.click : clickOn(row, true, undefined)
302
+ }
303
+
304
+ /** Whether a plain left click hit the head line of a block, which folds and opens it. `headLine` is 0-based. */
305
+ export const blockHeadClick = (
306
+ headLine: number,
71
307
  { button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
72
- ): WaitingClick | null => {
308
+ ): boolean => pressed && button === MOUSE_BUTTON.left && row - 1 === headLine
309
+
310
+ /**
311
+ * What a plain left click on a block does. A click on the `[ask]` button of a question or a board row (the columns of
312
+ * its first line) asks the coordinator, whatever the fold. Any other click on a row that is cut off expands or folds it,
313
+ * except that a file or Artifact opens on the click after it expanded; a row that fits opens its file or does nothing.
314
+ * The `+<n>` line, the line right
315
+ * under the rows, toggles `moreKey` (all points or the first five); `moreKey` is null while the block has no such
316
+ * line. Other lines, a click with a modifier (ctrl is left to the terminal's own link opening), a release and the
317
+ * wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as `renderScreen` returns it.
318
+ */
319
+ export const blockClick = (
320
+ rows: readonly BlockRow[],
321
+ firstLine: number,
322
+ {
323
+ button,
324
+ row,
325
+ pressed,
326
+ column,
327
+ }: Pick<MouseEvent, 'button' | 'row' | 'pressed'> & Partial<Pick<MouseEvent, 'column'>>,
328
+ moreKey: string | null = null,
329
+ ): BlockClick | null => {
73
330
  if (!pressed || button !== MOUSE_BUTTON.left) return null
74
- const target = rowAt(rows, firstLine, row - 1)
75
- return target ? clickOn(target) : null
331
+ const line = row - 1
332
+ const moreLine = firstLine + rows.reduce((sum, { lines }) => sum + lines.length, 0)
333
+ if (moreKey !== null && line === moreLine) return { toggle: moreKey }
334
+ const target = rowAt(rows, firstLine, line)
335
+ return target ? clickOn(target.row, target.onFirstLine, column) : null
76
336
  }
@@ -0,0 +1,39 @@
1
+ import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+ import { type QuestionRecord, questionWaitingItems } from './question-records'
4
+ import { readQuestionRecords } from './questions-read'
5
+ import { type SentFileRecord, sentWaitingItems } from './sent-files'
6
+ import { readSentRecords } from './sent-files-read'
7
+
8
+ /** The two logs that wait on the user, as read from the main checkout. */
9
+ export type WaitingRecords = { sent: SentFileRecord[]; questions: QuestionRecord[] }
10
+
11
+ /** The files sent to the user and the questions left unanswered that no one closed, files first. */
12
+ export const waitingItemsOfRecords = (
13
+ sent: readonly SentFileRecord[],
14
+ questions: readonly QuestionRecord[],
15
+ now: number,
16
+ ): WaitingItem[] => [...sentWaitingItems(sent, now), ...questionWaitingItems(questions, now)]
17
+
18
+ /** What waits on the user: the board's own rows (none when `board` is null), then the sends and questions. */
19
+ export const waitingItemsOf = (
20
+ board: string | null,
21
+ language: PlanLanguage,
22
+ { sent, questions }: WaitingRecords,
23
+ now: number,
24
+ ): WaitingItem[] => [
25
+ ...(board === null ? [] : openWaitingItems(board, language)),
26
+ ...waitingItemsOfRecords(sent, questions, now),
27
+ ]
28
+
29
+ /** Both logs read from `root`; each file is read once, whoever uses it. */
30
+ export const readWaitingRecords = (root: string): WaitingRecords => ({
31
+ sent: readSentRecords(root),
32
+ questions: readQuestionRecords(root),
33
+ })
34
+
35
+ /** The files sent to the user and the questions left unanswered that no one closed, files first, read from `root`. */
36
+ export const recordWaitingItems = (root: string, now: number): WaitingItem[] => {
37
+ const { sent, questions } = readWaitingRecords(root)
38
+ return waitingItemsOfRecords(sent, questions, now)
39
+ }
@@ -0,0 +1,8 @@
1
+ import { DAY_MS } from '../plan-format/clock'
2
+
3
+ /** A question or a sent file waits on the user this long at most; a later answer or `jh waiting close` ends it earlier. */
4
+ const WAITING_DAYS = 3
5
+
6
+ /** Whether a record that was closed at `closedAt` (null: never) and began at `at` still waits on the user at `now`. */
7
+ export const isStillWaiting = (closedAt: number | null, at: number, now: number): boolean =>
8
+ closedAt === null && now - at <= WAITING_DAYS * DAY_MS
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
2
  import { basename, dirname, join, resolve } from 'node:path'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
4
+ import { pageDirectoryOf, systemLocation } from '../shared/private-location'
4
5
  import { openInBrowser } from '../terminal/open'
5
6
  import { runGh } from '../watch/gh'
6
7
  import { embedExternalImages, fetchImage } from './embed'
@@ -48,6 +49,28 @@ export const inlineImage = (root: string, path: string): string | null => {
48
49
  return `data:${type};base64,${readFileSync(file).toString('base64')}`
49
50
  }
50
51
 
52
+ /**
53
+ * Writes the page to `out` when the user named one, else to `preview-<name>.html` in the user's private page directory
54
+ * (verified by `ensurePrivateDirectory`, written through `writePrivateFile`), never to a fixed name in the shared
55
+ * temporary directory; returns the path written.
56
+ */
57
+ export const writePreviewPage = (
58
+ file: string,
59
+ page: string,
60
+ out: string | null,
61
+ pageDirectory: string = pageDirectoryOf(systemLocation()),
62
+ ): string => {
63
+ if (out !== null) {
64
+ const chosen = resolve(out)
65
+ writeFileSync(chosen, page)
66
+ return chosen
67
+ }
68
+ ensurePrivateDirectory(pageDirectory)
69
+ const path = join(pageDirectory, `preview-${basename(file, '.md')}.html`)
70
+ writePrivateFile(path, page)
71
+ return path
72
+ }
73
+
51
74
  /**
52
75
  * `jh preview <markdown-file> [--out <path>] [--open]`: renders the file through GitHub's markdown API and writes a
53
76
  * page that shows it in GitHub and in npm style, light and dark, and at phone width. The package's own images come
@@ -70,8 +93,7 @@ export const run = async (args: string[]): Promise<number> => {
70
93
  const { html: rewritten, failed } = await embedExternalImages(local, fetchImage)
71
94
  if (failed > 0)
72
95
  console.error(`${failed} external ${failed === 1 ? 'image' : 'images'} could not be embedded and stay linked`)
73
- const out = resolve(options.out ?? join(tmpdir(), `jh-preview-${basename(file, '.md')}.html`))
74
- writeFileSync(out, previewPage({ title: basename(file), html: rewritten }))
96
+ const out = writePreviewPage(file, previewPage({ title: basename(file), html: rewritten }), options.out)
75
97
  console.log(out)
76
98
  const reason = options.open ? openInBrowser(out) : null
77
99
  if (reason) console.error(`could not open the page: ${reason}`)
@@ -0,0 +1,89 @@
1
+ import { lstatSync, readFileSync, type Stats } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { isAbsolute, join } from 'node:path'
4
+ import { ensurePrivateDirectory, isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
5
+
6
+ type Entry = Pick<Stats, 'uid' | 'mode' | 'isDirectory' | 'isSymbolicLink'>
7
+
8
+ /** What choosing a private location reads from the system; the default is the real one (`systemLocation`). */
9
+ export type LocationSystem = {
10
+ env: Record<string, string | undefined>
11
+ temporaryDirectory: string
12
+ uid: number | undefined
13
+ /** What sits at the path, without following a link; null for nothing. */
14
+ entryAt: (path: string) => Entry | null
15
+ }
16
+
17
+ const PAGE_DIRECTORY_PREFIX = 'jackherd'
18
+ const GROUP_OR_OTHER = 0o077
19
+
20
+ const isPrivateDirectoryEntry = (entry: Entry | null, uid: number | undefined): boolean =>
21
+ entry !== null &&
22
+ !entry.isSymbolicLink() &&
23
+ entry.isDirectory() &&
24
+ isOwnedBy(entry, uid) &&
25
+ (entry.mode & GROUP_OR_OTHER) === 0
26
+
27
+ /**
28
+ * Where files only the user may read are kept: `$XDG_RUNTIME_DIR` when it is an absolute path to a directory of the
29
+ * user, no link, that neither group nor others may enter; else the temporary directory, which may be the shared `/tmp`
30
+ * and so needs a private level of its own below it (`userDirectoryOf`, `ensurePrivateLevels`).
31
+ */
32
+ export const runtimeBaseOf = ({ env, temporaryDirectory, uid, entryAt }: LocationSystem): string => {
33
+ const runtime = env.XDG_RUNTIME_DIR
34
+ return runtime && isAbsolute(runtime) && isPrivateDirectoryEntry(entryAt(runtime), uid) ? runtime : temporaryDirectory
35
+ }
36
+
37
+ /** The user's own level below `base`: `<base>/<prefix>-<uid>`, with `user` where the platform has no user ids. */
38
+ export const userDirectoryOf = (base: string, prefix: string, uid: number | undefined): string =>
39
+ join(base, `${prefix}-${uid ?? 'user'}`)
40
+
41
+ /**
42
+ * The user's directory for pages a browser opens: below the temporary directory, never the runtime directory, which a
43
+ * sandboxed browser may not reach.
44
+ */
45
+ export const pageDirectoryOf = (system: LocationSystem): string =>
46
+ userDirectoryOf(system.temporaryDirectory, PAGE_DIRECTORY_PREFIX, system.uid)
47
+
48
+ const entryAt = (path: string): Entry | null => {
49
+ try {
50
+ return lstatSync(path)
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ export const systemLocation = (): LocationSystem => ({
57
+ env: process.env,
58
+ temporaryDirectory: tmpdir(),
59
+ uid: process.getuid?.(),
60
+ entryAt,
61
+ })
62
+
63
+ /**
64
+ * Whether `userDirectory` and `directory` (itself or a directory right below it) both exist as directories of the user,
65
+ * no link, closed to group and others; creates nothing, for readers that must not read from a level someone else
66
+ * planted.
67
+ */
68
+ export const isPrivateLevels = (userDirectory: string, directory: string, system: LocationSystem): boolean =>
69
+ [userDirectory, directory].every((level) => isPrivateDirectoryEntry(system.entryAt(level), system.uid))
70
+
71
+ /** The text of a regular file; null for nothing, a link or anything else (a FIFO would block the read). */
72
+ export const readPrivateText = (path: string): string | null => {
73
+ try {
74
+ return lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Creates `directory`, the user level `userDirectory` itself or a directory right below it, and verifies each level
82
+ * with `ensurePrivateDirectory`, the user level first: nothing is created inside a level someone else owns or planted
83
+ * as a link, and this throws before anything is written there.
84
+ */
85
+ export const ensurePrivateLevels = (userDirectory: string, directory: string): string => {
86
+ ensurePrivateDirectory(userDirectory)
87
+ if (directory !== userDirectory) ensurePrivateDirectory(directory)
88
+ return directory
89
+ }
@@ -0,0 +1,48 @@
1
+ import { statSync } from 'node:fs'
2
+
3
+ /** What tells a file changed without reading it: when it was last written and how large it is. */
4
+ export type FileStamp = { mtimeMs: number; size: number }
5
+
6
+ type Entry<T> = FileStamp & { value: T }
7
+
8
+ /** What was read from each file, by path, with the stamp it was read at. */
9
+ export type StatCache<T> = Map<string, Entry<T>>
10
+
11
+ /** A cache and the keys one round of reads asked for; `dropUnused` clears the rest at the end of the round. */
12
+ export type CacheRound<V> = { entries: Map<string, V>; used: Set<string> }
13
+
14
+ /** A round over a `StatCache`. */
15
+ export type StatRound<T> = CacheRound<Entry<T>>
16
+
17
+ const isUnchanged = (entry: FileStamp, stamp: FileStamp): boolean =>
18
+ entry.mtimeMs === stamp.mtimeMs && entry.size === stamp.size
19
+
20
+ /** Removes what no read of the round used, so a file that is gone does not stay in the cache. */
21
+ export const dropUnused = (cache: Map<string, unknown>, used: ReadonlySet<string>): void => {
22
+ for (const key of [...cache.keys()]) if (!used.has(key)) cache.delete(key)
23
+ }
24
+
25
+ const stampOf = (path: string): FileStamp | null => {
26
+ try {
27
+ const { mtimeMs, size } = statSync(path)
28
+ return { mtimeMs, size }
29
+ } catch {
30
+ return null
31
+ }
32
+ }
33
+
34
+ /**
35
+ * What `read` makes of the file, asked again only when its modification time or size changed since the cache saw it.
36
+ * A file that cannot be stat-ed is read every time and not kept. The `key` (the path unless the result also depends
37
+ * on something else, such as how much of the file is read) is noted in `used`, for `dropUnused`.
38
+ */
39
+ export const readCached = <T>(round: StatRound<T>, path: string, read: (path: string) => T, key: string = path): T => {
40
+ round.used.add(key)
41
+ const stamp = stampOf(path)
42
+ if (!stamp) return read(path)
43
+ const kept = round.entries.get(key)
44
+ if (kept && isUnchanged(kept, stamp)) return kept.value
45
+ const value = read(path)
46
+ round.entries.set(key, { ...stamp, value })
47
+ return value
48
+ }