jackherd 1.16.0 → 1.18.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 (158) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +29 -33
  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 +423 -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 +16 -22
  40. package/kit/rules/rules/kit/code-style.md +26 -54
  41. package/kit/rules/rules/kit/pull-requests.md +17 -40
  42. package/kit/rules/rules/kit/testing.md +6 -12
  43. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  44. package/kit/workflow/skills/follow/SKILL.md +2 -3
  45. package/kit/workflow/skills/implement/SKILL.md +28 -43
  46. package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
  47. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  48. package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
  49. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  50. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  51. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  52. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  53. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  54. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  55. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  56. package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
  57. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  58. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  59. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  60. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  61. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  62. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  63. package/kit/workflow/skills/plan/SKILL.md +29 -48
  64. package/package.json +2 -1
  65. package/recipes/README.md +1 -0
  66. package/recipes/jira-cli.md +1 -1
  67. package/recipes/sources/google-calendar-today.ts +8 -2
  68. package/recipes/sources/jira.ts +10 -2
  69. package/recipes/sources/linear.ts +9 -3
  70. package/recipes/sources/sentry.ts +8 -2
  71. package/recipes/sources.md +4 -2
  72. package/schemas/jackherd.schema.json +67 -0
  73. package/schemas/sources.schema.json +9 -0
  74. package/src/approval/checked.ts +60 -0
  75. package/src/approval/command.ts +16 -0
  76. package/src/check/command.ts +51 -6
  77. package/src/check/lint-pointers.ts +79 -0
  78. package/src/check/lint-shaped.ts +196 -0
  79. package/src/check/plan-structure-check.ts +116 -4
  80. package/src/check/report.ts +9 -3
  81. package/src/check/schema.ts +37 -0
  82. package/src/check/settings.ts +4 -0
  83. package/src/check/skill-budget-defaults.ts +90 -32
  84. package/src/check/skill-budgets.ts +96 -18
  85. package/src/cli.ts +2 -0
  86. package/src/config/personal-sources.ts +46 -10
  87. package/src/config/repository-identity.ts +40 -0
  88. package/src/config/schema.ts +53 -1
  89. package/src/context/command.ts +33 -2
  90. package/src/context/options.ts +33 -8
  91. package/src/context/usage-logs.ts +53 -0
  92. package/src/context/usage.ts +110 -0
  93. package/src/lane/base-command.ts +35 -0
  94. package/src/lane/base.ts +43 -0
  95. package/src/lane/close-check.ts +49 -15
  96. package/src/lane/close-command.ts +71 -11
  97. package/src/lane/close-options.ts +29 -8
  98. package/src/lane/close-survey.ts +13 -1
  99. package/src/lane/close.ts +51 -12
  100. package/src/lane/command.ts +21 -3
  101. package/src/lane/send-command.ts +32 -0
  102. package/src/lane/send-herdr.ts +77 -0
  103. package/src/lane/send-options.ts +23 -0
  104. package/src/lane/send.ts +90 -0
  105. package/src/lane/skill-hint.ts +27 -0
  106. package/src/lane/space-command.ts +30 -0
  107. package/src/lane/space.ts +49 -0
  108. package/src/lane/start-lane-hint.ts +18 -0
  109. package/src/plan-contract/base-check-git.ts +33 -0
  110. package/src/plan-contract/base-check.ts +17 -0
  111. package/src/plan-contract/command.ts +25 -4
  112. package/src/plan-format/plan-structure.ts +17 -5
  113. package/src/plans/ask-coordinator.ts +23 -13
  114. package/src/plans/board-rows.ts +9 -4
  115. package/src/plans/click-hint.ts +4 -0
  116. package/src/plans/command.ts +6 -5
  117. package/src/plans/ensure-panes.ts +14 -3
  118. package/src/plans/git.ts +14 -0
  119. package/src/plans/github.ts +19 -10
  120. package/src/plans/layout.ts +82 -33
  121. package/src/plans/live.ts +8 -12
  122. package/src/plans/plan-panes.ts +21 -4
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/resume/command.ts +213 -0
  126. package/src/resume/decision.ts +102 -0
  127. package/src/resume/keeper.ts +65 -0
  128. package/src/resume/location.ts +22 -0
  129. package/src/resume/log.ts +27 -0
  130. package/src/resume/ports.ts +102 -0
  131. package/src/resume/record.ts +116 -0
  132. package/src/resume/targets.ts +84 -0
  133. package/src/resume/texts.ts +27 -0
  134. package/src/secrets/command.ts +15 -5
  135. package/src/sync/herdr-plugin.ts +2 -11
  136. package/src/sync/plan-ask-rules.ts +11 -0
  137. package/src/sync/render.ts +3 -1
  138. package/src/vocabulary/index.ts +13 -0
  139. package/src/waiting/command.ts +13 -4
  140. package/src/watch/command.ts +177 -78
  141. package/src/watch/config-reload.ts +119 -0
  142. package/src/watch/lane-model.ts +11 -1
  143. package/src/watch/lane-progress.ts +70 -8
  144. package/src/watch/lane-report.ts +66 -11
  145. package/src/watch/main-checkout.ts +5 -1
  146. package/src/watch/merge-duties.ts +33 -0
  147. package/src/watch/merge-overlap-read.ts +69 -0
  148. package/src/watch/plan-views.ts +16 -4
  149. package/src/watch/reference-paths.ts +46 -5
  150. package/src/watch/report-questions.ts +5 -2
  151. package/src/watch/single-report.ts +3 -2
  152. package/src/watch/source-dry-run-command.ts +22 -4
  153. package/src/watch/source-reason.ts +58 -0
  154. package/src/watch/source-reload.ts +21 -17
  155. package/src/watch/source-run.ts +24 -7
  156. package/src/watch/sources.ts +9 -2
  157. package/src/watch/texts.ts +14 -3
  158. package/src/watch/waiting-line.ts +4 -3
@@ -2,6 +2,7 @@ import { FULL_COMMIT } from '../shared/commit'
2
2
  import { inlineScript, isOption, isProgramPosition, programName } from '../shared/program-position'
3
3
  import {
4
4
  expandingHereDocumentAdvice,
5
+ expandsBraces,
5
6
  isExpanded,
6
7
  parseShell,
7
8
  ShellParseError,
@@ -17,6 +18,15 @@ import {
17
18
  shellDefinitionHint,
18
19
  } from '../shared/shell-aliases'
19
20
  import { GLOB, resolvePlainXargs, XARGS } from '../shared/xargs-program'
21
+ import {
22
+ AGENT_INPUT_COMMANDS,
23
+ AGENT_TARGET,
24
+ HERD_WORDS,
25
+ LIFTABLE_PANE_INPUT,
26
+ OTHER_SERVER_OPTIONS,
27
+ PANE_ID,
28
+ PANE_INPUT_COMMANDS,
29
+ } from './pane-input'
20
30
 
21
31
  export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
22
32
 
@@ -33,27 +43,75 @@ export type GuardPolicy = {
33
43
  /** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
34
44
  commands: readonly string[]
35
45
  /**
36
- * Whether the merge of this pull request may pass without a question: the user approved its branch, a standing
37
- * approval covers the plan of its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
46
+ * Whether the merge of this pull request may pass without a question: the user approved its branch with the change
47
+ * id (patch id of its diff) the pull request still carries, a standing approval covers the plan of its lane, or
48
+ * every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
38
49
  * A throw counts as not approved; a note is not approved either, and the question carries it.
39
50
  */
40
51
  isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
52
+ /**
53
+ * Whether the pane a plain `herdr pane send-text|send-keys|run <pane id>` writes into cannot be the herd. Asked only
54
+ * for that plain form; absent or throwing, every input into a pane asks.
55
+ */
56
+ isPaneSafe?: (pane: string) => boolean
57
+ /**
58
+ * Whether the agent a plain `herdr agent send-keys|prompt <target>` writes into cannot be the coordinator, whose open
59
+ * dialog a key would answer. Absent or throwing, every input into an agent asks.
60
+ */
61
+ isAgentSafe?: (target: string) => boolean
41
62
  /** The aliases and functions of the user's shell, read only when a word at a program position may be one; none by default. */
42
63
  shellDefinitions?: () => ShellDefinitions
43
64
  }
44
65
 
45
66
  /**
46
67
  * A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
47
- * the shell alias or function that may run one (`definition`, whose name is then the `command`).
68
+ * the shell alias or function that may run one (`definition`, whose name is then the `command`). `sole` where it was
69
+ * the one command of the call, read as written by `gh`; `pane` the pane id an input into a pane names in its plain
70
+ * form, `input` the words it writes there; `agent` the target an input into an agent names in its plain form.
48
71
  */
49
- /** A guarded command found in the script; `sole` where it was the one command of the call, read as written by `gh`. */
50
- type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; sole?: boolean }
72
+ type Finding = {
73
+ command: string
74
+ merge: PlainMerge | undefined
75
+ definition?: ShellDefinitionHit
76
+ sole?: boolean
77
+ pane?: string
78
+ input?: string
79
+ agent?: string
80
+ }
51
81
 
52
82
  /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
53
83
  type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
54
84
 
55
- /** The parts of a `gh api` call that decide whether it writes, and where. */
56
- type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
85
+ /**
86
+ * The parts of a `gh api` call that decide whether it writes, and where: the method it names, whether a header
87
+ * overrides it, the endpoints, the values of its field options (`fields`), and whether a file or stdin gives the body
88
+ * (`input`). A field or an input makes the method POST unless one is named.
89
+ */
90
+ type ApiCall = {
91
+ method: Word | undefined
92
+ override: boolean
93
+ endpoints: Word[]
94
+ fields: Word[]
95
+ input: boolean
96
+ }
97
+
98
+ /**
99
+ * The parts of a `curl` call that decide whether it writes, and where: the method it names, whether it sends data
100
+ * (which `-G` turns into a query) or uploads (which `-G` leaves a body), whether a header overrides the method, whether
101
+ * a config file or an unreadable word may add any option, and the URLs it may reach.
102
+ */
103
+ type CurlCall = {
104
+ method: Word | undefined
105
+ data: boolean
106
+ upload: boolean
107
+ get: boolean
108
+ override: boolean
109
+ unknown: boolean
110
+ targets: Word[]
111
+ }
112
+
113
+ /** An option split into its name and the value attached to it, if any. */
114
+ type SplitOption = { name: string; attached: Word | undefined }
57
115
 
58
116
  /** The one command whose guard an approval can lift. Every other guarded command asks each time. */
59
117
  const MERGE_COMMAND = 'gh pr merge'
@@ -118,6 +176,82 @@ const DANGEROUS_ENDPOINTS = [
118
176
  /(^|\/)actions\/(runs|jobs)\/[^/]+\/(rerun|rerun-failed-jobs)\/?$/,
119
177
  /(^|\/)git\/refs(\/|$)/,
120
178
  ]
179
+ const GRAPHQL_ENDPOINT = /(^|\/)graphql\/?$/
180
+ const API_INPUT_OPTION = '--input'
181
+ /** The GraphQL mutations that do what a dangerous endpoint does: merge, commit, move a ref, rerun the checks. */
182
+ const DANGEROUS_MUTATIONS =
183
+ /\b(mergePullRequest|mergeBranch|enablePullRequestAutoMerge|enqueuePullRequest|createCommitOnBranch|createRef|updateRefs?|deleteRef|rerequestCheckSuite)\b/
184
+ /** A query field whose text is not in the command: read from a file (`@file`) or nothing but expansions. */
185
+ const UNKNOWN_QUERY = /^query=(@|(\$(\{[^}]*\}|\w+)?)+$)/
186
+
187
+ /**
188
+ * `curl` is guarded whatever `guard.commands` says, for a write against the GitHub API: it merges, commits or deploys
189
+ * as `gh api` does, and no project lists it. A read passes.
190
+ */
191
+ const CURL_COMMAND = 'curl'
192
+ const GITHUB_API_HOST = 'api.github.com'
193
+ const CURL_READ_METHODS = new Set(['GET', 'HEAD'])
194
+ const CURL_METHOD_OPTIONS = new Set(['-X', '--request'])
195
+ /** Options that send data: POST, unless `-G` turns the data into a query string. */
196
+ const CURL_DATA_OPTIONS = new Set([
197
+ '-d',
198
+ '--data',
199
+ '--data-ascii',
200
+ '--data-binary',
201
+ '--data-raw',
202
+ '--data-urlencode',
203
+ '--json',
204
+ ])
205
+ /** Options that send a body whatever `-G` says: a form is a POST, an upload a PUT. */
206
+ const CURL_UPLOAD_OPTIONS = new Set(['-F', '--form', '--form-string', '-T', '--upload-file'])
207
+ /** Options that turn the data into a query string, or ask for the head only. */
208
+ const CURL_GET_OPTIONS = new Set(['-G', '--get', '-I', '--head'])
209
+ /** A config file can name any option, a method and data among them. */
210
+ const CURL_CONFIG_OPTIONS = new Set(['-K', '--config'])
211
+ const CURL_HEADER_OPTIONS = new Set(['-H', '--header'])
212
+ const CURL_URL_OPTION = '--url'
213
+ /** `--expand-<option>` is `<option>` with variables expanded in its value (curl 8.3). */
214
+ const CURL_EXPAND_PREFIX = '--expand-'
215
+ /**
216
+ * Every command `decideBashCommand` guards whatever `guard.commands` says, each with its own way to pass:
217
+ * `isGuardedUse` for curl, `isPaneSafe` and `isAgentSafe` for the inputs into a pane or an agent.
218
+ */
219
+ const FIXED_COMMANDS: readonly string[] = [...PANE_INPUT_COMMANDS, ...AGENT_INPUT_COMMANDS, CURL_COMMAND]
220
+ /** The short options of curl that take a value: in a group (`-sXPUT`, `-sX PUT`) the rest of the group or the next word. */
221
+ const CURL_SHORT_WITH_VALUE = new Set('AbcCdDeEFHKmoPQrtTuUwxXyYz')
222
+ const CURL_LONG_WITH_VALUE = new Set([
223
+ ...CURL_METHOD_OPTIONS,
224
+ ...CURL_DATA_OPTIONS,
225
+ ...CURL_UPLOAD_OPTIONS,
226
+ ...CURL_CONFIG_OPTIONS,
227
+ ...CURL_HEADER_OPTIONS,
228
+ CURL_URL_OPTION,
229
+ '--user',
230
+ '--output',
231
+ '--cookie',
232
+ '--cookie-jar',
233
+ '--write-out',
234
+ '--max-time',
235
+ '--connect-timeout',
236
+ '--retry',
237
+ '--retry-delay',
238
+ '--retry-max-time',
239
+ '--user-agent',
240
+ '--referer',
241
+ '--oauth2-bearer',
242
+ '--variable',
243
+ '--proxy',
244
+ '--cacert',
245
+ '--cert',
246
+ '--key',
247
+ '--resolve',
248
+ '--connect-to',
249
+ '--limit-rate',
250
+ '--range',
251
+ '--dump-header',
252
+ '--interface',
253
+ ])
254
+
121
255
  /** What the fixed end of an endpoint after an expansion may be, when the expansion can stand for the rest of the path. */
122
256
  const DANGEROUS_ENDINGS = [
123
257
  /(^|\/)merges?\/?$/,
@@ -203,33 +337,57 @@ const isOpaque = ({ value }: Word): boolean => isExpanded(value) && value.replac
203
337
 
204
338
  const isReadMethod = ({ value }: Word): boolean => !isExpanded(value) && value.toUpperCase() === READ_METHOD
205
339
 
206
- /** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
207
- const apiOption = ({ value, literal }: Word): { name: string; attached: Word | undefined } => {
208
- if (value.startsWith('-X') && value.length > 2) return { name: '-X', attached: { value: value.slice(2), literal } }
340
+ /** A long option split at its first `=`: `--name=value`. */
341
+ const splitAttached = ({ value, literal }: Word): SplitOption => {
209
342
  const at = value.indexOf('=')
210
343
  if (at === -1 || !value.startsWith('--')) return { name: value, attached: undefined }
211
344
  return { name: value.slice(0, at), attached: { value: value.slice(at + 1), literal } }
212
345
  }
213
346
 
347
+ /** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
348
+ const apiOption = (word: Word): SplitOption =>
349
+ word.value.startsWith('-X') && word.value.length > 2
350
+ ? { name: '-X', attached: { value: word.value.slice(2), literal: word.literal } }
351
+ : splitAttached(word)
352
+
214
353
  /** A short body option with its value glued on, as in `-fbody=x`. */
215
354
  const isGluedBodyOption = (value: string): boolean => /^-[fF]./.test(value)
216
355
 
356
+ /** The value of a field option: attached with `=`, glued to `-f`, or the next word. */
357
+ const fieldValue = (name: string, attached: Word | undefined, next: Word | undefined, word: Word): Word | undefined => {
358
+ if (attached !== undefined) return attached
359
+ return isGluedBodyOption(name) ? { value: name.slice(2), literal: word.literal } : next
360
+ }
361
+
362
+ const isOverrideHeader = (header: Word | undefined): boolean =>
363
+ header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)
364
+
365
+ /** Adds what one option of `gh api` does to the call; `value` is its attached value or the next word. */
366
+ const readApiOption = (call: ApiCall, { name, attached }: SplitOption, word: Word, next: Word | undefined): void => {
367
+ const value = attached ?? next
368
+ if (API_METHOD_OPTIONS.has(name)) call.method = value
369
+ if (name === API_INPUT_OPTION) call.input = true
370
+ else if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) {
371
+ const field = fieldValue(name, attached, next, word)
372
+ if (field !== undefined) call.fields.push(field)
373
+ }
374
+ if (HEADER_OPTIONS.has(name) && isOverrideHeader(value)) call.override = true
375
+ }
376
+
377
+ const takesNextApiWord = ({ name, attached }: SplitOption): boolean =>
378
+ attached === undefined &&
379
+ (API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
380
+
217
381
  const readApiCall = (args: Word[]): ApiCall => {
218
- const call: ApiCall = { method: undefined, fields: false, override: false, endpoints: [] }
382
+ const call: ApiCall = { method: undefined, override: false, endpoints: [], fields: [], input: false }
219
383
  for (let index = 0; index < args.length; index++) {
220
384
  if (!isOption(args[index])) {
221
385
  call.endpoints.push(args[index])
222
386
  continue
223
387
  }
224
- const { name, attached } = apiOption(args[index])
225
- const takesNext =
226
- attached === undefined &&
227
- (API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
228
- if (API_METHOD_OPTIONS.has(name)) call.method = attached ?? args[index + 1]
229
- if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) call.fields = true
230
- const header = HEADER_OPTIONS.has(name) ? (attached ?? args[index + 1]) : undefined
231
- if (header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)) call.override = true
232
- if (takesNext) index++
388
+ const option = apiOption(args[index])
389
+ readApiOption(call, option, args[index], args[index + 1])
390
+ if (takesNextApiWord(option)) index++
233
391
  }
234
392
  return call
235
393
  }
@@ -263,9 +421,96 @@ const isDangerousEndpoint = ({ value }: Word): boolean => {
263
421
  */
264
422
  const writesDangerously = (args: Word[]): boolean => {
265
423
  if (args.some(isOpaque)) return true
266
- const { method, fields, override, endpoints } = readApiCall(args)
267
- const writes = override || (method === undefined ? fields : !isReadMethod(method))
268
- return writes && endpoints.some(isDangerousEndpoint)
424
+ const call = readApiCall(args)
425
+ const { method, fields, input, override, endpoints } = call
426
+ const writes = override || (method === undefined ? fields.length > 0 || input : !isReadMethod(method))
427
+ return writes && (endpoints.some(isDangerousEndpoint) || runsDangerousMutation(call))
428
+ }
429
+
430
+ /**
431
+ * Whether a call to the GraphQL endpoint may run a mutation that merges, commits, moves a ref or reruns: its query
432
+ * names one, or its text is not in the command (a file, stdin, an expansion). A query that only reads passes.
433
+ */
434
+ const runsDangerousMutation = ({ endpoints, fields, input }: ApiCall): boolean =>
435
+ endpoints.some(({ value }) => GRAPHQL_ENDPOINT.test(normalizedPath(value))) &&
436
+ (input || fields.some(({ value }) => UNKNOWN_QUERY.test(value) || DANGEROUS_MUTATIONS.test(value)))
437
+
438
+ const isCurlWriteMethod = ({ value }: Word): boolean =>
439
+ isExpanded(value) || !CURL_READ_METHODS.has(value.toUpperCase())
440
+
441
+ /** A curl option's name, with `--expand-<option>` read as `<option>`. */
442
+ const curlOptionName = (name: string): string =>
443
+ name.startsWith(CURL_EXPAND_PREFIX) ? `--${name.slice(CURL_EXPAND_PREFIX.length)}` : name
444
+
445
+ /** Adds what one option, with its value, does to the call. */
446
+ const readCurlOption = (call: CurlCall, name: string, value: Word | undefined): void => {
447
+ if (CURL_METHOD_OPTIONS.has(name)) call.method = value
448
+ if (CURL_DATA_OPTIONS.has(name)) call.data = true
449
+ if (CURL_UPLOAD_OPTIONS.has(name)) call.upload = true
450
+ if (CURL_GET_OPTIONS.has(name)) call.get = true
451
+ if (CURL_CONFIG_OPTIONS.has(name)) call.unknown = true
452
+ if (CURL_HEADER_OPTIONS.has(name) && isOverrideHeader(value)) call.override = true
453
+ if (name === CURL_URL_OPTION && value !== undefined) call.targets.push(value)
454
+ }
455
+
456
+ /** A group of short options (`-sX PUT`, `-sXPUT`, `-sd x`): the first one that takes a value ends it. */
457
+ const readShortGroup = (call: CurlCall, word: Word, next: Word | undefined): boolean => {
458
+ const letters = word.value.slice(1)
459
+ for (let at = 0; at < letters.length; at++) {
460
+ const name = `-${letters[at]}`
461
+ if (!CURL_SHORT_WITH_VALUE.has(letters[at])) {
462
+ readCurlOption(call, name, undefined)
463
+ continue
464
+ }
465
+ const rest = letters.slice(at + 1)
466
+ readCurlOption(call, name, rest === '' ? next : { value: rest, literal: word.literal })
467
+ return rest === ''
468
+ }
469
+ return false
470
+ }
471
+
472
+ /**
473
+ * Reads the words after `curl`. A word made only of expansions where an option may stand could be any of them, so it
474
+ * counts as unknown; the value of an option (`-H "$AUTH"`) cannot.
475
+ */
476
+ const readCurlCall = (args: Word[]): CurlCall => {
477
+ const call: CurlCall = { method: undefined, data: false, upload: false, get: false, override: false, unknown: false, targets: [] }
478
+ for (let index = 0; index < args.length; index++) {
479
+ const word = args[index]
480
+ if (!isOption(word)) {
481
+ if (isOpaque(word)) call.unknown = true
482
+ call.targets.push(word)
483
+ continue
484
+ }
485
+ if (!word.value.startsWith('--')) {
486
+ if (readShortGroup(call, word, args[index + 1])) index++
487
+ continue
488
+ }
489
+ const split = splitAttached(word)
490
+ const name = curlOptionName(split.name)
491
+ const takesNext = split.attached === undefined && CURL_LONG_WITH_VALUE.has(name)
492
+ readCurlOption(call, name, split.attached ?? (takesNext ? args[index + 1] : undefined))
493
+ if (takesNext) index++
494
+ }
495
+ return call
496
+ }
497
+
498
+ const mentionsGitHubApi = (text: string): boolean => text.toLowerCase().includes(GITHUB_API_HOST)
499
+
500
+ /** A URL on the GitHub API, or one whose expanded part may be it and whose fixed end is a dangerous endpoint. */
501
+ const mayBeGitHubApi = (word: Word): boolean =>
502
+ mentionsGitHubApi(word.value) || (isExpanded(word.value) && isDangerousEndpoint(word))
503
+
504
+ /**
505
+ * Whether `curl` writes to the GitHub API: a method other than GET or HEAD, named, set by an override header, or
506
+ * implied by an upload or by data that `-G` does not turn into a query; a config file or an unreadable word may add
507
+ * any of them.
508
+ */
509
+ const curlWritesGitHub = (args: Word[]): boolean => {
510
+ const { method, data, upload, get, override, unknown, targets } = readCurlCall(args)
511
+ const implied = upload || (data && !get)
512
+ const writes = unknown || override || (method === undefined ? implied : isCurlWriteMethod(method))
513
+ return writes && targets.some(mayBeGitHubApi)
269
514
  }
270
515
 
271
516
  /** The words after the first plain `api` behind the program at `index`; all of them when `api` came from an expansion. */
@@ -275,9 +520,15 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
275
520
  return at === -1 ? after : after.slice(at + 1)
276
521
  }
277
522
 
278
- /** Every guarded command at a program position counts, except a `gh api` call that does not write dangerously. */
279
- const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
280
- command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
523
+ /**
524
+ * Every guarded command at a program position counts, except a `gh api` call that does not write dangerously and a
525
+ * `curl` that does not write to the GitHub API.
526
+ */
527
+ const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean => {
528
+ if (command === API_COMMAND) return writesDangerously(apiArguments(words, programIndex))
529
+ if (command === CURL_COMMAND) return curlWritesGitHub(argumentsOf(words, programIndex))
530
+ return true
531
+ }
281
532
 
282
533
  const commandWords = (command: string): string[] => command.split(' ').slice(1)
283
534
 
@@ -322,10 +573,61 @@ const argumentsOf = (words: SimpleCommand, index: number): Word[] => {
322
573
  const namesCommandWords = (words: SimpleCommand, index: number, command: string): boolean =>
323
574
  operandLists(argumentsOf(words, index)).some((operands) => matchesFrom(operands, commandWords(command)))
324
575
 
576
+ const isOtherServerOption = ({ value }: Word): boolean =>
577
+ OTHER_SERVER_OPTIONS.some((option) => value === option || value.startsWith(`${option}=`))
578
+
579
+ /** A word the shell hands on as written: quoted or plain, without an expansion, a glob or a brace list. */
580
+ const isFixedWord = ({ value, literal }: Word): boolean =>
581
+ !isExpanded(value) && !(literal && GLOB.test(value)) && !expandsBraces(value)
582
+
583
+ /**
584
+ * The pane id of `herdr pane <verb> <pane id> …` written plainly: the first word of its command, the command words and
585
+ * the pane id unquoted, every word fixed, no option that points at another herdr server. Anything else names no pane
586
+ * the guard can look up.
587
+ */
588
+ const plainPaneTarget = (words: SimpleCommand, index: number, command: string): string | undefined => {
589
+ const expected = command.split(' ')
590
+ const target = words[expected.length]
591
+ const plain =
592
+ index === 0 &&
593
+ words.every((word) => isFixedWord(word) && !isOtherServerOption(word)) &&
594
+ words.slice(0, expected.length + 1).every((word) => word.literal) &&
595
+ expected.every((value, at) => words[at]?.value === value)
596
+ return plain && target !== undefined && PANE_ID.test(target.value) ? target.value : undefined
597
+ }
598
+
599
+ /**
600
+ * The target of `herdr agent <verb> <target> …` written plainly: the first word of its command, the command words and
601
+ * the target fixed and unquoted, no option that points at another herdr server. The keys or the prompt after it may
602
+ * be any text, since no shell runs them.
603
+ */
604
+ const plainAgentTarget = (words: SimpleCommand, index: number, command: string): string | undefined => {
605
+ const expected = command.split(' ')
606
+ const target = words[expected.length]
607
+ const plain =
608
+ index === 0 &&
609
+ !words.some(isOtherServerOption) &&
610
+ words.slice(0, expected.length + 1).every((word) => word.literal && isFixedWord(word)) &&
611
+ expected.every((value, at) => words[at]?.value === value)
612
+ return plain && target !== undefined && AGENT_TARGET.test(target.value) ? target.value : undefined
613
+ }
614
+
615
+ /** The words `herdr pane <verb> <pane id>` writes into the pane, as one line; read only with a plain target. */
616
+ const paneInputOf = (words: SimpleCommand, command: string): string =>
617
+ words
618
+ .slice(command.split(' ').length + 1)
619
+ .map(({ value }) => value)
620
+ .join(' ')
621
+
325
622
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
326
623
  const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
327
624
  if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
328
625
  if (!isGuardedUse(command, words, index)) return undefined
626
+ if (PANE_INPUT_COMMANDS.includes(command)) {
627
+ const pane = plainPaneTarget(words, index, command)
628
+ return { command, merge: undefined, pane, input: pane === undefined ? undefined : paneInputOf(words, command) }
629
+ }
630
+ if (AGENT_INPUT_COMMANDS.includes(command)) return { command, merge: undefined, agent: plainAgentTarget(words, index, command) }
329
631
  const plain = sole && words[index].literal && words[index].value === GH
330
632
  return { command, sole: plain, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
331
633
  }
@@ -336,7 +638,9 @@ const programsOf = (commands: readonly string[]): string[] => [
336
638
 
337
639
  /** A shell alias or function at the word that may run one of the guarded programs: it hides the words after them. */
338
640
  const definitionFinding = (words: SimpleCommand, index: number, { commands, definitions }: Search): Finding[] => {
339
- const definition = shellDefinitionAt(words, index, definitions, programsOf(commands))
641
+ // A definition hides the words after `curl`, so every alias around it would ask; the guard aims at an oversight (D108).
642
+ const programs = programsOf(commands).filter((program) => program !== CURL_COMMAND)
643
+ const definition = shellDefinitionAt(words, index, definitions, programs)
340
644
  return definition ? [{ command: definition.name, merge: undefined, definition }] : []
341
645
  }
342
646
 
@@ -346,6 +650,8 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
346
650
  */
347
651
  const expandedProgramFindings = (words: SimpleCommand, index: number, { commands }: Search): Finding[] =>
348
652
  commands
653
+ // curl has no command words of its own, so any program from a variable could be it; only a written curl counts
654
+ .filter((command) => command !== CURL_COMMAND)
349
655
  .filter((command) => namesCommandWords(words, index, command) && isGuardedUse(command, words, index))
350
656
  .map((command) => ({ command, merge: undefined }))
351
657
 
@@ -432,7 +738,56 @@ const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
432
738
  return parsed.flatMap((words) => callsInCommand(words, search, sole))
433
739
  }
434
740
 
435
- const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
741
+ /** Whether the policy calls this target safe; no target, no check or a throwing one answer no. */
742
+ const isSafeTarget = (target: string | undefined, isSafe: ((target: string) => boolean) | undefined): boolean => {
743
+ if (target === undefined || isSafe === undefined) return false
744
+ try {
745
+ return isSafe(target)
746
+ } catch {
747
+ return false
748
+ }
749
+ }
750
+
751
+ const isPaneInput = ({ command, definition }: Finding): boolean =>
752
+ definition === undefined && PANE_INPUT_COMMANDS.includes(command)
753
+
754
+ const isAgentInput = ({ command, definition }: Finding): boolean =>
755
+ definition === undefined && AGENT_INPUT_COMMANDS.includes(command)
756
+
757
+ /**
758
+ * The policy for these findings: with more than one input into a pane, none is lifted, since the lookup sees each
759
+ * pane before the script runs and an earlier input can start the herd a later one clicks into.
760
+ */
761
+ const policyLiftingOnePaneInput = (findings: readonly Finding[], policy: GuardPolicy): GuardPolicy =>
762
+ findings.filter(isPaneInput).length > 1 ? { ...policy, isPaneSafe: undefined } : policy
763
+
764
+ /**
765
+ * Whether the input into a pane without the herd asks itself: a shell there runs it where no hook sees it. Read as a
766
+ * script under a policy that lifts nothing, so a guarded command or another input into a pane asks; a herd word, an
767
+ * expansion or a text the parse cannot read asks too.
768
+ */
769
+ const inputAsks = (input: string, policy: GuardPolicy): boolean => {
770
+ // A second line could run an alias the first one defined, and a quoted alias value is not searched.
771
+ if (input.includes('\n')) return true
772
+ try {
773
+ const words = parseShell(input).flat()
774
+ if (words.some(({ value }) => isExpanded(value) || HERD_WORDS.includes(value))) return true
775
+ } catch {
776
+ return true
777
+ }
778
+ const liftingNothing: GuardPolicy = {
779
+ commands: policy.commands,
780
+ isMergeApproved: () => false,
781
+ shellDefinitions: policy.shellDefinitions,
782
+ }
783
+ return decideBashCommand(input, liftingNothing).kind === 'ask'
784
+ }
785
+
786
+ const approvalOf = (finding: Finding, policy: GuardPolicy): boolean | MergeNote => {
787
+ const { command, merge, pane, input = '' } = finding
788
+ if (isPaneInput(finding))
789
+ return command === LIFTABLE_PANE_INPUT && isSafeTarget(pane, policy.isPaneSafe) && !inputAsks(input, policy)
790
+ if (isAgentInput(finding)) return isSafeTarget(finding.agent, policy.isAgentSafe)
436
791
  if (command !== MERGE_COMMAND || merge === undefined) return false
437
792
  try {
438
793
  return policy.isMergeApproved(merge)
@@ -461,8 +816,29 @@ const soleMergeSentence = (findings: Finding[]): string[] => {
461
816
  return unchecked.length > 0 ? [PLAIN_MERGE_SENTENCE] : []
462
817
  }
463
818
 
819
+ /** Why an input into a pane asks: the herd takes its clicks from its input. */
820
+ const paneInputSentence = (commands: string[]): string[] =>
821
+ commands.length === 0
822
+ ? []
823
+ : [
824
+ `${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own.`,
825
+ ]
826
+
827
+ /** Why an input into an agent asks: a key there can answer the dialog the agent has open, as if the user clicked. */
828
+ const agentInputSentence = (commands: string[]): string[] =>
829
+ commands.length === 0
830
+ ? []
831
+ : [
832
+ `${commands.map((command) => `\`${command}\``).join(' and ')} writes into an agent and can answer a dialog open there as if the user clicked, so the user confirms it. It passes only into a plain target (\`herdr agent prompt <lane> …\` at the start of its command, the target fixed) whose agent herdr names and which runs in a worktree, not in a main checkout, where the coordinator runs.`,
833
+ ]
834
+
835
+ const CURL_SENTENCE = `A \`${CURL_COMMAND}\` that writes to the GitHub API can merge, commit or deploy past the merge guard, so the user confirms it. Merge with a plain \`${MERGE_COMMAND} <number>\`.`
836
+
464
837
  const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
465
- const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
838
+ const named = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
839
+ const guarded = named.filter((command) => !FIXED_COMMANDS.includes(command))
840
+ const paneInputs = named.filter((command) => PANE_INPUT_COMMANDS.includes(command))
841
+ const agentInputs = named.filter((command) => AGENT_INPUT_COMMANDS.includes(command))
466
842
  const definitions = [
467
843
  ...new Map(
468
844
  findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
@@ -472,6 +848,9 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
472
848
  kind: 'ask',
473
849
  reason: [
474
850
  ...guardedListSentence([...new Set(guarded)]),
851
+ ...paneInputSentence([...new Set(paneInputs)]),
852
+ ...agentInputSentence([...new Set(agentInputs)]),
853
+ ...(named.includes(CURL_COMMAND) ? [CURL_SENTENCE] : []),
475
854
  ...definitions.map(
476
855
  (definition) =>
477
856
  `${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
@@ -542,19 +921,28 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
542
921
  * shell's input, a here-document's data and what `xargs` reads are not searched, and a call that only reads (`--help`,
543
922
  * `-h`, `gh … view`, `gh … list`) passes. A script that is exactly `gh pr merge <number>` with merge-method and
544
923
  * delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
545
- * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A script the parse cannot
546
- * read asks only when a guarded program stands where a command starts.
924
+ * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. The inputs into a pane
925
+ * (`PANE_INPUT_COMMANDS`) are guarded whatever the list says, since the herd takes clicks from its input: a plain
926
+ * `herdr pane <verb> <pane id> …` passes when `isPaneSafe` says the pane cannot be the herd, every other shape asks.
927
+ * The inputs into an agent (`AGENT_INPUT_COMMANDS`) are guarded the same way: a plain `herdr agent <verb> <target> …`
928
+ * passes when `isAgentSafe` says the agent is not the coordinator. `curl` is guarded whatever the list says and asks
929
+ * only for a write to the GitHub API (a write method, a body, an upload or a config file); `gh api graphql` asks for
930
+ * a dangerous mutation (`DANGEROUS_MUTATIONS`) and for a query whose text the command does not show.
931
+ * A script the parse cannot read asks only when a guarded program stands where a command starts.
547
932
  */
548
933
  export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
549
- if (policy.commands.length === 0) return PASS
550
- const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
934
+ const commands = [...new Set([...policy.commands, ...FIXED_COMMANDS])]
935
+ const search = { commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
551
936
  const findings = findingsOrUnreadable(command, search)
552
937
  if (findings instanceof ShellParseError) {
553
- return namesGuardedProgram(command, policy.commands) ? askUnreadable(findings, command) : PASS
938
+ // unreadable text with a curl asks only where it names the GitHub API: a curl elsewhere is no merge
939
+ const searched = mentionsGitHubApi(command) ? commands : commands.filter((each) => each !== CURL_COMMAND)
940
+ return namesGuardedProgram(command, searched) ? askUnreadable(findings, command) : PASS
554
941
  }
555
942
  if (findings.length === 0) return PASS
943
+ const findingsPolicy = policyLiftingOnePaneInput(findings, policy)
556
944
  const unapproved = findings
557
- .map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
945
+ .map((finding) => ({ finding, approval: approvalOf(finding, findingsPolicy) }))
558
946
  .filter(({ approval }) => approval !== true)
559
947
  if (unapproved.length === 0) return PASS
560
948
  const notes = unapproved.flatMap(({ approval }) => (typeof approval === 'object' ? [approval.note] : []))
@@ -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)))