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,194 @@
1
+ /**
2
+ * A plan approval outlives the session it was given in: the `PostToolUse` hook on `AskUserQuestion` leaves a pointer
3
+ * (plan, transcript file, `tool_use_id`) under `.jackherd/approvals/plans/`, and the merge guard follows it back into
4
+ * that transcript at every merge. The pointer only says where to look; what counts is the dialog it finds there, the
5
+ * question naming the plan and the label the user picked, so a pointer an agent writes itself gains nothing.
6
+ *
7
+ * The limit, as for the branch approval: an agent that runs as the user could still write a transcript file. This
8
+ * guards against a mistake, not against an agent that sets out to get around it.
9
+ */
10
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { dirname, isAbsolute, join } from 'node:path'
12
+ import { ASK_USER_QUESTION_TOOL, dialogAnswers } from '../shared/dialog-answers'
13
+ import { realPathOrNull } from '../shared/files'
14
+ import { isRecord } from '../shared/json'
15
+ import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
16
+ import type { PlanAnswer } from './plan-approval'
17
+
18
+ /**
19
+ * `answers`: per plan, the answer the hook input reported, null where it reported none. An approval still needs its
20
+ * dialog in the transcript; any other answer ends the approval at `recordedAt` without one, since it only adds a question.
21
+ */
22
+ export type PlanPointer = {
23
+ toolUseId: string
24
+ transcript: string
25
+ plans: string[]
26
+ answers: Record<string, string | null>
27
+ recordedAt: number
28
+ }
29
+
30
+ export type PlanAnswerQuery = {
31
+ /** The pointer directory, `.jackherd/approvals/plans` of the main checkout. */
32
+ directory: string
33
+ /** The transcript of the session that merges: only transcripts beside it, of the same project, are read. */
34
+ currentTranscript: string | undefined
35
+ plan: string
36
+ /** The label that approves a plan; a pointer with any other answer is a revocation. */
37
+ label: string
38
+ }
39
+
40
+ const PLAN_MENTION = /\bPlan `([^`]*)`/g
41
+ const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
42
+ const TOOL_USE_ID = /^[A-Za-z0-9_-]{1,128}$/
43
+ const POINTER_FILE = /^([A-Za-z0-9_-]{1,128})\.json$/
44
+ const TRANSCRIPT_FILE = /\.jsonl$/
45
+
46
+ /** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
47
+ const isPlanPath = (path: string): boolean =>
48
+ PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
49
+
50
+ const isTranscriptPath = (value: unknown): value is string =>
51
+ typeof value === 'string' && isAbsolute(value) && TRANSCRIPT_FILE.test(value)
52
+
53
+ const isToolUseId = (value: unknown): value is string => typeof value === 'string' && TOOL_USE_ID.test(value)
54
+
55
+ /** The one plan a question names as ``Plan `path` ``; a question that names none or several names none. */
56
+ export const namedPlan = (question: string): string | undefined => {
57
+ const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, path]) => path))
58
+ const [only] = plans
59
+ return plans.size === 1 && isPlanPath(only) ? only : undefined
60
+ }
61
+
62
+ type PlanAsked = { plan: string; question: string }
63
+
64
+ /** The plans of the single-choice questions that name one and offer one of the plan labels, with the question text. */
65
+ const plansAsked = (input: unknown, labels: readonly string[]): PlanAsked[] => {
66
+ const questions = isRecord(input) && Array.isArray(input.questions) ? input.questions.filter(isRecord) : []
67
+ return questions.flatMap((question) => {
68
+ const options = question.multiSelect !== true && Array.isArray(question.options) ? question.options : []
69
+ const offersLabel = options.some((option) => isRecord(option) && labels.includes(String(option.label)))
70
+ const text = typeof question.question === 'string' ? question.question : ''
71
+ const plan = namedPlan(text)
72
+ return offersLabel && plan !== undefined ? [{ plan, question: text }] : []
73
+ })
74
+ }
75
+
76
+ /** The answer the hook input reports for a question, keyed by its text; null when it reports none. */
77
+ const reportedAnswer = (response: unknown, question: string): string | null => {
78
+ const answers = isRecord(response) && isRecord(response.answers) ? response.answers : {}
79
+ const answer = answers[question]
80
+ return typeof answer === 'string' ? answer : null
81
+ }
82
+
83
+ /** The pointer for a `PostToolUse` input of an `AskUserQuestion` call that asked about a plan; undefined for any other. */
84
+ export const pointerOf = (input: unknown, labels: readonly string[], now: number): PlanPointer | undefined => {
85
+ if (!isRecord(input) || input.tool_name !== ASK_USER_QUESTION_TOOL) return undefined
86
+ const { tool_use_id: toolUseId, transcript_path: transcript } = input
87
+ if (!isToolUseId(toolUseId) || !isTranscriptPath(transcript)) return undefined
88
+ const asked = plansAsked(input.tool_input, labels.filter(Boolean))
89
+ if (asked.length === 0) return undefined
90
+ const answers = Object.fromEntries(asked.map(({ plan, question }) => [plan, reportedAnswer(input.tool_response, question)]))
91
+ return { toolUseId, transcript, plans: [...new Set(asked.map(({ plan }) => plan))], answers, recordedAt: now }
92
+ }
93
+
94
+ /** The lines of the file that contain `needle`: a transcript runs to hundreds of megabytes, and only these get parsed. */
95
+ const linesContaining = (text: Buffer, needle: string): string[] => {
96
+ const lines: string[] = []
97
+ for (let at = text.indexOf(needle); at !== -1; ) {
98
+ const start = text.lastIndexOf('\n', at) + 1
99
+ const newline = text.indexOf('\n', at)
100
+ const end = newline === -1 ? text.length : newline
101
+ lines.push(text.subarray(start, end).toString('utf8'))
102
+ at = newline === -1 ? -1 : text.indexOf(needle, end)
103
+ }
104
+ return lines
105
+ }
106
+
107
+ const parsedLines = (lines: string[]): unknown[] =>
108
+ lines.flatMap((line) => {
109
+ try {
110
+ return [JSON.parse(line)]
111
+ } catch {
112
+ return []
113
+ }
114
+ })
115
+
116
+ /** The answers of the dialog `toolUseId` in the transcript to questions naming `plan`, free text included. */
117
+ const answersInTranscript = (text: Buffer, toolUseId: string, plan: string): PlanAnswer[] =>
118
+ dialogAnswers(parsedLines(linesContaining(text, JSON.stringify(toolUseId))))
119
+ .filter(({ question }) => namedPlan(question) === plan)
120
+ .map(({ answer, answeredAt }) => ({ answer, answeredAt }))
121
+
122
+ /** A pointer whose reported answer is not the label ends the approval when it was recorded; no transcript needed. */
123
+ const revocationOf = (pointer: PlanPointer, plan: string): PlanAnswer => ({
124
+ answer: pointer.answers[plan] ?? '',
125
+ answeredAt: pointer.recordedAt,
126
+ })
127
+
128
+ /** The pointer directory of a main checkout, beside the approval gate's requests. */
129
+ export const planPointersDirectory = (main: string): string => join(main, '.jackherd', 'approvals', 'plans')
130
+
131
+ export const writePointer = (directory: string, pointer: PlanPointer): void => {
132
+ ensurePrivateDirectory(directory)
133
+ writeFileSync(join(directory, `${pointer.toolUseId}.json`), `${JSON.stringify(pointer)}\n`, { mode: PRIVATE_FILE })
134
+ }
135
+
136
+ const pointerIn = (directory: string, name: string): PlanPointer[] => {
137
+ const id = POINTER_FILE.exec(name)?.[1]
138
+ if (id === undefined) return []
139
+ try {
140
+ const value: unknown = JSON.parse(readFileSync(join(directory, name), 'utf8'))
141
+ if (!isRecord(value) || value.toolUseId !== id || !isTranscriptPath(value.transcript)) return []
142
+ const plans = Array.isArray(value.plans) ? value.plans : []
143
+ const valid = plans.length > 0 && plans.every((plan) => typeof plan === 'string' && isPlanPath(plan))
144
+ if (!valid || typeof value.recordedAt !== 'number') return []
145
+ const answers = isRecord(value.answers) ? value.answers : {}
146
+ const reported = (plan: string) => (typeof answers[plan] === 'string' ? answers[plan] : null)
147
+ return [{ ...(value as PlanPointer), answers: Object.fromEntries(plans.map((plan) => [plan, reported(plan)])) }]
148
+ } catch {
149
+ return []
150
+ }
151
+ }
152
+
153
+ /** Every file of the directory that parses as a pointer named after its own id; none for a missing directory. */
154
+ export const readPointers = (directory: string): PlanPointer[] => {
155
+ try {
156
+ return readdirSync(directory).flatMap((name) => pointerIn(directory, name))
157
+ } catch {
158
+ return []
159
+ }
160
+ }
161
+
162
+ const readOrNull = (path: string): Buffer | null => {
163
+ try {
164
+ return readFileSync(path)
165
+ } catch {
166
+ return null
167
+ }
168
+ }
169
+
170
+ /** The answers a pointer's transcript holds for `plan`, when that transcript lies beside the current one. */
171
+ const backedAnswers = (pointer: PlanPointer, plan: string, project: string, texts: Map<string, Buffer | null>) => {
172
+ const path = realPathOrNull(pointer.transcript)
173
+ if (path === null || dirname(path) !== project) return []
174
+ if (!texts.has(path)) texts.set(path, readOrNull(path))
175
+ const text = texts.get(path)
176
+ return text ? answersInTranscript(text, pointer.toolUseId, plan) : []
177
+ }
178
+
179
+ /**
180
+ * The answers to the plan questions naming `plan`. A pointer that reported the approval label counts only with its
181
+ * dialog in a transcript beside the current one (Claude Code keeps a project's sessions side by side); a pointer that
182
+ * reported anything else, free text or nothing included, is a revocation and counts as it stands.
183
+ */
184
+ export const planAnswers = ({ directory, currentTranscript, plan, label }: PlanAnswerQuery): PlanAnswer[] => {
185
+ const pointers = readPointers(directory).filter((pointer) => pointer.plans.includes(plan))
186
+ const revocations = pointers.filter((pointer) => pointer.answers[plan] !== label).map((pointer) => revocationOf(pointer, plan))
187
+ const project = currentTranscript === undefined ? null : realPathOrNull(dirname(currentTranscript))
188
+ if (project === null) return revocations
189
+ const texts = new Map<string, Buffer | null>()
190
+ const approvals = pointers
191
+ .filter((pointer) => pointer.answers[plan] === label)
192
+ .flatMap((pointer) => backedAnswers(pointer, plan, project, texts))
193
+ return [...approvals, ...revocations]
194
+ }
@@ -4,28 +4,80 @@
4
4
  * says which branch pull request <number> carries.
5
5
  */
6
6
 
7
+ import { FULL_COMMIT } from '../shared/commit'
8
+ import { parseJsonObject } from '../shared/json'
7
9
  import { runGh } from './gh'
8
10
 
9
- type PullRequestView = { headRefName?: unknown; isCrossRepository?: unknown; state?: unknown }
11
+ type PullRequestView = {
12
+ headRefName?: unknown
13
+ headRefOid?: unknown
14
+ baseRefName?: unknown
15
+ isCrossRepository?: unknown
16
+ state?: unknown
17
+ statusCheckRollup?: unknown
18
+ mergeable?: unknown
19
+ url?: unknown
20
+ changedFiles?: unknown
21
+ }
10
22
 
11
23
  /** Where a pull request lives and how many files it changes, as GitHub's `pr view` says. */
12
24
  export type RepositoryFacts = { repository: string; changedFiles: number }
13
25
 
14
- const VIEW_FIELDS = 'headRefName,isCrossRepository,state,url,changedFiles'
26
+ const VIEW_FIELDS = 'headRefName,headRefOid,baseRefName,isCrossRepository,state,url,changedFiles,statusCheckRollup,mergeable'
15
27
  const PULL_REQUEST_URL = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/pull\/[1-9]\d*$/
28
+ /** The conclusions of a finished check run that do not stand in a merge's way. */
29
+ const GREEN_CONCLUSIONS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
30
+
31
+ const viewOf = (json: string): PullRequestView | undefined => parseJsonObject(json)
16
32
 
33
+ /**
34
+ * A check of the rollup by its name, whether it is green, and whether it is a check run: a run comes from a workflow
35
+ * or an app, while a status anyone with push access can set through the API.
36
+ */
37
+ const checkOf = (entry: unknown): { name: string; green: boolean; run: boolean } | undefined => {
38
+ if (typeof entry !== 'object' || entry === null) return undefined
39
+ const { __typename, name, status, conclusion, context, state } = entry as Record<string, unknown>
40
+ if (__typename === 'CheckRun' && typeof name === 'string') {
41
+ const green = status === 'COMPLETED' && typeof conclusion === 'string' && GREEN_CONCLUSIONS.has(conclusion)
42
+ return { name, green, run: true }
43
+ }
44
+ if (__typename === 'StatusContext' && typeof context === 'string') return { name: context, green: state === 'SUCCESS', run: false }
45
+ return undefined
46
+ }
17
47
 
18
48
  /** The head branch of an open pull request from this repository; a fork's branch name proves nothing. */
19
49
  export const parseHeadBranch = (json: string): string | undefined => {
20
- try {
21
- const view: unknown = JSON.parse(json)
22
- if (typeof view !== 'object' || view === null) return undefined
23
- const { headRefName, isCrossRepository, state } = view as PullRequestView
24
- const ours = state === 'OPEN' && isCrossRepository === false
25
- return ours && typeof headRefName === 'string' ? headRefName : undefined
26
- } catch {
27
- return undefined
28
- }
50
+ const { headRefName, isCrossRepository, state } = viewOf(json) ?? {}
51
+ const ours = state === 'OPEN' && isCrossRepository === false
52
+ return ours && typeof headRefName === 'string' ? headRefName : undefined
53
+ }
54
+
55
+ /** The branch the pull request merges into, as GitHub says. */
56
+ export const parseBaseBranch = (json: string): string | undefined => {
57
+ const base = viewOf(json)?.baseRefName
58
+ return typeof base === 'string' ? base : undefined
59
+ }
60
+
61
+ /**
62
+ * Whether the head commit's checks let a merge pass without a question: GitHub can merge it, it has at least one check
63
+ * run, every check is green (a run finished as success, neutral or skipped, a status succeeded), and every required
64
+ * check is among the runs. An empty list is what a pull request shows right after a push, before any workflow started,
65
+ * so it does not pass; neither does a list of statuses alone, which an agent could set itself in that moment.
66
+ */
67
+ export const checksPassed = (json: string, requiredChecks: readonly string[] | undefined): boolean => {
68
+ const view = viewOf(json)
69
+ const rollup = view?.statusCheckRollup
70
+ if (view?.mergeable !== 'MERGEABLE' || !Array.isArray(rollup)) return false
71
+ const checks = rollup.map(checkOf)
72
+ if (!checks.every((check) => check?.green === true)) return false
73
+ const runs = new Set(checks.flatMap((check) => (check?.run ? [check.name] : [])))
74
+ return runs.size > 0 && (requiredChecks ?? []).every((name) => runs.has(name))
75
+ }
76
+
77
+ /** The commit the pull request's head points at, as GitHub says now; what a `--match-head-commit` must name. */
78
+ export const parseHeadCommit = (json: string): string | undefined => {
79
+ const commit = viewOf(json)?.headRefOid
80
+ return typeof commit === 'string' && FULL_COMMIT.test(commit) ? commit : undefined
29
81
  }
30
82
 
31
83
  /**
@@ -33,16 +85,10 @@ export const parseHeadBranch = (json: string): string | undefined => {
33
85
  * file list is then asked of that repository by name, so it cannot come from another one than `gh pr merge` merges in.
34
86
  */
35
87
  export const parseRepositoryFacts = (json: string): RepositoryFacts | undefined => {
36
- try {
37
- const view: unknown = JSON.parse(json)
38
- if (typeof view !== 'object' || view === null) return undefined
39
- const { url, changedFiles } = view as { url?: unknown; changedFiles?: unknown }
40
- const repository = typeof url === 'string' ? url.match(PULL_REQUEST_URL)?.[1] : undefined
41
- const counted = typeof changedFiles === 'number' && Number.isInteger(changedFiles) && changedFiles >= 0
42
- return repository !== undefined && counted ? { repository, changedFiles } : undefined
43
- } catch {
44
- return undefined
45
- }
88
+ const { url, changedFiles } = viewOf(json) ?? {}
89
+ const repository = typeof url === 'string' ? url.match(PULL_REQUEST_URL)?.[1] : undefined
90
+ const counted = typeof changedFiles === 'number' && Number.isInteger(changedFiles) && changedFiles >= 0
91
+ return repository !== undefined && counted ? { repository, changedFiles } : undefined
46
92
  }
47
93
 
48
94
  /** Runs `gh pr view <number>` in `directory`, the directory the command itself runs in; any failure answers nothing. */
@@ -1,13 +1,46 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { isAbsolute, relative } from 'node:path'
3
+ import { mainCheckoutOf } from '../shared/checkout'
3
4
  import { parseTranscript } from '../shared/dialog-answers'
5
+ import { isCoordinatorAgent } from '../shared/lane'
6
+ import { once } from '../shared/once'
7
+ import { ownPaneAgent } from '../shared/own-pane'
8
+ import { readShellDefinitions } from '../shared/shell-snapshots'
4
9
  import { approvedBranches } from './approval'
5
10
  import { allFilesMatch, changedFilesOf } from './check-free'
6
- import { decideBashCommand, type GuardPolicy, type MergeGuardDecision } from './decision'
7
- import { parseHeadBranch, parseRepositoryFacts, type RepositoryFacts, viewPullRequest } from './pull-request'
11
+ import { decideBashCommand, type GuardPolicy, type MergeGuardDecision, type MergeNote, type PlainMerge } from './decision'
12
+ import { isPlanApproved, type PlanVerdict } from './plan-approval'
13
+ import { isLaneIn, planApprovalSources, planBaseOf } from './plan-approval-sources'
14
+ import { planPointersDirectory, readPointers } from './plan-pointers'
15
+ import {
16
+ checksPassed,
17
+ parseBaseBranch,
18
+ parseHeadBranch,
19
+ parseHeadCommit,
20
+ parseRepositoryFacts,
21
+ viewPullRequest,
22
+ } from './pull-request'
8
23
  import { type GuardSettings, readGuardSettings } from './settings'
9
24
 
10
- type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown }
25
+ type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown; session_id?: unknown }
26
+
27
+ /** The exact option labels that approve a merge: the one of the pull request question and the one of a plan question. */
28
+ export type ApprovalLabels = { selfMerge: string; plan: string }
29
+
30
+ /** What GitHub says about the pull request a merge names; `files` is asked only when a rule needs it. */
31
+ type MergeFacts = {
32
+ branch: string
33
+ baseBranch: string | undefined
34
+ headCommit: string | undefined
35
+ /** The `gh pr view` answer, which carries the checks and whether GitHub can merge. */
36
+ view: string
37
+ files: () => readonly string[] | undefined
38
+ }
39
+
40
+ type PlanApproval = (facts: MergeFacts & { headCommit: string }) => PlanVerdict
41
+
42
+ /** The ways a merge may pass without a question. */
43
+ type MergeApprovals = { branches: ReadonlySet<string>; withoutChecks: readonly string[]; plan: PlanApproval | undefined }
11
44
 
12
45
  const PASS: MergeGuardDecision = { kind: 'pass' }
13
46
 
@@ -27,46 +60,102 @@ const isInside = (directory: string, project: string): boolean => {
27
60
  return path === '' || (!path.startsWith('..') && !isAbsolute(path))
28
61
  }
29
62
 
30
- /** Every file of the pull request, as GitHub lists them, matches `merge.withoutChecks`. */
31
- const isCheckFree = (
32
- pullRequest: number,
33
- facts: RepositoryFacts | undefined,
34
- withoutChecks: readonly string[],
35
- directory: string,
36
- ): boolean => facts !== undefined && allFilesMatch(changedFilesOf(pullRequest, directory, facts) ?? [], withoutChecks)
63
+ /**
64
+ * The user approved the branch and its checks passed, every file needs no checks, or the user approved the plan of the
65
+ * branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check is not
66
+ * merged unchecked. A plan approval that no longer covers the plan answers with a note for the question.
67
+ */
68
+ const approvesMerge = (
69
+ { branches, withoutChecks, plan }: MergeApprovals,
70
+ merge: PlainMerge,
71
+ facts: MergeFacts,
72
+ ): boolean | MergeNote => {
73
+ const { headCommit } = facts
74
+ if (headCommit === undefined || merge.headCommit !== headCommit) return false
75
+ if (branches.has(facts.branch) && checksPassed(facts.view, undefined)) return true
76
+ if (withoutChecks.length > 0 && allFilesMatch(facts.files() ?? [], withoutChecks)) return true
77
+ return plan === undefined ? false : plan({ ...facts, headCommit })
78
+ }
37
79
 
38
- const policyFor = (input: HookInput, label: string, projectDirectory: string): GuardPolicy => {
80
+ /**
81
+ * The files of the pull request, while its head is still the commit `gh pr view` named: a push between the two
82
+ * calls would list the files of a commit the merge does not name.
83
+ */
84
+ const filesAtHead = (pullRequest: number, view: string, directory: string): (() => readonly string[] | undefined) => {
85
+ const facts = parseRepositoryFacts(view)
86
+ const headCommit = parseHeadCommit(view)
87
+ return once(() => {
88
+ if (facts === undefined || headCommit === undefined) return undefined
89
+ const files = changedFilesOf(pullRequest, directory, facts)
90
+ const again = files === undefined ? undefined : parseHeadCommit(viewPullRequest(pullRequest, directory) ?? '')
91
+ return again === headCommit ? files : undefined
92
+ })
93
+ }
94
+
95
+ /** The facts of the pull request from its `gh pr view`; undefined unless it is open and from this repository. */
96
+ const mergeFactsOf = (pullRequest: number, view: string, directory: string): MergeFacts | undefined => {
97
+ const branch = parseHeadBranch(view)
98
+ if (branch === undefined) return undefined
99
+ return {
100
+ branch,
101
+ baseBranch: parseBaseBranch(view),
102
+ headCommit: parseHeadCommit(view),
103
+ view,
104
+ files: filesAtHead(pullRequest, view, directory),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * How to tell whether the user approved the plan of the branch's lane, read in the main checkout; undefined when no
110
+ * plan approval can hold (no label, no transcript, no repository, plan approval off, no pointer, a session that is not
111
+ * the coordinator's), so GitHub is not asked.
112
+ */
113
+ const planApprovalFor = (input: HookInput, label: string, projectDirectory: string): PlanApproval | undefined => {
114
+ const transcript = input.transcript_path
115
+ if (label === '' || typeof transcript !== 'string') return undefined
116
+ try {
117
+ const main = mainCheckoutOf(projectDirectory)
118
+ if (readPointers(planPointersDirectory(main)).length === 0) return undefined
119
+ const base = planBaseOf(main)
120
+ if (base === undefined) return undefined
121
+ const isLane = isLaneIn(main, base.settings.worktreeDirectory)
122
+ if (!isCoordinatorAgent({ agent: ownPaneAgent(), session: input.session_id, main, isLane })) return undefined
123
+ return ({ branch, baseBranch, headCommit, view, files }) =>
124
+ baseBranch !== undefined &&
125
+ isPlanApproved(
126
+ { pullRequest: { branch, baseBranch, headCommit }, label, now: Date.now(), settings: base.settings },
127
+ planApprovalSources({ main, base, transcript, label, files, view }),
128
+ )
129
+ } catch {
130
+ return undefined
131
+ }
132
+ }
133
+
134
+ const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: string): GuardPolicy => {
39
135
  const settings = readGuardSettings(projectDirectory)
40
136
  const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
41
- const views = new Map<number, string | undefined>()
42
- const viewOf = (pullRequest: number): string => {
43
- if (!views.has(pullRequest)) views.set(pullRequest, viewPullRequest(pullRequest, directory))
44
- return views.get(pullRequest) ?? ''
45
- }
46
- let approved: Set<string> | undefined
137
+ const branches = once(() => approvedBranchesFrom(input.transcript_path, settings, labels.selfMerge))
138
+ const plan = once(() => planApprovalFor(input, labels.plan, projectDirectory))
47
139
  return {
48
140
  commands: settings.commands,
49
- isMergeApproved: (pullRequest) => {
141
+ shellDefinitions: once(() => readShellDefinitions()),
142
+ isMergeApproved: (merge) => {
50
143
  if (!isInside(directory, projectDirectory)) return false
51
- const branches = (approved ??= approvedBranchesFrom(input.transcript_path, settings, label))
52
- if (branches.size === 0 && settings.withoutChecks.length === 0) return false
53
- // The branch lookup also proves the pull request is open and from this repository; both paths need that.
54
- const view = viewOf(pullRequest)
55
- const branch = parseHeadBranch(view)
56
- if (branch === undefined) return false
57
- return (
58
- branches.has(branch) || isCheckFree(pullRequest, parseRepositoryFacts(view), settings.withoutChecks, directory)
59
- )
144
+ const approvals = { branches: branches(), withoutChecks: settings.withoutChecks, plan: plan() }
145
+ if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.plan === undefined) return false
146
+ // The branch lookup also proves the pull request is open and from this repository; every path needs that.
147
+ const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
148
+ return facts !== undefined && approvesMerge(approvals, merge, facts)
60
149
  },
61
150
  }
62
151
  }
63
152
 
64
153
  /** Reads the hook input and decides; throws on input it cannot read. */
65
- export const decideInput = (text: string, label: string, projectDirectory: string): MergeGuardDecision => {
154
+ export const decideInput = (text: string, labels: ApprovalLabels, projectDirectory: string): MergeGuardDecision => {
66
155
  const input = JSON.parse(text) as HookInput
67
156
  const command = input.tool_input?.command
68
157
  if (typeof command !== 'string') return PASS
69
- return decideBashCommand(command, policyFor(input, label, projectDirectory))
158
+ return decideBashCommand(command, policyFor(input, labels, projectDirectory))
70
159
  }
71
160
 
72
161
  export const failureDecision = (error: unknown): MergeGuardDecision => ({
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { homedir } from 'node:os'
3
3
  import { join } from 'node:path'
4
+ import { isRecord } from '../shared/json'
4
5
 
5
6
  export type GuardSettings = {
6
7
  commands: string[]
@@ -10,7 +11,7 @@ export type GuardSettings = {
10
11
  }
11
12
 
12
13
  export const DEFAULT_SETTINGS: GuardSettings = {
13
- commands: ['gh pr merge', 'gh workflow run'],
14
+ commands: ['gh pr merge', 'gh workflow run', 'gh run rerun', 'gh api'],
14
15
  selfMerge: { offer: true, approvalMinutes: 180 },
15
16
  withoutChecks: [],
16
17
  }
@@ -29,27 +30,26 @@ const isCheckFreePattern = (value: unknown): value is string =>
29
30
 
30
31
  const isPatternList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isCheckFreePattern)
31
32
 
32
- const isRecord = (value: unknown): value is Record<string, unknown> =>
33
- typeof value === 'object' && value !== null && !Array.isArray(value)
34
-
35
33
  const isCommandList = (value: unknown): value is string[] =>
36
34
  Array.isArray(value) && value.every((entry) => typeof entry === 'string' && GUARDED_COMMAND.test(entry))
37
35
 
38
36
  const isMinutes = (value: unknown): value is number =>
39
37
  typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_MINUTES
40
38
 
41
- /** The first config that exists; the hook has no schema library, `jh` validates the file. */
42
- const readConfig = (directories: string[]): Record<string, unknown> => {
39
+ /** The first config that exists, `{}` for none, `null` for one that is no JSON object; the hook has no schema library, `jh` validates the file. */
40
+ export const readConfigOrNull = (directories: string[]): Record<string, unknown> | null => {
43
41
  const path = directories.map((directory) => join(directory, CONFIG_PATH)).find((candidate) => existsSync(candidate))
44
42
  if (!path) return {}
45
43
  try {
46
44
  const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
47
- return isRecord(config) ? config : {}
45
+ return isRecord(config) ? config : null
48
46
  } catch {
49
- return {}
47
+ return null
50
48
  }
51
49
  }
52
50
 
51
+ const readConfig = (directories: string[]): Record<string, unknown> => readConfigOrNull(directories) ?? {}
52
+
53
53
  /**
54
54
  * The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
55
55
  * malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
@@ -1,13 +1,13 @@
1
1
  // Runs the decision off the main thread, so index.ts can end it when it takes too long.
2
- import { decideInput, failureDecision } from './run'
2
+ import { type ApprovalLabels, decideInput, failureDecision } from './run'
3
3
 
4
- type Job = { text: string; label: string; projectDirectory: string }
4
+ type Job = { text: string; labels: ApprovalLabels; projectDirectory: string }
5
5
 
6
6
  declare const self: Worker
7
7
 
8
8
  self.onmessage = ({ data }: MessageEvent<Job>) => {
9
9
  try {
10
- self.postMessage(decideInput(data.text, data.label, data.projectDirectory))
10
+ self.postMessage(decideInput(data.text, data.labels, data.projectDirectory))
11
11
  } catch (error) {
12
12
  self.postMessage(failureDecision(error))
13
13
  }
@@ -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
+ }