jackherd 1.8.1 → 1.10.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 (261) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/INSTALL.md +3 -1
  3. package/README.md +75 -64
  4. package/dist/{chunk-OU2KALXP.js → chunk-5MQBCUQ5.js} +24 -4
  5. package/dist/config.d.ts +45 -4
  6. package/dist/config.js +84 -27
  7. package/dist/plan-format.d.ts +63 -7
  8. package/dist/plan-format.js +66 -12
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  15. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  16. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  17. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  18. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  19. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  20. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  21. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  22. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  23. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  24. package/kit/hooks/hooks/merge-guard/decision.ts +127 -140
  25. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  26. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  27. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  28. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  29. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  30. package/kit/hooks/hooks/open-question/index.ts +26 -0
  31. package/kit/hooks/hooks/open-question/record.ts +135 -0
  32. package/kit/hooks/hooks/open-question/run.ts +52 -0
  33. package/kit/hooks/hooks/open-question/store.ts +39 -0
  34. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  35. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  36. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  37. package/kit/hooks/hooks/sent-file/store.ts +9 -0
  38. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  39. package/kit/hooks/hooks/shared/config.ts +7 -0
  40. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  41. package/kit/hooks/hooks/shared/files.ts +7 -0
  42. package/kit/hooks/hooks/shared/lane.ts +1 -1
  43. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  44. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  45. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  46. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  47. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  48. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  49. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  50. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  51. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  52. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  53. package/kit/hooks/hooks.json +21 -4
  54. package/kit/hooks/statusline.sh +1 -1
  55. package/kit/review/agents/skill-review.md +34 -0
  56. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  57. package/kit/workflow/skills/follow/SKILL.md +1 -8
  58. package/kit/workflow/skills/implement/SKILL.md +10 -6
  59. package/kit/workflow/skills/orchestrate/SKILL.md +213 -412
  60. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  61. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  62. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  63. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  64. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  65. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  66. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  67. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  68. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  69. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  70. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  71. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  72. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  73. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  74. package/kit/workflow/skills/plan/SKILL.md +6 -5
  75. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  76. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  77. package/package.json +4 -1
  78. package/recipes/README.md +2 -1
  79. package/recipes/sources.md +1 -1
  80. package/schemas/jackherd.schema.json +85 -7
  81. package/src/check/agent-text-reviewers.ts +23 -0
  82. package/src/check/command.ts +2 -0
  83. package/src/check/plan-structure-check.ts +49 -2
  84. package/src/check/plan.ts +8 -1
  85. package/src/check/schema.ts +9 -0
  86. package/src/check/skill-budget-defaults.ts +55 -0
  87. package/src/check/skill-budgets.ts +185 -0
  88. package/src/cli.ts +4 -0
  89. package/src/config/auto-window.ts +8 -0
  90. package/src/config/link-kinds.ts +7 -1
  91. package/src/config/schema.ts +80 -26
  92. package/src/context/fill.ts +12 -0
  93. package/src/context/session-log.ts +43 -1
  94. package/src/lane/close-board.ts +106 -0
  95. package/src/lane/close-check.ts +183 -0
  96. package/src/lane/close-command-line.ts +11 -0
  97. package/src/lane/close-command.ts +217 -0
  98. package/src/lane/close-options.ts +43 -0
  99. package/src/lane/close-survey.ts +83 -0
  100. package/src/lane/close.ts +141 -0
  101. package/src/lane/command.ts +13 -0
  102. package/src/lane-prompt/build.ts +74 -0
  103. package/src/lane-prompt/command.ts +87 -0
  104. package/src/lane-prompt/input.ts +116 -0
  105. package/src/lane-prompt/options.ts +68 -0
  106. package/src/lane-prompt/texts.ts +101 -0
  107. package/src/plan-format/clock.ts +12 -0
  108. package/src/plan-format/dependencies.ts +9 -9
  109. package/src/plan-format/index.ts +1 -0
  110. package/src/plan-format/lanes.ts +30 -5
  111. package/src/plan-format/short-names.ts +71 -0
  112. package/src/plan-format/terminal-text.ts +4 -0
  113. package/src/plan-format/waiting.ts +53 -0
  114. package/src/plans/active-tasks.ts +60 -0
  115. package/src/plans/appointment-rows.ts +59 -0
  116. package/src/plans/ask-coordinator.ts +77 -0
  117. package/src/plans/ask-target.ts +25 -0
  118. package/src/plans/child-stop.ts +51 -0
  119. package/src/plans/closable-records.ts +34 -0
  120. package/src/plans/command.ts +3 -2
  121. package/src/plans/current-phase.ts +17 -7
  122. package/src/plans/current-task.ts +63 -5
  123. package/src/plans/fetch.ts +26 -3
  124. package/src/plans/git.ts +19 -5
  125. package/src/plans/herd-claim.ts +173 -0
  126. package/src/plans/herd-memory.ts +36 -25
  127. package/src/plans/lane-plans.ts +44 -13
  128. package/src/plans/lane-standing.ts +9 -4
  129. package/src/plans/lane-summary.ts +367 -92
  130. package/src/plans/lane-tickets.ts +15 -0
  131. package/src/plans/layout-phase.ts +80 -19
  132. package/src/plans/layout-summary.ts +163 -74
  133. package/src/plans/layout.ts +472 -114
  134. package/src/plans/link-chips.ts +1 -0
  135. package/src/plans/live.ts +119 -10
  136. package/src/plans/phase-ids.ts +8 -0
  137. package/src/plans/phase-models.ts +1 -1
  138. package/src/plans/phase-ranges.ts +45 -0
  139. package/src/plans/phase-source.ts +15 -0
  140. package/src/plans/plan-cache.ts +28 -0
  141. package/src/plans/plan-heads.ts +54 -0
  142. package/src/plans/pull-requests.ts +23 -2
  143. package/src/plans/question-records.ts +48 -0
  144. package/src/plans/questions-read.ts +13 -0
  145. package/src/plans/read-cache.ts +8 -0
  146. package/src/plans/record-id.ts +4 -0
  147. package/src/plans/restart-decision.ts +13 -4
  148. package/src/plans/selection.ts +117 -34
  149. package/src/plans/sent-files-read.ts +13 -0
  150. package/src/plans/sent-files.ts +114 -0
  151. package/src/plans/settings.ts +2 -1
  152. package/src/plans/signalled-phase.ts +42 -0
  153. package/src/plans/signals-read.ts +62 -0
  154. package/src/plans/snapshot.ts +279 -60
  155. package/src/plans/sprint-export.ts +11 -11
  156. package/src/plans/sprint-page.ts +5 -5
  157. package/src/plans/subagent-phases.ts +95 -0
  158. package/src/plans/subagent-tasks.ts +51 -0
  159. package/src/plans/subagents-read.ts +131 -0
  160. package/src/plans/subagents.ts +190 -0
  161. package/src/plans/supervisor.ts +14 -4
  162. package/src/plans/symbols.ts +3 -0
  163. package/src/plans/task-name.ts +34 -0
  164. package/src/plans/texts.ts +36 -14
  165. package/src/plans/unlisted-lanes.ts +10 -5
  166. package/src/plans/view.ts +20 -12
  167. package/src/plans/waiting-click.ts +231 -0
  168. package/src/plans/waiting-records.ts +39 -0
  169. package/src/plans/waiting-window.ts +8 -0
  170. package/src/preview/command.ts +105 -0
  171. package/src/preview/embed.ts +209 -0
  172. package/src/preview/images.ts +89 -0
  173. package/src/preview/open.ts +12 -0
  174. package/src/preview/options.ts +32 -0
  175. package/src/preview/page.ts +89 -0
  176. package/src/preview/render.ts +41 -0
  177. package/src/settled/board-row.ts +1 -2
  178. package/src/shared/is-record.ts +2 -0
  179. package/src/shared/private-location.ts +89 -0
  180. package/src/shared/private-records.ts +48 -0
  181. package/src/shared/stat-cache.ts +48 -0
  182. package/src/sync/kit-source.ts +29 -10
  183. package/src/sync/kit.ts +2 -0
  184. package/src/sync/manifest.ts +2 -0
  185. package/src/sync/plan-ask-rules.ts +94 -0
  186. package/src/sync/plan-hooks.ts +2 -0
  187. package/src/sync/render.ts +3 -3
  188. package/src/sync/settings-hooks.ts +4 -1
  189. package/src/sync/sync-plan.ts +1 -0
  190. package/src/sync/sync.ts +24 -3
  191. package/src/task-signals/agent.ts +15 -0
  192. package/src/task-signals/claude-read.ts +89 -0
  193. package/src/task-signals/claude-signals.ts +112 -0
  194. package/src/task-signals/command.ts +71 -0
  195. package/src/task-signals/fixtures/session.jsonl +7 -0
  196. package/src/task-signals/matcher.ts +96 -0
  197. package/src/task-signals/record-store.ts +40 -0
  198. package/src/task-signals/record.ts +68 -0
  199. package/src/task-signals/text.ts +13 -0
  200. package/src/task-signals/types.ts +14 -0
  201. package/src/terminal/open.ts +13 -6
  202. package/src/terminal/text.ts +16 -4
  203. package/src/terminal/width.ts +2 -1
  204. package/src/version/skill-diff.ts +46 -0
  205. package/src/vocabulary/index.ts +44 -6
  206. package/src/waiting/command.ts +50 -0
  207. package/src/watch/appointments.ts +2 -4
  208. package/src/watch/approvals.ts +2 -2
  209. package/src/watch/asking-lanes.ts +40 -0
  210. package/src/watch/base-tip.ts +11 -0
  211. package/src/watch/command.ts +401 -90
  212. package/src/watch/failed-tests.ts +56 -10
  213. package/src/watch/failure-read.ts +18 -6
  214. package/src/watch/failures.ts +29 -5
  215. package/src/watch/feedback.ts +1 -1
  216. package/src/watch/gh-process.ts +9 -3
  217. package/src/watch/heartbeat-progress.ts +6 -18
  218. package/src/watch/json-guards.ts +0 -3
  219. package/src/watch/kit-update.ts +28 -9
  220. package/src/watch/lane-fill.ts +1 -1
  221. package/src/watch/lane-model.ts +1 -1
  222. package/src/watch/lane-name.ts +6 -0
  223. package/src/watch/lane-parallel.ts +1 -1
  224. package/src/watch/lane-progress.ts +58 -14
  225. package/src/watch/lane-report-file.ts +20 -5
  226. package/src/watch/lane-report.ts +37 -5
  227. package/src/watch/lane-rest.ts +28 -0
  228. package/src/watch/lane-sources.ts +31 -3
  229. package/src/watch/main-checkout.ts +34 -0
  230. package/src/watch/plan-badges-read.ts +13 -7
  231. package/src/watch/plan-badges.ts +9 -2
  232. package/src/watch/previews.ts +1 -1
  233. package/src/watch/pull-requests.ts +8 -3
  234. package/src/watch/reference-paths.ts +87 -0
  235. package/src/watch/running-subagents.ts +43 -0
  236. package/src/watch/skill-change.ts +124 -32
  237. package/src/watch/source-change.ts +48 -0
  238. package/src/watch/source-path.ts +1 -1
  239. package/src/watch/sources.ts +7 -4
  240. package/src/watch/stream-output.ts +14 -8
  241. package/src/watch/task-parallel-auto.ts +208 -0
  242. package/src/watch/task-parallel-data-paths.ts +46 -0
  243. package/src/watch/task-parallel-input.ts +108 -0
  244. package/src/watch/task-parallel-read.ts +151 -0
  245. package/src/watch/task-parallel-touched.ts +39 -0
  246. package/src/watch/task-parallel.ts +235 -0
  247. package/src/watch/task-signal-missing.ts +69 -0
  248. package/src/watch/task-signal-read.ts +29 -0
  249. package/src/watch/texts.ts +10 -0
  250. package/src/watch/update-available.ts +6 -3
  251. package/src/watch/waiting-line.ts +19 -0
  252. package/src/watch/watch-events.ts +36 -1
  253. package/src/watch/watcher-lock.ts +25 -29
  254. package/src/watch/workflow-runs.ts +2 -1
  255. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  256. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  257. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  258. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  259. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  260. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  261. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -1,13 +1,5 @@
1
- import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
2
1
  import { FULL_COMMIT } from '../shared/commit'
3
- import {
4
- inlineScript,
5
- isOption,
6
- isProgramPosition,
7
- isShell,
8
- programName,
9
- runsInterpreterProgram,
10
- } from '../shared/program-position'
2
+ import { inlineScript, isOption, isProgramPosition, programName } from '../shared/program-position'
11
3
  import {
12
4
  expandingHereDocumentAdvice,
13
5
  isExpanded,
@@ -18,15 +10,13 @@ import {
18
10
  type Word,
19
11
  } from '../shared/shell'
20
12
  import {
21
- definesShellAlias,
22
- mayRunDefinedFunction,
23
13
  NO_DEFINITIONS,
24
14
  type ShellDefinitionHit,
25
15
  type ShellDefinitions,
26
16
  shellDefinitionAt,
27
17
  shellDefinitionHint,
28
18
  } from '../shared/shell-aliases'
29
- import { resolvePlainXargs } from './xargs-program'
19
+ import { GLOB, resolvePlainXargs, XARGS } from '../shared/xargs-program'
30
20
 
31
21
  export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
32
22
 
@@ -43,8 +33,8 @@ export type GuardPolicy = {
43
33
  /** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
44
34
  commands: readonly string[]
45
35
  /**
46
- * Whether the merge of this pull request may pass without a question: the user approved its branch or the plan of
47
- * its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
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>`.
48
38
  * A throw counts as not approved; a note is not approved either, and the question carries it.
49
39
  */
50
40
  isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
@@ -54,10 +44,9 @@ export type GuardPolicy = {
54
44
 
55
45
  /**
56
46
  * A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
57
- * the shell alias or function that may run one (`definition`, whose name is then the `command`), or a program whose
58
- * name is unknown (`note` says why it may run one).
47
+ * the shell alias or function that may run one (`definition`, whose name is then the `command`).
59
48
  */
60
- type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; note?: string }
49
+ type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit }
61
50
 
62
51
  /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
63
52
  type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
@@ -78,11 +67,21 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
78
67
  const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
79
68
  const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
80
69
  const HEAD_COMMIT_FLAG = '--match-head-commit'
81
- const GLOB = /[*?[]/
82
-
83
- const SOURCING = new Set(['source', '.'])
84
70
 
85
- const INPUT_PATH = /^(\/dev\/stdin|\/dev\/fd\/|\/proc\/self\/fd\/)/
71
+ /** The option that prints a command's help and runs nothing; gh also takes `-h`, another tool may read it as a host. */
72
+ const HELP_OPTION = '--help'
73
+ const GH_SHORT_HELP = '-h'
74
+ /** The gh verbs that only read: `gh <noun> view`, `gh <noun> list`. */
75
+ const READING_VERBS = new Set(['view', 'list'])
76
+ const GH = 'gh'
77
+ /** What `xargs` appends to the program it runs: words from its input, which the guard cannot know. */
78
+ const XARGS_INPUT: Word = { value: '$xargs-input', literal: false }
79
+ /**
80
+ * Where a command may start in text the parse could not read: an operator, a bracket, a quote, a backtick, a line
81
+ * break. Cut there, a word at a program position of a piece may be one.
82
+ */
83
+ const COMMAND_BOUNDARY = /[;&|()`'"{}\n]/
84
+ const BLANKS = /\s+/
86
85
 
87
86
  /** `gh api` is guarded only for a write that merges, writes a file, starts or reruns a workflow or moves a ref (D43); every other call passes. */
88
87
  const API_COMMAND = 'gh api'
@@ -126,9 +125,6 @@ const DANGEROUS_ENDINGS = [
126
125
  /(^|\/)(rerun|rerun-failed-jobs)\/?$/,
127
126
  /(^|\/)refs(\/|$)/,
128
127
  ]
129
- const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
130
- const EXPANDED_FUNCTION_NOTE =
131
- 'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
132
128
 
133
129
  const PASS: MergeGuardDecision = { kind: 'pass' }
134
130
 
@@ -272,23 +268,59 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
272
268
  return at === -1 ? after : after.slice(at + 1)
273
269
  }
274
270
 
275
- /** Every guarded command counts wherever it is found, except a `gh api` call that does not write dangerously. */
271
+ /** Every guarded command at a program position counts, except a `gh api` call that does not write dangerously. */
276
272
  const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
277
273
  command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
278
274
 
279
- /** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
280
- const mentionsDangerousApiCall = (script: string): boolean => {
281
- const at = findWord(script, API_WORD, 0)
282
- return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
275
+ const commandWords = (command: string): string[] => command.split(' ').slice(1)
276
+
277
+ /** `gh <noun> view` or `gh <noun> list`, written plainly: it only reads. */
278
+ const isGhRead = (words: SimpleCommand, index: number): boolean => {
279
+ const [noun, verb] = words.slice(index + 1, index + 3)
280
+ const plainNoun = noun?.literal === true && !isOption(noun)
281
+ return programName(words[index]) === GH && plainNoun && verb?.literal === true && READING_VERBS.has(verb.value)
282
+ }
283
+
284
+ const isHelpOption = (program: Word, { value }: Word): boolean =>
285
+ value === HELP_OPTION || (value === GH_SHORT_HELP && programName(program) === GH)
286
+
287
+ /** The command's own words, written plainly, then `--help` (or gh's `-h`): it prints the help and runs nothing. */
288
+ const isHelpOf = (words: SimpleCommand, index: number, command: string): boolean => {
289
+ const expected = commandWords(command)
290
+ const after = words.slice(index + 1, index + 2 + expected.length)
291
+ const help = after[expected.length]
292
+ return (
293
+ after.every((word) => word.literal) &&
294
+ expected.every((value, at) => after[at]?.value === value) &&
295
+ help !== undefined &&
296
+ isHelpOption(words[index], help)
297
+ )
298
+ }
299
+
300
+ /** A call of the program at `index` that only reads: a help text or a gh view or list (D108). */
301
+ const isReading = (words: SimpleCommand, index: number, command: string): boolean =>
302
+ isGhRead(words, index) || isHelpOf(words, index, command)
303
+
304
+ /** An `xargs` before the program hands it words from its input: they may be any of the command words. */
305
+ const isFedByXargs = (words: SimpleCommand, index: number): boolean =>
306
+ words.slice(0, index).some((word, at) => programName(word) === XARGS && isProgramPosition(words, at))
307
+
308
+ /** The words after the program at `index`, with a stand-in for what `xargs` appends. */
309
+ const argumentsOf = (words: SimpleCommand, index: number): Word[] => {
310
+ const after = words.slice(index + 1)
311
+ return isFedByXargs(words, index) ? [...after, XARGS_INPUT] : after
283
312
  }
284
313
 
314
+ /** Whether the words after the program at `index` can be the command's own words. */
315
+ const namesCommandWords = (words: SimpleCommand, index: number, command: string): boolean =>
316
+ operandLists(argumentsOf(words, index)).some((operands) => matchesFrom(operands, commandWords(command)))
317
+
285
318
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
286
319
  const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
287
- const expected = command.split(' ').slice(1)
288
- if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
320
+ if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
289
321
  if (!isGuardedUse(command, words, index)) return undefined
290
- const plain = sole && words[index].literal && words[index].value === 'gh'
291
- return { command, merge: plain ? plainMerge(words.slice(index), expected) : undefined }
322
+ const plain = sole && words[index].literal && words[index].value === GH
323
+ return { command, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
292
324
  }
293
325
 
294
326
  const programsOf = (commands: readonly string[]): string[] => [
@@ -301,90 +333,37 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
301
333
  return definition ? [{ command: definition.name, merge: undefined, definition }] : []
302
334
  }
303
335
 
304
- const expandedFunctionFinding = (words: SimpleCommand, { commands, definitions }: Search, text: string): Finding[] =>
305
- mayRunDefinedFunction(words, definitions, programsOf(commands), text)
306
- ? [{ command: '', merge: undefined, note: EXPANDED_FUNCTION_NOTE }]
307
- : []
336
+ /**
337
+ * A program from a variable, a substitution or a glob may be a guarded one: it counts when the parsed words after it
338
+ * can be that command's words (`"$tool" pr merge 5`). A variable that holds the whole command (`$X`) is not searched.
339
+ */
340
+ const expandedProgramFindings = (words: SimpleCommand, index: number, { commands }: Search): Finding[] =>
341
+ commands
342
+ .filter((command) => namesCommandWords(words, index, command) && isGuardedUse(command, words, index))
343
+ .map((command) => ({ command, merge: undefined }))
344
+
345
+ /** A program word from an expansion or a glob; a quoted word (`'gh pr merge'`, a script text) is the name it spells. */
346
+ const isExpandedProgram = ({ value, literal }: Word): boolean => isExpanded(value) || (literal && GLOB.test(value))
308
347
 
309
348
  const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
310
349
  if (!isProgramPosition(words, index)) return []
350
+ if (isExpandedProgram(words[index])) return expandedProgramFindings(words, index, search)
311
351
  const program = programName(words[index])
312
352
  const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
313
353
  if (guarded.length === 0) return definitionFinding(words, index, search)
314
354
  return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
315
355
  }
316
356
 
317
- const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
318
-
319
- const findWord = (script: string, word: string, from: number): number => {
320
- for (let at = script.indexOf(word, from); at !== -1; at = script.indexOf(word, at + 1)) {
321
- if (!isWordCharacter(script[at - 1]) && !isWordCharacter(script[at + word.length])) return at
322
- }
323
- return -1
324
- }
325
-
326
- /** Whether the script names the words as whole words in this order; a linear search, so a long script cannot stall it. */
327
- const namesInOrder = (script: string, words: string[]): boolean => {
328
- let from = 0
329
- for (const word of words) {
330
- const at = findWord(script, word, from)
331
- if (at === -1) return false
332
- from = at + word.length
333
- }
334
- return true
335
- }
336
-
337
- /** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
338
- const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
339
- commands
340
- .filter((command) =>
341
- command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
342
- )
343
- .map((command) => ({ command, merge: undefined }))
344
-
345
- /** A program expanded from a variable or a substitution could be the guarded one. */
346
- const runsExpandedProgram = (words: SimpleCommand): boolean =>
347
- words.some((word, index) => !word.literal && isProgramPosition(words, index))
348
-
349
- const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
350
-
351
- /**
352
- * A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
353
- * substitution or from a file descriptor; or `source` of such a thing: the text comes from somewhere else.
354
- */
355
- const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
356
- words.some((word, index) => {
357
- if (!isProgramPosition(words, index)) return false
358
- const shell = isShell(word)
359
- if (!shell && !SOURCING.has(programName(word))) return false
360
- const rest = words.slice(index + 1)
361
- const operands = rest.filter((each) => !isOption(each))
362
- const fromInput = shell && (operands.length === 0 || rest.some(({ value }) => value === '-s'))
363
- return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
364
- })
365
-
366
- /** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
367
- const definesGitAlias = (words: SimpleCommand): boolean =>
368
- words.some(
369
- (word, index) =>
370
- isProgramPosition(words, index) &&
371
- programName(word) === 'git' &&
372
- words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
373
- )
374
-
375
- /** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
376
- const runsInlineExpansion = (words: SimpleCommand): boolean =>
377
- words.some((_word, index) => inlineScript(words, index)?.includes('$') === true)
378
-
379
357
  /**
380
358
  * Whether the command's words contain the guarded command's words in this order, anything between them: the
381
359
  * fallback for a wrapper nobody listed (`mise exec -- gh pr merge 5`), which hides the program from the position test.
360
+ * It reads the parsed words of the command, never text inside them.
382
361
  */
383
362
  const containsInOrder = (words: SimpleCommand, command: string): boolean => {
384
363
  const [program, ...rest] = command.split(' ')
385
364
  const at = words.findIndex((word) => programName(word) === program)
386
365
  let from = at + 1
387
- if (at === -1) return false
366
+ if (at === -1 || isReading(words, at, command)) return false
388
367
  for (const expected of rest) {
389
368
  const next = words.findIndex((word, index) => index >= from && word.literal && word.value === expected)
390
369
  if (next === -1) return false
@@ -393,45 +372,28 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
393
372
  return isGuardedUse(command, words, at)
394
373
  }
395
374
 
396
- /** `text` is the searchable text of the script around the command: a variable there may hold a function's name. */
397
- const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
375
+ const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean): Finding[] => {
398
376
  const direct = words.flatMap((_word, index) => {
399
377
  const script = inlineScript(words, index)
400
- return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false, whole)
378
+ return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false)
401
379
  })
402
380
  const found = new Set(direct.map(({ command }) => command))
403
381
  const byWords = search.commands
404
382
  .filter((command) => !found.has(command) && containsInOrder(words, command))
405
383
  .map((command) => ({ command, merge: undefined }))
406
- return [...direct, ...byWords, ...expandedFunctionFinding(words, search, text)]
384
+ return [...direct, ...byWords]
407
385
  }
408
386
 
409
387
  /**
410
- * Each check reads the whole script: a shell that reads stdin, an expanded program, an opaque runner or an interpreter
411
- * handed program text hides what runs.
388
+ * The guarded commands in the parsed script: in a program position, in the script of `sh -c`, `eval` or `trap`, in a
389
+ * substitution. Text the script hands to an interpreter, a shell's input, a here-document's data and the words
390
+ * `xargs` reads are not searched (D108).
412
391
  */
413
- const hidesWhatRuns = (words: SimpleCommand): boolean =>
414
- runsExpandedProgram(words) ||
415
- runsOpaquely(words) ||
416
- readsScriptFromElsewhere(words) ||
417
- runsInlineExpansion(words) ||
418
- runsInterpreterProgram(words) ||
419
- definesGitAlias(words) ||
420
- definesShellAlias(words)
421
-
422
- const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
423
- parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
424
-
425
- /** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
426
- const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
392
+ const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
427
393
  const guardedPrograms = programsOf(search.commands)
428
394
  const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
429
395
  const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
430
- const text = searchableText(whole, parsed)
431
- return [
432
- ...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
433
- ...callsInScriptWide(whole, parsed, search.commands),
434
- ]
396
+ return parsed.flatMap((words) => callsInCommand(words, search, sole))
435
397
  }
436
398
 
437
399
  const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
@@ -451,9 +413,7 @@ const guardedListSentence = (commands: string[]): string[] =>
451
413
  ]
452
414
 
453
415
  const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
454
- const guarded = findings
455
- .filter(({ definition, note }) => definition === undefined && note === undefined)
456
- .map(({ command }) => command)
416
+ const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
457
417
  const definitions = [
458
418
  ...new Map(
459
419
  findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
@@ -467,7 +427,6 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
467
427
  (definition) =>
468
428
  `${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
469
429
  ),
470
- ...new Set(findings.flatMap(({ note }) => note ?? [])),
471
430
  ...notes,
472
431
  expandingHereDocumentAdvice(script),
473
432
  ]
@@ -476,9 +435,34 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
476
435
  }
477
436
  }
478
437
 
438
+ /** A word of text the parse could not read: one with an expansion or a backslash may stand for any word. */
439
+ const roughWord = (value: string): Word => ({ value, literal: !/[$\\]/.test(value) })
440
+
441
+ /**
442
+ * Whether text the parse could not read has a guarded program at a program position: the text is cut where a command
443
+ * may start (an operator, a bracket, a quote, a backtick, a line break), and a piece whose word at a program position
444
+ * is a guarded program counts, unless that call only reads. A split program word (`g''h`) is not found, accepted
445
+ * (D108): the guard catches mistakes, not a hidden intent.
446
+ */
447
+ const namesGuardedProgram = (script: string, commands: readonly string[]): boolean => {
448
+ const programs = programsOf(commands)
449
+ return script.split(COMMAND_BOUNDARY).some((piece) => {
450
+ const words = piece
451
+ .split(BLANKS)
452
+ .filter((value) => value !== '')
453
+ .map(roughWord)
454
+ return words.some(
455
+ (word, index) =>
456
+ isProgramPosition(words, index) &&
457
+ programs.includes(programName(word)) &&
458
+ !commands.some((command) => isReading(words, index, command)),
459
+ )
460
+ })
461
+ }
462
+
479
463
  /**
480
- * Quotes split a word (`g''h`), a variable builds it, an expansion hides a command: in text the parse cannot follow,
481
- * no word search can tell whether a guarded command runs, so every unreadable script asks (fail closed).
464
+ * Quotes split a word, an expansion hides a command: in text the parse cannot follow, a guarded program at a program
465
+ * position may run with any words, so the script asks.
482
466
  */
483
467
  const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
484
468
  kind: 'ask',
@@ -493,7 +477,7 @@ const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuard
493
477
 
494
478
  const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
495
479
  try {
496
- return callsIn(command, search, true, command)
480
+ return callsIn(command, search, true)
497
481
  } catch (error) {
498
482
  if (error instanceof ShellParseError) return error
499
483
  throw error
@@ -501,20 +485,23 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
501
485
  }
502
486
 
503
487
  /**
504
- * Decides on a Bash command before it runs: `ask` when it runs, or could run, a guarded command (`gh pr merge`,
505
- * `gh workflow run`, `gh run rerun` and a `gh api` write to a merge, a file, a dispatch, a rerun or a ref by default), also behind options before the
506
- * verb, a chain, a wrapper, `sh -c`, `eval`, a script piped into a shell, an interpreter's program text, a shell alias
507
- * or function, or a program held in a variable. A script that is exactly `gh pr merge <number>` with merge-method and
488
+ * Decides on a Bash command before it runs: `ask` when a guarded command (`gh pr merge` and `gh workflow run` by
489
+ * default) stands at a program position of the parsed script, also behind options before the verb, a chain, a wrapper,
490
+ * `env`, `sh -c`, `eval`, a substitution or a shell alias or function, or behind a program from a variable whose
491
+ * parsed words are the command's words. Only the parsed script counts (D108): text handed to an interpreter or a
492
+ * shell's input, a here-document's data and what `xargs` reads are not searched, and a call that only reads (`--help`,
493
+ * `-h`, `gh … view`, `gh … list`) passes. A script that is exactly `gh pr merge <number>` with merge-method and
508
494
  * delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
509
- * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely
510
- * names the words (an argument, a commit message) is none. A script the parse cannot read asks whatever it names,
511
- * unless the list is empty.
495
+ * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A script the parse cannot
496
+ * read asks only when a guarded program stands where a command starts.
512
497
  */
513
498
  export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
514
499
  if (policy.commands.length === 0) return PASS
515
500
  const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
516
501
  const findings = findingsOrUnreadable(command, search)
517
- if (findings instanceof ShellParseError) return askUnreadable(findings, command)
502
+ if (findings instanceof ShellParseError) {
503
+ return namesGuardedProgram(command, policy.commands) ? askUnreadable(findings, command) : PASS
504
+ }
518
505
  if (findings.length === 0) return PASS
519
506
  const unapproved = findings
520
507
  .map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
@@ -2,8 +2,8 @@
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] 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`.
5
+ // runs into its own timeout. argv[2] is the exact option label of the pull request question that approves a merge, argv[3]
6
+ // the one of the standing approval and argv[4] the word before its end, all rendered into the hook entry by `sync`.
7
7
  import type { MergeGuardDecision } from './decision'
8
8
  import { type ApprovalLabels, failureDecision } from './run'
9
9
 
@@ -35,7 +35,10 @@ const decide = async (): Promise<MergeGuardDecision> => {
35
35
  try {
36
36
  const text = await Bun.stdin.text()
37
37
  const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
38
- const labels = { selfMerge: process.argv[2] ?? '', plan: process.argv[3] ?? '' }
38
+ const labels = {
39
+ selfMerge: process.argv[2] ?? '',
40
+ standing: { approve: process.argv[3] ?? '', until: process.argv[4] ?? '' },
41
+ }
39
42
  return await decideWithin({ text, labels, projectDirectory }, DEADLINE_MS)
40
43
  } catch (error) {
41
44
  return failureDecision(error)
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The plan a branch belongs to: the lane whose worktree has the branch checked out, and the one plan that lane's rows
3
+ * link on the boards, as the main checkout holds them (the coordinator keeps its board there, committed or not). The
4
+ * git call runs without a shell and with a timeout; any failure answers nothing, so the guard asks.
5
+ */
6
+ import { readdirSync, readFileSync } from 'node:fs'
7
+ import { join, posix, relative, resolve } from 'node:path'
8
+ import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
9
+ import { laneOf } from '../shared/lane'
10
+ import type { BoardSettings } from './board-settings'
11
+
12
+ /** A board's directory relative to the repository, and its text. */
13
+ export type Board = { directory: string; text: string }
14
+
15
+ const GIT_TIMEOUT_MS = 3000
16
+
17
+ /** The lane whose worktree, directly below the worktree directory, has `branch` checked out. */
18
+ export const laneInWorktreeList = (
19
+ worktreeList: string,
20
+ main: string,
21
+ worktreeDirectory: string,
22
+ branch: string,
23
+ ): string | undefined => {
24
+ const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
25
+ const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
26
+ const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
27
+ if (path === undefined) return undefined
28
+ const lane = laneOf({ cwd: path, main, worktreeDirectory })
29
+ return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
30
+ }
31
+
32
+ /** The one plan the lane's open rows link across the boards; none for a cleaned-up lane, no plan or several. */
33
+ export const onlyPlanOf = (boards: readonly Board[], lane: string, language: BoardLanguage): string | undefined => {
34
+ const rows = boards.flatMap(({ directory, text }) => lanePlansOf(text, directory, lane, language) ?? [])
35
+ const plans = new Set(rows.filter(({ closed }) => !closed).flatMap(({ plans }) => plans))
36
+ const [only] = plans
37
+ return plans.size === 1 ? only : undefined
38
+ }
39
+
40
+ const worktreeListOf = (main: string): string | undefined => {
41
+ const result = Bun.spawnSync(['git', '-C', main, 'worktree', 'list', '--porcelain'], {
42
+ stdin: 'ignore',
43
+ stdout: 'pipe',
44
+ stderr: 'ignore',
45
+ timeout: GIT_TIMEOUT_MS,
46
+ })
47
+ return result.exitCode === 0 ? result.stdout.toString() : undefined
48
+ }
49
+
50
+ const readTextOrUndefined = (path: string): string | undefined => {
51
+ try {
52
+ return readFileSync(path, 'utf8')
53
+ } catch {
54
+ return undefined
55
+ }
56
+ }
57
+
58
+ /** The boards under the plan directory of the main checkout whose directory name matches the board pattern. */
59
+ const boardsIn = (main: string, settings: BoardSettings): Board[] => {
60
+ const pattern = new Bun.Glob(settings.board.pattern)
61
+ try {
62
+ return readdirSync(join(main, settings.planDirectory), { withFileTypes: true })
63
+ .filter((entry) => entry.isDirectory() && pattern.match(entry.name))
64
+ .flatMap((entry) => {
65
+ const directory = posix.join(settings.planDirectory, entry.name)
66
+ const text = readTextOrUndefined(join(main, directory, settings.board.file))
67
+ return text === undefined ? [] : [{ directory, text }]
68
+ })
69
+ } catch {
70
+ return []
71
+ }
72
+ }
73
+
74
+ /** The one plan the lane of `branch` links on the boards of the main checkout; undefined when it cannot tell. */
75
+ export const planOfBranch = (main: string, settings: BoardSettings, branch: string): string | undefined => {
76
+ const list = worktreeListOf(main)
77
+ const lane = list === undefined ? undefined : laneInWorktreeList(list, main, settings.worktreeDirectory, branch)
78
+ return lane === undefined ? undefined : onlyPlanOf(boardsIn(main, settings), lane, settings.planLanguage)
79
+ }