jackherd 1.9.0 → 1.10.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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs'
1
+ import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import type { ContextReading } from '../context'
4
4
  import { type AgentLogReader, contextOfAgent } from '../context/report'
@@ -13,12 +13,14 @@ import {
13
13
  shortTitle,
14
14
  sprintTitleOf,
15
15
  } from '../plan-format'
16
- import { openWaitingItems, type WaitingItem } from '../plan-format/waiting'
16
+ import type { WaitingItem } from '../plan-format/waiting'
17
+ import { dropUnused } from '../shared/stat-cache'
17
18
  import type { TaskSignal } from '../task-signals/types'
18
19
  import type { LaneAgent, LaneStatusSource } from '../terminal'
19
20
  import type { SourceTicket, WatchBadges, WatchHealth } from '../watch/plan-badges'
20
21
  import { readWatchReading } from '../watch/plan-badges-read'
21
22
  import { assignSignals, laneOfDirectory, type UnplannedTask } from './active-tasks'
23
+ import { type AppointmentBlock, appointmentBlock } from './appointment-rows'
22
24
  import { boardRowsOf } from './board-rows'
23
25
  import {
24
26
  type ActivityTimes,
@@ -33,7 +35,15 @@ import { activityTextOf, taskSignalsOf } from './current-task'
33
35
  import { errorText } from './error-text'
34
36
  import { type BaseReader, baseReaderOf, gitStatusOf, refExists } from './git'
35
37
  import type { PullRequestState } from './github'
36
- import { type Fallbacks, laneWorkOf, readAtBase, readFallback, readPlans, textAtBase } from './lane-plans'
38
+ import {
39
+ type Fallbacks,
40
+ laneWorkOf,
41
+ type PlanSources,
42
+ readAtBase,
43
+ readFallback,
44
+ readPlans,
45
+ textAtBase,
46
+ } from './lane-plans'
37
47
  import { laneSummary } from './lane-summary'
38
48
  import { boardRowTexts, laneTicketsOf, withoutTicketLinks } from './lane-tickets'
39
49
  import type { LaneView } from './layout'
@@ -41,15 +51,19 @@ import { extractLinkChips } from './link-chips'
41
51
  import type { Options } from './options'
42
52
  import { laneModelsOf, type PhaseModelRecord } from './phase-models'
43
53
  import { readPhaseModels } from './phase-models-read'
54
+ import { laneHeads, planPullRequests, readerOncePerRef } from './plan-heads'
44
55
  import { type PlannedPlan, plannedPlans } from './planned'
45
- import { type SentChip, type SentFileRecord, sentChipsByLane, sentWaitingItems } from './sent-files'
46
- import { readSentRecords } from './sent-files-read'
56
+ import type { PullRequest } from './pull-requests'
57
+ import type { ReadCache } from './read-cache'
58
+ import { type SentChip, sentChipsByLane } from './sent-files'
47
59
  import { baseRefOf, boardRepoPathOf, type PlansSettings, worktreeOf } from './settings'
60
+ import { signalledTitle } from './signalled-phase'
48
61
  import { readAllSignals } from './signals-read'
49
62
  import type { SubagentActivity } from './subagents'
50
- import { readSubagentActivity } from './subagents-read'
63
+ import { readSubagentActivity, subagentSourceOf } from './subagents-read'
51
64
  import { boardTexts } from './texts'
52
65
  import { type UnlistedLane, unlistedLanes, unlistedSprintLane } from './unlisted-lanes'
66
+ import { readWaitingRecords, waitingItemsOf } from './waiting-records'
53
67
  import { withWorkedPhases } from './worked-phases'
54
68
 
55
69
  /** Where the view reads from: the main checkout, the sprint document, the command line and the project settings. */
@@ -101,6 +115,8 @@ export type Snapshot = {
101
115
  watchesTickets?: boolean
102
116
  /** What waits on the user's answer: the open rows of the board's section for it, from the checkout's board file. */
103
117
  waiting?: WaitingItem[]
118
+ /** The board's open appointments, read from the same board text as `waiting`, for the block under it. */
119
+ appointments?: AppointmentBlock
104
120
  /** What agents report doing that no lane takes: the coordinator's own, or a lane the board does not list. */
105
121
  unplaced?: UnplannedTask[]
106
122
  }
@@ -115,13 +131,19 @@ type LaneSources = {
115
131
  /** The agents' log reader, asking once per agent however often the read is used in one snapshot. */
116
132
  readLog: AgentLogReader
117
133
  pullRequests: PullRequestState
134
+ /** The open pull requests that change a plan, whose heads raise the ticks the base ref has. */
135
+ planPullRequests: PullRequest[]
136
+ /** The reader of a pull request head, resolved once per read whatever the number of lanes. */
137
+ headReaderOf: (ref: string) => BaseReader
118
138
  /** What agents report doing, in every lane and outside them. */
119
139
  signals: TaskSignal[]
120
140
  /** The directory the lanes' worktrees lie in, which tells a signal's lane. */
121
141
  worktrees: string
142
+ parsed?: PlanSources['parsed']
122
143
  }
123
144
 
124
- type BoardLanes = { markdown: string; lanes: SprintLane[] }
145
+ /** `liveMarkdown` is the checkout's board file (the coordinator edits it before any commit), else `markdown`. */
146
+ type BoardLanes = { markdown: string; liveMarkdown: string; lanes: SprintLane[] }
125
147
 
126
148
  const SHOWN_NAMES = 2
127
149
 
@@ -219,12 +241,20 @@ const laneView = async (
219
241
  modified,
220
242
  } = await readPlans(
221
243
  lane,
222
- { root, settings, base: sources.base, sprintDirectory: sources.sprintDirectory },
244
+ {
245
+ root,
246
+ settings,
247
+ base: sources.base,
248
+ sprintDirectory: sources.sprintDirectory,
249
+ heads: laneHeads(sources.planPullRequests, gitStatus.branch, sources.headReaderOf),
250
+ parsed: sources.parsed,
251
+ },
223
252
  laneWorkOf(worktree, gitStatus),
224
253
  fallbacks,
225
254
  )
226
255
  const shown = withWorkedPhases(lane, phasesByHref)
227
256
  const { progress, activity } = progressWithMemory(shown, phasesByHref, memories, { now, modified })
257
+ const laneSubagents = sources.subagents.get(lane.name)
228
258
  const view: LaneView = {
229
259
  progress,
230
260
  ...running,
@@ -238,6 +268,11 @@ const laneView = async (
238
268
  activity,
239
269
  guesses: taskSignalsOf(Object.values(phasesByHref).flat(), workText(sources, lane.name, agent), now),
240
270
  reported: sources.signals.filter(({ cwd }) => laneOfDirectory(cwd, sources.worktrees) === lane.name),
271
+ subagents: laneSubagents?.phaseRuns ?? [],
272
+ subagentTasks: laneSubagents?.taskDescriptions ?? [],
273
+ phaselessReviews: laneSubagents?.phaselessReviews ?? [],
274
+ describedSubagents: laneSubagents?.describedSubagents ?? [],
275
+ branch: gitStatus.branch,
241
276
  },
242
277
  now,
243
278
  ),
@@ -289,12 +324,17 @@ const withTickets = (result: LaneResult, tickets: readonly SourceTicket[]): Lane
289
324
  }
290
325
 
291
326
  /** What agents report doing; nothing when it cannot be read. */
292
- const readSignals = ({ root, readSignals: read }: Source, worktrees: string, now: number): TaskSignal[] => {
327
+ const readSignals = (
328
+ { root, readSignals: read }: Source,
329
+ worktrees: string,
330
+ now: number,
331
+ cache?: ReadCache,
332
+ ): TaskSignal[] => {
293
333
  try {
294
334
  return (
295
335
  read ??
296
336
  ((main, directory, at) =>
297
- readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at))
337
+ readAllSignals({ projects: defaultProjectsDirectory(), root: main, worktrees: directory }, at, cache?.signals))
298
338
  )(root, worktrees, now)
299
339
  } catch {
300
340
  return []
@@ -322,46 +362,19 @@ const readAgents = (terminal: LaneStatusSource): { agents: LaneAgent[]; errors:
322
362
  }
323
363
 
324
364
  /** What subagents of the coordinator do in the worktrees; nothing when their logs cannot be read. */
325
- const readSubagents = ({ root, readSubagents: read }: Source, worktrees: string): Map<string, SubagentActivity> => {
365
+ const readSubagents = (
366
+ { root, settings, readSubagents: read }: Source,
367
+ worktrees: string,
368
+ ): Map<string, SubagentActivity> => {
326
369
  try {
327
370
  return (
328
- read ??
329
- ((main, directory) =>
330
- readSubagentActivity({
331
- projects: defaultProjectsDirectory(),
332
- root: main,
333
- approvalsRoot: main,
334
- worktreeDirectory: directory,
335
- }))
371
+ read ?? ((main, directory) => readSubagentActivity(subagentSourceOf(main, directory, settings.skillPrefix)))
336
372
  )(root, worktrees)
337
373
  } catch {
338
374
  return new Map()
339
375
  }
340
376
  }
341
377
 
342
- /**
343
- * The open items that wait on the user, from the checkout's board file (the coordinator edits it before any commit),
344
- * else from the text read at the base ref.
345
- */
346
- const waitingOf = (
347
- { sprintFile, settings }: Source,
348
- markdown: string,
349
- sends: readonly SentFileRecord[],
350
- now: number,
351
- ): WaitingItem[] => {
352
- const board = (() => {
353
- try {
354
- return openWaitingItems(
355
- existsSync(sprintFile) ? readFileSync(sprintFile, 'utf8') : markdown,
356
- settings.planLanguage,
357
- )
358
- } catch {
359
- return openWaitingItems(markdown, settings.planLanguage)
360
- }
361
- })()
362
- return [...board, ...sentWaitingItems(sends, now)]
363
- }
364
-
365
378
  /** The view with the files its session sent to the user, only when there are any, so it otherwise stays as it was. */
366
379
  const withSentFiles = (view: LaneView, chips: ReadonlyMap<string | null, SentChip[]>): LaneView => {
367
380
  const found = chips.get(view.progress.lane.name)
@@ -395,8 +408,8 @@ const readBoardLanes = async (
395
408
  const { sprintFile, settings } = source
396
409
  const atBase = textAtBase(base, boardPath)
397
410
  const markdown = atBase ?? (await readFallback(boardPath, sprintFile, fallbacks)) ?? ''
398
- const checkoutText = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
399
- return { markdown, lanes: boardRowsOf(markdown, checkoutText, settings.planLanguage).lanes }
411
+ const liveMarkdown = atBase !== null && existsSync(sprintFile) ? await Bun.file(sprintFile).text() : markdown
412
+ return { markdown, liveMarkdown, lanes: boardRowsOf(markdown, liveMarkdown, settings.planLanguage).lanes }
400
413
  }
401
414
 
402
415
  /** The plans the sprint's plans tables name, read at the base ref, by link; lanes read theirs themselves. */
@@ -429,11 +442,17 @@ const forgetLanes = (memories: Map<string, LaneMemory> | undefined, lanes: reado
429
442
 
430
443
  const laneSetOf = (
431
444
  source: Source,
432
- context: { base: BaseReader; board: BoardLanes; sprintDirectory: string; worktrees: string },
445
+ context: {
446
+ base: BaseReader
447
+ board: BoardLanes
448
+ sprintDirectory: string
449
+ worktrees: string
450
+ cache?: ReadCache
451
+ },
433
452
  pullRequests: PullRequestState,
434
453
  now: number,
435
454
  ): LaneSet => {
436
- const { base, board, sprintDirectory, worktrees } = context
455
+ const { base, board, sprintDirectory, worktrees, cache } = context
437
456
  const { agents, errors } = readAgents(source.terminal)
438
457
  const isWanted = ({ name }: { name: string }) => !source.options.lane || name === source.options.lane
439
458
  const subagents = readSubagents(source, worktrees)
@@ -449,8 +468,11 @@ const laneSetOf = (
449
468
  subagents,
450
469
  readLog: onceReader(source.readAgentLog ?? agentLog),
451
470
  pullRequests,
452
- signals: readSignals(source, worktrees, now),
471
+ planPullRequests: planPullRequests([...pullRequests.byBranch.values()].flat(), source.settings.planDirectory),
472
+ headReaderOf: readerOncePerRef((ref) => baseReaderOf(source.root, ref)),
473
+ signals: readSignals(source, worktrees, now, cache),
453
474
  worktrees,
475
+ parsed: cache && { entries: cache.plans, used: new Set<string>() },
454
476
  },
455
477
  errors,
456
478
  }
@@ -487,16 +509,6 @@ const decoratedViews = (
487
509
  ),
488
510
  )
489
511
 
490
- /** The files sent to the user, read once: the records, and their badges by lane. */
491
- const sendsOf = (
492
- root: string,
493
- worktrees: string,
494
- now: number,
495
- ): { records: SentFileRecord[]; chips: Map<string | null, SentChip[]> } => {
496
- const records = readSentRecords(root)
497
- return { records, chips: sentChipsByLane(records, worktrees, now) }
498
- }
499
-
500
512
  /** The open phases no lane with an agent leads; none while the view shows one lane. */
501
513
  const plannedOf = (
502
514
  source: Source,
@@ -516,11 +528,13 @@ const plannedOf = (
516
528
  /**
517
529
  * Reads the board, its plans and the lanes' state. `memories` holds, per lane, what the herd saw at the last read; it
518
530
  * is how the current phase becomes the one whose boxes moved since, and it is cleaned of lanes the board dropped.
531
+ * `cache`, kept by the caller between reads, spares parsing the session logs and plans that did not change.
519
532
  */
520
533
  export const loadSnapshot = async (
521
534
  source: Source,
522
535
  pullRequests: PullRequestState,
523
536
  memories?: Map<string, LaneMemory>,
537
+ cache?: ReadCache,
524
538
  ): Promise<Snapshot> => {
525
539
  const { root, sprintFile, options, settings } = source
526
540
  const fallbacks: Fallbacks = []
@@ -532,14 +546,18 @@ export const loadSnapshot = async (
532
546
  const sprintDirectory = dirname(boardPath)
533
547
  const worktrees = join(root, settings.worktreeDirectory)
534
548
  const now = Date.now()
535
- const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees }, pullRequests, now)
549
+ const liveBoard = board.liveMarkdown
550
+ const laneSet = laneSetOf(source, { base, board, sprintDirectory, worktrees, cache }, pullRequests, now)
536
551
  const results = await laneResultsOf(laneSet, fallbacks, now, memories)
552
+ const { parsed } = laneSet.sources
553
+ if (parsed) dropUnused(parsed.entries, parsed.used)
537
554
  const listedPlans = await readListedPlans(source, base, sprintDirectory, markdown, fallbacks)
538
555
  const documentsPlans = Object.assign({}, listedPlans, ...results.map((result) => result.texts))
539
556
  const { ticketSources } = settings
540
557
  const watched = ticketSources.length > 0 || results.some(({ view }) => view.pullRequests.length > 0)
541
558
  const reading = watched ? readWatchReading(root, now, ticketSources) : null
542
- const sends = sendsOf(root, worktrees, now)
559
+ const waitingRecords = readWaitingRecords(root)
560
+ const sentChips = sentChipsByLane(waitingRecords.sent, worktrees, now)
543
561
  const notice = fallbacks.length > 0 ? fallbackNotice(fallbacks, baseRef, refExists(root, baseRef)) : null
544
562
  const unknownLaneTitle =
545
563
  options.lane && results.length === 0 ? boardTexts(settings.board.term).laneNotListed(options.lane) : null
@@ -549,7 +567,7 @@ export const loadSnapshot = async (
549
567
  reading,
550
568
  phaseModels: readPhaseModels(root),
551
569
  sprintDirectory,
552
- sentChips: sends.chips,
570
+ sentChips,
553
571
  }),
554
572
  documents: {
555
573
  sprint: markdown,
@@ -559,10 +577,15 @@ export const loadSnapshot = async (
559
577
  },
560
578
  planned: plannedOf(source, documentsPlans, results, sprintDirectory),
561
579
  files: [sprintFile, ...results.flatMap((result) => result.files)],
562
- titles: new Map(results.flatMap(({ view, pane }) => (pane ? [[pane, shortTitle(view.progress)]] : []))),
580
+ titles: new Map(
581
+ results.flatMap(({ view, pane }) =>
582
+ pane ? [[pane, signalledTitle(view.summary?.signalled ?? null, shortTitle(view.progress))]] : [],
583
+ ),
584
+ ),
563
585
  errors: [...laneSet.errors, ...pullRequests.errors, ...(notice ? [notice] : [])],
564
586
  watch: reading?.health,
565
- waiting: waitingOf(source, markdown, sends.records, now),
587
+ waiting: waitingItemsOf(liveBoard, settings.planLanguage, waitingRecords, now),
588
+ appointments: appointmentBlock(liveBoard, settings.planLanguage, new Date(now)),
566
589
  unplaced: unplacedOf(laneSet.sources, laneSet.lanes, Boolean(options.lane)),
567
590
  ...(ticketSources.length > 0 ? { watchesTickets: true } : {}),
568
591
  }
@@ -1,7 +1,7 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
2
- import { tmpdir } from 'node:os'
3
1
  import { dirname } from 'node:path'
4
2
  import { pathToFileURL } from 'node:url'
3
+ import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
4
+ import { pageDirectoryOf, systemLocation } from '../shared/private-location'
5
5
  import { openInBrowser } from '../terminal/open'
6
6
  import { errorText } from './error-text'
7
7
  import { baseRefInfo, remoteBlobBase } from './git'
@@ -11,19 +11,19 @@ import { renderPlanBody, renderSprintHtml } from './sprint-html'
11
11
  import { buildSprintPage, type SprintPage, sprintPagePath } from './sprint-page'
12
12
  import { boardTexts } from './texts'
13
13
 
14
- const DIRECTORY_MODE = 0o700
15
- const FILE_MODE = 0o600
16
-
17
- /** What writing and opening the page needs from outside; the defaults are the real ones. */
14
+ /**
15
+ * What writing and opening the page needs from outside; the defaults are the real ones. `pageDirectory` is the
16
+ * user's own directory the page goes into, verified private before anything is written.
17
+ */
18
18
  export type ExportEnvironment = {
19
19
  now: () => Date
20
- temporaryDirectory: string
20
+ pageDirectory: string
21
21
  open: (target: string) => string | null
22
22
  }
23
23
 
24
24
  const systemEnvironment = (): ExportEnvironment => ({
25
25
  now: () => new Date(),
26
- temporaryDirectory: tmpdir(),
26
+ pageDirectory: pageDirectoryOf(systemLocation()),
27
27
  open: (target) => openInBrowser(target),
28
28
  })
29
29
 
@@ -46,10 +46,10 @@ const writePage = (
46
46
  { sprintFile, root, settings }: Source,
47
47
  environment: ExportEnvironment,
48
48
  ): WrittenPage | { error: string } => {
49
- const path = sprintPagePath(environment.temporaryDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
49
+ const path = sprintPagePath(environment.pageDirectory, dirname(boardRepoPathOf(root, settings, sprintFile)))
50
50
  try {
51
- mkdirSync(dirname(path), { recursive: true, mode: DIRECTORY_MODE })
52
- writeFileSync(path, renderSprintHtml(page), { mode: FILE_MODE })
51
+ ensurePrivateDirectory(environment.pageDirectory)
52
+ writePrivateFile(path, renderSprintHtml(page))
53
53
  } catch (error) {
54
54
  return { error: errorText(error) }
55
55
  }
@@ -16,6 +16,7 @@ import type { PlanLanguage } from '../vocabulary'
16
16
  import type { LaneView } from './layout'
17
17
  import { extractLinkChips, type LinkChip } from './link-chips'
18
18
  import { planAnchorOf } from './markdown-html'
19
+ import { mainPhaseIdOf } from './phase-ids'
19
20
  import type { PullRequest } from './pull-requests'
20
21
  import type { Snapshot } from './snapshot'
21
22
 
@@ -150,14 +151,13 @@ const uniqueId = (base: string, taken: Set<string>): string => {
150
151
  }
151
152
 
152
153
  const phaseOf = (phase: PhaseProgress, dependencies: Record<string, string>): PagePhase => {
153
- const numbered = phase.id.replace(/[a-z]$/, '')
154
154
  return {
155
155
  id: phase.id,
156
156
  title: phase.title,
157
157
  done: phase.done,
158
158
  total: phase.total,
159
159
  items: phase.items,
160
- dependsOn: dependencies[phase.id] ?? dependencies[numbered] ?? null,
160
+ dependsOn: dependencies[phase.id] ?? dependencies[mainPhaseIdOf(phase.id)] ?? null,
161
161
  }
162
162
  }
163
163
 
@@ -282,6 +282,6 @@ export const buildSprintPage = (snapshot: Snapshot, context: PageContext): Sprin
282
282
  }
283
283
  }
284
284
 
285
- /** The file the page is written to: `<temporary directory>/jackherd/<board directory>.html`. */
286
- export const sprintPagePath = (temporaryDirectory: string, sprintDirectory: string): string =>
287
- join(temporaryDirectory, 'jackherd', `${posix.basename(sprintDirectory) || 'board'}.html`)
285
+ /** The file the page is written to: `<page directory>/<board directory>.html`. */
286
+ export const sprintPagePath = (pageDirectory: string, sprintDirectory: string): string =>
287
+ join(pageDirectory, `${posix.basename(sprintDirectory) || 'board'}.html`)
@@ -0,0 +1,95 @@
1
+ import { isPhaseOpen, type PlanPhase } from '../plan-format'
2
+ import { PHASE_ID_PREFIX, spannedPhaseIds } from '../plan-format/lanes'
3
+ import { isNamedBy } from './phase-ids'
4
+
5
+ /** The agent types that review a phase; they sit on its review gate. */
6
+ export const REVIEW_TYPE = {
7
+ uncleBob: 'uncle-bob',
8
+ specReview: 'spec-review',
9
+ securityAudit: 'security-audit',
10
+ review: 'review',
11
+ ive: 'ive',
12
+ jobs: 'jobs',
13
+ } as const
14
+
15
+ export type ReviewType = (typeof REVIEW_TYPE)[keyof typeof REVIEW_TYPE]
16
+
17
+ /**
18
+ * The phases a running subagent names in its description, its review type (null for no review), and when its log was
19
+ * last written. `ids` is empty only for a review that names no phase: the lane places it (`PhaselessReview`).
20
+ */
21
+ export type SubagentPhaseRun = { ids: string[]; review: ReviewType | null; at: number }
22
+
23
+ /** A running review whose description names no phase: it sits on the lane's signalled phase, else shows as its description. */
24
+ export type PhaselessReview = { review: ReviewType; at: number; description: string | null }
25
+
26
+ /** What the subagents say about one phase of the lane: the types of the reviews among them, and the newest log write. */
27
+ export type SubagentPhase = { reviews: ReviewType[]; at: number }
28
+
29
+ const isReviewTypeName = (type: string): type is ReviewType => (Object.values(REVIEW_TYPE) as string[]).includes(type)
30
+
31
+ /** `P21`, `P17-P20`, `P17 – 19`, `Phase 21`, `Phase 10c`: the first number, an optional sub-phase letter, an optional end. */
32
+ const PHASE_MENTION = new RegExp(
33
+ `(?:\\b${PHASE_ID_PREFIX}|\\bPhase\\s+)(\\d+)([a-z]?)(?:\\s*[-–—]\\s*(?:${PHASE_ID_PREFIX})?(\\d+)\\b)?`,
34
+ 'gi',
35
+ )
36
+
37
+ /** The ids of a range `first`-`last`; a range that runs backwards or is absurdly wide names only its first end. */
38
+ const rangeIds = (first: number, last: number): string[] => {
39
+ const spanned = spannedPhaseIds(first, last)
40
+ return spanned.length > 0 ? spanned : [String(first)]
41
+ }
42
+
43
+ /** The ids of the phases a description names, in the order named; a range names every phase between its ends. */
44
+ export const phaseIdsOfDescription = (description: string): string[] =>
45
+ [...description.matchAll(PHASE_MENTION)].flatMap(([, first = '', letter = '', last]) =>
46
+ last === undefined ? [`${Number(first)}${letter.toLowerCase()}`] : rangeIds(Number(first), Number(last)),
47
+ )
48
+
49
+ const withoutPrefix = (type: string, prefixes: readonly string[]): string => {
50
+ const prefix = prefixes.find((each) => each !== '' && type.startsWith(each))
51
+ return prefix === undefined ? type : type.slice(prefix.length)
52
+ }
53
+
54
+ /** The `REVIEW_TYPE` an agent type is, plain or with one of the skill `prefixes` of a copy; null for any other type. */
55
+ export const reviewTypeOf = (type: string | null, prefixes: readonly string[]): ReviewType | null => {
56
+ const plain = type === null ? null : withoutPrefix(type, prefixes)
57
+ return plain !== null && isReviewTypeName(plain) ? plain : null
58
+ }
59
+
60
+ /**
61
+ * The run of a subagent from its `description` and `agentType`: null when the description names no phase and the
62
+ * subagent is no review. The description is read whole, not cut for the display.
63
+ */
64
+ export const subagentPhaseRunOf = (
65
+ description: string | null,
66
+ agentType: string | null,
67
+ at: number,
68
+ reviewPrefixes: readonly string[],
69
+ ): SubagentPhaseRun | null => {
70
+ const ids = description === null ? [] : phaseIdsOfDescription(description)
71
+ const review = reviewTypeOf(agentType, reviewPrefixes)
72
+ return ids.length === 0 && review === null ? null : { ids, review, at }
73
+ }
74
+
75
+ const names = (run: SubagentPhaseRun, id: string): boolean => isNamedBy(id, new Set(run.ids))
76
+
77
+ const reviewsOf = (runs: readonly SubagentPhaseRun[]): ReviewType[] => [
78
+ ...new Set(runs.flatMap(({ review }) => (review === null ? [] : [review]))),
79
+ ]
80
+
81
+ /**
82
+ * The open phases of the lane that running subagents name, by phase id: a sub-phase (`21b`) is named by its main phase.
83
+ * Phases the lane does not hold are left out, since another lane's phases are not its own.
84
+ */
85
+ export const subagentPhases = (
86
+ phases: readonly Pick<PlanPhase, 'id' | 'items'>[],
87
+ runs: readonly SubagentPhaseRun[],
88
+ ): Map<string, SubagentPhase> =>
89
+ new Map(
90
+ phases.filter(isPhaseOpen).flatMap(({ id }) => {
91
+ const naming = runs.filter((run) => names(run, id))
92
+ if (naming.length === 0) return []
93
+ return [[id, { reviews: reviewsOf(naming), at: Math.max(...naming.map(({ at }) => at)) }]]
94
+ }),
95
+ )
@@ -0,0 +1,51 @@
1
+ import type { ActiveTask } from './active-tasks'
2
+ import type { TaskPhase } from './current-task'
3
+ import { taskNameOf } from './task-name'
4
+
5
+ /** A running subagent that is no review: its description as the herd shows it, and when its log was last written. */
6
+ export type DescribedSubagent = { description: string; at: number }
7
+
8
+ /** Where the subagents sit: their tasks by phase id, and the descriptions placed, which show no line of their own. */
9
+ export type SubagentTaskPlacements = { byPhase: Map<string, ActiveTask[]>; placed: string[] }
10
+
11
+ type NamedTask = { phaseId: string; index: number; name: string }
12
+
13
+ /** A letter or digit right after the name continues a word, so `Probe` does not name `Proben lesen`. */
14
+ const WORD_GOES_ON = /^[\p{L}\p{N}]/u
15
+
16
+ /** The open tasks that carry a short name, the longest names first, so a name that starts another loses to it. */
17
+ const namedOpenTasks = (phases: readonly TaskPhase[]): NamedTask[] =>
18
+ phases
19
+ .flatMap(({ id, items }) =>
20
+ items.flatMap((item, index) => {
21
+ const name = item.done || item.gate ? null : taskNameOf(item.text)
22
+ return name === null ? [] : [{ phaseId: id, index, name }]
23
+ }),
24
+ )
25
+ .sort((first, second) => second.name.length - first.name.length)
26
+
27
+ const startsWithName = (description: string, name: string): boolean =>
28
+ description.toLowerCase().startsWith(name.toLowerCase()) && !WORD_GOES_ON.test(description.slice(name.length))
29
+
30
+ /**
31
+ * The open task each subagent works on, from the short name its description starts with (`/implement` Step 0b, case
32
+ * ignored): it gets its `▶` like a reported task, one per task however many subagents share it. A ticked task and a
33
+ * review gate item take none; a subagent whose description starts with no name stays where it was.
34
+ */
35
+ export const subagentTaskPlacements = (
36
+ phases: readonly TaskPhase[],
37
+ subagents: readonly DescribedSubagent[],
38
+ ): SubagentTaskPlacements => {
39
+ const tasks = namedOpenTasks(phases)
40
+ const byPhase = new Map<string, ActiveTask[]>()
41
+ const placed: string[] = []
42
+ for (const { description, at } of subagents) {
43
+ const task = tasks.find(({ name }) => startsWithName(description, name))
44
+ if (task === undefined) continue
45
+ placed.push(description)
46
+ const known = byPhase.get(task.phaseId) ?? []
47
+ if (known.some(({ index }) => index === task.index)) continue
48
+ byPhase.set(task.phaseId, [...known, { index: task.index, agent: null, guessed: false, startedAt: at }])
49
+ }
50
+ return { byPhase, placed }
51
+ }