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
@@ -2,14 +2,15 @@
2
2
  // decision on stdout when the command runs a guarded one, or with nothing to leave the call to the
3
3
  // permission rules. Any failure asks: a guard that crashes, or takes too long, must not wave the command through.
4
4
  // The decision runs in a worker that is ended after DEADLINE_MS, because Claude Code lets a call through when a hook
5
- // runs into its own timeout. argv[2] is the exact option label that approves a merge, rendered into the hook entry by `sync`.
5
+ // runs into its own timeout. argv[2] and argv[3] are the exact option labels that approve a merge: the one of the pull request
6
+ // question and the one of a plan question, rendered into the hook entry by `sync`.
6
7
  import type { MergeGuardDecision } from './decision'
7
- import { failureDecision } from './run'
8
+ import { type ApprovalLabels, failureDecision } from './run'
8
9
 
9
10
  /** Below the 10 s the hook entry allows, with room for the start of Bun and for printing. */
10
11
  const DEADLINE_MS = 7000
11
12
 
12
- type Job = { text: string; label: string; projectDirectory: string }
13
+ type Job = { text: string; labels: ApprovalLabels; projectDirectory: string }
13
14
 
14
15
  const timeoutDecision: MergeGuardDecision = {
15
16
  kind: 'ask',
@@ -34,7 +35,8 @@ const decide = async (): Promise<MergeGuardDecision> => {
34
35
  try {
35
36
  const text = await Bun.stdin.text()
36
37
  const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
37
- return await decideWithin({ text, label: process.argv[2] ?? '', projectDirectory }, DEADLINE_MS)
38
+ const labels = { selfMerge: process.argv[2] ?? '', plan: process.argv[3] ?? '' }
39
+ return await decideWithin({ text, labels, projectDirectory }, DEADLINE_MS)
38
40
  } catch (error) {
39
41
  return failureDecision(error)
40
42
  }
@@ -0,0 +1,15 @@
1
+ /** Whether a path is one GitHub lists for a pull request, and the character test the config validators share. */
2
+
3
+ /** A C0 control character or DEL. */
4
+ export const isControlCharacter = (character: string): boolean => {
5
+ const code = character.charCodeAt(0)
6
+ return code < 0x20 || code === 0x7f
7
+ }
8
+
9
+ /** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
10
+ export const isPlainPath = (file: string): boolean =>
11
+ file !== '' &&
12
+ !file.startsWith('/') &&
13
+ !file.includes('\\') &&
14
+ ![...file].some(isControlCharacter) &&
15
+ !file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
@@ -0,0 +1,23 @@
1
+ // PostToolUse hook for AskUserQuestion, shipped by jackherd. When the call asked about a plan (``Plan `<path>` ``) and
2
+ // offered the label that approves it or the one that revokes it, it leaves a pointer to that dialog in the main
3
+ // checkout's `.jackherd/approvals/plans/`, so the merge guard finds the answer again after the session ended. It writes
4
+ // only the pointer; the merge guard reads the answer from the transcript itself. argv[2] and argv[3] are the two labels,
5
+ // rendered into the hook entry by `sync`. It never blocks and prints nothing: a pointer it fails to write costs a question.
6
+ // The other way round it costs a revocation: when no pointer is written for a "revoke" answer, the merge guard never
7
+ // sees that answer, and an older approval keeps holding until the next plan question that is recorded.
8
+ import { mainCheckoutOf } from '../shared/checkout'
9
+ import { planPointersDirectory, pointerOf, writePointer } from './plan-pointers'
10
+
11
+ const record = async (): Promise<void> => {
12
+ const pointer = pointerOf(JSON.parse(await Bun.stdin.text()), process.argv.slice(2, 4), Date.now())
13
+ if (pointer === undefined) return
14
+ const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
15
+ writePointer(planPointersDirectory(mainCheckoutOf(projectDirectory)), pointer)
16
+ }
17
+
18
+ try {
19
+ await record()
20
+ } catch {
21
+ // Nothing to do: without a pointer the merge asks.
22
+ }
23
+ process.exit(0)
@@ -0,0 +1,142 @@
1
+ import { baseBranchOf } from '../shared/base-branch'
2
+ import { BOARD_WORDS, type BoardLanguage } from '../shared/board-lanes'
3
+ import { isRecord, parseJsonObject } from '../shared/json'
4
+ import { isRepositoryDirectory } from '../shared/repository-directory'
5
+ import { isControlCharacter } from './plain-path'
6
+
7
+ /**
8
+ * What plan approval needs: the paths that always ask (the defaults plus the project's own) and where plans, boards
9
+ * and lanes live. `null` from `readPlanApprovalSettings` means plan approval is off and every merge asks.
10
+ */
11
+ export type PlanApprovalSettings = {
12
+ exceptPaths: readonly string[]
13
+ planLanguage: BoardLanguage
14
+ planDirectory: string
15
+ board: { pattern: string; file: string }
16
+ worktreeDirectory: string
17
+ baseBranch: string
18
+ /** The checks that must be among the head commit's and green; undefined: at least one check, every one green. */
19
+ requiredChecks: readonly string[] | undefined
20
+ }
21
+
22
+ /**
23
+ * The paths that always ask, whatever the project adds: workflows, migrations, env files, the agent setup and its
24
+ * instructions, the jackherd config, `bunfig.toml`, whose `preload` runs before every hook (they start in the project
25
+ * root, so only the root one), git hooks, lockfiles, registry config and code owners.
26
+ */
27
+ export const PLAN_APPROVAL_ALWAYS_ASK: readonly string[] = [
28
+ '.github/**',
29
+ '**/migrations/**',
30
+ '**/.env*',
31
+ '.claude/**',
32
+ '.jackherd/**',
33
+ 'bunfig.toml',
34
+ '**/CLAUDE.md',
35
+ '**/AGENTS.md',
36
+ '.agents/**',
37
+ '.mcp.json',
38
+ '.husky/**',
39
+ 'lefthook.yml',
40
+ '**/bun.lock',
41
+ '**/bun.lockb',
42
+ '**/package-lock.json',
43
+ '**/pnpm-lock.yaml',
44
+ '**/yarn.lock',
45
+ '**/.npmrc',
46
+ '**/CODEOWNERS',
47
+ ]
48
+
49
+ /** Where a project keeps its jackherd config, as a path inside the repository. */
50
+ export const PROJECT_CONFIG = '.jackherd/config.json'
51
+
52
+ /**
53
+ * The same rules as `alwaysAskPattern` in the config schema: path words with wildcards, relative, no empty, `.` or
54
+ * `..` segment (GitHub lists no file with one, so such a pattern would never ask). Broad on purpose (`.github/**`,
55
+ * `**`): a pattern here only ever adds a question.
56
+ */
57
+ const isAlwaysAskPattern = (value: unknown): value is string =>
58
+ typeof value === 'string' &&
59
+ /^[A-Za-z0-9_.*/-]+$/.test(value) &&
60
+ value.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
61
+
62
+ const MAX_CHECK_NAME = 200
63
+ const BOARD_PATTERN = /^(?![-.])[A-Za-z0-9._*?-]+$/
64
+ const BOARD_FILE = /^(?![-.])[A-Za-z0-9._-]+\.md$/
65
+
66
+ /** The value at `key`, its default when absent, `undefined` when present but invalid. */
67
+ const valueOr = <T>(section: Record<string, unknown>, key: string, isValid: (value: unknown) => value is T, fallback: T) =>
68
+ !(key in section) ? fallback : isValid(section[key]) ? section[key] : undefined
69
+
70
+ /** An optional value: `{ value }` with `value` undefined when absent, `undefined` when present but invalid. */
71
+ const optionalValue = <T>(section: Record<string, unknown>, key: string, isValid: (value: unknown) => value is T) =>
72
+ !(key in section) ? { value: undefined } : isValid(section[key]) ? { value: section[key] } : undefined
73
+
74
+ const matches =
75
+ (pattern: RegExp) =>
76
+ (value: unknown): value is string =>
77
+ typeof value === 'string' && pattern.test(value)
78
+
79
+ const isPlanLanguage = (value: unknown): value is BoardLanguage => typeof value === 'string' && Object.hasOwn(BOARD_WORDS, value)
80
+
81
+ const isSectionOrAbsent = (config: Record<string, unknown>, key: string): boolean =>
82
+ !(key in config) || isRecord(config[key])
83
+
84
+ const sectionAt = (config: Record<string, unknown>, key: string): Record<string, unknown> =>
85
+ isRecord(config[key]) ? config[key] : {}
86
+
87
+ const isPathList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isAlwaysAskPattern)
88
+
89
+ /** The same rules as `checkName` in the config schema: a name with a visible character, no control character. */
90
+ const isCheckName = (value: unknown): value is string =>
91
+ typeof value === 'string' &&
92
+ value.trim() !== '' &&
93
+ value.length <= MAX_CHECK_NAME &&
94
+ ![...value].some(isControlCharacter)
95
+
96
+ const isCheckList = (value: unknown): value is string[] => Array.isArray(value) && value.every(isCheckName)
97
+
98
+ const sectionsAreObjects = (config: Record<string, unknown>): boolean =>
99
+ isSectionOrAbsent(config, 'guard') &&
100
+ isSectionOrAbsent(sectionAt(config, 'guard'), 'planApproval') &&
101
+ isSectionOrAbsent(config, 'board')
102
+
103
+ /** Every value plan approval reads, with its default where absent; `undefined` stands for a present but invalid one. */
104
+ const planApprovalValues = (config: Record<string, unknown>) => {
105
+ const board = sectionAt(config, 'board')
106
+ const planApproval = sectionAt(sectionAt(config, 'guard'), 'planApproval')
107
+ return {
108
+ exceptPaths: valueOr(planApproval, 'exceptPaths', isPathList, []),
109
+ requiredChecks: optionalValue(planApproval, 'requiredChecks', isCheckList),
110
+ planLanguage: valueOr(config, 'planLanguage', isPlanLanguage, 'en'),
111
+ planDirectory: valueOr(config, 'planDirectory', isRepositoryDirectory, 'docs/plans'),
112
+ pattern: valueOr(board, 'pattern', matches(BOARD_PATTERN), 'board-*'),
113
+ file: valueOr(board, 'file', matches(BOARD_FILE), 'board.md'),
114
+ worktreeDirectory: valueOr(config, 'worktreeDirectory', isRepositoryDirectory, '.claude/worktrees'),
115
+ baseBranch: baseBranchOf(config),
116
+ }
117
+ }
118
+
119
+ type Values = ReturnType<typeof planApprovalValues>
120
+ type ValidValues = { [Key in keyof Values]: Exclude<Values[Key], undefined> }
121
+
122
+ const allValid = (values: Values): values is ValidValues => Object.values(values).every((value) => value !== undefined)
123
+
124
+ /**
125
+ * The plan approval settings of the project's `.jackherd/config.json` as the base commit holds it (`null`: it holds
126
+ * none). Unlike the other guard settings, a missing config, one that is no JSON object, or any invalid value they read
127
+ * turns plan approval off (`null`), so neither a mistyped project path nor a home config can shrink the list silently.
128
+ * The project's paths add to `PLAN_APPROVAL_ALWAYS_ASK`.
129
+ */
130
+ export const planApprovalSettingsOf = (text: string | null): PlanApprovalSettings | null => {
131
+ const config = text === null ? null : (parseJsonObject(text) ?? null)
132
+ if (config === null || !sectionsAreObjects(config)) return null
133
+ const values = planApprovalValues(config)
134
+ if (!allValid(values)) return null
135
+ const { exceptPaths, requiredChecks, pattern, file, ...where } = values
136
+ return {
137
+ exceptPaths: [...PLAN_APPROVAL_ALWAYS_ASK, ...exceptPaths],
138
+ board: { pattern, file },
139
+ ...where,
140
+ requiredChecks: requiredChecks.value,
141
+ }
142
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Where plan approval reads its facts: git in the main checkout for the base branch, the worktrees and the boards, the
3
+ * pointer directory and the transcripts for the answers. Every git call runs without a shell and with a timeout; any
4
+ * failure answers nothing, so the guard asks.
5
+ */
6
+ import { existsSync } from 'node:fs'
7
+ import { join, posix, relative, resolve } from 'node:path'
8
+ import { lanePlansOf } from '../shared/board-lanes'
9
+ import { laneOf } from '../shared/lane'
10
+ import type { PlanApprovalSources } from './plan-approval'
11
+ import { FULL_COMMIT } from '../shared/commit'
12
+ import { isPlainPath } from './plain-path'
13
+ import { planAnswers, planPointersDirectory } from './plan-pointers'
14
+ import { checksPassed } from './pull-request'
15
+ import { baseBranchOf } from '../shared/base-branch'
16
+ import { type PlanApprovalSettings, PROJECT_CONFIG, planApprovalSettingsOf } from './plan-approval-settings'
17
+ import { readConfigOrNull } from './settings'
18
+
19
+ /** The base commit plan approval reads, just fetched, and the settings its project config holds. */
20
+ export type PlanBase = { commit: string; settings: PlanApprovalSettings }
21
+
22
+ export type PlanSourceContext = {
23
+ /** The main checkout, where the worktrees, the base branch and the pointers are. */
24
+ main: string
25
+ base: PlanBase
26
+ /** The transcript of the session that merges. */
27
+ transcript: string | undefined
28
+ /** The label that approves a plan. */
29
+ label: string
30
+ files: () => readonly string[] | undefined
31
+ /** The `gh pr view` answer for the pull request, which carries its checks. */
32
+ view: string
33
+ }
34
+
35
+ const GIT_TIMEOUT_MS = 3000
36
+ const BASE_REMOTE = 'origin'
37
+ const HEADER = /^[0-9a-f]+ blob (\d+)$/
38
+
39
+ /**
40
+ * The output of `git cat-file --batch`, one entry per requested path in order: the text of a blob, null for a path the
41
+ * commit lacks or that is no blob. Undefined when the output does not hold one entry per path.
42
+ */
43
+ export const parseCatFileBatch = (output: Buffer, count: number): (string | null)[] | undefined => {
44
+ const texts: (string | null)[] = []
45
+ let at = 0
46
+ while (texts.length < count) {
47
+ const newline = output.indexOf('\n', at)
48
+ if (newline === -1) return undefined
49
+ const size = HEADER.exec(output.subarray(at, newline).toString('utf8'))?.[1]
50
+ if (size === undefined) {
51
+ texts.push(null)
52
+ at = newline + 1
53
+ continue
54
+ }
55
+ const end = newline + 1 + Number(size)
56
+ if (end > output.length) return undefined
57
+ texts.push(output.subarray(newline + 1, end).toString('utf8'))
58
+ at = end + 1
59
+ }
60
+ return texts
61
+ }
62
+
63
+ /** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
64
+ export const laneInWorktreeList = (
65
+ worktreeList: string,
66
+ main: string,
67
+ worktreeDirectory: string,
68
+ branch: string,
69
+ ): string | undefined => {
70
+ const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
71
+ const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
72
+ const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
73
+ if (path === undefined) return undefined
74
+ const lane = laneOf({ cwd: path, main, worktreeDirectory })
75
+ return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
76
+ }
77
+
78
+ /** Whether `name` is the directory of a lane's worktree below the worktree directory. */
79
+ export const isLaneIn =
80
+ (main: string, worktreeDirectory: string) =>
81
+ (name: string): boolean => {
82
+ const worktree = join(resolve(main, worktreeDirectory), name)
83
+ return laneOf({ cwd: worktree, main, worktreeDirectory }) === name && existsSync(worktree)
84
+ }
85
+
86
+ /** Only the base branch into its remote ref: no tags, no FETCH_HEAD written, no maintenance started. */
87
+ export const baseFetchArguments = (baseBranch: string): string[] => [
88
+ 'fetch',
89
+ '--quiet',
90
+ '--no-tags',
91
+ '--no-write-fetch-head',
92
+ '--no-auto-maintenance',
93
+ BASE_REMOTE,
94
+ `+refs/heads/${baseBranch}:refs/remotes/${BASE_REMOTE}/${baseBranch}`,
95
+ ]
96
+
97
+ /** No prompt for a password or a host key: a fetch that would wait for one fails at once instead. */
98
+ export const NON_INTERACTIVE = { GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
99
+
100
+ const git = (main: string, args: readonly string[], stdin?: string): Buffer | undefined => {
101
+ const result = Bun.spawnSync(['git', '-C', main, ...args], {
102
+ stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
103
+ stdout: 'pipe',
104
+ stderr: 'ignore',
105
+ timeout: GIT_TIMEOUT_MS,
106
+ env: { ...process.env, ...NON_INTERACTIVE },
107
+ })
108
+ return result.exitCode === 0 ? result.stdout : undefined
109
+ }
110
+
111
+ const baseCommitOf = (main: string, baseBranch: string): string | undefined => {
112
+ const commit = git(main, ['rev-parse', '--verify', '--quiet', `${BASE_REMOTE}/${baseBranch}^{commit}`])
113
+ ?.toString()
114
+ .trim()
115
+ return commit !== undefined && FULL_COMMIT.test(commit) ? commit : undefined
116
+ }
117
+
118
+ /** The texts of the paths at the commit, in one `git cat-file --batch`; null for each one it lacks or cannot read. */
119
+ const readAtCommit = (main: string, commit: string, paths: readonly string[]): Map<string, string | null> => {
120
+ const asked = paths.filter(isPlainPath)
121
+ const output =
122
+ asked.length === 0
123
+ ? undefined
124
+ : git(main, ['cat-file', '--batch'], asked.map((path) => `${commit}:${path}\n`).join(''))
125
+ const texts = output === undefined ? undefined : parseCatFileBatch(output, asked.length)
126
+ return new Map(paths.map((path) => [path, texts?.[asked.indexOf(path)] ?? null]))
127
+ }
128
+
129
+ /** The board directories under the plan directory at the commit whose name matches the board pattern. */
130
+ const boardDirectories = (main: string, commit: string, settings: PlanApprovalSettings): string[] => {
131
+ const listing = git(main, ['ls-tree', '-d', '--name-only', commit, '--', `${settings.planDirectory}/`])
132
+ const pattern = new Bun.Glob(settings.board.pattern)
133
+ return (listing?.toString() ?? '')
134
+ .split('\n')
135
+ .filter((path) => path !== '' && pattern.match(posix.basename(path)))
136
+ }
137
+
138
+ /** Brings `origin/<baseBranch>` up to date, so a stale ref cannot hold an approval GitHub's base no longer does. */
139
+ const fetchBase = (main: string, baseBranch: string): boolean => git(main, baseFetchArguments(baseBranch)) !== undefined
140
+
141
+ /** The newest first-parent commit of the base from before the time; undefined when there is none. */
142
+ const commitBefore = (main: string, commit: string, time: number): string | undefined => {
143
+ const found = git(main, ['rev-list', '-1', '--first-parent', `--before=@${Math.floor(time / 1000)}`, commit])
144
+ ?.toString()
145
+ .trim()
146
+ return found !== undefined && FULL_COMMIT.test(found) ? found : undefined
147
+ }
148
+
149
+ /**
150
+ * The plan as the base held it at the time of the answer; undefined when the base did not hold it then, or not
151
+ * readably. A plan that was only in a pull request when the user approved it could have grown before it landed, and
152
+ * the state the user saw is nowhere on the base, so the guard asks once more after it landed.
153
+ */
154
+ const planAtApproval = (main: string, commit: string, plan: string, answeredAt: number): string | undefined => {
155
+ const before = commitBefore(main, commit, answeredAt)
156
+ return (before === undefined ? undefined : readAtCommit(main, before, [plan]).get(plan)) ?? undefined
157
+ }
158
+
159
+ /**
160
+ * The base plan approval reads: `origin/<baseBranch>` right after a fetch, and the settings of the project config at
161
+ * that commit, never of the checkout. The base branch comes from the checkout's config (the base cannot name itself)
162
+ * and must agree with the one the base's config names. Undefined, so the guard asks, when any step fails.
163
+ */
164
+ export const planBaseOf = (main: string): PlanBase | undefined => {
165
+ const baseBranch = baseBranchOf(readConfigOrNull([main]))
166
+ if (baseBranch === undefined || !fetchBase(main, baseBranch)) return undefined
167
+ const commit = baseCommitOf(main, baseBranch)
168
+ if (commit === undefined) return undefined
169
+ const settings = planApprovalSettingsOf(readAtCommit(main, commit, [PROJECT_CONFIG]).get(PROJECT_CONFIG) ?? null)
170
+ return settings !== null && settings.baseBranch === baseBranch ? { commit, settings } : undefined
171
+ }
172
+
173
+ export const planApprovalSources = ({ main, base, transcript, label, files, view }: PlanSourceContext): PlanApprovalSources => {
174
+ const { commit, settings } = base
175
+ return {
176
+ checksPassed: (requiredChecks) => checksPassed(view, requiredChecks),
177
+ containsBase: (headCommit) =>
178
+ FULL_COMMIT.test(headCommit) && git(main, ['merge-base', '--is-ancestor', commit, headCommit]) !== undefined,
179
+ files,
180
+ readAtBase: (paths) => readAtCommit(main, commit, paths),
181
+ laneOfBranch: (branch) => {
182
+ const list = git(main, ['worktree', 'list', '--porcelain'])?.toString()
183
+ return list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
184
+ },
185
+ lanePlans: (lane) => {
186
+ const boards = boardDirectories(main, commit, settings)
187
+ const texts = readAtCommit(main, commit, boards.map((directory) => `${directory}/${settings.board.file}`))
188
+ return boards.flatMap((directory) => {
189
+ const text = texts.get(`${directory}/${settings.board.file}`)
190
+ return (text ? lanePlansOf(text, directory, lane, settings.planLanguage) : undefined) ?? []
191
+ })
192
+ },
193
+ answers: (plan) => planAnswers({ directory: planPointersDirectory(main), currentTranscript: transcript, plan, label }),
194
+ readAtCommitBefore: (plan, answeredAt) => (isPlainPath(plan) ? planAtApproval(main, commit, plan, answeredAt) : undefined),
195
+ }
196
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Whether the user's approval of a plan lifts the question for the merge of a pull request: it merges into the base
3
+ * branch, its checks passed, its head contains the base, the pull request comes from a lane whose rows link exactly
4
+ * that plan, the plan still has an open phase, no file matches a path that always asks, none is a package.json and
5
+ * none the CHANGELOG.md of a published package, the newest answer to a plan question naming it approved it, and the
6
+ * plan gained no phase and no undecided task since that answer. Everything is read from the base branch and from
7
+ * GitHub, never from the pull request's own files, and every doubt answers "no", so the guard asks as before. Only a
8
+ * plain `gh pr merge <number>` gets this far.
9
+ */
10
+ import type { LanePlans } from '../shared/board-lanes'
11
+ import { hasOpenPhase } from '../shared/plan-open-phases'
12
+ import { isRecord } from '../shared/json'
13
+ import type { MergeNote } from './decision'
14
+ import { isPlainPath } from './plain-path'
15
+ import { type NewTask, newPhasesSince, newTasksSince } from './plan-growth'
16
+
17
+ /** The label the user picked in a plan question and when; read from the transcript, not from the pointer. */
18
+ export type PlanAnswer = { answer: string; answeredAt: number }
19
+
20
+ /** Where the facts come from; each is asked only once the steps before it said yes. */
21
+ export type PlanApprovalSources = {
22
+ /** Whether GitHub can merge the head commit and its checks passed, the required ones among them. */
23
+ checksPassed: (requiredChecks: readonly string[] | undefined) => boolean
24
+ /** Whether the head commit contains the base commit plan approval reads. */
25
+ containsBase: (headCommit: string) => boolean
26
+ /** The files of the pull request as GitHub lists them, renames with their old name; undefined when not complete. */
27
+ files: () => readonly string[] | undefined
28
+ /** The texts of these paths at the base branch; null for a path it does not have. */
29
+ readAtBase: (paths: readonly string[]) => ReadonlyMap<string, string | null>
30
+ /** The lane whose worktree has the branch checked out. */
31
+ laneOfBranch: (branch: string) => string | undefined
32
+ /** The lane's rows in each board at the base branch that has any. */
33
+ lanePlans: (lane: string) => LanePlans[]
34
+ /** The answers to the plan questions naming this plan, each backed by its dialog in a transcript. */
35
+ answers: (plan: string) => PlanAnswer[]
36
+ /** The plan as the base held it at the time of the answer; undefined when it held none then or it is unreadable. */
37
+ readAtCommitBefore: (plan: string, answeredAt: number) => string | undefined
38
+ }
39
+
40
+ export type PlanApprovalQuery = {
41
+ /** The pull request as GitHub names it: its head branch and commit, and the branch it merges into. */
42
+ pullRequest: { branch: string; headCommit: string; baseBranch: string }
43
+ /** The exact option label that approves a plan. */
44
+ label: string
45
+ /** The time of the check, in milliseconds. */
46
+ now: number
47
+ /** The paths that always ask, the base branch, and the checks that must have passed (undefined: every one listed). */
48
+ settings: { exceptPaths: readonly string[]; baseBranch: string; requiredChecks: readonly string[] | undefined }
49
+ }
50
+
51
+ /** `true` lifts the question; `false` asks as before; a note asks and says why, for a plan that grew since its approval. */
52
+ export type PlanVerdict = boolean | MergeNote
53
+
54
+ const MANIFEST = 'package.json'
55
+ /** How many new tasks a note names before it only counts the rest. */
56
+ const NAMED_TASKS = 5
57
+ const TASK_LENGTH = 80
58
+ const CHANGELOG = 'changelog.md'
59
+
60
+ const directoryOf = (file: string): string => file.slice(0, Math.max(file.lastIndexOf('/'), 0))
61
+
62
+ /** In lower case, as a case-insensitive file system would write it. */
63
+ const fileName = (file: string): string => file.slice(file.lastIndexOf('/') + 1).toLowerCase()
64
+
65
+ const manifestOf = (directory: string): string => (directory === '' ? MANIFEST : `${directory}/${MANIFEST}`)
66
+
67
+ /**
68
+ * Compared in lower case: on a case-insensitive file system git writes `.Claude/x` into the existing `.claude/`, so a
69
+ * pattern must not miss it. Folding only ever adds a question.
70
+ */
71
+ const matchesAny = (files: readonly string[], patterns: readonly string[]): boolean => {
72
+ const globs = patterns.map((pattern) => new Bun.Glob(pattern.toLowerCase()))
73
+ return files.some((file) => globs.some((glob) => glob.match(file.toLowerCase())))
74
+ }
75
+
76
+ /** The manifests of the packages whose CHANGELOG.md the pull request changes. */
77
+ const changelogManifests = (files: readonly string[]): string[] => [
78
+ ...new Set(files.filter((file) => fileName(file) === CHANGELOG).map((file) => manifestOf(directoryOf(file)))),
79
+ ]
80
+
81
+ /** A package is private only when its manifest at the base says `"private": true`; a missing or broken one is published. */
82
+ const isPrivate = (manifest: string | null | undefined): boolean => {
83
+ try {
84
+ const parsed: unknown = JSON.parse(manifest ?? '')
85
+ return isRecord(parsed) && parsed.private === true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /** The one plan the lane's open rows link across the boards; none or several gives none. */
92
+ const onlyPlanOf = (rows: readonly LanePlans[]): string | undefined => {
93
+ const plans = new Set(rows.filter(({ closed }) => !closed).flatMap(({ plans }) => plans))
94
+ const [only] = plans
95
+ return plans.size === 1 ? only : undefined
96
+ }
97
+
98
+ /**
99
+ * When the user approved the plan: the time of the newest answers not from the future, when every one of them is the
100
+ * label. Two answers at the same time (two questions in one dialog) approve only together.
101
+ */
102
+ const approvedAt = (answers: readonly PlanAnswer[], label: string, now: number): number | undefined => {
103
+ const past = answers.filter(({ answeredAt }) => answeredAt <= now)
104
+ if (past.length === 0) return undefined
105
+ const newest = Math.max(...past.map(({ answeredAt }) => answeredAt))
106
+ const atNewest = past.filter(({ answeredAt }) => answeredAt === newest)
107
+ return atNewest.every(({ answer }) => answer === label) ? newest : undefined
108
+ }
109
+
110
+ const shortened = (task: string): string => (task.length > TASK_LENGTH ? `${task.slice(0, TASK_LENGTH - 1)}…` : task)
111
+
112
+ const tasksNote = (tasks: readonly NewTask[]): string => {
113
+ const named = tasks.slice(0, NAMED_TASKS).map(({ phase, task }) => `P${phase} "${shortened(task)}"`)
114
+ const rest = tasks.length > NAMED_TASKS ? ` and ${tasks.length - NAMED_TASKS} more` : ''
115
+ return `${tasks.length} new tasks: ${named.join(', ')}${rest}`
116
+ }
117
+
118
+ /** Why the approval no longer covers the plan: the phases and the undecided tasks it gained; undefined when none. */
119
+ const growthNote = (plan: string, approvedText: string, currentText: string): string | undefined => {
120
+ const phases = newPhasesSince(approvedText, currentText)
121
+ const tasks = newTasksSince(approvedText, currentText)
122
+ const parts = [
123
+ ...(phases.length > 0 ? [`new phases: ${phases.map((phase) => `"${phase}"`).join(', ')}`] : []),
124
+ ...(tasks.length > 0 ? [tasksNote(tasks)] : []),
125
+ ]
126
+ return parts.length === 0 ? undefined : `Plan \`${plan}\` gained since the user's approval ${parts.join('; ')}.`
127
+ }
128
+
129
+ /**
130
+ * No file of the pull request matches a path that always asks or is a package.json (private or not: it can drop
131
+ * `"private"` or add a dependency), and none is the changelog of a package the base does not call private.
132
+ */
133
+ const changesOnlyOrdinaryFiles = (files: readonly string[], exceptPaths: readonly string[], sources: PlanApprovalSources) => {
134
+ if (files.length === 0 || !files.every(isPlainPath) || matchesAny(files, exceptPaths)) return false
135
+ if (files.some((file) => fileName(file) === MANIFEST)) return false
136
+ const manifests = changelogManifests(files)
137
+ if (manifests.length === 0) return true
138
+ const texts = sources.readAtBase(manifests)
139
+ return manifests.every((manifest) => isPrivate(texts.get(manifest)))
140
+ }
141
+
142
+ /** The plan of the branch's lane and its text at the base, while that plan has an open phase there. */
143
+ const openPlanOf = (branch: string, sources: PlanApprovalSources): { plan: string; text: string } | undefined => {
144
+ const lane = sources.laneOfBranch(branch)
145
+ const plan = lane === undefined ? undefined : onlyPlanOf(sources.lanePlans(lane))
146
+ if (plan === undefined) return undefined
147
+ const text = sources.readAtBase([plan]).get(plan)
148
+ return typeof text === 'string' && hasOpenPhase(text) ? { plan, text } : undefined
149
+ }
150
+
151
+ /** The pull request merges into the base, the head contains it, and its checks passed. */
152
+ const isTestedAgainstBase = ({ pullRequest, settings }: PlanApprovalQuery, sources: PlanApprovalSources): boolean =>
153
+ pullRequest.baseBranch === settings.baseBranch &&
154
+ sources.checksPassed(settings.requiredChecks) &&
155
+ sources.containsBase(pullRequest.headCommit)
156
+
157
+ export const isPlanApproved = (query: PlanApprovalQuery, sources: PlanApprovalSources): PlanVerdict => {
158
+ if (!isTestedAgainstBase(query, sources)) return false
159
+ const files = sources.files()
160
+ if (files === undefined || !changesOnlyOrdinaryFiles(files, query.settings.exceptPaths, sources)) return false
161
+ const open = openPlanOf(query.pullRequest.branch, sources)
162
+ const answeredAt = open === undefined ? undefined : approvedAt(sources.answers(open.plan), query.label, query.now)
163
+ if (open === undefined || answeredAt === undefined) return false
164
+ const approvedText = sources.readAtCommitBefore(open.plan, answeredAt)
165
+ if (approvedText === undefined) return false
166
+ const note = growthNote(open.plan, approvedText, open.text)
167
+ return note === undefined ? true : { note }
168
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * What a plan gained since the user approved it: phases, and tasks in the phases they already saw. Both compare the
3
+ * text of the approved plan with the plan at the base, whatever the boxes say, so ticking a task is no growth.
4
+ */
5
+ import { phaseBlocksOf, taskOf } from '../shared/plan-open-phases'
6
+
7
+ /** A task of a phase that was there at the approval, which the approved plan did not hold. */
8
+ export type NewTask = { phase: string; task: string }
9
+
10
+ /** A task the user decided, marked `(User <date> …)` with `DD.MM.` or `YYYY-MM-DD`; the user already saw it. */
11
+ const USER_DECIDED = /\(User (?:\d{1,2}\.\d{1,2}\.|\d{4}-\d{2}-\d{2})/
12
+
13
+ const tasksOf = (lines: readonly string[]): string[] =>
14
+ lines.flatMap((line) => {
15
+ const task = taskOf(line)
16
+ return task === null ? [] : [task.text]
17
+ })
18
+
19
+ /** The headings of the current plan's phases the approved plan does not have, by their text: a rename is new. */
20
+ export const newPhasesSince = (approvedText: string, currentText: string): string[] => {
21
+ const approved = new Set(phaseBlocksOf(approvedText).map(({ heading }) => heading))
22
+ return phaseBlocksOf(currentText)
23
+ .map(({ heading }) => heading)
24
+ .filter((heading) => !approved.has(heading))
25
+ }
26
+
27
+ /**
28
+ * The tasks of the current plan that the same phase of the approved plan did not hold, by their text (a rewording is
29
+ * new), leaving out tasks the user decided and every task of a phase that is new itself.
30
+ */
31
+ export const newTasksSince = (approvedText: string, currentText: string): NewTask[] => {
32
+ const approved = new Map(phaseBlocksOf(approvedText).map(({ id, lines }) => [id, new Set(tasksOf(lines))]))
33
+ return phaseBlocksOf(currentText).flatMap(({ id, lines }) => {
34
+ const known = approved.get(id)
35
+ if (known === undefined) return []
36
+ return tasksOf(lines)
37
+ .filter((task) => !known.has(task) && !USER_DECIDED.test(task))
38
+ .map((task) => ({ phase: id, task }))
39
+ })
40
+ }