jackherd 1.6.0 → 1.7.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 (152) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/INSTALL.md +176 -0
  3. package/README.md +260 -315
  4. package/assets/branding/README.md +7 -3
  5. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  6. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  7. package/assets/branding/jackherd-logo-dark.svg +1 -1
  8. package/assets/icons/bark-dark.svg +1 -0
  9. package/assets/icons/bark.svg +1 -0
  10. package/assets/icons/bowl-dark.svg +1 -0
  11. package/assets/icons/bowl.svg +1 -0
  12. package/assets/icons/decide-dark.svg +1 -0
  13. package/assets/icons/decide.svg +1 -0
  14. package/assets/icons/ear-dark.svg +1 -0
  15. package/assets/icons/ear.svg +1 -0
  16. package/assets/icons/lanes-dark.svg +1 -0
  17. package/assets/icons/lanes.svg +1 -0
  18. package/assets/icons/plan-dark.svg +1 -0
  19. package/assets/icons/plan.svg +1 -0
  20. package/assets/icons/space.svg +1 -0
  21. package/dist/{chunk-QJSXHHQ3.js → chunk-OBUWTQ6Z.js} +25 -0
  22. package/dist/config.d.ts +14 -3
  23. package/dist/config.js +111 -10
  24. package/dist/plan-format.d.ts +180 -77
  25. package/dist/plan-format.js +292 -93
  26. package/docs/lanes-dark.svg +50 -0
  27. package/docs/lanes.svg +50 -1
  28. package/docs/skills-dark.svg +65 -0
  29. package/docs/skills.svg +65 -1
  30. package/docs/workflow-dark.svg +138 -0
  31. package/docs/workflow.svg +135 -169
  32. package/herdr-plugin/herdr-plugin.toml +6 -6
  33. package/herdr-plugin/open-plans.ts +1 -1
  34. package/herdr-plugin/restore-plans.ts +7 -7
  35. package/install.sh +1 -1
  36. package/kit/hooks/hooks/approval-gate/decision.ts +61 -0
  37. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +24 -0
  38. package/kit/hooks/hooks/approval-gate/index.ts +69 -0
  39. package/kit/hooks/hooks/approval-gate/lane.ts +14 -0
  40. package/kit/hooks/hooks/approval-gate/request.ts +111 -0
  41. package/kit/hooks/hooks/approval-gate/run.ts +94 -0
  42. package/kit/hooks/hooks/approval-gate/settings.ts +26 -0
  43. package/kit/hooks/hooks/approval-gate/store.ts +70 -0
  44. package/kit/hooks/hooks/approval-gate/transcripts.ts +40 -0
  45. package/kit/hooks/hooks/merge-guard/approval.ts +11 -74
  46. package/kit/hooks/hooks/merge-guard/run.ts +2 -1
  47. package/kit/hooks/hooks/model-gate/agent-definition.ts +13 -0
  48. package/kit/hooks/hooks/model-gate/index.ts +58 -0
  49. package/kit/hooks/hooks/model-gate/run.ts +63 -0
  50. package/kit/hooks/hooks/model-log/index.ts +60 -0
  51. package/kit/hooks/hooks/model-log/run.ts +57 -0
  52. package/kit/hooks/hooks/model-log/store.ts +40 -0
  53. package/kit/hooks/hooks/model-log/transcript.ts +37 -0
  54. package/kit/hooks/hooks/shared/checkout.ts +22 -0
  55. package/kit/hooks/hooks/shared/command.ts +6 -0
  56. package/kit/hooks/hooks/shared/config.ts +21 -0
  57. package/kit/hooks/hooks/shared/dialog-answer.ts +5 -0
  58. package/kit/hooks/hooks/shared/dialog-answers.ts +74 -0
  59. package/kit/hooks/hooks/shared/files.ts +19 -0
  60. package/kit/hooks/hooks/shared/fresh.ts +4 -0
  61. package/kit/hooks/hooks/shared/json.ts +4 -0
  62. package/kit/hooks/hooks/shared/model-family.ts +48 -0
  63. package/kit/hooks/hooks/shared/plan-model-table.ts +41 -0
  64. package/kit/hooks/hooks/shared/private-directory.ts +29 -0
  65. package/kit/hooks/hooks/shared/repository-directory.ts +14 -0
  66. package/kit/hooks/hooks/shared/subagent-phase.ts +99 -0
  67. package/kit/hooks/hooks.json +25 -0
  68. package/kit/workflow/skills/follow/SKILL.md +4 -3
  69. package/kit/workflow/skills/implement/SKILL.md +6 -6
  70. package/kit/workflow/skills/orchestrate/SKILL.md +113 -30
  71. package/kit/workflow/skills/plan/SKILL.md +5 -5
  72. package/kit/workflow/skills/start/SKILL.md +2 -2
  73. package/package.json +4 -1
  74. package/schemas/jackherd.schema.json +43 -5
  75. package/src/check/command.ts +31 -4
  76. package/src/check/deferral-arrows.ts +1 -1
  77. package/src/check/parallel-report.ts +23 -0
  78. package/src/check/plan-structure-check.ts +120 -53
  79. package/src/cli.ts +8 -2
  80. package/src/config/nested-quantifier.ts +73 -0
  81. package/src/config/repository-directory.ts +11 -0
  82. package/src/config/schema.ts +63 -8
  83. package/src/plan-format/board-prose.ts +36 -0
  84. package/src/plan-format/dependencies.ts +227 -0
  85. package/src/plan-format/documentation-gate.ts +16 -0
  86. package/src/plan-format/index.ts +23 -1
  87. package/src/plan-format/lanes.ts +35 -1
  88. package/src/plan-format/model-column.ts +52 -0
  89. package/src/plan-format/phases.ts +16 -1
  90. package/src/plan-format/plan-structure.ts +10 -28
  91. package/src/plan-format/progress.ts +7 -29
  92. package/src/plans/command.ts +3 -3
  93. package/src/plans/current-phase.ts +71 -0
  94. package/src/plans/ensure-panes.ts +2 -2
  95. package/src/plans/herd-command.ts +4 -0
  96. package/src/plans/lane-discovery.ts +14 -2
  97. package/src/plans/lane-tickets.ts +69 -0
  98. package/src/plans/layout.ts +182 -88
  99. package/src/plans/live.ts +5 -1
  100. package/src/plans/phase-models-read.ts +37 -0
  101. package/src/plans/phase-models.ts +70 -0
  102. package/src/plans/plan-panes.ts +8 -7
  103. package/src/plans/pull-requests.ts +78 -4
  104. package/src/plans/selection.ts +1 -40
  105. package/src/plans/settings.ts +9 -3
  106. package/src/plans/shown-with-more.ts +5 -0
  107. package/src/plans/snapshot.ts +82 -8
  108. package/src/plans/sprint-html.ts +2 -2
  109. package/src/plans/supervisor.ts +5 -4
  110. package/src/plans/symbols.ts +9 -6
  111. package/src/plans/texts.ts +30 -13
  112. package/src/plans/view.ts +1 -0
  113. package/src/sync/herdr-plugin.ts +1 -1
  114. package/src/sync/next-steps.ts +1 -1
  115. package/src/sync/render.ts +2 -1
  116. package/src/sync/setup-questions.ts +2 -2
  117. package/src/terminal/text.ts +4 -0
  118. package/src/terminal/types.ts +1 -1
  119. package/src/vocabulary/index.ts +39 -0
  120. package/src/watch/approvals.ts +96 -0
  121. package/src/watch/command.ts +154 -50
  122. package/src/watch/failed-tests.ts +52 -0
  123. package/src/watch/failure-read.ts +56 -0
  124. package/src/watch/failures.ts +107 -0
  125. package/src/watch/feedback-item.ts +21 -0
  126. package/src/watch/feedback-read.ts +2 -1
  127. package/src/watch/feedback.ts +37 -28
  128. package/src/watch/gh-process.ts +53 -0
  129. package/src/watch/gh.ts +10 -20
  130. package/src/watch/heartbeat-progress.ts +1 -1
  131. package/src/watch/lane-model.ts +54 -0
  132. package/src/watch/lane-parallel.ts +46 -0
  133. package/src/watch/lane-progress.ts +28 -4
  134. package/src/watch/lane-report.ts +62 -9
  135. package/src/watch/lane-sources.ts +1 -1
  136. package/src/watch/no-finding.ts +47 -0
  137. package/src/watch/plan-badges-read.ts +19 -7
  138. package/src/watch/plan-badges.ts +59 -7
  139. package/src/watch/plan-views.ts +5 -5
  140. package/src/watch/previews.ts +3 -9
  141. package/src/watch/pull-requests.ts +12 -2
  142. package/src/watch/recorded-reads.ts +25 -0
  143. package/src/watch/source-fields.ts +4 -0
  144. package/src/watch/source-tickets.ts +99 -0
  145. package/src/watch/sources.ts +62 -15
  146. package/src/watch/texts.ts +21 -1
  147. package/src/watch/watch-defaults.ts +8 -0
  148. package/src/watch/watch-events.ts +64 -4
  149. package/src/watch/watcher-lock.ts +1 -1
  150. package/src/watch/workflow-runs.ts +5 -7
  151. package/docs/lanes.mmd +0 -9
  152. package/docs/skills.mmd +0 -10
@@ -6,6 +6,7 @@ import { agentLog } from '../context/session-log'
6
6
  import {
7
7
  type LaneProgress,
8
8
  laneProgress,
9
+ type PlansByHref,
9
10
  readSprintDocument,
10
11
  resolvePlanPath,
11
12
  type SprintLane,
@@ -14,16 +15,20 @@ import {
14
15
  } from '../plan-format'
15
16
  import type { LaneAgent, LaneStatusSource } from '../terminal'
16
17
  import type { PlanLanguage } from '../vocabulary'
17
- import type { WatchBadges, WatchHealth } from '../watch/plan-badges'
18
+ import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
18
19
  import { readWatchReading } from '../watch/plan-badges-read'
19
20
  import { boardRowsOf } from './board-rows'
21
+ import { type LaneMemory, newestRowIdentity, nextMemory, withMovedPhase } from './current-phase'
20
22
  import { errorText } from './error-text'
21
23
  import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
22
24
  import type { PullRequestState } from './github'
23
25
  import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
26
+ import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
24
27
  import { type LaneView, sortByUrgency } from './layout'
25
28
  import { extractLinkChips } from './link-chips'
26
29
  import type { Options } from './options'
30
+ import { laneModelsOf, type PhaseModelRecord } from './phase-models'
31
+ import { readPhaseModels } from './phase-models-read'
27
32
  import { currentPlanFirst, followingPlans, type PlannedPlan, plannedPlans } from './planned'
28
33
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
29
34
  import { boardTexts } from './texts'
@@ -68,8 +73,13 @@ export type Snapshot = {
68
73
  files: string[]
69
74
  titles: Map<string, string | null>
70
75
  errors: string[]
71
- /** The watcher's health; absent when no lane has an open pull request, so the watcher was not looked at. */
76
+ /**
77
+ * The watcher's health; absent when no lane has an open pull request and no source is marked `ticket`, so the
78
+ * watcher was not looked at.
79
+ */
72
80
  watch?: WatchHealth
81
+ /** True when the config marks a ticket source, so a missing watcher is said without a pull request too. */
82
+ watchesTickets?: boolean
73
83
  /** The project's response language, for the words of the watcher's badges. */
74
84
  language?: PlanLanguage
75
85
  }
@@ -114,7 +124,28 @@ const runningState = async (sources: LaneSources, name: string, agent: LaneAgent
114
124
  }
115
125
  }
116
126
 
117
- const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallbacks): Promise<LaneResult> => {
127
+ /**
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.
130
+ */
131
+ const progressWithMemory = (
132
+ shown: SprintLane,
133
+ plans: PlansByHref,
134
+ memories: Map<string, LaneMemory> | undefined,
135
+ ): LaneProgress => {
136
+ 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)
141
+ }
142
+
143
+ const laneView = async (
144
+ sources: LaneSources,
145
+ lane: SprintLane,
146
+ fallbacks: Fallbacks,
147
+ memories?: Map<string, LaneMemory>,
148
+ ): Promise<LaneResult> => {
118
149
  const { source, agents } = sources
119
150
  const { root, settings } = source
120
151
  const worktree = worktreeOf(root, settings, lane.name)
@@ -127,7 +158,7 @@ const laneView = async (sources: LaneSources, lane: SprintLane, fallbacks: Fallb
127
158
  fallbacks,
128
159
  )
129
160
  const shown = withWorkedPhases(lane, plans)
130
- const progress = laneProgress(shown, plans)
161
+ const progress = progressWithMemory(shown, plans, memories)
131
162
  const view: LaneView = {
132
163
  progress,
133
164
  ...running,
@@ -171,6 +202,23 @@ const withBadges = (view: LaneView, badges: WatchBadges): LaneView => {
171
202
  return Object.keys(own).length > 0 ? { ...view, badges: own } : view
172
203
  }
173
204
 
205
+ /** The view with the model each of its phases last ran on, only when there is one, so it otherwise stays as it was. */
206
+ const withModels = (view: LaneView, records: readonly PhaseModelRecord[], sprintDirectory: string): LaneView => {
207
+ const models = laneModelsOf(view.progress, records, sprintDirectory)
208
+ return Object.keys(models).length > 0 ? { ...view, models } : view
209
+ }
210
+
211
+ /**
212
+ * The view with the lane's tickets, found by its source lane or by its id in the board rows and plan texts of the
213
+ * lane; a link chip with the URL of a shown ticket gives way to the badge. Without tickets it stays as it was.
214
+ */
215
+ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): LaneView => {
216
+ const { view, texts } = result
217
+ const lane = view.progress.lane
218
+ const found = laneTicketsOf(tickets, lane.name, [...boardRowTexts(lane), ...Object.values(texts)])
219
+ return found ? { ...view, chips: withoutTicketLinks(view.chips, found), tickets: found } : view
220
+ }
221
+
174
222
  /** The terminal's agents, or none with the reason when it cannot be asked, so the screen says why lanes show "no agent". */
175
223
  const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors: string[] } => {
176
224
  try {
@@ -234,7 +282,20 @@ const readListedPlans = async (
234
282
  return Object.fromEntries(entries.flat())
235
283
  }
236
284
 
237
- export const loadSnapshot = async (source: Source, pullRequests: PullRequestState): Promise<Snapshot> => {
285
+ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: readonly SprintLane[]): void => {
286
+ const names = new Set(lanes.map((lane) => lane.name))
287
+ for (const name of [...(memories?.keys() ?? [])]) if (!names.has(name)) memories?.delete(name)
288
+ }
289
+
290
+ /**
291
+ * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
292
+ * is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
293
+ */
294
+ export const loadSnapshot = async (
295
+ source: Source,
296
+ pullRequests: PullRequestState,
297
+ memories?: Map<string, LaneMemory>,
298
+ ): Promise<Snapshot> => {
238
299
  const { root, sprintFile, options, settings, terminal } = source
239
300
  const fallbacks: Fallbacks = []
240
301
  const base = baseReaderOf(root, baseRefOf(settings))
@@ -258,9 +319,10 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
258
319
  pullRequests,
259
320
  }
260
321
  const results = [
261
- ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks)))),
322
+ ...(await Promise.all(lanes.map((lane) => laneView(sources, lane, fallbacks, memories)))),
262
323
  ...(await Promise.all(unlisted.map((lane) => unlistedView(sources, lane)))),
263
324
  ]
325
+ forgetLanes(memories, lanes)
264
326
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
265
327
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
266
328
  const planned = options.lane
@@ -271,14 +333,25 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
271
333
  sprintDirectory,
272
334
  language: settings.planLanguage,
273
335
  })
274
- const reading = results.some(({ view }) => view.pullRequests.length > 0) ? readWatchReading(root, Date.now()) : null
336
+ const { ticketSources } = settings
337
+ const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
338
+ const reading = watched ? readWatchReading(root, Date.now(), ticketSources) : null
339
+ const phaseModels = readPhaseModels(root)
275
340
  const baseRef = baseRefOf(settings)
276
341
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
277
342
  const unknownLaneTitle =
278
343
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
279
344
  return {
280
345
  title: unknownLaneTitle ?? sprintTitleOf(markdown, sprintDirectory),
281
- views: sortByUrgency(results.map((result) => withBadges(result.view, reading?.badges ?? {}))),
346
+ views: sortByUrgency(
347
+ results.map((result) =>
348
+ withModels(
349
+ withBadges(withTickets(result, reading?.tickets ?? []), reading?.badges ?? {}),
350
+ phaseModels,
351
+ sprintDirectory,
352
+ ),
353
+ ),
354
+ ),
282
355
  documents: {
283
356
  sprint: markdown,
284
357
  plans: documentsPlans,
@@ -295,6 +368,7 @@ export const loadSnapshot = async (source: Source, pullRequests: PullRequestStat
295
368
  ...(source.boardNotice ? [source.boardNotice] : []),
296
369
  ],
297
370
  watch: reading?.health,
371
+ ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
298
372
  language: settings.responseLanguage,
299
373
  }
300
374
  }
@@ -15,7 +15,7 @@ import type {
15
15
  SprintPage,
16
16
  } from './sprint-page'
17
17
  import { phaseCountOf, phaseStateOf, statusSymbol, statusWord } from './symbols'
18
- import { laneMarkText, TEXTS } from './texts'
18
+ import { checksText, laneMarkText, TEXTS } from './texts'
19
19
 
20
20
  const BAR_PERCENT = 100
21
21
 
@@ -149,7 +149,7 @@ const pullRequestLine = (lane: PageLane): string =>
149
149
  lane.pullRequests
150
150
  .map(
151
151
  (pull) =>
152
- `<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(TEXTS.checks[pull.checks])}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
152
+ `<div class="pr">↗ ${linked(pull.url, `#${pull.number}`)} <span class="checks-${pull.checks}">${escapeHtml(checksText(pull))}</span> ${escapeHtml(pull.draft ? TEXTS.draft : '')}${escapeHtml(pull.title)}</div>`,
153
153
  )
154
154
  .join('')
155
155
 
@@ -4,6 +4,7 @@ import { dirname } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { pendingKitUpdate, VERSION_POLL_MS } from '../version/installed-version'
6
6
  import type { StartedKit } from '../version/started-kit'
7
+ import { HERD_COMMAND } from './herd-command'
7
8
  import { CHILD_MARKER, exitCodeOf, isRestartTrigger, nextStep, RESTART_DEBOUNCE_MS } from './restart-decision'
8
9
  import { TERMINAL_OFF } from './view'
9
10
 
@@ -13,7 +14,7 @@ const packageSourceDirectory = (): string => dirname(dirname(fileURLToPath(impor
13
14
 
14
15
  const entryScript = (): string => {
15
16
  const script = process.argv[1]
16
- if (!script) throw new Error('Cannot supervise the plans view: process.argv[1] is missing')
17
+ if (!script) throw new Error('Cannot supervise the herd: process.argv[1] is missing')
17
18
  return script
18
19
  }
19
20
 
@@ -21,7 +22,7 @@ const describeEnd = (code: number | null, signal: NodeJS.Signals | null): string
21
22
  signal ? `signal ${signal}` : `exit ${code}`
22
23
 
23
24
  const spawnView = (scriptPath: string, args: string[]): ChildProcess =>
24
- spawn(process.execPath, [scriptPath, 'plans', ...args], {
25
+ spawn(process.execPath, [scriptPath, HERD_COMMAND.name, ...args], {
25
26
  stdio: 'inherit',
26
27
  env: { ...process.env, [CHILD_MARKER]: '1' },
27
28
  })
@@ -59,7 +60,7 @@ export const superviseView = (
59
60
  const waitForChange = (reason: string): void => {
60
61
  child = undefined
61
62
  process.stdout.write(TERMINAL_OFF)
62
- console.log(`plans view stopped (${reason}); waiting for the next change to src/`)
63
+ console.log(`herd stopped (${reason}); waiting for the next change to src/`)
63
64
  }
64
65
 
65
66
  const start = (isFirst: boolean): void => {
@@ -69,7 +70,7 @@ export const superviseView = (
69
70
  current.on('error', (error) => {
70
71
  if (child !== current) return
71
72
  if (isFirst) {
72
- console.error(`plans view could not start: ${error.message}`)
73
+ console.error(`herd could not start: ${error.message}`)
73
74
  finish(1)
74
75
  return
75
76
  }
@@ -1,11 +1,11 @@
1
1
  import { AGENT_STATUS, type AgentStatus, isAgentStatus } from '../terminal/types'
2
2
 
3
- /** The symbols and words the plans views share: herdr's own status words, and the state of a phase. */
3
+ /** The symbols and words the herd panes share: herdr's own status words, and the state of a phase. Every lane state is one terminal cell; the layout colours it. */
4
4
  export const STATUS_SYMBOL: Record<AgentStatus, string> = {
5
- [AGENT_STATUS.working]: '⚡',
6
- [AGENT_STATUS.blocked]: '❓',
7
- [AGENT_STATUS.idle]: '⏸',
8
- [AGENT_STATUS.done]: '📨',
5
+ [AGENT_STATUS.working]: '●',
6
+ [AGENT_STATUS.blocked]: '◆',
7
+ [AGENT_STATUS.idle]: '○',
8
+ [AGENT_STATUS.done]: '◉',
9
9
  }
10
10
  export const NO_AGENT_SYMBOL = '·'
11
11
  export const NO_AGENT_WORD = 'no agent'
@@ -18,7 +18,7 @@ export const statusSymbol = (agent: string | null): string => {
18
18
 
19
19
  export const statusWord = (agent: string | null): string => agent ?? NO_AGENT_WORD
20
20
 
21
- /** Before a row that opens and closes: lanes, the notes of a lane, the planned phases, the ticked items of a phase. */
21
+ /** Before a row that opens and closes: lanes, the planned phases, the ticked items of a phase. */
22
22
  export const CHEVRON = { closed: '▸', open: '▾' } as const
23
23
 
24
24
  export const chevronOf = (open: boolean): string => (open ? CHEVRON.open : CHEVRON.closed)
@@ -45,6 +45,9 @@ 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
50
+
48
51
  export const phaseCountOf = (phase: { title: string | null; done: number; total: number }): string => {
49
52
  const state = phaseStateOf(phase)
50
53
  if (state === PHASE_STATE.missing) return PHASE_COUNT.missing
@@ -1,9 +1,10 @@
1
1
  import { PLAN_LANGUAGE, type PlanLanguage } from '../vocabulary'
2
- import { CHECKS, type ChecksState } from './pull-requests'
3
- import { NO_AGENT_WORD, PHASE_COUNT } from './symbols'
2
+ import { CHECK_NAME_LENGTH, CHECKS, type ChecksState, type PullRequest } from './pull-requests'
3
+ import { shownWithMore } from './shown-with-more'
4
+ import { MODEL_MARK, NO_AGENT_WORD, PHASE_COUNT } from './symbols'
4
5
 
5
6
  /**
6
- * Everything the plans view says in words but the watcher's badges. The view is English whatever language the
7
+ * Everything the herd says in words but the watcher's badges. The herd is English whatever language the
7
8
  * project answers in; plan and phase titles come from the plans as written, and the status words are herdr's own terms.
8
9
  */
9
10
  export type PlanTexts = {
@@ -15,11 +16,10 @@ export type PlanTexts = {
15
16
  noRow: string
16
17
  notInPlan: string
17
18
  noCheckboxes: string
18
- ticked: string
19
+ /** In place of the count of a phase whose only open box is the documentation box of its review gate. */
20
+ allButDocs: string
19
21
  tickedUnmerged: string
20
22
  tickedSummary: (count: number) => string
21
- notesHidden: (count: number) => string
22
- notesShown: (count: number) => string
23
23
  draft: string
24
24
  privateLink: string
25
25
  planned: (phases: number, plans: number) => string
@@ -59,7 +59,7 @@ export const BADGE_TEXTS: Record<PlanLanguage, BadgeTexts> = {
59
59
  },
60
60
  }
61
61
 
62
- /** What the view says about a board, in the word the project calls it (`board.term`). */
62
+ /** What the herd says about a board, in the word the project calls it (`board.term`). */
63
63
  export const boardTexts = (term: string) => ({
64
64
  missing: (glob: string) => `no ${term} under ${glob}`,
65
65
  laneNotListed: (lane: string) => `lane "${lane}" is not in the ${term}`,
@@ -70,6 +70,8 @@ export const boardTexts = (term: string) => ({
70
70
  notReadable: (reason: string) => `${term} not readable: ${reason}`,
71
71
  })
72
72
 
73
+ const ALL_BUT_DOCS = 'docs open'
74
+
73
75
  export const TEXTS: PlanTexts = {
74
76
  noAgent: NO_AGENT_WORD,
75
77
  waiting: (lanes) => `waiting (${lanes})`,
@@ -79,11 +81,9 @@ export const TEXTS: PlanTexts = {
79
81
  noRow: 'no row in lanes',
80
82
  notInPlan: 'not in the plan',
81
83
  noCheckboxes: 'no checkboxes',
82
- ticked: 'ticked',
84
+ allButDocs: ALL_BUT_DOCS,
83
85
  tickedUnmerged: 'ticked, not merged',
84
86
  tickedSummary: (count) => `${count} ticked · click or a lists them`,
85
- notesHidden: (count) => `${count} ${count === 1 ? 'note' : 'notes'} hidden · click or n shows them`,
86
- notesShown: (count) => `${count} ${count === 1 ? 'note' : 'notes'} shown · click or n hides them`,
87
87
  draft: 'draft · ',
88
88
  privateLink: 'private',
89
89
  following: (groups) => `also open · ${groups}`,
@@ -95,13 +95,30 @@ export const TEXTS: PlanTexts = {
95
95
  [CHECKS.pending]: '… checks',
96
96
  [CHECKS.none]: 'no checks',
97
97
  },
98
- hint: '↑↓ select →← open a all n notes s html q quit ? help',
98
+ hint: '↑↓ select →← open a show ticked s html q quit ? help',
99
99
  legend: [
100
100
  '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
- '⚡ 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
- `n/m = m checkboxes of the phase, n ticked (the phase being worked on is yellow; ticked, not merged = all ticked in the worktree, not on the base branch yet) · ${PHASE_COUNT.allTicked} n/n all ticked · ${PHASE_COUNT.empty} no checkboxes · ${PHASE_COUNT.missing} not in the plan · n shows or hides the notes of a lane · s opens every lane and plan as one HTML page · ⎇ branch · ↑ commits ahead · ± uncommitted · no worktree: plan read from main`,
101
+ '● 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`,
104
+ '↗ 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',
103
106
  ],
104
107
  }
105
108
 
106
109
  /** The mark of a running lane the board has no row for; null for a lane it lists. */
107
110
  export const laneMarkText = (lane: { unlisted?: boolean }): string | null => (lane.unlisted ? TEXTS.noRow : null)
111
+
112
+ /** The most red checks the checks column names; the rest is counted. */
113
+ export const NAMED_CHECKS = 3
114
+
115
+ /**
116
+ * The words of a pull request's check state: `✗ test, lint` names the red checks (at most `NAMED_CHECKS`, then
117
+ * `+<n>`), every other state and a red one without names is the fixed word of `PLAN_TEXTS.checks`.
118
+ */
119
+ export const checksText = ({ checks, failedChecks }: Pick<PullRequest, 'checks' | 'failedChecks'>): string => {
120
+ if (checks !== CHECKS.failed || failedChecks.length === 0) return TEXTS.checks[checks]
121
+ const { shown, more } = shownWithMore(failedChecks, NAMED_CHECKS)
122
+ const names = shown.map((name) => name.slice(0, CHECK_NAME_LENGTH)).join(', ')
123
+ return `✗ ${names}${more > 0 ? ` +${more}` : ''}`
124
+ }
package/src/plans/view.ts CHANGED
@@ -36,6 +36,7 @@ export const renderSnapshot = (
36
36
  lanes: snapshot.views,
37
37
  planned: snapshot.planned,
38
38
  watch: snapshot.watch,
39
+ watchesTickets: snapshot.watchesTickets,
39
40
  language: snapshot.language,
40
41
  },
41
42
  selection,
@@ -142,7 +142,7 @@ export const linkHerdrPlugin = ({
142
142
  return `Could not link the herdr plugin, herdr said: ${result.output || `exit ${result.exitCode}`}`
143
143
  const linkedLine = linked
144
144
  ? `Relinked the herdr plugin from ${linked.root} to ${directory} (${reasonText(reason, linked)}).`
145
- : 'Linked the herdr plugin (the pane and the action for the plans view).'
145
+ : 'Linked the herdr plugin (the pane and the action for the herd).'
146
146
  return joinLines(linkedLine, unlink())
147
147
  }
148
148
 
@@ -36,7 +36,7 @@ export const nextSteps = ({ skillPrefix, adapter, tools, version, cli }: NextSte
36
36
  `${orchestrate}: run a board's phases in parallel lanes.`,
37
37
  herdrLine(orchestrate, adapter, tools),
38
38
  ghLine(orchestrate, tools),
39
- `${cli} plans: see every plan and lane in one view.`,
39
+ `${cli} herd: the whole herd at a glance, the state of every lane.`,
40
40
  `Docs: https://www.npmjs.com/package/${PACKAGE_NAME}/v/${version}#documentation`,
41
41
  ]
42
42
  }
@@ -1,6 +1,6 @@
1
1
  import type { JackherdConfig } from '../config'
2
2
  import { frozenInstallCommand } from '../config/detect'
3
- import { ADDRESS_FORM, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
3
+ import { ADDRESS_FORM, APPROVAL_TEXTS, LANGUAGE_NAME, PLAN_VOCABULARY, SELF_MERGE_LABEL } from '../vocabulary'
4
4
 
5
5
  const PLACEHOLDER = /\{\{\s*(kit\.[A-Za-z0-9_.-]*)\s*\}\}/g
6
6
 
@@ -39,6 +39,7 @@ const placeholderValues = (config: JackherdConfig): Map<string, string> => {
39
39
  ...prefixed('board', config.board),
40
40
  ['kit.guard.selfMergeLabel', SELF_MERGE_LABEL[config.responseLanguage]],
41
41
  ['kit.guard.selfMerge', config.guard.selfMerge.offer ? 'offered' : 'not offered'],
42
+ ...prefixed('approval', APPROVAL_TEXTS[config.responseLanguage]),
42
43
  ['kit.merge.withoutChecks', checkFreeList(config.merge.withoutChecks)],
43
44
  ...prefixed('heading', { ...header, ...sections, ...columns, phase }),
44
45
  ])
@@ -11,7 +11,7 @@ const LANGUAGE_CHOICES: readonly Choice<PlanLanguage>[] = [PLAN_LANGUAGE.en, PLA
11
11
  }))
12
12
 
13
13
  const ADAPTER_CHOICES: readonly Choice<TerminalAdapter>[] = [
14
- { value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the plans view)' },
14
+ { value: TERMINAL_ADAPTER.herdr, label: 'Yes, use herdr (agent states and context fill in the herd)' },
15
15
  { value: TERMINAL_ADAPTER.none, label: 'No, not now' },
16
16
  ]
17
17
 
@@ -20,7 +20,7 @@ const RESPONSE_QUESTION = 'Which language should the skills answer you in?'
20
20
  const PLAN_QUESTION = 'Which language should plans and boards be written in? It sets their headings.'
21
21
 
22
22
  const ADAPTER_QUESTION =
23
- 'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the plans view; /plan and /implement work without it.'
23
+ 'Use herdr? It runs parallel lanes for /orchestrate and shows each agent in the herd; /plan and /implement work without it.'
24
24
 
25
25
  const INSTALL_QUESTION = `herdr is not installed. It is only needed for parallel lanes. Install it now with its official installer, \`${INSTALLERS.herdr}\`?`
26
26
 
@@ -41,6 +41,7 @@ export const paint = {
41
41
  green: style('32'),
42
42
  yellow: style('33'),
43
43
  magenta: style('35'),
44
+ teal: style('38;5;37'),
44
45
  cyan: style('36'),
45
46
  inverse: style('7'),
46
47
  }
@@ -67,6 +68,9 @@ export const link = (url: string, text: string): string =>
67
68
  export const badge = (url: string, text: string, hint = '', tone: BadgeTone = BADGE_TONE.grey): string =>
68
69
  `${ESC}[${tone}m ${link(url, text)}${hint ? `${FAINT_ON} ${hint}${FAINT_OFF}` : ''} ${RESET}`
69
70
 
71
+ /** `text` as a badge without a link, for what no click opens: the background of `tone`, a cell of padding either side. */
72
+ export const box = (text: string, tone: BadgeTone = BADGE_TONE.grey): string => `${ESC}[${tone}m ${text} ${RESET}`
73
+
70
74
  /** A line for output that is not a terminal: styling dropped, each link written out as `text (url)`. */
71
75
  export const plainText = (text: string): string =>
72
76
  stripAnsi(text.replace(LINK, (_, url, inner) => `${stripAnsi(inner)} (${url})`))
@@ -16,7 +16,7 @@ export type AdoptedName = { pane: string; name: string }
16
16
  export type LaneAgent = { name: string; pane: string; status: string; session: string | null; cwd: string | null }
17
17
 
18
18
  /**
19
- * Where lane agents run. The plans view reads agent status and sets a short title per lane through
19
+ * Where lane agents run. The herd reads agent status and sets a short title per lane through
20
20
  * this; the adapter `none` stands for a setup without a terminal multiplexer.
21
21
  */
22
22
  export type LaneStatusSource = {
@@ -38,6 +38,25 @@ export const SELF_MERGE_LABEL: Record<PlanLanguage, string> = {
38
38
  [PLAN_LANGUAGE.de]: 'Freigeben, und nach grünen Checks selbst mergen',
39
39
  }
40
40
 
41
+ /**
42
+ * The words of an approval: the two option labels of the coordinator's question about a lane's call, which the
43
+ * approval gate hook matches exactly, and what the hook tells the lane when it denies. Fixed per language, not config.
44
+ */
45
+ export const APPROVAL_TEXTS: Record<PlanLanguage, { allow: string; deny: string; pending: string; denied: string }> = {
46
+ [PLAN_LANGUAGE.en]: {
47
+ allow: 'Allow this call',
48
+ deny: 'Deny this call',
49
+ pending: 'Approval pending with the coordinator; retry the same call once it is approved.',
50
+ denied: 'The user denied this call through the coordinator; do not retry it.',
51
+ },
52
+ [PLAN_LANGUAGE.de]: {
53
+ allow: 'Aufruf erlauben',
54
+ deny: 'Aufruf ablehnen',
55
+ pending: 'Freigabe steht aus beim Koordinator; denselben Aufruf nach der Freigabe erneut versuchen.',
56
+ denied: 'Der User hat diesen Aufruf über den Koordinator abgelehnt; nicht erneut versuchen.',
57
+ },
58
+ }
59
+
41
60
  export const isPlanLanguage = (value: string): value is PlanLanguage =>
42
61
  (Object.values(PLAN_LANGUAGE) as string[]).includes(value)
43
62
 
@@ -81,8 +100,18 @@ export type PlanVocabulary = {
81
100
  laneClosed: string
82
101
  /** Words a starts-after cell begins with when the lane may start at once. */
83
102
  startsNow: readonly string[]
103
+ /** Opens the parenthesis by which a board row's phases cell records a user's side order in prose. */
104
+ sideOrderMarker: string
84
105
  /** Words that may stand between two phases of one entry (`P1 Rule und P2 Monitor`). */
85
106
  phaseConnectors: readonly string[]
107
+ /** Words that open a writes cell naming another phase's set instead of listing it (`wie 15`). */
108
+ writesLike: readonly string[]
109
+ /** Words that join several phases after a `writesLike` word (`wie 15 und 16`); a comma joins them in every language. */
110
+ writesLikeJoiners: readonly string[]
111
+ /** Words a `Depends on` cell says it depends on nothing with, besides a dash. */
112
+ noDependency: readonly string[]
113
+ /** The word that opens the serial marker of a phase that ships in a release (`Release 1.7.0`); the release files stay a judgement. */
114
+ releaseMarker: string
86
115
  statusWords: Record<PlanStatus, StatusWord>
87
116
  }
88
117
 
@@ -104,8 +133,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
104
133
  phase: 'Phase',
105
134
  noReviewGate: 'Kein Review-Gate',
106
135
  laneClosed: 'abgeräumt',
136
+ sideOrderMarker: 'dazu (User',
107
137
  startsNow: ['sofort'],
108
138
  phaseConnectors: ['danach', 'und', 'dann', 'sowie'],
139
+ writesLike: ['wie'],
140
+ writesLikeJoiners: ['und', 'sowie'],
141
+ noDependency: ['keine', 'kein'],
142
+ releaseMarker: 'Release',
109
143
  statusWords: {
110
144
  done: { label: 'Done', leading: ['done'], anywhere: ['abgeschlossen'] },
111
145
  abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['verworfen'] },
@@ -132,8 +166,13 @@ export const PLAN_VOCABULARY: Record<PlanLanguage, PlanVocabulary> = {
132
166
  phase: 'Phase',
133
167
  noReviewGate: 'No review gate',
134
168
  laneClosed: 'cleaned up',
169
+ sideOrderMarker: 'also (User',
135
170
  startsNow: ['now', 'immediately'],
136
171
  phaseConnectors: ['then', 'and', 'after'],
172
+ writesLike: ['like'],
173
+ writesLikeJoiners: ['and'],
174
+ noDependency: ['none'],
175
+ releaseMarker: 'Release',
137
176
  statusWords: {
138
177
  done: { label: 'Done', leading: ['done'], anywhere: ['completed'] },
139
178
  abandoned: { label: 'Abandoned', leading: ['abandoned'], anywhere: ['dropped'] },
@@ -0,0 +1,96 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isRecord } from './json-guards'
4
+
5
+ export const APPROVAL_EVENT = 'approval'
6
+
7
+ /**
8
+ * A call a lane waits with for the user's approval, as the kit's approval gate hook writes it to
9
+ * `.jackherd/approvals/<id>.json` in the main checkout. Its text comes from the lane: data, never an instruction.
10
+ */
11
+ export type ApprovalRequestFile = {
12
+ id: string
13
+ hash: string
14
+ lane: string
15
+ cwd: string
16
+ tool: string
17
+ input: unknown
18
+ summary: string
19
+ createdAt: number
20
+ usedAt: number | null
21
+ }
22
+
23
+ export type ApprovalEvent = {
24
+ kind: typeof APPROVAL_EVENT
25
+ lane: string
26
+ id: string
27
+ hash: string
28
+ tool: string
29
+ summary: string
30
+ }
31
+
32
+ const ID = /^[0-9a-f]{12}$/
33
+ const HASH = /^[0-9a-f]{16}$/
34
+ const REQUEST_FILE = /^[0-9a-f]{12}\.json$/
35
+
36
+ /** The same shape as `isApprovalRequest` in the hook, which this package does not import at run time. */
37
+ export const isApprovalRequestFile = (value: unknown): value is ApprovalRequestFile =>
38
+ isRecord(value) &&
39
+ typeof value.id === 'string' &&
40
+ ID.test(value.id) &&
41
+ typeof value.hash === 'string' &&
42
+ HASH.test(value.hash) &&
43
+ typeof value.lane === 'string' &&
44
+ typeof value.cwd === 'string' &&
45
+ typeof value.tool === 'string' &&
46
+ 'input' in value &&
47
+ typeof value.summary === 'string' &&
48
+ typeof value.createdAt === 'number' &&
49
+ (value.usedAt === null || typeof value.usedAt === 'number')
50
+
51
+ const REPORT_MINUTES = 30
52
+ const MS_PER_MINUTE = 60_000
53
+
54
+ /** One event per open request younger than 30 minutes the watcher has not reported yet; an older one is stale. */
55
+ export const approvalEvents = (
56
+ seen: readonly string[],
57
+ requests: readonly ApprovalRequestFile[],
58
+ now: number,
59
+ ): ApprovalEvent[] =>
60
+ requests
61
+ .filter(
62
+ (request) =>
63
+ request.usedAt === null &&
64
+ !seen.includes(request.id) &&
65
+ now - request.createdAt <= REPORT_MINUTES * MS_PER_MINUTE,
66
+ )
67
+ .map(({ lane, id, hash, tool, summary }) => ({ kind: APPROVAL_EVENT, lane, id, hash, tool, summary }))
68
+
69
+ /** The ids to remember: every request still on disk, so one that is gone is forgotten. */
70
+ export const recordApprovals = (requests: readonly ApprovalRequestFile[]): string[] =>
71
+ requests.map((request) => request.id)
72
+
73
+ const requestFileNames = (directory: string): string[] => {
74
+ try {
75
+ return readdirSync(directory).filter((name) => REQUEST_FILE.test(name))
76
+ } catch {
77
+ return []
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The requests in the main checkout's `.jackherd/approvals/`, a claimed one as used; a file that does not parse is
83
+ * skipped, and a directory that cannot be listed reads as none, so the watch goes on.
84
+ */
85
+ export const readApprovalRequests = (root: string): ApprovalRequestFile[] => {
86
+ const directory = join(root, '.jackherd', 'approvals')
87
+ return requestFileNames(directory).flatMap((name) => {
88
+ try {
89
+ const request: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
90
+ if (!isApprovalRequestFile(request) || `${request.id}.json` !== name) return []
91
+ return [existsSync(join(directory, `${request.id}.used`)) ? { ...request, usedAt: request.createdAt } : request]
92
+ } catch {
93
+ return []
94
+ }
95
+ })
96
+ }