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
@@ -0,0 +1,48 @@
1
+ import { clockText } from '../plan-format/clock'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
4
+ import { isRecordId } from './record-id'
5
+ import { isStillWaiting } from './waiting-window'
6
+
7
+ /** The record of a question that ended without an answer, as the kit hook `open-question` writes it to `.jackherd/questions/`. */
8
+ export type QuestionRecord = {
9
+ id: string
10
+ question: string
11
+ askedAt: number
12
+ cwd: string
13
+ session: string
14
+ closedAt: number | null
15
+ }
16
+
17
+ /** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
18
+ export const isQuestionId = isRecordId
19
+
20
+ /** The same shape as `isQuestionRecord` in the hook, which this package does not import at run time (contract test). */
21
+ export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
22
+ if (typeof value !== 'object' || value === null) return false
23
+ const record = value as Record<string, unknown>
24
+ return (
25
+ typeof record.id === 'string' &&
26
+ isQuestionId(record.id) &&
27
+ typeof record.question === 'string' &&
28
+ typeof record.askedAt === 'number' &&
29
+ typeof record.cwd === 'string' &&
30
+ typeof record.session === 'string' &&
31
+ (record.closedAt === null || typeof record.closedAt === 'number')
32
+ )
33
+ }
34
+
35
+ const itemOf = (record: QuestionRecord): WaitingItem => ({
36
+ since: clockText(record.askedAt),
37
+ item: terminalSafe(record.question),
38
+ kind: WAITING_KIND.question,
39
+ id: record.id,
40
+ open: true,
41
+ })
42
+
43
+ /** What the unanswered questions wait on: one open item per record younger than three days, oldest first. */
44
+ export const questionWaitingItems = (records: readonly QuestionRecord[], now: number): WaitingItem[] =>
45
+ records
46
+ .filter(({ closedAt, askedAt }) => isStillWaiting(closedAt, askedAt, now))
47
+ .sort((left, right) => left.askedAt - right.askedAt)
48
+ .map(itemOf)
@@ -0,0 +1,13 @@
1
+ import { closableRecordLog } from './closable-records'
2
+ import { isQuestionRecord } from './question-records'
3
+
4
+ const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => askedAt)
5
+
6
+ /** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
+ export const readQuestionRecords = log.read
8
+
9
+ /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
+ export const closeQuestionRecords = log.close
11
+
12
+ /** Marks every open record `closedAt` and returns how many it closed. */
13
+ export const closeAllQuestionRecords = log.closeAll
@@ -0,0 +1,8 @@
1
+ import type { StatCache } from '../shared/stat-cache'
2
+ import type { TaskSignal } from '../task-signals/types'
3
+ import type { PlanCache } from './plan-cache'
4
+
5
+ /** What the herd keeps between its reads, so session logs and plans that did not change are not parsed again. */
6
+ export type ReadCache = { signals: StatCache<TaskSignal[]>; plans: PlanCache }
7
+
8
+ export const newReadCache = (): ReadCache => ({ signals: new Map(), plans: new Map() })
@@ -0,0 +1,4 @@
1
+ const RECORD_ID = /^[0-9a-f]{12}$/
2
+
3
+ /** Whether `value` has the shape of a record's id: twelve lower-case hex digits. */
4
+ export const isRecordId = (value: string): boolean => RECORD_ID.test(value)
@@ -2,6 +2,12 @@ import { constants } from 'node:os'
2
2
 
3
3
  export const CHILD_MARKER = 'JACKHERD_PLANS_CHILD'
4
4
 
5
+ /** The signal a newer herd view sends the older one of its terminal, so the older one ends as a wanted end. */
6
+ export const TAKEOVER_SIGNAL = 'SIGUSR2'
7
+
8
+ /** The exit code of a view that ended because a newer herd took its terminal over. */
9
+ export const TAKEN_OVER_CODE = 75
10
+
5
11
  /** Milliseconds a burst of file changes is collapsed into one restart. */
6
12
  export const RESTART_DEBOUNCE_MS = 300
7
13
 
@@ -26,12 +32,15 @@ export type NextStep = 'respawn' | 'wait' | 'exit'
26
32
 
27
33
  /**
28
34
  * What the supervisor does once the child has ended. Only a wanted end stops it: a forwarded
29
- * signal (`stopping`) or a clean exit code 0 (`q`, Ctrl-C in raw mode). A restart we asked for
30
- * respawns; any other end is a crash, and the supervisor waits for the next change to the source.
35
+ * signal (`stopping`), a takeover by a newer herd (the exit code `TAKEN_OVER_CODE`, or the end by SIGKILL or
36
+ * `TAKEOVER_SIGNAL` that the newer herd causes when the child does not answer in time) or a clean exit code 0 (`q`,
37
+ * Ctrl-C in raw mode). A restart we asked for respawns, even when our own stopper had to SIGKILL the child; any other
38
+ * end is a crash, and the supervisor waits for the next change to the source.
31
39
  */
32
- export const nextStep = ({ restarting, stopping, code }: ChildExit): NextStep => {
33
- if (stopping) return 'exit'
40
+ export const nextStep = ({ restarting, stopping, code, signal }: ChildExit): NextStep => {
41
+ if (stopping || code === TAKEN_OVER_CODE) return 'exit'
34
42
  if (restarting) return 'respawn'
43
+ if (signal === 'SIGKILL' || signal === TAKEOVER_SIGNAL) return 'exit'
35
44
  return code === 0 ? 'exit' : 'wait'
36
45
  }
37
46
 
@@ -0,0 +1,52 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs'
2
+ import type { PlanLanguage } from '../vocabulary'
3
+ import { boardRowKey } from './ask-target'
4
+ import { closeAppointmentRow, closeWaitingRow } from './board-close'
5
+ import { errorText } from './error-text'
6
+ import { isQuestionId } from './question-records'
7
+ import { DONE_KIND, type DoneTarget } from './row-targets'
8
+ import { TEXTS } from './texts'
9
+
10
+ /** What `closeRow` needs from the outside: the board file, its language, the clock, and the closing of log records. */
11
+ export type CloseDependencies = {
12
+ sprintFile: string
13
+ language: PlanLanguage
14
+ now: Date
15
+ /** Closes the records of the files and questions log by id; how many it closed. */
16
+ closeRecords: (ids: readonly string[]) => number
17
+ }
18
+
19
+ const closeRecord = (id: string, { closeRecords }: CloseDependencies): string => {
20
+ if (!isQuestionId(id)) return TEXTS.notClosed('not a record id')
21
+ return closeRecords([id]) > 0 ? TEXTS.closed(`record ${id}`) : TEXTS.notClosed('record not found or closed already')
22
+ }
23
+
24
+ const closedBoard = (target: DoneTarget, board: string, { language, now }: CloseDependencies): string | null => {
25
+ if (target.kind === DONE_KIND.boardRow) return closeWaitingRow(board, language, target, now)
26
+ return target.kind === DONE_KIND.appointment ? closeAppointmentRow(board, language, target, now) : null
27
+ }
28
+
29
+ const subjectOfRow = (target: DoneTarget): string => {
30
+ if (target.kind === DONE_KIND.boardRow) return `board row ${boardRowKey(target.since, target.item)}`
31
+ return target.kind === DONE_KIND.appointment ? `appointment ${boardRowKey(target.when, target.action)}` : ''
32
+ }
33
+
34
+ const closeBoardRow = (target: DoneTarget, dependencies: CloseDependencies): string => {
35
+ try {
36
+ const board = readFileSync(dependencies.sprintFile, 'utf8')
37
+ const closed = closedBoard(target, board, dependencies)
38
+ if (closed === null) return TEXTS.notClosed('row not found in the board')
39
+ writeFileSync(dependencies.sprintFile, closed)
40
+ return TEXTS.closed(subjectOfRow(target))
41
+ } catch (error) {
42
+ return TEXTS.notClosed(errorText(error))
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Closes what a `[done]` button names: a log record by its id, or a row of the board's file by writing the stamp into its
48
+ * status cell. Returns the footer line. The board is read again just before it is written, so a row the coordinator
49
+ * changed since the herd drew it is found by its text or not at all.
50
+ */
51
+ export const closeRow = (target: DoneTarget, dependencies: CloseDependencies): string =>
52
+ target.kind === DONE_KIND.record ? closeRecord(target.id, dependencies) : closeBoardRow(target, dependencies)
@@ -0,0 +1,148 @@
1
+ import { ESC, RESET, stripAnsi, visibleLength } from '../terminal/text'
2
+ import { textWidth } from '../terminal/width'
3
+ import { isChipKey } from './link-chips'
4
+ import type { ClickTarget, MouseEvent, Selection } from './selection'
5
+ import type { BlockHit, BlockRow } from './waiting-click'
6
+
7
+ /** A clickable row on screen: its key, the screen line (0-based, header lines first) it starts on and how many lines it takes. */
8
+ export type FocusHit = { key: string; line: number; lines: number }
9
+
10
+ export const FOCUS_MARK = '❯'
11
+ export const FOCUS_ON = `${ESC}[48;5;238m`
12
+
13
+ const BACKGROUND_LOST = new RegExp(`${ESC}\\[(?:0|49)?m`, 'g')
14
+ const LEADING_STYLES = new RegExp(`^((?:${ESC}\\[[0-9;]*m)*)(.)`, 'u')
15
+ /** The cells a row's lead may give up for the mark: a blank, the bar of a block, the closed chevron; an open chevron or a tree branch says more. */
16
+ const MARKABLE_LEADS = new Set([' ', '▌', '▸'])
17
+
18
+ const markedLead = (line: string): string =>
19
+ line.replace(LEADING_STYLES, (whole, styles: string, lead: string) =>
20
+ MARKABLE_LEADS.has(lead) ? `${styles}${FOCUS_MARK}` : whole,
21
+ )
22
+
23
+ /**
24
+ * A row under the mouse or the selection: a light background across `width` cells, and, on the first line of the row
25
+ * (`marked`), the `❯` in place of the bar, blank or closed chevron that leads it. The background is laid again after
26
+ * every reset inside the line, so styled text and buttons keep it.
27
+ */
28
+ export const focusLine = (line: string, width: number, marked: boolean): string => {
29
+ const shown = marked ? markedLead(line) : line
30
+ const padded = `${shown}${' '.repeat(Math.max(width - visibleLength(shown), 0))}`
31
+ return `${FOCUS_ON}${padded.replace(BACKGROUND_LOST, (reset) => `${reset}${FOCUS_ON}`)}${RESET}`
32
+ }
33
+
34
+ /** The row on screen line `line`, null where none is. */
35
+ export const focusHitAt = (hits: readonly FocusHit[], line: number): FocusHit | null =>
36
+ hits.find((hit) => line >= hit.line && line < hit.line + hit.lines) ?? null
37
+
38
+ /** The screen a motion report is looked up on: header lines, the body's rows and how far it is scrolled. */
39
+ export type HoverFrame = { headerRows: number; bodyRows: number; top: number }
40
+
41
+ /** The screen line (0-based, header first, the body unscrolled) under a motion report; null in the footer. */
42
+ export const lineIndexAt = ({ row }: MouseEvent, { headerRows, bodyRows, top }: HoverFrame): number | null => {
43
+ const line = row - 1
44
+ if (line < headerRows) return line
45
+ const bodyLine = line - headerRows
46
+ return bodyLine < bodyRows ? headerRows + top + bodyLine : null
47
+ }
48
+
49
+ /** The key of the row under the mouse, null over none. */
50
+ export const hoveredKeyAt = (hits: readonly FocusHit[], mouse: MouseEvent, frame: HoverFrame): string | null => {
51
+ const line = lineIndexAt(mouse, frame)
52
+ return line === null ? null : (focusHitAt(hits, line)?.key ?? null)
53
+ }
54
+
55
+ const BRACKET_BUTTON = /\[[^[\]]+\]/g
56
+
57
+ /** The columns `[from, to)` of the square-bracket buttons in the visible text of a line. */
58
+ export const bracketSpans = (visible: string): { from: number; to: number }[] =>
59
+ [...visible.matchAll(BRACKET_BUTTON)].map((match) => {
60
+ const from = textWidth(visible.slice(0, match.index))
61
+ return { from, to: from + textWidth(match[0]) }
62
+ })
63
+
64
+ /** The column a square-bracket button under `column` starts in, null where the mouse is over none. */
65
+ export const bracketAt = (visible: string, column: number): number | null =>
66
+ bracketSpans(visible).find((span) => column >= span.from && column < span.to)?.from ?? null
67
+
68
+ const STYLE_OR_LINK = new RegExp(`(${ESC}\\[[0-9;]*m|${ESC}\\]8;[^;${ESC}\\x07]*;[^${ESC}\\x07]*(?:${ESC}\\\\|\\x07))`)
69
+ const BOLD_ON = `${ESC}[1m`
70
+ const BOLD_OFF = `${ESC}[22m`
71
+
72
+ /** The line with the cells `[from, to)` bold, counted by what shows; styles and links in the line stay where they are. */
73
+ export const boldCells = (line: string, from: number, to: number): string => {
74
+ let column = 0
75
+ return line
76
+ .split(STYLE_OR_LINK)
77
+ .map((token, index) => {
78
+ if (index % 2 === 1) return token
79
+ return [...token]
80
+ .map((character) => {
81
+ const start = column
82
+ column += textWidth(character)
83
+ const opens = start === from ? BOLD_ON : ''
84
+ const closes = column >= to && start < to ? BOLD_OFF : ''
85
+ return `${opens}${character}${closes}`
86
+ })
87
+ .join('')
88
+ })
89
+ .join('')
90
+ }
91
+
92
+ /** The line with the square-bracket button that starts in column `from` bold; the line itself where none does. */
93
+ export const boldBracketAt = (line: string, from: number): string => {
94
+ const span = bracketSpans(stripAnsi(line)).find((each) => each.from === from)
95
+ return span ? boldCells(line, span.from, span.to) : line
96
+ }
97
+
98
+ /** The key of the hit of a line that holds links but is no row of its own: it is marked by the mouse only. */
99
+ export const LINKS_KEY_PREFIX = 'links@'
100
+
101
+ /** The selection with the row under the mouse and the button in it set; the selection itself while both are the same. */
102
+ export const withHovered = (selection: Selection, key: string | null, from: number | null = null): Selection =>
103
+ (selection.hovered ?? null) === key && (selection.hoveredFrom ?? null) === from
104
+ ? selection
105
+ : { ...selection, hovered: key, hoveredFrom: from }
106
+
107
+ /** Whether a row is the selected one or the one under the mouse. */
108
+ export const isFocused = ({ selected, hovered }: Selection, key: string): boolean => key === selected || key === hovered
109
+
110
+ /** Whether the buttons of a row are all bold: it is the row the arrows selected, and the mouse is not on it. */
111
+ export const buttonsAllBold = ({ selected, hovered }: Selection, key: string): boolean =>
112
+ key === selected && key !== hovered
113
+
114
+ /** The row of a drawn block that has `key`, null for a key of the body. */
115
+ export const blockRowByKey = (blocks: readonly (BlockHit | null)[], key: string): BlockRow | null =>
116
+ blocks.flatMap((block) => block?.rows ?? []).find((row) => row.key === key) ?? null
117
+
118
+ /** The rows of a block on top of the herd, from the screen line of its first row; none while the block is not drawn or folded. */
119
+ export const focusHitsOfBlock = (block: BlockHit | null): FocusHit[] => {
120
+ let line = block?.firstLine ?? 0
121
+ return (block?.rows ?? []).map((row) => {
122
+ const hit = { key: row.key, line, lines: row.lines.length }
123
+ line += row.lines.length
124
+ return hit
125
+ })
126
+ }
127
+
128
+ /**
129
+ * The rows of a body from the lines its whole-line targets span, `offset` screen lines below the top; a chip is part of
130
+ * its row. A line with chips and no row of its own (the links under a lane) is a hit under a key made of its line.
131
+ */
132
+ export const focusHitsOfTargets = (targets: readonly ClickTarget[], offset: number): FocusHit[] => {
133
+ const hits = new Map<string, FocusHit>()
134
+ for (const { key, line, wholeLine } of targets) {
135
+ if (!wholeLine || isChipKey(key)) continue
136
+ const known = hits.get(key)
137
+ hits.set(
138
+ key,
139
+ known ? { ...known, lines: line + 1 - (known.line - offset) } : { key, line: offset + line, lines: 1 },
140
+ )
141
+ }
142
+ const rowLines = new Set(targets.filter(({ wholeLine }) => wholeLine).map(({ line }) => line))
143
+ const linkLines = [...new Set(targets.filter(({ key }) => isChipKey(key)).map(({ line }) => line))]
144
+ const links = linkLines
145
+ .filter((line) => !rowLines.has(line))
146
+ .map((line) => ({ key: `${LINKS_KEY_PREFIX}${offset + line}`, line: offset + line, lines: 1 }))
147
+ return [...hits.values(), ...links]
148
+ }
@@ -0,0 +1,7 @@
1
+ /** What a `[done]` button closes: a record of the files and questions log, a row of the waiting section, an appointment row. */
2
+ export const DONE_KIND = { record: 'record', boardRow: 'boardRow', appointment: 'appointment' } as const
3
+
4
+ export type DoneTarget =
5
+ | { kind: typeof DONE_KIND.record; id: string }
6
+ | { kind: typeof DONE_KIND.boardRow; since: string; item: string }
7
+ | { kind: typeof DONE_KIND.appointment; when: string; action: string }
@@ -1,6 +1,7 @@
1
1
  import { currentPhase, type LaneProgress } from '../plan-format'
2
2
  import { AGENT_STATUS } from '../terminal/types'
3
3
  import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
4
+ import { TEXTS } from './texts'
4
5
 
5
6
  /**
6
7
  * Whether a lane is drawn open. It is decided once, when the lane first appears (`autoOpen`, by its status then), and
@@ -8,6 +9,10 @@ import { chipUrlOf, isChipKey, planUrlOf } from './link-chips'
8
9
  */
9
10
  export type LaneFold = { open: boolean }
10
11
 
12
+ /** The blocks on top of the herd, by the name their fold is remembered under. */
13
+ export const TOP_BLOCK = { waiting: 'waiting', next: 'next', appointments: 'appointments', sources: 'sources' } as const
14
+ export type TopBlockId = (typeof TOP_BLOCK)[keyof typeof TOP_BLOCK]
15
+
11
16
  /**
12
17
  * The row under the cursor and the expanded phases, both as keys, whether ticked phases and items are
13
18
  * listed one by one or summed up in one line, whether the footer shows the symbol legend, and the
@@ -15,10 +20,16 @@ export type LaneFold = { open: boolean }
15
20
  */
16
21
  export type Selection = {
17
22
  selected: string | null
23
+ /** The row under the mouse, null or absent while it is over none; drawn like the selected one. */
24
+ hovered?: string | null
25
+ /** The column the square-bracket button under the mouse starts in; null or absent while the mouse is over none. */
26
+ hoveredFrom?: number | null
18
27
  expanded: ReadonlySet<string>
19
28
  showDone: boolean
20
29
  showLegend: boolean
21
30
  laneFolds: ReadonlyMap<string, LaneFold>
31
+ /** Per folded top block, the keys of its points seen when it was folded; a block that is open has no entry. */
32
+ blockFolds?: Readonly<Partial<Record<TopBlockId, ReadonlySet<string>>>>
22
33
  }
23
34
 
24
35
  /** A lane as the folds need it: its name and the status its agent reports, null without an agent. */
@@ -70,12 +81,25 @@ export const PLANNED_KEY = 'planned'
70
81
 
71
82
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
72
83
 
84
+ const PHASE_KEY_SHAPE = '[^@>#]+:\\d+:[^:#>]+'
85
+
86
+ const PHASE_KEY = new RegExp(`^${PHASE_KEY_SHAPE}$`)
87
+
88
+ /** Whether a key names a phase: not a lane, row or task key, which carry `@`, `>` or `#`. */
89
+ const isPhaseKey = (key: string): boolean => PHASE_KEY.test(key)
90
+
91
+ /** The key of a lane's `next` row, which opens to the phases that wait. */
92
+ export const nextKey = (lane: string): string => `next>${lane}`
93
+
94
+ /** The key of a lane's `✓` row, which opens to the finished phases. */
95
+ export const finishedKey = (lane: string): string => `done>${lane}`
96
+
73
97
  const TICKED_KEY_PREFIX = 'ticked>'
74
98
 
75
99
  /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
76
100
  export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
77
101
 
78
- const ITEM_KEY = /^(.+:\d+:[^:#]+)#(\d+)$/
102
+ const ITEM_KEY = new RegExp(`^(${PHASE_KEY_SHAPE})#(\\d+)$`)
79
103
 
80
104
  /**
81
105
  * The key of a task row: its phase's key and the task's index in `phase.items`; a click or Enter opens its text.
@@ -173,13 +197,10 @@ const foldLane = (selection: Selection, lane: string, next: (open: boolean) => b
173
197
  return { ...selection, laneFolds: new Map(selection.laneFolds).set(lane, { ...fold, open: next(fold.open) }) }
174
198
  }
175
199
 
176
- /** Opens a task and the phase it belongs to. */
177
- const openTaskWithPhase = (expanded: Set<string>, phase: string, task: string): void => {
178
- expanded.add(phase)
179
- expanded.add(task)
180
- }
181
-
182
- /** Selects a row and opens it, or closes it when it is already open: a phase shows its items, a lane its phases. */
200
+ /**
201
+ * Selects the row under a click. A "N ticked" line flips `showDone` and selects its phase, a lane line flips the
202
+ * fold of its lane, any other row opens when closed and closes when open: a phase shows its items.
203
+ */
183
204
  export const applyClick = (selection: Selection, key: string): Selection => {
184
205
  const phase = tickedPhaseOfKey(key)
185
206
  if (phase !== null) return { ...selection, showDone: !selection.showDone, selected: phase }
@@ -187,7 +208,7 @@ export const applyClick = (selection: Selection, key: string): Selection => {
187
208
  if (lane !== null) return { ...foldLane(selection, lane, flipped), selected: key }
188
209
  const expanded = new Set(selection.expanded)
189
210
  const itemPhase = itemPhaseOfKey(key)
190
- if (itemPhase !== null && !expanded.has(itemPhase)) openTaskWithPhase(expanded, itemPhase, key)
211
+ if (itemPhase !== null && !expanded.has(itemPhase)) addWithPhase(expanded, key)
191
212
  else toggleIn(expanded, key)
192
213
  return { ...selection, selected: key, expanded }
193
214
  }
@@ -197,17 +218,30 @@ const toggle = (selection: Selection, keys: readonly string[]): Selection => {
197
218
  return current ? applyClick(selection, current) : selection
198
219
  }
199
220
 
200
- const changeSelected = (selection: Selection, keys: readonly string[], next: (open: boolean) => boolean): Selection => {
221
+ /** Adds a key to the expanded ones, with the phase of a task. */
222
+ const addWithPhase = (expanded: Set<string>, key: string): void => {
223
+ expanded.add(key)
224
+ const itemPhase = itemPhaseOfKey(key)
225
+ if (itemPhase !== null) expanded.add(itemPhase)
226
+ }
227
+
228
+ const removeKey = (expanded: Set<string>, key: string): void => {
229
+ expanded.delete(key)
230
+ }
231
+
232
+ /** Applies a change to the selected row: `nextFold` to the fold of a lane, `change` to the expanded keys for any other row. */
233
+ const changeSelected = (
234
+ selection: Selection,
235
+ keys: readonly string[],
236
+ nextFold: (open: boolean) => boolean,
237
+ change: (expanded: Set<string>, key: string) => void,
238
+ ): Selection => {
201
239
  const current = selectedKey(selection, keys)
202
240
  if (!current) return selection
203
241
  const lane = laneOfKey(current)
204
- if (lane !== null) return { ...foldLane(selection, lane, next), selected: current }
242
+ if (lane !== null) return { ...foldLane(selection, lane, nextFold), selected: current }
205
243
  const expanded = new Set(selection.expanded)
206
- if (next(expanded.has(current))) {
207
- expanded.add(current)
208
- const itemPhase = itemPhaseOfKey(current)
209
- if (itemPhase !== null) expanded.add(itemPhase)
210
- } else expanded.delete(current)
244
+ change(expanded, current)
211
245
  return { ...selection, selected: current, expanded }
212
246
  }
213
247
 
@@ -218,9 +252,59 @@ export const toggledExpansion = (selection: Selection, key: string): Selection =
218
252
  return { ...selection, expanded }
219
253
  }
220
254
 
221
- const expand = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, opened)
255
+ /**
256
+ * Whether `block` is drawn folded: it was folded and every point now open was there then. A point that arrived since
257
+ * opens it again, so folding never hides something new. Each block remembers its own points.
258
+ */
259
+ export const isBlockFolded = ({ blockFolds }: Selection, block: TopBlockId, keys: readonly string[]): boolean => {
260
+ const folded = blockFolds?.[block]
261
+ return folded !== undefined && keys.every((key) => folded.has(key))
262
+ }
263
+
264
+ /**
265
+ * The selection with the fold of each block forgotten that a new point opened, given the points each block holds now.
266
+ * Without it a block that opened for a new point would fold again by itself once that point is gone. The selection
267
+ * itself when no fold is forgotten.
268
+ */
269
+ export const settledBlockFolds = (
270
+ selection: Selection,
271
+ keysOf: Readonly<Record<TopBlockId, readonly string[]>>,
272
+ ): Selection => {
273
+ const folds = selection.blockFolds ?? {}
274
+ const opened = Object.values(TOP_BLOCK).filter(
275
+ (block) => folds[block] !== undefined && !isBlockFolded(selection, block, keysOf[block]),
276
+ )
277
+ if (opened.length === 0) return selection
278
+ const kept = Object.fromEntries(Object.entries(folds).filter(([block]) => !opened.includes(block as TopBlockId)))
279
+ const { blockFolds: _forgotten, ...rest } = selection
280
+ return Object.keys(kept).length > 0 ? { ...rest, blockFolds: kept } : rest
281
+ }
282
+
283
+ /** The selection with `block` folded over the points `keys` when it was open, and open when it was folded. */
284
+ export const toggledBlockFold = (selection: Selection, block: TopBlockId, keys: readonly string[]): Selection => {
285
+ if (!isBlockFolded(selection, block, keys)) {
286
+ return { ...selection, blockFolds: { ...selection.blockFolds, [block]: new Set(keys) } }
287
+ }
288
+ const { blockFolds, ...rest } = selection
289
+ const { [block]: _folded, ...otherFolds } = blockFolds ?? {}
290
+ return Object.keys(otherFolds).length > 0 ? { ...rest, blockFolds: otherFolds } : rest
291
+ }
222
292
 
223
- const collapse = (selection: Selection, keys: readonly string[]): Selection => changeSelected(selection, keys, closed)
293
+ const expand = (selection: Selection, keys: readonly string[]): Selection =>
294
+ changeSelected(selection, keys, opened, addWithPhase)
295
+
296
+ const collapse = (selection: Selection, keys: readonly string[]): Selection =>
297
+ changeSelected(selection, keys, closed, removeKey)
298
+
299
+ /**
300
+ * The line for a press of `a` that changes nothing on screen, null for any other input. The key lists the ticked
301
+ * tasks inside an expanded phase, so it needs one among the `keys` drawn.
302
+ */
303
+ export const showDoneNotice = (selection: Selection, input: string, keys: readonly string[]): string | null => {
304
+ if (!isInput(INPUT.showDone, input)) return null
305
+ const phaseOpen = keys.some((key) => isPhaseKey(key) && selection.expanded.has(key))
306
+ return phaseOpen ? null : TEXTS.showDoneNeedsPhase
307
+ }
224
308
 
225
309
  export const applyKey = (selection: Selection, input: string, keys: readonly string[]): Selection => {
226
310
  if (isInput(INPUT.up, input)) return move(selection, keys, -1)
@@ -233,6 +317,9 @@ export const applyKey = (selection: Selection, input: string, keys: readonly str
233
317
  return selection
234
318
  }
235
319
 
320
+ /** Whether an input is enter or space, which acts on the selected row. */
321
+ export const isActivateKey = (input: string): boolean => isInput(INPUT.toggle, input)
322
+
236
323
  /** The key that writes the whole sprint as an HTML page and opens it. */
237
324
  export const isSprintPageKey = (input: string): boolean => isInput(INPUT.sprintPage, input)
238
325
 
@@ -242,6 +329,12 @@ export const parseMouse = (key: string): MouseEvent | null => {
242
329
  return { button: Number(match[1]), column: Number(match[2]), row: Number(match[3]), pressed: match[4] === 'M' }
243
330
  }
244
331
 
332
+ const MOTION_BIT = 32
333
+
334
+ /** Whether a report is the mouse moving (with or without a button held), not a click, a release or the wheel. */
335
+ export const isMotion = ({ button, pressed }: MouseEvent): boolean =>
336
+ pressed && button >= MOTION_BIT && button < MOUSE_BUTTON.wheelUp
337
+
245
338
  export const targetAt = (targets: readonly ClickTarget[], line: number, column: number): string | null =>
246
339
  targets.find((target) => target.line === line && column >= target.from && column < target.to)?.key ?? null
247
340
 
@@ -1,22 +1,13 @@
1
- import { join } from 'node:path'
2
- import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
- import { isSentFileRecord, type SentFileRecord } from './sent-files'
1
+ import { closableRecordLog } from './closable-records'
2
+ import { isSentFileRecord } from './sent-files'
4
3
 
5
- const directoryOf = (root: string): string => join(root, '.jackherd', 'sent')
4
+ const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
6
5
 
7
6
  /** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
8
- export const readSentRecords = (root: string): SentFileRecord[] =>
9
- readRecords(directoryOf(root), isSentFileRecord, ({ sentAt }) => sentAt)
10
-
11
- const closeWhere = (root: string, isChosen: (record: SentFileRecord) => boolean, now: number): number => {
12
- const open = readSentRecords(root).filter((record) => record.closedAt === null && isChosen(record))
13
- for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
14
- return open.length
15
- }
7
+ export const readSentRecords = log.read
16
8
 
17
9
  /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
18
- export const closeSentRecords = (root: string, ids: readonly string[], now: number): number =>
19
- closeWhere(root, (record) => ids.includes(record.id), now)
10
+ export const closeSentRecords = log.close
20
11
 
21
12
  /** Marks every open record `closedAt` and returns how many it closed. */
22
- export const closeAllSentRecords = (root: string, now: number): number => closeWhere(root, () => true, now)
13
+ export const closeAllSentRecords = log.closeAll
@@ -1,8 +1,10 @@
1
1
  import { basename } from 'node:path'
2
- import { twoDigits } from '../plan-format/clock'
2
+ import { clockText, DAY_MS } from '../plan-format/clock'
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
- import type { WaitingItem } from '../plan-format/waiting'
4
+ import { WAITING_KIND, type WaitingItem } from '../plan-format/waiting'
5
+ import { isRecordId } from './record-id'
5
6
  import { laneDirectoryOf } from './unlisted-lanes'
7
+ import { isStillWaiting } from './waiting-window'
6
8
 
7
9
  /** The record of a send of files to the user, as the kit hook `sent-file` writes it to `.jackherd/sent/`. */
8
10
  export type SentFileRecord = {
@@ -19,12 +21,8 @@ export type SentFileRecord = {
19
21
  /** A badge for a sent file: a `file:` address and the file's name. */
20
22
  export type SentChip = { url: string; label: string }
21
23
 
22
- const ID = /^[0-9a-f]{12}$/
23
24
  /** The address of a published artifact, as the hook reads it from the tool's answer, as a whole. */
24
25
  const ARTIFACT_ADDRESS = /^https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+$/
25
- const DAY_MS = 86_400_000
26
- /** A send waits on the user this long at most; the coordinator closes it earlier, with the answer. */
27
- const WAITING_DAYS = 3
28
26
  const SHOWN_PER_LANE = 5
29
27
 
30
28
  /** A published artifact is recorded by its address; a sent file by its path. */
@@ -49,7 +47,7 @@ export const isSentFileRecord = (value: unknown): value is SentFileRecord => {
49
47
  const record = value as Record<string, unknown>
50
48
  return (
51
49
  typeof record.id === 'string' &&
52
- ID.test(record.id) &&
50
+ isRecordId(record.id) &&
53
51
  isStringArray(record.files) &&
54
52
  record.files.every(isRecordedFile) &&
55
53
  (record.caption === null || typeof record.caption === 'string') &&
@@ -96,18 +94,13 @@ export const sentChipsByLane = (
96
94
  return lanes
97
95
  }
98
96
 
99
- const clockText = (at: number): string => {
100
- const date = new Date(at)
101
- return `${twoDigits(date.getDate())}.${twoDigits(date.getMonth() + 1)}. ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
102
- }
103
-
104
97
  const itemOf = (record: SentFileRecord): WaitingItem => {
105
98
  const [first] = record.files
106
99
  const more = record.files.length > 1 ? ` +${record.files.length - 1}` : ''
107
100
  return {
108
101
  since: clockText(record.sentAt),
109
102
  item: `${labelOf(first, record.caption)}${more}`,
110
- kind: 'file',
103
+ kind: WAITING_KIND.file,
111
104
  id: record.id,
112
105
  open: true,
113
106
  url: addressOf(first),
@@ -117,5 +110,5 @@ const itemOf = (record: SentFileRecord): WaitingItem => {
117
110
  /** What the sends that no one closed wait on: one open item per record younger than three days, oldest first. */
118
111
  export const sentWaitingItems = (records: readonly SentFileRecord[], now: number): WaitingItem[] =>
119
112
  latestPerFile(records)
120
- .filter(({ closedAt, sentAt }) => closedAt === null && now - sentAt <= WAITING_DAYS * DAY_MS)
113
+ .filter(({ closedAt, sentAt }) => isStillWaiting(closedAt, sentAt, now))
121
114
  .map(itemOf)