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.
Files changed (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Input into a herdr pane. The herd takes its clicks as mouse reports on its own input, and these three commands
3
+ * write into a pane's input, so an agent could type a click into the herd with them. `herdr agent send-keys` and
4
+ * `herdr agent prompt` reach only a pane with an agent (`agent_not_found` otherwise, herdr 0.9.0), never the herd; but
5
+ * a key there answers the dialog open in that agent, and the transcript records it as the user's click (tried in a
6
+ * throwaway session), so into the coordinator they ask.
7
+ */
8
+ import { isRecord } from '../shared/json'
9
+
10
+ /** The commands that write into a pane's input; fixed in the guard, never taken from `guard.commands`. */
11
+ export const PANE_INPUT_COMMANDS: readonly string[] = ['herdr pane send-text', 'herdr pane send-keys', 'herdr pane run']
12
+
13
+ /** The commands that write into an agent's input; fixed in the guard like the pane inputs. */
14
+ export const AGENT_INPUT_COMMANDS: readonly string[] = ['herdr agent send-keys', 'herdr agent prompt']
15
+
16
+ /** An agent target as herdr takes it: a pane id or an agent name. */
17
+ export const AGENT_TARGET = /^[A-Za-z0-9][A-Za-z0-9:_.-]{0,63}$/
18
+
19
+ /**
20
+ * The one input into a pane that can pass without a question, a whole command line at a time. Text and keys always
21
+ * ask: the pane's shell keeps a line typed in one call for the next, which no check of a single call sees.
22
+ */
23
+ export const LIFTABLE_PANE_INPUT = 'herdr pane run'
24
+
25
+ /**
26
+ * The words that start the herd after the CLI (`jh herd`, and `plans` as it was called up to 1.6), as
27
+ * `src/plans/herd-command.ts` names them; a contract test holds the two together.
28
+ */
29
+ export const HERD_WORDS: readonly string[] = ['herd', 'plans']
30
+
31
+ /** A pane id as herdr writes it, `w3B:p1`; a pane name is no target of these commands. */
32
+ export const PANE_ID = /^[A-Za-z0-9]+:p[A-Za-z0-9]+$/
33
+
34
+ /** Options that point the command at another herdr server, where the lookup would not find the pane. */
35
+ export const OTHER_SERVER_OPTIONS: readonly string[] = ['--session', '--remote']
36
+
37
+ const isArgv = (value: unknown): value is string[] =>
38
+ Array.isArray(value) && value.every((word) => typeof word === 'string')
39
+
40
+ /** The argv of each foreground process in a `herdr pane process-info` answer; undefined for an answer of another shape. */
41
+ export const foregroundOf = (json: string): string[][] | undefined => {
42
+ try {
43
+ const answer: unknown = JSON.parse(json)
44
+ const info = isRecord(answer) && isRecord(answer.result) ? answer.result.process_info : undefined
45
+ const processes = isRecord(info) ? info.foreground_processes : undefined
46
+ if (!Array.isArray(processes)) return undefined
47
+ return processes.flatMap((each) => (isRecord(each) && isArgv(each.argv) ? [each.argv] : []))
48
+ } catch {
49
+ return undefined
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Whether a pane with these foreground processes cannot be the herd: herdr named at least one, and none carries a
55
+ * herd word after its program. Any doubt (no answer, no process named) counts as the herd. A word `herd` in another
56
+ * program's arguments asks once too often, never once too few.
57
+ */
58
+ export const cannotBeHerd = (foreground: readonly (readonly string[])[] | undefined): boolean =>
59
+ foreground !== undefined &&
60
+ foreground.length > 0 &&
61
+ foreground.every((argv) => !argv.slice(1).some((word) => HERD_WORDS.includes(word)))
@@ -0,0 +1,38 @@
1
+ /** The adapter that asks herdr what runs in a pane; `pane-input.ts` reads its answer. */
2
+ import { paneAgentOf } from '../shared/pane-agent'
3
+ import { foregroundOf } from './pane-input'
4
+
5
+ const PROCESS_INFO_TIMEOUT_MS = 2000
6
+
7
+ /**
8
+ * The directory the agent `target` runs in, which herdr reads from the process itself (`shared/own-pane.ts`); undefined
9
+ * when herdr does not answer in time, knows no such agent or answers otherwise.
10
+ */
11
+ export const herdrAgentDirectory = (target: string, directory: string): string | undefined => {
12
+ try {
13
+ const result = Bun.spawnSync(['herdr', 'agent', 'get', target], {
14
+ cwd: directory,
15
+ timeout: PROCESS_INFO_TIMEOUT_MS,
16
+ stdout: 'pipe',
17
+ stderr: 'ignore',
18
+ })
19
+ return result.exitCode === 0 ? (paneAgentOf(JSON.parse(result.stdout.toString()))?.cwd ?? undefined) : undefined
20
+ } catch {
21
+ return undefined
22
+ }
23
+ }
24
+
25
+ /** The foreground of `pane` as herdr names it now; undefined when herdr does not answer in time or answers otherwise. */
26
+ export const herdrForeground = (pane: string, directory: string): string[][] | undefined => {
27
+ try {
28
+ const result = Bun.spawnSync(['herdr', 'pane', 'process-info', '--pane', pane], {
29
+ cwd: directory,
30
+ timeout: PROCESS_INFO_TIMEOUT_MS,
31
+ stdout: 'pipe',
32
+ stderr: 'ignore',
33
+ })
34
+ return result.exitCode === 0 ? foregroundOf(result.stdout.toString()) : undefined
35
+ } catch {
36
+ return undefined
37
+ }
38
+ }
@@ -1,15 +1,20 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { isAbsolute } from 'node:path'
2
3
  import { mainCheckoutOf } from '../shared/checkout'
4
+ import { commandOutput } from '../shared/command'
3
5
  import { dialogAnswers, parseTranscript } from '../shared/dialog-answers'
4
6
  import { isWithinDirectory } from '../shared/files'
5
7
  import { once } from '../shared/once'
6
8
  import { readShellDefinitions } from '../shared/shell-snapshots'
7
- import { ALWAYS_ASK_PATHS } from './always-ask'
8
- import { approvedBranches } from './approval'
9
+ import { ALWAYS_ASK_PATHS, alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
10
+ import { type ApprovedChange, approvedChanges } from './approval'
9
11
  import { boardSettingsOf } from './board-settings'
12
+ import { changeIdOf, isSameChange } from './change-id'
10
13
  import { changedFilesOf, isCheckFree } from './check-free'
11
14
  import { decideBashCommand, type GuardPolicy, type MergeGuardDecision, type MergeNote, type PlainMerge } from './decision'
12
15
  import { planOfBranch } from './lane-plan'
16
+ import { cannotBeHerd } from './pane-input'
17
+ import { herdrAgentDirectory, herdrForeground } from './pane-process-info'
13
18
  import {
14
19
  checksPassed,
15
20
  parseBaseBranch,
@@ -26,7 +31,7 @@ type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown
26
31
  /** The exact option labels that approve a merge: the one of the pull request question and those of the standing approval. */
27
32
  export type ApprovalLabels = { selfMerge: string; standing: StandingLabels }
28
33
 
29
- /** What GitHub says about the pull request a merge names; `files` is asked only when a rule needs it. */
34
+ /** What GitHub says about the pull request a merge names; `files` and `change` are asked only when a rule needs them. */
30
35
  type MergeFacts = {
31
36
  branch: string
32
37
  baseBranch: string | undefined
@@ -34,13 +39,15 @@ type MergeFacts = {
34
39
  /** The `gh pr view` answer, which carries the checks and whether GitHub can merge. */
35
40
  view: string
36
41
  files: () => readonly string[] | undefined
42
+ /** The patch id of the pull request's diff at the head commit. */
43
+ change: () => string | undefined
37
44
  }
38
45
 
39
46
  type StandingApproval = (facts: MergeFacts) => boolean | MergeNote
40
47
 
41
48
  /** The ways a merge may pass without a question. */
42
49
  type MergeApprovals = {
43
- branches: ReadonlySet<string>
50
+ changes: readonly ApprovedChange[]
44
51
  /** The checks a self-merge of an approved branch waits for a successful run of. */
45
52
  requiredChecks: readonly string[] | undefined
46
53
  withoutChecks: readonly string[]
@@ -51,47 +58,101 @@ type MergeApprovals = {
51
58
 
52
59
  const PASS: MergeGuardDecision = { kind: 'pass' }
53
60
 
54
- /** The transcript of the session that merges, one parsed entry per line; none without one. */
55
- const transcriptEntries = (transcriptPath: unknown): unknown[] =>
56
- typeof transcriptPath === 'string' ? parseTranscript(readFileSync(transcriptPath, 'utf8')) : []
61
+ const approvedChangesFrom = (entries: unknown[], settings: GuardSettings, label: string): ApprovedChange[] => {
62
+ if (!settings.selfMerge.offer) return []
63
+ return approvedChanges({ entries, label, now: Date.now(), windowMinutes: settings.selfMerge.approvalMinutes })
64
+ }
65
+
66
+ const changedSinceApproval = (branch: string, change: string): MergeNote => ({
67
+ note: `The user approved branch \`${branch}\` with change @${change}; the change id of the pull request differs (a push after the approval, or GitHub's diff reads otherwise), so the user confirms the merge.`,
68
+ })
69
+
70
+ const unboundMerge: MergeNote = {
71
+ note: 'The user approved this branch, but the merge names no head commit: bind the merge with `--match-head-commit <headRefOid>` (from `gh pr view`), so a push after the check is not merged unchecked.',
72
+ }
73
+
74
+ /** The note for a merge of an approved branch that names no head commit; false for any other head that differs. */
75
+ const unboundMergeOf = (
76
+ merge: PlainMerge,
77
+ changes: readonly ApprovedChange[],
78
+ branch: string,
79
+ ): MergeNote | false =>
80
+ merge.headCommit === undefined && changes.some((change) => change.branch === branch) ? unboundMerge : false
81
+
82
+ const carriesGuardFiles = (files: readonly string[] | undefined): MergeNote => ({
83
+ note:
84
+ files === undefined
85
+ ? 'GitHub did not list the files of the pull request, so a guard file may be among them and the user confirms the merge.'
86
+ : `The pull request carries a guard file (${files.map((file) => `\`${file}\``).join(', ')}), which can loosen the guard that merges it, so the user confirms the merge.`,
87
+ })
57
88
 
58
- const approvedBranchesFrom = (entries: unknown[], settings: GuardSettings, label: string): Set<string> => {
59
- if (!settings.selfMerge.offer) return new Set()
60
- return approvedBranches({ entries, label, now: Date.now(), windowMinutes: settings.selfMerge.approvalMinutes })
89
+ /**
90
+ * The guard files a self-merge never covers (`STANDING_ALWAYS_ASK`): a pull request could loosen the guard that merges
91
+ * it. The project's own `guard.alwaysAsk` stays covered, since the user's pick is the approval for that pull request.
92
+ */
93
+ const guardFilesOf = (files: readonly string[] | undefined): readonly string[] | undefined =>
94
+ files === undefined ? undefined : alwaysAskingFiles(files, STANDING_ALWAYS_ASK)
95
+
96
+ /**
97
+ * The user approved the branch with the change the pull request carries, its checks passed, and it carries no guard
98
+ * file; a change that differs, or a guard file, answers with a note for the question.
99
+ */
100
+ const approvesChange = (
101
+ changes: readonly ApprovedChange[],
102
+ requiredChecks: readonly string[] | undefined,
103
+ facts: MergeFacts,
104
+ ): boolean | MergeNote => {
105
+ const named = changes.filter(({ branch }) => branch === facts.branch)
106
+ if (named.length === 0 || !checksPassed(facts.view, requiredChecks)) return false
107
+ const id = facts.change()
108
+ if (!named.some(({ change }) => isSameChange(change, id))) return changedSinceApproval(facts.branch, named[0].change)
109
+ const guarded = guardFilesOf(facts.files())
110
+ return guarded?.length === 0 || carriesGuardFiles(guarded)
61
111
  }
62
112
 
63
113
  /**
64
- * The user approved the branch and its checks passed, every file needs no checks, or a standing approval covers the
65
- * plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a push after the check
66
- * is not merged unchecked. A standing approval that does not cover a file answers with a note for the question.
114
+ * The user approved the branch with this change and its checks passed, every file needs no checks, or a standing
115
+ * approval covers the plan of the branch's lane; each only for a merge bound to the head commit GitHub reports, so a
116
+ * push after the check is not merged unchecked. An approval that does not cover the merge answers with a note.
67
117
  */
68
118
  const approvesMerge = (
69
- { branches, requiredChecks, withoutChecks, alwaysAsk, standing }: MergeApprovals,
119
+ { changes, requiredChecks, withoutChecks, alwaysAsk, standing }: MergeApprovals,
70
120
  merge: PlainMerge,
71
121
  facts: MergeFacts,
72
122
  ): boolean | MergeNote => {
73
123
  const { headCommit } = facts
74
- if (headCommit === undefined || merge.headCommit !== headCommit) return false
75
- if (branches.has(facts.branch) && checksPassed(facts.view, requiredChecks)) return true
124
+ if (headCommit === undefined) return false
125
+ if (merge.headCommit !== headCommit) return unboundMergeOf(merge, changes, facts.branch)
126
+ const selfMerge = approvesChange(changes, requiredChecks, facts)
127
+ if (selfMerge === true) return true
76
128
  if (withoutChecks.length > 0 && isCheckFree(facts.files() ?? [], withoutChecks, alwaysAsk)) return true
77
- return standing === undefined ? false : standing(facts)
129
+ const verdict = standing === undefined ? false : standing(facts)
130
+ return verdict === false ? selfMerge : verdict
78
131
  }
79
132
 
133
+ /** The transcript of the session that merges, one parsed entry per line; none without one. */
134
+ const transcriptEntries = (transcriptPath: unknown): unknown[] =>
135
+ typeof transcriptPath === 'string' ? parseTranscript(readFileSync(transcriptPath, 'utf8')) : []
136
+
80
137
  /**
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.
138
+ * A lookup about the pull request, kept only while its head is still the commit `gh pr view` named: a push between
139
+ * the calls would answer for a commit the merge does not name.
83
140
  */
84
- const filesAtHead = (pullRequest: number, view: string, directory: string): (() => readonly string[] | undefined) => {
85
- const facts = parseRepositoryFacts(view)
141
+ const atHead = <T>(pullRequest: number, view: string, directory: string, lookup: () => T | undefined): (() => T | undefined) => {
86
142
  const headCommit = parseHeadCommit(view)
87
143
  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
144
+ if (headCommit === undefined) return undefined
145
+ const value = lookup()
146
+ const again = value === undefined ? undefined : parseHeadCommit(viewPullRequest(pullRequest, directory) ?? '')
147
+ return again === headCommit ? value : undefined
92
148
  })
93
149
  }
94
150
 
151
+ const filesAtHead = (pullRequest: number, view: string, directory: string): (() => readonly string[] | undefined) => {
152
+ const facts = parseRepositoryFacts(view)
153
+ return atHead(pullRequest, view, directory, () => (facts === undefined ? undefined : changedFilesOf(pullRequest, directory, facts)))
154
+ }
155
+
95
156
  /** The facts of the pull request from its `gh pr view`; undefined unless it is open and from this repository. */
96
157
  const mergeFactsOf = (pullRequest: number, view: string, directory: string): MergeFacts | undefined => {
97
158
  const branch = parseHeadBranch(view)
@@ -102,6 +163,7 @@ const mergeFactsOf = (pullRequest: number, view: string, directory: string): Mer
102
163
  headCommit: parseHeadCommit(view),
103
164
  view,
104
165
  files: filesAtHead(pullRequest, view, directory),
166
+ change: atHead(pullRequest, view, directory, () => changeIdOf(pullRequest, directory)),
105
167
  }
106
168
  }
107
169
 
@@ -131,26 +193,46 @@ const standingApprovalFor = (entries: unknown[], labels: StandingLabels, project
131
193
  }
132
194
  }
133
195
 
196
+ /**
197
+ * Whether an agent in this directory runs in a linked worktree, a lane's place: its own git directory is not the
198
+ * repository's common one. A main checkout, where the coordinator runs, a directory outside a repository and any
199
+ * doubt answer no, a git that answers no single absolute path too (before 2.31 it echoes `--path-format`).
200
+ */
201
+ const runsInWorktree = (agentDirectory: string | undefined): boolean => {
202
+ if (agentDirectory === undefined) return false
203
+ try {
204
+ const gitDirectory = (option: string): string =>
205
+ commandOutput(['git', 'rev-parse', '--path-format=absolute', option], agentDirectory)
206
+ const own = gitDirectory('--absolute-git-dir')
207
+ const common = gitDirectory('--git-common-dir')
208
+ return [own, common].every((path) => isAbsolute(path) && !path.includes('\n')) && own !== common
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
134
214
  const policyFor = (input: HookInput, labels: ApprovalLabels, projectDirectory: string): GuardPolicy => {
135
215
  const settings = readGuardSettings(projectDirectory)
136
216
  const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
137
217
  const entries = once(() => transcriptEntries(input.transcript_path))
138
- const branches = once(() => approvedBranchesFrom(entries(), settings, labels.selfMerge))
218
+ const changes = once(() => approvedChangesFrom(entries(), settings, labels.selfMerge))
139
219
  const standing = once(() => standingApprovalFor(entries(), labels.standing, projectDirectory))
140
220
  return {
141
221
  commands: settings.commands,
142
222
  shellDefinitions: once(() => readShellDefinitions()),
223
+ isPaneSafe: (pane) => cannotBeHerd(herdrForeground(pane, directory)),
224
+ isAgentSafe: (target) => runsInWorktree(herdrAgentDirectory(target, directory)),
143
225
  isMergeApproved: (merge) => {
144
226
  // the same branch name in another checkout is another pull request
145
227
  if (!isWithinDirectory(directory, projectDirectory)) return false
146
228
  const approvals = {
147
- branches: branches(),
229
+ changes: changes(),
148
230
  requiredChecks: settings.requiredChecks,
149
231
  withoutChecks: settings.withoutChecks,
150
232
  alwaysAsk: [...ALWAYS_ASK_PATHS, ...settings.alwaysAsk],
151
233
  standing: standing(),
152
234
  }
153
- if (approvals.branches.size === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
235
+ if (approvals.changes.length === 0 && approvals.withoutChecks.length === 0 && approvals.standing === undefined) return false
154
236
  // The branch lookup also proves the pull request is open, no draft and from this repository; every path needs that.
155
237
  const facts = mergeFactsOf(merge.pullRequest, viewPullRequest(merge.pullRequest, directory) ?? '', directory)
156
238
  return facts !== undefined && approvesMerge(approvals, merge, facts)
@@ -8,14 +8,18 @@
8
8
  * that plan, and never with a file of `STANDING_ALWAYS_ASK`. Every doubt answers "no", so the guard asks as before.
9
9
  */
10
10
  import type { DialogAnswer } from '../shared/dialog-answer'
11
- import { MS_PER_MINUTE } from '../shared/fresh'
12
- import { newestVerdict } from '../shared/newest-verdict'
11
+ import {
12
+ type StandingKind,
13
+ type StandingLabels,
14
+ type StandingMark,
15
+ standingGrants as grantsOfKind,
16
+ standingMarkOf as markOfKind,
17
+ } from '../shared/standing-mark'
13
18
  import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from './always-ask'
14
19
  import type { MergeNote } from './decision'
15
20
  import { isPlainPath } from './plain-path'
16
21
 
17
- /** The exact option label that approves, and the word before the end in the question, both per response language. */
18
- export type StandingLabels = { approve: string; until: string }
22
+ export type { StandingLabels } from '../shared/standing-mark'
19
23
 
20
24
  /** A plan whose lanes merge without a question until `until`, in milliseconds. */
21
25
  export type StandingGrant = { plan: string; until: number }
@@ -39,92 +43,25 @@ export type StandingApprovalQuery = {
39
43
  settingsBaseBranch: string
40
44
  }
41
45
 
42
- export const MAX_STANDING_MINUTES = 24 * 60
43
-
44
46
  const PLAN_PATH = /^[A-Za-z0-9_./-]+\.md$/
45
47
 
46
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
47
-
48
48
  /** A plan path as a board links it: a relative markdown file, no empty, `.` or `..` segment. */
49
49
  const isPlanPath = (path: string): boolean =>
50
50
  PLAN_PATH.test(path) && path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..')
51
51
 
52
- /** A local moment from its parts; undefined for one the calendar or the clock does not have (Feb 30, 24:00, a DST gap). */
53
- const localMoment = ([year, month, day, hour, minute]: number[]): number | undefined => {
54
- const date = new Date(year, month - 1, day, hour, minute)
55
- const exact =
56
- date.getFullYear() === year &&
57
- date.getMonth() === month - 1 &&
58
- date.getDate() === day &&
59
- date.getHours() === hour &&
60
- date.getMinutes() === minute
61
- return exact ? date.getTime() : undefined
62
- }
52
+ const PLAN_KIND: StandingKind = { key: 'Plan', isSubject: isPlanPath }
53
+
54
+ const grantOfMark = ({ subject, until }: StandingMark): StandingGrant => ({ plan: subject, until })
63
55
 
64
56
  /** The plan and the end a question names as ``Plan `<path>` <until> YYYY-MM-DD HH:MM``; none for none or several marks. */
65
57
  export const standingMarkOf = (question: string, until: string): StandingGrant | undefined => {
66
- if (until === '') return undefined
67
- const mark = new RegExp(`\\bPlan \`([^\`]*)\` ${escapeRegExp(until)} (\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2})(?!\\d)`, 'g')
68
- const marks = [...question.matchAll(mark)]
69
- if (marks.length !== 1) return undefined
70
- const [, plan, ...parts] = marks[0]
71
- const end = localMoment(parts.map(Number))
72
- return end !== undefined && isPlanPath(plan) ? { plan, until: end } : undefined
73
- }
74
-
75
- const PLAN_MENTION = /\bPlan `([^`]*)`/g
76
-
77
- /** The one plan a question names as ``Plan `<path>` ``, with or without an end; none for none or several. */
78
- const namedPlanOf = (question: string): string | undefined => {
79
- const plans = new Set([...question.matchAll(PLAN_MENTION)].map(([, plan]) => plan))
80
- const [only] = plans
81
- return plans.size === 1 && isPlanPath(only) ? only : undefined
82
- }
83
-
84
- /**
85
- * The plan an answer speaks about, and the end its question names; `NaN` for a question that names the plan without
86
- * an end, which never approves but, answered with anything else, ends the approval: in doubt it ends.
87
- */
88
- const markOf = (question: string, until: string): StandingGrant | undefined => {
89
- const mark = standingMarkOf(question, until)
90
- if (mark !== undefined) return mark
91
- const plan = namedPlanOf(question)
92
- return plan === undefined ? undefined : { plan, until: Number.NaN }
93
- }
94
-
95
- type MarkedAnswer = DialogAnswer & { mark: StandingGrant }
96
-
97
- const approves = ({ answer, offered, answeredAt, mark }: MarkedAnswer, label: string): boolean =>
98
- label !== '' &&
99
- answer === label &&
100
- offered.includes(label) &&
101
- mark.until > answeredAt &&
102
- mark.until - answeredAt <= MAX_STANDING_MINUTES * MS_PER_MINUTE
103
-
104
- /** The approval the newest answers about one plan give: all of them approve, and the earliest end is still ahead. */
105
- const grantOf = (plan: string, answers: readonly MarkedAnswer[], label: string, now: number): StandingGrant[] => {
106
- const verdict = newestVerdict(answers, now)
107
- if (verdict.length === 0 || !verdict.every((answer) => approves(answer, label))) return []
108
- const until = Math.min(...verdict.map(({ mark }) => mark.until))
109
- return until > now ? [{ plan, until }] : []
58
+ const mark = markOfKind(question, PLAN_KIND, until)
59
+ return mark === undefined ? undefined : grantOfMark(mark)
110
60
  }
111
61
 
112
62
  /** The plans the user approved standing in these dialog answers, and until when; only those still holding at `now`. */
113
- export const standingGrants = (answers: readonly DialogAnswer[], labels: StandingLabels, now: number): StandingGrant[] => {
114
- const marked = answers.flatMap((answer): MarkedAnswer[] => {
115
- const mark = markOf(answer.question, labels.until)
116
- return mark === undefined ? [] : [{ ...answer, mark }]
117
- })
118
- const plans = [...new Set(marked.map(({ mark }) => mark.plan))]
119
- return plans.flatMap((plan) =>
120
- grantOf(
121
- plan,
122
- marked.filter(({ mark }) => mark.plan === plan),
123
- labels.approve,
124
- now,
125
- ),
126
- )
127
- }
63
+ export const standingGrants = (answers: readonly DialogAnswer[], labels: StandingLabels, now: number): StandingGrant[] =>
64
+ grantsOfKind(answers, PLAN_KIND, labels, now).map(grantOfMark)
128
65
 
129
66
  const exceptedNote = (plan: string, files: readonly string[]): MergeNote => ({
130
67
  note: `The standing approval of plan \`${plan}\` does not cover ${files.map((file) => `\`${file}\``).join(', ')}: workflows, migrations, env files and the guards' own files always ask.`,
@@ -0,0 +1,89 @@
1
+ import { questionsOf, recordsOf } from './run'
2
+
3
+ /** The coordinator's mark on the header chip of every question it asks the user. */
4
+ const PAW = '🐾'
5
+ const EXAMPLE_HEADER = 'Merge'
6
+ const QUESTION_LENGTH = 80
7
+
8
+ export const MAX_QUESTION_WORDS = 15
9
+ export const MAX_LABEL_WORDS = 5
10
+ /** The words of an option's description: one sentence of about 12, with room for a clause. */
11
+ export const MAX_DESCRIPTION_WORDS = 15
12
+ const DESCRIPTION_SENTENCE_WORDS = 12
13
+ /** The most the dialog tool takes in a header chip. */
14
+ const MAX_HEADER_LENGTH = 12
15
+
16
+ /** Code in backticks, a flag, a path with an extension or with two slashes; a slash between two words is none. */
17
+ const PATH_OR_FLAG = /`|(?:^|\s)--[a-z]|\S+\/\S+\.\w+|\S+\/\S+\/\S+/
18
+ /** A link stays in the question: the user decides in the dialog, the link of a draft under review included. */
19
+ const LINK = /https?:\/\/\S+/g
20
+
21
+ const wordsIn = (text: string): number => text.split(/\s+/).filter(Boolean).length
22
+
23
+ const shorten = (text: string): string =>
24
+ text.length > QUESTION_LENGTH ? `${text.slice(0, QUESTION_LENGTH - 1)}…` : text
25
+
26
+ const labelsOf = (options: unknown): string[] =>
27
+ recordsOf(options).flatMap((option) => (typeof option.label === 'string' ? [option.label] : []))
28
+
29
+ const textOf = (value: unknown): string => (typeof value === 'string' ? value : '')
30
+
31
+ const headerReason = (header: string, name: string): string | null =>
32
+ header.trimStart().startsWith(PAW)
33
+ ? null
34
+ : `${name}: start the header chip with ${PAW}, as in "${PAW} ${header.trim() || EXAMPLE_HEADER}" (${MAX_HEADER_LENGTH} characters at most).`
35
+
36
+ const questionLengthReason = (text: string, name: string): string | null =>
37
+ wordsIn(text) <= MAX_QUESTION_WORDS
38
+ ? null
39
+ : `${name}: ask it in ${MAX_QUESTION_WORDS} words at most; the context goes in the chat before the dialog (§ 4, Short questions).`
40
+
41
+ const pathReason = (text: string, name: string): string | null =>
42
+ PATH_OR_FLAG.test(text.replace(LINK, ''))
43
+ ? `${name}: no path, flag or code in the question, say it in everyday words and put the detail in the chat before the dialog.`
44
+ : null
45
+
46
+ const descriptionReason = (options: unknown, name: string): string | null => {
47
+ const long = recordsOf(options).find((option) => wordsIn(textOf(option.description)) > MAX_DESCRIPTION_WORDS)
48
+ return long === undefined
49
+ ? null
50
+ : `${name}: option "${textOf(long.label)}": describe it in one sentence of about ${DESCRIPTION_SENTENCE_WORDS} words.`
51
+ }
52
+
53
+ const labelLengthReason = (labels: readonly string[], name: string): string | null => {
54
+ const long = labels.find((label) => wordsIn(label) > MAX_LABEL_WORDS)
55
+ return long === undefined
56
+ ? null
57
+ : `${name}: option "${long}": ${MAX_LABEL_WORDS} words at most; the rest goes in its description.`
58
+ }
59
+
60
+ /**
61
+ * A question that offers a label a guard reads word for word keeps the branch and plan marks and the self-merge label
62
+ * as written, so only the paw is required of it.
63
+ */
64
+ const questionReasons = (question: Record<string, unknown>, guardedLabels: readonly string[]): string[] => {
65
+ const text = textOf(question.question)
66
+ const name = `Question "${shorten(text)}"`
67
+ const labels = labelsOf(question.options)
68
+ const header = headerReason(textOf(question.header), name)
69
+ const lengths = labels.some((label) => guardedLabels.includes(label))
70
+ ? []
71
+ : [
72
+ questionLengthReason(text, name),
73
+ pathReason(text, name),
74
+ labelLengthReason(labels, name),
75
+ descriptionReason(question.options, name),
76
+ ]
77
+ return [header, ...lengths].filter((reason): reason is string => reason !== null)
78
+ }
79
+
80
+ /**
81
+ * The reason to send a coordinator's dialog back because a question does not keep the form: the paw at the start of
82
+ * the header chip, at most `MAX_QUESTION_WORDS` words in the question and no path, flag or code in it, at most
83
+ * `MAX_LABEL_WORDS` in a label and `MAX_DESCRIPTION_WORDS` in a description. Every miss of every question is named, so
84
+ * one send-back is enough. Null when every question keeps the form.
85
+ */
86
+ export const formReasonOf = (toolInput: unknown, guardedLabels: readonly string[]): string | null => {
87
+ const reasons = questionsOf(toolInput).flatMap((question) => questionReasons(question, guardedLabels))
88
+ return reasons.length === 0 ? null : reasons.join(' ')
89
+ }
@@ -2,18 +2,24 @@
2
2
  // guard. A dialog that shows a plan code bare (`F9` instead of "the release check (F9)") is denied with one line of
3
3
  // reason, so the question is asked again in the user's words. The codes are the ones the plans linked from the boards
4
4
  // of the main checkout define, in the main checkout and in the worktrees of the lanes the boards name, else a fallback
5
- // pattern; a cache keyed by each file's time and size spares reading the unchanged ones. Anything else, and any
6
- // failure, answers nothing, so the dialog opens.
7
- import { statSync } from 'node:fs'
5
+ // pattern; a cache keyed by each file's time and size spares reading the unchanged ones. A dialog whose question
6
+ // lacks its recommendation as the first option, marked with argv[2], is denied as well, and so is a coordinator's
7
+ // question that breaks the form of `form.ts` (🐾 header, short question and labels); argv[3…] are the labels a
8
+ // guard reads word for word, which carry no mark. Anything else, and any failure, answers nothing, so the dialog opens.
9
+ import { existsSync, statSync } from 'node:fs'
8
10
  import { join } from 'node:path'
9
11
  import { boardLocationOf, boardsIn } from '../shared/boards'
10
12
  import { mainCheckoutOf } from '../shared/checkout'
11
13
  import { readConfig } from '../shared/config'
12
14
  import { readTextOrNull } from '../shared/files'
13
15
  import { isRecord } from '../shared/json'
16
+ import { isCoordinatorAgent } from '../shared/lane'
17
+ import { ownPaneAgent } from '../shared/own-pane'
14
18
  import { planCodesOf } from '../shared/plan-codes'
15
19
  import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
16
20
  import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
21
+ import { formReasonOf } from './form'
22
+ import { type RecommendationWords, recommendationReasonOf } from './recommendation'
17
23
  import { denyReasonOf, dialogTextsOf, planPathsOfBoards, planRootsOfBoards } from './run'
18
24
 
19
25
  const HOOK_EVENT = 'PreToolUse'
@@ -27,6 +33,12 @@ const statOf = (path: string): PlanFileStat | null => {
27
33
  return stat?.isFile() ? { path, mtimeMs: stat.mtimeMs, size: stat.size } : null
28
34
  }
29
35
 
36
+ /** argv[2] is the recommended mark, argv[3…] the labels a guard reads word for word; an older install passes none. */
37
+ const recommendationWordsOf = (argv: readonly string[]): RecommendationWords => ({
38
+ mark: argv[2] ?? '',
39
+ guardedLabels: argv.slice(3),
40
+ })
41
+
30
42
  /** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
31
43
  const saveCache = (main: string, cache: CodeCache): void => {
32
44
  try {
@@ -59,12 +71,40 @@ const planCodesFor = (cwd: string): Set<string> | null => {
59
71
  }
60
72
  }
61
73
 
74
+ /** Whether the dialog is the coordinator's: the form (paw, short question and labels) binds nobody else. */
75
+ const isCoordinatorCall = (cwd: string, session: unknown): boolean => {
76
+ try {
77
+ const main = mainCheckoutOf(cwd)
78
+ const location = boardLocationOf(readConfig([main]))
79
+ if (location === null) return false
80
+ const { worktreeDirectory } = location
81
+ return isCoordinatorAgent({
82
+ agent: ownPaneAgent(),
83
+ session,
84
+ main,
85
+ isLane: (name) => existsSync(join(main, worktreeDirectory, name)),
86
+ })
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ const formReasonFor = (cwd: string, session: unknown, toolInput: unknown, guardedLabels: readonly string[]): string | null =>
93
+ isCoordinatorCall(cwd, session) ? formReasonOf(toolInput, guardedLabels) : null
94
+
62
95
  const decide = async (): Promise<string | null> => {
63
96
  try {
64
97
  const input: unknown = JSON.parse(await Bun.stdin.text())
65
98
  if (!isRecord(input) || typeof input.cwd !== 'string') return null
66
99
  const texts = dialogTextsOf(input.tool_input)
67
- return texts.length === 0 ? null : denyReasonOf(texts, planCodesFor(input.cwd))
100
+ if (texts.length === 0) return null
101
+ const words = recommendationWordsOf(process.argv)
102
+ const reasons = [
103
+ denyReasonOf(texts, planCodesFor(input.cwd)),
104
+ recommendationReasonOf(input.tool_input, words),
105
+ formReasonFor(input.cwd, input.session_id, input.tool_input, words.guardedLabels),
106
+ ].filter((reason): reason is string => reason !== null)
107
+ return reasons.length === 0 ? null : reasons.join(' ')
68
108
  } catch {
69
109
  return null
70
110
  }