jackherd 1.15.2 → 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 (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  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 +438 -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 +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  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 +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -27,6 +27,8 @@ export type Selection = {
27
27
  laneFolds: ReadonlyMap<string, LaneFold>
28
28
  /** The top blocks the user opened; every other one is folded, at the start of the herd all of them. */
29
29
  openBlocks?: ReadonlySet<TopBlockId>
30
+ /** The sources whose block of entries the user folded; absent or without the source while it is open, as at the start. */
31
+ foldedEntryBlocks?: ReadonlySet<string>
30
32
  /** True once the user folded the lanes block, which is open at the start; absent or false while it is open. */
31
33
  lanesFolded?: boolean
32
34
  }
@@ -110,6 +112,26 @@ export const topBlockHeadKey = (block: TopBlockId): string => `${TOP_HEAD_KEY_PR
110
112
  export const topBlockOfKey = (key: string): TopBlockId | null =>
111
113
  Object.values(TOP_BLOCK).find((block) => topBlockHeadKey(block) === key) ?? null
112
114
 
115
+ const ENTRY_HEAD_KEY_PREFIX = `${TOP_HEAD_KEY_PREFIX}entries>`
116
+
117
+ /** The key of the head of the block of entries of a source, which the arrows reach like every other clickable row. */
118
+ export const entryBlockHeadKey = (source: string): string => `${ENTRY_HEAD_KEY_PREFIX}${source}`
119
+
120
+ /** The source whose block of entries a head key names, null for any other key. */
121
+ export const entryBlockOfKey = (key: string): string | null =>
122
+ key.startsWith(ENTRY_HEAD_KEY_PREFIX) ? key.slice(ENTRY_HEAD_KEY_PREFIX.length) : null
123
+
124
+ /** Whether the block of entries of `source` is drawn folded: it is open at the start, until the user folds it. */
125
+ export const isEntryBlockFolded = ({ foldedEntryBlocks }: Selection, source: string): boolean =>
126
+ foldedEntryBlocks?.has(source) === true
127
+
128
+ const withEntryBlockOpen = (selection: Selection, source: string, open: boolean): Selection => {
129
+ const folded = new Set(selection.foldedEntryBlocks)
130
+ if (open) folded.delete(source)
131
+ else folded.add(source)
132
+ return { ...selection, foldedEntryBlocks: folded }
133
+ }
134
+
113
135
  /** Whether the lanes block is drawn open: it is at the start of the herd, until the user folds it. */
114
136
  export const isLanesOpen = ({ lanesFolded }: Selection): boolean => !lanesFolded
115
137
 
@@ -274,6 +296,13 @@ const foldedHead = (selection: Selection, key: string, next: (open: boolean) =>
274
296
  const lane = laneOfKey(key)
275
297
  if (lane !== null) return { ...foldLane(selection, lane, next), selected: key }
276
298
  if (key === LANES_KEY) return { ...withLanesOpen(selection, next(isLanesOpen(selection))), selected: key }
299
+ const entrySource = entryBlockOfKey(key)
300
+ if (entrySource !== null) {
301
+ return {
302
+ ...withEntryBlockOpen(selection, entrySource, next(!isEntryBlockFolded(selection, entrySource))),
303
+ selected: key,
304
+ }
305
+ }
277
306
  const block = topBlockOfKey(key)
278
307
  if (block === null) return null
279
308
  return { ...withBlockOpen(selection, block, next(!isBlockFolded(selection, block))), selected: key }
@@ -4,6 +4,7 @@ import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
6
  import type { ToneName } from '../config/tones'
7
+ import { DEFAULT_MIN_FREE_GB } from '../lane/space'
7
8
  import type { TicketSource } from '../watch/plan-badges'
8
9
  import type { SourceShow } from '../watch/sources'
9
10
  import { type WatchedSource, watchedSourceOf } from '../watch/watched-source'
@@ -15,6 +16,8 @@ export type PlansSettings = Pick<
15
16
  'planDirectory' | 'board' | 'worktreeDirectory' | 'baseBranch' | 'planLanguage' | 'responseLanguage' | 'skillPrefix'
16
17
  > & {
17
18
  adapter: JackherdConfig['terminal']['adapter']
19
+ /** Free gigabytes a new lane needs on the disk (`lanes.minFreeGb`). */
20
+ minFreeGb: number
18
21
  fetchIntervalMinutes: number
19
22
  /** Whether the running task of a folded lane shimmers; `NO_COLOR` keeps it still whatever this says. */
20
23
  shimmer: boolean
@@ -58,6 +61,7 @@ export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
58
61
  responseLanguage: config.responseLanguage,
59
62
  skillPrefix: config.skillPrefix,
60
63
  adapter: config.terminal.adapter,
64
+ minFreeGb: config.lanes.minFreeGb ?? DEFAULT_MIN_FREE_GB,
61
65
  fetchIntervalMinutes: config.plans.fetchIntervalMinutes,
62
66
  shimmer: config.plans.shimmer,
63
67
  linkKinds: config.plans.linkKinds,
@@ -635,15 +635,20 @@ const decoratedViews = (
635
635
  ),
636
636
  )
637
637
 
638
- /** Each source with the tone and the label its config gives it. */
638
+ /** Each source with the tone, the label and the `show` its config gives it. */
639
639
  const withSourceStyle = (
640
640
  statuses: readonly SourceStatus[],
641
641
  { herdSources, sourceLabels }: HerdSourceSettings,
642
642
  ): HerdSource[] =>
643
643
  statuses.map((status) => {
644
644
  const label = sourceLabels[status.name]
645
- const tone = herdSources.find(({ name }) => name === status.name)?.tone ?? null
646
- return { ...status, tone, ...(label === undefined ? {} : { label }) }
645
+ const config = herdSources.find(({ name }) => name === status.name)
646
+ return {
647
+ ...status,
648
+ tone: config?.tone ?? null,
649
+ ...(label === undefined ? {} : { label }),
650
+ ...(config?.show === undefined ? {} : { show: config.show }),
651
+ }
647
652
  })
648
653
 
649
654
  /**
@@ -2,6 +2,7 @@ import { TONE, type ToneName } from '../config/tones'
2
2
  import { padVisible } from '../terminal/text'
3
3
  import { textWidth } from '../terminal/width'
4
4
  import type { EntryStatus, SourceStatus } from '../watch/source-statuses'
5
+ import { SOURCE_SHOW, type SourceShow } from '../watch/sources'
5
6
  import { ASK_KIND } from './ask-target'
6
7
  import type { AskedCheck } from './source-asked'
7
8
  import { chevronOf } from './symbols'
@@ -9,9 +10,10 @@ import type { BlockItem } from './waiting-click'
9
10
 
10
11
  /**
11
12
  * A source's status with the tone its config names for the mark while its report is new (null where it names none) and
12
- * the label its config gives it, absent where it gives none.
13
+ * the label its config gives it, absent where it gives none, and `show` where it asks for its entries in a block of
14
+ * their own.
13
15
  */
14
- export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string }
16
+ export type HerdSource = SourceStatus & { tone?: ToneName | null; label?: string; show?: SourceShow }
15
17
 
16
18
  /** One source in the line of the folded sources block: what it says and the tone it wears, none for dim. */
17
19
  export type SourceSummary = { text: string; tone?: ToneName }
@@ -179,6 +181,37 @@ const entryItems = (source: HerdSource, view: SourceView, now: number, newTone:
179
181
  ]
180
182
  }
181
183
 
184
+ /** Whether the source asks for its entries in a block of their own. */
185
+ export const showsEntries = (source: HerdSource): boolean => source.show === SOURCE_SHOW.entries
186
+
187
+ /** The sources that have a block of their own: those that ask for it and have entries; an empty run draws none. */
188
+ export const entryBlockSources = (sources: readonly HerdSource[]): HerdSource[] =>
189
+ sources.filter((source) => showsEntries(source) && source.entries.length > 0)
190
+
191
+ /** The source as the `Sources` block draws it: a source with a block of its own keeps its status, not its entries. */
192
+ export const statusOnly = (source: HerdSource): HerdSource =>
193
+ showsEntries(source) ? { ...source, entries: [], latest: null } : source
194
+
195
+ /**
196
+ * The rows of the block of a source that shows its entries: count, time and title as in the opened source, in the
197
+ * order the state holds them, in the tone of the source, none of them asking or expanding; a row with an address
198
+ * is linked. Only the first `limit` entries become rows, the columns measured over those.
199
+ */
200
+ export const entryBlockItems = (source: HerdSource, now: number, limit: number): BlockItem[] => {
201
+ const shown = source.entries.slice(0, limit)
202
+ const columns = columnsOf(shown, now)
203
+ return shown.map((entry) => ({
204
+ key: entryKey(source.name, entry),
205
+ mark: ENTRY_MARK,
206
+ lead: entryLeadOf(entry, now, columns),
207
+ text: entry.title,
208
+ url: entry.url,
209
+ ask: null,
210
+ expands: false,
211
+ ...(source.tone && { tone: source.tone }),
212
+ }))
213
+ }
214
+
182
215
  const DEFAULT_VIEW: SourceView = { expanded: new Set(), asked: () => false }
183
216
 
184
217
  /**
@@ -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
+ }