jackherd 1.9.0 → 1.10.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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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 +52 -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/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,76 +1,231 @@
1
- import type { WaitingItem } from '../plan-format/waiting'
2
- import { fit, wrap } from '../terminal/text'
1
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
2
+ import { fit, padVisible, wrap } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
+ import { ASK_KIND, type AskTarget } from './ask-target'
4
5
  import { plain } from './layout-phase'
5
6
  import { MOUSE_BUTTON, type MouseEvent } from './selection'
7
+ import { shortTitle } from './task-name'
6
8
 
7
- /** How many for-you rows the herd draws; the rest is one `+<n>` line. */
8
- export const SHOWN_WAITING = 5
9
+ /**
10
+ * One row of a block of the herd as drawn: its plain text lines (more than one while it is expanded; the first starts
11
+ * 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
12
+ * click opens.
13
+ */
14
+ export type BlockRow = {
15
+ key: string
16
+ mark: string
17
+ lines: string[]
18
+ cut: boolean
19
+ expanded: boolean
20
+ url: string | null
21
+ /** What the coordinator can be asked to put: a dialog question or a row of the board; null for files and appointments. */
22
+ ask: AskTarget | null
23
+ /** The screen column (1-based) `[ask]` starts in on the first line, five cells wide; null for a row without one. */
24
+ askColumn: number | null
25
+ /** What the folded block shows of the row: the time column, if any, and the short title, without the mark. */
26
+ preview: string
27
+ /** Per line, the index in it where the part behind the short title starts; the line's length where none of it is. */
28
+ dimFrom: number[]
29
+ }
30
+
31
+ /** What a click does: expand or fold the row (its key), open an address, ask the coordinator to put the row, or nothing (null). */
32
+ export type BlockClick = { toggle: string } | { open: string } | { ask: AskTarget }
33
+
34
+ /**
35
+ * 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
36
+ * the block can paint it), its text, and what a click may do.
37
+ */
38
+ export type BlockItem = {
39
+ key: string
40
+ mark: string
41
+ /** A column before the text, padded by the caller to the widest of the block (the time of an appointment). */
42
+ lead?: string
43
+ text: string
44
+ /** What the row shows while folded; the whole `text` when absent. */
45
+ title?: string
46
+ url: string | null
47
+ ask: AskTarget | null
48
+ }
49
+
50
+ /**
51
+ * What a click on a block is matched against, built from the block as it was drawn: `firstLine` is the screen line
52
+ * (0-based) of the first row, the line above it is the block's head; `rows` and `moreKey` are empty and null while the
53
+ * block is folded; `keys` are all its points, what a fold of the block remembers.
54
+ */
55
+ export type BlockHit = {
56
+ firstLine: number
57
+ rows: readonly BlockRow[]
58
+ moreKey: string | null
59
+ keys: readonly string[]
60
+ }
61
+
62
+ /** How many rows a block of the herd (for-you, appointments) draws; the rest is one `+<n>` line. */
63
+ export const SHOWN_BLOCK_ROWS = 5
64
+ /** The key in the herd's expanded set that shows every point of the for-you block instead of the first five. */
65
+ export const WAITING_MORE_KEY = 'more>waiting'
66
+ /** The same for the appointment block. */
67
+ export const APPOINTMENT_MORE_KEY = 'more>appointment'
9
68
  /** The bar and the blank that start every line of a for-you row. */
10
69
  export const WAITING_MARK = '▌'
11
70
 
71
+ /** The button at the right edge of a question or a board row; a click on it, and only on it, asks the coordinator. */
72
+ export const ASK_BUTTON = '[ask]'
73
+ const ASK_GAP = 1
12
74
  const FILE_MARK = '📎'
13
75
  const QUESTION_MARK = '?'
14
76
  const OPENABLE = /^(https?|file):\/\//
15
77
 
16
- /**
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.
19
- */
20
- export type WaitingRow = { key: string; lines: string[]; cut: boolean; expanded: boolean; url: string | null }
21
-
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 }
24
-
25
78
  /** The key of a row in the herd's expanded set: the record's id, else its text. */
26
79
  export const waitingKey = ({ id, item }: WaitingItem): string => `waiting>${id ?? item}`
27
80
 
28
- const roomOf = (width: number): number => Math.max(width - textWidth(`${WAITING_MARK} `), 1)
81
+ /** The keys of all open points, the shown ones and the `+<n>` rest alike: what the fold of the block remembers. */
82
+ export const waitingKeys = (items: readonly WaitingItem[]): string[] => items.map(waitingKey)
29
83
 
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)
84
+ const BAR_WIDTH = textWidth(`${WAITING_MARK} `)
85
+
86
+ const roomOf = (width: number): number => Math.max(width - BAR_WIDTH, 1)
87
+
88
+ /** The cells a row's text may take and the screen column `[ask]` starts in; no column for a row without a button. */
89
+ const askLayout = (ask: AskTarget | null, width: number): { room: number; askColumn: number | null } => {
90
+ if (!ask) return { room: roomOf(width), askColumn: null }
91
+ const room = Math.max(roomOf(width) - (textWidth(ASK_BUTTON) + ASK_GAP), 1)
92
+ return { room, askColumn: BAR_WIDTH + room + ASK_GAP + 1 }
93
+ }
94
+
95
+ const askOf = (item: WaitingItem): AskTarget | null => {
96
+ if (item.kind === WAITING_KIND.file) return null
97
+ if (item.kind === WAITING_KIND.question) return item.id ? { kind: ASK_KIND.question, id: item.id } : null
98
+ return { kind: ASK_KIND.boardRow, since: item.since, item: item.item }
99
+ }
100
+
101
+ /** The text wrapped to `room` cells behind `start` (the mark and the time column), the other lines indented to the column of the text. */
102
+ const hangingLines = (start: string, text: string, room: number): string[] => {
103
+ const hang = textWidth(start)
104
+ const [first, ...rest] = wrap(text, Math.max(room - hang, 1))
105
+ return [`${start}${first}`, ...rest.map((line) => `${' '.repeat(hang)}${line}`)]
106
+ }
107
+
108
+ /**
109
+ * Per line, the index where the part behind the short title starts (the line's length while none of it is on the
110
+ * line): the title may take several lines, and the rest starts on the line where it ends. `titleLength` is 0 for a
111
+ * row without a short title.
112
+ */
113
+ const dimStarts = (lines: readonly string[], start: string, titleLength: number): number[] => {
114
+ let remaining = titleLength
115
+ return lines.map((line, index) => {
116
+ if (titleLength === 0) return line.length
117
+ const from = index === 0 ? start.length : textWidth(start)
118
+ const content = line.length - from
119
+ if (remaining > content) {
120
+ remaining -= content + 1
121
+ return line.length
122
+ }
123
+ const dimFrom = from + Math.max(remaining, 0)
124
+ remaining = 0
125
+ return dimFrom
126
+ })
127
+ }
128
+
129
+ const itemOf = (item: WaitingItem): BlockItem => ({
130
+ key: waitingKey(item),
131
+ mark: item.kind === WAITING_KIND.file ? FILE_MARK : QUESTION_MARK,
132
+ text: plain(item.item),
133
+ title: shortTitle(plain(item.item)),
134
+ url: item.kind === WAITING_KIND.file && item.url && OPENABLE.test(item.url) ? item.url : null,
135
+ ask: askOf(item),
136
+ })
137
+
138
+ const rowOf = (
139
+ { key, mark, lead, text, title = text, url, ask }: BlockItem,
140
+ expandedKeys: ReadonlySet<string>,
141
+ width: number,
142
+ ): BlockRow => {
143
+ const { room, askColumn } = askLayout(ask, width)
144
+ const leadText = lead === undefined ? '' : `${lead} `
145
+ const start = `${mark} ${leadText}`
146
+ const cut = title !== text || textWidth(`${start}${text}`) > room
35
147
  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 }
148
+ const [first, ...rest] = expanded ? hangingLines(start, text, room) : [fit(`${start}${title}`, room)]
149
+ const lines = ask ? [padVisible(first, room + ASK_GAP), ...rest] : [first, ...rest]
150
+ const dimFrom = dimStarts([first, ...rest], start, expanded && title !== text ? title.length : 0)
151
+ return { key, mark, lines, cut, expanded, url, ask, askColumn, preview: `${leadText}${title}`, dimFrom }
38
152
  }
39
153
 
40
- /** The rows the herd draws for what waits on the user: the first five, each folded to one line unless expanded. */
154
+ /**
155
+ * The rows a block of the herd draws: the first five, or all of them while `moreKey` is in the expanded set; each
156
+ * folded to one line unless expanded.
157
+ */
158
+ export const blockRows = (
159
+ items: readonly BlockItem[],
160
+ expandedKeys: ReadonlySet<string>,
161
+ width: number,
162
+ moreKey: string,
163
+ ): BlockRow[] =>
164
+ (expandedKeys.has(moreKey) ? items : items.slice(0, SHOWN_BLOCK_ROWS)).map((item) => rowOf(item, expandedKeys, width))
165
+
166
+ /** The rows the herd draws for what waits on the user. */
41
167
  export const waitingRows = (
42
168
  items: readonly WaitingItem[],
43
169
  expandedKeys: ReadonlySet<string>,
44
170
  width: number,
45
- ): WaitingRow[] => items.slice(0, SHOWN_WAITING).map((item) => rowOf(item, expandedKeys, width))
171
+ ): BlockRow[] => blockRows(items.map(itemOf), expandedKeys, width, WAITING_MORE_KEY)
172
+
173
+ /** The key of the `+<n>` line of a block with `count` points, null while the block has no more than it shows. */
174
+ export const moreKeyOf = (count: number, key: string): string | null => (count > SHOWN_BLOCK_ROWS ? key : null)
46
175
 
47
- const rowAt = (rows: readonly WaitingRow[], firstLine: number, line: number): WaitingRow | null => {
176
+ const rowAt = (
177
+ rows: readonly BlockRow[],
178
+ firstLine: number,
179
+ line: number,
180
+ ): { row: BlockRow; onFirstLine: boolean } | null => {
48
181
  let first = firstLine
49
182
  for (const row of rows) {
50
- if (line < first + row.lines.length) return line >= first ? row : null
183
+ if (line < first + row.lines.length) return line >= first ? { row, onFirstLine: line === first } : null
51
184
  first += row.lines.length
52
185
  }
53
186
  return null
54
187
  }
55
188
 
56
- const clickOn = ({ key, cut, expanded, url }: WaitingRow): WaitingClick | null => {
189
+ const onAskButton = ({ askColumn }: BlockRow, column: number | undefined): boolean =>
190
+ askColumn !== null && column !== undefined && column >= askColumn && column < askColumn + textWidth(ASK_BUTTON)
191
+
192
+ const clickOn = (row: BlockRow, onFirstLine: boolean, column: number | undefined): BlockClick | null => {
193
+ const { key, cut, expanded, url, ask } = row
194
+ if (ask && onFirstLine && onAskButton(row, column)) return { ask }
57
195
  if (url) return cut && !expanded ? { toggle: key } : { open: url }
58
196
  return cut ? { toggle: key } : null
59
197
  }
60
198
 
199
+ /** Whether a plain left click hit the head line of a block, which folds and opens it. `headLine` is 0-based. */
200
+ export const blockHeadClick = (
201
+ headLine: number,
202
+ { button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
203
+ ): boolean => pressed && button === MOUSE_BUTTON.left && row - 1 === headLine
204
+
61
205
  /**
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.
206
+ * 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
207
+ * its first line) asks the coordinator, whatever the fold. Any other click on a row that is cut off expands or folds it,
208
+ * except that a file or Artifact opens on the click after it expanded; a row that fits opens its file or does nothing.
209
+ * The `+<n>` line, the line right
210
+ * under the rows, toggles `moreKey` (all points or the first five); `moreKey` is null while the block has no such
211
+ * line. Other lines, a click with a modifier (ctrl is left to the terminal's own link opening), a release and the
212
+ * wheel do nothing. `firstLine` is the screen line (0-based) of the first row, as `renderScreen` returns it.
67
213
  */
68
- export const waitingClick = (
69
- rows: readonly WaitingRow[],
214
+ export const blockClick = (
215
+ rows: readonly BlockRow[],
70
216
  firstLine: number,
71
- { button, row, pressed }: Pick<MouseEvent, 'button' | 'row' | 'pressed'>,
72
- ): WaitingClick | null => {
217
+ {
218
+ button,
219
+ row,
220
+ pressed,
221
+ column,
222
+ }: Pick<MouseEvent, 'button' | 'row' | 'pressed'> & Partial<Pick<MouseEvent, 'column'>>,
223
+ moreKey: string | null = null,
224
+ ): BlockClick | null => {
73
225
  if (!pressed || button !== MOUSE_BUTTON.left) return null
74
- const target = rowAt(rows, firstLine, row - 1)
75
- return target ? clickOn(target) : null
226
+ const line = row - 1
227
+ const moreLine = firstLine + rows.reduce((sum, { lines }) => sum + lines.length, 0)
228
+ if (moreKey !== null && line === moreLine) return { toggle: moreKey }
229
+ const target = rowAt(rows, firstLine, line)
230
+ return target ? clickOn(target.row, target.onFirstLine, column) : null
76
231
  }
@@ -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
+ }
@@ -9,10 +9,15 @@ import { parseKitPath } from './kit-path'
9
9
 
10
10
  export type KitLocation = { kitDirectory: string; version: string; packageRoot: string }
11
11
 
12
- /** What one kit module ships: its files and the text of its `hooks.json`, if any. */
13
- type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined }
12
+ /** What one kit module ships: its files and the texts of its `hooks.json` and `ask-rules.json`, if any. */
13
+ type KitModule = { name: string; files: KitFile[]; hooksText: string | undefined; askRulesText: string | undefined }
14
14
 
15
15
  const MODULE_HOOKS_FILE = 'hooks.json'
16
+ const MODULE_ASK_RULES_FILE = 'ask-rules.json'
17
+ /** Files a module ships to sync itself, never into `.claude/`. */
18
+ export const MODULE_DATA_FILES: readonly string[] = [MODULE_HOOKS_FILE, MODULE_ASK_RULES_FILE]
19
+
20
+ const askRulesSchema = z.array(z.string().min(1))
16
21
 
17
22
  /** A test beside a kit script runs in this package and never ships into a project. */
18
23
  const TEST_FILE = /\.test\.ts$/
@@ -68,6 +73,21 @@ const mergeModuleHooks = (modules: KitModule[]): string | undefined => {
68
73
  return Object.keys(merged).length > 0 ? JSON.stringify(merged, null, 2) : undefined
69
74
  }
70
75
 
76
+ /** The `permissions.ask` rules of every module's `ask-rules.json` (a JSON list of rules), each once, in module order. */
77
+ const mergeModuleAskRules = (modules: KitModule[]): string[] => {
78
+ const rules = modules.flatMap(({ name, askRulesText }) => {
79
+ if (askRulesText === undefined) return []
80
+ const invalid = new Error(`Kit ${name}/${MODULE_ASK_RULES_FILE} is not a JSON list of permission rules`)
81
+ const parsed = askRulesSchema.safeParse(parseJsonOr(askRulesText, invalid))
82
+ if (!parsed.success) throw invalid
83
+ return parsed.data
84
+ })
85
+ return [...new Set(rules)]
86
+ }
87
+
88
+ const readOptional = async (moduleDirectory: string, relatives: string[], file: string): Promise<string | undefined> =>
89
+ relatives.includes(file) ? readFile(join(moduleDirectory, file), 'utf8') : undefined
90
+
71
91
  const listFiles = async (directory: string, prefix = ''): Promise<string[]> => {
72
92
  const entries = await readdir(join(directory, prefix), { withFileTypes: true })
73
93
  const nested = await Promise.all(
@@ -94,12 +114,11 @@ const readKitFile = async (moduleDirectory: string, relative: string): Promise<K
94
114
  const readModule = async (kitDirectory: string, name: string): Promise<KitModule> => {
95
115
  const moduleDirectory = join(kitDirectory, name)
96
116
  const relatives = (await listFiles(moduleDirectory)).sort()
97
- const filePaths = relatives.filter((relative) => relative !== MODULE_HOOKS_FILE && !TEST_FILE.test(relative))
117
+ const filePaths = relatives.filter((relative) => !MODULE_DATA_FILES.includes(relative) && !TEST_FILE.test(relative))
98
118
  const files = await Promise.all(filePaths.map((relative) => readKitFile(moduleDirectory, relative)))
99
- const hooksText = relatives.includes(MODULE_HOOKS_FILE)
100
- ? await readFile(join(moduleDirectory, MODULE_HOOKS_FILE), 'utf8')
101
- : undefined
102
- return { name, files, hooksText }
119
+ const hooksText = await readOptional(moduleDirectory, relatives, MODULE_HOOKS_FILE)
120
+ const askRulesText = await readOptional(moduleDirectory, relatives, MODULE_ASK_RULES_FILE)
121
+ return { name, files, hooksText, askRulesText }
103
122
  }
104
123
 
105
124
  const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
@@ -119,8 +138,8 @@ const listModuleNames = async (kitDirectory: string): Promise<string[]> => {
119
138
  /**
120
139
  * Reads the kit shipped with the package. Every directory under `kit/` is a module that mirrors
121
140
  * `.claude/` (`kit/workflow/skills/plan/SKILL.md` → `.claude/skills/plan/SKILL.md`); a module's
122
- * `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and is not
123
- * copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
141
+ * `hooks.json` holds its hooks by name, `{ "<name>": { event, matcher?, handler } }`, and its
142
+ * `ask-rules.json` the `permissions.ask` rules a project install adds; neither is copied, nor is a `*.test.ts`. Files at the kit root, such as a README, belong to no module.
124
143
  *
125
144
  * A missing kit directory, or one without modules, is an error, never an empty kit: an empty kit
126
145
  * would make sync remove every untouched kit file from the project.
@@ -134,7 +153,7 @@ export const readKitSource = async ({
134
153
  const modules = await Promise.all(names.map((name) => readModule(kitDirectory, name)))
135
154
  assertUniquePaths(modules)
136
155
  const files = modules.flatMap((module) => module.files).sort((left, right) => (left.path < right.path ? -1 : 1))
137
- return { version, files, hooksTemplate: mergeModuleHooks(modules) }
156
+ return { version, files, hooksTemplate: mergeModuleHooks(modules), askRules: mergeModuleAskRules(modules) }
138
157
  }
139
158
 
140
159
  const readPackageJson = async (path: string) => packageJsonSchema.parse(JSON.parse(await readFile(path, 'utf8')))
package/src/sync/kit.ts CHANGED
@@ -6,4 +6,6 @@ export type KitSource = {
6
6
  files: KitFile[]
7
7
  /** The unrendered text of `kit/hooks.json`, if the kit ships hooks. */
8
8
  hooksTemplate: string | undefined
9
+ /** The `permissions.ask` rules of the kit's `ask-rules.json` files; none for a global install. */
10
+ askRules?: readonly string[]
9
11
  }
@@ -21,6 +21,8 @@ const manifestSchema = z.strictObject({
21
21
  z.string().regex(HOOK_NAME),
22
22
  z.strictObject({ base: hookDefinitionSchema, pending: sha256.optional() }),
23
23
  ),
24
+ /** The `permissions.ask` rules sync put into `settings.json`, which it removes once the kit drops them. */
25
+ askRules: z.array(z.string().min(1)).optional(),
24
26
  /** Checksum of the `settings.json.kit-new` sync wrote last. */
25
27
  settingsKitNew: sha256.optional(),
26
28
  })