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
package/src/plans/live.ts CHANGED
@@ -16,8 +16,10 @@ import {
16
16
  } from './fetch'
17
17
  import { fetchHeadPath } from './git'
18
18
  import { fetchPullRequests, noPullRequests, type PullRequestState } from './github'
19
+ import { recallLanes, rememberLanes } from './herd-memory'
19
20
  import type { Screen } from './layout'
20
21
  import { type KnownPage, pageAfterWrite, pageKey, pageNeedsWriting, planAnchorOn, withPlanChips } from './plan-chips'
22
+ import { herdLabel } from './plan-panes'
21
23
  import { pullRequestUrl } from './pull-requests'
22
24
  import {
23
25
  inputOutcome,
@@ -29,7 +31,7 @@ import {
29
31
  type View,
30
32
  windowTop,
31
33
  } from './selection'
32
- import { BASE_REMOTE, baseRefOf, boardRepoPathOf, clockLocaleOf } from './settings'
34
+ import { BASE_REMOTE, baseRefOf, boardRepoPathOf, CLOCK_LOCALE } from './settings'
33
35
  import { singleFlight } from './single-flight'
34
36
  import { loadSnapshot, type Snapshot, type Source } from './snapshot'
35
37
  import { exportSprintPage, writeSprintPageInSteps } from './sprint-export'
@@ -51,7 +53,7 @@ type Live = {
51
53
  /** This clone's `FETCH_HEAD`, watched beside the plan files; null when git names none. */
52
54
  fetchHead: string | null
53
55
  reported: Map<string, string | null>
54
- /** Per lane, the ticks seen at the last read and the phase that moved last; the herd's start empties it. */
56
+ /** Per lane, the ticks seen at the last read, the phase that moved last and when each moved; kept across herds (`rememberLanes`). */
55
57
  memories: Map<string, LaneMemory>
56
58
  pending: Timer | null
57
59
  /** The snapshot as read, without the plan chips; `snapshot` is this plus the chips of `page`. */
@@ -133,7 +135,7 @@ const redraw = (live: Live) => {
133
135
  live.view.selection,
134
136
  [...live.errors, ...(live.pageError ? [live.pageError] : []), ...(live.notice ? [live.notice] : [])],
135
137
  paneWidth(),
136
- clockLocaleOf(live.source.settings),
138
+ CLOCK_LOCALE,
137
139
  )
138
140
  const top = windowTop(live.view, screen.body.cursorLine, screen.body.lines.length, bodyRows(screen))
139
141
  live.screen = screen
@@ -189,6 +191,7 @@ const refresh = (live: Live, onChange: () => void): Promise<void> => {
189
191
  const readOnce = async (live: Live, onChange: () => void) => {
190
192
  try {
191
193
  const read = await loadSnapshot(live.source, live.pullRequests, live.memories)
194
+ rememberLanes(live.source.root, live.memories, read.documents.laneOrder)
192
195
  const next = withKnownPage(live, read)
193
196
  live.read = read
194
197
  if (!live.snapshot) live.view = { ...live.view, selection: startSelection(next, live.source.options) }
@@ -198,7 +201,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
198
201
  )
199
202
  live.view = { ...live.view, selection: folded }
200
203
  live.snapshot = next
201
- const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), clockLocaleOf(live.source.settings))
204
+ const fetchLine = fetchNotice(live.fetch, baseRefOf(live.source.settings), CLOCK_LOCALE)
202
205
  live.errors = [
203
206
  ...next.errors,
204
207
  ...(fetchLine ? [fetchLine] : []),
@@ -206,7 +209,7 @@ const readOnce = async (live: Live, onChange: () => void) => {
206
209
  ]
207
210
  syncWatchers(live.watchers, watchedFiles(next.files, live.fetchHead), onChange)
208
211
  } catch (error) {
209
- const at = clockOf(clockLocaleOf(live.source.settings))
212
+ const at = clockOf(CLOCK_LOCALE)
210
213
  live.errors = [`refresh failed at ${at}, showing the last good state: ${errorText(error)}`]
211
214
  }
212
215
  redraw(live)
@@ -280,7 +283,7 @@ export const runLive = (source: Source): void => {
280
283
  watchers: new Map(),
281
284
  fetchHead: fetchHeadPath(source.root),
282
285
  reported: new Map(),
283
- memories: new Map(),
286
+ memories: recallLanes(source.root),
284
287
  pending: null,
285
288
  read: null,
286
289
  page: null,
@@ -323,12 +326,14 @@ export const runLive = (source: Source): void => {
323
326
  }
324
327
  const quit = () => {
325
328
  clearTitles(source, live.reported)
329
+ source.terminal.labelOwnPane(null)
326
330
  for (const watcher of live.watchers.values()) watcher.close()
327
331
  process.stdout.write(TERMINAL_OFF)
328
332
  process.exit(0)
329
333
  }
330
334
  const onInput = (input: string) => (QUIT_KEYS.has(input) ? quit() : handleInput(live, input))
331
335
 
336
+ source.terminal.labelOwnPane(herdLabel(source.options.lane))
332
337
  enterTerminal(onInput, quit, () => redraw(live))
333
338
  setInterval(() => {
334
339
  fetchWhenDue(false)
@@ -135,6 +135,21 @@ const quoted = (arg: string): string => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'
135
135
  export const viewCommandOf = (argv: readonly string[], lane: string | null): string =>
136
136
  [...argv.slice(0, 2), HERD_COMMAND.name, ...(lane ? ['--lane', lane] : [])].map(quoted).join(' ')
137
137
 
138
+ /** The pane label of a herd: `herd` for every lane, `herd · <lane>` for one lane. */
139
+ export const herdLabel = (lane: string | null): string => (lane ? `${HERD_COMMAND.name} · ${lane}` : HERD_COMMAND.name)
140
+
141
+ /** The pane label of the coordinator. */
142
+ export const COORDINATOR_LABEL = '🐾 Jack'
143
+
144
+ /** The coordinator's pane with its label, null when there is no coordinator pane among `panes`. */
145
+ export const coordinatorLabelOf = (
146
+ panes: readonly PaneState[],
147
+ coordinator: string | null,
148
+ ): { pane: string; label: string } | null =>
149
+ coordinator && panes.some((each) => each.pane === coordinator)
150
+ ? { pane: coordinator, label: COORDINATOR_LABEL }
151
+ : null
152
+
138
153
  /** The new pane of a `herdr pane split` answer; throws on a changed answer. */
139
154
  export const splitPaneOf = (json: string): string => {
140
155
  const pane = (JSON.parse(json) as { result?: { pane?: { pane_id?: unknown } } }).result?.pane?.pane_id
@@ -1,8 +1,9 @@
1
1
  import { posix } from 'node:path'
2
2
  import {
3
- PHASE_ID_PREFIX,
3
+ isPhaseOpen,
4
4
  type PlanPhase,
5
5
  parsePlanPhases,
6
+ phaseLabels,
6
7
  readPlanHeader,
7
8
  resolvePlanPath,
8
9
  type SprintLane,
@@ -31,16 +32,10 @@ export type PlannedInput = {
31
32
  language: PlanLanguage
32
33
  }
33
34
 
34
- /**
35
- * A phase is open while a task before its review gate is unticked; an open documentation or roadmap box in
36
- * the gate does not reopen a phase that is built and merged. A phase that has only gate boxes counts by them.
37
- */
38
- const isOpen = ({ items }: PlanPhase): boolean => {
39
- const tasks = items.filter(({ gate }) => !gate)
40
- return (tasks.length > 0 ? tasks : items).some(({ done }) => !done)
41
- }
35
+ /** A plan's file, its text and its phases, as one plan row reads them. */
36
+ type PlanRead = { path: string; markdown: string; phases: readonly PlanPhase[] }
42
37
 
43
- const openPhases = (phases: PlanPhase[]): PlanPhase[] => phases.filter(isOpen)
38
+ const openPhases = (phases: readonly PlanPhase[]): PlanPhase[] => phases.filter(isPhaseOpen)
44
39
 
45
40
  const isRunning = ({ lane, agent }: PlannedLane): boolean => agent !== null && !lane.closed
46
41
 
@@ -51,8 +46,8 @@ const planPaths = ({ lane }: PlannedLane, sprintDirectory: string): string[] =>
51
46
  const followedPaths = ({ lanes, sprintDirectory }: PlannedInput): Set<string> =>
52
47
  new Set(lanes.filter(isRunning).flatMap((lane) => planPaths(lane, sprintDirectory)))
53
48
 
54
- const plannedPlanOf = (path: string, markdown: string, language: PlanLanguage, excluded: ReadonlySet<string>) => {
55
- const phases = openPhases(parsePlanPhases(markdown, language)).filter(({ id }) => !excluded.has(id))
49
+ const plannedPlanOf = ({ path, markdown, phases: all }: PlanRead, language: PlanLanguage) => {
50
+ const phases = openPhases(all)
56
51
  if (phases.length === 0) return []
57
52
  const items = phases.flatMap((phase) => phase.items)
58
53
  const plan: PlannedPlan = {
@@ -77,40 +72,12 @@ export const plannedPlans = (input: PlannedInput): PlannedPlan[] => {
77
72
  const path = resolvePlanPath(input.sprintDirectory, href)
78
73
  if (seen.has(path) || followed.has(path)) return []
79
74
  seen.add(path)
80
- return plannedPlanOf(path, markdown, input.language, new Set())
75
+ return plannedPlanOf({ path, markdown, phases: parsePlanPhases(markdown, input.language) }, input.language)
81
76
  })
82
77
  }
83
78
 
84
- /**
85
- * The open phases of the plans a lane works on that its own steps do not name, which is what comes after the
86
- * phase the lane is on. Empty for a lane without an agent or a closed one: it is listed in the closing block.
87
- */
88
- export const followingPlans = (planned: PlannedLane, input: Omit<PlannedInput, 'lanes'>): PlannedPlan[] => {
89
- if (!isRunning(planned)) return []
90
- const seen = new Set<string>()
91
- return planned.lane.steps.flatMap(({ planHref }) => {
92
- const markdown = planHref ? input.plans[planHref] : undefined
93
- const path = planHref ? resolvePlanPath(input.sprintDirectory, planHref) : null
94
- if (path === null || markdown === undefined || seen.has(path)) return []
95
- seen.add(path)
96
- const named = new Set(
97
- planned.lane.steps
98
- .filter((step) => step.planHref !== null && resolvePlanPath(input.sprintDirectory, step.planHref) === path)
99
- .flatMap((step) => step.phaseIds),
100
- )
101
- return plannedPlanOf(path, markdown, input.language, named)
102
- })
103
- }
104
-
105
- /** The plan the lane's current phase belongs to goes first; the others keep their order. */
106
- export const currentPlanFirst = (plans: readonly PlannedPlan[], currentPath: string | null): PlannedPlan[] => [
107
- ...plans.filter(({ path }) => path === currentPath),
108
- ...plans.filter(({ path }) => path !== currentPath),
109
- ]
110
-
111
79
  /** `P3 P4 P5` */
112
- export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string =>
113
- plan.phaseIds.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' ')
80
+ export const phaseList = (plan: Pick<PlannedPlan, 'phaseIds'>): string => phaseLabels(plan.phaseIds, ' ')
114
81
 
115
82
  /** How many phases the plans list together. */
116
83
  export const plannedPhaseCount = (planned: readonly Pick<PlannedPlan, 'phaseIds'>[]): number =>
@@ -36,8 +36,8 @@ export type InputContext = {
36
36
  bodyRows: number
37
37
  }
38
38
 
39
- /** A clickable span of a frame line, columns 0-based and `to` exclusive. */
40
- export type ClickTarget = { line: number; from: number; to: number; key: string }
39
+ /** A clickable span of a frame line, columns 0-based and `to` exclusive; `wholeLine` spans the pane whatever leads it. */
40
+ export type ClickTarget = { line: number; from: number; to: number; key: string; wholeLine?: true }
41
41
 
42
42
  /** An SGR mouse report; column and row are 1-based as the terminal sends them. */
43
43
  export type MouseEvent = { button: number; column: number; row: number; pressed: boolean }
@@ -71,12 +71,8 @@ export const PLANNED_KEY = 'planned'
71
71
 
72
72
  export const phaseKey = (lane: string, stepIndex: number, phaseId: string): string => `${lane}:${stepIndex}:${phaseId}`
73
73
 
74
- const FOLLOWING_KEY_PREFIX = 'following>'
75
74
  const TICKED_KEY_PREFIX = 'ticked>'
76
75
 
77
- /** The key of the folded row under a lane that lists the open phases after its own. */
78
- export const followingKey = (lane: string): string => `${FOLLOWING_KEY_PREFIX}${lane}`
79
-
80
76
  /** The key of the "N ticked" line of an open phase; it is clicked only, and a click lands on the phase. */
81
77
  export const tickedKey = (phase: string): string => `${TICKED_KEY_PREFIX}${phase}`
82
78
 
@@ -3,7 +3,6 @@ import { join, relative } from 'node:path'
3
3
  import { type JackherdConfig, parseJackherdConfig } from '../config'
4
4
  import { PROJECT_CONFIG_PATH } from '../config/location'
5
5
  import { readConfig } from '../config/read-config'
6
- import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
7
6
  import type { TicketSource } from '../watch/plan-badges'
8
7
 
9
8
  export const CONFIG_PATH = PROJECT_CONFIG_PATH
@@ -20,10 +19,8 @@ export type PlansSettings = Pick<
20
19
  ticketSources: TicketSource[]
21
20
  }
22
21
 
23
- const CLOCK_LOCALE: Record<PlanLanguage, string> = {
24
- [PLAN_LANGUAGE.de]: 'de-DE',
25
- [PLAN_LANGUAGE.en]: 'en-GB',
26
- }
22
+ /** The locale of the herd's clock: the herd is English, with a 24-hour clock. */
23
+ export const CLOCK_LOCALE = 'en-GB'
27
24
 
28
25
  /** The herd's slice of a validated config. */
29
26
  export const plansSettingsOf = (config: JackherdConfig): PlansSettings => ({
@@ -67,8 +64,6 @@ export const BASE_REMOTE = 'origin'
67
64
 
68
65
  export const baseRefOf = ({ baseBranch }: Pick<PlansSettings, 'baseBranch'>): string => `${BASE_REMOTE}/${baseBranch}`
69
66
 
70
- export const clockLocaleOf = (settings: PlansSettings): string => CLOCK_LOCALE[settings.responseLanguage]
71
-
72
67
  /** The herd's settings from the config `readConfig` finds. */
73
68
  export const readSettings = (roots: string[], home: string = homedir()): PlansSettings =>
74
69
  plansSettingsOf(readConfig(roots, home))
@@ -14,22 +14,30 @@ import {
14
14
  sprintTitleOf,
15
15
  } from '../plan-format'
16
16
  import type { LaneAgent, LaneStatusSource } from '../terminal'
17
- import type { PlanLanguage } from '../vocabulary'
18
17
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
19
18
  import { readWatchReading } from '../watch/plan-badges-read'
20
19
  import { boardRowsOf } from './board-rows'
21
- import { type LaneMemory, newestRowIdentity, nextMemory, withMovedPhase } from './current-phase'
20
+ import {
21
+ type ActivityTimes,
22
+ type LaneMemory,
23
+ newestRowIdentity,
24
+ nextActivity,
25
+ nextMemory,
26
+ type PlanModified,
27
+ withMovedPhase,
28
+ } from './current-phase'
22
29
  import { errorText } from './error-text'
23
30
  import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
24
31
  import type { PullRequestState } from './github'
25
32
  import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
33
+ import { laneSummary } from './lane-summary'
26
34
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
27
35
  import { type LaneView, sortByUrgency } from './layout'
28
36
  import { extractLinkChips } from './link-chips'
29
37
  import type { Options } from './options'
30
38
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
31
39
  import { readPhaseModels } from './phase-models-read'
32
- import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
40
+ import { type PlannedPlan, plannedPlans } from './planned'
33
41
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
34
42
  import { boardTexts } from './texts'
35
43
  import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
@@ -44,8 +52,6 @@ export type Source = {
44
52
  terminal: LaneStatusSource
45
53
  /** The log lines of an agent's chat, null without one; defaults to reading them from `~/.claude/projects`. */
46
54
  readAgentLog?: AgentLogReader
47
- /** Why this board was chosen over others, shown with the errors; null or absent when there was no choice. */
48
- boardNotice?: string | null
49
55
  }
50
56
 
51
57
  /**
@@ -80,8 +86,6 @@ export type Snapshot = {
80
86
  watch?: WatchHealth
81
87
  /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
82
88
  watchesTickets?: boolean
83
- /** The project's response language, for the words of the watcher's badges. */
84
- language?: PlanLanguage
85
89
  }
86
90
 
87
91
  type LaneSources = {
@@ -104,12 +108,6 @@ const contextOf = (agent: LaneAgent | undefined, source: Source): ContextReading
104
108
  return report && { share: report.share, level: report.level }
105
109
  }
106
110
 
107
- /** The repository path of the plan the lane's current phase belongs to; null when every phase is done. */
108
- const currentPlanPath = ({ current, steps }: LaneProgress, sprintDirectory: string): string | null => {
109
- const href = current ? steps[current.stepIndex].step.planHref : null
110
- return href ? resolvePlanPath(sprintDirectory, href) : null
111
- }
112
-
113
111
  /** What a lane's worktree and agent say, listed in the sprint or not: git state, pull requests, context. */
114
112
  const runningState = async (sources: LaneSources, name: string, agent: LaneAgent | undefined) => {
115
113
  const { source, pullRequests } = sources
@@ -118,6 +116,7 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
118
116
  gitStatus,
119
117
  view: {
120
118
  status: { agent: agent?.status ?? null, ...gitStatus },
119
+ ...(agent?.title ? { sessionTitle: agent.title } : {}),
121
120
  pullRequests: gitStatus.branch ? (pullRequests.byBranch.get(gitStatus.branch) ?? []) : [],
122
121
  context: contextOf(agent, source),
123
122
  },
@@ -125,25 +124,31 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
125
124
  }
126
125
 
127
126
  /**
128
- * The progress of a lane with the phase whose boxes moved since the last read as its current one: the lane's memory
129
- * in `memories` is updated by this read. Without `memories` the newest board row decides alone.
127
+ * The progress of a lane with the phase whose boxes moved since the last read as its current one, and when each of
128
+ * its phases last moved, a movement bounded by when its plan file was last written (`modified`): the lane's memory in
129
+ * `memories` is updated by this read at `now`. Without `memories` the newest board row decides alone and no movement
130
+ * is known.
130
131
  */
131
132
  const progressWithMemory = (
132
133
  shown: SprintLane,
133
134
  plans: PlansByHref,
134
135
  memories: Map<string, LaneMemory> | undefined,
135
- ): LaneProgress => {
136
+ { now, modified }: { now: number; modified: PlanModified },
137
+ ): { progress: LaneProgress; activity: ActivityTimes } => {
136
138
  const progress = laneProgress(shown, plans)
137
- if (!memories) return progress
138
- const memory = nextMemory(memories.get(shown.name), plans, newestRowIdentity(progress))
139
- memories.set(shown.name, memory)
140
- return withMovedPhase(progress, memory.moved)
139
+ if (!memories) return { progress, activity: {} }
140
+ const previous = memories.get(shown.name)
141
+ const memory = nextMemory(previous, plans, newestRowIdentity(progress))
142
+ const activity = nextActivity(previous, memory.ticks, now, modified)
143
+ memories.set(shown.name, { ...memory, activity })
144
+ return { progress: withMovedPhase(progress, memory.moved), activity }
141
145
  }
142
146
 
143
147
  const laneView = async (
144
148
  sources: LaneSources,
145
149
  lane: SprintLane,
146
150
  fallbacks: Fallbacks,
151
+ now: number,
147
152
  memories?: Map<string, LaneMemory>,
148
153
  ): Promise<LaneResult> => {
149
154
  const { source, agents } = sources
@@ -151,27 +156,26 @@ const laneView = async (
151
156
  const worktree = worktreeOf(root, settings, lane.name)
152
157
  const agent = agents.find((candidate) => candidate.name === lane.name)
153
158
  const { gitStatus, view: running } = await runningState(sources, lane.name, agent)
154
- const { plans, files, texts } = await readPlans(
159
+ const {
160
+ plans: phasesByHref,
161
+ files,
162
+ texts,
163
+ modified,
164
+ } = await readPlans(
155
165
  lane,
156
166
  { root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
157
167
  laneWorkOf(worktree, gitStatus),
158
168
  fallbacks,
159
169
  )
160
- const shown = withWorkedPhases(lane, plans)
161
- const progress = progressWithMemory(shown, plans, memories)
170
+ const shown = withWorkedPhases(lane, phasesByHref)
171
+ const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
162
172
  const view: LaneView = {
163
173
  progress,
164
174
  ...running,
165
175
  chips: Object.fromEntries(
166
176
  Object.entries(texts).map(([href, text]) => [href, extractLinkChips(text, settings.linkKinds)]),
167
177
  ),
168
- following: currentPlanFirst(
169
- followingPlans(
170
- { lane: shown, agent: agent?.status ?? null },
171
- { plans: texts, sprintDirectory: sources.sprintDirectory, language: settings.planLanguage },
172
- ),
173
- currentPlanPath(progress, sources.sprintDirectory),
174
- ),
178
+ summary: laneSummary({ progress, phases: phasesByHref, texts, activity, language: settings.planLanguage }, now),
175
179
  }
176
180
  return { view, files, texts, pane: agent?.pane ?? null }
177
181
  }
@@ -318,8 +322,9 @@ export const loadSnapshot = async (
318
322
  agents,
319
323
  pullRequests,
320
324
  }
325
+ const now = Date.now()
321
326
  const results = [
322
- ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, memories)))),
327
+ ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, now, memories)))),
323
328
  ...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
324
329
  ]
325
330
  forgetLanes(memories, lanes)
@@ -335,7 +340,7 @@ export const loadSnapshot = async (
335
340
  })
336
341
  const { ticketSources } = settings
337
342
  const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
338
- const reading = watched ? readWatchReading(root, Date.now(), ticketSources) : null
343
+ const reading = watched ? readWatchReading(root, now, ticketSources) : null
339
344
  const phaseModels = readPhaseModels(root)
340
345
  const baseRef = baseRefOf(settings)
341
346
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
@@ -361,14 +366,8 @@ export const loadSnapshot = async (
361
366
  planned,
362
367
  files: [sprintFile, ...results.flatMap((result) => result.files)],
363
368
  titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
364
- errors: [
365
- ...errors,
366
- ...pullRequests.errors,
367
- ...(notice ? [notice] : []),
368
- ...(source.boardNotice ? [source.boardNotice] : []),
369
- ],
369
+ errors: [...errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
370
370
  watch: reading?.health,
371
371
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
372
- language: settings.responseLanguage,
373
372
  }
374
373
  }
@@ -45,8 +45,8 @@ export const phaseStateOf = ({
45
45
  /** What a phase shows in place of a symbol: `0/5`, `✓ 15/15`, `–` without checkboxes, `?` when the plan lacks it. */
46
46
  export const PHASE_COUNT = { missing: '?', empty: '–', allTicked: '✓' } as const
47
47
 
48
- /** Beside the model a phase's subagent ran on: it fits the plan's recommendation, or it differs from it. */
49
- export const MODEL_MARK = { fits: '✓', differs: '≠' } as const
48
+ /** Between the model a phase's subagent ran on and the plan's recommendation it differs from. */
49
+ export const MODEL_MARK = { differs: '≠' } as const
50
50
 
51
51
  export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
52
52
  const state = phaseStateOf(phase)
@@ -1,11 +1,20 @@
1
- import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
1
+ import { STALE_ACTIVITY_MINUTES } from './lane-summary'
2
2
  import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
3
3
  import { shownWithMore } from './shown-with-more'
4
4
  import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
5
5
 
6
+ /** The words of an open lane: its section labels, the age of a running phase, the wave, the finished phases not merged yet, and the pointer of a phase with only its review gate open. */
7
+ export type HerdTexts = {
8
+ sections: { session: string; chips: string; now: string; next: string; later: string; done: string }
9
+ minutesAgo: (minutes: number) => string
10
+ wave: (wave: number) => string
11
+ notMerged: string
12
+ reviewGate: string
13
+ }
14
+
6
15
  /**
7
- * Everything the herd says in words but the watcher's badges. The herd is English whatever language the
8
- * project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
16
+ * Everything the herd says in words but the watcher's badges. The herd is English whatever language the project
17
+ * answers in; plan and phase titles and tasks come from the plans as written, and the status words are herdr's own terms.
9
18
  */
10
19
  export type PlanTexts = {
11
20
  noAgent: string
@@ -15,24 +24,20 @@ export type PlanTexts = {
15
24
  noWorktree: string
16
25
  noRow: string
17
26
  notInPlan: string
18
- noCheckboxes: string
19
27
  /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
20
28
  allButDocs: string
21
- tickedUnmerged: string
22
29
  tickedSummary: (count: number) => string
23
30
  draft: string
24
31
  privateLink: string
25
32
  planned: (phases: number, plans: number) => string
26
- following: (groups: string) => string
33
+ phaseTally: (done: number, total: number) => string
34
+ herd: HerdTexts
27
35
  checks: Record<ChecksState, string>
28
36
  hint: string
29
37
  legend: string[]
30
38
  }
31
39
 
32
- /**
33
- * The words of the watcher's badges and of the header notice when they are missing, in the project's response
34
- * language (D96): they report on the lane's pull request as the watcher's own messages do.
35
- */
40
+ /** The words of the watcher's badges and of the header notice when they are missing. */
36
41
  export type BadgeTexts = {
37
42
  preview: string
38
43
  /** The feedback since the last push, without ignored authors. */
@@ -42,21 +47,12 @@ export type BadgeTexts = {
42
47
  stale: (minutes: number) => string
43
48
  }
44
49
 
45
- export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
46
- [PLAN_LANGUAGE.de]: {
47
- preview: '🌐 preview',
48
- feedback: (count) => `💬 ${count} neu`,
49
- changesRequested: '✋ Änderungen verlangt',
50
- watcherOff: 'Watcher aus',
51
- stale: (minutes) => `Stand ${minutes} min alt`,
52
- },
53
- [PLAN_LANGUAGE.en]: {
54
- preview: '🌐 preview',
55
- feedback: (count) => `💬 ${count} new`,
56
- changesRequested: '✋ changes requested',
57
- watcherOff: 'watcher off',
58
- stale: (minutes) => `state ${minutes} min old`,
59
- },
50
+ export const BADGE_TEXTS: BadgeTexts = {
51
+ preview: '🌐 preview',
52
+ feedback: (count) => `💬 ${count} new`,
53
+ changesRequested: '✋ changes requested',
54
+ watcherOff: 'watcher off',
55
+ stale: (minutes) => `state ${minutes} min old`,
60
56
  }
61
57
 
62
58
  /** What the herd says about a board, in the word the project calls it (`board.term`). */
@@ -80,13 +76,18 @@ export const TEXTS: PlanTexts = {
80
76
  noWorktree: 'no worktree',
81
77
  noRow: 'no row in lanes',
82
78
  notInPlan: 'not in the plan',
83
- noCheckboxes: 'no checkboxes',
84
79
  allButDocs: ALL_BUT_DOCS,
85
- tickedUnmerged: 'ticked, not merged',
86
80
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
87
81
  draft: 'draft · ',
88
82
  privateLink: 'private',
89
- following: (groups) => `also open · ${groups}`,
83
+ phaseTally: (done, total) => (total > 0 ? `${done}/${total} ph` : ''),
84
+ herd: {
85
+ sections: { session: 'SESSION', chips: '📎', now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' },
86
+ minutesAgo: (minutes) => (minutes === 0 ? '· just now' : `· ${minutes} min ago`),
87
+ wave: (wave) => `wave ${wave}:`,
88
+ notMerged: 'not merged',
89
+ reviewGate: 'Review gate',
90
+ },
90
91
  planned: (phases, plans) =>
91
92
  `planned (${phases} ${phases === 1 ? 'phase' : 'phases'} in ${plans} ${plans === 1 ? 'plan' : 'plans'})`,
92
93
  checks: {
@@ -99,10 +100,10 @@ export const TEXTS: PlanTexts = {
99
100
  legend: [
100
101
  'enter/space or click toggles a lane or a phase, click or enter on a task opens its whole text, and opens a ↗ pull request · wheel PgUp PgDn scroll · j k h l like the arrows · click or ctrl+click on a 📎 chip opens its link · ✦ artifact · ⑂ pull request or issue · ◈ Figma · private = the plan says so',
101
102
  '● working · ◆ blocked, waits for you · ◉ done = the agent finished its answer, not the lane · ○ idle · (·) no agent = a worktree nobody works in · waiting = not started: no worktree yet, something left to do, and what it starts after; finished lanes (cleaned up, or every named phase ticked) are not shown · working and blocked lanes open by themselves, → ← overrule until the status changes',
102
- `▸ ▾ open and close · ▶ next open task · ✓ finished phase · ${ALL_BUT_DOCS} = only the documentation box is open · ticked, not merged = all ticked in the worktree, not on the base branch yet · the herd follows the phase whose boxes moved last, in yellow · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
103
- `◐ lane progress · n/m = m checkboxes of the phase, n ticked · n/n in green = all ticked · 🥣 share of the context window used · Opus ${MODEL_MARK.fits} = the phase's subagent ran on the plan's model, Sonnet ${MODEL_MARK.differs} Opus in amber = on another`,
103
+ `▸ ▾ open and close · NOW ▶ the phases whose boxes or task lines moved in the last ${STALE_ACTIVITY_MINUTES} min, the one moved last first, · n min ago since then, → the task each is on (Review gate = only its review gate is open) · NEXT the next wave · LATER the other open phases · DONE ✓ the finished ones, not merged = ticked in the worktree, not on the base branch yet · ${ALL_BUT_DOCS} = only the documentation box is open · the lane line names the phase moved last · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · a lists the ticked checkboxes one by one · s opens every lane and plan as one HTML page`,
104
+ `◐ progress over the lane's plans · n/m = m checkboxes, n ticked · n/m ph = n of m phases finished · n/n in green = all ticked · 🥣 share of the context window used · Sonnet ${MODEL_MARK.differs} Opus in amber = the phase's subagent ran on another model than the plan's`,
104
105
  '↗ pull request · 💬 new feedback · ✋ changes requested · 🌐 preview · 📡 ticket: chip 📡 T-1 · status under 📎, 📡 <n> in amber on a folded lane when tickets are new',
105
- '⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main',
106
+ "SESSION: the agent's title · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main",
106
107
  ],
107
108
  }
108
109
 
package/src/plans/view.ts CHANGED
@@ -37,7 +37,6 @@ export const renderSnapshot = (
37
37
  planned: snapshot.planned,
38
38
  watch: snapshot.watch,
39
39
  watchesTickets: snapshot.watchesTickets,
40
- language: snapshot.language,
41
40
  },
42
41
  selection,
43
42
  width,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  type LaneProgress,
3
3
  laneProgress,
4
- PHASE_ID_PREFIX,
5
4
  type PlanStep,
6
5
  type PlansByHref,
6
+ phaseLabels,
7
7
  type SprintLane,
8
8
  } from '../plan-format'
9
9
  import { belongsToLane } from './phase-source'
@@ -24,7 +24,7 @@ const workedStep = (last: PlanStep, ids: string[]): PlanStep => ({
24
24
  planTitle: last.planTitle,
25
25
  planHref: last.planHref,
26
26
  phaseIds: ids,
27
- label: ids.map((id) => `${PHASE_ID_PREFIX}${id}`).join(' '),
27
+ label: phaseLabels(ids, ' '),
28
28
  startsAfter: '',
29
29
  model: '',
30
30
  withoutRow: true,