jackherd 1.8.1 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  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/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -0,0 +1,235 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ export const TASK_PARALLEL_EVENT = 'parallel tasks'
4
+
5
+ /**
6
+ * A proposal: the line `formatTaskParallelLine` made for `lane`; with `lane` null, the line that the switch
7
+ * `watch.parallelTasks.auto` ended.
8
+ */
9
+ export type TaskParallelEvent = { kind: typeof TASK_PARALLEL_EVENT; lane: string | null; line: string }
10
+
11
+ /** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
12
+ export type TaskParallelFingerprints = Record<string, string>
13
+
14
+ export const isTaskParallelFingerprints = (value: unknown): value is TaskParallelFingerprints =>
15
+ typeof value === 'object' &&
16
+ value !== null &&
17
+ !Array.isArray(value) &&
18
+ Object.values(value).every((entry) => typeof entry === 'string')
19
+
20
+ export type OpenTask = { text: string; phase: string }
21
+
22
+ /** One agent's recent work: the files it touched, its active task text and phase when the signal names them. */
23
+ export type ActiveWork = { name: string; files: string[]; taskText?: string; phase?: string }
24
+
25
+ export type TaskParallelInput = {
26
+ lane: string
27
+ open: OpenTask[]
28
+ active: ActiveWork[]
29
+ /** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
30
+ phaseWrites: Record<string, string[]>
31
+ /** The same from the plan at the base branch, which the lane cannot change; what an `auto` mark trusts. */
32
+ baseWrites: Record<string, string[]>
33
+ doneCount: number
34
+ /** Tokens a fresh agent costs before it does anything, measured by the caller. */
35
+ baselineTokens: number
36
+ /** A proposal is made only when the estimate stays below this. */
37
+ thresholdTokens: number
38
+ }
39
+
40
+ export type TaskParallelResult =
41
+ | { proposed: false; reason: string }
42
+ | {
43
+ proposed: true
44
+ tasks: OpenTask[]
45
+ waiting: number
46
+ extraAgents: number
47
+ estimateTokens: number
48
+ reason: string
49
+ }
50
+
51
+ export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
52
+
53
+ export const AUTO_VERDICT = { auto: 'auto', off: 'off', asks: 'asks' } as const
54
+
55
+ /**
56
+ * Whether a proposal starts without a question (`task-parallel-auto.ts`): `auto` with the switch's last day, `off`
57
+ * without a switch, `asks` with the limit that held it back.
58
+ */
59
+ export type AutoVerdict =
60
+ | { kind: typeof AUTO_VERDICT.auto; until: string }
61
+ | { kind: typeof AUTO_VERDICT.off }
62
+ | { kind: typeof AUTO_VERDICT.asks; reason: string }
63
+
64
+ const MIN_TASKS_BESIDE_AGENT = 1
65
+ const MIN_TASKS_WITHOUT_AGENT = 2
66
+ const TEXT_LIMIT = 60
67
+ const TOKENS_PER_K = 1000
68
+ export const GLOB_CHARACTER = /[*?[{]/
69
+ const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
70
+ const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
71
+ const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
72
+ const BACKTICKED = /`([^`]+)`/g
73
+ const FILE_LIKE = new RegExp(`/|\\.(?:${PATH_EXTENSIONS})$|^\\.[\\w-]+`)
74
+
75
+ const normalise = (entry: string): string | null => {
76
+ const trimmed = entry.trim()
77
+ if (trimmed === '' || /\s/.test(trimmed)) return null
78
+ const path = trimmed.replace(LINE_SUFFIX, '').replace(/^\.\//, '').replace(/\/+$/, '')
79
+ return FILE_LIKE.test(path) && path !== '' ? path : null
80
+ }
81
+
82
+ /**
83
+ * The paths a task line names: backticked entries that look like a path or file name and bare file names with a known
84
+ * extension. `:line` suffixes and `./` are cut, globs stay globs, duplicates drop. A task naming none is unknown, and
85
+ * unknown is never proposed.
86
+ */
87
+ export const pathsOfTask = (text: string): string[] => {
88
+ const backticked = [...text.matchAll(BACKTICKED)].map((match) => match[1] ?? '')
89
+ const bare = text.replace(BACKTICKED, ' ').match(BARE_FILE) ?? []
90
+ return [...new Set([...backticked, ...bare].map(normalise).filter((path): path is string => path !== null))]
91
+ }
92
+
93
+ const literalPrefix = (path: string): string => {
94
+ const glob = path.search(GLOB_CHARACTER)
95
+ return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
96
+ }
97
+
98
+ const isInside = (inner: string, outer: string): boolean =>
99
+ outer === '' || inner === outer || inner.startsWith(`${outer}/`)
100
+
101
+ const holdsName = (name: string, path: string): boolean => `/${path}/`.includes(`/${name}/`) || path === name
102
+
103
+ /**
104
+ * Whether two paths may touch the same files. Directories contain what lies below them, a glob stands for the
105
+ * directory its literal part ends in, `''` is the whole repository, and a bare name matches that name in any directory.
106
+ * When unsure the answer is yes.
107
+ */
108
+ export const pathsOverlap = (left: string, right: string): boolean => {
109
+ const [a, b] = [literalPrefix(left), literalPrefix(right)]
110
+ const bareLeft = !left.includes('/') && !GLOB_CHARACTER.test(left) && left !== ''
111
+ const bareRight = !right.includes('/') && !GLOB_CHARACTER.test(right) && right !== ''
112
+ if (bareLeft || bareRight) {
113
+ const [name, other] = bareLeft ? [left, right] : [right, left]
114
+ return GLOB_CHARACTER.test(other) ? true : other === '' || holdsName(name, other)
115
+ }
116
+ return isInside(a, b) || isInside(b, a)
117
+ }
118
+
119
+ const clash = (left: readonly string[], right: readonly string[]): boolean =>
120
+ left.some((a) => right.some((b) => pathsOverlap(a, b)))
121
+
122
+ const activeFootprint = (input: TaskParallelInput): string[] =>
123
+ input.active.flatMap((work) => [
124
+ ...work.files,
125
+ ...pathsOfTask(work.taskText ?? ''),
126
+ ...(work.phase === undefined ? [] : (input.phaseWrites[work.phase] ?? [])),
127
+ ])
128
+
129
+ const estimateTokens = (input: TaskParallelInput, taskCount: number): { extraAgents: number; tokens: number } => {
130
+ const extraAgents = input.active.length > 0 ? taskCount : taskCount - 1
131
+ return { extraAgents, tokens: input.baselineTokens * extraAgents }
132
+ }
133
+
134
+ const kilo = (tokens: number): string => `${Math.round(tokens / TOKENS_PER_K)}k`
135
+
136
+ /** The first group of open tasks, in plan order, whose paths clash neither with the running work nor with each other. */
137
+ const parallelGroup = (input: TaskParallelInput): OpenTask[] => {
138
+ const running = activeFootprint(input)
139
+ const chosen: { task: OpenTask; paths: string[] }[] = []
140
+ for (const task of input.open) {
141
+ const paths = pathsOfTask(task.text)
142
+ const free = paths.length > 0 && !clash(paths, running) && chosen.every((other) => !clash(paths, other.paths))
143
+ if (free) chosen.push({ task, paths })
144
+ }
145
+ return chosen.map(({ task }) => task)
146
+ }
147
+
148
+ /**
149
+ * Open tasks that could start now beside the running work: no path overlap with it or with each other, none without a
150
+ * named path. Proposed for one task beside running work or two tasks without, and when the baseline cost of the extra agents stays below the
151
+ * threshold; the reason says in plain words which way it went.
152
+ */
153
+ export const proposeTaskParallel = (input: TaskParallelInput): TaskParallelResult => {
154
+ const tasks = parallelGroup(input)
155
+ const needed = input.active.length > 0 ? MIN_TASKS_BESIDE_AGENT : MIN_TASKS_WITHOUT_AGENT
156
+ if (tasks.length < needed) return { proposed: false, reason: 'no task can run beside another' }
157
+ const { extraAgents, tokens } = estimateTokens(input, tasks.length)
158
+ if (tokens >= input.thresholdTokens)
159
+ return {
160
+ proposed: false,
161
+ reason: `~${kilo(tokens)} tokens for ${extraAgents} extra agents is not below the ${kilo(input.thresholdTokens)} limit`,
162
+ }
163
+ return {
164
+ proposed: true,
165
+ tasks,
166
+ waiting: input.open.length,
167
+ extraAgents,
168
+ estimateTokens: tokens,
169
+ reason: `${tasks.length} of ${input.open.length} waiting tasks name disjoint paths that the running work does not touch; ~${kilo(tokens)} tokens for ${extraAgents} extra ${extraAgents === 1 ? 'agent' : 'agents'}`,
170
+ }
171
+ }
172
+
173
+ const FNV_OFFSET = 0x811c9dc5
174
+ const FNV_PRIME = 0x01000193
175
+
176
+ const hash = (text: string): string => {
177
+ let value = FNV_OFFSET
178
+ for (const character of text) {
179
+ value = Math.imul(value ^ (character.codePointAt(0) ?? 0), FNV_PRIME) >>> 0
180
+ }
181
+ return value.toString(16).padStart(8, '0')
182
+ }
183
+
184
+ /**
185
+ * A stable fingerprint of what decides a proposal: ticks, active tasks and open tasks, order-independent. Files an
186
+ * agent touches are left out, they move on every keystroke and would repeat the report.
187
+ */
188
+ export const taskParallelFingerprint = (input: TaskParallelInput): string =>
189
+ hash(
190
+ JSON.stringify([
191
+ input.lane,
192
+ input.doneCount,
193
+ input.active.map((work) => `${work.phase ?? ''}|${work.taskText ?? ''}`).sort(),
194
+ input.open.map((task) => `${task.phase}|${task.text}`).sort(),
195
+ ]),
196
+ )
197
+
198
+ /**
199
+ * The proposal to report this heartbeat, if any: only when the fingerprint moved since `previous`, and only a real
200
+ * proposal. The returned fingerprint is what the caller keeps for the next heartbeat, proposal or not.
201
+ */
202
+ export const taskParallelReport = (
203
+ previous: string | undefined,
204
+ input: TaskParallelInput,
205
+ ): { fingerprint: string; proposal: TaskParallelProposal | null } => {
206
+ const fingerprint = taskParallelFingerprint(input)
207
+ if (fingerprint === previous) return { fingerprint, proposal: null }
208
+ const result = proposeTaskParallel(input)
209
+ return { fingerprint, proposal: result.proposed ? result : null }
210
+ }
211
+
212
+ const CHECKBOX = /^\s*(?:[-*]\s*)?\[[ xX]\]\s*/
213
+ /** The separators of the line itself, so a task's text cannot fake a mark such as `· auto until`. */
214
+ const SEPARATORS = /[·∥]/g
215
+
216
+ const proposalTaskText = (text: string): string =>
217
+ ellipsize(
218
+ terminalSafe(text.replace(/\s+/g, ' ')).replace(CHECKBOX, '').replaceAll('"', "'").replace(SEPARATORS, '-').trim(),
219
+ TEXT_LIMIT,
220
+ )
221
+
222
+ const autoSuffix = (verdict: AutoVerdict): string => {
223
+ if (verdict.kind === AUTO_VERDICT.auto) return ` · auto until ${verdict.until}`
224
+ return verdict.kind === AUTO_VERDICT.asks ? ` · asks: ${terminalSafe(verdict.reason)}` : ''
225
+ }
226
+
227
+ /**
228
+ * `parallel tasks <lane>: "<a>" ∥ "<b>" · ~<n>k tokens for 1 extra agent`, one line; under a set switch it ends in
229
+ * `· auto until <day>` or `· asks: <the limit that held>`.
230
+ */
231
+ export const formatTaskParallelLine = (lane: string, proposal: TaskParallelProposal, verdict: AutoVerdict): string => {
232
+ const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
233
+ const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
234
+ return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}${autoSuffix(verdict)}`
235
+ }
@@ -0,0 +1,69 @@
1
+ import { MS_PER_MINUTE } from '../plan-format/clock'
2
+ import { isRecord } from '../shared/is-record'
3
+
4
+ export const NO_TASK_SIGNAL_EVENT = 'no task signal'
5
+
6
+ /** The key a lane without a current phase is reported under, so it too is reported once. */
7
+ const NO_PHASE = ''
8
+
9
+ /** What the heartbeat read of a lane: whether its agent works, whether a task signal is open, its current phase. */
10
+ export type NoSignalReading = { working: boolean; signalled: boolean; phaseId: string | null }
11
+
12
+ /**
13
+ * Per lane: since when it works without a signal (null while it does not), and the phase it was last reported for
14
+ * (`''` for a lane without a phase, null before any report).
15
+ */
16
+ export type NoSignalState = Record<string, { since: number | null; reported: string | null }>
17
+
18
+ export type NoTaskSignalEvent = { kind: typeof NO_TASK_SIGNAL_EVENT; lane: string; phaseId: string | null }
19
+
20
+ type LaneEntry = NoSignalState[string]
21
+
22
+ const nextEntry = (
23
+ previous: LaneEntry | undefined,
24
+ { working, signalled }: NoSignalReading,
25
+ now: number,
26
+ ): LaneEntry => ({
27
+ since: working && !signalled ? (previous?.since ?? now) : null,
28
+ reported: previous?.reported ?? null,
29
+ })
30
+
31
+ const isDue = (entry: LaneEntry, phaseKey: string, now: number, limitMinutes: number): boolean =>
32
+ entry.since !== null && now - entry.since >= limitMinutes * MS_PER_MINUTE && entry.reported !== phaseKey
33
+
34
+ /**
35
+ * The lanes that worked `limitMinutes` or longer without a task signal (`jh task start` or an entry of their own task
36
+ * list), one event per lane and phase (D112): a signal or a lane that stops working starts the clock again, a phase
37
+ * already reported is not reported again, a new one is. A lane without a reading keeps what it had.
38
+ */
39
+ export const noTaskSignalEvents = (
40
+ previous: NoSignalState,
41
+ readings: Readonly<Record<string, NoSignalReading>>,
42
+ now: number,
43
+ limitMinutes: number,
44
+ ): { events: NoTaskSignalEvent[]; state: NoSignalState } => {
45
+ const events: NoTaskSignalEvent[] = []
46
+ const state: NoSignalState = { ...previous }
47
+ for (const [lane, reading] of Object.entries(readings)) {
48
+ const entry = nextEntry(previous[lane], reading, now)
49
+ const phaseKey = reading.phaseId ?? NO_PHASE
50
+ if (isDue(entry, phaseKey, now, limitMinutes)) {
51
+ events.push({ kind: NO_TASK_SIGNAL_EVENT, lane, phaseId: reading.phaseId })
52
+ entry.reported = phaseKey
53
+ }
54
+ state[lane] = entry
55
+ }
56
+ return { events, state }
57
+ }
58
+
59
+ /** The line the coordinator reads: `lane <name> no task signal` with the phase when one is known. */
60
+ export const noTaskSignalLine = ({ lane, phaseId }: NoTaskSignalEvent): string =>
61
+ `lane ${lane} ${NO_TASK_SIGNAL_EVENT}${phaseId === null ? '' : ` (P${phaseId})`}`
62
+
63
+ const isEntry = (value: unknown): boolean =>
64
+ isRecord(value) &&
65
+ (value.since === null || typeof value.since === 'number') &&
66
+ (value.reported === null || typeof value.reported === 'string')
67
+
68
+ export const isNoSignalState = (value: unknown): value is NoSignalState =>
69
+ isRecord(value) && Object.values(value).every(isEntry)
@@ -0,0 +1,29 @@
1
+ import { join } from 'node:path'
2
+ import { defaultProjectsDirectory } from '../context/session-log'
3
+ import { laneOfDirectory } from '../plans/active-tasks'
4
+ import { readAllSignals } from '../plans/signals-read'
5
+
6
+ export type SignalSources = {
7
+ /** The main checkout, which holds `.jackherd/tasks/`. */
8
+ root: string
9
+ /** The lanes' worktree directory, relative to `root`. */
10
+ worktreeDirectory: string
11
+ now: number
12
+ /** Where Claude Code keeps its session logs; the user's by default. */
13
+ projectsDirectory?: string
14
+ }
15
+
16
+ /**
17
+ * The lanes with an open task signal, read as the herd reads them (`readAllSignals`, the lane by the signal's
18
+ * directory): a task in progress in a recent session of the lane's worktree, a subagent's among them, or an open
19
+ * `jh task` record whose directory lies in that worktree.
20
+ */
21
+ export const signalledLanes = (lanes: readonly string[], sources: SignalSources): Set<string> => {
22
+ const worktrees = join(sources.root, sources.worktreeDirectory)
23
+ const signals = readAllSignals(
24
+ { projects: sources.projectsDirectory ?? defaultProjectsDirectory(), root: sources.root, worktrees },
25
+ sources.now,
26
+ )
27
+ const signalled = new Set(signals.flatMap(({ cwd }) => laneOfDirectory(cwd, worktrees) ?? []))
28
+ return new Set(lanes.filter((lane) => signalled.has(lane)))
29
+ }
@@ -26,8 +26,14 @@ export type WatchTexts = {
26
26
  laneModelDuty: string
27
27
  /** What phases of one lane that can run together ask of the coordinator, with the phases as `P19 ∥ P20`. */
28
28
  parallelDuty: (phases: string) => string
29
+ /** What a lane that works without a task signal asks of the coordinator. */
30
+ noTaskSignalDuty: string
29
31
  /** What a lane's call waiting for approval asks of the coordinator, with the mark (request id and call hash) the question must carry. */
30
32
  approvalDuty: (id: string, hash: string) => string
33
+ /** The standing duty while the main checkout is behind its base branch on the remote (`origin/main`), with the count of commits. */
34
+ mainCheckoutBehind: (remoteBase: string, count: number) => string
35
+ /** The standing duty while the main checkout has another branch than the base checked out (null for a detached head). */
36
+ mainCheckoutOffBase: (branch: string | null, baseBranch: string) => string
31
37
  /** The reminder after the coordinator line: `<term>: <count> rows only in the checkout`. */
32
38
  checkoutRows: (term: string, count: number) => string
33
39
  /** The head of the status line of the user's own open pull requests without a lane. */
@@ -63,8 +69,12 @@ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
63
69
  laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
64
70
  parallelDuty: (phases) =>
65
71
  `${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
72
+ noTaskSignalDuty: 'remind the lane to report its task with jh task start; do not stop it',
66
73
  laneModelDuty:
67
74
  'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
75
+ mainCheckoutBehind: (remoteBase, count) =>
76
+ `main checkout behind ${remoteBase} by ${count} (git merge --ff-only ${remoteBase})`,
77
+ mainCheckoutOffBase: (branch, baseBranch) => `main checkout on ${branch ?? 'a detached head'}, not ${baseBranch}`,
68
78
  checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
69
79
  otherPullRequests: 'other PRs',
70
80
  approvalDuty: (id, hash) =>
@@ -1,26 +1,29 @@
1
1
  import { latestVersionIfDue, type UpdateCheck } from '../sync/if-changed'
2
2
  import { isNewerVersion } from '../version/release'
3
+ import { UPDATE_AVAILABLE_EVENT } from './watch-events'
3
4
 
4
5
  /** The one line that wakes the coordinator when npm has a newer release; the orchestrate skill asks the user. */
5
6
  export const updateAvailableLine = (installed: string, latest: string): string =>
6
- `=== update available ${installed} → ${latest}`
7
+ `=== ${UPDATE_AVAILABLE_EVENT} ${installed} → ${latest}`
7
8
 
8
9
  /**
9
10
  * The watcher's daily look at npm, on the hook's timestamp and check, so a day holds one registry question across
10
11
  * both. Prints a line only for a newer plain release it has not announced yet; otherwise nothing, so a quiet
11
- * day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all.
12
+ * day costs no tokens. Call it on every poll: the timestamp decides whether npm is asked at all. `hint` is what the line
13
+ * carries after it, the path of the reference that holds the handling (`referenceHint`).
12
14
  */
13
15
  export const updateAnnouncer = (
14
16
  home: string,
15
17
  installed: string,
16
18
  check: UpdateCheck,
17
19
  log: (line: string) => void,
20
+ hint: () => string = () => '',
18
21
  ): (() => Promise<void>) => {
19
22
  let announced: string | null = null
20
23
  return async () => {
21
24
  const latest = await latestVersionIfDue(home, check)
22
25
  if (latest === null || latest === announced || !isNewerVersion(latest, installed)) return
23
26
  announced = latest
24
- log(updateAvailableLine(installed, latest))
27
+ log(`${updateAvailableLine(installed, latest)}${hint()}`)
25
28
  }
26
29
  }
@@ -0,0 +1,19 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+ import type { WaitingItem } from '../plan-format/waiting'
3
+ import { shownWithMore } from '../plans/shown-with-more'
4
+
5
+ const SHOWN_ITEMS = 3
6
+ const ITEM_LENGTH = 40
7
+
8
+ const itemText = ({ item }: WaitingItem): string => ellipsize(terminalSafe(item).replace(/\s+/g, ' '), ITEM_LENGTH)
9
+
10
+ /**
11
+ * The line that brings back what waits on the user's answer, in every heartbeat and status until the item is closed:
12
+ * `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open.
13
+ */
14
+ export const waitingLine = (items: readonly WaitingItem[]): string | null => {
15
+ const open = items.filter(({ open: isOpen }) => isOpen)
16
+ if (open.length === 0) return null
17
+ const { shown, more } = shownWithMore(open, SHOWN_ITEMS)
18
+ return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}`
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { CHECKS, type ChecksState } from '../plans/pull-requests'
2
+ import { isRecord } from '../shared/is-record'
2
3
  import {
3
4
  APPOINTMENT_EVENT,
4
5
  type AppointmentEvent,
@@ -10,7 +11,6 @@ import { APPROVAL_EVENT, type ApprovalEvent } from './approvals'
10
11
  import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
11
12
  import { emptyFailureState, type FailureState, failedTestsOf, isFailureState } from './failures'
12
13
  import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
13
- import { isRecord } from './json-guards'
14
14
  import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
15
15
  import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels } from './lane-model'
16
16
  import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
@@ -18,8 +18,23 @@ import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
18
18
  import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
19
19
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
20
20
  import { emptySourceState, isSourceState, SOURCE_EVENT, type SourceEvent, type SourceState } from './sources'
21
+ import {
22
+ isTaskParallelFingerprints,
23
+ TASK_PARALLEL_EVENT,
24
+ type TaskParallelEvent,
25
+ type TaskParallelFingerprints,
26
+ } from './task-parallel'
27
+ import {
28
+ isNoSignalState,
29
+ NO_TASK_SIGNAL_EVENT,
30
+ type NoSignalState,
31
+ type NoTaskSignalEvent,
32
+ } from './task-signal-missing'
21
33
  import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
22
34
 
35
+ /** The event `updateAvailableLine` prints; it is no `WatchEvent`, the announcer logs it on its own. */
36
+ export const UPDATE_AVAILABLE_EVENT = 'update available'
37
+
23
38
  /** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
24
39
  export const WATCH_EVENT = {
25
40
  merged: 'merged',
@@ -34,6 +49,8 @@ export const WATCH_EVENT = {
34
49
  laneFill: LANE_FILL_EVENT,
35
50
  laneModel: LANE_MODEL_EVENT,
36
51
  laneParallel: LANE_PARALLEL_EVENT,
52
+ taskParallel: TASK_PARALLEL_EVENT,
53
+ noTaskSignal: NO_TASK_SIGNAL_EVENT,
37
54
  approval: APPROVAL_EVENT,
38
55
  source: SOURCE_EVENT,
39
56
  due: APPOINTMENT_EVENT.due,
@@ -86,12 +103,20 @@ export type WatchState = {
86
103
  laneModels: LaneModels
87
104
  /** The phases last reported as able to run together for each watched lane; a lane without two has none. */
88
105
  laneParallels: LaneParallels
106
+ /** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
107
+ taskParallels: TaskParallelFingerprints
108
+ /** The last day of `watch.parallelTasks.auto` whose end was reported, so it is said once; null before any. */
109
+ parallelAutoEnded: string | null
110
+ /** Per watched lane: since when it works without a task signal and the phase last reported as `no task signal`. */
111
+ noTaskSignals: NoSignalState
89
112
  /** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
90
113
  approvals: string[]
91
114
  /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
92
115
  sources: SourceState
93
116
  /** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
94
117
  reportMtimes: Record<string, number | null>
118
+ /** Lane → the questions of its report last shown or seen (`questionsFingerprint`); a lane without an entry is unseen. */
119
+ reportQuestions: Record<string, string>
95
120
  /** The lane lines of the status block last printed; null before the first report. */
96
121
  statusLines: string[] | null
97
122
  /** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
@@ -139,6 +164,8 @@ export type WatchEvent =
139
164
  | LaneFillEvent
140
165
  | LaneModelEvent
141
166
  | LaneParallelEvent
167
+ | TaskParallelEvent
168
+ | NoTaskSignalEvent
142
169
  | ApprovalEvent
143
170
  | SourceEvent
144
171
  | AppointmentEvent
@@ -173,9 +200,13 @@ export const emptyWatchState = (): WatchState => ({
173
200
  laneLevels: {},
174
201
  laneModels: {},
175
202
  laneParallels: {},
203
+ taskParallels: {},
204
+ parallelAutoEnded: null,
205
+ noTaskSignals: {},
176
206
  approvals: [],
177
207
  sources: emptySourceState(),
178
208
  reportMtimes: {},
209
+ reportQuestions: {},
179
210
  statusLines: null,
180
211
  appointments: emptyAppointmentState(),
181
212
  })
@@ -218,10 +249,14 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
218
249
  isLaneLevels(value.laneLevels) &&
219
250
  isLaneModels(value.laneModels) &&
220
251
  isLaneParallels(value.laneParallels) &&
252
+ isTaskParallelFingerprints(value.taskParallels) &&
253
+ (value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
254
+ isNoSignalState(value.noTaskSignals) &&
221
255
  Array.isArray(value.approvals) &&
222
256
  value.approvals.every((id) => typeof id === 'string') &&
223
257
  isSourceState(value.sources) &&
224
258
  everyValue(value.reportMtimes, isSeq) &&
259
+ everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
225
260
  (value.statusLines === null ||
226
261
  (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
227
262
  isAppointmentState(value.appointments)
@@ -1,7 +1,15 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { basename, join } from 'node:path'
2
+ import { rmSync } from 'node:fs'
3
+ import { basename, dirname, join } from 'node:path'
4
+ import { writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
5
+ import {
6
+ ensurePrivateLevels,
7
+ type LocationSystem,
8
+ readPrivateText,
9
+ runtimeBaseOf,
10
+ systemLocation,
11
+ userDirectoryOf,
12
+ } from '../shared/private-location'
5
13
  import { CLI_NAME, PACKAGE_NAME } from '../version/release'
6
14
 
7
15
  /** Looks up the command line of a process; null when there is no such process. */
@@ -17,8 +25,6 @@ const RUNTIME = 'bun'
17
25
  const DIRECTORY_PREFIX = 'jackherd-watch'
18
26
  const KEY_HASH_LENGTH = 12
19
27
  const NOT_A_SEGMENT = /[^A-Za-z0-9._-]+/g
20
- const PRIVATE_DIRECTORY = 0o700
21
- const PRIVATE_FILE = 0o600
22
28
  const STOP_WAIT_MS = 5_000
23
29
  const STOP_POLL_MS = 50
24
30
 
@@ -73,14 +79,6 @@ export const repositoryKey = (root: string): string => {
73
79
  return `${name}-${hash}`
74
80
  }
75
81
 
76
- const readText = (file: string): string | null => {
77
- try {
78
- return readFileSync(file, 'utf8')
79
- } catch {
80
- return null
81
- }
82
- }
83
-
84
82
  const commandLineOf: CommandLineOf = (pid) => {
85
83
  const result = Bun.spawnSync(['ps', '-o', 'command=', '-p', String(pid)], { stdout: 'pipe', stderr: 'ignore' })
86
84
  return result.exitCode === 0 ? result.stdout.toString().trim() || null : null
@@ -96,36 +94,34 @@ const isAlive = (pid: number): boolean => {
96
94
  }
97
95
 
98
96
  /** Where `ensureWatcherDirectory` puts the watcher's files, without creating anything: for readers such as the herd. */
99
- export const watcherDirectoryOf = (root: string): string =>
100
- join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
97
+ export const watcherDirectoryOf = (root: string, system: LocationSystem = systemLocation()): string =>
98
+ join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
101
99
 
102
100
  /**
103
101
  * The directory for the lock and state of the watcher of the repository at `root`, one per user and repository,
104
- * readable by that user only: with `TMPDIR` unset, `tmpdir()` is the shared `/tmp`, where another account could
105
- * plant the files. A watcher in another repository has its own lock and does not supersede this one.
102
+ * readable by that user only: without a private runtime directory the base is the temporary directory, which may be
103
+ * the shared `/tmp`, where another account could plant the files, so both levels are verified (`ensurePrivateLevels`)
104
+ * and this throws for a level that is a link or someone else's. A watcher in another repository has its own lock and
105
+ * does not supersede this one.
106
106
  */
107
- export const ensureWatcherDirectory = (root: string): string => {
108
- const directory = watcherDirectoryOf(root)
109
- mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
110
- return directory
107
+ export const ensureWatcherDirectory = (root: string, system: LocationSystem = systemLocation()): string => {
108
+ const directory = watcherDirectoryOf(root, system)
109
+ return ensurePrivateLevels(dirname(directory), directory)
111
110
  }
112
111
 
113
112
  /** Whether the lock names a live process that is a watcher, so its saved state is being kept up to date. */
114
113
  export const isWatcherRunning = (lockFile: string, lookUp: CommandLineOf = commandLineOf): boolean => {
115
- const pid = lockedPid(readText(lockFile))
114
+ const pid = lockedPid(readPrivateText(lockFile))
116
115
  return pid !== null && isAlive(pid) && isWatcherCommand(lookUp(pid) ?? '')
117
116
  }
118
117
 
119
- /** Writes a file only the user can read. */
120
- export const writePrivate = (file: string, content: string) => writeFileSync(file, content, { mode: PRIVATE_FILE })
121
-
122
118
  /**
123
119
  * Takes the lock for this process. A watcher that held it gets SIGTERM and is waited for, so the state
124
120
  * file it writes on the way out is complete before this one reads it.
125
121
  */
126
122
  export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
127
- const previous = supersedeTarget(readText(lockFile), process.pid, commandLineOf)
128
- writePrivate(lockFile, String(process.pid))
123
+ const previous = supersedeTarget(readPrivateText(lockFile), process.pid, commandLineOf)
124
+ writePrivateFile(lockFile, String(process.pid))
129
125
  if (previous === null || !isAlive(previous)) return { replaced: null, stillRunning: null }
130
126
  try {
131
127
  process.kill(previous, 'SIGTERM')
@@ -137,9 +133,9 @@ export const takeOverLock = async (lockFile: string): Promise<TakeOver> => {
137
133
  return isAlive(previous) ? { replaced: null, stillRunning: previous } : { replaced: previous, stillRunning: null }
138
134
  }
139
135
 
140
- export const stopNoticeOf = (lockFile: string): string => stopNotice(readText(lockFile), process.pid)
136
+ export const stopNoticeOf = (lockFile: string): string => stopNotice(readPrivateText(lockFile), process.pid)
141
137
 
142
138
  /** Removes the lock only while it still names this process; a successor's lock stays. */
143
139
  export const releaseLock = (lockFile: string) => {
144
- if (lockedPid(readText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
140
+ if (lockedPid(readPrivateText(lockFile)) === process.pid) rmSync(lockFile, { force: true })
145
141
  }
@@ -1,7 +1,8 @@
1
1
  import type { JackherdConfig } from '../config'
2
2
  import { GITHUB_HOST } from '../plans/github'
3
3
  import { runIdOfLink } from '../plans/pull-requests'
4
- import { isRecord, parseArray } from './json-guards'
4
+ import { isRecord } from '../shared/is-record'
5
+ import { parseArray } from './json-guards'
5
6
 
6
7
  /** Which workflow's runs are deploys, and which environments count as their target (`watch.deploy`). */
7
8
  export type DeployConfig = NonNullable<JackherdConfig['watch']['deploy']>
@@ -1,23 +0,0 @@
1
- // PostToolUse hook for AskUserQuestion, shipped by jackherd. When the call asked about a plan (``Plan `<path>` ``) and
2
- // offered the label that approves it or the one that revokes it, it leaves a pointer to that dialog in the main
3
- // checkout's `.jackherd/approvals/plans/`, so the merge guard finds the answer again after the session ended. It writes
4
- // only the pointer; the merge guard reads the answer from the transcript itself. argv[2] and argv[3] are the two labels,
5
- // rendered into the hook entry by `sync`. It never blocks and prints nothing: a pointer it fails to write costs a question.
6
- // The other way round it costs a revocation: when no pointer is written for a "revoke" answer, the merge guard never
7
- // sees that answer, and an older approval keeps holding until the next plan question that is recorded.
8
- import { mainCheckoutOf } from '../shared/checkout'
9
- import { planPointersDirectory, pointerOf, writePointer } from './plan-pointers'
10
-
11
- const record = async (): Promise<void> => {
12
- const pointer = pointerOf(JSON.parse(await Bun.stdin.text()), process.argv.slice(2, 4), Date.now())
13
- if (pointer === undefined) return
14
- const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
15
- writePointer(planPointersDirectory(mainCheckoutOf(projectDirectory)), pointer)
16
- }
17
-
18
- try {
19
- await record()
20
- } catch {
21
- // Nothing to do: without a pointer the merge asks.
22
- }
23
- process.exit(0)