jackherd 1.16.0 → 1.17.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 (155) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +22 -21
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +2 -3
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +120 -299
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +2 -1
  62. package/recipes/README.md +1 -0
  63. package/recipes/jira-cli.md +1 -1
  64. package/recipes/sources/google-calendar-today.ts +8 -2
  65. package/recipes/sources/jira.ts +10 -2
  66. package/recipes/sources/linear.ts +9 -3
  67. package/recipes/sources/sentry.ts +8 -2
  68. package/recipes/sources.md +4 -2
  69. package/schemas/jackherd.schema.json +67 -0
  70. package/schemas/sources.schema.json +9 -0
  71. package/src/approval/checked.ts +60 -0
  72. package/src/approval/command.ts +16 -0
  73. package/src/check/command.ts +51 -6
  74. package/src/check/lint-pointers.ts +79 -0
  75. package/src/check/lint-shaped.ts +196 -0
  76. package/src/check/plan-structure-check.ts +116 -4
  77. package/src/check/report.ts +9 -3
  78. package/src/check/schema.ts +37 -0
  79. package/src/check/settings.ts +4 -0
  80. package/src/check/skill-budget-defaults.ts +77 -26
  81. package/src/check/skill-budgets.ts +58 -10
  82. package/src/cli.ts +2 -0
  83. package/src/config/personal-sources.ts +46 -10
  84. package/src/config/repository-identity.ts +40 -0
  85. package/src/config/schema.ts +53 -1
  86. package/src/context/command.ts +33 -2
  87. package/src/context/options.ts +33 -8
  88. package/src/context/usage-logs.ts +53 -0
  89. package/src/context/usage.ts +110 -0
  90. package/src/lane/base-command.ts +35 -0
  91. package/src/lane/base.ts +43 -0
  92. package/src/lane/close-check.ts +49 -15
  93. package/src/lane/close-command.ts +71 -11
  94. package/src/lane/close-options.ts +29 -8
  95. package/src/lane/close-survey.ts +13 -1
  96. package/src/lane/close.ts +51 -12
  97. package/src/lane/command.ts +21 -3
  98. package/src/lane/send-command.ts +32 -0
  99. package/src/lane/send-herdr.ts +77 -0
  100. package/src/lane/send-options.ts +23 -0
  101. package/src/lane/send.ts +90 -0
  102. package/src/lane/skill-hint.ts +27 -0
  103. package/src/lane/space-command.ts +30 -0
  104. package/src/lane/space.ts +49 -0
  105. package/src/lane/start-lane-hint.ts +18 -0
  106. package/src/plan-contract/base-check-git.ts +33 -0
  107. package/src/plan-contract/base-check.ts +17 -0
  108. package/src/plan-contract/command.ts +25 -4
  109. package/src/plan-format/plan-structure.ts +17 -5
  110. package/src/plans/ask-coordinator.ts +23 -13
  111. package/src/plans/board-rows.ts +9 -4
  112. package/src/plans/click-hint.ts +4 -0
  113. package/src/plans/command.ts +6 -5
  114. package/src/plans/ensure-panes.ts +14 -3
  115. package/src/plans/git.ts +14 -0
  116. package/src/plans/github.ts +19 -10
  117. package/src/plans/layout.ts +82 -33
  118. package/src/plans/live.ts +8 -12
  119. package/src/plans/plan-panes.ts +21 -4
  120. package/src/plans/selection.ts +29 -0
  121. package/src/plans/settings.ts +4 -0
  122. package/src/resume/command.ts +213 -0
  123. package/src/resume/decision.ts +102 -0
  124. package/src/resume/keeper.ts +65 -0
  125. package/src/resume/location.ts +22 -0
  126. package/src/resume/log.ts +27 -0
  127. package/src/resume/ports.ts +102 -0
  128. package/src/resume/record.ts +116 -0
  129. package/src/resume/targets.ts +84 -0
  130. package/src/resume/texts.ts +27 -0
  131. package/src/secrets/command.ts +15 -5
  132. package/src/sync/herdr-plugin.ts +2 -11
  133. package/src/sync/plan-ask-rules.ts +11 -0
  134. package/src/sync/render.ts +3 -1
  135. package/src/vocabulary/index.ts +13 -0
  136. package/src/waiting/command.ts +13 -4
  137. package/src/watch/command.ts +177 -78
  138. package/src/watch/config-reload.ts +119 -0
  139. package/src/watch/lane-model.ts +11 -1
  140. package/src/watch/lane-progress.ts +70 -8
  141. package/src/watch/lane-report.ts +66 -11
  142. package/src/watch/main-checkout.ts +5 -1
  143. package/src/watch/merge-duties.ts +33 -0
  144. package/src/watch/merge-overlap-read.ts +69 -0
  145. package/src/watch/plan-views.ts +16 -4
  146. package/src/watch/reference-paths.ts +46 -5
  147. package/src/watch/report-questions.ts +5 -2
  148. package/src/watch/single-report.ts +3 -2
  149. package/src/watch/source-dry-run-command.ts +22 -4
  150. package/src/watch/source-reason.ts +58 -0
  151. package/src/watch/source-reload.ts +21 -17
  152. package/src/watch/source-run.ts +24 -7
  153. package/src/watch/sources.ts +9 -2
  154. package/src/watch/texts.ts +14 -3
  155. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,213 @@
1
+ import { join } from 'node:path'
2
+ import { type SendPorts, sendLanePrompt } from '../lane/send'
3
+ import { errorText } from '../plans/error-text'
4
+ import type { PlanLanguage } from '../vocabulary'
5
+ import { type ResumeDecision, resumeDecision } from './decision'
6
+ import { type ResumeRecord, resumedRecord } from './record'
7
+ import { type AgentPane, type Streak, steadyTarget, TARGET_MISS, type TargetMissed, targetFor } from './targets'
8
+ import { resumeTexts } from './texts'
9
+
10
+ type Limits = { attempts: number; intervalMs: number }
11
+
12
+ /**
13
+ * After a crash the coordinator's session was back after ~8 s and the lanes' after ~3 s (`RESTORE_LIMITS`), but a slow
14
+ * machine resuming many sessions at once takes longer, and a message lost here is a lost restart: three minutes each
15
+ * for the coordinator and for the lanes, polled every two seconds.
16
+ */
17
+ export const RESUME_LIMITS = {
18
+ coordinator: { attempts: 90, intervalMs: 2_000 },
19
+ lanes: { attempts: 90, intervalMs: 2_000 },
20
+ } as const satisfies Record<string, Limits>
21
+
22
+ /** A record file and what it holds; null for a damaged one. */
23
+ export type RecordEntry = { path: string; record: ResumeRecord | null }
24
+
25
+ /** What `jh resume` reads and does; `run` wires them to the files, the watcher lock and herdr. */
26
+ export type ResumePorts = {
27
+ now: () => number
28
+ records: () => RecordEntry[]
29
+ /** The record at `path` once more, right before it is marked. */
30
+ reread: (path: string) => ResumeRecord | null
31
+ writeRecord: (path: string, record: ResumeRecord) => void
32
+ watcherRunning: (root: string) => boolean
33
+ boardExists: (board: string) => boolean
34
+ settingsOf: (root: string) => { language: PlanLanguage; worktreeDirectory: string }
35
+ /** herdr's agent panes; throws with herdr's error when it does not answer. */
36
+ agentPanes: () => AgentPane[]
37
+ send: SendPorts
38
+ sleep: (ms: number) => Promise<void>
39
+ log: (root: string, message: string) => void
40
+ notify: (title: string, body: string) => void
41
+ }
42
+
43
+ type Resume = Extract<ResumeDecision, { kind: 'resume' }>
44
+
45
+ type Recipient = { name: string; directory: string }
46
+
47
+ type Search = { found: Map<Recipient, string>; missed: Map<Recipient, TargetMissed> }
48
+
49
+ const NOTE_TITLE = 'jackherd resume'
50
+
51
+ const missLine = (who: string, miss: TargetMissed): string => {
52
+ if (miss.reason === TARGET_MISS.ambiguous)
53
+ return `${who}: no target, ${miss.panes.length} unnamed agent panes (${miss.panes.join(', ')}); nothing sent`
54
+ if (miss.reason === TARGET_MISS.elsewhere)
55
+ return `${who}: no target, the agent of that name runs in another directory (${miss.panes.join(', ')}); nothing sent`
56
+ return `${who}: no target, the agent did not come back in time; nothing sent`
57
+ }
58
+
59
+ const planLine = ({ coordinator, interrupted, blocked }: Resume): string =>
60
+ `resume: coordinator ${coordinator}, interrupted ${interrupted.join(', ') || '-'}, blocked ${blocked.join(', ') || '-'}`
61
+
62
+ /** herdr's panes of one poll; none, with a log line, when herdr does not answer. */
63
+ const pollPanes = (ports: ResumePorts, root: string): AgentPane[] => {
64
+ try {
65
+ return ports.agentPanes()
66
+ } catch (error) {
67
+ ports.log(root, `herdr did not answer: ${errorText(error)}`)
68
+ return []
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Polls herdr until every recipient has a steady target (`steadyTarget`) or the window is used up; `onFound` runs for
74
+ * each as soon as it has one. Returns the targets found and, for the others, the last miss.
75
+ */
76
+ const awaitTargets = async (
77
+ ports: ResumePorts,
78
+ root: string,
79
+ recipients: readonly Recipient[],
80
+ limits: Limits,
81
+ onFound: (target: string) => Promise<void>,
82
+ ): Promise<Search> => {
83
+ const search: Search = { found: new Map(), missed: new Map() }
84
+ const streaks = new Map<Recipient, Streak>()
85
+ for (let attempt = 1; search.found.size < recipients.length; attempt++) {
86
+ const panes = pollPanes(ports, root)
87
+ for (const recipient of recipients.filter((each) => !search.found.has(each))) {
88
+ const steady = steadyTarget(targetFor(panes, recipient.name, recipient.directory), streaks.get(recipient) ?? null)
89
+ streaks.set(recipient, steady.streak)
90
+ if (steady.target.kind === 'none') search.missed.set(recipient, steady.target)
91
+ else {
92
+ search.found.set(recipient, steady.target.target)
93
+ search.missed.delete(recipient)
94
+ await onFound(steady.target.target)
95
+ }
96
+ }
97
+ if (search.found.size === recipients.length || attempt >= limits.attempts) break
98
+ await ports.sleep(limits.intervalMs)
99
+ }
100
+ return search
101
+ }
102
+
103
+ const reportMisses = (ports: ResumePorts, root: string, missed: Search['missed'], who: (name: string) => string) => {
104
+ for (const [recipient, miss] of missed) {
105
+ const line = missLine(who(recipient.name), miss)
106
+ ports.log(root, line)
107
+ if (miss.reason !== TARGET_MISS.missing) ports.notify(NOTE_TITLE, `${root} ${line}`)
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Marks the record as resumed, after reading it once more: a record another start marked or a new watcher replaced
113
+ * in between is left alone. False when it was left alone or could not be written, so nothing is sent.
114
+ */
115
+ const marked = (ports: ResumePorts, path: string, record: ResumeRecord): boolean => {
116
+ const current = ports.reread(path)
117
+ if (current === null || current.resumedAt !== null || current.startedAt !== record.startedAt) {
118
+ ports.log(record.root, 'record changed since it was read; nothing sent')
119
+ return false
120
+ }
121
+ try {
122
+ ports.writeRecord(path, resumedRecord(current, ports.now()))
123
+ return true
124
+ } catch (error) {
125
+ ports.log(record.root, `cannot mark the record (${errorText(error)}); nothing sent`)
126
+ return false
127
+ }
128
+ }
129
+
130
+ const sendTo = async (ports: ResumePorts, root: string, target: string, text: string) => {
131
+ ports.log(root, (await sendLanePrompt(ports.send, target, text)).line)
132
+ }
133
+
134
+ /** The coordinator's target once it is back in the main checkout; null, logged, when it is not within the window. */
135
+ const awaitCoordinator = async (ports: ResumePorts, decision: Resume): Promise<string | null> => {
136
+ const recipient = { name: decision.coordinator, directory: decision.root }
137
+ const search = await awaitTargets(ports, decision.root, [recipient], RESUME_LIMITS.coordinator, async () => {})
138
+ reportMisses(ports, decision.root, search.missed, (name) => `no coordinator ${name}`)
139
+ return search.found.get(recipient) ?? null
140
+ }
141
+
142
+ const resumeLanes = async (ports: ResumePorts, decision: Resume, worktreeDirectory: string, text: string) => {
143
+ const recipients = decision.interrupted.map((name) => ({
144
+ name,
145
+ directory: join(decision.root, worktreeDirectory, name),
146
+ }))
147
+ const search = await awaitTargets(ports, decision.root, recipients, RESUME_LIMITS.lanes, (target) =>
148
+ sendTo(ports, decision.root, target, text),
149
+ )
150
+ reportMisses(ports, decision.root, search.missed, (name) => name)
151
+ }
152
+
153
+ /**
154
+ * Wakes one repository: waits for its coordinator first, since a coordinator that never comes back here runs in
155
+ * another herdr session and its record stays for that one; marks the record; then sends the coordinator its line and
156
+ * each interrupted lane its text.
157
+ */
158
+ const resumeRepository = async (ports: ResumePorts, path: string, record: ResumeRecord, decision: Resume) => {
159
+ const { language, worktreeDirectory } = ports.settingsOf(decision.root)
160
+ const texts = resumeTexts(language)
161
+ ports.log(decision.root, planLine(decision))
162
+ if (decision.unsafe.length > 0) ports.log(decision.root, `left out, no lane names: ${decision.unsafe.join(', ')}`)
163
+ const coordinator = await awaitCoordinator(ports, decision)
164
+ if (coordinator === null || !marked(ports, path, record)) return
165
+ const line = texts.coordinatorLine(decision.board, decision.interrupted, decision.blocked)
166
+ await sendTo(ports, decision.root, coordinator, line)
167
+ await resumeLanes(ports, decision, worktreeDirectory, texts.laneText)
168
+ }
169
+
170
+ const resumeEntry = async (ports: ResumePorts, path: string, record: ResumeRecord) => {
171
+ const decision = resumeDecision({
172
+ record,
173
+ now: ports.now(),
174
+ watcherRunning: ports.watcherRunning(record.root),
175
+ boardExists: ports.boardExists(record.board),
176
+ })
177
+ if (decision.kind === 'none') ports.log(record.root, decision.reason)
178
+ else await resumeRepository(ports, path, record, decision)
179
+ }
180
+
181
+ /** Every repository with a record, one after the other; a failure in one is logged and the next goes on. Always 0. */
182
+ export const resumeAll = async (ports: ResumePorts): Promise<number> => {
183
+ try {
184
+ const entries = ports.records()
185
+ if (entries.length === 0) ports.log('-', 'no resume record')
186
+ for (const { path, record } of entries) {
187
+ try {
188
+ if (record === null) ports.log(path, 'damaged record, read as none')
189
+ else await resumeEntry(ports, path, record)
190
+ } catch (error) {
191
+ ports.log(record?.root ?? path, `failed: ${errorText(error)}`)
192
+ }
193
+ }
194
+ } catch (error) {
195
+ ports.log('-', `cannot read the records: ${errorText(error)}`)
196
+ }
197
+ return 0
198
+ }
199
+
200
+ /**
201
+ * `jh resume`: herdr's second `[[startup]]` hook. After a crash of the watcher (its record still marked running, no
202
+ * watcher holding the lock now) sends the coordinator one fixed `/orchestrate … ` line and each lane that worked one
203
+ * fixed text, at most once per record. Every step goes to `~/.jackherd/logs/resume.log`; the exit code is always 0,
204
+ * a hook leaves nothing behind but its log.
205
+ */
206
+ export const run = async (args: string[]): Promise<number> => {
207
+ if (args.length > 0) {
208
+ console.log('Usage: jh resume (run by the herdr plugin when herdr starts; takes no arguments)')
209
+ return args.includes('--help') || args.includes('-h') ? 0 : 1
210
+ }
211
+ const { systemResumePorts } = await import('./ports')
212
+ return resumeAll(systemResumePorts())
213
+ }
@@ -0,0 +1,102 @@
1
+ import { isAbsolute, relative, sep } from 'node:path'
2
+ import { AGENT_STATUS, type AgentStatus } from '../terminal/types'
3
+ import { SAFE_LANE_NAME } from '../watch/lane-name'
4
+ import type { ResumeRecord } from './record'
5
+
6
+ /** Why `jh resume` leaves a repository alone. */
7
+ export const RESUME_SKIP = {
8
+ noRecord: 'no-record',
9
+ stopped: 'stopped',
10
+ resumed: 'resumed',
11
+ stale: 'stale',
12
+ watcherRunning: 'watcher-running',
13
+ boardMissing: 'board-missing',
14
+ unsafeBoard: 'unsafe-board',
15
+ unsafeCoordinator: 'unsafe-coordinator',
16
+ } as const
17
+
18
+ export type ResumeSkip = (typeof RESUME_SKIP)[keyof typeof RESUME_SKIP]
19
+
20
+ /**
21
+ * How long after the watcher last saved its record a herdr start still resumes: a crash in the evening
22
+ * and a herdr start the next morning resume, a start days later does not, since the interrupted commands then meet
23
+ * code that has moved on. Measured from `savedAt`.
24
+ */
25
+ export const RESUME_MAX_AGE_MS = 12 * 60 * 60 * 1000
26
+
27
+ export type ResumeInput = {
28
+ record: ResumeRecord | null
29
+ /** The time of this herdr start, against which the record's age is measured. */
30
+ now: number
31
+ /** Whether a watcher holds the repository's lock now: then herdr was handed over live, nothing crashed. */
32
+ watcherRunning: boolean
33
+ boardExists: boolean
34
+ }
35
+
36
+ export type ResumeDecision =
37
+ | { kind: 'none'; reason: ResumeSkip }
38
+ | {
39
+ kind: 'resume'
40
+ root: string
41
+ /** The board relative to the main checkout, one path without blanks, as the coordinator line carries it. */
42
+ board: string
43
+ coordinator: string
44
+ /** The lanes that worked when the watcher went down; each gets the resume text. */
45
+ interrupted: string[]
46
+ /** The lanes that waited for an answer; only named to the coordinator. */
47
+ blocked: string[]
48
+ /** Lane names that are no single file name: never sent to, only logged. */
49
+ unsafe: string[]
50
+ }
51
+
52
+ /** Blanks, control and format characters (bidi marks, zero-width ones): a board path with them is not one plain word. */
53
+ const NOT_ONE_WORD = /[\s\p{Cc}\p{Cf}]/u
54
+
55
+ /** The board relative to `root`, null when it is not a file inside it or would not stay one word on the line. */
56
+ const boardInside = (root: string, board: string): string | null => {
57
+ const path = relative(root, board)
58
+ const outside = path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
59
+ return outside || NOT_ONE_WORD.test(path) ? null : path
60
+ }
61
+
62
+ /** A single file name that herdr cannot read as an option: `herdr agent prompt <target> …` takes it as an argument. */
63
+ const isSafeName = (name: string): boolean => SAFE_LANE_NAME.test(name) && !name.startsWith('-')
64
+
65
+ const lanesWith = (lanes: Record<string, string>, status: AgentStatus): string[] =>
66
+ Object.entries(lanes).flatMap(([name, laneStatus]) => (laneStatus === status && isSafeName(name) ? [name] : []))
67
+
68
+ const skip = (reason: ResumeSkip): ResumeDecision => ({ kind: 'none', reason })
69
+
70
+ /** The reason the record itself gives against a restart, checked before what the system says now. */
71
+ const recordSkip = (record: ResumeRecord, now: number): ResumeSkip | null => {
72
+ if (record.stoppedAt !== null) return RESUME_SKIP.stopped
73
+ if (record.resumedAt !== null) return RESUME_SKIP.resumed
74
+ if (now - record.savedAt > RESUME_MAX_AGE_MS) return RESUME_SKIP.stale
75
+ return null
76
+ }
77
+
78
+ /**
79
+ * Whether a herdr start follows a crash of the watcher and whom to wake: a record still marked running, never resumed,
80
+ * saved within `RESUME_MAX_AGE_MS`, with no watcher running now and its board in place. Only lanes that worked get a
81
+ * message; names that are no single file name, a coordinator name of that kind and a board outside the main checkout
82
+ * never reach a send.
83
+ */
84
+ export const resumeDecision = ({ record, now, watcherRunning, boardExists }: ResumeInput): ResumeDecision => {
85
+ if (record === null) return skip(RESUME_SKIP.noRecord)
86
+ const reason = recordSkip(record, now)
87
+ if (reason !== null) return skip(reason)
88
+ if (watcherRunning) return skip(RESUME_SKIP.watcherRunning)
89
+ if (!isSafeName(record.coordinator)) return skip(RESUME_SKIP.unsafeCoordinator)
90
+ const board = boardInside(record.root, record.board)
91
+ if (board === null) return skip(RESUME_SKIP.unsafeBoard)
92
+ if (!boardExists) return skip(RESUME_SKIP.boardMissing)
93
+ return {
94
+ kind: 'resume',
95
+ root: record.root,
96
+ board,
97
+ coordinator: record.coordinator,
98
+ interrupted: lanesWith(record.lanes, AGENT_STATUS.working),
99
+ blocked: lanesWith(record.lanes, AGENT_STATUS.blocked),
100
+ unsafe: Object.keys(record.lanes).filter((name) => !isSafeName(name)),
101
+ }
102
+ }
@@ -0,0 +1,65 @@
1
+ import { errorText } from '../plans/error-text'
2
+ import type { LaneSnapshot } from '../watch/lane-watch'
3
+ import { followedRecord, type RecordStart, type ResumeRecord, runningRecord, stoppedRecord } from './record'
4
+
5
+ /** The one record file of a repository; `read` gives null for a missing or damaged one. */
6
+ export type RecordStore = {
7
+ read: () => ResumeRecord | null
8
+ write: (record: ResumeRecord) => void
9
+ }
10
+
11
+ export type KeeperPorts = {
12
+ store: RecordStore
13
+ log: (line: string) => void
14
+ }
15
+
16
+ /** What the watcher calls: `follow` at every herdr poll, `stop` once on a clean end. */
17
+ export type RecordKeeper = {
18
+ follow: (lanes: LaneSnapshot, now: number) => void
19
+ stop: (now: number) => void
20
+ }
21
+
22
+ export const laneStatusesOf = (snapshot: LaneSnapshot): Record<string, string> =>
23
+ Object.fromEntries(Object.entries(snapshot).map(([name, lane]) => [name, lane.status]))
24
+
25
+ /**
26
+ * Keeps the resume record of the watcher in `start.pid` beside its loop: running at the start, saved when a lane status
27
+ * changes or `RESUME_REFRESH_MS` is up, stamped as stopped at a clean end unless a successor holds the file by then (a
28
+ * missing or damaged file is left alone). A write that fails is retried at the next poll and logged once per series; it
29
+ * never ends the watch, since without the record a crash only goes unresumed.
30
+ *
31
+ * Accepted: `follow` writes without reading the file first, so a predecessor that outlived `takeOverLock`'s wait can
32
+ * overwrite its successor's record until the successor's next write.
33
+ */
34
+ export const keepRecord = (start: RecordStart, now: number, ports: KeeperPorts): RecordKeeper => {
35
+ let failing = false
36
+ const attempt = (action: () => void): boolean => {
37
+ try {
38
+ action()
39
+ if (failing) ports.log('resume record written again')
40
+ failing = false
41
+ return true
42
+ } catch (error) {
43
+ if (!failing) ports.log(`resume record failed: ${errorText(error)}`)
44
+ failing = true
45
+ return false
46
+ }
47
+ }
48
+ let current = runningRecord(start, now)
49
+ let unsaved = !attempt(() => ports.store.write(current))
50
+ return {
51
+ follow: (lanes, at) => {
52
+ const next = followedRecord(current, laneStatusesOf(lanes), at)
53
+ if (next === current && !unsaved) return
54
+ current = next
55
+ unsaved = !attempt(() => ports.store.write(next))
56
+ },
57
+ stop: (at) => {
58
+ attempt(() => {
59
+ const stored = ports.store.read()
60
+ const stopped = stored === null ? null : stoppedRecord(stored, start.pid, at)
61
+ if (stopped !== null) ports.store.write(stopped)
62
+ })
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,22 @@
1
+ import { dirname } from 'node:path'
2
+ import { isOwnedBy } from '../../kit/hooks/hooks/shared/private-directory'
3
+ import { isPrivateLevels, type LocationSystem } from '../shared/private-location'
4
+
5
+ const OTHERS_WRITE = 0o002
6
+
7
+ /**
8
+ * Whether `jh resume` may trust the records in `directory`: the directory itself is the user's, no link, closed to
9
+ * group and others (`isPrivateLevels`), and the level above (`~/.jackherd`) is the user's, no link and closed to others'
10
+ * writes, so nobody else can swap the directory. That level is checked less strictly: it holds the user's own config,
11
+ * and a umask of `002` leaves it open to the user's own group.
12
+ */
13
+ export const isTrustedRecordDirectory = (directory: string, system: LocationSystem): boolean => {
14
+ const parent = system.entryAt(dirname(directory))
15
+ const parentHolds =
16
+ parent !== null &&
17
+ !parent.isSymbolicLink() &&
18
+ parent.isDirectory() &&
19
+ isOwnedBy(parent, system.uid) &&
20
+ (parent.mode & OTHERS_WRITE) === 0
21
+ return parentHolds && isPrivateLevels(directory, directory, system)
22
+ }
@@ -0,0 +1,27 @@
1
+ import { join } from 'node:path'
2
+ import { TOOL_DIRECTORY } from '../config/location'
3
+ import { terminalSafe } from '../plan-format/terminal-text'
4
+
5
+ /** How many lines the resume log keeps: a few restarts' worth, so one can be checked afterwards. */
6
+ export const RESUME_LOG_LINES = 200
7
+
8
+ const LOG_DIRECTORY = 'logs'
9
+ const LOG_FILE = 'resume.log'
10
+
11
+ export const resumeLogDirectory = (home: string): string => join(home, TOOL_DIRECTORY, LOG_DIRECTORY)
12
+
13
+ export const resumeLogPath = (home: string): string => join(resumeLogDirectory(home), LOG_FILE)
14
+
15
+ /**
16
+ * One log line: the time, the repository and the step. A line break of herdr's answers becomes a blank, and every
17
+ * other control character is dropped (`terminalSafe`): the line is also printed, and a name read from a record must
18
+ * not act on the terminal.
19
+ */
20
+ export const logLine = (now: number, root: string, message: string): string =>
21
+ terminalSafe(`${new Date(now).toISOString()} ${root} ${message}`.replace(/\s*\n\s*/g, ' '))
22
+
23
+ /** The log with `lines` appended, cut to its last `max` lines. */
24
+ export const trimmedLog = (existing: string | null, lines: readonly string[], max: number): string => {
25
+ const kept = [...(existing ?? '').split('\n').filter((line) => line !== ''), ...lines]
26
+ return `${kept.slice(-max).join('\n')}\n`
27
+ }
@@ -0,0 +1,102 @@
1
+ import { lstatSync, readdirSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
5
+ import { herdrSendPorts } from '../lane/send-herdr'
6
+ import { errorText } from '../plans/error-text'
7
+ import { readSettings } from '../plans/settings'
8
+ import { readPrivateText, systemLocation } from '../shared/private-location'
9
+ import { herdrRunner } from '../terminal/herdr'
10
+ import { isWatcherRunning, WATCHER_FILES, watcherDirectoryOf } from '../watch/watcher-lock'
11
+ import type { RecordEntry, ResumePorts } from './command'
12
+ import type { RecordStore } from './keeper'
13
+ import { isTrustedRecordDirectory } from './location'
14
+ import { logLine, RESUME_LOG_LINES, resumeLogDirectory, resumeLogPath, trimmedLog } from './log'
15
+ import { isRecordFileOf, parseResumeRecord, type ResumeRecord, resumeRecordDirectory, resumeRecordPath } from './record'
16
+ import { resumablePanesOf } from './targets'
17
+
18
+ const RECORD_FILE = /^[A-Za-z0-9._-]+\.json$/
19
+
20
+ const readRecord = (path: string): ResumeRecord | null => {
21
+ const text = readPrivateText(path)
22
+ return text === null ? null : parseResumeRecord(text)
23
+ }
24
+
25
+ /** A record counts only under the name its own `root` gives; a file under another name reads as damaged. */
26
+ const entryOf = (directory: string, name: string): RecordEntry => {
27
+ const path = join(directory, name)
28
+ const record = readRecord(path)
29
+ return { path, record: record !== null && isRecordFileOf(name, record) ? record : null }
30
+ }
31
+
32
+ /**
33
+ * The records of `~/.jackherd/resume/`, read only from a directory nobody but the user can change
34
+ * (`isTrustedRecordDirectory`): board and lane names in there reach other sessions. Throws when the directory cannot be
35
+ * listed; `resumeAll` logs it.
36
+ */
37
+ const readRecords = (home: string): RecordEntry[] => {
38
+ const directory = resumeRecordDirectory(home)
39
+ if (!isTrustedRecordDirectory(directory, systemLocation())) return []
40
+ return readdirSync(directory)
41
+ .filter((name) => RECORD_FILE.test(name))
42
+ .map((name) => entryOf(directory, name))
43
+ }
44
+
45
+ const writeRecord = (home: string, path: string, record: ResumeRecord) => {
46
+ ensurePrivateDirectory(resumeRecordDirectory(home))
47
+ writePrivateFile(path, `${JSON.stringify(record)}\n`)
48
+ }
49
+
50
+ /** The watcher's record of `root` below `home`, the file `jh resume` reads after a crash. */
51
+ export const systemRecordStore = (root: string, home: string = homedir()): RecordStore => {
52
+ const path = resumeRecordPath(home, root)
53
+ return { read: () => readRecord(path), write: (record) => writeRecord(home, path, record) }
54
+ }
55
+
56
+ const isFile = (path: string): boolean => {
57
+ try {
58
+ return lstatSync(path).isFile()
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ const appendLog = (home: string, line: string) => {
65
+ try {
66
+ ensurePrivateDirectory(resumeLogDirectory(home))
67
+ const path = resumeLogPath(home)
68
+ writePrivateFile(path, trimmedLog(readPrivateText(path), [line], RESUME_LOG_LINES))
69
+ } catch (error) {
70
+ console.error(`jh resume: cannot write the log: ${errorText(error)}`)
71
+ }
72
+ console.log(line)
73
+ }
74
+
75
+ /** The ports of `jh resume` on this machine: the user's records and log below `home`, the watcher lock, herdr. */
76
+ export const systemResumePorts = (home: string = homedir()): ResumePorts => {
77
+ const herdr = herdrRunner(process.env)
78
+ const log = (root: string, message: string) => appendLog(home, logLine(Date.now(), root, message))
79
+ return {
80
+ now: Date.now,
81
+ records: () => readRecords(home),
82
+ reread: readRecord,
83
+ writeRecord: (path, record) => writeRecord(home, path, record),
84
+ watcherRunning: (root) => isWatcherRunning(join(watcherDirectoryOf(root), WATCHER_FILES.lock)),
85
+ boardExists: isFile,
86
+ settingsOf: (root) => {
87
+ const { responseLanguage, worktreeDirectory } = readSettings([root], home)
88
+ return { language: responseLanguage, worktreeDirectory }
89
+ },
90
+ agentPanes: () => resumablePanesOf(herdr(['agent', 'list']), herdr(['pane', 'list'])),
91
+ send: herdrSendPorts(),
92
+ sleep: (ms) => Bun.sleep(ms),
93
+ log,
94
+ notify: (title, body) => {
95
+ try {
96
+ herdr(['notification', 'show', title, '--body', body])
97
+ } catch (error) {
98
+ log('-', `herdr note failed: ${errorText(error)}`)
99
+ }
100
+ },
101
+ }
102
+ }
@@ -0,0 +1,116 @@
1
+ import { isAbsolute, join } from 'node:path'
2
+ import { TOOL_DIRECTORY } from '../config/location'
3
+ import { isRecord } from '../shared/is-record'
4
+ import { parseJsonOrNull } from '../shared/parse-json'
5
+ import { repositoryKey } from '../watch/watcher-lock'
6
+
7
+ export const RESUME_RECORD_VERSION = 1
8
+
9
+ /** Below `~/.jackherd/`: the user's own, outside every repository an agent writes to. */
10
+ const RESUME_DIRECTORY = 'resume'
11
+
12
+ /**
13
+ * What the watcher keeps about one repository so `jh resume` can tell a crash from a stop: a record without `stoppedAt`
14
+ * was left by a watcher that never ended cleanly. `lanes` holds each lane's last `agent_status`, `resumedAt` the one
15
+ * restart this record may cause.
16
+ */
17
+ export type ResumeRecord = {
18
+ version: typeof RESUME_RECORD_VERSION
19
+ root: string
20
+ /** The board the watcher followed, absolute. */
21
+ board: string
22
+ /** The coordinator's herdr name. */
23
+ coordinator: string
24
+ pid: number
25
+ startedAt: number
26
+ savedAt: number
27
+ lanes: Record<string, string>
28
+ stoppedAt: number | null
29
+ resumedAt: number | null
30
+ }
31
+
32
+ /** What the watcher knows when it takes the lock. */
33
+ export type RecordStart = Pick<ResumeRecord, 'root' | 'board' | 'coordinator' | 'pid'>
34
+
35
+ const isTime = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
36
+
37
+ const isTimeOrNull = (value: unknown): boolean => value === null || isTime(value)
38
+
39
+ const isAbsolutePath = (value: unknown): boolean => typeof value === 'string' && isAbsolute(value)
40
+
41
+ const isLaneStatuses = (value: unknown): boolean =>
42
+ isRecord(value) && Object.values(value).every((status) => typeof status === 'string')
43
+
44
+ export const isResumeRecord = (value: unknown): value is ResumeRecord =>
45
+ isRecord(value) &&
46
+ value.version === RESUME_RECORD_VERSION &&
47
+ isAbsolutePath(value.root) &&
48
+ isAbsolutePath(value.board) &&
49
+ typeof value.coordinator === 'string' &&
50
+ Number.isInteger(value.pid) &&
51
+ (value.pid as number) > 0 &&
52
+ isTime(value.startedAt) &&
53
+ isTime(value.savedAt) &&
54
+ isLaneStatuses(value.lanes) &&
55
+ 'stoppedAt' in value &&
56
+ isTimeOrNull(value.stoppedAt) &&
57
+ 'resumedAt' in value &&
58
+ isTimeOrNull(value.resumedAt)
59
+
60
+ /** The record in `text`; null for a damaged or foreign one, which the caller logs. */
61
+ export const parseResumeRecord = (text: string): ResumeRecord | null => {
62
+ const value = parseJsonOrNull(text)
63
+ return isResumeRecord(value) ? value : null
64
+ }
65
+
66
+ /** `~/.jackherd/resume/<repositoryKey>.json`, the key the watcher directory of the same repository carries. */
67
+ export const resumeRecordDirectory = (home: string): string => join(home, TOOL_DIRECTORY, RESUME_DIRECTORY)
68
+
69
+ const recordFileName = (root: string): string => `${repositoryKey(root)}.json`
70
+
71
+ export const resumeRecordPath = (home: string, root: string): string =>
72
+ join(resumeRecordDirectory(home), recordFileName(root))
73
+
74
+ /** Whether the record lies under the name its `root` gives: a file under another name was not written by the watcher. */
75
+ export const isRecordFileOf = (fileName: string, record: ResumeRecord): boolean =>
76
+ fileName === recordFileName(record.root)
77
+
78
+ export const runningRecord = (start: RecordStart, now: number): ResumeRecord => ({
79
+ version: RESUME_RECORD_VERSION,
80
+ ...start,
81
+ startedAt: now,
82
+ savedAt: now,
83
+ lanes: {},
84
+ stoppedAt: null,
85
+ resumedAt: null,
86
+ })
87
+
88
+ const sameStatuses = (left: Record<string, string>, right: Record<string, string>): boolean => {
89
+ const names = Object.keys(left)
90
+ return names.length === Object.keys(right).length && names.every((name) => left[name] === right[name])
91
+ }
92
+
93
+ /** The record with the lanes' statuses; the same object when none changed, so the watcher writes only on a change. */
94
+ export const withLanes = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
95
+ sameStatuses(record.lanes, lanes) ? record : { ...record, lanes: { ...lanes }, savedAt: now }
96
+
97
+ /**
98
+ * How often the watcher saves its record although no lane changed: once an hour, so a watcher that rests longer than
99
+ * `RESUME_MAX_AGE_MS` of `decision.ts` without a change still leaves a record young enough to resume after a crash.
100
+ */
101
+ export const RESUME_REFRESH_MS = 60 * 60 * 1000
102
+
103
+ /** The record with a new save time once `RESUME_REFRESH_MS` is up; the same object before, so nothing is written. */
104
+ export const refreshedRecord = (record: ResumeRecord, now: number): ResumeRecord =>
105
+ now - record.savedAt < RESUME_REFRESH_MS ? record : { ...record, savedAt: now }
106
+
107
+ /** The record after one herdr poll: new statuses, else a new save time once due; the same object when neither. */
108
+ export const followedRecord = (record: ResumeRecord, lanes: Record<string, string>, now: number): ResumeRecord =>
109
+ refreshedRecord(withLanes(record, lanes, now), now)
110
+
111
+ /** The record stamped as stopped cleanly; null when it names another process, whose record a successor keeps. */
112
+ export const stoppedRecord = (record: ResumeRecord, ownPid: number, now: number): ResumeRecord | null =>
113
+ record.pid === ownPid ? { ...record, stoppedAt: now, savedAt: now } : null
114
+
115
+ /** The record stamped as resumed, written before the first message goes out: one restart per record. */
116
+ export const resumedRecord = (record: ResumeRecord, now: number): ResumeRecord => ({ ...record, resumedAt: now })