jackherd 1.16.0 → 1.18.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 (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +29 -33
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +16 -22
  40. package/kit/rules/rules/kit/code-style.md +26 -54
  41. package/kit/rules/rules/kit/pull-requests.md +17 -40
  42. package/kit/rules/rules/kit/testing.md +6 -12
  43. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  44. package/kit/workflow/skills/follow/SKILL.md +2 -3
  45. package/kit/workflow/skills/implement/SKILL.md +28 -43
  46. package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
  47. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  48. package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
  49. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  50. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  51. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  52. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  53. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  54. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  55. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  56. package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
  57. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  58. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  59. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  60. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  61. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  62. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  63. package/kit/workflow/skills/plan/SKILL.md +29 -48
  64. package/package.json +2 -1
  65. package/recipes/README.md +1 -0
  66. package/recipes/jira-cli.md +1 -1
  67. package/recipes/sources/google-calendar-today.ts +8 -2
  68. package/recipes/sources/jira.ts +10 -2
  69. package/recipes/sources/linear.ts +9 -3
  70. package/recipes/sources/sentry.ts +8 -2
  71. package/recipes/sources.md +4 -2
  72. package/schemas/jackherd.schema.json +67 -0
  73. package/schemas/sources.schema.json +9 -0
  74. package/src/approval/checked.ts +60 -0
  75. package/src/approval/command.ts +16 -0
  76. package/src/check/command.ts +51 -6
  77. package/src/check/lint-pointers.ts +79 -0
  78. package/src/check/lint-shaped.ts +196 -0
  79. package/src/check/plan-structure-check.ts +116 -4
  80. package/src/check/report.ts +9 -3
  81. package/src/check/schema.ts +37 -0
  82. package/src/check/settings.ts +4 -0
  83. package/src/check/skill-budget-defaults.ts +90 -32
  84. package/src/check/skill-budgets.ts +96 -18
  85. package/src/cli.ts +2 -0
  86. package/src/config/personal-sources.ts +46 -10
  87. package/src/config/repository-identity.ts +40 -0
  88. package/src/config/schema.ts +53 -1
  89. package/src/context/command.ts +33 -2
  90. package/src/context/options.ts +33 -8
  91. package/src/context/usage-logs.ts +53 -0
  92. package/src/context/usage.ts +110 -0
  93. package/src/lane/base-command.ts +35 -0
  94. package/src/lane/base.ts +43 -0
  95. package/src/lane/close-check.ts +49 -15
  96. package/src/lane/close-command.ts +71 -11
  97. package/src/lane/close-options.ts +29 -8
  98. package/src/lane/close-survey.ts +13 -1
  99. package/src/lane/close.ts +51 -12
  100. package/src/lane/command.ts +21 -3
  101. package/src/lane/send-command.ts +32 -0
  102. package/src/lane/send-herdr.ts +77 -0
  103. package/src/lane/send-options.ts +23 -0
  104. package/src/lane/send.ts +90 -0
  105. package/src/lane/skill-hint.ts +27 -0
  106. package/src/lane/space-command.ts +30 -0
  107. package/src/lane/space.ts +49 -0
  108. package/src/lane/start-lane-hint.ts +18 -0
  109. package/src/plan-contract/base-check-git.ts +33 -0
  110. package/src/plan-contract/base-check.ts +17 -0
  111. package/src/plan-contract/command.ts +25 -4
  112. package/src/plan-format/plan-structure.ts +17 -5
  113. package/src/plans/ask-coordinator.ts +23 -13
  114. package/src/plans/board-rows.ts +9 -4
  115. package/src/plans/click-hint.ts +4 -0
  116. package/src/plans/command.ts +6 -5
  117. package/src/plans/ensure-panes.ts +14 -3
  118. package/src/plans/git.ts +14 -0
  119. package/src/plans/github.ts +19 -10
  120. package/src/plans/layout.ts +82 -33
  121. package/src/plans/live.ts +8 -12
  122. package/src/plans/plan-panes.ts +21 -4
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/resume/command.ts +213 -0
  126. package/src/resume/decision.ts +102 -0
  127. package/src/resume/keeper.ts +65 -0
  128. package/src/resume/location.ts +22 -0
  129. package/src/resume/log.ts +27 -0
  130. package/src/resume/ports.ts +102 -0
  131. package/src/resume/record.ts +116 -0
  132. package/src/resume/targets.ts +84 -0
  133. package/src/resume/texts.ts +27 -0
  134. package/src/secrets/command.ts +15 -5
  135. package/src/sync/herdr-plugin.ts +2 -11
  136. package/src/sync/plan-ask-rules.ts +11 -0
  137. package/src/sync/render.ts +3 -1
  138. package/src/vocabulary/index.ts +13 -0
  139. package/src/waiting/command.ts +13 -4
  140. package/src/watch/command.ts +177 -78
  141. package/src/watch/config-reload.ts +119 -0
  142. package/src/watch/lane-model.ts +11 -1
  143. package/src/watch/lane-progress.ts +70 -8
  144. package/src/watch/lane-report.ts +66 -11
  145. package/src/watch/main-checkout.ts +5 -1
  146. package/src/watch/merge-duties.ts +33 -0
  147. package/src/watch/merge-overlap-read.ts +69 -0
  148. package/src/watch/plan-views.ts +16 -4
  149. package/src/watch/reference-paths.ts +46 -5
  150. package/src/watch/report-questions.ts +5 -2
  151. package/src/watch/single-report.ts +3 -2
  152. package/src/watch/source-dry-run-command.ts +22 -4
  153. package/src/watch/source-reason.ts +58 -0
  154. package/src/watch/source-reload.ts +21 -17
  155. package/src/watch/source-run.ts +24 -7
  156. package/src/watch/sources.ts +9 -2
  157. package/src/watch/texts.ts +14 -3
  158. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The board's history row a call under a standing approval leaves before it passes: its id, lane, the start of its
3
+ * hash, the kind of statement and its first table, and the end of the approval. The board is committed and SQL may
4
+ * carry customer data in its values, so the row names no value; the whole call stays in the request file.
5
+ */
6
+ import { type BoardLanguage, openLaneNamesOf } from '../shared/board-lanes'
7
+ import type { Board } from '../shared/boards'
8
+ import { isRecord } from '../shared/json'
9
+ import { STAGE } from './settings'
10
+
11
+ export type StandingHistoryEntry = { id: string; lane: string; hash: string; query: string; until: number; at: number }
12
+
13
+ /** The history section and its table head per plan language, as `PLAN_VOCABULARY` and `withHistoryRow` in the package. */
14
+ const HISTORY_HEADING: Record<BoardLanguage, string> = { de: 'Verlauf', en: 'History' }
15
+
16
+ /** Statement words a history row may name; anything else reads as `sql`, so a first word is never a value. */
17
+ const STATEMENTS = new Set(['select', 'insert', 'update', 'delete', 'merge', 'with', 'truncate', 'alter', 'create', 'drop', 'grant', 'revoke', 'copy', 'call', 'do', 'set', 'begin', 'commit'])
18
+
19
+ /** The word before the table each statement writes to. */
20
+ const TABLE_AFTER: Record<string, RegExp> = {
21
+ update: /^update\s+(?:only\s+)?/i,
22
+ insert: /^insert\s+into\s+/i,
23
+ delete: /^delete\s+from\s+(?:only\s+)?/i,
24
+ }
25
+
26
+ const IDENTIFIER = /^"?([A-Za-z_][A-Za-z0-9_$]*)"?(?:\."?([A-Za-z_][A-Za-z0-9_$]*)"?)?/
27
+ const LEADING_NOISE = /^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)*/
28
+ const HASH_START = 8
29
+ /** The header row and the separator row of a table. */
30
+ const TABLE_HEAD_LINES = 2
31
+
32
+ const twoDigits = (value: number): string => String(value).padStart(2, '0')
33
+
34
+ const timeOfDay = (at: number): string => {
35
+ const date = new Date(at)
36
+ return `${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`
37
+ }
38
+
39
+ /** The date cell of a history row, `YYYY-MM-DD` in local time. */
40
+ export const historyDate = (at: number): string => {
41
+ const date = new Date(at)
42
+ return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}`
43
+ }
44
+
45
+ const tableOf = (statement: string, sql: string): string | undefined => {
46
+ const after = TABLE_AFTER[statement]
47
+ const match = after?.exec(sql)
48
+ const identifier = match ? IDENTIFIER.exec(sql.slice(match[0].length)) : null
49
+ return identifier ? [identifier[1], identifier[2]].filter(Boolean).join('.') : undefined
50
+ }
51
+
52
+ /** The SQL of a call's input (`{ query }`, as an SQL tool takes it); empty for any other input. */
53
+ export const queryOf = (input: unknown): string =>
54
+ isRecord(input) && typeof input.query === 'string' ? input.query : ''
55
+
56
+ /** The kind of statement and the table it writes to, from the start of the SQL: `update orders`, `select`, `sql`. */
57
+ export const sqlHeadOf = (query: string): string => {
58
+ const sql = query.replace(LEADING_NOISE, '')
59
+ const statement = /^[A-Za-z]+/.exec(sql)?.[0].toLowerCase() ?? ''
60
+ if (!STATEMENTS.has(statement)) return 'sql'
61
+ const table = tableOf(statement, sql)
62
+ return table === undefined ? statement : `${statement} ${table}`
63
+ }
64
+
65
+ /** The row's text: the time, then what passed without asking and under which approval. */
66
+ export const standingHistoryLine = ({ id, lane, hash, query, until, at }: StandingHistoryEntry): string =>
67
+ `${timeOfDay(at)}: ${STAGE} sql without asking ${id} ${lane} ${hash.slice(0, HASH_START)} (${sqlHeadOf(query)}, standing approval ${STAGE} until ${timeOfDay(until)})`
68
+
69
+ const cell = (text: string): string => text.replace(/\s+/g, ' ').trim().replaceAll('|', '\\|')
70
+
71
+ /** The board with a row on top of its history table; null for a board without that table, so nothing passes. */
72
+ export const withHistoryRow = (board: string, language: BoardLanguage, date: string, text: string): string | null => {
73
+ const lines = board.split('\n')
74
+ const heading = new RegExp(`^##\\s+${HISTORY_HEADING[language]}\\s*$`)
75
+ const start = lines.findIndex((line) => heading.test(line))
76
+ if (start === -1) return null
77
+ const rest = lines.slice(start + 1)
78
+ const end = rest.findIndex((line) => /^##\s/.test(line))
79
+ const tableAt = (end === -1 ? rest : rest.slice(0, end)).findIndex((line) => line.startsWith('|'))
80
+ if (tableAt === -1) return null
81
+ const firstRow = start + 1 + tableAt + TABLE_HEAD_LINES
82
+ return [...lines.slice(0, firstRow), `| ${date} | ${cell(text)} |`, ...lines.slice(firstRow)].join('\n')
83
+ }
84
+
85
+ /** The board whose lanes table names the lane as open; the only board when none does; else none. */
86
+ export const boardForLane = (boards: readonly Board[], lane: string, language: BoardLanguage): Board | null => {
87
+ const naming = boards.filter(({ text }) => openLaneNamesOf(text, language).includes(lane))
88
+ if (naming.length === 1) return naming[0]
89
+ return naming.length === 0 && boards.length === 1 ? boards[0] : null
90
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The standing approval for calls against stage: the user approved the environment for a while in the coordinator's
3
+ * dialog, and the coordinator marked this very call checked (`jh approval checked <id> <hash>`). Both are read from
4
+ * the coordinator's transcript; together they replace the dialog for one call. Only an `execute_sql` tool the config
5
+ * names as stage, whose name does not say `prod`, and only one insert, update or delete pass; every doubt asks as before.
6
+ * The SQL check guards against a mistake (a migration, a schema change, a statement appended), not against SQL written
7
+ * to get around it: the coordinator's reading before the mark is the control.
8
+ */
9
+ import type { StandingKind, StandingMark } from '../shared/standing-mark'
10
+ import type { CheckedMark } from './checked-marks'
11
+ import type { ApprovalRequest } from './request'
12
+ import { type GateSettings, isStandingTool, STAGE } from './settings'
13
+ import { queryOf } from './standing-history'
14
+
15
+ export type StandingSqlQuery = {
16
+ request: ApprovalRequest
17
+ /** The environments the user approved standing, as they hold now. */
18
+ grants: readonly StandingMark[]
19
+ checks: readonly CheckedMark[]
20
+ standingTools: GateSettings['standingTools']
21
+ now: number
22
+ }
23
+
24
+ /** The call passes, and the approval it passes under ends at `until`. */
25
+ export type StandingSqlPass = { until: number }
26
+
27
+ /** How a standing approval of an environment reads in the question: ``Environment `stage` <until> <YYYY-MM-DD HH:MM>``. */
28
+ export const ENVIRONMENT_KIND: StandingKind = { key: 'Environment', isSubject: (subject) => subject === STAGE }
29
+
30
+ /** The statements a standing approval covers, at the very start; a schema change or anything else goes to the user's dialog. */
31
+ const STANDING_HEAD = /^[ \t\n]*(?:insert|update|delete)\b/i
32
+ const TRAILING_TERMINATORS = new Set([' ', '\t', '\n', ';'])
33
+
34
+ const isStageTool = (tool: string, standingTools: GateSettings['standingTools']): boolean =>
35
+ isStandingTool(tool) && standingTools[tool] === STAGE
36
+
37
+ /** The SQL without the whitespace and semicolons it ends with; a scan from the end, linear in the length. */
38
+ const withoutTrailingTerminators = (query: string): string => {
39
+ let end = query.length
40
+ while (end > 0 && TRAILING_TERMINATORS.has(query[end - 1])) end--
41
+ return query.slice(0, end)
42
+ }
43
+
44
+ /** One insert, update or delete: no semicolon before the end, so no second statement rides along (one in a value asks too). */
45
+ const isStandingStatement = (query: string): boolean =>
46
+ STANDING_HEAD.test(query) && !withoutTrailingTerminators(query).includes(';')
47
+
48
+ const isCheckedNow = ({ id, hash, createdAt }: ApprovalRequest, checks: readonly CheckedMark[], now: number): boolean =>
49
+ checks.some((check) => check.id === id && check.hash === hash && check.checkedAt >= createdAt && check.checkedAt <= now)
50
+
51
+ /** Whether the call passes without a dialog, and until when the approval holds; null asks as before. */
52
+ export const standingSqlDecision = ({ request, grants, checks, standingTools, now }: StandingSqlQuery): StandingSqlPass | null => {
53
+ if (request.usedAt !== null || !isStageTool(request.tool, standingTools)) return null
54
+ if (!isStandingStatement(queryOf(request.input))) return null
55
+ const grant = grants.find(({ subject, until }) => subject === STAGE && until > now)
56
+ if (grant === undefined || !isCheckedNow(request, checks, now)) return null
57
+ return { until: grant.until }
58
+ }
@@ -2,6 +2,10 @@ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readSync } fro
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { type DialogAnswer, dialogAnswers, parseTranscript } from '../shared/dialog-answers'
5
+ import { type CheckedMark, checkedMarks } from './checked-marks'
6
+
7
+ /** What the coordinator's transcripts say about held calls: the user's dialog answers and the coordinator's marks. */
8
+ export type CoordinatorLog = { answers: DialogAnswer[]; checks: CheckedMark[] }
5
9
 
6
10
  /** Claude Code's transcripts: `~/.claude/projects/<project>/<session>.jsonl`; this file is the client adapter. */
7
11
  const PROJECTS_DIRECTORY = join(homedir(), '.claude', 'projects')
@@ -29,12 +33,14 @@ const readTail = (path: string): string => {
29
33
  }
30
34
  }
31
35
 
32
- /** The dialog answers in the transcripts of these sessions; a session without a transcript gives none. */
33
- export const coordinatorAnswers = (
36
+ /** The dialog answers and checked marks in the transcripts of these sessions, each read once; a session without a transcript gives none. */
37
+ export const coordinatorLog = (
34
38
  sessions: readonly string[],
35
39
  projectsDirectory: string = PROJECTS_DIRECTORY,
36
- ): DialogAnswer[] =>
37
- sessions.flatMap((session) => {
40
+ ): CoordinatorLog => {
41
+ const entries = sessions.map((session) => {
38
42
  const path = transcriptPath(session, projectsDirectory)
39
- return path ? dialogAnswers(parseTranscript(readTail(path))) : []
43
+ return path ? parseTranscript(readTail(path)) : []
40
44
  })
45
+ return { answers: entries.flatMap(dialogAnswers), checks: entries.flatMap(checkedMarks) }
46
+ }
@@ -0,0 +1,74 @@
1
+ import { basename } from 'node:path'
2
+ import { parseShell } from '../shared/shell'
3
+
4
+ const MAX_DEPTH = 4
5
+ const LANE_NAME = /^[a-z][a-z0-9_-]{0,31}$/
6
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash'])
7
+ const WRAPPERS = new Set(['exec', 'nohup', 'env', 'time', 'setsid', 'command', 'nice', 'stdbuf', 'sudo', 'timeout'])
8
+ const KEYWORDS = new Set(['!', '{', 'if', 'then', 'elif', 'else', 'while', 'until', 'do'])
9
+ const ASSIGNMENT = /^\w+=/
10
+ const DURATION = /^\d+(?:\.\d+)?[smhd]?$/
11
+ const COMMAND_FLAG = /^-[a-zA-Z]*c[a-zA-Z]*$/
12
+ /** What an unreadable call is searched for: the text that spells the start. */
13
+ const START_TEXT = /\bherdr\s+agent\s+start\s+([a-z][a-z0-9_-]{0,31})\b/g
14
+
15
+ const isPrefixWord = (word: string): boolean =>
16
+ KEYWORDS.has(word) ||
17
+ WRAPPERS.has(basename(word)) ||
18
+ ASSIGNMENT.test(word) ||
19
+ DURATION.test(word) ||
20
+ word.startsWith('-')
21
+
22
+ /** The lane of a `herdr agent start <lane>` that is the command itself, behind wrappers; null for any other command. */
23
+ const startOfCommand = (words: readonly string[]): string | null => {
24
+ const program = words.findIndex((word) => !isPrefixWord(word))
25
+ if (program === -1 || basename(words[program] ?? '') !== 'herdr') return null
26
+ if (words[program + 1] !== 'agent' || words[program + 2] !== 'start') return null
27
+ const lane = words[program + 3] ?? ''
28
+ return LANE_NAME.test(lane) ? lane : null
29
+ }
30
+
31
+ /** The script a `bash -c '<script>'` or `eval <words>` runs; null for any other command. */
32
+ const nestedScriptOf = (words: readonly string[]): string | null => {
33
+ const program = words.findIndex((word) => !isPrefixWord(word))
34
+ const name = basename(words[program] ?? '')
35
+ if (name === 'eval') return words.slice(program + 1).join(' ')
36
+ if (!SHELLS.has(name)) return null
37
+ const flag = words.findIndex((word, index) => index > program && COMMAND_FLAG.test(word))
38
+ return flag === -1 ? null : (words[flag + 1] ?? null)
39
+ }
40
+
41
+ const startsIn = (script: string, depth: number): string[] =>
42
+ depth > MAX_DEPTH
43
+ ? []
44
+ : parseShell(script).flatMap((command) => {
45
+ const words = command.map((word) => word.value)
46
+ const lane = startOfCommand(words)
47
+ const nested = nestedScriptOf(words)
48
+ return [...(lane === null ? [] : [lane]), ...(nested === null ? [] : startsIn(nested, depth + 1))]
49
+ })
50
+
51
+ /**
52
+ * The lanes a Bash call starts with `herdr agent start <lane>`, also behind wrappers, in `bash -c` and `eval`. Text
53
+ * that only names the command is no start. A call the parser cannot read falls back to the text match: a doubtful call
54
+ * is checked against the board, which a start with a row passes anyway.
55
+ */
56
+ export const laneStartsOf = (command: string): string[] => {
57
+ try {
58
+ return startsIn(command, 0)
59
+ } catch {
60
+ return [...command.matchAll(START_TEXT)].map((match) => match[1] ?? '')
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The reason to refuse the start of a lane that has no row in any board; null when a board has it or there is no
66
+ * board to check. A row is a table line that opens with the lane in backticks.
67
+ */
68
+ export const missingRowReason = (lane: string, boards: readonly string[]): string | null => {
69
+ if (boards.length === 0) return null
70
+ const row = new RegExp(`^\\|\\s*\`${lane}\``, 'm')
71
+ return boards.some((board) => row.test(board))
72
+ ? null
73
+ : `Lane ${lane} has no row: write a table row that opens with \`| \\\`${lane}\\\`\` in the board before \`herdr agent start\` (orchestrate § 2 step 5), then start it.`
74
+ }
@@ -0,0 +1,36 @@
1
+ // PreToolUse hook for Bash, shipped by jackherd: refuses `herdr agent start <lane>` from the main checkout while no
2
+ // board holds a row for the lane (orchestrate § 2 step 5). Reads the hook input on stdin; exit 2 blocks the call and
3
+ // hands the reason on stderr back to the model, exit 0 leaves it to the permission rules. Any failure lets the call
4
+ // through: the guard repeats a rule of the skill and must not stop a start it cannot judge.
5
+ import { resolve } from 'node:path'
6
+ import { boardLocationOf, boardsIn } from '../shared/boards'
7
+ import { mainCheckoutOf, repositoryRootOf } from '../shared/checkout'
8
+ import { readConfig } from '../shared/config'
9
+ import { isRecord } from '../shared/json'
10
+ import { laneStartsOf, missingRowReason } from './decision'
11
+
12
+ /** The reasons to refuse the call; none outside the main checkout, without a board location or on any failure. */
13
+ const reasonsOf = (command: string, cwd: string): string[] => {
14
+ try {
15
+ const lanes = laneStartsOf(command)
16
+ if (lanes.length === 0) return []
17
+ const main = mainCheckoutOf(cwd)
18
+ if (resolve(repositoryRootOf(cwd)) !== resolve(main)) return []
19
+ const location = boardLocationOf(readConfig([main]))
20
+ if (location === null) return []
21
+ const boards = boardsIn(main, location).map(({ text }) => text)
22
+ return lanes.flatMap((lane) => missingRowReason(lane, boards) ?? [])
23
+ } catch {
24
+ return []
25
+ }
26
+ }
27
+
28
+ const input: unknown = await Bun.stdin.json().catch(() => null)
29
+ const toolInput = isRecord(input) ? input.tool_input : undefined
30
+ const command = isRecord(toolInput) && typeof toolInput.command === 'string' ? toolInput.command : ''
31
+ const cwd = isRecord(input) && typeof input.cwd === 'string' ? input.cwd : process.cwd()
32
+ const [reason] = command === '' ? [] : reasonsOf(command, cwd)
33
+ if (reason !== undefined) {
34
+ console.error(reason)
35
+ process.exit(2)
36
+ }
@@ -1,5 +1,5 @@
1
1
  /** Workflows and the actions they run. */
2
- const WORKFLOW_PATHS: readonly string[] = ['.github/**']
2
+ export const WORKFLOW_PATHS: readonly string[] = ['.github/**']
3
3
 
4
4
  /** Migrations, in a `migrations` folder or as SQL in a `drizzle` folder. */
5
5
  export const MIGRATION_PATHS: readonly string[] = ['**/migrations/**', '**/drizzle/*.sql']
@@ -11,7 +11,7 @@ export const ENV_PATHS: readonly string[] = ['**/.env*', '**/*.env', '**/.dev.va
11
11
  * The guards and what runs before them: the project's hooks and settings, the jackherd config, git hooks and
12
12
  * `bunfig.toml`, whose `preload` runs before every hook (they start in the project root, so only the root one).
13
13
  */
14
- const GUARD_PATHS: readonly string[] = [
14
+ export const GUARD_PATHS: readonly string[] = [
15
15
  '.claude/hooks/**',
16
16
  '.claude/settings*.json',
17
17
  '.jackherd/**',
@@ -9,16 +9,30 @@
9
9
  import { type DialogAnswer, dialogAnswers } from '../shared/dialog-answers'
10
10
  import { isFresh } from '../shared/fresh'
11
11
 
12
+ /** A branch the user approved, with the change they saw: the first characters of its `git patch-id --verbatim`. */
13
+ export type ApprovedChange = { branch: string; change: string }
14
+
12
15
  const BRANCH_MENTION = /\bBranch `([^`]*)`/g
16
+ /** The change right after the branch, as ``Branch `name` @<patch id>``. */
17
+ const CHANGE_MENTION = /\bBranch `[^`]*` @([0-9a-f]{7,40})\b/g
13
18
 
14
19
  /** A branch name as git takes it from a shell word: no leading dash, no space, no `..`, no shell character. */
15
20
  const BRANCH_NAME = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{))[A-Za-z0-9._/-]+$/
16
21
 
17
- /** The one branch a question names as ``Branch `name` ``; a question that names none or several approves none. */
18
- const namedBranch = (question: string): string | undefined => {
19
- const names = new Set([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
20
- const [only] = names
21
- return names.size === 1 && BRANCH_NAME.test(only) ? only : undefined
22
+ const onlyOf = (values: string[]): string | undefined => {
23
+ const distinct = new Set(values)
24
+ const [only] = distinct
25
+ return distinct.size === 1 ? only : undefined
26
+ }
27
+
28
+ /**
29
+ * The one branch a question names as ``Branch `name` ``, with the one change it names after it; a question that names
30
+ * none or several of either approves none.
31
+ */
32
+ const namedChange = (question: string): ApprovedChange | undefined => {
33
+ const branch = onlyOf([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
34
+ const change = onlyOf([...question.matchAll(CHANGE_MENTION)].map(([, id]) => id))
35
+ return branch !== undefined && change !== undefined && BRANCH_NAME.test(branch) ? { branch, change } : undefined
22
36
  }
23
37
 
24
38
  const approves = (answer: DialogAnswer, label: string): boolean => answer.answer === label && answer.offered.includes(label)
@@ -33,12 +47,10 @@ export type ApprovalQuery = {
33
47
  windowMinutes: number
34
48
  }
35
49
 
36
- /** The branches whose question the user answered with `label` within the window. */
37
- export const approvedBranches = ({ entries, label, now, windowMinutes }: ApprovalQuery): Set<string> => {
38
- if (label === '') return new Set()
39
- return new Set(
40
- dialogAnswers(entries)
41
- .filter((answer) => approves(answer, label) && isFresh(answer.answeredAt, now, windowMinutes))
42
- .flatMap((answer) => namedBranch(answer.question) ?? []),
43
- )
50
+ /** The branches and changes whose question the user answered with `label` within the window. */
51
+ export const approvedChanges = ({ entries, label, now, windowMinutes }: ApprovalQuery): ApprovedChange[] => {
52
+ if (label === '') return []
53
+ return dialogAnswers(entries)
54
+ .filter((answer) => approves(answer, label) && isFresh(answer.answeredAt, now, windowMinutes))
55
+ .flatMap((answer) => namedChange(answer.question) ?? [])
44
56
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The change a pull request carries, as one id: the `git patch-id --verbatim` of its diff, as GitHub serves it. The
3
+ * coordinator writes the same id of the staged change into the self-merge question, so the approval holds for the
4
+ * change the user saw: a push that alters the diff alters the id, a rebase without a conflict keeps it.
5
+ */
6
+ import { runGh } from './gh'
7
+
8
+ const PATCH_ID_TIMEOUT_MS = 4000
9
+ const PATCH_ID = /^([0-9a-f]{40}) /
10
+
11
+ /** The patch id from the first line `git patch-id` prints, `<patch id> <commit>`; nothing for an empty diff. */
12
+ export const parsePatchId = (stdout: string): string | undefined => stdout.match(PATCH_ID)?.[1]
13
+
14
+ /** Whether the change the question named is this one: the question names the first characters. */
15
+ export const isSameChange = (named: string, patchId: string | undefined): boolean =>
16
+ patchId !== undefined && patchId.startsWith(named)
17
+
18
+ const patchIdOf = (diff: string, directory: string): string | undefined => {
19
+ // --verbatim keeps whitespace: an indentation pushed after the approval changes a workflow, so it changes the id
20
+ const result = Bun.spawnSync(['git', 'patch-id', '--verbatim'], {
21
+ cwd: directory,
22
+ stdin: new TextEncoder().encode(diff),
23
+ timeout: PATCH_ID_TIMEOUT_MS,
24
+ stdout: 'pipe',
25
+ stderr: 'ignore',
26
+ })
27
+ return result.exitCode === 0 ? parsePatchId(result.stdout.toString()) : undefined
28
+ }
29
+
30
+ /** The patch id of pull request <number>'s diff (`gh pr diff`); any failure, a diff too large for GitHub too, answers nothing. */
31
+ export const changeIdOf = (pullRequest: number, directory: string): string | undefined => {
32
+ const diff = runGh(['pr', 'diff', String(pullRequest)], directory)
33
+ return diff === undefined ? undefined : patchIdOf(diff, directory)
34
+ }