jackherd 1.9.0 → 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 (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  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/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. 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,17 +44,13 @@ 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 }
64
53
 
65
- /** A whole token of a script's text; `joined` when only blanks, quotes, commas and brackets stand before it. */
66
- type TextToken = { value: string; end: number; joined: boolean }
67
-
68
54
  /** The parts of a `gh api` call that decide whether it writes, and where. */
69
55
  type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
70
56
 
@@ -81,11 +67,21 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
81
67
  const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
82
68
  const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
83
69
  const HEAD_COMMIT_FLAG = '--match-head-commit'
84
- const GLOB = /[*?[]/
85
70
 
86
- const SOURCING = new Set(['source', '.'])
87
-
88
- 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+/
89
85
 
90
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. */
91
87
  const API_COMMAND = 'gh api'
@@ -129,19 +125,6 @@ const DANGEROUS_ENDINGS = [
129
125
  /(^|\/)(rerun|rerun-failed-jobs)\/?$/,
130
126
  /(^|\/)refs(\/|$)/,
131
127
  ]
132
- const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
133
- const EXPANDED_FUNCTION_NOTE =
134
- '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.'
135
-
136
- /** A run of characters that no quote, comma, bracket, backslash, `+`, blank or shell operator ends. */
137
- const TEXT_TOKEN = /[^\s'"`,()[\]{}\\+;&|<>]+/g
138
- /** What may stand between two command words in program text: `"gh", "workflow"`, `"gh workflow " + "run"`, `\"gh`. */
139
- const WORD_GAP = /^[\s'"`,()[\]{}\\+]*$/
140
- /**
141
- * Options and their values a command word may be separated by (`workflow -R o/r run`; gh takes flags before the
142
- * subcommand, too); more ends the search, so a long run of options cannot stall it.
143
- */
144
- const MAX_TOKENS_BETWEEN_WORDS = 16
145
128
 
146
129
  const PASS: MergeGuardDecision = { kind: 'pass' }
147
130
 
@@ -285,23 +268,59 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
285
268
  return at === -1 ? after : after.slice(at + 1)
286
269
  }
287
270
 
288
- /** 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. */
289
272
  const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
290
273
  command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
291
274
 
292
- /** In text the parse cannot see, the token `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
293
- const mentionsDangerousApiCall = (script: string, tokens: TextToken[]): boolean => {
294
- const api = tokens.find(({ value }) => value === API_WORD)
295
- return api !== undefined && DANGEROUS_MENTION.test(script.slice(api.end))
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)
296
282
  }
297
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
312
+ }
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
+
298
318
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
299
319
  const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
300
- const expected = command.split(' ').slice(1)
301
- 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
302
321
  if (!isGuardedUse(command, words, index)) return undefined
303
- const plain = sole && words[index].literal && words[index].value === 'gh'
304
- 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 }
305
324
  }
306
325
 
307
326
  const programsOf = (commands: readonly string[]): string[] => [
@@ -314,124 +333,37 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
314
333
  return definition ? [{ command: definition.name, merge: undefined, definition }] : []
315
334
  }
316
335
 
317
- const expandedFunctionFinding = (words: SimpleCommand, { commands, definitions }: Search, text: string): Finding[] =>
318
- mayRunDefinedFunction(words, definitions, programsOf(commands), text)
319
- ? [{ command: '', merge: undefined, note: EXPANDED_FUNCTION_NOTE }]
320
- : []
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))
321
347
 
322
348
  const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
323
349
  if (!isProgramPosition(words, index)) return []
350
+ if (isExpandedProgram(words[index])) return expandedProgramFindings(words, index, search)
324
351
  const program = programName(words[index])
325
352
  const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
326
353
  if (guarded.length === 0) return definitionFinding(words, index, search)
327
354
  return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
328
355
  }
329
356
 
330
- /**
331
- * The script cut into whole tokens: a path (`kit/workflow/skills`) or a dotted name (`subprocess.run`) is one token,
332
- * while quotes, commas, brackets and shell operators end one, so `["gh", "workflow", "run"]` gives three.
333
- */
334
- const textTokens = (script: string): TextToken[] => {
335
- const tokens: TextToken[] = []
336
- let previousEnd = 0
337
- for (const match of script.matchAll(TEXT_TOKEN)) {
338
- const end = match.index + match[0].length
339
- const joined = WORD_GAP.test(script.slice(previousEnd, match.index))
340
- tokens.push({ value: match[0], end, joined })
341
- previousEnd = end
342
- }
343
- return tokens
344
- }
345
-
346
- const isOptionToken = (value: string): boolean => value.length > 1 && value.startsWith('-')
347
-
348
- /**
349
- * Whether the tokens from `start` on spell the words, each joined to the one before: only quotes, commas, brackets and
350
- * blanks between them, and options such as `-R o/r` that `gh` takes between its command words. From the first option
351
- * on, any joined token may follow, since brackets and braces cut a value such as `"${REPO}"` or `REPOS[0]` into
352
- * several tokens; without an option before it, a word between the command words ends the match. A bounded walk, so a
353
- * long script cannot stall it.
354
- */
355
- const spellsFrom = (tokens: TextToken[], start: number, words: string[]): boolean => {
356
- let index = start
357
- for (const word of words) {
358
- let skipped = 0
359
- let optionSeen = false
360
- while (tokens[index]?.joined && tokens[index].value !== word && skipped < MAX_TOKENS_BETWEEN_WORDS) {
361
- optionSeen ||= isOptionToken(tokens[index].value)
362
- if (!optionSeen) return false
363
- skipped++
364
- index++
365
- }
366
- if (!tokens[index]?.joined || tokens[index].value !== word) return false
367
- index++
368
- }
369
- return true
370
- }
371
-
372
- /** Whether the words stand adjacent somewhere in the tokens, in this order. */
373
- const spellsWords = (tokens: TextToken[], [first, ...rest]: string[]): boolean =>
374
- first === undefined || tokens.some(({ value }, index) => value === first && spellsFrom(tokens, index + 1, rest))
375
-
376
- /**
377
- * Where the program is hidden or the words are handed over as text, the command words after the program, standing
378
- * adjacent as whole tokens, are the sign: `$g workflow run` and `["gh", "workflow", "run"]` count, a path segment
379
- * `workflow` and a `run` elsewhere in the script do not.
380
- */
381
- const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] => {
382
- const tokens = textTokens(script)
383
- return commands
384
- .filter((command) =>
385
- command === API_COMMAND
386
- ? mentionsDangerousApiCall(script, tokens)
387
- : spellsWords(tokens, command.split(' ').slice(1)),
388
- )
389
- .map((command) => ({ command, merge: undefined }))
390
- }
391
-
392
- /** A program expanded from a variable or a substitution could be the guarded one. */
393
- const runsExpandedProgram = (words: SimpleCommand): boolean =>
394
- words.some((word, index) => !word.literal && isProgramPosition(words, index))
395
-
396
- const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
397
-
398
- /**
399
- * A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
400
- * substitution or from a file descriptor; or `source` of such a thing: the text comes from somewhere else.
401
- */
402
- const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
403
- words.some((word, index) => {
404
- if (!isProgramPosition(words, index)) return false
405
- const shell = isShell(word)
406
- if (!shell && !SOURCING.has(programName(word))) return false
407
- const rest = words.slice(index + 1)
408
- const operands = rest.filter((each) => !isOption(each))
409
- const fromInput = shell && (operands.length === 0 || rest.some(({ value }) => value === '-s'))
410
- return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
411
- })
412
-
413
- /** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
414
- const definesGitAlias = (words: SimpleCommand): boolean =>
415
- words.some(
416
- (word, index) =>
417
- isProgramPosition(words, index) &&
418
- programName(word) === 'git' &&
419
- words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
420
- )
421
-
422
- /** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
423
- const runsInlineExpansion = (words: SimpleCommand): boolean =>
424
- words.some((_word, index) => inlineScript(words, index)?.includes('$') === true)
425
-
426
357
  /**
427
358
  * Whether the command's words contain the guarded command's words in this order, anything between them: the
428
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.
429
361
  */
430
362
  const containsInOrder = (words: SimpleCommand, command: string): boolean => {
431
363
  const [program, ...rest] = command.split(' ')
432
364
  const at = words.findIndex((word) => programName(word) === program)
433
365
  let from = at + 1
434
- if (at === -1) return false
366
+ if (at === -1 || isReading(words, at, command)) return false
435
367
  for (const expected of rest) {
436
368
  const next = words.findIndex((word, index) => index >= from && word.literal && word.value === expected)
437
369
  if (next === -1) return false
@@ -440,45 +372,28 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
440
372
  return isGuardedUse(command, words, at)
441
373
  }
442
374
 
443
- /** `text` is the searchable text of the script around the command: a variable there may hold a function's name. */
444
- const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
375
+ const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean): Finding[] => {
445
376
  const direct = words.flatMap((_word, index) => {
446
377
  const script = inlineScript(words, index)
447
- 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)
448
379
  })
449
380
  const found = new Set(direct.map(({ command }) => command))
450
381
  const byWords = search.commands
451
382
  .filter((command) => !found.has(command) && containsInOrder(words, command))
452
383
  .map((command) => ({ command, merge: undefined }))
453
- return [...direct, ...byWords, ...expandedFunctionFinding(words, search, text)]
384
+ return [...direct, ...byWords]
454
385
  }
455
386
 
456
387
  /**
457
- * Each check reads the whole script: a shell that reads stdin, an expanded program, an opaque runner or an interpreter
458
- * 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).
459
391
  */
460
- const hidesWhatRuns = (words: SimpleCommand): boolean =>
461
- runsExpandedProgram(words) ||
462
- runsOpaquely(words) ||
463
- readsScriptFromElsewhere(words) ||
464
- runsInlineExpansion(words) ||
465
- runsInterpreterProgram(words) ||
466
- definesGitAlias(words) ||
467
- definesShellAlias(words)
468
-
469
- const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
470
- parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
471
-
472
- /** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
473
- const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
392
+ const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
474
393
  const guardedPrograms = programsOf(search.commands)
475
394
  const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
476
395
  const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
477
- const text = searchableText(whole, parsed)
478
- return [
479
- ...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
480
- ...callsInScriptWide(whole, parsed, search.commands),
481
- ]
396
+ return parsed.flatMap((words) => callsInCommand(words, search, sole))
482
397
  }
483
398
 
484
399
  const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
@@ -498,9 +413,7 @@ const guardedListSentence = (commands: string[]): string[] =>
498
413
  ]
499
414
 
500
415
  const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
501
- const guarded = findings
502
- .filter(({ definition, note }) => definition === undefined && note === undefined)
503
- .map(({ command }) => command)
416
+ const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
504
417
  const definitions = [
505
418
  ...new Map(
506
419
  findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
@@ -514,7 +427,6 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
514
427
  (definition) =>
515
428
  `${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
516
429
  ),
517
- ...new Set(findings.flatMap(({ note }) => note ?? [])),
518
430
  ...notes,
519
431
  expandingHereDocumentAdvice(script),
520
432
  ]
@@ -523,9 +435,34 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
523
435
  }
524
436
  }
525
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
+
526
463
  /**
527
- * Quotes split a word (`g''h`), a variable builds it, an expansion hides a command: in text the parse cannot follow,
528
- * 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.
529
466
  */
530
467
  const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
531
468
  kind: 'ask',
@@ -540,7 +477,7 @@ const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuard
540
477
 
541
478
  const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
542
479
  try {
543
- return callsIn(command, search, true, command)
480
+ return callsIn(command, search, true)
544
481
  } catch (error) {
545
482
  if (error instanceof ShellParseError) return error
546
483
  throw error
@@ -548,20 +485,23 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
548
485
  }
549
486
 
550
487
  /**
551
- * Decides on a Bash command before it runs: `ask` when it runs, or could run, a guarded command (`gh pr merge`,
552
- * `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
553
- * verb, a chain, a wrapper, `sh -c`, `eval`, a script piped into a shell, an interpreter's program text, a shell alias
554
- * 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
555
494
  * delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
556
- * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely
557
- * names the words (an argument, a commit message) is none. A script the parse cannot read asks whatever it names,
558
- * 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.
559
497
  */
560
498
  export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
561
499
  if (policy.commands.length === 0) return PASS
562
500
  const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
563
501
  const findings = findingsOrUnreadable(command, search)
564
- if (findings instanceof ShellParseError) return askUnreadable(findings, command)
502
+ if (findings instanceof ShellParseError) {
503
+ return namesGuardedProgram(command, policy.commands) ? askUnreadable(findings, command) : PASS
504
+ }
565
505
  if (findings.length === 0) return PASS
566
506
  const unapproved = findings
567
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
+ }