jackherd 1.15.2 → 1.17.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 +34 -0
- package/INSTALL.md +2 -1
- package/README.md +26 -22
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +1 -1
- package/kit/rules/rules/kit/code-style.md +6 -6
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +18 -11
- package/kit/workflow/skills/implement/SKILL.md +1 -1
- package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +3 -2
- package/package.json +3 -1
- package/recipes/README.md +4 -2
- package/recipes/google-calendar.md +30 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +38 -0
- package/recipes/sources/google-calendar.ts +368 -0
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +17 -3
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +81 -23
- package/src/check/skill-budgets.ts +58 -10
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +191 -25
- package/src/plans/live.ts +57 -14
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/row-actions.ts +17 -10
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/plans/snapshot.ts +8 -3
- package/src/plans/source-rows.ts +35 -2
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +68 -12
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-dry-run.ts +1 -1
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +15 -4
- package/src/watch/waiting-line.ts +4 -3
|
@@ -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
|
|
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
|
-
):
|
|
37
|
-
sessions.
|
|
40
|
+
): CoordinatorLog => {
|
|
41
|
+
const entries = sessions.map((session) => {
|
|
38
42
|
const path = transcriptPath(session, projectsDirectory)
|
|
39
|
-
return 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
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
38
|
-
if (label === '') return
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|