jackherd 1.16.0 → 1.18.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 (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +29 -33
  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 +16 -22
  40. package/kit/rules/rules/kit/code-style.md +26 -54
  41. package/kit/rules/rules/kit/pull-requests.md +17 -40
  42. package/kit/rules/rules/kit/testing.md +6 -12
  43. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  44. package/kit/workflow/skills/follow/SKILL.md +2 -3
  45. package/kit/workflow/skills/implement/SKILL.md +28 -43
  46. package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
  47. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  48. package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
  49. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  50. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  51. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  52. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  53. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  54. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  55. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  56. package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
  57. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  58. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  59. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  60. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  61. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  62. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  63. package/kit/workflow/skills/plan/SKILL.md +29 -48
  64. package/package.json +2 -1
  65. package/recipes/README.md +1 -0
  66. package/recipes/jira-cli.md +1 -1
  67. package/recipes/sources/google-calendar-today.ts +8 -2
  68. package/recipes/sources/jira.ts +10 -2
  69. package/recipes/sources/linear.ts +9 -3
  70. package/recipes/sources/sentry.ts +8 -2
  71. package/recipes/sources.md +4 -2
  72. package/schemas/jackherd.schema.json +67 -0
  73. package/schemas/sources.schema.json +9 -0
  74. package/src/approval/checked.ts +60 -0
  75. package/src/approval/command.ts +16 -0
  76. package/src/check/command.ts +51 -6
  77. package/src/check/lint-pointers.ts +79 -0
  78. package/src/check/lint-shaped.ts +196 -0
  79. package/src/check/plan-structure-check.ts +116 -4
  80. package/src/check/report.ts +9 -3
  81. package/src/check/schema.ts +37 -0
  82. package/src/check/settings.ts +4 -0
  83. package/src/check/skill-budget-defaults.ts +90 -32
  84. package/src/check/skill-budgets.ts +96 -18
  85. package/src/cli.ts +2 -0
  86. package/src/config/personal-sources.ts +46 -10
  87. package/src/config/repository-identity.ts +40 -0
  88. package/src/config/schema.ts +53 -1
  89. package/src/context/command.ts +33 -2
  90. package/src/context/options.ts +33 -8
  91. package/src/context/usage-logs.ts +53 -0
  92. package/src/context/usage.ts +110 -0
  93. package/src/lane/base-command.ts +35 -0
  94. package/src/lane/base.ts +43 -0
  95. package/src/lane/close-check.ts +49 -15
  96. package/src/lane/close-command.ts +71 -11
  97. package/src/lane/close-options.ts +29 -8
  98. package/src/lane/close-survey.ts +13 -1
  99. package/src/lane/close.ts +51 -12
  100. package/src/lane/command.ts +21 -3
  101. package/src/lane/send-command.ts +32 -0
  102. package/src/lane/send-herdr.ts +77 -0
  103. package/src/lane/send-options.ts +23 -0
  104. package/src/lane/send.ts +90 -0
  105. package/src/lane/skill-hint.ts +27 -0
  106. package/src/lane/space-command.ts +30 -0
  107. package/src/lane/space.ts +49 -0
  108. package/src/lane/start-lane-hint.ts +18 -0
  109. package/src/plan-contract/base-check-git.ts +33 -0
  110. package/src/plan-contract/base-check.ts +17 -0
  111. package/src/plan-contract/command.ts +25 -4
  112. package/src/plan-format/plan-structure.ts +17 -5
  113. package/src/plans/ask-coordinator.ts +23 -13
  114. package/src/plans/board-rows.ts +9 -4
  115. package/src/plans/click-hint.ts +4 -0
  116. package/src/plans/command.ts +6 -5
  117. package/src/plans/ensure-panes.ts +14 -3
  118. package/src/plans/git.ts +14 -0
  119. package/src/plans/github.ts +19 -10
  120. package/src/plans/layout.ts +82 -33
  121. package/src/plans/live.ts +8 -12
  122. package/src/plans/plan-panes.ts +21 -4
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/resume/command.ts +213 -0
  126. package/src/resume/decision.ts +102 -0
  127. package/src/resume/keeper.ts +65 -0
  128. package/src/resume/location.ts +22 -0
  129. package/src/resume/log.ts +27 -0
  130. package/src/resume/ports.ts +102 -0
  131. package/src/resume/record.ts +116 -0
  132. package/src/resume/targets.ts +84 -0
  133. package/src/resume/texts.ts +27 -0
  134. package/src/secrets/command.ts +15 -5
  135. package/src/sync/herdr-plugin.ts +2 -11
  136. package/src/sync/plan-ask-rules.ts +11 -0
  137. package/src/sync/render.ts +3 -1
  138. package/src/vocabulary/index.ts +13 -0
  139. package/src/waiting/command.ts +13 -4
  140. package/src/watch/command.ts +177 -78
  141. package/src/watch/config-reload.ts +119 -0
  142. package/src/watch/lane-model.ts +11 -1
  143. package/src/watch/lane-progress.ts +70 -8
  144. package/src/watch/lane-report.ts +66 -11
  145. package/src/watch/main-checkout.ts +5 -1
  146. package/src/watch/merge-duties.ts +33 -0
  147. package/src/watch/merge-overlap-read.ts +69 -0
  148. package/src/watch/plan-views.ts +16 -4
  149. package/src/watch/reference-paths.ts +46 -5
  150. package/src/watch/report-questions.ts +5 -2
  151. package/src/watch/single-report.ts +3 -2
  152. package/src/watch/source-dry-run-command.ts +22 -4
  153. package/src/watch/source-reason.ts +58 -0
  154. package/src/watch/source-reload.ts +21 -17
  155. package/src/watch/source-run.ts +24 -7
  156. package/src/watch/sources.ts +9 -2
  157. package/src/watch/texts.ts +14 -3
  158. package/src/watch/waiting-line.ts +4 -3
package/src/plans/live.ts CHANGED
@@ -47,10 +47,7 @@ import {
47
47
  parseMouse,
48
48
  splitKeys,
49
49
  syncLaneFolds,
50
- TOP_BLOCK,
51
- type TopBlockId,
52
50
  toggledExpansion,
53
- topBlockHeadKey,
54
51
  type View,
55
52
  windowTop,
56
53
  withoutStaleMotions,
@@ -527,18 +524,18 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
527
524
  }
528
525
 
529
526
  /**
530
- * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head of `block` folds or
531
- * opens it (a block of entries, `block` null, has no such head), a
527
+ * A plain click on a block on top of the herd, matched against `hit` as it was drawn: the head folds or
528
+ * opens its block, a
532
529
  * cut-off row expands, a file or Artifact opens, a button asks the coordinator or closes the row; the failure or the
533
530
  * result shows in the footer.
534
531
  */
535
- const clickBlock = (live: Live, block: TopBlockId | null, hit: BlockHit | null, input: string): boolean => {
532
+ const clickBlock = (live: Live, hit: BlockHit | null, input: string): boolean => {
536
533
  const mouse = parseMouse(input)
537
534
  if (!mouse || !hit) return false
538
- if (block !== null && blockHeadClick(hit.firstLine - 1, mouse)) {
535
+ if (hit.headKey && blockHeadClick(hit.firstLine - 1, mouse)) {
539
536
  live.view = {
540
537
  ...live.view,
541
- selection: applyClick(live.view.selection, topBlockHeadKey(block)),
538
+ selection: applyClick(live.view.selection, hit.headKey),
542
539
  }
543
540
  return true
544
541
  }
@@ -588,7 +585,6 @@ const handleInput = (live: Live, input: string) => {
588
585
  hover(live, screen, mouse)
589
586
  return
590
587
  }
591
- live.notice = null
592
588
  const settled = settledSelection(live.view.selection, screen)
593
589
  live.view = { ...live.view, selection: mouse ? settled : withoutHover(settled) }
594
590
  if (activateBlockRow(live, screen, input)) {
@@ -600,9 +596,9 @@ const handleInput = (live: Live, input: string) => {
600
596
  return
601
597
  }
602
598
  if (
603
- clickBlock(live, TOP_BLOCK.yourTurn, screen.yourTurnBlock, input) ||
604
- screen.entryBlocks.some((hit) => clickBlock(live, null, hit, input)) ||
605
- clickBlock(live, TOP_BLOCK.sources, screen.sourceBlock, input)
599
+ clickBlock(live, screen.yourTurnBlock, input) ||
600
+ screen.entryBlocks.some((hit) => clickBlock(live, hit, input)) ||
601
+ clickBlock(live, screen.sourceBlock, input)
606
602
  ) {
607
603
  redraw(live)
608
604
  return
@@ -35,6 +35,14 @@ export type PlanPaneResult = { actions: PaneAction[]; notes: string[] }
35
35
 
36
36
  type ListedPane = { pane_id: string; tab_id: string; cwd: string; agent?: unknown; label?: unknown }
37
37
 
38
+ /**
39
+ * The exit codes of `herd --ensure`: `noCoordinator` when the coordinator's pane is missing or ambiguous, so a
40
+ * caller that runs it after a restart knows to wait for the coordinator's session; 1 stays the error of the CLI.
41
+ */
42
+ export const ENSURE_EXIT = { done: 0, noCoordinator: 3 } as const
43
+
44
+ export type EnsureExit = (typeof ENSURE_EXIT)[keyof typeof ENSURE_EXIT]
45
+
38
46
  const isListedPane = (value: unknown): value is ListedPane => {
39
47
  if (typeof value !== 'object' || value === null) return false
40
48
  const candidate = value as Record<string, unknown>
@@ -124,9 +132,13 @@ export const isIdleShell = ({ agent, foreground }: PaneState): boolean =>
124
132
  const isStartingShell = ({ agent, foreground }: PaneState): boolean =>
125
133
  !agent && (foreground.length === 0 || SHELLS.has(programOf(foreground[0])))
126
134
 
135
+ const hasHerdBeside = (panes: readonly PaneState[], agent: PaneState): boolean =>
136
+ panes.some((each) => each.tab === agent.tab && each.label === herdLabel(null))
137
+
127
138
  /**
128
139
  * The coordinator's pane: the agent herdr lists under the coordinator's name, else the calling pane when an agent
129
- * in the main checkout runs there, else the only agent in the main checkout. Two agents there and no name: none.
140
+ * in the main checkout runs there, else the only agent in the main checkout, else the only one there whose tab holds
141
+ * a pane labelled `herd` (herdr keeps labels across a restart, agent names not always). Otherwise none.
130
142
  */
131
143
  export const coordinatorPaneOf = (
132
144
  panes: readonly PaneState[],
@@ -136,15 +148,20 @@ export const coordinatorPaneOf = (
136
148
  if (hints.named) return hints.named
137
149
  const inRoot = panes.filter((each) => each.agent && each.cwd === root)
138
150
  if (hints.caller && inRoot.some((each) => each.pane === hints.caller)) return hints.caller
139
- return inRoot.length === 1 ? (inRoot[0]?.pane ?? null) : null
151
+ const candidates = inRoot.length === 1 ? inRoot : inRoot.filter((agent) => hasHerdBeside(panes, agent))
152
+ return candidates.length === 1 ? (candidates[0]?.pane ?? null) : null
140
153
  }
141
154
 
142
- /** Why no coordinator pane was found: no agent in the main checkout, or several and none of them named. */
155
+ /** The exit code of `herd --ensure` for the coordinator pane it found among `panes`, or none. */
156
+ export const ensureExitOf = (panes: readonly PaneState[], coordinator: string | null): EnsureExit =>
157
+ panes.some((each) => each.pane === coordinator) ? ENSURE_EXIT.done : ENSURE_EXIT.noCoordinator
158
+
159
+ /** Why no coordinator pane was found: no agent in the main checkout, or several, none named and none alone beside a herd pane. */
143
160
  export const missingCoordinatorNote = (panes: readonly PaneState[], root: string, name: string): string => {
144
161
  const inRoot = panes.filter((each) => each.agent && each.cwd === root).length
145
162
  return inRoot === 0
146
163
  ? 'coordinator: no agent in the main checkout'
147
- : `coordinator: ${inRoot} agents in the main checkout and none named ${name}; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
164
+ : `coordinator: ${inRoot} agents in the main checkout, none named ${name} and none alone beside a herd pane; run herd --ensure in its pane, or herdr agent rename <pane> ${name}`
148
165
  }
149
166
 
150
167
  const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`)
@@ -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,
@@ -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
+ }