jackherd 1.8.1 → 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 (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -0,0 +1,206 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ export const TASK_PARALLEL_EVENT = 'parallel tasks'
4
+
5
+ /** A proposal for the non-stream watcher: the line `formatTaskParallelLine` made for `lane`. */
6
+ export type TaskParallelEvent = { kind: typeof TASK_PARALLEL_EVENT; lane: string; line: string }
7
+
8
+ /** Lane → the fingerprint last seen at a heartbeat; a lane without an entry has had none. */
9
+ export type TaskParallelFingerprints = Record<string, string>
10
+
11
+ export const isTaskParallelFingerprints = (value: unknown): value is TaskParallelFingerprints =>
12
+ typeof value === 'object' &&
13
+ value !== null &&
14
+ !Array.isArray(value) &&
15
+ Object.values(value).every((entry) => typeof entry === 'string')
16
+
17
+ export type OpenTask = { text: string; phase: string }
18
+
19
+ /** One agent's recent work: the files it touched, its active task text and phase when the signal names them. */
20
+ export type ActiveWork = { name: string; files: string[]; taskText?: string; phase?: string }
21
+
22
+ export type TaskParallelInput = {
23
+ lane: string
24
+ open: OpenTask[]
25
+ active: ActiveWork[]
26
+ /** Phase id → the paths of its writes cell; `''` is the whole repository. Absent for a phase without one. */
27
+ phaseWrites: Record<string, string[]>
28
+ doneCount: number
29
+ /** Tokens a fresh agent costs before it does anything, measured by the caller. */
30
+ baselineTokens: number
31
+ /** A proposal is made only when the estimate stays below this. */
32
+ thresholdTokens: number
33
+ }
34
+
35
+ export type TaskParallelResult =
36
+ | { proposed: false; reason: string }
37
+ | {
38
+ proposed: true
39
+ tasks: OpenTask[]
40
+ waiting: number
41
+ extraAgents: number
42
+ estimateTokens: number
43
+ reason: string
44
+ }
45
+
46
+ export type TaskParallelProposal = Extract<TaskParallelResult, { proposed: true }>
47
+
48
+ const MIN_TASKS_BESIDE_AGENT = 1
49
+ const MIN_TASKS_WITHOUT_AGENT = 2
50
+ const TEXT_LIMIT = 60
51
+ const TOKENS_PER_K = 1000
52
+ const GLOB_CHARACTER = /[*?[{]/
53
+ const LINE_SUFFIX = /(?::\d+(?:[-:]\d+)*|#L\d+.*)$/
54
+ const PATH_EXTENSIONS = 'ts|tsx|js|mjs|json|md|sh|yml|yaml|css|html|toml|sql'
55
+ const BARE_FILE = new RegExp(`(?<![\\w./\`-])[\\w./-]+\\.(?:${PATH_EXTENSIONS})(?::\\d+(?:[-:]\\d+)*)?(?![\\w/])`, 'g')
56
+ const BACKTICKED = /`([^`]+)`/g
57
+ const FILE_LIKE = new RegExp(`/|\\.(?:${PATH_EXTENSIONS})$|^\\.[\\w-]+`)
58
+
59
+ const normalise = (entry: string): string | null => {
60
+ const trimmed = entry.trim()
61
+ if (trimmed === '' || /\s/.test(trimmed)) return null
62
+ const path = trimmed.replace(LINE_SUFFIX, '').replace(/^\.\//, '').replace(/\/+$/, '')
63
+ return FILE_LIKE.test(path) && path !== '' ? path : null
64
+ }
65
+
66
+ /**
67
+ * The paths a task line names: backticked entries that look like a path or file name and bare file names with a known
68
+ * extension. `:line` suffixes and `./` are cut, globs stay globs, duplicates drop. A task naming none is unknown, and
69
+ * unknown is never proposed.
70
+ */
71
+ export const pathsOfTask = (text: string): string[] => {
72
+ const backticked = [...text.matchAll(BACKTICKED)].map((match) => match[1] ?? '')
73
+ const bare = text.replace(BACKTICKED, ' ').match(BARE_FILE) ?? []
74
+ return [...new Set([...backticked, ...bare].map(normalise).filter((path): path is string => path !== null))]
75
+ }
76
+
77
+ const literalPrefix = (path: string): string => {
78
+ const glob = path.search(GLOB_CHARACTER)
79
+ return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
80
+ }
81
+
82
+ const isInside = (inner: string, outer: string): boolean =>
83
+ outer === '' || inner === outer || inner.startsWith(`${outer}/`)
84
+
85
+ const holdsName = (name: string, path: string): boolean => `/${path}/`.includes(`/${name}/`) || path === name
86
+
87
+ /**
88
+ * Whether two paths may touch the same files. Directories contain what lies below them, a glob stands for the
89
+ * directory its literal part ends in, `''` is the whole repository, and a bare name matches that name in any directory.
90
+ * When unsure the answer is yes.
91
+ */
92
+ export const pathsOverlap = (left: string, right: string): boolean => {
93
+ const [a, b] = [literalPrefix(left), literalPrefix(right)]
94
+ const bareLeft = !left.includes('/') && !GLOB_CHARACTER.test(left) && left !== ''
95
+ const bareRight = !right.includes('/') && !GLOB_CHARACTER.test(right) && right !== ''
96
+ if (bareLeft || bareRight) {
97
+ const [name, other] = bareLeft ? [left, right] : [right, left]
98
+ return GLOB_CHARACTER.test(other) ? true : other === '' || holdsName(name, other)
99
+ }
100
+ return isInside(a, b) || isInside(b, a)
101
+ }
102
+
103
+ const clash = (left: readonly string[], right: readonly string[]): boolean =>
104
+ left.some((a) => right.some((b) => pathsOverlap(a, b)))
105
+
106
+ const activeFootprint = (input: TaskParallelInput): string[] =>
107
+ input.active.flatMap((work) => [
108
+ ...work.files,
109
+ ...pathsOfTask(work.taskText ?? ''),
110
+ ...(work.phase === undefined ? [] : (input.phaseWrites[work.phase] ?? [])),
111
+ ])
112
+
113
+ const estimateTokens = (input: TaskParallelInput, taskCount: number): { extraAgents: number; tokens: number } => {
114
+ const extraAgents = input.active.length > 0 ? taskCount : taskCount - 1
115
+ return { extraAgents, tokens: input.baselineTokens * extraAgents }
116
+ }
117
+
118
+ const kilo = (tokens: number): string => `${Math.round(tokens / TOKENS_PER_K)}k`
119
+
120
+ /** The first group of open tasks, in plan order, whose paths clash neither with the running work nor with each other. */
121
+ const parallelGroup = (input: TaskParallelInput): OpenTask[] => {
122
+ const running = activeFootprint(input)
123
+ const chosen: { task: OpenTask; paths: string[] }[] = []
124
+ for (const task of input.open) {
125
+ const paths = pathsOfTask(task.text)
126
+ const free = paths.length > 0 && !clash(paths, running) && chosen.every((other) => !clash(paths, other.paths))
127
+ if (free) chosen.push({ task, paths })
128
+ }
129
+ return chosen.map(({ task }) => task)
130
+ }
131
+
132
+ /**
133
+ * Open tasks that could start now beside the running work: no path overlap with it or with each other, none without a
134
+ * named path. Proposed for one task beside running work or two tasks without, and when the baseline cost of the extra agents stays below the
135
+ * threshold; the reason says in plain words which way it went.
136
+ */
137
+ export const proposeTaskParallel = (input: TaskParallelInput): TaskParallelResult => {
138
+ const tasks = parallelGroup(input)
139
+ const needed = input.active.length > 0 ? MIN_TASKS_BESIDE_AGENT : MIN_TASKS_WITHOUT_AGENT
140
+ if (tasks.length < needed) return { proposed: false, reason: 'no task can run beside another' }
141
+ const { extraAgents, tokens } = estimateTokens(input, tasks.length)
142
+ if (tokens >= input.thresholdTokens)
143
+ return {
144
+ proposed: false,
145
+ reason: `~${kilo(tokens)} tokens for ${extraAgents} extra agents is not below the ${kilo(input.thresholdTokens)} limit`,
146
+ }
147
+ return {
148
+ proposed: true,
149
+ tasks,
150
+ waiting: input.open.length,
151
+ extraAgents,
152
+ estimateTokens: tokens,
153
+ reason: `${tasks.length} of ${input.open.length} waiting tasks name disjoint paths that the running work does not touch; ~${kilo(tokens)} tokens for ${extraAgents} extra ${extraAgents === 1 ? 'agent' : 'agents'}`,
154
+ }
155
+ }
156
+
157
+ const FNV_OFFSET = 0x811c9dc5
158
+ const FNV_PRIME = 0x01000193
159
+
160
+ const hash = (text: string): string => {
161
+ let value = FNV_OFFSET
162
+ for (const character of text) {
163
+ value = Math.imul(value ^ (character.codePointAt(0) ?? 0), FNV_PRIME) >>> 0
164
+ }
165
+ return value.toString(16).padStart(8, '0')
166
+ }
167
+
168
+ /**
169
+ * A stable fingerprint of what decides a proposal: ticks, active tasks and open tasks, order-independent. Files an
170
+ * agent touches are left out, they move on every keystroke and would repeat the report.
171
+ */
172
+ export const taskParallelFingerprint = (input: TaskParallelInput): string =>
173
+ hash(
174
+ JSON.stringify([
175
+ input.lane,
176
+ input.doneCount,
177
+ input.active.map((work) => `${work.phase ?? ''}|${work.taskText ?? ''}`).sort(),
178
+ input.open.map((task) => `${task.phase}|${task.text}`).sort(),
179
+ ]),
180
+ )
181
+
182
+ /**
183
+ * The proposal to report this heartbeat, if any: only when the fingerprint moved since `previous`, and only a real
184
+ * proposal. The returned fingerprint is what the caller keeps for the next heartbeat, proposal or not.
185
+ */
186
+ export const taskParallelReport = (
187
+ previous: string | undefined,
188
+ input: TaskParallelInput,
189
+ ): { fingerprint: string; proposal: TaskParallelProposal | null } => {
190
+ const fingerprint = taskParallelFingerprint(input)
191
+ if (fingerprint === previous) return { fingerprint, proposal: null }
192
+ const result = proposeTaskParallel(input)
193
+ return { fingerprint, proposal: result.proposed ? result : null }
194
+ }
195
+
196
+ const CHECKBOX = /^\s*(?:[-*]\s*)?\[[ xX]\]\s*/
197
+
198
+ const proposalTaskText = (text: string): string =>
199
+ ellipsize(terminalSafe(text.replace(/\s+/g, ' ')).replace(CHECKBOX, '').replaceAll('"', "'").trim(), TEXT_LIMIT)
200
+
201
+ /** `parallel tasks <lane>: "<a>" ∥ "<b>" · ~<n>k tokens for 1 extra agent`, one line. */
202
+ export const formatTaskParallelLine = (lane: string, proposal: TaskParallelProposal): string => {
203
+ const tasks = proposal.tasks.map((task) => `"${proposalTaskText(task.text)}"`).join(' ∥ ')
204
+ const agents = proposal.extraAgents === 1 ? 'agent' : 'agents'
205
+ return `parallel tasks ${proposalTaskText(lane)}: ${tasks} · ~${kilo(proposal.estimateTokens)} tokens for ${proposal.extraAgents} extra ${agents}`
206
+ }
@@ -0,0 +1,19 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+ import type { WaitingItem } from '../plan-format/waiting'
3
+ import { shownWithMore } from '../plans/shown-with-more'
4
+
5
+ const SHOWN_ITEMS = 3
6
+ const ITEM_LENGTH = 40
7
+
8
+ const itemText = ({ item }: WaitingItem): string => ellipsize(terminalSafe(item).replace(/\s+/g, ' '), ITEM_LENGTH)
9
+
10
+ /**
11
+ * The line that brings back what waits on the user's answer, in every heartbeat and status until the item is closed:
12
+ * `for you (<n>): <item>; …`, three items and `+<n>` for the rest. Null when no item is open.
13
+ */
14
+ export const waitingLine = (items: readonly WaitingItem[]): string | null => {
15
+ const open = items.filter(({ open: isOpen }) => isOpen)
16
+ if (open.length === 0) return null
17
+ const { shown, more } = shownWithMore(open, SHOWN_ITEMS)
18
+ return `for you (${open.length}): ${[...shown.map(itemText), ...(more > 0 ? [`+${more}`] : [])].join('; ')}`
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { CHECKS, type ChecksState } from '../plans/pull-requests'
2
+ import { isRecord } from '../shared/is-record'
2
3
  import {
3
4
  APPOINTMENT_EVENT,
4
5
  type AppointmentEvent,
@@ -10,7 +11,6 @@ import { APPROVAL_EVENT, type ApprovalEvent } from './approvals'
10
11
  import { type CoordinatorLevel, type CoordinatorReading, isContextLevel, levelStep } from './coordinator-fill'
11
12
  import { emptyFailureState, type FailureState, failedTestsOf, isFailureState } from './failures'
12
13
  import { emptyFeedbackState, FEEDBACK_EVENT, type FeedbackEvent, type FeedbackState, isFeedbackState } from './feedback'
13
- import { isRecord } from './json-guards'
14
14
  import { isLaneLevels, LANE_FILL_EVENT, type LaneFillEvent, type LaneLevels } from './lane-fill'
15
15
  import { isLaneModels, LANE_MODEL_EVENT, type LaneModelEvent, type LaneModels } from './lane-model'
16
16
  import { isLaneParallels, LANE_PARALLEL_EVENT, type LaneParallelEvent, type LaneParallels } from './lane-parallel'
@@ -18,6 +18,12 @@ import { type LaneSnapshot, type SeenSeqs, seenSeqsOf } from './lane-watch'
18
18
  import { emptyPreviewState, isPreviewState, PREVIEW_EVENT, type PreviewEvent, type PreviewState } from './previews'
19
19
  import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
20
20
  import { emptySourceState, isSourceState, SOURCE_EVENT, type SourceEvent, type SourceState } from './sources'
21
+ import {
22
+ isTaskParallelFingerprints,
23
+ TASK_PARALLEL_EVENT,
24
+ type TaskParallelEvent,
25
+ type TaskParallelFingerprints,
26
+ } from './task-parallel'
21
27
  import { RUN_EVENT, RUN_STATUS, type WorkflowRun } from './workflow-runs'
22
28
 
23
29
  /** What the coordinator is told besides a lane that stopped. The values are the words of the report. */
@@ -34,6 +40,7 @@ export const WATCH_EVENT = {
34
40
  laneFill: LANE_FILL_EVENT,
35
41
  laneModel: LANE_MODEL_EVENT,
36
42
  laneParallel: LANE_PARALLEL_EVENT,
43
+ taskParallel: TASK_PARALLEL_EVENT,
37
44
  approval: APPROVAL_EVENT,
38
45
  source: SOURCE_EVENT,
39
46
  due: APPOINTMENT_EVENT.due,
@@ -86,6 +93,8 @@ export type WatchState = {
86
93
  laneModels: LaneModels
87
94
  /** The phases last reported as able to run together for each watched lane; a lane without two has none. */
88
95
  laneParallels: LaneParallels
96
+ /** The fingerprint of the parallel-task situation of each watched lane at the last heartbeat; a proposal is made only when it moved. */
97
+ taskParallels: TaskParallelFingerprints
89
98
  /** The ids of the approval requests on disk when they were last read, each reported once while it is open. */
90
99
  approvals: string[]
91
100
  /** Per configured source of `watch.sources`: the entries seen (`[id, fingerprint]`) and the start of a failure streak. */
@@ -139,6 +148,7 @@ export type WatchEvent =
139
148
  | LaneFillEvent
140
149
  | LaneModelEvent
141
150
  | LaneParallelEvent
151
+ | TaskParallelEvent
142
152
  | ApprovalEvent
143
153
  | SourceEvent
144
154
  | AppointmentEvent
@@ -173,6 +183,7 @@ export const emptyWatchState = (): WatchState => ({
173
183
  laneLevels: {},
174
184
  laneModels: {},
175
185
  laneParallels: {},
186
+ taskParallels: {},
176
187
  approvals: [],
177
188
  sources: emptySourceState(),
178
189
  reportMtimes: {},
@@ -218,6 +229,7 @@ const isWatchState = (value: Record<string, unknown>): value is WatchState =>
218
229
  isLaneLevels(value.laneLevels) &&
219
230
  isLaneModels(value.laneModels) &&
220
231
  isLaneParallels(value.laneParallels) &&
232
+ isTaskParallelFingerprints(value.taskParallels) &&
221
233
  Array.isArray(value.approvals) &&
222
234
  value.approvals.every((id) => typeof id === 'string') &&
223
235
  isSourceState(value.sources) &&
@@ -1,7 +1,8 @@
1
1
  import type { JackherdConfig } from '../config'
2
2
  import { GITHUB_HOST } from '../plans/github'
3
3
  import { runIdOfLink } from '../plans/pull-requests'
4
- import { isRecord, parseArray } from './json-guards'
4
+ import { isRecord } from '../shared/is-record'
5
+ import { parseArray } from './json-guards'
5
6
 
6
7
  /** Which workflow's runs are deploys, and which environments count as their target (`watch.deploy`). */
7
8
  export type DeployConfig = NonNullable<JackherdConfig['watch']['deploy']>