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,17 +1,15 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { isAbsolute, relative } from 'node:path'
3
2
  import { mainCheckoutOf } from '../shared/checkout'
4
- import { parseTranscript } from '../shared/dialog-answers'
5
- import { isCoordinatorAgent } from '../shared/lane'
3
+ import { dialogAnswers, parseTranscript } from '../shared/dialog-answers'
4
+ import { isWithinDirectory } from '../shared/files'
6
5
  import { once } from '../shared/once'
7
- import { ownPaneAgent } from '../shared/own-pane'
8
6
  import { readShellDefinitions } from '../shared/shell-snapshots'
7
+ import { ALWAYS_ASK_PATHS } from './always-ask'
9
8
  import { approvedBranches } from './approval'
10
- import { allFilesMatch, changedFilesOf } from './check-free'
9
+ import { boardSettingsOf } from './board-settings'
10
+ import { changedFilesOf, isCheckFree } from './check-free'
11
11
  import { decideBashCommand, type GuardPolicy, type MergeGuardDecision, type MergeNote, type PlainMerge } from './decision'
12
- import { isPlanApproved, type PlanVerdict } from './plan-approval'
13
- import { isLaneIn, planApprovalSources, planBaseOf } from './plan-approval-sources'
14
- import { planPointersDirectory, readPointers } from './plan-pointers'
12
+ import { planOfBranch } from './lane-plan'
15
13
  import {
16
14
  checksPassed,
17
15
  parseBaseBranch,
@@ -20,12 +18,13 @@ import {
20
18
  parseRepositoryFacts,
21
19
  viewPullRequest,
22
20
  } from './pull-request'
23
- import { type GuardSettings, readGuardSettings } from './settings'
21
+ import { type GuardSettings, readConfigOrNull, readGuardSettings } from './settings'
22
+ import { standingVerdict, type StandingLabels, standingGrants } from './standing-approval'
24
23
 
25
- type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown; session_id?: unknown }
24
+ type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown }
26
25
 
27
- /** The exact option labels that approve a merge: the one of the pull request question and the one of a plan question. */
28
- export type ApprovalLabels = { selfMerge: string; plan: string }
26
+ /** The exact option labels that approve a merge: the one of the pull request question and those of the standing approval. */
27
+ export type ApprovalLabels = { selfMerge: string; standing: StandingLabels }
29
28
 
30
29
  /** What GitHub says about the pull request a merge names; `files` is asked only when a rule needs it. */
31
30
  type MergeFacts = {
@@ -37,44 +36,43 @@ type MergeFacts = {
37
36
  files: () => readonly string[] | undefined
38
37
  }
39
38
 
40
- type PlanApproval = (facts: MergeFacts & { headCommit: string }) => PlanVerdict
39
+ type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
41
40
 
42
41
  /** The ways a merge may pass without a question. */
43
- type MergeApprovals = { branches: ReadonlySet<string>; withoutChecks: readonly string[]; plan: PlanApproval | undefined }
42
+ type MergeApprovals = {
43
+ branches: ReadonlySet<string>
44
+ withoutChecks: readonly string[]
45
+ /** The paths that always ask, which a `withoutChecks` pattern never opens. */
46
+ alwaysAsk: readonly string[]
47
+ standing: StandingApproval | undefined
48
+ }
44
49
 
45
50
  const PASS: MergeGuardDecision = { kind: 'pass' }
46
51
 
47
- const approvedBranchesFrom = (transcriptPath: unknown, settings: GuardSettings, label: string): Set<string> => {
48
- if (!settings.selfMerge.offer || typeof transcriptPath !== 'string') return new Set()
49
- return approvedBranches({
50
- entries: parseTranscript(readFileSync(transcriptPath, 'utf8')),
51
- label,
52
- now: Date.now(),
53
- windowMinutes: settings.selfMerge.approvalMinutes,
54
- })
55
- }
52
+ /** The transcript of the session that merges, one parsed entry per line; none without one. */
53
+ const transcriptEntries = (transcriptPath: unknown): unknown[] =>
54
+ typeof transcriptPath === 'string' ? parseTranscript(readFileSync(transcriptPath, 'utf8')) : []
56
55
 
57
- /** Where the command runs must lie in the project: the same branch name in another checkout is another pull request. */
58
- const isInside = (directory: string, project: string): boolean => {
59
- const path = relative(project, directory)
60
- return path === '' || (!path.startsWith('..') && !isAbsolute(path))
56
+ const approvedBranchesFrom = (entries: unknown[], settings: GuardSettings, label: string): Set<string> => {
57
+ if (!settings.selfMerge.offer) return new Set()
58
+ return approvedBranches({ entries, label, now: Date.now(), windowMinutes: settings.selfMerge.approvalMinutes })
61
59
  }
62
60
 
63
61
  /**
64
- * The user approved the branch and its checks passed, every file needs no checks, or the user approved the plan of the
65
- * branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check is not
66
- * merged unchecked. A plan approval that no longer covers the plan answers with a note for the question.
62
+ * The user approved the branch and its checks passed, every file needs no checks, or a standing approval covers the
63
+ * plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check
64
+ * is not merged unchecked. A standing approval that does not cover a file answers with a note for the question.
67
65
  */
68
66
  const approvesMerge = (
69
- { branches, withoutChecks, plan }: MergeApprovals,
67
+ { branches, withoutChecks, alwaysAsk, standing }: MergeApprovals,
70
68
  merge: PlainMerge,
71
69
  facts: MergeFacts,
72
70
  ): boolean | MergeNote => {
73
71
  const { headCommit } = facts
74
72
  if (headCommit === undefined || merge.headCommit !== headCommit) return false
75
73
  if (branches.has(facts.branch) && checksPassed(facts.view, undefined)) return true
76
- if (withoutChecks.length > 0 && allFilesMatch(facts.files() ?? [], withoutChecks)) return true
77
- return plan === undefined ? false : plan({ ...facts, headCommit })
74
+ if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
75
+ return standing === undefined ? false : standing(facts)
78
76
  }
79
77
 
80
78
  /**
@@ -106,25 +104,25 @@ const mergeFactsOf = (pullRequest: number, view: string, directory: string): Mer
106
104
  }
107
105
 
108
106
  /**
109
- * How to tell whether the user approved the plan of the branch's lane, read in the main checkout; undefined when no
110
- * plan approval can hold (no label, no transcript, no repository, plan approval off, no pointer, a session that is not
111
- * the coordinator's), so GitHub is not asked.
107
+ * How to tell whether a standing approval in this session's transcript covers a merge, with the boards, worktrees and
108
+ * settings of the main checkout; undefined when none can (no approval still holding, no repository, settings that do
109
+ * not read), so GitHub is not asked.
112
110
  */
113
- const planApprovalFor = (input: HookInput, label: string, projectDirectory: string): PlanApproval | undefined => {
114
- const transcript = input.transcript_path
115
- if (label === '' || typeof transcript !== 'string') return undefined
111
+ const standingApprovalFor = (entries: unknown[], labels: StandingLabels, projectDirectory: string): StandingApproval | undefined => {
112
+ const grants = standingGrants(dialogAnswers(entries), labels, Date.now())
113
+ if (grants.length === 0) return undefined
116
114
  try {
117
115
  const main = mainCheckoutOf(projectDirectory)
118
- if (readPointers(planPointersDirectory(main)).length === 0) return undefined
119
- const base = planBaseOf(main)
120
- if (base === undefined) return undefined
121
- const isLane = isLaneIn(main, base.settings.worktreeDirectory)
122
- if (!isCoordinatorAgent({ agent: ownPaneAgent(), session: input.session_id, main, isLane })) return undefined
123
- return ({ branch, baseBranch, headCommit, view, files }) =>
124
- baseBranch !== undefined &&
125
- isPlanApproved(
126
- { pullRequest: { branch, baseBranch, headCommit }, label, now: Date.now(), settings: base.settings },
127
- planApprovalSources({ main, base, transcript, label, files, view }),
116
+ const settings = boardSettingsOf(readConfigOrNull([main]))
117
+ if (settings === null) return undefined
118
+ return ({ branch, baseBranch, view, files }) =>
119
+ standingVerdict(
120
+ { grants, branch, baseBranch, settingsBaseBranch: settings.baseBranch },
121
+ {
122
+ planOfBranch: (name) => planOfBranch(main, settings, name),
123
+ checksPassed: () => checksPassed(view, settings.requiredChecks),
124
+ files,
125
+ },
128
126
  )
129
127
  } catch {
130
128
  return undefined
@@ -134,15 +132,22 @@ const planApprovalFor = (input: HookInput, label: string, projectDirectory: stri
134
132
  const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: string): GuardPolicy => {
135
133
  const settings = readGuardSettings(projectDirectory)
136
134
  const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
137
- const branches = once(() => approvedBranchesFrom(input.transcript_path, settings, labels.selfMerge))
138
- const plan = once(() => planApprovalFor(input, labels.plan, projectDirectory))
135
+ const entries = once(() => transcriptEntries(input.transcript_path))
136
+ const branches = once(() => approvedBranchesFrom(entries(), settings, labels.selfMerge))
137
+ const standing = once(() => standingApprovalFor(entries(), labels.standing, projectDirectory))
139
138
  return {
140
139
  commands: settings.commands,
141
140
  shellDefinitions: once(() => readShellDefinitions()),
142
141
  isMergeApproved: (merge) => {
143
- if (!isInside(directory, projectDirectory)) return false
144
- const approvals = { branches: branches(), withoutChecks: settings.withoutChecks, plan: plan() }
145
- if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.plan === undefined) return false
142
+ // the same branch name in another checkout is another pull request
143
+ if (!isWithinDirectory(directory, projectDirectory)) return false
144
+ const approvals = {
145
+ branches: branches(),
146
+ withoutChecks: settings.withoutChecks,
147
+ alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
148
+ standing: standing(),
149
+ }
150
+ if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
146
151
  // The branch lookup also proves the pull request is open and from this repository; every path needs that.
147
152
  const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
148
153
  return facts !== undefined && approvesMerge(approvals, merge, facts)
@@ -8,12 +8,18 @@ export type GuardSettings = {
8
8
  selfMerge: { offer: boolean; approvalMinutes: number }
9
9
  /** Path patterns of a pull request that is merged without checks; empty means every merge waits for them. */
10
10
  withoutChecks: string[]
11
+ /**
12
+ * The project's own paths that always ask (`guard.alwaysAsk`, and `guard.planApproval.exceptPaths` of a config from
13
+ * before 1.10.0); they only ever add a question.
14
+ */
15
+ alwaysAsk: string[]
11
16
  }
12
17
 
13
18
  export const DEFAULT_SETTINGS: GuardSettings = {
14
- commands: ['gh pr merge', 'gh workflow run', 'gh run rerun', 'gh api'],
19
+ commands: ['gh pr merge', 'gh workflow run'],
15
20
  selfMerge: { offer: true, approvalMinutes: 180 },
16
21
  withoutChecks: [],
22
+ alwaysAsk: [],
17
23
  }
18
24
 
19
25
  const CONFIG_PATH = join('.jackherd', 'config.json')
@@ -50,6 +56,10 @@ export const readConfigOrNull = (directories: string[]): Record<string, unknown>
50
56
 
51
57
  const readConfig = (directories: string[]): Record<string, unknown> => readConfigOrNull(directories) ?? {}
52
58
 
59
+ /** Every non-empty string of a list; a path only ever adds a question, so no entry is refused for its form. */
60
+ const pathsOf = (value: unknown): string[] =>
61
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string' && entry !== '') : []
62
+
53
63
  /**
54
64
  * The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
55
65
  * malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
@@ -59,6 +69,7 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
59
69
  const section = isRecord(config.guard) ? config.guard : {}
60
70
  const merge = isRecord(config.merge) ? config.merge : {}
61
71
  const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
72
+ const legacy = isRecord(section.planApproval) ? section.planApproval : {}
62
73
  return {
63
74
  commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
64
75
  selfMerge: {
@@ -68,5 +79,6 @@ export const readGuardSettings = (projectDirectory: string, home: string = homed
68
79
  : DEFAULT_SETTINGS.selfMerge.approvalMinutes,
69
80
  },
70
81
  withoutChecks: isPatternList(merge.withoutChecks) ? merge.withoutChecks : DEFAULT_SETTINGS.withoutChecks,
82
+ alwaysAsk: [...pathsOf(section.alwaysAsk), ...pathsOf(legacy.exceptPaths)],
71
83
  }
72
84
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The standing approval: the user's answer to a dialog whose question carries ``Plan `<path>` <until> <YYYY-MM-DD HH:MM>``
3
+ * lifts the question for the merges of that plan's lanes until that moment, at most 24 hours after the answer. It is
4
+ * read from the transcript of the session that merges, like the self-merge label, so a new session after a hand-over
5
+ * holds none and asks again (`claude --resume` keeps the transcript, so there it holds on, at most 24 hours). The newest
6
+ * answer to a question naming the plan decides; any other label ends the approval, also on a question without an end.
7
+ * A merge passes only into the base branch, with green checks GitHub can merge, from a branch whose lane links exactly
8
+ * that plan, and never with a file of `STANDING_ALWAYS_ASK`. Every doubt answers "no", so the guard asks as before.
9
+ */
10
+ import type { DialogAnswer } from '../shared/dialog-answer'
11
+ import { MS_PER_MINUTE } from '../shared/fresh'
12
+ import { newestVerdict } from '../shared/newest-verdict'
13
+ import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
14
+ import type { MergeNote } from './decision'
15
+ import { isPlainPath } from './plain-path'
16
+
17
+ /** The exact option label that approves, and the word before the end in the question, both per response language. */
18
+ export type StandingLabels = { approve: string; until: string }
19
+
20
+ /** A plan whose lanes merge without a question until `until`, in milliseconds. */
21
+ export type StandingGrant = { plan: string; until: number }
22
+
23
+ /** Where the facts of a merge come from; each is asked only once the steps before it said yes. */
24
+ export type StandingApprovalSources = {
25
+ /** The one plan the lane of the branch links on the boards; undefined for none, several, or a branch of no lane. */
26
+ planOfBranch: (branch: string) => string | undefined
27
+ /** Whether GitHub can merge the head commit and its checks passed. */
28
+ checksPassed: () => boolean
29
+ /** The files of the pull request as GitHub lists them, renames with their old name; undefined when not complete. */
30
+ files: () => readonly string[] | undefined
31
+ }
32
+
33
+ export type StandingApprovalQuery = {
34
+ grants: readonly StandingGrant[]
35
+ /** The head branch and the branch the pull request merges into, as GitHub says. */
36
+ branch: string
37
+ baseBranch: string | undefined
38
+ /** The project's base branch. */
39
+ settingsBaseBranch: string
40
+ }
41
+
42
+ export const MAX_STANDING_MINUTES = 24 * 60
43
+
44
+ const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
45
+
46
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
47
+
48
+ /** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
49
+ const isPlanPath = (path: string): boolean =>
50
+ PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
51
+
52
+ /** A local moment from its parts; undefined for one the calendar or the clock does not have (Feb 30, 24:00, a DST gap). */
53
+ const localMoment = ([year, month, day, hour, minute]: number[]): number | undefined => {
54
+ const date = new Date(year, month - 1, day, hour, minute)
55
+ const exact =
56
+ date.getFullYear() === year &&
57
+ date.getMonth() === month - 1 &&
58
+ date.getDate() === day &&
59
+ date.getHours() === hour &&
60
+ date.getMinutes() === minute
61
+ return exact ? date.getTime() : undefined
62
+ }
63
+
64
+ /** The plan and the end a question names as ``Plan `<path>` <until> YYYY-MM-DD HH:MM``; none for none or several marks. */
65
+ export const standingMarkOf = (question: string, until: string): StandingGrant | undefined => {
66
+ if (until === '') return undefined
67
+ const mark = new RegExp(`\\bPlan \`([^\`]*)\` ${escapeRegExp(until)} (\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})(?!\\d)`, 'g')
68
+ const marks = [...question.matchAll(mark)]
69
+ if (marks.length !== 1) return undefined
70
+ const [, plan, ...parts] = marks[0]
71
+ const end = localMoment(parts.map(Number))
72
+ return end !== undefined && isPlanPath(plan) ? { plan, until: end } : undefined
73
+ }
74
+
75
+ const PLAN_MENTION = /\bPlan `([^`]*)`/g
76
+
77
+ /** The one plan a question names as ``Plan `<path>` ``, with or without an end; none for none or several. */
78
+ const namedPlanOf = (question: string): string | undefined => {
79
+ const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, plan]) => plan))
80
+ const [only] = plans
81
+ return plans.size === 1 && isPlanPath(only) ? only : undefined
82
+ }
83
+
84
+ /**
85
+ * The plan an answer speaks about, and the end its question names; `NaN` for a question that names the plan without
86
+ * an end, which never approves but, answered with anything else, ends the approval: in doubt it ends.
87
+ */
88
+ const markOf = (question: string, until: string): StandingGrant | undefined => {
89
+ const mark = standingMarkOf(question, until)
90
+ if (mark !== undefined) return mark
91
+ const plan = namedPlanOf(question)
92
+ return plan === undefined ? undefined : { plan, until: Number.NaN }
93
+ }
94
+
95
+ type MarkedAnswer = DialogAnswer & { mark: StandingGrant }
96
+
97
+ const approves = ({ answer, offered, answeredAt, mark }: MarkedAnswer, label: string): boolean =>
98
+ label !== '' &&
99
+ answer === label &&
100
+ offered.includes(label) &&
101
+ mark.until > answeredAt &&
102
+ mark.until - answeredAt <= MAX_STANDING_MINUTES * MS_PER_MINUTE
103
+
104
+ /** The approval the newest answers about one plan give: all of them approve, and the earliest end is still ahead. */
105
+ const grantOf = (plan: string, answers: readonly MarkedAnswer[], label: string, now: number): StandingGrant[] => {
106
+ const verdict = newestVerdict(answers, now)
107
+ if (verdict.length === 0 || !verdict.every((answer) => approves(answer, label))) return []
108
+ const until = Math.min(...verdict.map(({ mark }) => mark.until))
109
+ return until > now ? [{ plan, until }] : []
110
+ }
111
+
112
+ /** The plans the user approved standing in these dialog answers, and until when; only those still holding at `now`. */
113
+ export const standingGrants = (answers: readonly DialogAnswer[], labels: StandingLabels, now: number): StandingGrant[] => {
114
+ const marked = answers.flatMap((answer): MarkedAnswer[] => {
115
+ const mark = markOf(answer.question, labels.until)
116
+ return mark === undefined ? [] : [{ ...answer, mark }]
117
+ })
118
+ const plans = [...new Set(marked.map(({ mark }) => mark.plan))]
119
+ return plans.flatMap((plan) =>
120
+ grantOf(
121
+ plan,
122
+ marked.filter(({ mark }) => mark.plan === plan),
123
+ labels.approve,
124
+ now,
125
+ ),
126
+ )
127
+ }
128
+
129
+ const exceptedNote = (plan: string, files: readonly string[]): MergeNote => ({
130
+ note: `The standing approval of plan \`${plan}\` does not cover ${files.map((file) => `\`${file}\``).join(', ')}: workflows, migrations, env files and the guards' own files always ask.`,
131
+ })
132
+
133
+ /** `true` lifts the question; `false` asks as before; a note asks and says which files the approval does not cover. */
134
+ export const standingVerdict = (query: StandingApprovalQuery, sources: StandingApprovalSources): boolean | MergeNote => {
135
+ if (query.grants.length === 0 || query.baseBranch !== query.settingsBaseBranch) return false
136
+ const plan = sources.planOfBranch(query.branch)
137
+ if (plan === undefined || !query.grants.some((grant) => grant.plan === plan) || !sources.checksPassed()) return false
138
+ const files = sources.files()
139
+ if (files === undefined || files.length === 0 || !files.every(isPlainPath)) return false
140
+ const excepted = alwaysAskingFiles(files, STANDING_ALWAYS_ASK)
141
+ return excepted.length === 0 ? true : exceptedNote(plan, excepted)
142
+ }
@@ -0,0 +1,26 @@
1
+ // Stop hook of Claude Code, shipped by jackherd: records each `AskUserQuestion` question that ended without an answer
2
+ // (the dialog was rejected or interrupted, or answered with "later") in `.jackherd/questions/` of the main checkout, and
3
+ // closes the open records a later answer to the same question settles. The herd, the heartbeat and `jh waiting` show the
4
+ // open ones as something that waits on the user. The whole transcript is read, because a rejected dialog ends its turn
5
+ // without a Stop hook. It prints nothing and never blocks.
6
+ import { createHash } from 'node:crypto'
7
+ import { readFileSync } from 'node:fs'
8
+ import { mainCheckoutOf } from '../shared/checkout'
9
+ import { runOpenQuestion } from './run'
10
+ import { readQuestionRecords, writeQuestionRecord } from './store'
11
+
12
+ const ID_LENGTH = 12
13
+
14
+ try {
15
+ runOpenQuestion(JSON.parse(await Bun.stdin.text()), {
16
+ mainCheckoutOf,
17
+ readTranscript: (path) => readFileSync(path, 'utf8'),
18
+ readRecords: readQuestionRecords,
19
+ writeRecord: writeQuestionRecord,
20
+ idOf: (toolUseId, question) =>
21
+ createHash('sha256').update(`${toolUseId}\n${question}`).digest('hex').slice(0, ID_LENGTH),
22
+ })
23
+ } catch {
24
+ // Unreadable input records nothing.
25
+ }
26
+ process.exit(0)
@@ -0,0 +1,135 @@
1
+ import { ASK_USER_QUESTION_TOOL, contentOf, dialogAnswers, questionsOf } from '../shared/dialog-answers'
2
+ import { isRecord, type JsonObject } from '../shared/json'
3
+
4
+ /**
5
+ * A question of a dialog that ended without an answer, as the hook records it: what was asked, when, from where and
6
+ * when it closed (null while it is open). Written by the hook, read by the herd, the watcher and `jh waiting`.
7
+ */
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
+ export type QuestionContext = {
18
+ cwd: string
19
+ session: string
20
+ /** What the dialogs of the transcript have answered, as `answeredQuestionsOf` finds it. */
21
+ answered: readonly AnsweredQuestion[]
22
+ /** The id of a question; the same call and text give the same id, so a second run finds the record it wrote. */
23
+ idOf: (toolUseId: string, question: string) => string
24
+ }
25
+
26
+ /** A question the user answered for real, with the time of the answer. */
27
+ export type AnsweredQuestion = { question: string; answeredAt: number }
28
+
29
+ type Ask = { toolUseId: string; question: string; askedAt: number; position: number; messageId: string | null }
30
+
31
+ const ID = /^[0-9a-f]{12}$/
32
+ const MAX_QUESTION = 200
33
+ const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g
34
+ /** An answer that puts the question off rather than settling it: later, or not yet. */
35
+ const LATER_WORD = /\b(später|spaeter|later|noch\s+nicht|not\s+yet)\b/i
36
+
37
+ /** The question as one line without control characters, cut to what a row shows; how records and answers are compared. */
38
+ export const questionTextOf = (value: string): string =>
39
+ value.replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim().slice(0, MAX_QUESTION)
40
+
41
+ const messageIdOf = (entry: JsonObject): string | null =>
42
+ isRecord(entry.message) && typeof entry.message.id === 'string' ? entry.message.id : null
43
+
44
+ const mainThread = (entries: readonly unknown[]): JsonObject[] =>
45
+ entries.filter(isRecord).filter((entry) => entry.isSidechain !== true)
46
+
47
+ /** Every question of every `AskUserQuestion` call of the main thread, with where in the transcript it stands. */
48
+ const asksOf = (entries: readonly JsonObject[]): Ask[] =>
49
+ entries.flatMap((entry, position) => {
50
+ const askedAt = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
51
+ if (Number.isNaN(askedAt)) return []
52
+ return contentOf(entry)
53
+ .filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL)
54
+ .flatMap((block) =>
55
+ typeof block.id === 'string'
56
+ ? questionsOf(block.input).map(({ question }) => ({
57
+ toolUseId: block.id as string,
58
+ question,
59
+ askedAt,
60
+ position,
61
+ messageId: messageIdOf(entry),
62
+ }))
63
+ : [],
64
+ )
65
+ })
66
+
67
+ /** The answers of each tool result by tool use id; null for a result without answers (a rejection, an error). */
68
+ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | null> => {
69
+ const results = new Map<string, JsonObject | null>()
70
+ for (const entry of entries) {
71
+ const answers = isRecord(entry.toolUseResult) && isRecord(entry.toolUseResult.answers) ? entry.toolUseResult.answers : null
72
+ for (const block of contentOf(entry))
73
+ if (block.type === 'tool_result' && typeof block.tool_use_id === 'string') results.set(block.tool_use_id, answers)
74
+ }
75
+ return results
76
+ }
77
+
78
+ /** Whether the transcript went on after the call, other than with the rest of the message that made it. */
79
+ const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
80
+ entries
81
+ .slice(ask.position + 1)
82
+ .some((entry) => (entry.type === 'user' || entry.type === 'assistant') && messageIdOf(entry) !== ask.messageId)
83
+
84
+ /** No answer came back, the answer is not a text for this question, or it only puts the question off. */
85
+ const endedUnanswered = (ask: Ask, results: ReadonlyMap<string, JsonObject | null>, entries: readonly JsonObject[]) => {
86
+ if (!results.has(ask.toolUseId)) return wentOnAfter(ask, entries)
87
+ const answer = results.get(ask.toolUseId)?.[ask.question]
88
+ return typeof answer !== 'string' || LATER_WORD.test(answer)
89
+ }
90
+
91
+ /** The questions the user answered in a dialog without putting them off, in transcript order. */
92
+ export const answeredQuestionsOf = (entries: readonly unknown[]): AnsweredQuestion[] =>
93
+ dialogAnswers(entries)
94
+ .filter(({ answer }) => !LATER_WORD.test(answer))
95
+ .map(({ question, answeredAt }) => ({ question: questionTextOf(question), answeredAt }))
96
+
97
+ /** The answer to `question` given after `at`, if a dialog gave one. */
98
+ export const answeredSince = (
99
+ answered: readonly AnsweredQuestion[],
100
+ question: string,
101
+ at: number,
102
+ ): AnsweredQuestion | undefined => answered.find((done) => done.question === question && done.answeredAt > at)
103
+
104
+ /**
105
+ * The questions of the main thread that stand open: their dialog was rejected or interrupted, or answered with "later",
106
+ * and no dialog answered the same text since. A call that carries its own answers asked nobody; a sidechain is no dialog
107
+ * of the user's.
108
+ */
109
+ export const openQuestionsOf = (entries: readonly unknown[], context: QuestionContext): QuestionRecord[] => {
110
+ const thread = mainThread(entries)
111
+ const results = resultsOf(thread)
112
+ return asksOf(thread)
113
+ .filter((ask) => endedUnanswered(ask, results, thread))
114
+ .map((ask) => ({ ask, question: questionTextOf(ask.question) }))
115
+ .filter(({ ask, question }) => question !== '' && !answeredSince(context.answered, question, ask.askedAt))
116
+ .map(({ ask, question }) => ({
117
+ id: context.idOf(ask.toolUseId, question),
118
+ question,
119
+ askedAt: ask.askedAt,
120
+ cwd: context.cwd,
121
+ session: context.session,
122
+ closedAt: null,
123
+ }))
124
+ }
125
+
126
+ /** A record of the shape the hook writes; the herd reads questions only through this. */
127
+ export const isQuestionRecord = (value: unknown): value is QuestionRecord =>
128
+ isRecord(value) &&
129
+ typeof value.id === 'string' &&
130
+ ID.test(value.id) &&
131
+ typeof value.question === 'string' &&
132
+ typeof value.askedAt === 'number' &&
133
+ typeof value.cwd === 'string' &&
134
+ typeof value.session === 'string' &&
135
+ (value.closedAt === null || typeof value.closedAt === 'number')
@@ -0,0 +1,57 @@
1
+ import { ASK_USER_QUESTION_TOOL, parseTranscript } from '../shared/dialog-answers'
2
+ import { isRecord } from '../shared/json'
3
+ import { answeredQuestionsOf, answeredSince, openQuestionsOf, type QuestionRecord } from './record'
4
+
5
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
6
+ export type OpenQuestionDependencies = {
7
+ mainCheckoutOf: (cwd: string) => string
8
+ readTranscript: (path: string) => string
9
+ readRecords: (main: string) => QuestionRecord[]
10
+ writeRecord: (main: string, record: QuestionRecord) => void
11
+ idOf: (toolUseId: string, question: string) => string
12
+ }
13
+
14
+ const MARKERS = [ASK_USER_QUESTION_TOOL, '"answers"', '"is_error":true']
15
+
16
+ /**
17
+ * The lines of a transcript that can say anything about a dialog, and its last line: a transcript runs to many
18
+ * megabytes, and the last line is how a call without a result shows that the session went on after it.
19
+ */
20
+ export const relevantLines = (text: string): string => {
21
+ const lines = text.split('\n').filter((line) => line.trim() !== '')
22
+ return lines
23
+ .filter((line, index) => index === lines.length - 1 || MARKERS.some((marker) => line.includes(marker)))
24
+ .join('\n')
25
+ }
26
+
27
+ /**
28
+ * Records the questions of the session's dialogs that ended without an answer, and closes the open records of every
29
+ * session that a dialog in this transcript has answered since. The whole transcript is read, not the last turn: a
30
+ * rejected dialog ends its turn without a Stop hook, so it is found at the end of the next turn. Claude Code's hook
31
+ * input: `cwd`, `session_id`, `transcript_path`. A question whose text has an open record already adds none, so asking
32
+ * it again and putting it off again leaves one. Writes nothing on any failure.
33
+ */
34
+ export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDependencies): void => {
35
+ try {
36
+ if (!isRecord(input)) return
37
+ const { cwd, session_id: session, transcript_path: transcriptPath } = input
38
+ if (typeof cwd !== 'string' || typeof session !== 'string' || typeof transcriptPath !== 'string') return
39
+ const entries = parseTranscript(relevantLines(dependencies.readTranscript(transcriptPath)))
40
+ const main = dependencies.mainCheckoutOf(cwd)
41
+ const stored = dependencies.readRecords(main)
42
+ const answered = answeredQuestionsOf(entries)
43
+ for (const record of stored.filter(({ closedAt }) => closedAt === null)) {
44
+ const answer = answeredSince(answered, record.question, record.askedAt)
45
+ if (answer) dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt })
46
+ }
47
+ const known = new Set(stored.map(({ id }) => id))
48
+ const openTexts = new Set(stored.filter(({ closedAt }) => closedAt === null).map(({ question }) => question))
49
+ for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf })) {
50
+ if (known.has(record.id) || openTexts.has(record.question)) continue
51
+ openTexts.add(record.question)
52
+ dependencies.writeRecord(main, record)
53
+ }
54
+ } catch {
55
+ // A failed record is no record; the herd shows no question.
56
+ }
57
+ }
@@ -0,0 +1,39 @@
1
+ import { lstatSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { writePrivateRecord } from '../shared/private-directory'
4
+ import { isQuestionRecord, type QuestionRecord } from './record'
5
+
6
+ const RECORD_FILE = /^[0-9a-f]{12}\.json$/
7
+ const MAX_RECORD_BYTES = 16 * 1024
8
+
9
+ /** Where the records lie: in the main checkout beside `.jackherd/sent`, which the herd reads. Ignored by git. */
10
+ export const questionDirectory = (main: string): string => join(main, '.jackherd', 'questions')
11
+
12
+ const recordAt = (path: string): QuestionRecord[] => {
13
+ try {
14
+ const entry = lstatSync(path)
15
+ if (!entry.isFile() || entry.size > MAX_RECORD_BYTES) return []
16
+ const record: unknown = JSON.parse(readFileSync(path, 'utf8'))
17
+ return isQuestionRecord(record) ? [record] : []
18
+ } catch {
19
+ return []
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Every well-formed `<id>.json` record of the directory, like the herd reads them: a link and a file over 16 KiB are
25
+ * skipped. Nothing when the directory is missing or unreadable.
26
+ */
27
+ export const readQuestionRecords = (main: string): QuestionRecord[] => {
28
+ const directory = questionDirectory(main)
29
+ try {
30
+ return readdirSync(directory)
31
+ .filter((name) => RECORD_FILE.test(name))
32
+ .flatMap((name) => recordAt(join(directory, name)))
33
+ } catch {
34
+ return []
35
+ }
36
+ }
37
+
38
+ export const writeQuestionRecord = (main: string, record: QuestionRecord): void =>
39
+ writePrivateRecord(questionDirectory(main), record)
@@ -1,18 +1,9 @@
1
- import { chmodSync, renameSync, writeFileSync } from 'node:fs'
2
1
  import { join } from 'node:path'
3
- import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
2
+ import { writePrivateRecord } from '../shared/private-directory'
4
3
  import type { SentRecord } from './record'
5
4
 
6
5
  /** Where the records lie: in the main checkout beside `.jackherd/approvals`, which the herd reads. Ignored by git. */
7
6
  export const sentDirectory = (main: string): string => join(main, '.jackherd', 'sent')
8
7
 
9
- /** Writes the record through a temporary file, so a reader never sees half of it. */
10
- export const writeSentRecord = (main: string, record: SentRecord): void => {
11
- const directory = sentDirectory(main)
12
- ensurePrivateDirectory(directory)
13
- const path = join(directory, `${record.id}.json`)
14
- const temporary = `${path}.${process.pid}.tmp`
15
- writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
16
- chmodSync(temporary, PRIVATE_FILE)
17
- renameSync(temporary, path)
18
- }
8
+ export const writeSentRecord = (main: string, record: SentRecord): void =>
9
+ writePrivateRecord(sentDirectory(main), record)