jackherd 1.7.0 → 1.8.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 (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -5,10 +5,10 @@
5
5
  * lane's new head commit (only with `watch.previews`), a new or changed entry of a configured source or its failure (only
6
6
  * with `watch.sources`), a gh outage, a lane's or the coordinator's own context fill rising to warn or alert, or the
7
7
  * heartbeat. A blocked lane adds one duty line, a merge one per lane, a lane at alert one, and every report ends with one status line
8
- * per lane and one for the coordinator, in the words of `responseLanguage`. When the installed kit changes version
8
+ * per lane and one for the coordinator, in English. When the installed kit changes version
9
9
  * it prints `=== kit updated <old> → <new>` and ends; once a day it asks npm and prints `=== update available <old> → <new>`
10
10
  * for a newer release, once per version. The coordinator is the agent in the
11
- * watcher's own herdr pane unless `--coordinator` names it, else `orchestrator-<repository>`. A new start replaces a running watcher of the same
11
+ * watcher's own herdr pane unless `--coordinator` names it, else `jack-<repository>` (or the old `orchestrator-<repository>`). A new start replaces a running watcher of the same
12
12
  * repository; one in another repository keeps running.
13
13
  *
14
14
  * Without `--stream` it exits after one report, whose last line says so and points to the stream: that run is the
@@ -31,7 +31,7 @@ import { homedir } from 'node:os'
31
31
  import { basename, dirname, join, relative } from 'node:path'
32
32
  import { readResolvedConfig } from '../config/read-config'
33
33
  import type { JackherdConfig } from '../config/schema'
34
- import { defaultCoordinatorName } from '../context/coordinator-name'
34
+ import { resolveCoordinatorName } from '../context/coordinator-name'
35
35
  import { coordinatorSettings } from '../context/options'
36
36
  import { contextReport } from '../context/report'
37
37
  import { agentLog } from '../context/session-log'
@@ -42,12 +42,19 @@ import { ensurePlanPanes } from '../plans/ensure-panes'
42
42
  import { errorText } from '../plans/error-text'
43
43
  import { mainCheckout, worktreeNames, worktreeRoot } from '../plans/git'
44
44
  import { discoverLanes, isUnchanged, laneSetChange, laneSetLines } from '../plans/lane-discovery'
45
- import { clockLocaleOf, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
45
+ import { CLOCK_LOCALE, type PlansSettings, plansSettingsOf, worktreeOf } from '../plans/settings'
46
46
  import { boardTexts } from '../plans/texts'
47
47
  import { defaultUpdateCheck } from '../sync/if-changed'
48
48
  import { herdrLaneStatusSource } from '../terminal/herdr'
49
49
  import { AGENT_STATUS } from '../terminal/types'
50
50
  import { startedKit } from '../version/started-kit'
51
+ import {
52
+ type AppointmentTable,
53
+ appointmentEvents,
54
+ isAppointmentEvent,
55
+ readAppointmentTable,
56
+ recordAppointments,
57
+ } from './appointments'
51
58
  import { type ApprovalRequestFile, approvalEvents, readApprovalRequests, recordApprovals } from './approvals'
52
59
  import {
53
60
  type CoordinatorRead,
@@ -81,6 +88,7 @@ import { type LaneModelReading, laneModelEvents, recordLaneModels } from './lane
81
88
  import { laneParallelEvents, recordLaneParallels } from './lane-parallel'
82
89
  import { boardCheckoutRows, watchedLaneParallel, watchedLaneProgress } from './lane-progress'
83
90
  import {
91
+ appointmentEventLine,
84
92
  approvalDutyLines,
85
93
  armLine,
86
94
  blockedDutyLines,
@@ -140,7 +148,7 @@ import { runSource, SOURCE_LIMITS } from './source-run'
140
148
  import { recordWatchedSources } from './source-tickets'
141
149
  import { isSourceDue, type SourceRun, type SourceSettings, type SourceState, sourceEvents } from './sources'
142
150
  import { streamHeartbeatLine } from './stream-output'
143
- import { WATCH_TEXTS, type WatchTexts } from './texts'
151
+ import { type WatchTexts, watchTexts } from './texts'
144
152
  import { updateAnnouncer } from './update-available'
145
153
  import {
146
154
  coordinatorWatchEvent,
@@ -254,6 +262,8 @@ type Watching = {
254
262
  laneParallelReadings: Record<string, string[]>
255
263
  /** The approval requests of the last poll; null before the first read, so the stored ids stay. */
256
264
  approvalRequests: ApprovalRequestFile[] | null
265
+ /** The board's appointment table at the last poll and the time it was read at; null before the first read and while the board cannot be read. */
266
+ appointments: { table: AppointmentTable; readAt: number } | null
257
267
  planViews: PlanViews
258
268
  }
259
269
 
@@ -262,8 +272,7 @@ const GITHUB_POLL_MS = 60_000
262
272
  const MINUTE_MS = 60_000
263
273
  const USAGE_EXIT = 2
264
274
 
265
- const clock = (watch: Watch, epochMs: number): string =>
266
- new Date(epochMs).toLocaleTimeString(clockLocaleOf(watch.settings))
275
+ const clock = (epochMs: number): string => new Date(epochMs).toLocaleTimeString(CLOCK_LOCALE)
267
276
 
268
277
  const agentNamesOf = (agents: readonly HerdrAgent[]): string[] => agents.flatMap((agent) => agent.name ?? [])
269
278
 
@@ -292,6 +301,9 @@ const recordedOf = (watch: Watch, watching: Watching): WatchState => {
292
301
  laneParallels: recordLaneParallels(withReads.laneParallels, watching.laneParallelReadings, watching.lanes),
293
302
  approvals: watching.approvalRequests ? recordApprovals(watching.approvalRequests) : withReads.approvals,
294
303
  sources: recordWatchedSources(withReads.sources, watching.sourceRuns, watch.sources, watching.githubReadAt),
304
+ appointments: watching.appointments
305
+ ? recordAppointments(withReads.appointments, watching.appointments.table, watching.appointments.readAt)
306
+ : withReads.appointments,
295
307
  }
296
308
  return withReportBaselines(withLevels, watching.lanes, (lane) => reportMtime(reportPathOf(watch, lane)))
297
309
  }
@@ -337,11 +349,14 @@ const stopMessageOf = (watch: Watch, lane: string, status: string, state: WatchS
337
349
  reportedMtime: state.reportMtimes[lane] ?? null,
338
350
  })
339
351
 
340
- /** `--coordinator` if given, else the agent in the watcher's own pane, else `orchestrator-<repository>`. */
352
+ /**
353
+ * `--coordinator` if given, else the agent in the watcher's own pane, else `jack-<repository>`, or the old
354
+ * `orchestrator-<repository>` while only that one runs.
355
+ */
341
356
  const coordinatorNameFor = (watch: Watch, agents: readonly HerdrAgent[]): string =>
342
357
  watch.options.coordinator ??
343
358
  coordinatorNameOf(agents, process.env.HERDR_PANE_ID) ??
344
- defaultCoordinatorName(basename(watch.root))
359
+ resolveCoordinatorName(basename(watch.root), agentNamesOf(agents))
345
360
 
346
361
  /**
347
362
  * One read of the fills: the coordinator's, and each watched lane's (null without a value), with `models`, lane → the
@@ -402,6 +417,10 @@ const eventLinesOf = async (
402
417
  lines.push(...sourceEventLines(event))
403
418
  continue
404
419
  }
420
+ if (isAppointmentEvent(event)) {
421
+ lines.push(appointmentEventLine(event))
422
+ continue
423
+ }
405
424
  const headSha = event.kind === WATCH_EVENT.deploy ? runs.find((run) => run.id === event.runId)?.headSha : undefined
406
425
  const target =
407
426
  event.kind === WATCH_EVENT.deploy && headSha && watch.deploy
@@ -483,20 +502,20 @@ const statusLinesOf = async (
483
502
  const laneLines = watching.lanes.map((lane) =>
484
503
  laneReportLine(laneReportOf(lane, watching, state, pullRequests, progress[lane] ?? null), watch.texts),
485
504
  )
486
- const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(watch, state.ghDownSince))]
505
+ const outage = state.ghDownSince === null ? [] : [ghUnavailableStatusLine(clock(state.ghDownSince))]
487
506
  const board = { term: watch.settings.board.term, checkoutRows }
488
507
  const coordinator = coordinatorStatusLine(watching.coordinator.name, watching.coordinator.read, watch.texts, board)
489
508
  return {
490
509
  laneLines,
491
510
  coordinator,
492
511
  others: others === null ? [] : [others],
493
- rest: [...notes, ...outage, ...failingSourceLines(watch, state.sources)],
512
+ rest: [...notes, ...outage, ...failingSourceLines(state.sources)],
494
513
  }
495
514
  }
496
515
 
497
- const failingSourceLines = (watch: Watch, sources: SourceState): string[] =>
516
+ const failingSourceLines = (sources: SourceState): string[] =>
498
517
  Object.entries(sources).flatMap(([name, { failingSince }]) =>
499
- failingSince === null ? [] : [sourceFailingStatusLine(name, clock(watch, failingSince))],
518
+ failingSince === null ? [] : [sourceFailingStatusLine(name, clock(failingSince))],
500
519
  )
501
520
 
502
521
  /** The section of each stopped lane (its header and message) and the report times it leaves behind. */
@@ -775,15 +794,31 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
775
794
  ]
776
795
  }
777
796
 
778
- /** The board's lanes, null when the board cannot be read. */
779
- const readSprintLanes = (watch: Watch): SprintLane[] | null => {
797
+ /** The board's text, null when it cannot be read; parsing it is the caller's, so a parser error is not taken for an unreadable board. */
798
+ const readBoardText = (watch: Watch): string | null => {
780
799
  try {
781
- return parseSprintLanes(readFileSync(watch.sprintFile, 'utf8'), watch.settings.planLanguage)
800
+ return readFileSync(watch.sprintFile, 'utf8')
782
801
  } catch {
783
802
  return null
784
803
  }
785
804
  }
786
805
 
806
+ /**
807
+ * The board's appointment table at `now`, in the plan language, and when it was read; null when the board cannot be
808
+ * read, so the reported appointments stay as they were.
809
+ */
810
+ const readAppointments = (watch: Watch, now: number): Watching['appointments'] => {
811
+ const board = readBoardText(watch)
812
+ if (board === null) return null
813
+ return { table: readAppointmentTable(board, watch.settings.planLanguage, new Date(now)), readAt: now }
814
+ }
815
+
816
+ /** The board's lanes, null when the board cannot be read. */
817
+ const readSprintLanes = (watch: Watch): SprintLane[] | null => {
818
+ const board = readBoardText(watch)
819
+ return board === null ? null : parseSprintLanes(board, watch.settings.planLanguage)
820
+ }
821
+
787
822
  /** The sprint's lane names; the previous ones when the sprint cannot be read, so no lane drops out over it. */
788
823
  const sprintLaneNames = (watch: Watch, previous: string[]): string[] =>
789
824
  readSprintLanes(watch)?.map((lane) => lane.name) ?? previous
@@ -872,7 +907,7 @@ const printStreamHeartbeat = async (watch: Watch, watching: Watching, now: numbe
872
907
  const counts = currentPhaseCountsOf(progress)
873
908
  const notes = progressNotes(watching.lanes, watching.current, counts, watching.heartbeatCounts)
874
909
  watching.heartbeatCounts = { ...watching.heartbeatCounts, ...counts }
875
- console.log(streamHeartbeatLine(clock(watch, now), watching.lanes, watching.current, notes))
910
+ console.log(streamHeartbeatLine(clock(now), watching.lanes, watching.current, notes))
876
911
  }
877
912
 
878
913
  /**
@@ -889,7 +924,14 @@ const pollOnce = async (watch: Watch, watching: Watching): Promise<boolean> => {
889
924
  // read at every poll, not with GitHub: a lane waits for the answer only minutes; after the await, so a state saved
890
925
  // during the GitHub read records no request as seen before it was reported
891
926
  watching.approvalRequests = readApprovalRequests(watch.root)
892
- const polledEvents = [...githubEvents, ...approvalEvents(watching.state.approvals, watching.approvalRequests, now)]
927
+ // read at every poll like the approvals; the events and the record use the same read and time, so an appointment
928
+ // that falls due between the two is neither recorded unreported nor reported twice
929
+ watching.appointments = readAppointments(watch, now)
930
+ const polledEvents = [
931
+ ...githubEvents,
932
+ ...approvalEvents(watching.state.approvals, watching.approvalRequests, now),
933
+ ...appointmentEvents(watching.state.appointments, watching.appointments?.table ?? null, now),
934
+ ]
893
935
  const stopped = [...firedLanes(watching.baseline, watching.current, watching.lanes), ...watching.departed]
894
936
  watching.departed = []
895
937
  const heartbeat = isHeartbeatDue(watching.quietSince, now, heartbeatMinutes * MINUTE_MS)
@@ -942,6 +984,7 @@ const initialWatching = (
942
984
  laneModelReadings: {},
943
985
  laneParallelReadings: {},
944
986
  approvalRequests: null,
987
+ appointments: null,
945
988
  planViews: { failureReported: false },
946
989
  })
947
990
 
@@ -1007,7 +1050,7 @@ const watchOf = (options: WatchOptions): Watch => {
1007
1050
  checks: config.watch.checks,
1008
1051
  previews: config.watch.previews,
1009
1052
  sources: config.watch.sources,
1010
- texts: WATCH_TEXTS[settings.responseLanguage],
1053
+ texts: watchTexts(settings.responseLanguage),
1011
1054
  herdr: insideHerdr(settings.adapter, process.env),
1012
1055
  }
1013
1056
  }
@@ -3,6 +3,7 @@ import { currentPhase, isTickedUnmerged, type LaneProgress, phasesText } from '.
3
3
  import { terminalSafe } from '../plan-format/terminal-text'
4
4
  import { CHECK_NAME_LENGTH, type ChecksState } from '../plans/pull-requests'
5
5
  import { shownWithMore } from '../plans/shown-with-more'
6
+ import type { AppointmentEvent } from './appointments'
6
7
  import type { ApprovalEvent } from './approvals'
7
8
  import type { CoordinatorRead } from './coordinator-fill'
8
9
  import { type FeedbackEvent, firstLine } from './feedback'
@@ -74,8 +75,8 @@ const contextSegment = (level: ContextLevel | null): string[] =>
74
75
  level === null || level === CONTEXT_LEVEL.ok ? [] : [`context ${level}`]
75
76
 
76
77
  /**
77
- * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|kein Branch ·
78
- * PR #<n> <checks>|kein PR · offene Phase …|Phase … abgehakt, nicht gemergt|Plan fertig|kein Plan` in German.
78
+ * One line per lane, in the words of `texts`: `<lane> <status> [· context warn|alert] · <branch>|no branch ·
79
+ * PR #<n> <checks>|no PR · open phase …|phase … ticked, not merged|plan finished|no plan`.
79
80
  */
80
81
  export const laneReportLine = (report: LaneReport, texts: WatchTexts): string =>
81
82
  [
@@ -158,6 +159,13 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
158
159
  return `=== ${event.kind}`
159
160
  }
160
161
 
162
+ /**
163
+ * The line of an appointment event: `=== due <when> · <action>` for one that fell due, `=== appointment unreadable
164
+ * <when> · <action>` for an open row whose time cell could not be read. Both texts come from the board as written.
165
+ */
166
+ export const appointmentEventLine = (event: AppointmentEvent): string =>
167
+ `=== ${event.kind} ${terminalSafe(event.when)} · ${terminalSafe(event.action).slice(0, REPORT_TEXT_LENGTH)}`
168
+
161
169
  /**
162
170
  * The lines of a feedback event: `=== feedback #<number> (lane) <author> <label> · <k> new`, then one line per entry,
163
171
  * `<path>:<line> <first line>` or `<first line>`, cut to a length and to `FEEDBACK_ENTRY_LIMIT` entries.
@@ -230,7 +238,7 @@ const coordinatorValue = (name: string | null, read: CoordinatorRead | null, tex
230
238
  }
231
239
 
232
240
  /**
233
- * `coordinator <name> 62 % warn`, `coordinator <name> kein Wert (<reason>)` or `coordinator kein Name` in German;
241
+ * `coordinator <name> 62 % warn`, `coordinator <name> no value (<reason>)` or `coordinator no name`;
234
242
  * while the board has rows only in the checkout, `· board: 2 rows only in the checkout` follows.
235
243
  */
236
244
  export const coordinatorStatusLine = (
@@ -1,6 +1,6 @@
1
- import { APPROVAL_TEXTS, PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
1
+ import { APPROVAL_TEXTS, type PlanLanguage } from '../vocabulary'
2
2
 
3
- /** The words of the watcher's status and duty lines, in the project's response language. */
3
+ /** The words of the watcher's status and duty lines. */
4
4
  export type WatchTexts = {
5
5
  noBranch: string
6
6
  noPullRequest: string
@@ -37,60 +37,36 @@ export type WatchTexts = {
37
37
  /** The mark the approval gate hook reads from the question, as its `requestMark` writes it (contract test). */
38
38
  const approvalMark = (id: string, hash: string): string => `\`approval ${id} ${hash}\``
39
39
 
40
- export const WATCH_TEXTS: Record<PlanLanguage, WatchTexts> = {
41
- [PLAN_LANGUAGE.de]: {
42
- noBranch: 'kein Branch',
43
- noPullRequest: 'kein PR',
44
- openPhase: 'offene Phase',
45
- tickedUnmerged: 'abgehakt, nicht gemergt',
46
- phase: 'Phase',
47
- startsAfter: 'Startet nach',
48
- planFinished: 'Plan fertig',
49
- noPlan: 'kein Plan',
50
- noName: 'kein Name',
51
- noValue: 'kein Wert',
52
- unnamedPane: (name) => `Pane ohne Namen, herdr agent rename "$HERDR_PANE_ID" ${name}`,
53
- noAgentNamed: (name) => `kein Agent namens ${name}`,
54
- afterMerge: (number) => `nach Merge #${number}`,
55
- mergedLaneDuty: 'nächste Phase starten oder Spur schließen',
56
- otherLaneDuty: 'rebasen, dann nächste Phase starten oder Grund nennen',
57
- blockedDuty:
58
- 'Frage gegen Plan und Regeln prüfen, in der Spur beantworten oder per Dialog an den User; nie das Pane weiterreichen',
59
- laneAlertDuty: 'Übergabe an der nächsten Phasengrenze anfordern (§ 8)',
60
- approvalDuty: (id, hash) =>
61
- `per AskUserQuestion fragen, die Frage enthält ${approvalMark(id, hash)} (Hash wie in der gezeigten Datei), Optionen „${APPROVAL_TEXTS.de.allow}“ und „${APPROVAL_TEXTS.de.deny}“ (§ 4)`,
62
- parallelDuty: (phases) =>
63
- `${phases} parallel möglich: dem User vorschlagen, Inhalt und Release-Dateien selbst prüfen (Tabelle prüft nur Pfade und Marker)`,
64
- laneModelDuty: 'idle umstellen: /exit, dann claude --resume <session> --model <id> im selben Pane (§ 2 Schritt 6)',
65
- checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'Zeile' : 'Zeilen'} nur im Checkout`,
66
- otherPullRequests: 'andere PRs',
67
- },
68
- [PLAN_LANGUAGE.en]: {
69
- noBranch: 'no branch',
70
- noPullRequest: 'no PR',
71
- openPhase: 'open phase',
72
- tickedUnmerged: 'ticked, not merged',
73
- phase: 'phase',
74
- startsAfter: 'starts after',
75
- planFinished: 'plan finished',
76
- noPlan: 'no plan',
77
- noName: 'no name',
78
- noValue: 'no value',
79
- unnamedPane: (name) => `pane without a name, herdr agent rename "$HERDR_PANE_ID" ${name}`,
80
- noAgentNamed: (name) => `no agent named ${name}`,
81
- afterMerge: (number) => `after merge #${number}`,
82
- mergedLaneDuty: 'start the next phase or close the lane',
83
- otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
84
- blockedDuty:
85
- 'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
86
- laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
87
- approvalDuty: (id, hash) =>
88
- `ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS.en.allow}" and "${APPROVAL_TEXTS.en.deny}" (§ 4)`,
89
- parallelDuty: (phases) =>
90
- `${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
91
- laneModelDuty:
92
- 'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
93
- checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
94
- otherPullRequests: 'other PRs',
95
- },
96
- }
40
+ /**
41
+ * The watcher's words: English whatever the project's languages. Only the two option labels in the approval duty follow
42
+ * `dialogLanguage` (the config's `responseLanguage`), because the coordinator asks the user with them and the approval
43
+ * gate hook matches the answer against exactly those labels, as `jackherd sync` renders them into it.
44
+ */
45
+ export const watchTexts = (dialogLanguage: PlanLanguage): WatchTexts => ({
46
+ noBranch: 'no branch',
47
+ noPullRequest: 'no PR',
48
+ openPhase: 'open phase',
49
+ tickedUnmerged: 'ticked, not merged',
50
+ phase: 'phase',
51
+ startsAfter: 'starts after',
52
+ planFinished: 'plan finished',
53
+ noPlan: 'no plan',
54
+ noName: 'no name',
55
+ noValue: 'no value',
56
+ unnamedPane: (name) => `pane without a name, herdr agent rename "$HERDR_PANE_ID" ${name}`,
57
+ noAgentNamed: (name) => `no agent named ${name}`,
58
+ afterMerge: (number) => `after merge #${number}`,
59
+ mergedLaneDuty: 'start the next phase or close the lane',
60
+ otherLaneDuty: 'rebase, then start the next phase or name the reason to wait',
61
+ blockedDuty:
62
+ 'vet the question against the plan and the rules, answer it in the lane or ask the user in a dialog; never hand the pane over',
63
+ laneAlertDuty: 'request the hand-over at the next phase boundary (§ 8)',
64
+ parallelDuty: (phases) =>
65
+ `${phases} can run in parallel: propose it to the user, check content and release files yourself (the table covers paths and markers only)`,
66
+ laneModelDuty:
67
+ 'switch it while idle: /exit, then claude --resume <session> --model <id> in the same pane (§ 2 step 6)',
68
+ checkoutRows: (term, count) => `${term}: ${count} ${count === 1 ? 'row' : 'rows'} only in the checkout`,
69
+ otherPullRequests: 'other PRs',
70
+ approvalDuty: (id, hash) =>
71
+ `ask through AskUserQuestion, the question contains ${approvalMark(id, hash)} (hash as in the file shown), options "${APPROVAL_TEXTS[dialogLanguage].allow}" and "${APPROVAL_TEXTS[dialogLanguage].deny}" (§ 4)`,
72
+ })
@@ -1,4 +1,11 @@
1
1
  import { CHECKS, type ChecksState } from '../plans/pull-requests'
2
+ import {
3
+ APPOINTMENT_EVENT,
4
+ type AppointmentEvent,
5
+ type AppointmentState,
6
+ emptyAppointmentState,
7
+ isAppointmentState,
8
+ } from './appointments'
2
9
  import { APPROVAL_EVENT, type ApprovalEvent } from './approvals'
3
10
  import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
4
11
  import { emptyFailureState, type FailureState, failedTestsOf, isFailureState } from './failures'
@@ -29,6 +36,8 @@ export const WATCH_EVENT = {
29
36
  laneParallel: LANE_PARALLEL_EVENT,
30
37
  approval: APPROVAL_EVENT,
31
38
  source: SOURCE_EVENT,
39
+ due: APPOINTMENT_EVENT.due,
40
+ appointmentUnreadable: APPOINTMENT_EVENT.unreadable,
32
41
  } as const
33
42
 
34
43
  export type PullRequestObservation = Omit<WatchedPullRequest, 'number' | 'branch' | 'failedRuns'>
@@ -85,6 +94,8 @@ export type WatchState = {
85
94
  reportMtimes: Record<string, number | null>
86
95
  /** The lane lines of the status block last printed; null before the first report. */
87
96
  statusLines: string[] | null
97
+ /** The board's appointments reported as due and its unreadable appointment rows reported, each once (`appointments.ts`). */
98
+ appointments: AppointmentState
88
99
  }
89
100
 
90
101
  /** The kinds of the events a pull request fires. */
@@ -130,6 +141,7 @@ export type WatchEvent =
130
141
  | LaneParallelEvent
131
142
  | ApprovalEvent
132
143
  | SourceEvent
144
+ | AppointmentEvent
133
145
 
134
146
  export const isPullRequestEvent = (event: WatchEvent): event is PullRequestEvent =>
135
147
  (PULL_REQUEST_KINDS as readonly string[]).includes(event.kind)
@@ -165,8 +177,12 @@ export const emptyWatchState = (): WatchState => ({
165
177
  sources: emptySourceState(),
166
178
  reportMtimes: {},
167
179
  statusLines: null,
180
+ appointments: emptyAppointmentState(),
168
181
  })
169
182
 
183
+ /** The fields every state file carries since the first one; each field added later is read as empty when missing. */
184
+ const FIRST_FORMAT_FIELDS = ['seenSeqs', 'pullRequests', 'runs', 'ghDownSince'] as const
185
+
170
186
  const isOneOf = (values: Record<string, string>, value: unknown): boolean =>
171
187
  typeof value === 'string' && Object.values(values).includes(value)
172
188
 
@@ -207,7 +223,8 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
207
223
  isSourceState(value.sources) &&
208
224
  everyValue(value.reportMtimes, isSeq) &&
209
225
  (value.statusLines === null ||
210
- (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string')))
226
+ (Array.isArray(value.statusLines) && value.statusLines.every((line) => typeof line === 'string'))) &&
227
+ isAppointmentState(value.appointments)
211
228
 
212
229
  /**
213
230
  * Reads the state file; throws on any other shape, so a damaged file is seen, not read as empty. The
@@ -217,22 +234,8 @@ export const parseWatchState = (json: string): WatchState => {
217
234
  const value: unknown = JSON.parse(json)
218
235
  if (!isRecord(value)) throw new Error('watch state: unexpected shape')
219
236
  if (isRecord(value.seenSeqs)) {
220
- const complete = {
221
- savedAt: null,
222
- laneBranches: {},
223
- coordinatorLevel: null,
224
- feedback: emptyFeedbackState(),
225
- failures: emptyFailureState(),
226
- previews: emptyPreviewState(),
227
- laneLevels: {},
228
- laneModels: {},
229
- laneParallels: {},
230
- approvals: [],
231
- sources: emptySourceState(),
232
- reportMtimes: {},
233
- statusLines: null,
234
- ...value,
235
- }
237
+ if (!FIRST_FORMAT_FIELDS.every((field) => field in value)) throw new Error('watch state: unexpected shape')
238
+ const complete = { ...emptyWatchState(), ...value }
236
239
  if (!isWatchState(complete)) throw new Error('watch state: unexpected shape')
237
240
  return complete
238
241
  }
@@ -289,23 +292,13 @@ export const recordWatchState = (previous: WatchState, observation: Observation,
289
292
  return { ...previous, seenSeqs: seenSeqsOf(lanes), ghDownSince: previous.ghDownSince ?? now, laneBranches }
290
293
  }
291
294
  return {
295
+ ...previous,
292
296
  seenSeqs: seenSeqsOf(lanes),
293
297
  pullRequests: recordsOf(previous, github.pullRequests, github.own ?? []),
294
298
  runs: runsOf(github.runs),
295
299
  ghDownSince: null,
296
300
  savedAt: now,
297
301
  laneBranches: keptLaneBranches(previous, observation, github.pullRequests),
298
- coordinatorLevel: previous.coordinatorLevel,
299
- feedback: previous.feedback,
300
- failures: previous.failures,
301
- previews: previous.previews,
302
- laneLevels: previous.laneLevels,
303
- laneModels: previous.laneModels,
304
- laneParallels: previous.laneParallels,
305
- approvals: previous.approvals,
306
- sources: previous.sources,
307
- reportMtimes: previous.reportMtimes,
308
- statusLines: previous.statusLines,
309
302
  }
310
303
  }
311
304
 
@@ -1,14 +0,0 @@
1
- import { isAbsolute, relative, resolve, sep } from 'node:path'
2
-
3
- /** herdr's agent names; a worktree whose directory has another name cannot be a lane. */
4
- const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
5
-
6
- export type LaneLocation = { cwd: string; main: string; worktreeDirectory: string }
7
-
8
- /** The lane whose worktree holds `cwd`: the first directory below the main checkout's worktree directory, else null. */
9
- export const laneOf = ({ cwd, main, worktreeDirectory }: LaneLocation): string | null => {
10
- const path = relative(resolve(main, worktreeDirectory), resolve(cwd))
11
- if (path === '' || path.startsWith('..') || isAbsolute(path)) return null
12
- const [lane] = path.split(sep)
13
- return LANE_NAME.test(lane) ? lane : null
14
- }