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
@@ -0,0 +1,99 @@
1
+ import { BAR_SEGMENT, INVERSE_ON, paint, RESET, stripAnsi, visibleLength } from '../terminal/text'
2
+ import type { ClickTarget, Selection } from './selection'
3
+
4
+ /**
5
+ * The lines of a frame, the line holding the selected row, a phase or a task (-1 when none), so the window can
6
+ * follow it, and the spans a click expands or closes.
7
+ */
8
+ export type Frame = { lines: string[]; cursorLine: number; targets: ClickTarget[] }
9
+
10
+ /** What goes in front of a frame's lines: `first` on its first line, `rest` on the others, both drawn in `style`. */
11
+ export type Lead = { first: string; rest: string; style: (text: string) => string }
12
+
13
+ /** The tree lines: a branch to a sibling that has more below it, the last branch, and what carries on beside them. */
14
+ export const TREE = { branch: '├─ ', last: '└─ ', pipe: '│ ', gap: ' ', trunk: '│' } as const
15
+ const TREE_GUTTER = TREE.branch.length
16
+
17
+ export const textFrame = (lines: string[]): Frame => ({ lines, cursorLine: -1, targets: [] })
18
+
19
+ /** Stacks frames, moving each one's cursor line and click targets below the frames before it. */
20
+ export const joinFrames = (frames: Frame[]): Frame =>
21
+ frames.reduce<Frame>((joined, frame) => {
22
+ const offset = joined.lines.length
23
+ return {
24
+ lines: [...joined.lines, ...frame.lines],
25
+ cursorLine: frame.cursorLine === -1 ? joined.cursorLine : offset + frame.cursorLine,
26
+ targets: [...joined.targets, ...frame.targets.map((target) => ({ ...target, line: offset + target.line }))],
27
+ }
28
+ }, textFrame([]))
29
+
30
+ /**
31
+ * A frame moved right by its lead, as many cells as `first` is wide; an empty line takes the lead without its
32
+ * trailing blanks. A click target over the whole line keeps the left edge and takes the lead in.
33
+ */
34
+ export const prefixed = (frame: Frame, { first, rest, style }: Lead): Frame => {
35
+ const gutter = visibleLength(first)
36
+ return {
37
+ lines: frame.lines.map((line, index) => {
38
+ const lead = index === 0 ? first : rest
39
+ return line === '' ? style(lead.trimEnd()) : `${style(lead)}${line}`
40
+ }),
41
+ cursorLine: frame.cursorLine,
42
+ targets: frame.targets.map((target) => ({
43
+ ...target,
44
+ from: target.wholeLine ? 0 : target.from + gutter,
45
+ to: target.to + gutter,
46
+ })),
47
+ }
48
+ }
49
+
50
+ /** The context of the children of a node: the pane minus the tree lines in front of them. */
51
+ export const deeper = <Context extends { width: number }>(context: Context): Context => ({
52
+ ...context,
53
+ width: Math.max(context.width - TREE_GUTTER, 1),
54
+ })
55
+
56
+ const branches = (children: Frame[]): Frame[] => {
57
+ const present = children.filter((child) => child.lines.length > 0)
58
+ return present.map((child, index) => {
59
+ const last = index === present.length - 1
60
+ return prefixed(child, {
61
+ first: last ? TREE.last : TREE.branch,
62
+ rest: last ? TREE.gap : TREE.pipe,
63
+ style: paint.dim,
64
+ })
65
+ })
66
+ }
67
+
68
+ /**
69
+ * The children of a node as a tree: each child's first line behind a branch, the last behind the closing one, the
70
+ * lines of a child after its first beside the trunk. The caller has drawn the children `TREE_GUTTER` narrower
71
+ * (`deeper`); a child without lines leaves no branch.
72
+ */
73
+ export const nest = (children: Frame[]): Frame => joinFrames(branches(children))
74
+
75
+ /**
76
+ * A row drawn inverse across the pane. A progress bar inside it is switched back to normal video: inverted,
77
+ * its filled and empty blocks would trade places with the background and the bar would be unreadable.
78
+ */
79
+ const highlight = (line: string, width: number): string => {
80
+ const bars = line.match(BAR_SEGMENT) ?? []
81
+ const texts = line.split(BAR_SEGMENT).map(stripAnsi)
82
+ const body = texts.map((text, index) => `${text}${bars[index] ? `${bars[index]}${INVERSE_ON}` : ''}`).join('')
83
+ return `${INVERSE_ON}${body}${' '.repeat(Math.max(width - visibleLength(line), 0))}${RESET}`
84
+ }
85
+
86
+ /** A selectable row: drawn inverse when selected, clickable across the pane on every line, `below` follows unselectable. */
87
+ export const rowFrame = (
88
+ key: string,
89
+ rows: string[],
90
+ below: string[],
91
+ { state, width }: { state: Selection; width: number },
92
+ ): Frame => {
93
+ const selected = state.selected === key
94
+ return {
95
+ lines: [...(selected ? rows.map((row) => highlight(row, width)) : rows), ...below],
96
+ cursorLine: selected ? 0 : -1,
97
+ targets: rows.map((_, line) => ({ line, from: 0, to: width, key, wholeLine: true as const })),
98
+ }
99
+ }
package/src/plans/git.ts CHANGED
@@ -14,6 +14,14 @@ const git = (cwd: string, args: string[]): string | null => {
14
14
  return result.exitCode === 0 ? result.stdout.toString().trim() : null
15
15
  }
16
16
 
17
+ /** What git says about a file in a checkout: its porcelain status and the short commit that last changed it. */
18
+ export type FileGitFacts = { status: string | null; commit: string | null }
19
+
20
+ export const fileGitFacts = (root: string, path: string): FileGitFacts => ({
21
+ status: git(root, ['status', '--porcelain', '--', path]),
22
+ commit: git(root, ['log', '-1', '--format=%h', '--', path]),
23
+ })
24
+
17
25
  /** The text of `path` at `ref`, or null when the ref or the file does not exist there. */
18
26
  export const showAtRef = (root: string, ref: string, path: string): string | null => {
19
27
  const result = Bun.spawnSync(['git', '-C', root, 'show', `${ref}:${path}`], { stdout: 'pipe', stderr: 'pipe' })
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { z } from 'zod'
5
+ import { repositoryKey } from '../watch/watcher-lock'
6
+ import type { LaneMemory } from './current-phase'
7
+
8
+ const DIRECTORY_PREFIX = 'jackherd-herd'
9
+ const MEMORY_FILE = 'herd-memory.json'
10
+ const PRIVATE_DIRECTORY = 0o700
11
+ const PRIVATE_FILE = 0o600
12
+
13
+ const phaseRef = z.object({ planHref: z.string(), phaseId: z.string() })
14
+
15
+ const laneMemory = z.object({
16
+ ticks: z.record(z.string(), z.record(z.string(), z.string())),
17
+ moved: phaseRef.nullable(),
18
+ row: z.string(),
19
+ activity: z.record(z.string(), z.number()).optional(),
20
+ })
21
+
22
+ const memoryFile = z.record(z.string(), laneMemory)
23
+
24
+ /**
25
+ * The lanes' memories as a file holds them; nothing for a missing, broken or foreign file, so the herd then starts
26
+ * as on its first read and no phase gets a time from it.
27
+ */
28
+ export const parseLaneMemories = (text: string | null): Map<string, LaneMemory> => {
29
+ if (text === null) return new Map()
30
+ try {
31
+ const parsed = memoryFile.safeParse(JSON.parse(text))
32
+ return parsed.success ? new Map(Object.entries(parsed.data)) : new Map()
33
+ } catch {
34
+ return new Map()
35
+ }
36
+ }
37
+
38
+ /**
39
+ * What a herd that read `lanes` writes back: the stored memories with those lanes replaced by its own, a lane it read
40
+ * but no longer remembers dropped; other herds' lanes (another pane, another `--lane`) stay as they were.
41
+ */
42
+ export const mergedMemories = (
43
+ stored: ReadonlyMap<string, LaneMemory>,
44
+ own: ReadonlyMap<string, LaneMemory>,
45
+ lanes: readonly string[],
46
+ ): Record<string, LaneMemory> => {
47
+ const read = new Set(lanes)
48
+ const kept = [...stored].filter(([lane]) => !read.has(lane))
49
+ const fresh = [...own].filter(([lane]) => read.has(lane))
50
+ return Object.fromEntries([...kept, ...fresh])
51
+ }
52
+
53
+ /**
54
+ * Where the herds of the repository at `root` keep what they saw of each lane, one directory per user and
55
+ * repository like the watcher's, so a `--once` run and a restarted herd know when a phase last moved.
56
+ */
57
+ const herdMemoryDirectory = (root: string): string =>
58
+ join(tmpdir(), `${DIRECTORY_PREFIX}-${process.getuid?.() ?? 'user'}`, repositoryKey(root))
59
+
60
+ export const memoryFileOf = (directory: string): string => join(directory, MEMORY_FILE)
61
+
62
+ const readText = (file: string): string | null => {
63
+ try {
64
+ return readFileSync(file, 'utf8')
65
+ } catch {
66
+ return null
67
+ }
68
+ }
69
+
70
+ export const readLaneMemories = (directory: string): Map<string, LaneMemory> =>
71
+ parseLaneMemories(readText(memoryFileOf(directory)))
72
+
73
+ /**
74
+ * Writes the memories of `lanes` into the file of `directory`, beside what other herds keep there; readable by the
75
+ * user only, and replaced in one step so a herd reading at the same time never sees half a file.
76
+ */
77
+ export const writeLaneMemories = (
78
+ directory: string,
79
+ memories: ReadonlyMap<string, LaneMemory>,
80
+ lanes: readonly string[],
81
+ ): void => {
82
+ mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
83
+ const file = memoryFileOf(directory)
84
+ const merged = mergedMemories(readLaneMemories(directory), memories, lanes)
85
+ const partial = `${file}.${process.pid}`
86
+ writeFileSync(partial, JSON.stringify(merged), { mode: PRIVATE_FILE })
87
+ renameSync(partial, file)
88
+ }
89
+
90
+ /** The memories the herds of the repository at `root` kept. */
91
+ export const recallLanes = (root: string): Map<string, LaneMemory> => readLaneMemories(herdMemoryDirectory(root))
92
+
93
+ /**
94
+ * Keeps the memories of `lanes` for the next herd. A memory that cannot be written costs only the times of the next
95
+ * run, which then starts as on its first read, so the view goes on without it.
96
+ */
97
+ export const rememberLanes = (
98
+ root: string,
99
+ memories: ReadonlyMap<string, LaneMemory>,
100
+ lanes: readonly string[],
101
+ ): void => {
102
+ try {
103
+ writeLaneMemories(herdMemoryDirectory(root), memories, lanes)
104
+ } catch {
105
+ return
106
+ }
107
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync } from 'node:fs'
1
+ import { existsSync, statSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { type PlansByHref, parsePlanPhases, resolvePlanPath, type SprintLane } from '../plan-format'
4
4
  import type { BaseReader } from './git'
@@ -52,13 +52,14 @@ export const laneWorkOf = (worktree: string, status: { ahead: number | null; dir
52
52
  * The plans of a lane. The base ref is the truth; while the lane has work of its own in its worktree, the phases
53
53
  * its rows name, and those new there or ticked further, come from the worktree (`mergePlanPhases`). A plan the base ref lacks is read from the lane's worktree, else from the main
54
54
  * checkout (a repository that ignores `docs/` keeps plan and board only there). `files` are the plan files to
55
- * watch for changes.
55
+ * watch for changes, `modified` when each was last written, by plan link (`nextActivity` bounds a movement by it).
56
56
  */
57
57
  export const readPlans = async (lane: SprintLane, sources: PlanSources, work: LaneWork, fallbacks: Fallbacks) => {
58
58
  const { root, settings, sprintDirectory } = sources
59
59
  const plans: PlansByHref = {}
60
60
  const files: string[] = []
61
61
  const texts: Record<string, string> = {}
62
+ const modified: Record<string, number> = {}
62
63
  for (const href of new Set(lane.steps.flatMap((step) => (step.planHref ? [step.planHref] : [])))) {
63
64
  const path = resolvePlanPath(sprintDirectory, href)
64
65
  const worktreeFile = work.worktree ? join(work.worktree, path) : null
@@ -75,6 +76,7 @@ export const readPlans = async (lane: SprintLane, sources: PlanSources, work: La
75
76
  )
76
77
  files.push(file)
77
78
  texts[href] = text
79
+ if (existsSync(file)) modified[href] = statSync(file).mtimeMs
78
80
  }
79
- return { plans, files, texts }
81
+ return { plans, files, texts, modified }
80
82
  }
@@ -0,0 +1,222 @@
1
+ import {
2
+ isPhaseFinished,
3
+ isPhaseOpen,
4
+ isTickedUnmerged,
5
+ type LaneProgress,
6
+ type PhaseProgress,
7
+ type PlanPhase,
8
+ type PlansByHref,
9
+ parseDependencyTable,
10
+ phaseProgress,
11
+ phaseWaves,
12
+ } from '../plan-format'
13
+ import type { PlanLanguage } from '../vocabulary'
14
+ import { type ActivityTimes, activityKey } from './current-phase'
15
+ import { currentTasks, type TaskSignal } from './current-task'
16
+
17
+ /**
18
+ * A phase the lane works on now: an open one whose ticks or task lines moved less than `STALE_ACTIVITY_MINUTES` ago.
19
+ * `stepIndex` is the newest step of the lane that shows it, null for a phase no step shows; `minutesAgo` the whole
20
+ * minutes since that movement.
21
+ */
22
+ export type ActivePhase = {
23
+ phase: PhaseProgress
24
+ stepIndex: number | null
25
+ minutesAgo: number
26
+ /** The index of the task the phase is on in its checkboxes (`currentTasks`); null when none is open. */
27
+ taskIndex: number | null
28
+ }
29
+
30
+ /** The phases that come next: the open ones of the next wave, `wave` its 1-based number, null without a dependency table. */
31
+ export type NextPhases = { wave: number | null; phaseIds: string[] }
32
+
33
+ /** Open phases after the next ones; `planTitle` is null for the lane's current plan, the board's title for another. */
34
+ export type LaterPhases = { planTitle: string | null; phaseIds: string[] }
35
+
36
+ export type FinishedPhase = { id: string; unmerged: boolean }
37
+
38
+ /** A phase the lane's board rows name that has nothing to run: `missing` when the plan lacks it, else it has no checkbox. */
39
+ export type UnwrittenPhase = { id: string; missing: boolean }
40
+
41
+ /** Checkboxes and phases over every plan of the lane, a phase counted when it has a checkbox. */
42
+ export type PlanTally = { done: number; total: number; phasesDone: number; phases: number }
43
+
44
+ /** A lane as the herd sums it up: what runs now, what comes next and after, what is finished, and how far it is. */
45
+ export type LaneSummary = {
46
+ /** The running phases, the one moved last first. */
47
+ active: ActivePhase[]
48
+ /** The open phase of the current plan moved last, at any age; null while none has a time. */
49
+ latest: PhaseProgress | null
50
+ next: NextPhases | null
51
+ later: LaterPhases[]
52
+ finished: FinishedPhase[]
53
+ unwritten: UnwrittenPhase[]
54
+ tally: PlanTally
55
+ }
56
+
57
+ /**
58
+ * `phases` are the lane's plans as it reads them (the base merged with its worktree), `texts` their texts for the
59
+ * dependency tables; a plan missing from `phases` falls back to the phases the lane's steps show.
60
+ */
61
+ export type SummaryInput = {
62
+ progress: LaneProgress
63
+ phases: Readonly<PlansByHref>
64
+ texts: Readonly<Record<string, string>>
65
+ activity: ActivityTimes
66
+ /** The language the plans were written in, for reading their dependency tables; the herd's words are English. */
67
+ language: PlanLanguage
68
+ /** Hints at the task a phase is on, by phase id; none when absent, and no source writes one yet. */
69
+ signals?: readonly TaskSignal[]
70
+ }
71
+
72
+ /** Minutes without a tick or a new task line after which a phase no longer runs. */
73
+ export const STALE_ACTIVITY_MINUTES = 20
74
+
75
+ const MINUTE = 60_000
76
+
77
+ const isWritten = ({ total }: PhaseProgress): boolean => total > 0
78
+
79
+ const planHrefs = ({ steps }: LaneProgress): string[] => [
80
+ ...new Set(steps.flatMap(({ step }) => (step.planHref ? [step.planHref] : []))),
81
+ ]
82
+
83
+ /** The plan of the lane's current phase, else its first plan. */
84
+ const focusHref = (progress: LaneProgress): string | null => {
85
+ const current = progress.current ? progress.steps[progress.current.stepIndex]?.step.planHref : null
86
+ return current ?? planHrefs(progress)[0] ?? null
87
+ }
88
+
89
+ /** The phases the steps on `href` show, each once, when the plan itself was not read. */
90
+ const stepPhases = ({ steps }: LaneProgress, href: string): PhaseProgress[] => {
91
+ const shown = steps.filter(({ step }) => step.planHref === href).flatMap(({ phases }) => phases)
92
+ return shown.filter((phase, index) => shown.findIndex(({ id }) => id === phase.id) === index)
93
+ }
94
+
95
+ const phasesOf = (input: SummaryInput, href: string): PhaseProgress[] => {
96
+ const plan: readonly PlanPhase[] | undefined = input.phases[href]
97
+ return plan ? plan.map(({ id }) => phaseProgress(plan, id)) : stepPhases(input.progress, href)
98
+ }
99
+
100
+ const newestStepShowing = ({ steps }: LaneProgress, href: string, id: string): number | null => {
101
+ const index = steps
102
+ .map(({ step, phases }) => step.planHref === href && phases.some((phase) => phase.id === id))
103
+ .lastIndexOf(true)
104
+ return index === -1 ? null : index
105
+ }
106
+
107
+ type TimedPhase = { phase: PhaseProgress; minutesAgo: number }
108
+
109
+ /** The open phases with a time of their last movement, the one moved last first; a phase without a time is absent. */
110
+ const timedPhases = (input: SummaryInput, href: string, phases: PhaseProgress[], now: number): TimedPhase[] =>
111
+ phases
112
+ .filter(isPhaseOpen)
113
+ .flatMap((phase) => {
114
+ const at = input.activity[activityKey(href, phase.id)]
115
+ return at === undefined ? [] : [{ phase, minutesAgo: Math.max(Math.floor((now - at) / MINUTE), 0) }]
116
+ })
117
+ .sort((first, second) => first.minutesAgo - second.minutesAgo)
118
+
119
+ /** The timed phases that moved less than `STALE_ACTIVITY_MINUTES` ago, each with the task it is on. */
120
+ const activeOf = (input: SummaryInput, href: string, timed: TimedPhase[], now: number): ActivePhase[] => {
121
+ const running = timed.filter(({ minutesAgo }) => minutesAgo < STALE_ACTIVITY_MINUTES)
122
+ const tasks = currentTasks(
123
+ running.map(({ phase }) => phase),
124
+ input.signals ?? [],
125
+ now,
126
+ STALE_ACTIVITY_MINUTES * MINUTE,
127
+ )
128
+ return running.map(({ phase, minutesAgo }) => ({
129
+ phase,
130
+ stepIndex: newestStepShowing(input.progress, href, phase.id),
131
+ minutesAgo,
132
+ taskIndex: tasks.get(phase.id) ?? null,
133
+ }))
134
+ }
135
+
136
+ /**
137
+ * The open phases of the first wave that holds one not running; without a dependency table, phases run one after the
138
+ * other and the next is the first open one not running. Null when nothing is left, or no wave holds what is left.
139
+ */
140
+ const nextOf = (
141
+ input: SummaryInput,
142
+ href: string,
143
+ phases: PhaseProgress[],
144
+ running: ReadonlySet<string>,
145
+ ): NextPhases | null => {
146
+ const waiting = phases.filter((phase) => isPhaseOpen(phase) && !running.has(phase.id)).map(({ id }) => id)
147
+ const text = input.texts[href]
148
+ const rows = text === undefined ? null : parseDependencyTable(text, input.language)
149
+ if (rows === null) return waiting.length > 0 ? { wave: null, phaseIds: [waiting[0]] } : null
150
+ const done = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
151
+ const waves = phaseWaves(rows, done)
152
+ const index = waves.findIndex((wave) => wave.some((id) => waiting.includes(id)))
153
+ return index === -1 ? null : { wave: index + 1, phaseIds: waves[index].filter((id) => waiting.includes(id)) }
154
+ }
155
+
156
+ const tallyOf = (phases: PhaseProgress[]): PlanTally => ({
157
+ done: phases.reduce((sum, phase) => sum + phase.done, 0),
158
+ total: phases.reduce((sum, phase) => sum + phase.total, 0),
159
+ phasesDone: phases.filter(isPhaseFinished).length,
160
+ phases: phases.filter(isWritten).length,
161
+ })
162
+
163
+ const titleOf = ({ steps }: LaneProgress, href: string): string =>
164
+ steps.find(({ step }) => step.planHref === href)?.step.planTitle ?? href
165
+
166
+ /** The open phases of the lane's other plans, each plan by its title. */
167
+ const otherPlans = (input: SummaryInput, hrefs: string[]): LaterPhases[] =>
168
+ hrefs.flatMap((href) => {
169
+ const open = phasesOf(input, href).filter(isPhaseOpen)
170
+ return open.length > 0 ? [{ planTitle: titleOf(input.progress, href), phaseIds: open.map(({ id }) => id) }] : []
171
+ })
172
+
173
+ /** The open phases of the current plan that neither run nor come next, then the open phases of the lane's other plans. */
174
+ const laterOf = (
175
+ input: SummaryInput,
176
+ href: string,
177
+ phases: PhaseProgress[],
178
+ upcoming: ReadonlySet<string>,
179
+ ): LaterPhases[] => {
180
+ const waiting = phases
181
+ .filter((phase) => isPhaseOpen(phase) && !upcoming.has(phase.id))
182
+ .map(({ id }) => id)
183
+ .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
184
+ const others = otherPlans(
185
+ input,
186
+ planHrefs(input.progress).filter((each) => each !== href),
187
+ )
188
+ return [...(waiting.length > 0 ? [{ planTitle: null, phaseIds: waiting }] : []), ...others]
189
+ }
190
+
191
+ /** The finished phases of the current plan, each marked while its ticks are not merged. */
192
+ const finishedOf = (phases: PhaseProgress[]): FinishedPhase[] =>
193
+ phases.filter(isPhaseFinished).map((phase) => ({ id: phase.id, unmerged: isTickedUnmerged(phase) }))
194
+
195
+ /**
196
+ * The lane summed up over its plans at `now`: the open phases running in its current plan (moved less than
197
+ * `STALE_ACTIVITY_MINUTES` ago; a phase without a time of its last movement never runs), the open phase moved last,
198
+ * the next wave's open phases, the other open phases, the finished ones (marked when not merged yet), the phases its
199
+ * rows name with nothing to run, and the tally over every plan of the lane. A phase ticked to the end, review gate
200
+ * included, is finished, never running.
201
+ */
202
+ export const laneSummary = (input: SummaryInput, now: number): LaneSummary => {
203
+ const href = focusHref(input.progress)
204
+ const tally = tallyOf(planHrefs(input.progress).flatMap((each) => phasesOf(input, each)))
205
+ if (href === null) return { active: [], latest: null, next: null, later: [], finished: [], unwritten: [], tally }
206
+ const phases = phasesOf(input, href)
207
+ const timed = timedPhases(input, href, phases, now)
208
+ const active = activeOf(input, href, timed, now)
209
+ const running = new Set(active.map(({ phase }) => phase.id))
210
+ const next = nextOf(input, href, phases, running)
211
+ return {
212
+ active,
213
+ latest: timed[0]?.phase ?? null,
214
+ next,
215
+ later: laterOf(input, href, phases, new Set([...running, ...(next?.phaseIds ?? [])])),
216
+ finished: finishedOf(phases),
217
+ unwritten: stepPhases(input.progress, href)
218
+ .filter((phase) => !isWritten(phase))
219
+ .map(({ id, title }) => ({ id, missing: title === null })),
220
+ tally,
221
+ }
222
+ }
@@ -0,0 +1,148 @@
1
+ import type { ChecklistItem, DeferredItem, LaneProgress, PhaseProgress } from '../plan-format'
2
+ import { onlyDocumentationOpen, phaseLabel } from '../plan-format'
3
+ import { fit, padVisible, paint, wrap } from '../terminal/text'
4
+ import { textWidth } from '../terminal/width'
5
+ import type { WatchBadges } from '../watch/plan-badges'
6
+ import { type Frame, rowFrame, textFrame } from './frame'
7
+ import type { LaneTickets } from './lane-tickets'
8
+ import type { LinkChip } from './link-chips'
9
+ import type { PhaseModel } from './phase-models'
10
+ import { itemKey, type Selection, tickedKey } from './selection'
11
+ import { chevronOf, MODEL_MARK, PHASE_COUNT, PHASE_STATE, type PhaseState, phaseStateOf } from './symbols'
12
+ import { type BadgeTexts, TEXTS } from './texts'
13
+
14
+ /** What every block of one lane is drawn with. */
15
+ export type LaneContext = {
16
+ progress: LaneProgress
17
+ state: Selection
18
+ width: number
19
+ chips: Record<string, LinkChip[]>
20
+ badges: WatchBadges
21
+ badgeTexts: BadgeTexts
22
+ /** The lane's tickets for the plan block that shows them, the first; null in every other block. */
23
+ tickets: LaneTickets | null
24
+ /** The model each phase ran on, by `phaseModelKey`. */
25
+ models: Record<string, PhaseModel>
26
+ }
27
+
28
+ export const ACTIVE_MARK = '▶'
29
+ export const DEFERRED_MARK = '→'
30
+ export const COLUMN_SEPARATOR = ' '
31
+ export const MIN_TITLE_WIDTH = 8
32
+
33
+ /** Places on each side of the slash of a count: ` 9/9 `, ` 15/15 `, `123/456`. */
34
+ const COUNT_PLACES = 3
35
+ /** The cells before a count: `◐`, `✓`, `?`, `–` or nothing, padded so every slash on screen is in one column. */
36
+ const COUNT_MARK_WIDTH = 2
37
+ const BLANK_COUNT = ' '.repeat(2 * COUNT_PLACES + 1)
38
+ const COUNT_CELL_WIDTH = COUNT_MARK_WIDTH + 1 + BLANK_COUNT.length
39
+ const MARKDOWN_MARKS = /\*\*|`/g
40
+
41
+ const PHASE_PAINT: Record<PhaseState, (count: string) => string> = {
42
+ [PHASE_STATE.missing]: paint.dim,
43
+ [PHASE_STATE.empty]: paint.dim,
44
+ [PHASE_STATE.all]: paint.green,
45
+ [PHASE_STATE.open]: (count) => count,
46
+ }
47
+
48
+ const PHASE_MARK: Record<PhaseState, string> = {
49
+ [PHASE_STATE.missing]: PHASE_COUNT.missing,
50
+ [PHASE_STATE.empty]: PHASE_COUNT.empty,
51
+ [PHASE_STATE.all]: '',
52
+ [PHASE_STATE.open]: '',
53
+ }
54
+
55
+ export const plain = (text: string): string => text.replace(MARKDOWN_MARKS, '')
56
+
57
+ export const countText = (done: number, total: number): string =>
58
+ `${String(done).padStart(COUNT_PLACES)}/${String(total).padEnd(COUNT_PLACES)}`
59
+
60
+ /** Mark and count in one fixed-width cell at the right edge, the same for the lane, phase and planned rows. */
61
+ export const countCell = (mark: string, count: string): string => `${padVisible(mark, COUNT_MARK_WIDTH)} ${count}`
62
+
63
+ /** A word in place of a count, right-aligned in the width of the count cell like `n/m`. */
64
+ const wordCell = (word: string): string => `${' '.repeat(Math.max(COUNT_CELL_WIDTH - textWidth(word), 0))}${word}`
65
+
66
+ /** The count cell of a phase row at the right edge; the current phase is bold yellow. A phase lacking only the documentation box says so instead of a count. */
67
+ export const phaseCountCell = (phase: PhaseProgress, current: boolean): string => {
68
+ const state = phaseStateOf(phase)
69
+ if (state === PHASE_STATE.open && onlyDocumentationOpen(phase)) {
70
+ const cell = wordCell(TEXTS.allButDocs)
71
+ return current ? paint.bold(paint.yellow(cell)) : paint.green(cell)
72
+ }
73
+ const hasCount = state === PHASE_STATE.open || state === PHASE_STATE.all
74
+ const cell = countCell(PHASE_MARK[state], hasCount ? countText(phase.done, phase.total) : BLANK_COUNT)
75
+ return current ? paint.bold(paint.yellow(cell)) : PHASE_PAINT[state](cell)
76
+ }
77
+
78
+ const familyLabel = (family: string): string => `${family.charAt(0).toUpperCase()}${family.slice(1)}`
79
+
80
+ /**
81
+ * The model the phase's subagent last ran on, only where it differs from the plan's recommendation: `Sonnet ≠ Opus`
82
+ * in amber; empty without a record, without a recommendation, or when it fits.
83
+ */
84
+ export const modelMark = (model: PhaseModel | undefined): string =>
85
+ model?.recommended && model.fits === false
86
+ ? paint.yellow(`${familyLabel(model.used)} ${MODEL_MARK.differs} ${familyLabel(model.recommended)}`)
87
+ : ''
88
+
89
+ /** `text` wrapped beside a one-cell `mark`, each line in `style`; follow-up lines indent under the text. */
90
+ const markedLines = (text: string, mark: string, style: (line: string) => string, width: number): string[] =>
91
+ wrap(plain(text), Math.max(width - 2, 1)).map((line, index) => `${index > 0 ? ' ' : mark} ${style(line)}`)
92
+
93
+ /** The mark and the text style of a task: a ticked one green and dim, the one being worked on yellow and bold. */
94
+ const taskLook = ({ done }: ChecklistItem, active: boolean): { mark: string; style: (line: string) => string } => {
95
+ if (active) return { mark: paint.yellow(ACTIVE_MARK), style: paint.bold }
96
+ return done ? { mark: paint.green('☑'), style: paint.dim } : { mark: '☐', style: (line) => line }
97
+ }
98
+
99
+ /** A closed task as one row, cut at the pane's edge with `…`. */
100
+ const closedTaskRow = (item: ChecklistItem, active: boolean, width: number): string[] => {
101
+ const { mark, style } = taskLook(item, active)
102
+ return [fit(`${mark} ${style(plain(item.text))}`, width)]
103
+ }
104
+
105
+ /** An open task as rows: its whole text wrapped. */
106
+ const openTaskRows = (item: ChecklistItem, active: boolean, width: number): string[] => {
107
+ const { mark, style } = taskLook(item, active)
108
+ return markedLines(item.text, mark, style, width)
109
+ }
110
+
111
+ /** A task as a selectable row under its `key`: clickable across every line it takes, the cursor on its first. */
112
+ export const taskFrame = (key: string, rows: string[], context: Pick<LaneContext, 'state' | 'width'>): Frame =>
113
+ rowFrame(key, rows, [], context)
114
+
115
+ /** A checkbox moved to another phase, dim: `→ P9 <text>`. */
116
+ const deferredRow = ({ text, target }: DeferredItem, width: number): string =>
117
+ fit(paint.dim(`${DEFERRED_MARK} ${phaseLabel(target)} ${plain(text)}`), width)
118
+
119
+ const tickedSummaryFrame = (count: number, phaseRowKey: string, width: number): Frame => ({
120
+ lines: [fit(paint.dim(`${chevronOf(false)} ${TEXTS.tickedSummary(count)}`), width)],
121
+ cursorLine: -1,
122
+ targets: [{ line: 0, from: 0, to: width, key: tickedKey(phaseRowKey), wholeLine: true }],
123
+ })
124
+
125
+ /**
126
+ * The children of an open phase, one frame per task: open ones first, `active` marked among them, then the ones
127
+ * deferred to another phase, then the ticked ones listed or summed up.
128
+ */
129
+ export const phaseChildren = (
130
+ context: LaneContext,
131
+ phaseRowKey: string,
132
+ phase: PhaseProgress,
133
+ active: ChecklistItem | null,
134
+ ): Frame[] => {
135
+ const { state, width } = context
136
+ const frameOf = (item: ChecklistItem, index: number): Frame => {
137
+ const key = itemKey(phaseRowKey, index)
138
+ const isActive = item === active
139
+ const rows = state.expanded.has(key) ? openTaskRows(item, isActive, width) : closedTaskRow(item, isActive, width)
140
+ return taskFrame(key, rows, context)
141
+ }
142
+ const framesWhere = (done: boolean): Frame[] =>
143
+ phase.items.flatMap((item, index) => (item.done === done ? [frameOf(item, index)] : []))
144
+ const tickedCount = phase.items.filter((item) => item.done).length
145
+ const summary = tickedCount > 0 ? [tickedSummaryFrame(tickedCount, phaseRowKey, width)] : []
146
+ const deferred = (phase.deferred ?? []).map((item) => textFrame([deferredRow(item, width)]))
147
+ return [...framesWhere(false), ...deferred, ...(state.showDone ? framesWhere(true) : summary)]
148
+ }