jackherd 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -2,8 +2,11 @@ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
2
 
3
3
  export const TASK_PARALLEL_EVENT = 'parallel tasks'
4
4
 
5
- /** A proposal for the non-stream watcher: the line `formatTaskParallelLine` made for `lane`. */
6
- export type TaskParallelEvent = { kind: typeof TASK_PARALLEL_EVENT; lane: string; line: string }
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 }
7
10
 
8
11
  /** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
9
12
  export type TaskParallelFingerprints = Record<string, string>
@@ -25,6 +28,8 @@ export type TaskParallelInput = {
25
28
  active: ActiveWork[]
26
29
  /** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
27
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[]>
28
33
  doneCount: number
29
34
  /** Tokens a fresh agent costs before it does anything, measured by the caller. */
30
35
  baselineTokens: number
@@ -45,11 +50,22 @@ export type TaskParallelResult =
45
50
 
46
51
  export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
47
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
+
48
64
  const MIN_TASKS_BESIDE_AGENT = 1
49
65
  const MIN_TASKS_WITHOUT_AGENT = 2
50
66
  const TEXT_LIMIT = 60
51
67
  const TOKENS_PER_K = 1000
52
- const GLOB_CHARACTER = /[*?[{]/
68
+ export const GLOB_CHARACTER = /[*?[{]/
53
69
  const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
54
70
  const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
55
71
  const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
@@ -194,13 +210,26 @@ export const taskParallelReport = (
194
210
  }
195
211
 
196
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
197
215
 
198
216
  const proposalTaskText = (text: string): string =>
199
- ellipsize(terminalSafe(text.replace(/\s+/g, ' ')).replace(CHECKBOX, '').replaceAll('"', "'").trim(), TEXT_LIMIT)
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
+ }
200
226
 
201
- /** `parallel tasks <lane>: "<a>" ∥ "<b>" · ~<n>k tokens for 1 extra agent`, one line. */
202
- export const formatTaskParallelLine = (lane: string, proposal: TaskParallelProposal): string => {
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 => {
203
232
  const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
204
233
  const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
205
- return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}`
234
+ return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}${autoSuffix(verdict)}`
206
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
  }
@@ -24,8 +24,17 @@ import {
24
24
  type TaskParallelEvent,
25
25
  type TaskParallelFingerprints,
26
26
  } from './task-parallel'
27
+ import {
28
+ isNoSignalState,
29
+ NO_TASK_SIGNAL_EVENT,
30
+ type NoSignalState,
31
+ type NoTaskSignalEvent,
32
+ } from './task-signal-missing'
27
33
  import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
28
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
+
29
38
  /** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
30
39
  export const WATCH_EVENT = {
31
40
  merged: 'merged',
@@ -41,6 +50,7 @@ export const WATCH_EVENT = {
41
50
  laneModel: LANE_MODEL_EVENT,
42
51
  laneParallel: LANE_PARALLEL_EVENT,
43
52
  taskParallel: TASK_PARALLEL_EVENT,
53
+ noTaskSignal: NO_TASK_SIGNAL_EVENT,
44
54
  approval: APPROVAL_EVENT,
45
55
  source: SOURCE_EVENT,
46
56
  due: APPOINTMENT_EVENT.due,
@@ -95,12 +105,18 @@ export type WatchState = {
95
105
  laneParallels: LaneParallels
96
106
  /** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
97
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
98
112
  /** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
99
113
  approvals: string[]
100
114
  /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
101
115
  sources: SourceState
102
116
  /** Lane → change time of the report file last shown for it, null when it had none; a lane without an entry is unseen. */
103
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>
104
120
  /** The lane lines of the status block last printed; null before the first report. */
105
121
  statusLines: string[] | null
106
122
  /** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
@@ -149,6 +165,7 @@ export type WatchEvent =
149
165
  | LaneModelEvent
150
166
  | LaneParallelEvent
151
167
  | TaskParallelEvent
168
+ | NoTaskSignalEvent
152
169
  | ApprovalEvent
153
170
  | SourceEvent
154
171
  | AppointmentEvent
@@ -184,9 +201,12 @@ export const emptyWatchState = (): WatchState => ({
184
201
  laneModels: {},
185
202
  laneParallels: {},
186
203
  taskParallels: {},
204
+ parallelAutoEnded: null,
205
+ noTaskSignals: {},
187
206
  approvals: [],
188
207
  sources: emptySourceState(),
189
208
  reportMtimes: {},
209
+ reportQuestions: {},
190
210
  statusLines: null,
191
211
  appointments: emptyAppointmentState(),
192
212
  })
@@ -230,10 +250,13 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
230
250
  isLaneModels(value.laneModels) &&
231
251
  isLaneParallels(value.laneParallels) &&
232
252
  isTaskParallelFingerprints(value.taskParallels) &&
253
+ (value.parallelAutoEnded === null || typeof value.parallelAutoEnded === 'string') &&
254
+ isNoSignalState(value.noTaskSignals) &&
233
255
  Array.isArray(value.approvals) &&
234
256
  value.approvals.every((id) => typeof id === 'string') &&
235
257
  isSourceState(value.sources) &&
236
258
  everyValue(value.reportMtimes, isSeq) &&
259
+ everyValue(value.reportQuestions, (entry) => typeof entry === 'string') &&
237
260
  (value.statusLines === null ||
238
261
  (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
239
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,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)
@@ -1,196 +0,0 @@
1
- /**
2
- * Where plan approval reads its facts: git in the main checkout for the base branch, the worktrees and the boards, the
3
- * pointer directory and the transcripts for the answers. Every git call runs without a shell and with a timeout; any
4
- * failure answers nothing, so the guard asks.
5
- */
6
- import { existsSync } from 'node:fs'
7
- import { join, posix, relative, resolve } from 'node:path'
8
- import { lanePlansOf } from '../shared/board-lanes'
9
- import { laneOf } from '../shared/lane'
10
- import type { PlanApprovalSources } from './plan-approval'
11
- import { FULL_COMMIT } from '../shared/commit'
12
- import { isPlainPath } from './plain-path'
13
- import { planAnswers, planPointersDirectory } from './plan-pointers'
14
- import { checksPassed } from './pull-request'
15
- import { baseBranchOf } from '../shared/base-branch'
16
- import { type PlanApprovalSettings, PROJECT_CONFIG, planApprovalSettingsOf } from './plan-approval-settings'
17
- import { readConfigOrNull } from './settings'
18
-
19
- /** The base commit plan approval reads, just fetched, and the settings its project config holds. */
20
- export type PlanBase = { commit: string; settings: PlanApprovalSettings }
21
-
22
- export type PlanSourceContext = {
23
- /** The main checkout, where the worktrees, the base branch and the pointers are. */
24
- main: string
25
- base: PlanBase
26
- /** The transcript of the session that merges. */
27
- transcript: string | undefined
28
- /** The label that approves a plan. */
29
- label: string
30
- files: () => readonly string[] | undefined
31
- /** The `gh pr view` answer for the pull request, which carries its checks. */
32
- view: string
33
- }
34
-
35
- const GIT_TIMEOUT_MS = 3000
36
- const BASE_REMOTE = 'origin'
37
- const HEADER = /^[0-9a-f]+ blob (\d+)$/
38
-
39
- /**
40
- * The output of `git cat-file --batch`, one entry per requested path in order: the text of a blob, null for a path the
41
- * commit lacks or that is no blob. Undefined when the output does not hold one entry per path.
42
- */
43
- export const parseCatFileBatch = (output: Buffer, count: number): (string | null)[] | undefined => {
44
- const texts: (string | null)[] = []
45
- let at = 0
46
- while (texts.length < count) {
47
- const newline = output.indexOf('\n', at)
48
- if (newline === -1) return undefined
49
- const size = HEADER.exec(output.subarray(at, newline).toString('utf8'))?.[1]
50
- if (size === undefined) {
51
- texts.push(null)
52
- at = newline + 1
53
- continue
54
- }
55
- const end = newline + 1 + Number(size)
56
- if (end > output.length) return undefined
57
- texts.push(output.subarray(newline + 1, end).toString('utf8'))
58
- at = end + 1
59
- }
60
- return texts
61
- }
62
-
63
- /** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
64
- export const laneInWorktreeList = (
65
- worktreeList: string,
66
- main: string,
67
- worktreeDirectory: string,
68
- branch: string,
69
- ): string | undefined => {
70
- const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
71
- const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
72
- const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
73
- if (path === undefined) return undefined
74
- const lane = laneOf({ cwd: path, main, worktreeDirectory })
75
- return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
76
- }
77
-
78
- /** Whether `name` is the directory of a lane's worktree below the worktree directory. */
79
- export const isLaneIn =
80
- (main: string, worktreeDirectory: string) =>
81
- (name: string): boolean => {
82
- const worktree = join(resolve(main, worktreeDirectory), name)
83
- return laneOf({ cwd: worktree, main, worktreeDirectory }) === name && existsSync(worktree)
84
- }
85
-
86
- /** Only the base branch into its remote ref: no tags, no FETCH_HEAD written, no maintenance started. */
87
- export const baseFetchArguments = (baseBranch: string): string[] => [
88
- 'fetch',
89
- '--quiet',
90
- '--no-tags',
91
- '--no-write-fetch-head',
92
- '--no-auto-maintenance',
93
- BASE_REMOTE,
94
- `+refs/heads/${baseBranch}:refs/remotes/${BASE_REMOTE}/${baseBranch}`,
95
- ]
96
-
97
- /** No prompt for a password or a host key: a fetch that would wait for one fails at once instead. */
98
- export const NON_INTERACTIVE = { GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
99
-
100
- const git = (main: string, args: readonly string[], stdin?: string): Buffer | undefined => {
101
- const result = Bun.spawnSync(['git', '-C', main, ...args], {
102
- stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
103
- stdout: 'pipe',
104
- stderr: 'ignore',
105
- timeout: GIT_TIMEOUT_MS,
106
- env: { ...process.env, ...NON_INTERACTIVE },
107
- })
108
- return result.exitCode === 0 ? result.stdout : undefined
109
- }
110
-
111
- const baseCommitOf = (main: string, baseBranch: string): string | undefined => {
112
- const commit = git(main, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${baseBranch}^{commit}`])
113
- ?.toString()
114
- .trim()
115
- return commit !== undefined && FULL_COMMIT.test(commit) ? commit : undefined
116
- }
117
-
118
- /** The texts of the paths at the commit, in one `git cat-file --batch`; null for each one it lacks or cannot read. */
119
- const readAtCommit = (main: string, commit: string, paths: readonly string[]): Map<string, string | null> => {
120
- const asked = paths.filter(isPlainPath)
121
- const output =
122
- asked.length === 0
123
- ? undefined
124
- : git(main, ['cat-file', '--batch'], asked.map((path) => `${commit}:${path}\n`).join(''))
125
- const texts = output === undefined ? undefined : parseCatFileBatch(output, asked.length)
126
- return new Map(paths.map((path) => [path, texts?.[asked.indexOf(path)] ?? null]))
127
- }
128
-
129
- /** The board directories under the plan directory at the commit whose name matches the board pattern. */
130
- const boardDirectories = (main: string, commit: string, settings: PlanApprovalSettings): string[] => {
131
- const listing = git(main, ['ls-tree', '-d', '--name-only', commit, '--', `${settings.planDirectory}/`])
132
- const pattern = new Bun.Glob(settings.board.pattern)
133
- return (listing?.toString() ?? '')
134
- .split('\n')
135
- .filter((path) => path !== '' && pattern.match(posix.basename(path)))
136
- }
137
-
138
- /** Brings `origin/<baseBranch>` up to date, so a stale ref cannot hold an approval GitHub's base no longer does. */
139
- const fetchBase = (main: string, baseBranch: string): boolean => git(main, baseFetchArguments(baseBranch)) !== undefined
140
-
141
- /** The newest first-parent commit of the base from before the time; undefined when there is none. */
142
- const commitBefore = (main: string, commit: string, time: number): string | undefined => {
143
- const found = git(main, ['rev-list', '-1', '--first-parent', `--before=@${Math.floor(time / 1000)}`, commit])
144
- ?.toString()
145
- .trim()
146
- return found !== undefined && FULL_COMMIT.test(found) ? found : undefined
147
- }
148
-
149
- /**
150
- * The plan as the base held it at the time of the answer; undefined when the base did not hold it then, or not
151
- * readably. A plan that was only in a pull request when the user approved it could have grown before it landed, and
152
- * the state the user saw is nowhere on the base, so the guard asks once more after it landed.
153
- */
154
- const planAtApproval = (main: string, commit: string, plan: string, answeredAt: number): string | undefined => {
155
- const before = commitBefore(main, commit, answeredAt)
156
- return (before === undefined ? undefined : readAtCommit(main, before, [plan]).get(plan)) ?? undefined
157
- }
158
-
159
- /**
160
- * The base plan approval reads: `origin/<baseBranch>` right after a fetch, and the settings of the project config at
161
- * that commit, never of the checkout. The base branch comes from the checkout's config (the base cannot name itself)
162
- * and must agree with the one the base's config names. Undefined, so the guard asks, when any step fails.
163
- */
164
- export const planBaseOf = (main: string): PlanBase | undefined => {
165
- const baseBranch = baseBranchOf(readConfigOrNull([main]))
166
- if (baseBranch === undefined || !fetchBase(main, baseBranch)) return undefined
167
- const commit = baseCommitOf(main, baseBranch)
168
- if (commit === undefined) return undefined
169
- const settings = planApprovalSettingsOf(readAtCommit(main, commit, [PROJECT_CONFIG]).get(PROJECT_CONFIG) ?? null)
170
- return settings !== null && settings.baseBranch === baseBranch ? { commit, settings } : undefined
171
- }
172
-
173
- export const planApprovalSources = ({ main, base, transcript, label, files, view }: PlanSourceContext): PlanApprovalSources => {
174
- const { commit, settings } = base
175
- return {
176
- checksPassed: (requiredChecks) => checksPassed(view, requiredChecks),
177
- containsBase: (headCommit) =>
178
- FULL_COMMIT.test(headCommit) && git(main, ['merge-base', '--is-ancestor', commit, headCommit]) !== undefined,
179
- files,
180
- readAtBase: (paths) => readAtCommit(main, commit, paths),
181
- laneOfBranch: (branch) => {
182
- const list = git(main, ['worktree', 'list', '--porcelain'])?.toString()
183
- return list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
184
- },
185
- lanePlans: (lane) => {
186
- const boards = boardDirectories(main, commit, settings)
187
- const texts = readAtCommit(main, commit, boards.map((directory) => `${directory}/${settings.board.file}`))
188
- return boards.flatMap((directory) => {
189
- const text = texts.get(`${directory}/${settings.board.file}`)
190
- return (text ? lanePlansOf(text, directory, lane, settings.planLanguage) : undefined) ?? []
191
- })
192
- },
193
- answers: (plan) => planAnswers({ directory: planPointersDirectory(main), currentTranscript: transcript, plan, label }),
194
- readAtCommitBefore: (plan, answeredAt) => (isPlainPath(plan) ? planAtApproval(main, commit, plan, answeredAt) : undefined),
195
- }
196
- }