jackherd 1.7.1 → 1.8.1

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 (161) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/INSTALL.md +2 -0
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +86 -1037
  5. package/assets/branding/README.md +5 -7
  6. package/assets/branding/jackherd-logo-outline.svg +1 -0
  7. package/assets/branding/outline.test.ts +30 -0
  8. package/assets/icons/bark.svg +1 -1
  9. package/assets/icons/bowl.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear.svg +1 -1
  12. package/assets/icons/lanes.svg +1 -1
  13. package/assets/icons/plan.svg +1 -1
  14. package/dist/{chunk-OBUWTQ6Z.js → chunk-OU2KALXP.js} +26 -10
  15. package/dist/config.d.ts +11 -5
  16. package/dist/config.js +53 -21
  17. package/dist/plan-format.d.ts +75 -7
  18. package/dist/plan-format.js +180 -100
  19. package/docs/workflow-dark.svg +1 -1
  20. package/docs/workflow.svg +1 -1
  21. package/herdr-plugin/herdr-plugin.toml +1 -1
  22. package/install.sh +1 -1
  23. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  24. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  25. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  26. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  27. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  28. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  29. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  30. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  31. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  32. package/kit/hooks/hooks/merge-guard/decision.ts +344 -102
  33. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  34. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  35. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  36. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  37. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  39. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  40. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  41. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  42. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  43. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  44. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  45. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  46. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  47. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  48. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  49. package/kit/hooks/hooks/shared/command.ts +14 -2
  50. package/kit/hooks/hooks/shared/commit.ts +2 -0
  51. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  52. package/kit/hooks/hooks/shared/json.ts +10 -0
  53. package/kit/hooks/hooks/shared/lane.ts +57 -0
  54. package/kit/hooks/hooks/shared/once.ts +8 -0
  55. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  56. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  57. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  58. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  59. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  60. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  61. package/kit/hooks/hooks/shared/shell.ts +167 -20
  62. package/kit/hooks/hooks.json +11 -2
  63. package/kit/hooks/statusline.sh +29 -16
  64. package/kit/review/skills/diagnose/SKILL.md +1 -1
  65. package/kit/review/skills/review/SKILL.md +2 -0
  66. package/kit/rules/rules/kit/testing.md +2 -5
  67. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  68. package/kit/workflow/agents/spec-review.md +1 -1
  69. package/kit/workflow/skills/follow/SKILL.md +3 -3
  70. package/kit/workflow/skills/grill/SKILL.md +1 -1
  71. package/kit/workflow/skills/implement/SKILL.md +10 -11
  72. package/kit/workflow/skills/orchestrate/SKILL.md +156 -117
  73. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  74. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  75. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  76. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  77. package/kit/workflow/skills/plan/SKILL.md +5 -2
  78. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  79. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  80. package/package.json +4 -2
  81. package/recipes/README.md +21 -0
  82. package/recipes/github-issues.md +23 -0
  83. package/recipes/github-projects.md +22 -0
  84. package/recipes/jira-cli.md +26 -0
  85. package/recipes/jira-rest.md +24 -0
  86. package/recipes/linear.md +25 -0
  87. package/recipes/sentry.md +18 -0
  88. package/recipes/sources/jira.ts +51 -0
  89. package/recipes/sources/linear.ts +19 -0
  90. package/recipes/sources/sentry.ts +12 -0
  91. package/recipes/sources.md +80 -0
  92. package/recipes/status-page.md +19 -0
  93. package/schemas/jackherd.schema.json +42 -18
  94. package/src/check/command.ts +2 -2
  95. package/src/check/parallel-report.ts +19 -9
  96. package/src/cli.ts +1 -0
  97. package/src/config/context-shares.ts +130 -0
  98. package/src/config/read-config.ts +16 -4
  99. package/src/config/schema.ts +75 -20
  100. package/src/context/auto-compact.ts +68 -0
  101. package/src/context/command.ts +68 -17
  102. package/src/context/coordinator-name.ts +27 -9
  103. package/src/context/index.ts +0 -1
  104. package/src/context/level.ts +18 -11
  105. package/src/context/report.ts +6 -3
  106. package/src/plan-format/dependencies.ts +144 -32
  107. package/src/plan-format/index.ts +6 -0
  108. package/src/plan-format/lanes.ts +6 -0
  109. package/src/plan-format/progress.ts +19 -8
  110. package/src/plans/command.ts +14 -5
  111. package/src/plans/current-phase.ts +63 -7
  112. package/src/plans/current-task.ts +51 -0
  113. package/src/plans/ensure-panes.ts +25 -8
  114. package/src/plans/frame.ts +99 -0
  115. package/src/plans/git.ts +8 -0
  116. package/src/plans/herd-memory.ts +107 -0
  117. package/src/plans/lane-plans.ts +5 -3
  118. package/src/plans/lane-standing.ts +5 -4
  119. package/src/plans/lane-summary.ts +222 -0
  120. package/src/plans/layout-phase.ts +148 -0
  121. package/src/plans/layout-summary.ts +140 -0
  122. package/src/plans/layout.ts +164 -420
  123. package/src/plans/live.ts +11 -6
  124. package/src/plans/plan-panes.ts +15 -0
  125. package/src/plans/planned.ts +9 -42
  126. package/src/plans/selection.ts +2 -6
  127. package/src/plans/settings.ts +2 -7
  128. package/src/plans/snapshot.ts +38 -39
  129. package/src/plans/symbols.ts +2 -2
  130. package/src/plans/texts.ts +32 -31
  131. package/src/plans/view.ts +0 -1
  132. package/src/plans/worked-phases.ts +2 -2
  133. package/src/settled/board-row.ts +133 -0
  134. package/src/settled/check.ts +129 -0
  135. package/src/settled/command.ts +65 -0
  136. package/src/settled/evidence-version.ts +14 -0
  137. package/src/settled/options.ts +69 -0
  138. package/src/settled/send.ts +27 -0
  139. package/src/settled/settle.ts +87 -0
  140. package/src/settled/topics.ts +56 -0
  141. package/src/sync/render.ts +13 -4
  142. package/src/sync/sync.ts +25 -5
  143. package/src/terminal/herdr.ts +46 -10
  144. package/src/terminal/none.ts +1 -0
  145. package/src/terminal/types.ts +15 -2
  146. package/src/update/command.ts +2 -0
  147. package/src/update/config-shares.ts +30 -0
  148. package/src/update/update.ts +12 -1
  149. package/src/vocabulary/index.ts +51 -11
  150. package/src/watch/appointments.ts +206 -0
  151. package/src/watch/command.ts +62 -19
  152. package/src/watch/lane-report.ts +11 -3
  153. package/src/watch/texts.ts +35 -59
  154. package/src/watch/watch-events.ts +21 -28
  155. package/assets/icons/bark-dark.svg +0 -1
  156. package/assets/icons/bowl-dark.svg +0 -1
  157. package/assets/icons/decide-dark.svg +0 -1
  158. package/assets/icons/ear-dark.svg +0 -1
  159. package/assets/icons/lanes-dark.svg +0 -1
  160. package/assets/icons/plan-dark.svg +0 -1
  161. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -0,0 +1,182 @@
1
+ import { programName, ruleStart } from '../shared/program-position'
2
+ import { expandsBraces, isExpanded, type SimpleCommand, type Word } from '../shared/shell'
3
+
4
+ /**
5
+ * What an `xargs` call is told to run: the program word (absent when it runs `echo`) and where it stands among the
6
+ * words after `xargs`, and the strings `-I`, `-i`, `--replace` or BSD's `-J` replace with the input, which may stand
7
+ * anywhere, the program included.
8
+ */
9
+ type XargsCall = { program: Word | undefined; at: number; replaceStrings: string[] }
10
+
11
+ /** One option word read: the words it used up, and the replace string it set, if any. */
12
+ type OptionRead = { width: number; replaceString?: string }
13
+
14
+ const XARGS = 'xargs'
15
+ const END_OF_OPTIONS = '--'
16
+ const DEFAULT_REPLACE_STRING = '{}'
17
+ const GLOB = /[*?[]/
18
+ const ECHO: Word = { value: 'echo', literal: true }
19
+
20
+ /** GNU and BSD short options: with a value (attached or the next word), with a value only when attached, without one. */
21
+ const SHORT_WITH_VALUE = new Set(['a', 'd', 'E', 'I', 'J', 'L', 'n', 'P', 'R', 's', 'S'])
22
+ const SHORT_WITH_ATTACHED_VALUE = new Set(['e', 'i', 'l'])
23
+ const SHORT_FLAGS = new Set(['0', 'o', 'p', 'r', 't', 'x'])
24
+ const SHORT_REPLACE = new Set(['I', 'J'])
25
+ const SHORT_OLD_REPLACE = 'i'
26
+
27
+ const LONG_WITH_VALUE = new Set([
28
+ '--arg-file',
29
+ '--delimiter',
30
+ '--max-args',
31
+ '--max-chars',
32
+ '--max-procs',
33
+ '--process-slot-var',
34
+ ])
35
+ const LONG_WITH_ATTACHED_VALUE = new Set(['--eof', '--max-lines', '--replace'])
36
+ const LONG_FLAGS = new Set([
37
+ '--exit',
38
+ '--help',
39
+ '--interactive',
40
+ '--no-run-if-empty',
41
+ '--null',
42
+ '--open-tty',
43
+ '--show-limits',
44
+ '--verbose',
45
+ '--version',
46
+ ])
47
+ const LONG_REPLACE = '--replace'
48
+
49
+ /**
50
+ * Programs that read or write the files their arguments name and start nothing, whatever the arguments say: input
51
+ * handed to them by `xargs` stays arguments. A program that runs text or a program its arguments name stays out (`git`
52
+ * with `-c alias.…`, `sed` with `e`, `awk` with `system()`, `rg --pre`, `sort --compress-program`), as does every
53
+ * program not listed: the list is closed, so an unknown program keeps the search of the whole text.
54
+ */
55
+ const PLAIN_PROGRAMS = new Set([
56
+ 'basename',
57
+ 'cat',
58
+ 'chmod',
59
+ 'dirname',
60
+ 'du',
61
+ 'echo',
62
+ 'egrep',
63
+ 'fgrep',
64
+ 'file',
65
+ 'grep',
66
+ 'head',
67
+ 'jq',
68
+ 'ls',
69
+ 'md5sum',
70
+ 'mkdir',
71
+ 'printf',
72
+ 'readlink',
73
+ 'realpath',
74
+ 'rm',
75
+ 'rmdir',
76
+ 'sha1sum',
77
+ 'sha256sum',
78
+ 'shasum',
79
+ 'stat',
80
+ 'tail',
81
+ 'touch',
82
+ 'tr',
83
+ 'uniq',
84
+ 'wc',
85
+ ])
86
+
87
+ /**
88
+ * A word the shell hands over as written, quotes and escapes aside: an expansion, a glob or braces could become
89
+ * several words, or none, and shift which word is the program.
90
+ */
91
+ const isFixed = ({ value }: Word): boolean => !isExpanded(value) && !GLOB.test(value) && !expandsBraces(value)
92
+
93
+ /** A value in the next word must be there and fixed. */
94
+ const nextValue = (words: Word[], index: number): Word | undefined => {
95
+ const next = words[index + 1]
96
+ return next !== undefined && isFixed(next) ? next : undefined
97
+ }
98
+
99
+ const readLongOption = (words: Word[], index: number): OptionRead | undefined => {
100
+ const { value } = words[index]
101
+ const at = value.indexOf('=')
102
+ const name = at === -1 ? value : value.slice(0, at)
103
+ const attached = at === -1 ? undefined : value.slice(at + 1)
104
+ if (LONG_WITH_VALUE.has(name)) {
105
+ if (attached !== undefined) return { width: 1 }
106
+ return nextValue(words, index) ? { width: 2 } : undefined
107
+ }
108
+ if (LONG_WITH_ATTACHED_VALUE.has(name)) {
109
+ const replaceString = name === LONG_REPLACE ? (attached ?? DEFAULT_REPLACE_STRING) : undefined
110
+ return { width: 1, replaceString }
111
+ }
112
+ return LONG_FLAGS.has(name) && attached === undefined ? { width: 1 } : undefined
113
+ }
114
+
115
+ /** A short option that takes a value: the rest of its word, or the next word when nothing is attached. */
116
+ const readShortValue = (words: Word[], index: number, letter: string, attached: string): OptionRead | undefined => {
117
+ if (attached !== '') return { width: 1, replaceString: SHORT_REPLACE.has(letter) ? attached : undefined }
118
+ const separate = nextValue(words, index)
119
+ if (separate === undefined) return undefined
120
+ return { width: 2, replaceString: SHORT_REPLACE.has(letter) ? separate.value : undefined }
121
+ }
122
+
123
+ /** A cluster of short options (`-0rn1`, `-tI{}`): flags, then at most one option that takes the rest as its value. */
124
+ const readShortOptions = (words: Word[], index: number): OptionRead | undefined => {
125
+ const { value } = words[index]
126
+ for (let at = 1; at < value.length; at++) {
127
+ const letter = value[at]
128
+ if (SHORT_FLAGS.has(letter)) continue
129
+ const attached = value.slice(at + 1)
130
+ if (SHORT_WITH_VALUE.has(letter)) return readShortValue(words, index, letter, attached)
131
+ if (!SHORT_WITH_ATTACHED_VALUE.has(letter)) return undefined
132
+ const replaceString = letter === SHORT_OLD_REPLACE ? attached || DEFAULT_REPLACE_STRING : undefined
133
+ return { width: 1, replaceString }
134
+ }
135
+ return { width: 1 }
136
+ }
137
+
138
+ const readOption = (words: Word[], index: number): OptionRead | undefined =>
139
+ words[index].value.startsWith('--') ? readLongOption(words, index) : readShortOptions(words, index)
140
+
141
+ /**
142
+ * Reads the words after `xargs` up to its program. An option this guard does not know, or a word before the program
143
+ * that is not fixed, makes the call unreadable: undefined.
144
+ */
145
+ const readXargsCall = (args: Word[]): XargsCall | undefined => {
146
+ const replaceStrings: string[] = []
147
+ for (let index = 0; index < args.length; ) {
148
+ const word = args[index]
149
+ if (!isFixed(word)) return undefined
150
+ if (word.value === END_OF_OPTIONS) return { program: args[index + 1], at: index + 1, replaceStrings }
151
+ if (!word.value.startsWith('-') || word.value === '-') return { program: word, at: index, replaceStrings }
152
+ const option = readOption(args, index)
153
+ if (option === undefined) return undefined
154
+ if (option.replaceString !== undefined) replaceStrings.push(option.replaceString)
155
+ index += option.width
156
+ }
157
+ return { program: undefined, at: args.length, replaceStrings }
158
+ }
159
+
160
+ const isPlainProgram = (program: Word, replaceStrings: string[], guardedPrograms: readonly string[]): boolean =>
161
+ isFixed(program) &&
162
+ !replaceStrings.some((replaceString) => program.value.includes(replaceString)) &&
163
+ PLAIN_PROGRAMS.has(program.value) &&
164
+ !guardedPrograms.includes(program.value)
165
+
166
+ /**
167
+ * An `xargs` at the start of the command that runs a fixed program starting nothing (D77) becomes that program's
168
+ * call, the words before `xargs` kept: its input then only adds arguments, so the program counts like any other call
169
+ * and the text around it is no sign. Without a program it becomes `echo`, which `xargs` then runs. Every other
170
+ * command comes back as it is, so `xargs` stays opaque for a program from an expansion or a replace string, a glob,
171
+ * a guarded program, a starter (a shell, `env`, `xargs`, `timeout`, an interpreter) and any program not on the list.
172
+ */
173
+ export const resolvePlainXargs = (words: SimpleCommand, guardedPrograms: readonly string[]): SimpleCommand => {
174
+ const start = ruleStart(words)
175
+ if (words[start] === undefined || programName(words[start]) !== XARGS) return words
176
+ const call = readXargsCall(words.slice(start + 1))
177
+ if (call === undefined) return words
178
+ const prefix = words.slice(0, start)
179
+ if (call.program === undefined) return [...prefix, ECHO]
180
+ if (!isPlainProgram(call.program, call.replaceStrings, guardedPrograms)) return words
181
+ return [...prefix, ...words.slice(start + 1 + call.at)]
182
+ }
@@ -0,0 +1,23 @@
1
+ export const DEFAULT_BASE_BRANCH = 'main'
2
+
3
+ /**
4
+ * A branch name both guards accept as the base branch: no option (`-x`), no `HEAD`, none of the forms git refuses
5
+ * (`..`, `//`, `@{`, a `.lock` component, a trailing `.` or `/`), only characters a project plausibly names one with.
6
+ */
7
+ const BRANCH =/^(?!HEAD$)(?![-/.])(?!.*(?:\.\.|\/\/|@\{|\.lock(?:\/|$)))[A-Za-z0-9._/-]+(?<![./])$/
8
+
9
+ const BRANCH_REF = /^refs\/heads\//
10
+
11
+ const isBranchName = (value: unknown): value is string => typeof value === 'string' && BRANCH.test(value)
12
+
13
+ /**
14
+ * The base branch a project config names, `refs/heads/<name>` read as `<name>`: `main` when it names none, undefined
15
+ * when the config could not be read (`null`) or the value is no branch name.
16
+ */
17
+ export const baseBranchOf = (config: Record<string, unknown> | null): string | undefined => {
18
+ if (config === null) return undefined
19
+ if (!('baseBranch' in config)) return DEFAULT_BASE_BRANCH
20
+ const value = config.baseBranch
21
+ const name = typeof value === 'string' ? value.replace(BRANCH_REF, '') : value
22
+ return isBranchName(name) ? name : undefined
23
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The lanes table of a board, read the way the herd reads it (`parseSprintLanes` in the package), which a hook cannot
3
+ * import: the first table under `## <lanes>`, a row naming its lane in backticks or continuing the one above with `↳`,
4
+ * the plan as the link in the third cell. A test compares both on the repository's real boards.
5
+ */
6
+ import { posix } from 'node:path'
7
+
8
+ export type BoardLanguage = 'de' | 'en'
9
+
10
+ /** The plan vocabulary's words for the lanes section and a closed lane, per plan language. */
11
+ export const BOARD_WORDS: Record<BoardLanguage, { lanes: string; laneClosed: string }> = {
12
+ de: { lanes: 'Spuren', laneClosed: 'abgeräumt' },
13
+ en: { lanes: 'Lanes', laneClosed: 'cleaned up' },
14
+ }
15
+
16
+ /** A lane's rows: whether any says the lane was cleaned up, and the plans they link, relative to the repository. */
17
+ export type LanePlans = { closed: boolean; plans: string[] }
18
+
19
+ type Row = { lane: string; cells: string[] }
20
+
21
+ const LANE_NAME = /`([^`]+)`/
22
+ const CONTINUATION = '↳'
23
+ const PLAN_LINK = /\[([^\]]+)\]\(([^)]+)\)/
24
+ const SECTION_END = /^##\s/
25
+ const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
26
+ const PLAN_COLUMN = 2
27
+
28
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
29
+
30
+ const sectionLines = (markdown: string, heading: string): string[] => {
31
+ const lines = markdown.split('\n')
32
+ const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`)
33
+ const start = lines.findIndex((line) => pattern.test(line))
34
+ if (start === -1) return []
35
+ const rest = lines.slice(start + 1)
36
+ const end = rest.findIndex((line) => SECTION_END.test(line))
37
+ return end === -1 ? rest : rest.slice(0, end)
38
+ }
39
+
40
+ const cellsOf = (line: string): string[] =>
41
+ line
42
+ .slice(1, line.endsWith('|') ? -1 : undefined)
43
+ .split('|')
44
+ .map((cell) => cell.trim())
45
+
46
+ /** The body rows of the first table, header and separator rows dropped. */
47
+ const firstTableLines = (lines: string[]): string[] => {
48
+ const start = lines.findIndex((line) => line.startsWith('|'))
49
+ if (start === -1) return []
50
+ const rest = lines.slice(start)
51
+ const end = rest.findIndex((line) => !line.startsWith('|'))
52
+ return (end === -1 ? rest : rest.slice(0, end)).slice(1).filter((line) => !TABLE_SEPARATOR.test(line))
53
+ }
54
+
55
+ const laneRows = (markdown: string, language: BoardLanguage): Row[] => {
56
+ const rows: Row[] = []
57
+ let previous: string | null = null
58
+ for (const line of firstTableLines(sectionLines(markdown, BOARD_WORDS[language].lanes))) {
59
+ const cells = cellsOf(line)
60
+ const first = cells[0] ?? ''
61
+ const lane: string | null = first.match(LANE_NAME)?.[1] ?? (first.startsWith(CONTINUATION) ? previous : null)
62
+ if (lane === null) continue
63
+ rows.push({ lane, cells })
64
+ previous = lane
65
+ }
66
+ return rows
67
+ }
68
+
69
+ /** The repository path of a plan link, relative to the board's directory. */
70
+ const planPathOf = (boardDirectory: string, cell: string): string[] => {
71
+ const href = cell.match(PLAN_LINK)?.[2]
72
+ return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
73
+ }
74
+
75
+ /** Every lane the lanes table names, in table order. */
76
+ export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
77
+ ...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
78
+ ]
79
+
80
+ /** The rows of `lane` in the board's lanes table; undefined when the table has no row for it. */
81
+ export const lanePlansOf = (
82
+ markdown: string,
83
+ boardDirectory: string,
84
+ lane: string,
85
+ language: BoardLanguage,
86
+ ): LanePlans | undefined => {
87
+ const rows = laneRows(markdown, language).filter((row) => row.lane === lane)
88
+ if (rows.length === 0) return undefined
89
+ return {
90
+ closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
91
+ plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
92
+ }
93
+ }
@@ -0,0 +1,71 @@
1
+ import { isProgramPosition, programName } from './program-position'
2
+ import type { SimpleCommand, Word } from './shell'
3
+
4
+ /** Programs that run text they are given or read from elsewhere, so no parse of the shell sees what runs. */
5
+ const OPAQUE_RUNNERS = new Set([
6
+ 'xargs',
7
+ 'find',
8
+ 'trap',
9
+ 'coproc',
10
+ 'repeat',
11
+ 'script',
12
+ 'su',
13
+ 'flock',
14
+ 'ssh',
15
+ 'parallel',
16
+ 'ionice',
17
+ 'chrt',
18
+ 'taskset',
19
+ 'strace',
20
+ 'unbuffer',
21
+ 'chroot',
22
+ 'runuser',
23
+ 'systemd-run',
24
+ 'nsenter',
25
+ 'unshare',
26
+ 'firejail',
27
+ 'pkexec',
28
+ 'entr',
29
+ 'watchexec',
30
+ 'busybox',
31
+ 'watch',
32
+ 'osascript',
33
+ ])
34
+
35
+ /** Package runners whose `-c`/`--call` hands a command string to a shell: `npx -c`, `npm exec -c`, `pnpm dlx -c`. */
36
+ const PACKAGE_RUNNERS = new Set(['npx', 'npm', 'pnpm', 'yarn'])
37
+ const CALL_OPTION = /^(-c|--call)(=|$)/
38
+ const AWK_PROGRAMS = new Set(['awk', 'gawk', 'mawk', 'nawk'])
39
+ /** An awk program that starts a command: `system(…)`, a pipe into or out of `getline`, `print … | "cmd"`. */
40
+ const AWK_RUNS_COMMAND = /\bsystem\s*\(|\|\s*getline|\|&|print[^;}]*\|/
41
+ /** sed's `e` command, alone or after an address: `1e cmd`, `/x/e cmd`, `;e`. */
42
+ const SED_EXECUTE_COMMAND = /(^|[\s;{}!=\d$/])e(\s|;|$)/
43
+ /** sed's `s///e` flag, which runs the pattern space as a command. */
44
+ const SED_EXECUTE_FLAG = /^\s*s(.)(?:\\.|(?!\1).)*\1(?:\\.|(?!\1).)*\1[^;\s}]*e/
45
+
46
+ const sedRunsCommand = (rest: Word[]): boolean =>
47
+ rest.some(
48
+ ({ value }) => SED_EXECUTE_COMMAND.test(value) || value.split(/[;\n]/).some((piece) => SED_EXECUTE_FLAG.test(piece)),
49
+ )
50
+
51
+ const runsCommandText = (name: string, rest: Word[]): boolean =>
52
+ OPAQUE_RUNNERS.has(name) ||
53
+ (PACKAGE_RUNNERS.has(name) && rest.some(({ value }) => CALL_OPTION.test(value))) ||
54
+ (name === 'sed' && sedRunsCommand(rest)) ||
55
+ (AWK_PROGRAMS.has(name) && rest.some(({ value }) => AWK_RUNS_COMMAND.test(value)))
56
+
57
+ /**
58
+ * The index of a program that runs command text it is given (`watch '…'`, `script -qc '…'`, `npx -c '…'`, sed's `e`,
59
+ * awk's `system()`), anywhere a program can stand; -1 when there is none.
60
+ */
61
+ export const commandTextRunnerIndex = (words: SimpleCommand): number =>
62
+ words.findIndex(
63
+ (word, index) => isProgramPosition(words, index) && runsCommandText(programName(word), words.slice(index + 1)),
64
+ )
65
+
66
+ /**
67
+ * The text a mention search reads: the script as written and, beside it, every word as the shell decodes it, so a word
68
+ * split by quotes (`pu""sh`) or written in `$'…'` escapes (`pu\x73h`) is found too.
69
+ */
70
+ export const searchableText = (script: string, commands: SimpleCommand[]): string =>
71
+ [script, ...commands.map((words) => words.map(({ value }) => value).join(' '))].join('\n')
@@ -1,6 +1,18 @@
1
- /** The trimmed stdout of a command run without a shell; throws when it fails, so the caller gives up. */
1
+ /** Long enough for a local `git` or `herdr` answer, well below the deadline of the hook that waits for it. */
2
+ export const COMMAND_TIMEOUT_MS = 3000
3
+
4
+ /**
5
+ * The trimmed stdout of a command run without a shell; throws when it fails or outlives `COMMAND_TIMEOUT_MS`, so the
6
+ * caller gives up. A command past the limit is killed with SIGKILL, which it cannot ignore.
7
+ */
2
8
  export const commandOutput = (command: string[], cwd?: string): string => {
3
- const result = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'ignore' })
9
+ const result = Bun.spawnSync(command, {
10
+ cwd,
11
+ stdout: 'pipe',
12
+ stderr: 'ignore',
13
+ timeout: COMMAND_TIMEOUT_MS,
14
+ killSignal: 'SIGKILL',
15
+ })
4
16
  if (result.exitCode !== 0) throw new Error(`${command[0]} failed`)
5
17
  return result.stdout.toString().trim()
6
18
  }
@@ -0,0 +1,2 @@
1
+ /** A full commit id as git and GitHub write it: 40 hex digits for SHA-1, 64 for SHA-256. */
2
+ export const FULL_COMMIT = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/
@@ -3,6 +3,9 @@ import { isRecord, type JsonObject } from './json'
3
3
 
4
4
  export type { DialogAnswer } from './dialog-answer'
5
5
 
6
+ /** The tool name of the dialog, as hook inputs and transcripts carry it. */
7
+ export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
8
+
6
9
  /**
7
10
  * The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
8
11
  * client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
@@ -49,7 +52,7 @@ const askedQuestions = (entries: JsonObject[]): Map<string, AskedQuestion[]> =>
49
52
  entries
50
53
  .filter(isMainThread)
51
54
  .flatMap(contentOf)
52
- .filter((block) => block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string')
55
+ .filter((block) => block.type === 'tool_use' && block.name === ASK_USER_QUESTION_TOOL && typeof block.id === 'string')
53
56
  .map((block): [string, AskedQuestion[]] => [block.id as string, questionsOf(block.input)]),
54
57
  )
55
58
 
@@ -2,3 +2,13 @@ export type JsonObject = Record<string, unknown>
2
2
 
3
3
  export const isRecord = (value: unknown): value is JsonObject =>
4
4
  typeof value === 'object' && value !== null && !Array.isArray(value)
5
+
6
+ /** The JSON text as an object; undefined for text that is no JSON or no object (an array included). */
7
+ export const parseJsonObject = (text: string): JsonObject | undefined => {
8
+ try {
9
+ const value: unknown = JSON.parse(text)
10
+ return isRecord(value) ? value : undefined
11
+ } catch {
12
+ return undefined
13
+ }
14
+ }
@@ -0,0 +1,57 @@
1
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
2
+ import type { PaneAgent } from './pane-agent'
3
+
4
+ /** herdr's agent names; a worktree whose directory has another name cannot be a lane. */
5
+ const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
6
+
7
+ export type LaneLocation = { cwd: string; main: string; worktreeDirectory: string }
8
+
9
+ export type AgentLocation = {
10
+ /** The agent herdr runs in the pane the hook was called from. */
11
+ agent: PaneAgent
12
+ /** The session the hook was called for. */
13
+ session: string
14
+ main: string
15
+ worktreeDirectory: string
16
+ }
17
+
18
+ /** The session that merges, against the agent herdr runs in its pane. */
19
+ export type MergingSession = {
20
+ /** The agent of the pane the hook runs in; null outside herdr. */
21
+ agent: PaneAgent | null
22
+ /** The `session_id` of the hook input. */
23
+ session: unknown
24
+ main: string
25
+ /** Whether a herdr name is a lane's, whose worktree exists. */
26
+ isLane: (name: string) => boolean
27
+ }
28
+
29
+ /** The lane whose worktree holds `cwd`: the first directory below the main checkout's worktree directory, else null. */
30
+ export const laneOf = ({ cwd, main, worktreeDirectory }: LaneLocation): string | null => {
31
+ const path = relative(resolve(main, worktreeDirectory), resolve(cwd))
32
+ if (path === '' || path.startsWith('..') || isAbsolute(path)) return null
33
+ const [lane] = path.split(sep)
34
+ return LANE_NAME.test(lane) ? lane : null
35
+ }
36
+
37
+ /**
38
+ * The lane the agent of this pane is: an agent named after a worktree that it runs in, in the session the hook was
39
+ * called for. The shell's directory has no say: the coordinator's shell may stand in a lane's worktree.
40
+ */
41
+ export const laneOfAgent = ({ agent, session, main, worktreeDirectory }: AgentLocation): string | null => {
42
+ if (agent.name === null || agent.cwd === null || agent.session === null || agent.session !== session) return null
43
+ return laneOf({ cwd: agent.cwd, main, worktreeDirectory }) === agent.name ? agent.name : null
44
+ }
45
+
46
+ /**
47
+ * Whether the session that merges is the coordinator: the agent of its pane, in this very session, started in the main
48
+ * checkout and not under a lane's name. A lane's transcript may lie beside the coordinator's when it was started in the
49
+ * main checkout; its name still tells it apart, so the plan approval never covers a lane's own merge.
50
+ */
51
+ export const isCoordinatorAgent = ({ agent, session, main, isLane }: MergingSession): boolean =>
52
+ agent !== null &&
53
+ agent.session !== null &&
54
+ agent.session === session &&
55
+ agent.cwd !== null &&
56
+ resolve(agent.cwd) === resolve(main) &&
57
+ (agent.name === null || !isLane(agent.name))
@@ -0,0 +1,8 @@
1
+ /** The value of `compute`, computed at the first call and kept for every later one, undefined and throws aside. */
2
+ export const once = <T>(compute: () => T): (() => T) => {
3
+ let kept: { value: T } | undefined
4
+ return () => {
5
+ kept ??= { value: compute() }
6
+ return kept.value
7
+ }
8
+ }
@@ -0,0 +1,31 @@
1
+ import { type PaneAgent, paneAgentOf } from './pane-agent'
2
+
3
+ /** herdr's pane ids (`w2V:p1`); anything else never reaches the command line. */
4
+ const PANE_ID = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/
5
+ /** Well below the deadlines of the hooks that ask, which still have work to do after it. */
6
+ const HERDR_TIMEOUT_MS = 2000
7
+
8
+ /**
9
+ * The agent herdr runs in the pane this process was started in; null outside herdr and whenever herdr cannot be asked.
10
+ *
11
+ * Only the pane is fixed: herdr sets `HERDR_PANE_ID` in the client's environment, and no command the agent runs can
12
+ * change it there. herdr's answer about that pane is not: the agent's name and session are reported to herdr, and any
13
+ * process may report them. Only `cwd` herdr reads from the process itself. A caller trusts a name or a session only
14
+ * where a second fact binds it, such as the session of the hook input or a worktree of that name.
15
+ */
16
+ export const ownPaneAgent = (env: Record<string, string | undefined> = process.env): PaneAgent | null => {
17
+ const paneId = env.HERDR_PANE_ID
18
+ if (paneId === undefined || !PANE_ID.test(paneId)) return null
19
+ try {
20
+ const result = Bun.spawnSync(['herdr', 'agent', 'get', paneId], {
21
+ stdin: 'ignore',
22
+ stdout: 'pipe',
23
+ stderr: 'ignore',
24
+ timeout: HERDR_TIMEOUT_MS,
25
+ env,
26
+ })
27
+ return result.exitCode === 0 ? paneAgentOf(JSON.parse(result.stdout.toString())) : null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
@@ -0,0 +1,24 @@
1
+ import { isRecord, type JsonObject } from './json'
2
+
3
+ /** The agent herdr runs in a pane: its herdr name, the directory it started in and its client session, each null when unknown. */
4
+ export type PaneAgent = { name: string | null; cwd: string | null; session: string | null }
5
+
6
+ /** A client's session id as a UUID; nothing else may reach a path. */
7
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
8
+
9
+ /** The client session of an agent or a pane herdr reports; null for a shell, or a session id of another shape. */
10
+ export const sessionOf = (pane: JsonObject): string | null => {
11
+ const session = isRecord(pane.agent_session) ? pane.agent_session.value : undefined
12
+ return typeof pane.agent === 'string' && pane.agent !== '' && typeof session === 'string' && SESSION_ID.test(session)
13
+ ? session
14
+ : null
15
+ }
16
+
17
+ const textOrNull = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
18
+
19
+ /** The agent of the answer of `herdr agent get <pane>`; null for an error or an answer of another shape. */
20
+ export const paneAgentOf = (answer: unknown): PaneAgent | null => {
21
+ const agent = isRecord(answer) && isRecord(answer.result) ? answer.result.agent : undefined
22
+ if (!isRecord(agent)) return null
23
+ return { name: textOrNull(agent.name), cwd: textOrNull(agent.cwd), session: sessionOf(agent) }
24
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Whether a plan still has an open phase, read the way the herd reads phases (`parsePlanPhases` in the package),
3
+ * which a hook cannot import. A test compares both on the repository's real plans.
4
+ */
5
+
6
+ /** A task of a phase, a checkbox or a row with a ⬜/✅ cell: whether it is open, and its text without the state. */
7
+ export type PlanTask = { open: boolean; text: string }
8
+
9
+ type Phase = { id: string; items: boolean[]; deferredOpen: boolean; pointer?: string }
10
+
11
+ /** A phase's id, its heading without the leading hashes, and its lines up to the next heading that ends it. */
12
+ export type PhaseBlock = { id: string; heading: string; lines: string[] }
13
+
14
+ const PHASE_HEADING = /^###\s+Phase\s+(\d+[a-z]?)\b/i
15
+ /** `## 10 — title`, and `## P15 — title` as a board names the phase. */
16
+ const NUMBERED_SECTION = /^##\s+P?(\d+[a-z]?)\s*[—–:-]\s*(.*)$/
17
+ const PHASE_END = /^#{1,3}\s/
18
+ const SECTION_HEADING = /^##\s+(.+?)\s*$/
19
+ const TOP_HEADING = /^#\s/
20
+ const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.*)$/
21
+ const DEFERRED_TO = /\s→\s*P(\d+[a-z]?)\s*$/i
22
+ const SECTION_POINTER = /`##\s+([^`]+?)\s*`/
23
+ const STATUS = { done: '✅', open: '⬜' } as const
24
+
25
+ const collapsed = (text: string): string => text.trim().replace(/\s+/g, ' ')
26
+
27
+ /** The task a line holds, null for a line that is none. */
28
+ export const taskOf = (line: string): PlanTask | null => {
29
+ const checkbox = line.match(CHECKBOX)
30
+ if (checkbox) return { open: checkbox[1] === ' ', text: collapsed(checkbox[2]) }
31
+ const trimmed = line.trim()
32
+ if (!trimmed.startsWith('|')) return null
33
+ const cells = trimmed.replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim())
34
+ const status = cells.find((cell) => cell === STATUS.done || cell === STATUS.open)
35
+ if (status === undefined) return null
36
+ const text = cells.filter((cell) => cell !== STATUS.done && cell !== STATUS.open).join(' | ')
37
+ return { open: status === STATUS.open, text: collapsed(text) }
38
+ }
39
+
40
+ /** An item of a phase: `true` while open, `false` when done, null for a line that is none. */
41
+ const itemOf = (line: string): boolean | null => taskOf(line)?.open ?? null
42
+
43
+ /** An open checkbox moved on to another phase with `→ P<n>` at its end; it stays open, in that phase. */
44
+ const isDeferred = (line: string, phaseId: string): boolean => {
45
+ const checkbox = line.match(CHECKBOX)
46
+ const target = checkbox?.[1] === ' ' ? checkbox[2].match(DEFERRED_TO)?.[1]?.toLowerCase() : undefined
47
+ return target !== undefined && target !== phaseId
48
+ }
49
+
50
+ const phaseIdOf = (line: string): string | undefined =>
51
+ (line.match(PHASE_HEADING) ?? line.match(NUMBERED_SECTION))?.[1]?.toLowerCase()
52
+
53
+ const fileLine = (phase: Phase, line: string): void => {
54
+ phase.pointer ??= line.match(SECTION_POINTER)?.[1]
55
+ if (isDeferred(line, phase.id)) {
56
+ phase.deferredOpen = true
57
+ return
58
+ }
59
+ const item = itemOf(line)
60
+ if (item !== null) phase.items.push(item)
61
+ }
62
+
63
+ /** The phases of a plan as they stand, `### Phase <id>` and `## <id> — title` alike. */
64
+ export const phaseBlocksOf = (markdown: string): PhaseBlock[] => {
65
+ const blocks: PhaseBlock[] = []
66
+ let open: PhaseBlock | null = null
67
+ for (const line of markdown.split('\n')) {
68
+ const id = phaseIdOf(line)
69
+ if (id !== undefined) {
70
+ open = { id, heading: collapsed(line.replace(/^#+/, '')), lines: [] }
71
+ blocks.push(open)
72
+ } else if (PHASE_END.test(line)) open = null
73
+ else open?.lines.push(line)
74
+ }
75
+ return blocks
76
+ }
77
+
78
+ const phasesOf = (markdown: string): Phase[] =>
79
+ phaseBlocksOf(markdown).map(({ id, lines }) => {
80
+ const phase: Phase = { id, items: [], deferredOpen: false }
81
+ for (const line of lines) fileLine(phase, line)
82
+ return phase
83
+ })
84
+
85
+ /** The items of every `## ` section by heading, sub-headings included; the last of equal headings wins. */
86
+ const sectionItems = (markdown: string): Map<string, boolean[]> => {
87
+ const sections = new Map<string, boolean[]>()
88
+ let open: boolean[] | null = null
89
+ for (const line of markdown.split('\n')) {
90
+ const heading = line.match(SECTION_HEADING)
91
+ if (heading) {
92
+ open = []
93
+ sections.set(heading[1], open)
94
+ } else if (TOP_HEADING.test(line)) open = null
95
+ else {
96
+ const item = itemOf(line)
97
+ if (item !== null) open?.push(item)
98
+ }
99
+ }
100
+ return sections
101
+ }
102
+
103
+ /**
104
+ * True when a phase (`### Phase <id>` or `## <id> — title`) holds an open checkbox or ⬜ row, a deferred one included;
105
+ * a phase without items of its own counts the section it points at (``Siehe `## Dokumentation` ``).
106
+ */
107
+ export const hasOpenPhase = (markdown: string): boolean => {
108
+ const phases = phasesOf(markdown)
109
+ if (phases.some((phase) => phase.deferredOpen || phase.items.includes(true))) return true
110
+ const pointed = phases.filter((phase) => phase.items.length === 0 && phase.pointer !== undefined)
111
+ if (pointed.length === 0) return false
112
+ const sections = sectionItems(markdown)
113
+ return pointed.some((phase) => (sections.get(phase.pointer ?? '') ?? []).includes(true))
114
+ }