jackherd 1.8.0 → 1.9.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 (131) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +41 -60
  4. package/assets/branding/README.md +5 -7
  5. package/assets/branding/jackherd-logo-outline.svg +1 -0
  6. package/assets/branding/outline.test.ts +30 -0
  7. package/assets/icons/bark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide.svg +1 -1
  10. package/assets/icons/ear.svg +1 -1
  11. package/assets/icons/lanes.svg +1 -1
  12. package/assets/icons/plan.svg +1 -1
  13. package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +19 -3
  16. package/dist/plan-format.d.ts +22 -3
  17. package/dist/plan-format.js +21 -7
  18. package/herdr-plugin/herdr-plugin.toml +1 -1
  19. package/install.sh +1 -1
  20. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  21. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  22. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  23. package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
  24. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  25. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  26. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  27. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  28. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  29. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  30. package/kit/hooks/hooks.json +9 -0
  31. package/kit/hooks/statusline.sh +1 -1
  32. package/kit/workflow/skills/implement/SKILL.md +2 -0
  33. package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
  34. package/package.json +3 -1
  35. package/schemas/jackherd.schema.json +26 -2
  36. package/src/cli.ts +3 -0
  37. package/src/config/read-config.ts +5 -1
  38. package/src/config/schema.ts +22 -2
  39. package/src/context/fill.ts +12 -0
  40. package/src/context/session-log.ts +43 -1
  41. package/src/plan-format/clock.ts +4 -0
  42. package/src/plan-format/dependencies.ts +2 -5
  43. package/src/plan-format/lanes.ts +30 -5
  44. package/src/plan-format/terminal-text.ts +4 -0
  45. package/src/plan-format/waiting.ts +48 -0
  46. package/src/plans/active-tasks.ts +60 -0
  47. package/src/plans/current-phase.ts +17 -7
  48. package/src/plans/current-task.ts +63 -5
  49. package/src/plans/lane-standing.ts +12 -6
  50. package/src/plans/lane-summary.ts +141 -74
  51. package/src/plans/lane-tickets.ts +15 -0
  52. package/src/plans/layout-phase.ts +57 -18
  53. package/src/plans/layout-summary.ts +85 -67
  54. package/src/plans/layout.ts +253 -89
  55. package/src/plans/live.ts +22 -0
  56. package/src/plans/phase-models.ts +1 -1
  57. package/src/plans/phase-ranges.ts +45 -0
  58. package/src/plans/pull-requests.ts +6 -0
  59. package/src/plans/selection.ts +19 -16
  60. package/src/plans/sent-files-read.ts +22 -0
  61. package/src/plans/sent-files.ts +121 -0
  62. package/src/plans/signals-read.ts +54 -0
  63. package/src/plans/snapshot.ts +251 -55
  64. package/src/plans/subagents-read.ts +82 -0
  65. package/src/plans/subagents.ts +132 -0
  66. package/src/plans/texts.ts +16 -11
  67. package/src/plans/unlisted-lanes.ts +10 -5
  68. package/src/plans/view.ts +3 -3
  69. package/src/plans/waiting-click.ts +76 -0
  70. package/src/preview/command.ts +83 -0
  71. package/src/preview/embed.ts +209 -0
  72. package/src/preview/images.ts +89 -0
  73. package/src/preview/open.ts +12 -0
  74. package/src/preview/options.ts +32 -0
  75. package/src/preview/page.ts +89 -0
  76. package/src/preview/render.ts +41 -0
  77. package/src/settled/board-row.ts +1 -2
  78. package/src/shared/is-record.ts +2 -0
  79. package/src/shared/private-records.ts +48 -0
  80. package/src/task-signals/agent.ts +15 -0
  81. package/src/task-signals/claude-read.ts +72 -0
  82. package/src/task-signals/claude-signals.ts +112 -0
  83. package/src/task-signals/command.ts +71 -0
  84. package/src/task-signals/fixtures/session.jsonl +7 -0
  85. package/src/task-signals/matcher.ts +96 -0
  86. package/src/task-signals/record-store.ts +40 -0
  87. package/src/task-signals/record.ts +68 -0
  88. package/src/task-signals/text.ts +13 -0
  89. package/src/task-signals/types.ts +14 -0
  90. package/src/terminal/open.ts +13 -6
  91. package/src/terminal/text.ts +8 -1
  92. package/src/terminal/width.ts +2 -1
  93. package/src/update/command.ts +2 -0
  94. package/src/update/config-shares.ts +30 -0
  95. package/src/update/update.ts +12 -1
  96. package/src/vocabulary/index.ts +33 -1
  97. package/src/waiting/command.ts +35 -0
  98. package/src/watch/appointments.ts +5 -5
  99. package/src/watch/approvals.ts +2 -2
  100. package/src/watch/command.ts +91 -5
  101. package/src/watch/failed-tests.ts +56 -10
  102. package/src/watch/failure-read.ts +18 -6
  103. package/src/watch/failures.ts +29 -5
  104. package/src/watch/feedback.ts +1 -1
  105. package/src/watch/gh-process.ts +9 -3
  106. package/src/watch/json-guards.ts +0 -3
  107. package/src/watch/lane-fill.ts +1 -1
  108. package/src/watch/lane-model.ts +1 -1
  109. package/src/watch/lane-parallel.ts +1 -1
  110. package/src/watch/lane-progress.ts +19 -7
  111. package/src/watch/lane-report.ts +5 -1
  112. package/src/watch/lane-rest.ts +28 -0
  113. package/src/watch/plan-badges.ts +9 -2
  114. package/src/watch/previews.ts +1 -1
  115. package/src/watch/pull-requests.ts +2 -1
  116. package/src/watch/source-path.ts +1 -1
  117. package/src/watch/sources.ts +1 -1
  118. package/src/watch/stream-output.ts +12 -7
  119. package/src/watch/task-parallel-input.ts +118 -0
  120. package/src/watch/task-parallel-read.ts +149 -0
  121. package/src/watch/task-parallel-touched.ts +39 -0
  122. package/src/watch/task-parallel.ts +206 -0
  123. package/src/watch/waiting-line.ts +19 -0
  124. package/src/watch/watch-events.ts +13 -1
  125. package/src/watch/workflow-runs.ts +2 -1
  126. package/assets/icons/bark-dark.svg +0 -1
  127. package/assets/icons/bowl-dark.svg +0 -1
  128. package/assets/icons/decide-dark.svg +0 -1
  129. package/assets/icons/ear-dark.svg +0 -1
  130. package/assets/icons/lanes-dark.svg +0 -1
  131. package/assets/icons/plan-dark.svg +0 -1
@@ -1,49 +1,59 @@
1
- import { phaseLabel, phaseLabels } from '../plan-format'
2
- import { fit, padVisible, paint, spread, visibleLength } from '../terminal/text'
3
- import { textWidth } from '../terminal/width'
4
- import { deeper, type Frame, joinFrames, nest, prefixed, rowFrame, textFrame } from './frame'
1
+ import { phaseLabel } from '../plan-format'
2
+ import { fit, paint, spread, visibleLength } from '../terminal/text'
3
+ import { deeper, type Frame, joinFrames, nest, rowFrame, textFrame } from './frame'
5
4
  import type { ActivePhase, LaneSummary } from './lane-summary'
6
5
  import {
7
- ACTIVE_MARK,
6
+ activeArrow,
7
+ activeTitle,
8
+ agentCell,
8
9
  COLUMN_SEPARATOR,
9
- DEFERRED_MARK,
10
10
  type LaneContext,
11
11
  MIN_TITLE_WIDTH,
12
12
  modelMark,
13
+ nowLine,
13
14
  phaseChildren,
14
15
  phaseCountCell,
15
16
  plain,
17
+ TASK_INDENT,
16
18
  taskFrame,
17
19
  } from './layout-phase'
18
20
  import { type PhaseModel, phaseModelKey } from './phase-models'
21
+ import { phaseRanges } from './phase-ranges'
19
22
  import { itemKey, phaseKey } from './selection'
20
23
  import { PHASE_COUNT } from './symbols'
21
24
  import { type HerdTexts, TEXTS } from './texts'
22
25
 
23
- /** The context of a lane's sections: the lane's, with the herd's words. */
26
+ /** The context of a lane's branches: the lane's, with the herd's words. */
24
27
  type SummaryContext = LaneContext & { labels: HerdTexts }
25
28
 
26
- /** The label column of the summary's sections, as wide as the widest label and two cells. */
27
- const sectionWidth = ({ sections }: HerdTexts): number =>
28
- Math.max(...Object.values(sections).map(textWidth)) + COLUMN_SEPARATOR.length
29
-
30
- /** A frame behind a section label on its first line, its other lines indented to where the content starts. */
31
- const labelled = (labelWidth: number, label: string, frame: Frame): Frame =>
32
- prefixed(frame, { first: padVisible(label, labelWidth), rest: ' '.repeat(labelWidth), style: paint.dim })
29
+ /** Whether only the boxes of the review gate are open. */
30
+ const onlyGateOpen = ({ items }: ActivePhase['phase']): boolean => {
31
+ const open = items.filter(({ done }) => !done)
32
+ return open.length > 0 && open.every(({ gate }) => gate)
33
+ }
33
34
 
34
- /** `· 6 min ago`, dim: when the running phase last moved. */
35
- const ageCell = ({ minutesAgo }: ActivePhase, labels: HerdTexts): string =>
36
- `${paint.dim(labels.minutesAgo(minutesAgo))}${COLUMN_SEPARATOR}`
35
+ /**
36
+ * `· 6 min ago`, dim: when the running phase was last ticked or reported, nothing when the herd saw neither; `· gate`
37
+ * after it while only the review gate is open. Empty when there is neither.
38
+ */
39
+ const ageCell = ({ minutesAgo, phase }: ActivePhase, labels: HerdTexts): string => {
40
+ const words = [
41
+ ...(minutesAgo === null ? [] : [labels.minutesAgo(minutesAgo)]),
42
+ ...(onlyGateOpen(phase) ? [labels.gate] : []),
43
+ ]
44
+ return words.length > 0 ? `${paint.dim(words.join(' '))}${COLUMN_SEPARATOR}` : ''
45
+ }
37
46
 
38
47
  /**
39
- * `▶ P12 <title> [model] · 6 min ago 16/26`: title at the left, model, age and count at the right edge. In a pane
40
- * too narrow to leave the title `MIN_TITLE_WIDTH` cells, the model gives way first, then the age.
48
+ * `P12 <title> [model] · 6 min ago 16/26`: title at the left, model, age and count at the right edge, no arrow
49
+ * (arrows stand before tasks only). In a pane too narrow to leave the title `MIN_TITLE_WIDTH` cells, the model gives
50
+ * way first, then the age.
41
51
  */
42
52
  const activeRow = (active: ActivePhase, current: boolean, context: SummaryContext, model?: PhaseModel): string => {
43
53
  const { width, labels } = context
44
54
  const { phase } = active
45
55
  const title = phase.title === null ? paint.dim(TEXTS.notInPlan) : plain(phase.title)
46
- const head = `${paint.yellow(ACTIVE_MARK)} ${phaseLabel(phase.id)} ${title}`
56
+ const head = `${phaseLabel(phase.id)} ${title}`
47
57
  const count = phaseCountCell(phase, current)
48
58
  const withAge = `${ageCell(active, labels)}${count}`
49
59
  const mark = modelMark(model)
@@ -53,16 +63,24 @@ const activeRow = (active: ActivePhase, current: boolean, context: SummaryContex
53
63
  }
54
64
 
55
65
  /**
56
- * `→ <task>` under a closed running phase: the task it is on, dim, on one line whatever the task's fold, and a row a
57
- * click opens like the task itself; `→ review gate` in place of a box of the review gate; nothing when no task is open.
66
+ * One row per active task under a closed running phase, each with its own arrow: the title bold and the agent dim
67
+ * after it where agents reported it, `▷` and a plain title where the herd guesses. Always on one line whatever the
68
+ * task's fold, and a row a click opens like the task itself; `Review gate` in place of a box of the review gate.
58
69
  */
59
- const taskLineFrame = (context: SummaryContext, phaseRowKey: string, { phase, taskIndex }: ActivePhase): Frame => {
60
- const item = taskIndex === null ? undefined : phase.items[taskIndex]
61
- if (!item || taskIndex === null) return textFrame([])
62
- const text = item.gate ? context.labels.reviewGate : plain(item.text)
63
- const underTitle = ' '.repeat(visibleLength(ACTIVE_MARK) + 1)
64
- const line = fit(`${underTitle}${paint.dim(`${DEFERRED_MARK} ${text}`)}`, context.width)
65
- return taskFrame(itemKey(phaseRowKey, taskIndex), [line], context)
70
+ const taskLinesFrame = (context: SummaryContext, phaseRowKey: string, { phase, tasks }: ActivePhase): Frame => {
71
+ return joinFrames(
72
+ tasks.flatMap((task) => {
73
+ const item = phase.items[task.index]
74
+ if (!item) return []
75
+ const text = item.gate ? context.labels.reviewGate : plain(item.text)
76
+ const line = spread(
77
+ `${TASK_INDENT}${activeArrow(task.guessed)} ${activeTitle(text, task.guessed)}`,
78
+ agentCell(task.agent),
79
+ context.width,
80
+ )
81
+ return [taskFrame(itemKey(phaseRowKey, task.index), [line], context)]
82
+ }),
83
+ )
66
84
  }
67
85
 
68
86
  /** A running phase as a row that opens to its tasks; closed, the task it is on below it. */
@@ -71,70 +89,70 @@ const activeFrame = (context: SummaryContext, active: ActivePhase): Frame => {
71
89
  const stepIndex = active.stepIndex ?? -1
72
90
  const key = phaseKey(progress.lane.name, stepIndex, active.phase.id)
73
91
  const current = progress.current?.stepIndex === stepIndex && progress.current.phaseId === active.phase.id
74
- const item = active.taskIndex === null ? null : (active.phase.items[active.taskIndex] ?? null)
75
92
  const model = context.models[phaseModelKey(stepIndex, active.phase.id)]
76
93
  const row = rowFrame(key, [activeRow(active, current, context, model)], [], context)
77
- if (!state.expanded.has(key)) return joinFrames([row, taskLineFrame(context, key, active)])
78
- return joinFrames([row, nest(phaseChildren(deeper(context), key, active.phase, item))])
94
+ if (!state.expanded.has(key)) return joinFrames([row, taskLinesFrame(context, key, active)])
95
+ return joinFrames([
96
+ row,
97
+ nest(phaseChildren(deeper(context), key, active.phase, new Map(active.tasks.map((task) => [task.index, task])))),
98
+ ])
79
99
  }
80
100
 
81
- /** `wave 2: P5 · P10` or, without waves, `P7`. */
82
- const nextText = ({ next }: LaneSummary, labels: HerdTexts): string =>
83
- next ? `${next.wave === null ? '' : `${labels.wave(next.wave)} `}${phaseLabels(next.phaseIds, ' · ')}` : ''
84
-
85
101
  /** `P8 ?` for a named phase the plan lacks, `P9 –` for one without a checkbox. */
86
102
  const unwrittenText = ({ id, missing }: LaneSummary['unwritten'][number]): string =>
87
103
  `${phaseLabel(id)} ${paint.dim(missing ? PHASE_COUNT.missing : PHASE_COUNT.empty)}`
88
104
 
89
- /** `P6 P7 P8 ? · Second plan: P1`: the current plan's waiting phases, then the other plans' by their name. */
90
- const laterText = ({ later, unwritten }: LaneSummary): string => {
91
- const own = [
92
- ...(later[0]?.planTitle === null ? [phaseLabels(later[0].phaseIds, ' ')] : []),
93
- ...unwritten.map(unwrittenText),
94
- ]
95
- const others = later
105
+ /**
106
+ * `next P5–P7, P10 P8 ? · Second plan: P1` on one line: every open phase that does not run, the current plan's as
107
+ * ranges, then the other plans' by their name; empty when none waits.
108
+ */
109
+ const nextLineText = (summary: LaneSummary, labels: HerdTexts): string => {
110
+ const ownIds = summary.waiting.filter(({ planTitle }) => planTitle === null).flatMap(({ phaseIds }) => phaseIds)
111
+ const ownRanges = phaseRanges(ownIds)
112
+ const own = [...(ownRanges ? [ownRanges] : []), ...summary.unwritten.map(unwrittenText)]
113
+ const others = summary.waiting
96
114
  .filter(({ planTitle }) => planTitle !== null)
97
- .map(({ planTitle, phaseIds }) => `${plain((planTitle ?? '').split(':')[0]).trim()}: ${phaseLabels(phaseIds, ' ')}`)
98
- return [...(own.length > 0 ? [own.join(' ')] : []), ...others].join(' · ')
115
+ .map(({ planTitle, phaseIds }) => `${plain((planTitle ?? '').split(':')[0]).trim()}: ${phaseRanges(phaseIds)}`)
116
+ const text = [...(own.length > 0 ? [own.join(' ')] : []), ...others].join(' · ')
117
+ return text ? `${paint.dim(labels.next)} ${text}` : ''
99
118
  }
100
119
 
101
- /** ` · not merged` once for the line when every finished phase waits for its merge, ` · P1 P2 not merged` when some do. */
120
+ /** ` · not merged` once for the line when every finished phase waits for its merge, ` · P1–P2 not merged` when some do. */
102
121
  const unmergedNote = ({ finished }: LaneSummary, labels: HerdTexts): string => {
103
122
  const unmerged = finished.filter(({ unmerged }) => unmerged).map(({ id }) => id)
104
123
  if (unmerged.length === 0) return ''
105
- const which = unmerged.length === finished.length ? '' : `${phaseLabels(unmerged, ' ')} `
124
+ const which = unmerged.length === finished.length ? '' : `${phaseRanges(unmerged)} `
106
125
  return ` · ${paint.yellow(`${which}${labels.notMerged}`)}`
107
126
  }
108
127
 
109
- /** `✓ P1 ✓ P4 · not merged` on one line. */
128
+ /** `✓ P1–P7, P10 · not merged` on one line: one ✓, the finished phases as ranges. */
110
129
  const finishedText = (summary: LaneSummary, labels: HerdTexts): string => {
111
- const phases = summary.finished.map(({ id }) => `${paint.green(PHASE_COUNT.allTicked)} ${phaseLabel(id)}`).join(' ')
112
- return phases ? `${phases}${unmergedNote(summary, labels)}` : ''
130
+ const phases = phaseRanges(summary.finished.map(({ id }) => id))
131
+ return phases ? `${paint.green(PHASE_COUNT.allTicked)} ${phases}${unmergedNote(summary, labels)}` : ''
113
132
  }
114
133
 
115
- /** A section with one line of text, cut at the pane's edge; no frame when the text is empty. */
116
- const textSection = (labelWidth: number, label: string, text: string, width: number): Frame =>
117
- text ? labelled(labelWidth, label, textFrame([fit(text, Math.max(width - labelWidth, 1))])) : textFrame([])
134
+ /** One line of text cut at the pane's edge; no line when the text is empty. */
135
+ const lineFrame = (text: string, width: number): Frame => textFrame(text ? [fit(text, width)] : [])
118
136
 
119
- /** What an open lane shows above its sections, each drawn at the width its label column leaves. */
120
- export type LaneHead = { session: (width: number) => string; chips: (width: number) => Frame }
137
+ /** What an open lane shows above its phases, drawn at the width of its branches: the session, its pull requests and its link chips. */
138
+ export type LaneHead = { session: Frame; pullRequests: Frame; chips: Frame }
121
139
 
122
140
  /**
123
- * The children of an open lane, each on a branch with its label in one column: SESSION, the 📎 chips, then NOW, NEXT,
124
- * LATER and DONE, each section only when it has phases.
141
+ * The children of an open lane, one branch each, told apart by their marks rather than by a label: the session
142
+ * (`⎇`), the pull requests (`↗`), the link chips, then one branch per running phase (its tasks `▶`/`▷` under it), the
143
+ * `now:` lines, `next` with the open phases that wait, and `✓` with the finished ones; a branch without lines is left out.
125
144
  */
126
145
  export const summaryChildren = (context: LaneContext, summary: LaneSummary, head: LaneHead): Frame[] => {
127
146
  const labels = TEXTS.herd
128
- const labelWidth = sectionWidth(labels)
129
- const inner = { ...context, width: Math.max(context.width - labelWidth, 1), labels }
130
- const { sections } = labels
131
- const now = summary.active.map((active) => activeFrame(inner, active))
147
+ const inner = { ...context, labels }
148
+ const { width } = context
132
149
  return [
133
- labelled(labelWidth, sections.session, textFrame([head.session(inner.width)])),
134
- labelled(labelWidth, sections.chips, head.chips(inner.width)),
135
- ...(now.length > 0 ? [labelled(labelWidth, sections.now, joinFrames(now))] : []),
136
- textSection(labelWidth, sections.next, nextText(summary, labels), context.width),
137
- textSection(labelWidth, sections.later, laterText(summary), context.width),
138
- textSection(labelWidth, sections.done, finishedText(summary, labels), context.width),
150
+ head.session,
151
+ head.pullRequests,
152
+ head.chips,
153
+ ...summary.active.map((active) => activeFrame(inner, active)),
154
+ ...summary.unplanned.map((task) => textFrame([nowLine(task, width)])),
155
+ lineFrame(nextLineText(summary, labels), width),
156
+ lineFrame(finishedText(summary, labels), width),
139
157
  ]
140
158
  }