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.
- package/CHANGELOG.md +43 -0
- package/INSTALL.md +4 -2
- package/{NOTICE → NOTICE.txt} +1 -1
- package/README.md +94 -1033
- package/assets/icons/bark-dark.svg +1 -1
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl-dark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide-dark.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear-dark.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes-dark.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan-dark.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
- package/dist/config.d.ts +11 -5
- package/dist/config.js +53 -21
- package/dist/plan-format.d.ts +73 -7
- package/dist/plan-format.js +180 -100
- package/docs/workflow-dark.svg +1 -1
- package/docs/workflow.svg +1 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
- package/kit/hooks/hooks/approval-gate/index.ts +10 -8
- package/kit/hooks/hooks/approval-gate/run.ts +21 -12
- package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
- package/kit/hooks/hooks/git-guard/decision.ts +189 -15
- package/kit/hooks/hooks/git-guard/index.ts +13 -3
- package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
- package/kit/hooks/hooks/git-guard/repository.ts +63 -0
- package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
- package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
- package/kit/hooks/hooks/merge-guard/index.ts +6 -4
- package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
- package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
- package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
- package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
- package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
- package/kit/hooks/hooks/merge-guard/run.ts +118 -29
- package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
- package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
- package/kit/hooks/hooks/shared/base-branch.ts +23 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
- package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
- package/kit/hooks/hooks/shared/command.ts +14 -2
- package/kit/hooks/hooks/shared/commit.ts +2 -0
- package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
- package/kit/hooks/hooks/shared/json.ts +10 -0
- package/kit/hooks/hooks/shared/lane.ts +57 -0
- package/kit/hooks/hooks/shared/once.ts +8 -0
- package/kit/hooks/hooks/shared/own-pane.ts +31 -0
- package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
- package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
- package/kit/hooks/hooks/shared/program-position.ts +148 -5
- package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
- package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
- package/kit/hooks/hooks/shared/shell.ts +167 -20
- package/kit/hooks/hooks.json +11 -2
- package/kit/hooks/statusline.sh +29 -16
- package/kit/review/skills/diagnose/SKILL.md +1 -1
- package/kit/review/skills/review/SKILL.md +2 -0
- package/kit/rules/rules/kit/testing.md +2 -5
- package/kit/rules/rules/kit/writing-for-agents.md +3 -1
- package/kit/workflow/agents/spec-review.md +1 -1
- package/kit/workflow/skills/follow/SKILL.md +3 -3
- package/kit/workflow/skills/grill/SKILL.md +1 -1
- package/kit/workflow/skills/implement/SKILL.md +10 -11
- package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
- package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
- package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
- package/kit/workflow/skills/plan/SKILL.md +5 -2
- package/kit/workflow/skills/plan/references/template-English.md +6 -0
- package/kit/workflow/skills/plan/references/template-German.md +6 -0
- package/package.json +3 -2
- package/recipes/README.md +21 -0
- package/recipes/github-issues.md +23 -0
- package/recipes/github-projects.md +22 -0
- package/recipes/jira-cli.md +26 -0
- package/recipes/jira-rest.md +24 -0
- package/recipes/linear.md +25 -0
- package/recipes/sentry.md +18 -0
- package/recipes/sources/jira.ts +51 -0
- package/recipes/sources/linear.ts +19 -0
- package/recipes/sources/sentry.ts +12 -0
- package/recipes/sources.md +80 -0
- package/recipes/status-page.md +19 -0
- package/schemas/jackherd.schema.json +42 -18
- package/src/check/command.ts +2 -2
- package/src/check/parallel-report.ts +19 -9
- package/src/cli.ts +1 -0
- package/src/config/context-shares.ts +130 -0
- package/src/config/legacy-names.ts +8 -0
- package/src/config/read-config.ts +12 -4
- package/src/config/schema.ts +75 -20
- package/src/context/auto-compact.ts +68 -0
- package/src/context/command.ts +68 -17
- package/src/context/coordinator-name.ts +27 -9
- package/src/context/index.ts +0 -1
- package/src/context/level.ts +18 -11
- package/src/context/report.ts +6 -3
- package/src/plan-format/dependencies.ts +144 -32
- package/src/plan-format/index.ts +6 -0
- package/src/plan-format/lanes.ts +6 -0
- package/src/plan-format/progress.ts +19 -8
- package/src/plans/command.ts +14 -5
- package/src/plans/current-phase.ts +63 -7
- package/src/plans/current-task.ts +51 -0
- package/src/plans/ensure-panes.ts +25 -8
- package/src/plans/frame.ts +99 -0
- package/src/plans/git.ts +8 -0
- package/src/plans/herd-memory.ts +107 -0
- package/src/plans/lane-plans.ts +5 -3
- package/src/plans/lane-summary.ts +222 -0
- package/src/plans/layout-phase.ts +148 -0
- package/src/plans/layout-summary.ts +140 -0
- package/src/plans/layout.ts +164 -420
- package/src/plans/live.ts +11 -6
- package/src/plans/plan-panes.ts +15 -0
- package/src/plans/planned.ts +9 -42
- package/src/plans/selection.ts +2 -6
- package/src/plans/settings.ts +2 -7
- package/src/plans/snapshot.ts +38 -39
- package/src/plans/symbols.ts +2 -2
- package/src/plans/texts.ts +32 -31
- package/src/plans/view.ts +0 -1
- package/src/plans/worked-phases.ts +2 -2
- package/src/settled/board-row.ts +133 -0
- package/src/settled/check.ts +129 -0
- package/src/settled/command.ts +65 -0
- package/src/settled/evidence-version.ts +14 -0
- package/src/settled/options.ts +69 -0
- package/src/settled/send.ts +27 -0
- package/src/settled/settle.ts +87 -0
- package/src/settled/topics.ts +56 -0
- package/src/sync/command.ts +16 -1
- package/src/sync/render.ts +13 -4
- package/src/sync/sync.ts +25 -5
- package/src/terminal/herdr.ts +46 -10
- package/src/terminal/none.ts +1 -0
- package/src/terminal/types.ts +15 -2
- package/src/update/command.ts +4 -2
- package/src/update/update.ts +33 -9
- package/src/vocabulary/index.ts +47 -11
- package/src/watch/appointments.ts +205 -0
- package/src/watch/command.ts +62 -19
- package/src/watch/lane-report.ts +11 -3
- package/src/watch/texts.ts +35 -59
- package/src/watch/watch-events.ts +21 -28
- 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 = {
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
const
|
|
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
|
-
|
|
141
|
+
shellDefinitions: once(() => readShellDefinitions()),
|
|
142
|
+
isMergeApproved: (merge) => {
|
|
50
143
|
if (!isInside(directory, projectDirectory)) return false
|
|
51
|
-
const
|
|
52
|
-
if (branches.size === 0 &&
|
|
53
|
-
// The branch lookup also proves the pull request is open and from this repository;
|
|
54
|
-
const
|
|
55
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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;
|
|
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.
|
|
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
|
+
}
|