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
@@ -0,0 +1,34 @@
1
+ import { join } from 'node:path'
2
+ import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
+
4
+ /** A record that waits on the user until something sets `closedAt`. */
5
+ export type ClosableRecord = { id: string; closedAt: number | null }
6
+
7
+ export type ClosableRecordLog<T extends ClosableRecord> = {
8
+ /** Every record of the log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
9
+ read: (root: string) => T[]
10
+ /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
11
+ close: (root: string, ids: readonly string[], now: number) => number
12
+ /** Marks every open record `closedAt` and returns how many it closed. */
13
+ closeAll: (root: string, now: number) => number
14
+ }
15
+
16
+ /** The log of `.jackherd/<directoryName>/`: records the `guard` accepts, ordered by `timeOf`. */
17
+ export const closableRecordLog = <T extends ClosableRecord>(
18
+ directoryName: string,
19
+ guard: (value: unknown) => value is T,
20
+ timeOf: (record: T) => number,
21
+ ): ClosableRecordLog<T> => {
22
+ const directoryOf = (root: string): string => join(root, '.jackherd', directoryName)
23
+ const read = (root: string): T[] => readRecords(directoryOf(root), guard, timeOf)
24
+ const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number): number => {
25
+ const open = read(root).filter((record) => record.closedAt === null && isChosen(record))
26
+ for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
27
+ return open.length
28
+ }
29
+ return {
30
+ read,
31
+ close: (root, ids, now) => closeWhere(root, (record) => ids.includes(record.id), now),
32
+ closeAll: (root, now) => closeWhere(root, () => true, now),
33
+ }
34
+ }
@@ -6,6 +6,7 @@ import { boardFor, foundLanesOf } from './board-pick'
6
6
  import { ensurePlanPanes } from './ensure-panes'
7
7
  import { mainCheckout, worktreeRoot } from './git'
8
8
  import { fetchPullRequests } from './github'
9
+ import { claimOwnTerminal, startExclusive } from './herd-claim'
9
10
  import { recallLanes, rememberLanes } from './herd-memory'
10
11
  import { runLive } from './live'
11
12
  import { parseOptions } from './options'
@@ -59,8 +60,8 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
59
60
  }
60
61
  const source = { root, sprintFile: picked.file, options, settings, terminal }
61
62
  if (options.once || !process.stdout.isTTY) await printOnceWithNotice(source, picked.notice)
62
- else if (isSupervisedChild(process.env)) runLive(source)
63
- else return await superviseView(args, await startedKit())
63
+ else if (isSupervisedChild(process.env)) await startExclusive(claimOwnTerminal, () => runLive(source))
64
+ else process.exit(await superviseView(args, await startedKit()))
64
65
  return 0
65
66
  } catch (error) {
66
67
  console.error(error instanceof Error ? error.message : String(error))
@@ -1,5 +1,7 @@
1
1
  import { errorText } from './error-text'
2
2
  import type { PullRequestState } from './github'
3
+ import { pullHeadRefspec } from './plan-heads'
4
+ import type { PullRequest } from './pull-requests'
3
5
 
4
6
  /** The background fetch of the base branch: when it last ran, when it last worked and why it failed. */
5
7
  export type FetchState = {
@@ -70,12 +72,13 @@ export const fetchNotice = (state: FetchState, baseRef: string, locale: string):
70
72
  return `fetch failed (${state.failure}), reading ${baseRef} as it is: ${known}`
71
73
  }
72
74
 
73
- /** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
74
- export const fetchBase = async (root: string, remote: string, branch: string): Promise<string | null> => {
75
+ /** Fetches `refspecs` from `remote` in one call; returns the reason when it did not work, null when it did or there was nothing to fetch. Never rejects. */
76
+ export const fetchRefs = async (root: string, remote: string, refspecs: readonly string[]): Promise<string | null> => {
77
+ if (refspecs.length === 0) return null
75
78
  let timedOut = false
76
79
  let timer: Timer | undefined
77
80
  try {
78
- const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, branch], {
81
+ const child = Bun.spawn(['git', '-C', root, 'fetch', '--quiet', remote, ...refspecs], {
79
82
  stdout: 'ignore',
80
83
  stderr: 'pipe',
81
84
  env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
@@ -94,3 +97,23 @@ export const fetchBase = async (root: string, remote: string, branch: string): P
94
97
  clearTimeout(timer)
95
98
  }
96
99
  }
100
+
101
+ /** Fetches the base branch; returns the reason when it did not work, null when it did. Never rejects. */
102
+ export const fetchBase = (root: string, remote: string, branch: string): Promise<string | null> =>
103
+ fetchRefs(root, remote, [branch])
104
+
105
+ /**
106
+ * Fetches the heads of the given pull requests into the herd's own refs (`pullHeadRef`), in one call after the base.
107
+ * A failure is not reported: a head that was not fetched reads nothing, and the plan stays as the base has it.
108
+ */
109
+ export const fetchPlanHeads = async (
110
+ root: string,
111
+ remote: string,
112
+ pullRequests: readonly Pick<PullRequest, 'number'>[],
113
+ ): Promise<void> => {
114
+ await fetchRefs(
115
+ root,
116
+ remote,
117
+ pullRequests.map(({ number }) => pullHeadRefspec(number)),
118
+ )
119
+ }
package/src/plans/git.ts CHANGED
@@ -35,18 +35,32 @@ export const commitOf = (root: string, ref: string): string | null =>
35
35
  /** Reads files as they are at the base ref. */
36
36
  export type BaseReader = (path: string) => string | null
37
37
 
38
- let cached: { root: string; commit: string; texts: Map<string, string> } | null = null
38
+ /** How many commits keep their texts: the base ref and the heads of the open pull requests that change a plan. */
39
+ const CACHED_COMMITS = 16
40
+
41
+ /** The texts read per `root\0commit`, the one asked last at the end. */
42
+ const cachedTexts = new Map<string, Map<string, string>>()
43
+
44
+ const textsOf = (root: string, commit: string): Map<string, string> => {
45
+ const key = `${root}\0${commit}`
46
+ const texts = cachedTexts.get(key) ?? new Map<string, string>()
47
+ cachedTexts.delete(key)
48
+ cachedTexts.set(key, texts)
49
+ const oldest = cachedTexts.keys().next().value
50
+ if (cachedTexts.size > CACHED_COMMITS && oldest !== undefined) cachedTexts.delete(oldest)
51
+ return texts
52
+ }
39
53
 
40
54
  /**
41
55
  * A reader for the files at `ref`, with the commit resolved once. A file at a commit never changes, so what was
42
- * read is kept for as long as the ref points at the same commit: a re-read of the sprint costs one `git
43
- * rev-parse` instead of one `git show` per plan.
56
+ * read is kept for as long as the commit is among the last `CACHED_COMMITS` asked: a re-read of the sprint costs
57
+ * one `git rev-parse` per ref instead of one `git show` per plan, and the base and the pull request heads read in
58
+ * the same round do not evict each other.
44
59
  */
45
60
  export const baseReaderOf = (root: string, ref: string): BaseReader => {
46
61
  const commit = commitOf(root, ref)
47
62
  if (commit === null) return () => null
48
- if (cached?.root !== root || cached.commit !== commit) cached = { root, commit, texts: new Map() }
49
- const { texts } = cached
63
+ const texts = textsOf(root, commit)
50
64
  return (path) => {
51
65
  const kept = texts.get(path)
52
66
  if (kept !== undefined) return kept
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, readlinkSync, unlinkSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
5
+ import {
6
+ type LocationSystem,
7
+ readPrivateText,
8
+ runtimeBaseOf,
9
+ systemLocation,
10
+ userDirectoryOf,
11
+ } from '../shared/private-location'
12
+ import { HERD_COMMAND } from './herd-command'
13
+ import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
14
+
15
+ const DIRECTORY_PREFIX = 'jackherd-herd'
16
+ const POLL_MS = 50
17
+ const POLLS_BEFORE_KILL = 20
18
+
19
+ export type ClaimDeps = {
20
+ selfPid: number
21
+ prepare: () => void
22
+ readRecord: () => string | null
23
+ writeRecord: (pid: number) => void
24
+ removeRecordIf: (pid: number) => void
25
+ isHerdView: (pid: number) => boolean
26
+ isGone: (pid: number) => boolean
27
+ signal: (pid: number, signal: NodeJS.Signals) => void
28
+ sleep: (ms: number) => Promise<void>
29
+ onExit: (run: () => void) => void
30
+ }
31
+
32
+ /** A process id as the record file holds it; nothing for anything but a positive integer. */
33
+ export const parsePid = (text: string | null): number | null => {
34
+ if (text === null || !/^\d+\s*$/.test(text)) return null
35
+ const pid = Number(text)
36
+ return pid > 0 ? pid : null
37
+ }
38
+
39
+ /**
40
+ * The older herd view of this terminal that has to end before `selfPid` draws: the recorded pid when it is not
41
+ * this process and still is a herd view (a number the system has reused for something else is not).
42
+ */
43
+ export const olderHerdToStop = (
44
+ recorded: number | null,
45
+ selfPid: number,
46
+ isHerdView: (pid: number) => boolean,
47
+ ): number | null => (recorded === null || recorded === selfPid || !isHerdView(recorded) ? null : recorded)
48
+
49
+ const endOlderHerd = async (pid: number, deps: ClaimDeps): Promise<void> => {
50
+ deps.signal(pid, TAKEOVER_SIGNAL)
51
+ for (let poll = 0; poll < POLLS_BEFORE_KILL; poll++) {
52
+ if (deps.isGone(pid)) return
53
+ await deps.sleep(POLL_MS)
54
+ }
55
+ if (!deps.isGone(pid) && deps.isHerdView(pid)) deps.signal(pid, 'SIGKILL')
56
+ }
57
+
58
+ /**
59
+ * Makes this process the one herd view of its terminal: an older one is sent `TAKEOVER_SIGNAL` and, when it has not ended
60
+ * after about a second and its pid still is a herd view, SIGKILL; then the own pid is recorded and removed again when the process ends. The claim
61
+ * directory is verified first: a record in a directory of someone else is never read, and a directory that cannot be
62
+ * verified throws before anything is read, signalled or written.
63
+ */
64
+ export const claimTerminal = async (deps: ClaimDeps): Promise<void> => {
65
+ deps.prepare()
66
+ const older = olderHerdToStop(parsePid(deps.readRecord()), deps.selfPid, deps.isHerdView)
67
+ if (older !== null) await endOlderHerd(older, deps)
68
+ deps.writeRecord(deps.selfPid)
69
+ deps.onExit(() => deps.removeRecordIf(deps.selfPid))
70
+ }
71
+
72
+ /** The directory holding the claim records: one private level `jackherd-herd-<uid>` below `runtimeBaseOf`. */
73
+ export const claimDirectoryOf = (system: LocationSystem): string =>
74
+ userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid)
75
+
76
+ const exitWhenTakenOver = (): void => {
77
+ process.on(TAKEOVER_SIGNAL, () => process.exit(TAKEN_OVER_CODE))
78
+ }
79
+
80
+ /**
81
+ * Runs `start` once the takeover is done; a takeover that cannot be made (no terminal, no `/proc`) does not stop the
82
+ * view. `listen` runs first, so a newer herd's takeover signal that arrives while the claim is made ends this process
83
+ * as a wanted end instead of by the signal's default action; the view replaces that handler with its own.
84
+ */
85
+ export const startExclusive = async (
86
+ claim: () => Promise<void>,
87
+ start: () => void,
88
+ listen: () => void = exitWhenTakenOver,
89
+ ): Promise<void> => {
90
+ listen()
91
+ try {
92
+ await claim()
93
+ } catch {
94
+ // the view still starts: one stale herd is better than none
95
+ }
96
+ start()
97
+ }
98
+
99
+ const terminalOfStdin = (): string | null => {
100
+ try {
101
+ const target = readlinkSync('/proc/self/fd/0')
102
+ return target.startsWith('/dev/') ? target : null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ const readText = (path: string): string | null => {
109
+ try {
110
+ return readFileSync(path, 'utf8')
111
+ } catch {
112
+ return null
113
+ }
114
+ }
115
+
116
+ const isHerdViewProcess = (pid: number): boolean => {
117
+ const environment = readText(`/proc/${pid}/environ`)
118
+ const command = readText(`/proc/${pid}/cmdline`)
119
+ if (environment === null || command === null) return false
120
+ return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
121
+ }
122
+
123
+ const isGoneProcess = (pid: number): boolean => {
124
+ const stat = readText(`/proc/${pid}/stat`)
125
+ if (stat === null) return true
126
+ const stateIndex = stat.lastIndexOf(')') + 2
127
+ return stat.charAt(stateIndex) === 'Z'
128
+ }
129
+
130
+ const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
131
+ try {
132
+ process.kill(pid, signal)
133
+ } catch {
134
+ // already gone
135
+ }
136
+ }
137
+
138
+ const recordFileOf = (terminal: string): string =>
139
+ join(claimDirectoryOf(systemLocation()), `${createHash('sha256').update(terminal).digest('hex').slice(0, 16)}.pid`)
140
+
141
+ /**
142
+ * The claim operations for one record file; the claim directory is verified before the record is read or written, and
143
+ * a record that is a link or a FIFO is not read.
144
+ */
145
+ export const herdClaimDeps = (file: string): ClaimDeps => ({
146
+ selfPid: process.pid,
147
+ prepare: () => ensurePrivateDirectory(join(file, '..')),
148
+ readRecord: () => readPrivateText(file),
149
+ writeRecord: (pid) => {
150
+ ensurePrivateDirectory(join(file, '..'))
151
+ writePrivateFile(file, `${pid}\n`)
152
+ },
153
+ removeRecordIf: (pid) => {
154
+ try {
155
+ if (parsePid(readPrivateText(file)) === pid) unlinkSync(file)
156
+ } catch {
157
+ // the next herd of this terminal overwrites it
158
+ }
159
+ },
160
+ isHerdView: isHerdViewProcess,
161
+ isGone: isGoneProcess,
162
+ signal: signalProcess,
163
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
164
+ onExit: (run) => {
165
+ process.on('exit', run)
166
+ },
167
+ })
168
+
169
+ /** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
170
+ export const claimOwnTerminal = async (): Promise<void> => {
171
+ const terminal = terminalOfStdin()
172
+ if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
173
+ }
@@ -1,14 +1,19 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
- import { join } from 'node:path'
1
+ import { dirname, join } from 'node:path'
4
2
  import { z } from 'zod'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
4
+ import {
5
+ ensurePrivateLevels,
6
+ type LocationSystem,
7
+ readPrivateText,
8
+ runtimeBaseOf,
9
+ systemLocation,
10
+ userDirectoryOf,
11
+ } from '../shared/private-location'
5
12
  import { repositoryKey } from '../watch/watcher-lock'
6
13
  import type { LaneMemory } from './current-phase'
7
14
 
8
15
  const DIRECTORY_PREFIX = 'jackherd-herd'
9
16
  const MEMORY_FILE = 'herd-memory.json'
10
- const PRIVATE_DIRECTORY = 0o700
11
- const PRIVATE_FILE = 0o600
12
17
 
13
18
  const phaseRef = z.object({ planHref: z.string(), phaseId: z.string() })
14
19
 
@@ -54,41 +59,46 @@ export const mergedMemories = (
54
59
  * Where the herds of the repository at `root` keep what they saw of each lane, one directory per user and
55
60
  * repository like the watcher's, so a `--once` run and a restarted herd know when a phase last moved.
56
61
  */
57
- const herdMemoryDirectory = (root: string): string =>
58
- join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
62
+ export const herdMemoryDirectoryOf = (root: string, system: LocationSystem): string =>
63
+ join(userDirectoryOf(runtimeBaseOf(system), DIRECTORY_PREFIX, system.uid), repositoryKey(root))
59
64
 
60
- export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
61
-
62
- const readText = (file: string): string | null => {
63
- try {
64
- return readFileSync(file, 'utf8')
65
- } catch {
66
- return null
67
- }
65
+ /** The memory directory of `root` with both levels verified private (`ensurePrivateLevels`); throws when one is not. */
66
+ const ensureHerdMemoryDirectory = (root: string, system: LocationSystem): string => {
67
+ const directory = herdMemoryDirectoryOf(root, system)
68
+ return ensurePrivateLevels(dirname(directory), directory)
68
69
  }
69
70
 
71
+ export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
72
+
70
73
  export const readLaneMemories = (directory: string): Map<string, LaneMemory> =>
71
- parseLaneMemories(readText(memoryFileOf(directory)))
74
+ parseLaneMemories(readPrivateText(memoryFileOf(directory)))
72
75
 
73
76
  /**
74
77
  * Writes the memories of `lanes` into the file of `directory`, beside what other herds keep there; readable by the
75
- * user only, and replaced in one step so a herd reading at the same time never sees half a file.
78
+ * user only (the directory is verified with `ensurePrivateDirectory`), and replaced in one step through
79
+ * `writePrivateFile`, so a herd reading at the same time never sees half a file and a planted link is never followed.
76
80
  */
77
81
  export const writeLaneMemories = (
78
82
  directory: string,
79
83
  memories: ReadonlyMap<string, LaneMemory>,
80
84
  lanes: readonly string[],
81
85
  ): void => {
82
- mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
83
- const file = memoryFileOf(directory)
86
+ ensurePrivateDirectory(directory)
84
87
  const merged = mergedMemories(readLaneMemories(directory), memories, lanes)
85
- const partial = `${file}.${process.pid}`
86
- writeFileSync(partial, JSON.stringify(merged), { mode: PRIVATE_FILE })
87
- renameSync(partial, file)
88
+ writePrivateFile(memoryFileOf(directory), JSON.stringify(merged))
88
89
  }
89
90
 
90
- /** The memories the herds of the repository at `root` kept. */
91
- export const recallLanes = (root: string): Map<string, LaneMemory> => readLaneMemories(herdMemoryDirectory(root))
91
+ /**
92
+ * The memories the herds of the repository at `root` kept; none when the directory cannot be verified private, so a
93
+ * file planted in a directory of someone else is never read.
94
+ */
95
+ export const recallLanes = (root: string, system: LocationSystem = systemLocation()): Map<string, LaneMemory> => {
96
+ try {
97
+ return readLaneMemories(ensureHerdMemoryDirectory(root, system))
98
+ } catch {
99
+ return new Map()
100
+ }
101
+ }
92
102
 
93
103
  /**
94
104
  * Keeps the memories of `lanes` for the next herd. A memory that cannot be written costs only the times of the next
@@ -98,9 +108,10 @@ export const rememberLanes = (
98
108
  root: string,
99
109
  memories: ReadonlyMap<string, LaneMemory>,
100
110
  lanes: readonly string[],
111
+ system: LocationSystem = systemLocation(),
101
112
  ): void => {
102
113
  try {
103
- writeLaneMemories(herdMemoryDirectory(root), memories, lanes)
114
+ writeLaneMemories(ensureHerdMemoryDirectory(root, system), memories, lanes)
104
115
  } catch {
105
116
  return
106
117
  }
@@ -1,8 +1,10 @@
1
1
  import { existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { type PlansByHref, parsePlanPhases, resolvePlanPath, type SprintLane } from '../plan-format'
3
+ import { type PlanPhase, type PlansByHref, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import type { BaseReader } from './git'
5
- import { hasOwnWork, mergePlanPhases } from './phase-source'
5
+ import { hasOwnWork, mergePlanPhases, newerPhases } from './phase-source'
6
+ import { type PlanRound, parsedPlanPhases } from './plan-cache'
7
+ import type { PlanHead } from './plan-heads'
6
8
  import type { PlansSettings } from './settings'
7
9
 
8
10
  /** Paths that could not be read at the base ref and were read from a checkout instead. */
@@ -10,8 +12,20 @@ export type Fallbacks = string[]
10
12
 
11
13
  export type LaneWork = { worktree: string | null; ahead: number | null; dirty: number }
12
14
 
15
+ /** Parses a plan's text, kept under `key` in the round's cache. */
16
+ type ParsePlan = (markdown: string, key: string) => PlanPhase[]
17
+
13
18
  /** What reading a lane's plans needs: the main checkout, the settings, the base ref's reader, the board's directory. */
14
- export type PlanSources = { root: string; settings: PlansSettings; base: BaseReader; sprintDirectory: string }
19
+ export type PlanSources = {
20
+ root: string
21
+ settings: PlansSettings
22
+ base: BaseReader
23
+ sprintDirectory: string
24
+ /** The heads of the open pull requests that change a plan, besides the lane's own; a phase they ticked further counts. */
25
+ heads: readonly PlanHead[]
26
+ /** Kept between reads, so a plan whose text did not change is not parsed again; `used` collects the keys of one round. */
27
+ parsed?: PlanRound
28
+ }
15
29
 
16
30
  const isOutsideRepository = (path: string): boolean => path.startsWith('..')
17
31
 
@@ -49,10 +63,30 @@ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dir
49
63
  })
50
64
 
51
65
  /**
52
- * The plans of a lane. The base ref is the truth; while the lane has work of its own in its worktree, the phases
53
- * its rows name, and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is read from the lane's worktree, else from the main
54
- * checkout (a repository that ignores `docs/` keeps plan and board only there). `files` are the plan files to
55
- * watch for changes, `modified` when each was last written, by plan link (`nextActivity` bounds a movement by it).
66
+ * The base's phases with each phase an open pull request ticked further taken from that pull request's head, in
67
+ * the heads' order. A head whose ref was not fetched reads nothing and changes nothing; its phases are never the
68
+ * lane's work, so none is marked `touched`.
69
+ */
70
+ const withPullRequestHeads = (
71
+ base: PlanPhase[],
72
+ heads: readonly PlanHead[],
73
+ path: string,
74
+ parse: ParsePlan,
75
+ ): PlanPhase[] =>
76
+ heads
77
+ .filter((head) => head.files.includes(path))
78
+ .reduce((phases, head) => {
79
+ const headText = head.read(path)
80
+ return headText === null ? phases : newerPhases(phases, parse(headText, `${path}\0pr${head.number}`))
81
+ }, base)
82
+
83
+ /**
84
+ * The plans of a lane. The base ref is the truth, raised by the heads of open pull requests that ticked a phase
85
+ * further (`withPullRequestHeads`); while the lane has work of its own in its worktree, the phases its rows name,
86
+ * and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is
87
+ * read from the lane's worktree, else from the main checkout (a repository that ignores `docs/` keeps plan and
88
+ * board only there). `files` are the plan files to watch for changes, `modified` when each was last written, by
89
+ * plan link (`nextActivity` bounds a movement by it).
56
90
  */
57
91
  export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
58
92
  const { root, settings, sprintDirectory } = sources
@@ -68,12 +102,9 @@ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: La
68
102
  if (text === null) continue
69
103
  const own = worktreeFile === file && hasOwnWork(work) ? await Bun.file(file).text() : null
70
104
  const laneIds = lane.steps.filter((step) => step.planHref === href).flatMap((step) => step.phaseIds)
71
- plans[href] = mergePlanPhases(
72
- parsePlanPhases(text, settings.planLanguage),
73
- own === null ? null : parsePlanPhases(own, settings.planLanguage),
74
- laneIds,
75
- work,
76
- )
105
+ const parse: ParsePlan = (markdown, key) => parsedPlanPhases(sources.parsed, key, markdown, settings.planLanguage)
106
+ const base = withPullRequestHeads(parse(text, path), sources.heads, path, parse)
107
+ plans[href] = mergePlanPhases(base, own === null ? null : parse(own, `${file}\0own`), laneIds, work)
77
108
  files.push(file)
78
109
  texts[href] = text
79
110
  if (existsSync(file)) modified[href] = statSync(file).mtimeMs