jackherd 1.8.1 → 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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  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/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -5,27 +5,33 @@ import { type LaneSnapshot, NO_AGENT } from './lane-watch'
5
5
  import { WATCH_EVENT } from './watch-events'
6
6
 
7
7
  const SEPARATOR = ' · '
8
+ const RESTING: ReadonlySet<string> = new Set([AGENT_STATUS.idle, AGENT_STATUS.done])
8
9
 
9
- /** `lane status`, a working lane with its note: `lane working: P12 7/12`. */
10
- const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes): string => {
10
+ /** `lane status`, a working lane with its progress, a resting one with what it waits on: `lane idle: waits on: …`. */
11
+ const laneState = (lane: string, current: LaneSnapshot, notes: LaneNotes, rests: LaneNotes): string => {
11
12
  const status = current[lane]?.status ?? NO_AGENT
12
- const note = status === AGENT_STATUS.working ? notes[lane] : undefined
13
+ const note = status === AGENT_STATUS.working ? notes[lane] : RESTING.has(status) ? rests[lane] : undefined
13
14
  const head = `${terminalSafe(lane)} ${terminalSafe(status)}`
14
15
  return note === undefined ? head : `${head}: ${terminalSafe(note).replaceAll(SEPARATOR, ' / ')}`
15
16
  }
16
17
 
17
18
  /**
18
19
  * The heartbeat of a stream, as one line, since every line of a stream reaches the coordinator as a
19
- * message of its own: `=== heartbeat <time> · <lane> <status> · …`.
20
+ * message of its own: `=== heartbeat <time> · <lane> <status> · …`. `standing` are the lines that stay until
21
+ * their cause is gone (what waits on the user, the main checkout behind its base), each one entry at the end.
20
22
  */
21
23
  export const streamHeartbeatLine = (
22
24
  time: string,
23
25
  lanes: readonly string[],
24
26
  current: LaneSnapshot,
25
27
  notes: LaneNotes,
28
+ rests: LaneNotes = {},
29
+ standing: readonly string[] = [],
26
30
  ): string => {
27
- const states = lanes.map((lane) => laneState(lane, current, notes))
28
- return [`=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`, ...(states.length > 0 ? states : ['no lanes'])].join(
29
- SEPARATOR,
30
- )
31
+ const states = lanes.map((lane) => laneState(lane, current, notes, rests))
32
+ return [
33
+ `=== ${WATCH_EVENT.heartbeat} ${terminalSafe(time)}`,
34
+ ...(states.length > 0 ? states : ['no lanes']),
35
+ ...standing.map((line) => terminalSafe(line).replaceAll(SEPARATOR, ' / ')),
36
+ ].join(SEPARATOR)
31
37
  }
@@ -0,0 +1,208 @@
1
+ import { ALWAYS_ASK_PATHS, ENV_PATHS, MIGRATION_PATHS } from '../../kit/hooks/hooks/merge-guard/always-ask'
2
+ import { MAX_AUTO_WINDOW_DAYS, windowDays } from '../config/auto-window'
3
+ import type { JackherdConfig } from '../config/schema'
4
+ import { twoDigits } from '../plan-format/clock'
5
+ import {
6
+ AUTO_VERDICT,
7
+ type AutoVerdict,
8
+ GLOB_CHARACTER,
9
+ pathsOfTask,
10
+ type TaskParallelInput,
11
+ type TaskParallelProposal,
12
+ } from './task-parallel'
13
+
14
+ /** The window of `watch.parallelTasks.auto`: two `YYYY-MM-DD` days, both included. */
15
+ export type AutoWindow = NonNullable<JackherdConfig['watch']['parallelTasks']['auto']>
16
+
17
+ /** The switch, unset when the project has none, and every path a task may not name to start without a question. */
18
+ export type AutoSettings = {
19
+ auto?: AutoWindow
20
+ askPaths: readonly string[]
21
+ /** `watch.parallelTasks.maxTokens` of the same config as the switch. */
22
+ maxTokens: number
23
+ /** Whether the repository holds a path of `DATA_PATHS` at or below a directory, relative to its root. */
24
+ holdsDataPath: (directory: string) => boolean
25
+ }
26
+
27
+ export const MAX_AUTO_EXTRA_AGENTS = 2
28
+
29
+ /**
30
+ * The paths that always ask whose work takes effect before the merge: a migration may be pushed and a secret written
31
+ * while the pull request is open, so the merge guard comes too late. A directory of a writes set that holds one asks.
32
+ */
33
+ export const DATA_PATHS: readonly string[] = [...MIGRATION_PATHS, ...ENV_PATHS]
34
+
35
+ /**
36
+ * The paths that always ask (`ALWAYS_ASK_PATHS`, plus every package.json and CHANGELOG.md, which the merge guard
37
+ * reads apart): work on them is tier C and never starts without a question. The project's `guard.alwaysAsk` come on top.
38
+ */
39
+ export const AUTO_ASK_PATHS: readonly string[] = [...ALWAYS_ASK_PATHS, '**/package.json', '**/CHANGELOG.md']
40
+
41
+ const PROBE = 'x'
42
+ /** Shorter literal pieces of a pattern (`**`, `*`) say nothing about a task's text. */
43
+ const MIN_FRAGMENT = 3
44
+ /**
45
+ * A path that may leave the repository or hide where it lies: absolute, from home, a variable, a drive, a backslash, or
46
+ * a `.` or `..` segment.
47
+ */
48
+ const OUTSIDE_REPOSITORY = /^[/~]|^[A-Za-z]:|[\\%$]|(?:^|\/)\.{1,2}(?:\/|$)/
49
+
50
+ /** Whether a path may leave the repository or hide where it lies (`OUTSIDE_REPOSITORY`). */
51
+ export const leavesRepository = (path: string): boolean => OUTSIDE_REPOSITORY.test(path)
52
+
53
+ const hasFileName = (path: string): boolean => /\.[^/]*$/.test(path.slice(path.lastIndexOf('/') + 1))
54
+
55
+ /** A path up to the directory its first wildcard lies in; a path without one is itself. */
56
+ const literalPrefix = (path: string): string => {
57
+ const glob = path.search(GLOB_CHARACTER)
58
+ return glob === -1 ? path : path.slice(0, Math.max(path.lastIndexOf('/', glob), 0))
59
+ }
60
+
61
+ /** Whether one path lies inside the other or both are the same; `''` is the whole repository. */
62
+ const nested = (left: string, right: string): boolean =>
63
+ left === '' || right === '' || left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
64
+
65
+ const matchesAnyPattern = (path: string, patterns: readonly string[]): boolean => {
66
+ const lower = path.toLowerCase()
67
+ const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
68
+ return globs.some((glob) => glob.match(lower) || glob.match(`${lower}/${PROBE}`))
69
+ }
70
+
71
+ /**
72
+ * Whether a path a task names may touch a path that always asks. Only a named file inside a directory of the
73
+ * repository is matched (in lower case, as the merge guard does): a path that may leave the repository, a directory,
74
+ * a glob or a bare file name could reach such a path somewhere, so it counts as one. A dotted name is also matched as
75
+ * a directory, so `.claude` counts like `.claude/**`.
76
+ */
77
+ export const touchesAskPath = (path: string, patterns: readonly string[]): boolean => {
78
+ if (OUTSIDE_REPOSITORY.test(path) || !path.includes('/') || GLOB_CHARACTER.test(path) || !hasFileName(path))
79
+ return true
80
+ return matchesAnyPattern(path, patterns)
81
+ }
82
+
83
+ /** Whether a path relative to the repository root is, or lies in, one of `DATA_PATHS`. */
84
+ export const isDataPath = (path: string): boolean => matchesAnyPattern(path, DATA_PATHS)
85
+
86
+ /**
87
+ * Whether an entry of a phase's writes set may touch a path that always asks: the whole repository, a path that may
88
+ * leave it, an entry a pattern matches as written (`packages/*\/package.json`), a directory or glob that overlaps a
89
+ * pattern anchored at the root (`.claude/**`), or one under whose literal part the repository holds a migration or an
90
+ * env file (`holdsDataPath`). Other patterns that hold anywhere (`**\/package.json`) are not looked for below a
91
+ * directory: their work waits for the merge guard.
92
+ */
93
+ export const writesTouchAskPath = (
94
+ entry: string,
95
+ patterns: readonly string[],
96
+ holdsDataPath: (directory: string) => boolean,
97
+ ): boolean => {
98
+ if (entry === '' || OUTSIDE_REPOSITORY.test(entry)) return true
99
+ if (matchesAnyPattern(entry, patterns)) return true
100
+ if (!GLOB_CHARACTER.test(entry) && hasFileName(entry)) return false
101
+ const literal = literalPrefix(entry.toLowerCase())
102
+ const rootAnchored = patterns.filter((pattern) => !pattern.startsWith('**'))
103
+ return (
104
+ rootAnchored.some((pattern) => nested(literal, literalPrefix(pattern.toLowerCase()))) ||
105
+ holdsDataPath(literalPrefix(entry))
106
+ )
107
+ }
108
+
109
+ /** The literal pieces of the patterns (`.claude`, `package.json`, `migrations`), in lower case. */
110
+ const fragmentsOf = (patterns: readonly string[]): string[] => [
111
+ ...new Set(
112
+ patterns.flatMap((pattern) =>
113
+ pattern
114
+ .toLowerCase()
115
+ .split(/[/*?[\]{}]+/)
116
+ .filter((piece) => piece.length >= MIN_FRAGMENT),
117
+ ),
118
+ ),
119
+ ]
120
+
121
+ /** The local calendar day of `at`, as `YYYY-MM-DD`. */
122
+ export const localDay = (at: number): string => {
123
+ const date = new Date(at)
124
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
125
+ }
126
+
127
+ const windowRefusal = (auto: AutoWindow, today: string): string | null => {
128
+ if (!(windowDays(auto.since, auto.until) <= MAX_AUTO_WINDOW_DAYS))
129
+ return `window longer than ${MAX_AUTO_WINDOW_DAYS} days`
130
+ if (today < auto.since) return `off until ${auto.since}`
131
+ if (today > auto.until) return `ended ${auto.until}`
132
+ return null
133
+ }
134
+
135
+ const extraAgentsAtOnce = (input: TaskParallelInput, proposal: TaskParallelProposal): number =>
136
+ Math.max(input.active.length - 1, 0) + proposal.extraAgents
137
+
138
+ const taskPathRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
139
+ const path = proposal.tasks.flatMap((task) => pathsOfTask(task.text)).find((entry) => touchesAskPath(entry, patterns))
140
+ return path === undefined ? null : `a task names ${path}, which always asks`
141
+ }
142
+
143
+ /** A task's text may name such a path without backticks or extension (`.husky/pre-commit`, `CODEOWNERS`). */
144
+ const taskTextRefusal = (proposal: TaskParallelProposal, patterns: readonly string[]): string | null => {
145
+ const fragments = fragmentsOf(patterns)
146
+ const fragment = proposal.tasks
147
+ .map((task) => task.text.toLowerCase())
148
+ .flatMap((text) => fragments.filter((piece) => text.includes(piece)))[0]
149
+ return fragment === undefined ? null : `a task mentions ${fragment}, which always asks`
150
+ }
151
+
152
+ /**
153
+ * The task's text does not bind the subagent; its phase's writes set is what it may write, read from the plan at the
154
+ * base branch, so the lane's own copy widens nothing. A phase the base branch does not have asks.
155
+ */
156
+ const phaseWritesRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
157
+ for (const task of proposal.tasks) {
158
+ const writes = input.baseWrites[task.phase]
159
+ if (writes === undefined || writes.length === 0) return `phase ${task.phase} has no writes set on the base branch`
160
+ const entry = writes.find((path) => writesTouchAskPath(path, settings.askPaths, settings.holdsDataPath))
161
+ if (entry !== undefined)
162
+ return `phase ${task.phase} writes ${entry === '' ? 'the whole repository' : entry}, which always asks`
163
+ }
164
+ return null
165
+ }
166
+
167
+ const limitRefusal = (input: TaskParallelInput, proposal: TaskParallelProposal, settings: AutoSettings) => {
168
+ const { askPaths: patterns, maxTokens } = settings
169
+ if (input.active.length === 0) return 'no running work in the lane'
170
+ const atOnce = extraAgentsAtOnce(input, proposal)
171
+ if (atOnce > MAX_AUTO_EXTRA_AGENTS) return `${atOnce} extra agents at once, at most ${MAX_AUTO_EXTRA_AGENTS}`
172
+ if (proposal.estimateTokens >= Math.min(input.thresholdTokens, maxTokens)) return 'over the token limit'
173
+ return (
174
+ taskPathRefusal(proposal, patterns) ??
175
+ taskTextRefusal(proposal, patterns) ??
176
+ phaseWritesRefusal(input, proposal, settings)
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Whether the coordinator may start `proposal` without asking: the switch holds on `today`, the lane has running work
182
+ * (the extra agents are its subagents, never a new lane), at most two extra agents run at once with the subagents
183
+ * already running, the cost stays under the limit, and neither a task's paths, its text nor its phase's writes set
184
+ * reaches a path that always asks. Every doubt asks.
185
+ */
186
+ export const autoVerdict = (
187
+ input: TaskParallelInput,
188
+ proposal: TaskParallelProposal,
189
+ settings: AutoSettings,
190
+ today: string,
191
+ ): AutoVerdict => {
192
+ if (settings.auto === undefined) return { kind: AUTO_VERDICT.off }
193
+ const reason = windowRefusal(settings.auto, today) ?? limitRefusal(input, proposal, settings)
194
+ return reason === null ? { kind: AUTO_VERDICT.auto, until: settings.auto.until } : { kind: AUTO_VERDICT.asks, reason }
195
+ }
196
+
197
+ /**
198
+ * The line that the switch ended, once per last day: `ended` is the last day already reported (null before any), the
199
+ * answer the one to keep. Nothing while the switch holds or is unset.
200
+ */
201
+ export const autoEndedReport = (
202
+ ended: string | null,
203
+ auto: AutoWindow | undefined,
204
+ today: string,
205
+ ): { ended: string | null; line: string | null } =>
206
+ auto === undefined || today <= auto.until || ended === auto.until
207
+ ? { ended, line: null }
208
+ : { ended: auto.until, line: `parallel tasks auto ended ${auto.until}: every proposal asks again` }
@@ -0,0 +1,46 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isDataPath, leavesRepository } from './task-parallel-auto'
4
+
5
+ type Child = { path: string; searched: boolean }
6
+
7
+ /** Directories a search below a writes entry skips: installed packages and the repository's own store. */
8
+ const SKIPPED = new Set(['node_modules', '.git'])
9
+ /** How many entries one search reads before it gives up; a directory larger than this counts as holding one. */
10
+ const MAX_ENTRIES = 20_000
11
+
12
+ /** The children of a directory below the root, none when it cannot be read; `searched` marks a directory to descend. */
13
+ const childrenOf = (root: string, directory: string): Child[] => {
14
+ try {
15
+ return readdirSync(join(root, directory), { withFileTypes: true }).map((entry) => ({
16
+ path: directory === '' ? entry.name : `${directory}/${entry.name}`,
17
+ searched: entry.isDirectory() && !SKIPPED.has(entry.name),
18
+ }))
19
+ } catch {
20
+ return []
21
+ }
22
+ }
23
+
24
+ /** Whether a path below `relative` is a data path; `true` once the search has read more than `limit` entries. */
25
+ const searchBelow = (root: string, relative: string, limit: number): boolean => {
26
+ const pending = [relative]
27
+ let read = 0
28
+ while (pending.length > 0) {
29
+ for (const child of childrenOf(root, pending.pop() ?? '')) {
30
+ read += 1
31
+ if (read > limit || isDataPath(child.path)) return true
32
+ if (child.searched) pending.push(child.path)
33
+ }
34
+ }
35
+ return false
36
+ }
37
+
38
+ /**
39
+ * `holdsDataPath` for the repository at `root`: whether a migration or an env file lies at or below a directory,
40
+ * read from the file system at each call. A path that may leave the repository counts, a directory that does not exist
41
+ * holds nothing, and a search that reads more than `limit` entries counts as a hit, so a doubt asks.
42
+ */
43
+ export const holdsDataPathIn =
44
+ (root: string, limit: number = MAX_ENTRIES) =>
45
+ (directory: string): boolean =>
46
+ leavesRepository(directory) || isDataPath(directory) || searchBelow(root, directory, limit)
@@ -0,0 +1,108 @@
1
+ import { isPhaseFinished, isPhaseOpen, type PlanLanguage, type PlanPhase, parsePlanPhases } from '../plan-format'
2
+ import { type PhaseDependency, parseDependencyTable, writeSetOf } from '../plan-format/dependencies'
3
+ import { matchSignals, type OpenPlanTask } from '../task-signals/matcher'
4
+ import type { TaskSignal } from '../task-signals/types'
5
+ import type { ActiveWork, OpenTask, TaskParallelInput } from './task-parallel'
6
+
7
+ /** The tasks a lane could start from its plan: open items of the phases that are ready, with the writes of the table. */
8
+ export type PlanTaskView = {
9
+ open: OpenPlanTask[]
10
+ doneCount: number
11
+ /** Phase id → the paths its row writes, `wie N` cells resolved; a phase whose set cannot be resolved has none. */
12
+ phaseWrites: Record<string, string[]>
13
+ }
14
+
15
+ const phaseWritesOf = (rows: readonly PhaseDependency[]): Record<string, string[]> => {
16
+ const byId = new Map(rows.map((row) => [row.id, row]))
17
+ return Object.fromEntries(
18
+ rows.flatMap((row) => {
19
+ const writes = writeSetOf(row, byId)
20
+ return writes === null ? [] : [[row.id, writes]]
21
+ }),
22
+ )
23
+ }
24
+
25
+ const isReady = (row: PhaseDependency, doneIds: ReadonlySet<string>): boolean =>
26
+ !doneIds.has(row.id) && row.external.length === 0 && row.after.every((id) => doneIds.has(id))
27
+
28
+ /** The phases whose tasks may start: ready by the dependency table, else the first open phase of a plan without one. */
29
+ const readyPhaseIds = (phases: readonly PlanPhase[], table: readonly PhaseDependency[] | null): ReadonlySet<string> => {
30
+ const doneIds = new Set(phases.filter(isPhaseFinished).map(({ id }) => id))
31
+ if (table === null)
32
+ return new Set(
33
+ phases
34
+ .filter(isPhaseOpen)
35
+ .slice(0, 1)
36
+ .map(({ id }) => id),
37
+ )
38
+ return new Set(table.filter((row) => isReady(row, doneIds)).map(({ id }) => id))
39
+ }
40
+
41
+ const openTasksOf = (phase: PlanPhase): OpenPlanTask[] =>
42
+ phase.items.flatMap((item, taskIndex) =>
43
+ item.done || item.gate ? [] : [{ phaseId: phase.id, taskIndex, text: item.text }],
44
+ )
45
+
46
+ /** The open tasks a lane could start from `markdown`, its ticked count and the writes of its phases. */
47
+ export const planTaskViewOf = (markdown: string, language: PlanLanguage): PlanTaskView => {
48
+ const phases = parsePlanPhases(markdown, language)
49
+ const table = parseDependencyTable(markdown, language)
50
+ const ready = readyPhaseIds(phases, table)
51
+ return {
52
+ open: phases.filter(({ id }) => ready.has(id)).flatMap(openTasksOf),
53
+ doneCount: phases.reduce((count, phase) => count + phase.items.filter((item) => item.done).length, 0),
54
+ phaseWrites: table === null ? {} : phaseWritesOf(table),
55
+ }
56
+ }
57
+
58
+ export type TaskParallelFacts = {
59
+ lane: string
60
+ plan: PlanTaskView
61
+ /** What agents in the lane's worktree report they work on, the coordinator's subagents included. */
62
+ signals: readonly TaskSignal[]
63
+ /** Files the work in the lane's worktree wrote lately, relative to it. */
64
+ touchedFiles: readonly string[]
65
+ /** The lane's own agent is working, with or without a task report. */
66
+ laneWorking: boolean
67
+ /** The writes sets of the plan at the base branch; empty when the base branch has no such plan. */
68
+ baseWrites: Record<string, string[]>
69
+ baselineTokens: number
70
+ thresholdTokens: number
71
+ }
72
+
73
+ const activeWorkOf = (facts: TaskParallelFacts): ActiveWork[] => {
74
+ const matched = matchSignals(facts.plan.open, facts.signals)
75
+ const work: ActiveWork[] = matched.map(({ signal, task }) => ({
76
+ name: signal.agent,
77
+ files: [],
78
+ taskText: signal.text,
79
+ ...(task ? { phase: task.phaseId } : {}),
80
+ }))
81
+ if (work.length === 0 && facts.laneWorking) work.push({ name: facts.lane, files: [] })
82
+ const [first, ...rest] = work
83
+ return first ? [{ ...first, files: [...facts.touchedFiles] }, ...rest] : []
84
+ }
85
+
86
+ /** The tasks a running signal already works on are not open any more. */
87
+ const openTasksWithout = (facts: TaskParallelFacts): OpenTask[] => {
88
+ const taken = new Set(
89
+ matchSignals(facts.plan.open, facts.signals).flatMap(({ task }) =>
90
+ task ? [`${task.phaseId}:${task.taskIndex}`] : [],
91
+ ),
92
+ )
93
+ return facts.plan.open
94
+ .filter((task) => !taken.has(`${task.phaseId}:${task.taskIndex}`))
95
+ .map(({ phaseId, text }) => ({ phase: phaseId, text }))
96
+ }
97
+
98
+ /** The input of the pure core from what the watcher read. */
99
+ export const taskParallelInputOf = (facts: TaskParallelFacts): TaskParallelInput => ({
100
+ lane: facts.lane,
101
+ open: openTasksWithout(facts),
102
+ active: activeWorkOf(facts),
103
+ phaseWrites: facts.plan.phaseWrites,
104
+ baseWrites: facts.baseWrites,
105
+ doneCount: facts.plan.doneCount,
106
+ baselineTokens: facts.baselineTokens,
107
+ thresholdTokens: facts.thresholdTokens,
108
+ })
@@ -0,0 +1,151 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { firstAnswerTokens } from '../context/fill'
4
+ import { agentLogPath, readHeadLines, readTailLines, TAIL_BYTES } from '../context/session-log'
5
+ import type { SprintLane } from '../plan-format'
6
+ import { MS_PER_MINUTE } from '../plan-format/clock'
7
+ import { STALE_ACTIVITY_MINUTES } from '../plans/lane-summary'
8
+ import { type PlansSettings, worktreeOf } from '../plans/settings'
9
+ import { readClaudeSignals } from '../task-signals/claude-read'
10
+ import { recordSignals } from '../task-signals/record'
11
+ import { readTaskRecords } from '../task-signals/record-store'
12
+ import type { TaskSignal } from '../task-signals/types'
13
+ import { AGENT_STATUS, type LaneAgent } from '../terminal/types'
14
+ import { baseLanePlanText, watchedLanePlanText } from './lane-progress'
15
+ import type { TaskParallelInput } from './task-parallel'
16
+ import { planTaskViewOf, taskParallelInputOf } from './task-parallel-input'
17
+ import { isWithinWorktree, touchedFilesOf } from './task-parallel-touched'
18
+
19
+ const RECENT_WRITE_MS = 30 * 60_000
20
+ const HEAD_BYTES = 256 * 1024
21
+ const SUBAGENT_LOG = /^agent-[A-Za-z0-9]+\.jsonl$/
22
+ /** How many subagent logs of one session have their writes read, the newest first. */
23
+ const MAX_SUBAGENT_LOGS = 20
24
+
25
+ /** One agent's session: its log, the signals in it and its subagents, and the tails of the logs the writes are read from. */
26
+ type AgentRead = { agent: LaneAgent; logPath: string | null; signals: TaskSignal[]; tails: string[][] }
27
+
28
+ const modifiedAt = (path: string): number => {
29
+ try {
30
+ return statSync(path).mtimeMs
31
+ } catch {
32
+ return 0
33
+ }
34
+ }
35
+
36
+ /** The newest `MAX_SUBAGENT_LOGS` subagent logs of a session, by modification time, so a long session bounds the read. */
37
+ const subagentLogPaths = (logPath: string): string[] => {
38
+ const directory = join(logPath.replace(/\.jsonl$/, ''), 'subagents')
39
+ try {
40
+ return existsSync(directory)
41
+ ? readdirSync(directory)
42
+ .filter((name) => SUBAGENT_LOG.test(name))
43
+ .map((name) => ({ path: join(directory, name), modified: modifiedAt(join(directory, name)) }))
44
+ .sort((left, right) => right.modified - left.modified)
45
+ .slice(0, MAX_SUBAGENT_LOGS)
46
+ .map(({ path }) => path)
47
+ : []
48
+ } catch {
49
+ return []
50
+ }
51
+ }
52
+
53
+ const tailOf = (path: string): string[] => {
54
+ try {
55
+ return readTailLines(path, TAIL_BYTES)
56
+ } catch {
57
+ return []
58
+ }
59
+ }
60
+
61
+ const readAgent = (agent: LaneAgent, projectsDirectory: string | undefined, now: number): AgentRead => {
62
+ const logPath = agentLogPath(agent, projectsDirectory)
63
+ const notOlderThan = now - STALE_ACTIVITY_MINUTES * MS_PER_MINUTE
64
+ return {
65
+ agent,
66
+ logPath,
67
+ signals: logPath ? readClaudeSignals(logPath, { notOlderThan }) : [],
68
+ tails: logPath ? [logPath, ...subagentLogPaths(logPath)].map(tailOf) : [],
69
+ }
70
+ }
71
+
72
+ const baselineCache = new Map<string, number | null>()
73
+
74
+ /**
75
+ * What a fresh agent carries before it has worked, measured as `jh context` measures a fill: the input of the first
76
+ * answer in a session log (system prompt, project instructions and the first request). The smallest honest source on
77
+ * hand: a log of a started session, not a guess; cached per log, since the head of a log never changes.
78
+ */
79
+ const baselineOf = (logPath: string): number | null => {
80
+ if (!baselineCache.has(logPath)) {
81
+ try {
82
+ baselineCache.set(logPath, firstAnswerTokens(readHeadLines(logPath, HEAD_BYTES)))
83
+ } catch {
84
+ baselineCache.set(logPath, null)
85
+ }
86
+ }
87
+ return baselineCache.get(logPath) ?? null
88
+ }
89
+
90
+ const baselineFor = (lane: string, reads: readonly AgentRead[]): number | null => {
91
+ const own = reads.find(({ agent }) => agent.name === lane)
92
+ const sources = [own, ...reads.filter((read) => read !== own)]
93
+ for (const read of sources) {
94
+ const tokens = read?.logPath ? baselineOf(read.logPath) : null
95
+ if (tokens !== null) return tokens
96
+ }
97
+ return null
98
+ }
99
+
100
+ const uniqueSignals = (signals: readonly TaskSignal[]): TaskSignal[] => [
101
+ ...new Map(signals.map((signal) => [`${signal.agent}|${signal.text}`, signal])).values(),
102
+ ]
103
+
104
+ export type TaskParallelSources = {
105
+ root: string
106
+ settings: PlansSettings
107
+ boardFile: string
108
+ thresholdTokens: number
109
+ now: number
110
+ /** Where Claude Code keeps its session logs; the user's by default. */
111
+ projectsDirectory?: string
112
+ }
113
+
114
+ /**
115
+ * The input of the parallel-task check for each of `lanes`, read from the plan of the lane's newest row, the sessions
116
+ * of every listed agent (a lane's work, and the coordinator's subagents in the lane's worktree) and the records of
117
+ * `jh task`. A lane without a plan, without a token baseline, without a task left or without active work (a working
118
+ * agent, a running subagent or an open record in its worktree) is not in the answer.
119
+ */
120
+ export const readTaskParallelInputs = (
121
+ lanes: readonly SprintLane[],
122
+ agents: readonly LaneAgent[],
123
+ sources: TaskParallelSources,
124
+ ): Record<string, TaskParallelInput> => {
125
+ const reads = agents.map((agent) => readAgent(agent, sources.projectsDirectory, sources.now))
126
+ const records = recordSignals(readTaskRecords(sources.root))
127
+ const since = sources.now - RECENT_WRITE_MS
128
+ return Object.fromEntries(
129
+ lanes.flatMap((lane) => {
130
+ const text = watchedLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
131
+ const baselineTokens = baselineFor(lane.name, reads)
132
+ if (text === null || baselineTokens === null) return []
133
+ const worktree = worktreeOf(sources.root, sources.settings, lane.name)
134
+ const signals = uniqueSignals([...reads.flatMap((read) => read.signals), ...records]).filter((signal) =>
135
+ isWithinWorktree(signal.cwd, worktree),
136
+ )
137
+ const baseText = baseLanePlanText(sources.root, sources.settings, sources.boardFile, lane)
138
+ const input = taskParallelInputOf({
139
+ lane: lane.name,
140
+ plan: planTaskViewOf(text, sources.settings.planLanguage),
141
+ baseWrites: baseText === null ? {} : planTaskViewOf(baseText, sources.settings.planLanguage).phaseWrites,
142
+ signals,
143
+ touchedFiles: reads.flatMap((read) => read.tails.flatMap((tail) => touchedFilesOf(tail, worktree, since))),
144
+ laneWorking: agents.some((agent) => agent.name === lane.name && agent.status === AGENT_STATUS.working),
145
+ baselineTokens,
146
+ thresholdTokens: sources.thresholdTokens,
147
+ })
148
+ return input.open.length === 0 || input.active.length === 0 ? [] : [[lane.name, input]]
149
+ }),
150
+ )
151
+ }
@@ -0,0 +1,39 @@
1
+ import { isAbsolute, relative } from 'node:path'
2
+ import { parseLogEntry } from '../context/session-log'
3
+ import { isRecord } from '../shared/is-record'
4
+
5
+ const WRITING_TOOLS: ReadonlySet<string> = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
6
+
7
+ const writtenPathsOf = (entry: Record<string, unknown>): string[] => {
8
+ const content = isRecord(entry.message) && Array.isArray(entry.message.content) ? entry.message.content : []
9
+ return content.flatMap((block) => {
10
+ if (!isRecord(block) || block.type !== 'tool_use' || !WRITING_TOOLS.has(String(block.name))) return []
11
+ const input = isRecord(block.input) ? block.input : {}
12
+ const path = input.file_path ?? input.notebook_path
13
+ return typeof path === 'string' ? [path] : []
14
+ })
15
+ }
16
+
17
+ const insideWorktree = (path: string, worktree: string): string | null => {
18
+ const inside = relative(worktree, path)
19
+ return inside === '' || inside.startsWith('..') || isAbsolute(inside) ? null : inside
20
+ }
21
+
22
+ /** Whether `path` is the worktree or lies below it. */
23
+ export const isWithinWorktree = (path: string, worktree: string): boolean =>
24
+ insideWorktree(path, worktree) !== null || path === worktree
25
+
26
+ /**
27
+ * The files the log lines show written (`Edit`, `Write`, `MultiEdit`, `NotebookEdit`) at or after `sinceMs` inside
28
+ * `worktree`, relative to it and without repeats, in order of first write. A path outside the worktree, a line that is
29
+ * no JSON and a write without a time are left out.
30
+ */
31
+ export const touchedFilesOf = (lines: readonly string[], worktree: string, sinceMs: number): string[] => {
32
+ const touched = lines.flatMap((line) => {
33
+ const entry = parseLogEntry(line)
34
+ const time = typeof entry?.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN
35
+ if (!entry || Number.isNaN(time) || time < sinceMs) return []
36
+ return writtenPathsOf(entry).flatMap((path) => insideWorktree(path, worktree) ?? [])
37
+ })
38
+ return [...new Set(touched)]
39
+ }