jackherd 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/INSTALL.md +4 -2
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/legacy-names.ts +8 -0
  101. package/src/config/read-config.ts +12 -4
  102. package/src/config/schema.ts +75 -20
  103. package/src/context/auto-compact.ts +68 -0
  104. package/src/context/command.ts +68 -17
  105. package/src/context/coordinator-name.ts +27 -9
  106. package/src/context/index.ts +0 -1
  107. package/src/context/level.ts +18 -11
  108. package/src/context/report.ts +6 -3
  109. package/src/plan-format/dependencies.ts +144 -32
  110. package/src/plan-format/index.ts +6 -0
  111. package/src/plan-format/lanes.ts +6 -0
  112. package/src/plan-format/progress.ts +19 -8
  113. package/src/plans/command.ts +14 -5
  114. package/src/plans/current-phase.ts +63 -7
  115. package/src/plans/current-task.ts +51 -0
  116. package/src/plans/ensure-panes.ts +25 -8
  117. package/src/plans/frame.ts +99 -0
  118. package/src/plans/git.ts +8 -0
  119. package/src/plans/herd-memory.ts +107 -0
  120. package/src/plans/lane-plans.ts +5 -3
  121. package/src/plans/lane-summary.ts +222 -0
  122. package/src/plans/layout-phase.ts +148 -0
  123. package/src/plans/layout-summary.ts +140 -0
  124. package/src/plans/layout.ts +164 -420
  125. package/src/plans/live.ts +11 -6
  126. package/src/plans/plan-panes.ts +15 -0
  127. package/src/plans/planned.ts +9 -42
  128. package/src/plans/selection.ts +2 -6
  129. package/src/plans/settings.ts +2 -7
  130. package/src/plans/snapshot.ts +38 -39
  131. package/src/plans/symbols.ts +2 -2
  132. package/src/plans/texts.ts +32 -31
  133. package/src/plans/view.ts +0 -1
  134. package/src/plans/worked-phases.ts +2 -2
  135. package/src/settled/board-row.ts +133 -0
  136. package/src/settled/check.ts +129 -0
  137. package/src/settled/command.ts +65 -0
  138. package/src/settled/evidence-version.ts +14 -0
  139. package/src/settled/options.ts +69 -0
  140. package/src/settled/send.ts +27 -0
  141. package/src/settled/settle.ts +87 -0
  142. package/src/settled/topics.ts +56 -0
  143. package/src/sync/command.ts +16 -1
  144. package/src/sync/render.ts +13 -4
  145. package/src/sync/sync.ts +25 -5
  146. package/src/terminal/herdr.ts +46 -10
  147. package/src/terminal/none.ts +1 -0
  148. package/src/terminal/types.ts +15 -2
  149. package/src/update/command.ts +4 -2
  150. package/src/update/update.ts +33 -9
  151. package/src/vocabulary/index.ts +47 -11
  152. package/src/watch/appointments.ts +205 -0
  153. package/src/watch/command.ts +62 -19
  154. package/src/watch/lane-report.ts +11 -3
  155. package/src/watch/texts.ts +35 -59
  156. package/src/watch/watch-events.ts +21 -28
  157. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -0,0 +1,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
+ }
@@ -1,5 +1,5 @@
1
1
  import { posix } from 'node:path'
2
- import type { SimpleCommand, Word } from './shell'
2
+ import { isExpanded, type SimpleCommand, type Word } from './shell'
3
3
 
4
4
  /**
5
5
  * Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
@@ -11,10 +11,24 @@ export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
11
11
  const DURATION = /^\d+(\.\d+)?[smhd]?$/
12
12
  const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
13
13
  const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
14
+ const FUNCTION_KEYWORD = 'function'
15
+ /** zsh's precommand, `- eval …`; as a word it is also stdin (`cat -`), so it counts only where a command stands. */
16
+ const ZSH_PRECOMMAND = '-'
17
+ /**
18
+ * Shell words that run a command after names or counts of their own: `function` opens a body (`function f g {`),
19
+ * `coproc` may take a name, zsh's `repeat` a count and `nocorrect` none.
20
+ */
21
+ const SHELL_RUNNERS = [FUNCTION_KEYWORD, 'coproc', 'repeat', 'nocorrect']
22
+ const SHELL_COMMAND_OPENERS = new Set([...SHELL_RUNNERS, ZSH_PRECOMMAND])
23
+ /** The builtin that runs its arguments, joined, as a script in the same shell. */
24
+ const EVAL_BUILTIN = 'eval'
25
+ /** The builtin that runs its action text later in the same shell. */
26
+ const TRAP_BUILTIN = 'trap'
14
27
 
15
28
  /** Programs that run their arguments as a command, so a program right after them is the one that runs. */
16
29
  const COMMAND_RUNNERS = new Set([
17
30
  ...RULE_TRANSPARENT_WRAPPERS,
31
+ ...SHELL_RUNNERS,
18
32
  'env',
19
33
  'sudo',
20
34
  'doas',
@@ -67,16 +81,145 @@ const shellCommandScript = (words: SimpleCommand, shellIndex: number): string |
67
81
  return words.slice(flag + 1).find((word) => !isOption(word))?.value
68
82
  }
69
83
 
84
+ /** The action of `trap <action> <signal>…`: the first word after the options and `--`; the shell runs it later itself. */
85
+ const trapAction = (words: SimpleCommand, trapIndex: number): string | undefined => {
86
+ const rest = words.slice(trapIndex + 1)
87
+ const start = rest.findIndex((word) => !isOption(word) || word.value === '--')
88
+ const action = rest[start]?.value === '--' ? rest[start + 1] : rest[start]
89
+ return action?.value
90
+ }
91
+
92
+ /**
93
+ * Whether a word before a builtin leaves it the command: an option or its value, an assignment, a duration
94
+ * (`sudo -u me`, `env X=1`, `timeout 30`, `repeat 1`), a reserved word, or the name after `function`.
95
+ */
96
+ const leadsToBuiltin = (words: SimpleCommand, index: number): boolean => {
97
+ const { value } = words[index]
98
+ const previous = words[index - 1]
99
+ return (
100
+ isOption(words[index]) ||
101
+ isOption(previous) ||
102
+ ASSIGNMENT.test(value) ||
103
+ DURATION.test(value) ||
104
+ RESERVED_WORDS.has(value) ||
105
+ (previous !== undefined && programName(previous) === FUNCTION_KEYWORD)
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Where in a command a builtin runs as the command itself: at the rule start, right after a runner that is itself the
111
+ * command (with its options), or anywhere after a shell word that opens a command and stands as the command itself,
112
+ * whatever names or counts follow it (`function f g {`, `coproc NAME {`, `repeat $n`, zsh's `- eval`). No external
113
+ * runner starts a builtin from a later argument, so `timeout 30 rg eval` searches for the word. One pass per command; each word looks back only to the runner before it.
114
+ */
115
+ const builtinPositions = (words: SimpleCommand): boolean[] => {
116
+ const start = ruleStart(words)
117
+ const positions: boolean[] = []
118
+ let afterOpener = false
119
+ for (let index = 0; index < words.length; index++) {
120
+ let before = index - 1
121
+ while (before >= 0 && !COMMAND_RUNNERS.has(programName(words[before])) && leadsToBuiltin(words, before)) before--
122
+ const afterRunner = before >= 0 && COMMAND_RUNNERS.has(programName(words[before])) && positions[before]
123
+ positions[index] = index === start || afterRunner || afterOpener
124
+ if (positions[index] && SHELL_COMMAND_OPENERS.has(words[index].value)) afterOpener = true
125
+ }
126
+ return positions
127
+ }
128
+
129
+ /** One pass per command, since every word of it is asked: the positions of a command are its own. */
130
+ const positionsByCommand = new WeakMap<SimpleCommand, boolean[]>()
131
+
132
+ const isBuiltinPosition = (words: SimpleCommand, index: number): boolean => {
133
+ const known = positionsByCommand.get(words)
134
+ if (known) return known[index]
135
+ const positions = builtinPositions(words)
136
+ positionsByCommand.set(words, positions)
137
+ return positions[index]
138
+ }
139
+
140
+ /** Whether the word runs as `builtin`: named so where a command stands, also quoted (`'trap'`) or escaped (`\trap`). */
141
+ const isBuiltinAt = (words: SimpleCommand, index: number, builtin: string): boolean =>
142
+ programName(words[index]) === builtin && isBuiltinPosition(words, index)
143
+
70
144
  export const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
71
- const word = words[index]
72
- if (word.literal && word.value === 'eval') return words.slice(index + 1).map(({ value }) => value).join(' ')
73
- return isShell(word) ? shellCommandScript(words, index) : undefined
145
+ if (isBuiltinAt(words, index, EVAL_BUILTIN))
146
+ return words
147
+ .slice(index + 1)
148
+ .map(({ value }) => value)
149
+ .join(' ')
150
+ if (isBuiltinAt(words, index, TRAP_BUILTIN)) return trapAction(words, index)
151
+ return isShell(words[index]) ? shellCommandScript(words, index) : undefined
74
152
  }
75
153
 
154
+ /** Interpreters that run program text given on the command line or read from their input. */
155
+ const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
156
+ const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '--print', '-r'])
157
+ const VERSIONED_PYTHON = /^python\d+(\.\d+)*$/
158
+ /** A cluster of short options that ends in the one taking the program text: `python3 -Ic`, `perl -lne`. */
159
+ const INLINE_PROGRAM_CLUSTER = /^-[A-Za-z]*[ce]$/
160
+ /** An operand that names the interpreter's own input as the program file. */
161
+ const INPUT_OPERAND = /^(-|\/dev\/stdin|\/dev\/fd\/0|\/proc\/self\/fd\/0)$/
162
+ /** Options that print and exit, so an interpreter without an operand reads no program. */
163
+ const PRINT_AND_EXIT = new Set(['--version', '-v', '-V', '--help', '-h'])
164
+ /** Interpreters that, without an operand, print their help instead of reading a program; they read one after `run`. */
165
+ const SUBCOMMAND_INTERPRETERS = new Set(['bun'])
166
+ const RUN_SUBCOMMAND = 'run'
167
+ /** An operand that names a script file: the words after it are the script's arguments, not the interpreter's options. */
168
+ const SCRIPT_FILE = /\.(py|js|mjs|cjs|ts|mts|cts|jsx|tsx|rb|pl|pm|php|lua)$/i
169
+
170
+ const isInterpreter = (word: Word): boolean => {
171
+ const name = programName(word)
172
+ return INTERPRETERS.has(name) || VERSIONED_PYTHON.test(name)
173
+ }
174
+
175
+ const isInlineProgramOption = ({ value }: Word): boolean =>
176
+ INLINE_PROGRAM_OPTIONS.has(value.split('=')[0]) || INLINE_PROGRAM_CLUSTER.test(value)
177
+
178
+ /** The operand that names the program: the first one, or for `bun run` the one after `run`; `-` counts as one. */
179
+ const programOperand = (interpreter: Word, rest: Word[]): Word | undefined => {
180
+ const operands = rest.filter((word) => !isOption(word) || word.value === '-')
181
+ const afterRun = SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && operands[0]?.value === RUN_SUBCOMMAND
182
+ return afterRun ? operands[1] : operands[0]
183
+ }
184
+
185
+ /**
186
+ * Whether the interpreter reads its program from its input (`python3 -`, `node` fed by a pipe, `bun -`) or from an
187
+ * expansion it cannot show (`python3 <(…)`).
188
+ */
189
+ const readsProgramFromInput = (interpreter: Word, rest: Word[]): boolean => {
190
+ const operand = programOperand(interpreter, rest)
191
+ if (operand === undefined) {
192
+ return (
193
+ !SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && !rest.some(({ value }) => PRINT_AND_EXIT.has(value))
194
+ )
195
+ }
196
+ // A quoted operand is still a file name; only an expansion (`$f`, `<(…)`) hides where the program comes from.
197
+ return isExpanded(operand.value) || INPUT_OPERAND.test(operand.value)
198
+ }
199
+
200
+ /**
201
+ * The words up to a script file operand. Only a file name ends them: any other word could be an option's value
202
+ * (`python3 -W ignore -c …`), and stopping there would miss the `-c` behind it.
203
+ */
204
+ const interpreterOptions = (rest: Word[]): Word[] => {
205
+ const scriptFile = rest.findIndex((word) => !isOption(word) && SCRIPT_FILE.test(word.value))
206
+ return scriptFile === -1 ? rest : rest.slice(0, scriptFile)
207
+ }
208
+
209
+ /**
210
+ * An interpreter, anywhere a program can stand, handed program text (`python3 -c`, `node -e`) or reading it from its
211
+ * input (`python3 - <<EOF`, `… | node`): the program can start any command, and no parse of the shell sees it.
212
+ */
213
+ export const runsInterpreterProgram = (words: SimpleCommand): boolean =>
214
+ words.some((word, index) => {
215
+ if (!isProgramPosition(words, index) || !isInterpreter(word)) return false
216
+ const rest = words.slice(index + 1)
217
+ return interpreterOptions(rest).some(isInlineProgramOption) || readsProgramFromInput(word, rest)
218
+ })
219
+
76
220
  /** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
77
221
  export const readsScriptFromInput = (words: SimpleCommand): boolean => {
78
222
  const start = ruleStart(words)
79
223
  const shell = words[start]
80
224
  return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
81
225
  }
82
-