jackherd 0.0.1 → 1.0.1

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 (250) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * One word of a simple command, with quotes and escapes removed. `literal` is false when quoting,
3
+ * an escape, an expansion or a line continuation shaped the word, because then the text a
4
+ * permission rule matches is not `value`.
5
+ */
6
+ export type Word = { value: string; literal: boolean }
7
+
8
+ export type SimpleCommand = Word[]
9
+
10
+ type Cursor = { source: string; index: number }
11
+
12
+ /** `expands` when the delimiter is unquoted: the shell then runs `$(…)` and backticks in the body. */
13
+ type Heredoc = { delimiter: string; stripTabs: boolean; expands: boolean }
14
+
15
+ type ListState = {
16
+ commands: SimpleCommand[]
17
+ words: Word[]
18
+ word: Word | undefined
19
+ nextWordContinued: boolean
20
+ dropNextWord: boolean
21
+ heredocs: Heredoc[]
22
+ }
23
+
24
+ type Terminator = ')' | '`' | undefined
25
+
26
+ type Handler = (cursor: Cursor, state: ListState) => boolean
27
+
28
+ export class ShellParseError extends Error {}
29
+
30
+ const BLANKS = ' \t'
31
+ const OPERATORS = '&;|'
32
+ const REDIRECTION_CHARS = '<>&|'
33
+ const DELIMITER_END = ' \t\n;&|<>()'
34
+
35
+ /** Marks where an expansion stood in a word (`$z` keeps its name after it): the value is unknown before the shell runs. */
36
+ const EXPANSION = '$'
37
+
38
+ const peek = (cursor: Cursor, offset = 0): string => cursor.source[cursor.index + offset] ?? ''
39
+
40
+ /** Whether the character is one of `chars`; the empty string past the end of input never is. */
41
+ const isOneOf = (chars: string, char: string): boolean => char !== '' && chars.includes(char)
42
+
43
+ const atEnd = (cursor: Cursor): boolean => cursor.index >= cursor.source.length
44
+
45
+ const newListState = (): ListState => ({
46
+ commands: [],
47
+ words: [],
48
+ word: undefined,
49
+ nextWordContinued: false,
50
+ dropNextWord: false,
51
+ heredocs: [],
52
+ })
53
+
54
+ const currentWord = (state: ListState): Word => {
55
+ if (!state.word) {
56
+ state.word = { value: '', literal: !state.nextWordContinued }
57
+ state.nextWordContinued = false
58
+ }
59
+ return state.word
60
+ }
61
+
62
+ const appendShaped = (state: ListState, text: string): void => {
63
+ const word = currentWord(state)
64
+ word.value += text
65
+ word.literal = false
66
+ }
67
+
68
+ /** `{a,b}` and `{a..b}` expand into other words before the program sees them. */
69
+ const BRACE_EXPANSION = /\{[^}]*(,|\.\.)[^}]*\}/
70
+
71
+ const endWord = (state: ListState): void => {
72
+ const { word } = state
73
+ if (!word) return
74
+ if (BRACE_EXPANSION.test(word.value)) word.literal = false
75
+ state.word = undefined
76
+ if (state.dropNextWord) state.dropNextWord = false
77
+ else state.words.push(word)
78
+ }
79
+
80
+ const endCommand = (state: ListState): void => {
81
+ endWord(state)
82
+ state.dropNextWord = false
83
+ if (state.words.length > 0) state.commands.push(state.words)
84
+ state.words = []
85
+ }
86
+
87
+ /**
88
+ * Text in `${…}` or `$((…))` that could hold or end a command: a substitution, a quote or an
89
+ * escape (which the plain bracket count below cannot follow), an operator or a newline.
90
+ */
91
+ const MAYBE_COMMAND = /\$\(|[<>]\(|[`'";&|\n\\]/
92
+
93
+ /** Rather than parse a command inside an expansion, the whole text is refused as unparsable. */
94
+ const assertNoHiddenCommand = (text: string): void => {
95
+ if (MAYBE_COMMAND.test(text)) throw new ShellParseError('a command or quote inside an expansion')
96
+ }
97
+
98
+ const skipBalanced = (cursor: Cursor, open: string, close: string): void => {
99
+ const start = cursor.index
100
+ let depth = 0
101
+ while (!atEnd(cursor)) {
102
+ const char = peek(cursor)
103
+ cursor.index++
104
+ if (char === open) depth++
105
+ if (char === close && --depth === 0) return assertNoHiddenCommand(cursor.source.slice(start, cursor.index))
106
+ }
107
+ throw new ShellParseError(`missing ${close}`)
108
+ }
109
+
110
+ const SUBSTITUTION = /\$\(|`/
111
+
112
+ /** Skips the body. A substitution in an expanding body runs, so it is refused as unparsable. */
113
+ const readHeredocBody = (cursor: Cursor, { delimiter, stripTabs, expands }: Heredoc): void => {
114
+ while (!atEnd(cursor)) {
115
+ const newline = cursor.source.indexOf('\n', cursor.index)
116
+ const lineEnd = newline === -1 ? cursor.source.length : newline
117
+ const line = cursor.source.slice(cursor.index, lineEnd)
118
+ cursor.index = lineEnd + 1
119
+ if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) return
120
+ if (expands && SUBSTITUTION.test(line)) throw new ShellParseError('a command inside a here-document')
121
+ }
122
+ throw new ShellParseError(`here-document without its ${delimiter} line`)
123
+ }
124
+
125
+ const readHeredocHeader = (cursor: Cursor, stripTabs: boolean): Heredoc => {
126
+ let delimiter = ''
127
+ let quoted = false
128
+ while (!atEnd(cursor) && !isOneOf(DELIMITER_END, peek(cursor))) {
129
+ const char = peek(cursor)
130
+ if (isOneOf('\'"\\', char)) quoted = true
131
+ else delimiter += char
132
+ cursor.index++
133
+ }
134
+ if (delimiter === '') throw new ShellParseError('here-document without a delimiter')
135
+ return { delimiter, stripTabs, expands: !quoted }
136
+ }
137
+
138
+ const lineContinuation: Handler = (cursor, state) => {
139
+ if (peek(cursor) !== '\\' || peek(cursor, 1) !== '\n') return false
140
+ cursor.index += 2
141
+ if (state.word) state.word.literal = false
142
+ else state.nextWordContinued = true
143
+ return true
144
+ }
145
+
146
+ const escapedChar: Handler = (cursor, state) => {
147
+ if (peek(cursor) !== '\\') return false
148
+ appendShaped(state, peek(cursor, 1))
149
+ cursor.index += 2
150
+ return true
151
+ }
152
+
153
+ const blank: Handler = (cursor, state) => {
154
+ if (!isOneOf(BLANKS, peek(cursor))) return false
155
+ endWord(state)
156
+ cursor.index++
157
+ return true
158
+ }
159
+
160
+ const newline: Handler = (cursor, state) => {
161
+ if (peek(cursor) !== '\n') return false
162
+ endCommand(state)
163
+ cursor.index++
164
+ for (const heredoc of state.heredocs) readHeredocBody(cursor, heredoc)
165
+ state.heredocs = []
166
+ return true
167
+ }
168
+
169
+ const comment: Handler = (cursor, state) => {
170
+ if (peek(cursor) !== '#' || state.word) return false
171
+ while (!atEnd(cursor) && peek(cursor) !== '\n') cursor.index++
172
+ return true
173
+ }
174
+
175
+ const singleQuoted: Handler = (cursor, state) => {
176
+ if (peek(cursor) !== "'") return false
177
+ const close = cursor.source.indexOf("'", cursor.index + 1)
178
+ if (close === -1) throw new ShellParseError('unterminated single quote')
179
+ appendShaped(state, cursor.source.slice(cursor.index + 1, close))
180
+ cursor.index = close + 1
181
+ return true
182
+ }
183
+
184
+ const ANSI_C_ESCAPES: Record<string, string> = { n: '\n', t: '\t', r: '\r', e: '\u001b', a: '\u0007', b: '\b', f: '\f', v: '\v' }
185
+ const MAX_CODE_POINT = 0x10ffff
186
+ const ANSI_C_NUMERIC = /^(x[0-9a-fA-F]{1,2}|u[0-9a-fA-F]{1,4}|U[0-9a-fA-F]{1,8}|[0-7]{1,3})/
187
+
188
+ /** Decodes one escape of a `$'…'` string at the cursor, which stands on the backslash. */
189
+ const readAnsiCEscape = (cursor: Cursor): string => {
190
+ const numeric = cursor.source.slice(cursor.index + 1).match(ANSI_C_NUMERIC)?.[0]
191
+ if (numeric) {
192
+ cursor.index += 1 + numeric.length
193
+ const radix = /^[0-7]/.test(numeric) ? 8 : 16
194
+ const codePoint = Number.parseInt(radix === 8 ? numeric : numeric.slice(1), radix)
195
+ if (codePoint > MAX_CODE_POINT) throw new ShellParseError('an escape beyond Unicode')
196
+ return String.fromCodePoint(codePoint)
197
+ }
198
+ const letter = peek(cursor, 1)
199
+ cursor.index += 2
200
+ return ANSI_C_ESCAPES[letter] ?? letter
201
+ }
202
+
203
+ const ansiCQuoted: Handler = (cursor, state) => {
204
+ if (peek(cursor) !== '$' || peek(cursor, 1) !== "'") return false
205
+ cursor.index += 2
206
+ let text = ''
207
+ while (!atEnd(cursor) && peek(cursor) !== "'") {
208
+ if (peek(cursor) === '\\') text += readAnsiCEscape(cursor)
209
+ else {
210
+ text += peek(cursor)
211
+ cursor.index++
212
+ }
213
+ }
214
+ if (atEnd(cursor)) throw new ShellParseError('unterminated $-quote')
215
+ cursor.index++
216
+ appendShaped(state, text)
217
+ return true
218
+ }
219
+
220
+ const substitute = (cursor: Cursor, state: ListState, skip: number, terminator: Terminator): void => {
221
+ cursor.index += skip
222
+ state.commands.push(...parseList(cursor, terminator))
223
+ appendShaped(state, EXPANSION)
224
+ }
225
+
226
+ const startsCommandSubstitution = (cursor: Cursor): boolean =>
227
+ peek(cursor) === '$' && peek(cursor, 1) === '(' && peek(cursor, 2) !== '('
228
+
229
+ const readDoubleQuotedEscape = (cursor: Cursor, state: ListState): void => {
230
+ const next = peek(cursor, 1)
231
+ if (next !== '\n') appendShaped(state, isOneOf('$`"\\', next) ? next : `\\${next}`)
232
+ cursor.index += 2
233
+ }
234
+
235
+ const backtick: Handler = (cursor, state) => {
236
+ if (peek(cursor) !== '`') return false
237
+ substitute(cursor, state, 1, '`')
238
+ return true
239
+ }
240
+
241
+ const arithmetic: Handler = (cursor, state) => {
242
+ if (peek(cursor) !== '$' || peek(cursor, 1) !== '(' || peek(cursor, 2) !== '(') return false
243
+ cursor.index++
244
+ skipBalanced(cursor, '(', ')')
245
+ appendShaped(state, EXPANSION)
246
+ return true
247
+ }
248
+
249
+ const commandSubstitution: Handler = (cursor, state) => {
250
+ if (!startsCommandSubstitution(cursor)) return false
251
+ substitute(cursor, state, 2, ')')
252
+ return true
253
+ }
254
+
255
+ /** Inside double quotes only these two still open a command. */
256
+ const QUOTED_HANDLERS: Handler[] = [backtick, commandSubstitution]
257
+
258
+ const doubleQuoted: Handler = (cursor, state) => {
259
+ if (peek(cursor) !== '"') return false
260
+ appendShaped(state, '')
261
+ cursor.index++
262
+ while (!atEnd(cursor)) {
263
+ const char = peek(cursor)
264
+ if (char === '"') {
265
+ cursor.index++
266
+ return true
267
+ }
268
+ if (char === '\\') readDoubleQuotedEscape(cursor, state)
269
+ else if (!QUOTED_HANDLERS.some((handle) => handle(cursor, state))) {
270
+ appendShaped(state, char)
271
+ cursor.index++
272
+ }
273
+ }
274
+ throw new ShellParseError('unterminated double quote')
275
+ }
276
+
277
+ /** `$"…"` is a double-quoted string translated by the locale; the text is the same. */
278
+ const localeQuoted: Handler = (cursor, state) => {
279
+ if (peek(cursor) !== '$' || peek(cursor, 1) !== '"') return false
280
+ cursor.index++
281
+ return doubleQuoted(cursor, state)
282
+ }
283
+
284
+ const parameterExpansion: Handler = (cursor, state) => {
285
+ if (peek(cursor) !== '$') return false
286
+ if (peek(cursor, 1) === '{') {
287
+ cursor.index++
288
+ skipBalanced(cursor, '{', '}')
289
+ } else cursor.index++
290
+ appendShaped(state, EXPANSION)
291
+ return true
292
+ }
293
+
294
+ const processSubstitution: Handler = (cursor, state) => {
295
+ if (!isOneOf('<>', peek(cursor)) || peek(cursor, 1) !== '(') return false
296
+ endWord(state)
297
+ substitute(cursor, state, 2, ')')
298
+ return true
299
+ }
300
+
301
+ const hereString: Handler = (cursor, state) => {
302
+ if (peek(cursor) !== '<' || peek(cursor, 1) !== '<' || peek(cursor, 2) !== '<') return false
303
+ endWord(state)
304
+ cursor.index += 3
305
+ state.dropNextWord = true
306
+ return true
307
+ }
308
+
309
+ const hereDocument: Handler = (cursor, state) => {
310
+ if (peek(cursor) !== '<' || peek(cursor, 1) !== '<') return false
311
+ endWord(state)
312
+ cursor.index += 2
313
+ const stripTabs = peek(cursor) === '-'
314
+ if (stripTabs) cursor.index++
315
+ while (isOneOf(BLANKS, peek(cursor))) cursor.index++
316
+ state.heredocs.push(readHeredocHeader(cursor, stripTabs))
317
+ return true
318
+ }
319
+
320
+ const isFileDescriptor = (word: Word | undefined): boolean => word !== undefined && word.literal && /^\d+$/.test(word.value)
321
+
322
+ const redirection: Handler = (cursor, state) => {
323
+ const char = peek(cursor)
324
+ if (char !== '<' && char !== '>' && !(char === '&' && peek(cursor, 1) === '>')) return false
325
+ if (isFileDescriptor(state.word)) state.word = undefined
326
+ else endWord(state)
327
+ while (isOneOf(REDIRECTION_CHARS, peek(cursor))) cursor.index++
328
+ state.dropNextWord = true
329
+ return true
330
+ }
331
+
332
+ const operator: Handler = (cursor, state) => {
333
+ if (!isOneOf(OPERATORS, peek(cursor))) return false
334
+ endCommand(state)
335
+ while (isOneOf(OPERATORS, peek(cursor))) cursor.index++
336
+ return true
337
+ }
338
+
339
+ const subshell: Handler = (cursor, state) => {
340
+ if (peek(cursor) !== '(') return false
341
+ endCommand(state)
342
+ cursor.index++
343
+ state.commands.push(...parseList(cursor, ')'))
344
+ return true
345
+ }
346
+
347
+ const strayCloseParen: Handler = (cursor, state) => {
348
+ if (peek(cursor) !== ')') return false
349
+ endCommand(state)
350
+ cursor.index++
351
+ return true
352
+ }
353
+
354
+ const plainChar = (cursor: Cursor, state: ListState): void => {
355
+ currentWord(state).value += peek(cursor)
356
+ cursor.index++
357
+ }
358
+
359
+ /**
360
+ * First match wins, so a handler for a longer prefix precedes the one for its own prefix:
361
+ * `\\⏎` before `\\`, `$'`, `$"`, `$((` and `$(` before `$`, `<(`, `<<<` and `<<` before `<`.
362
+ */
363
+ const HANDLERS: Handler[] = [
364
+ lineContinuation,
365
+ escapedChar,
366
+ blank,
367
+ newline,
368
+ comment,
369
+ ansiCQuoted,
370
+ localeQuoted,
371
+ singleQuoted,
372
+ doubleQuoted,
373
+ backtick,
374
+ arithmetic,
375
+ commandSubstitution,
376
+ parameterExpansion,
377
+ processSubstitution,
378
+ hereString,
379
+ hereDocument,
380
+ redirection,
381
+ operator,
382
+ subshell,
383
+ strayCloseParen,
384
+ ]
385
+
386
+ const parseList = (cursor: Cursor, terminator: Terminator): SimpleCommand[] => {
387
+ const state = newListState()
388
+ while (!atEnd(cursor)) {
389
+ if (terminator !== undefined && peek(cursor) === terminator) {
390
+ cursor.index++
391
+ endCommand(state)
392
+ return state.commands
393
+ }
394
+ if (!HANDLERS.some((handle) => handle(cursor, state))) plainChar(cursor, state)
395
+ }
396
+ if (terminator !== undefined) throw new ShellParseError(`missing ${terminator}`)
397
+ endCommand(state)
398
+ return state.commands
399
+ }
400
+
401
+ /**
402
+ * Splits a shell script into its simple commands, the way a permission rule sees them: at `&&`,
403
+ * `||`, `;`, `|`, `&` and newlines, and into subshells, command substitutions and process
404
+ * substitutions. Here-document bodies, comments and redirection targets are not commands and are
405
+ * dropped. Throws `ShellParseError` on an unterminated quote or substitution.
406
+ */
407
+ export const parseShell = (script: string): SimpleCommand[] => parseList({ source: script, index: 0 }, undefined)
@@ -0,0 +1,36 @@
1
+ const lineEnd = (script: string, from: number): number => {
2
+ const end = script.indexOf('\n', from)
3
+ return end === -1 ? script.length : end
4
+ }
5
+
6
+ /** The index after the quote that closes the one at `from`; a backslash escapes inside double quotes. */
7
+ const afterQuoted = (script: string, from: number): number => {
8
+ const quote = script[from]
9
+ let index = from + 1
10
+ while (index < script.length && script[index] !== quote) index += quote === '"' && script[index] === '\\' ? 2 : 1
11
+ return index + 1
12
+ }
13
+
14
+ /** A lone `&`, not `&&`, `&>`, `>&`, `<&` or `|&`. */
15
+ const isLoneAmpersand = (script: string, index: number): boolean =>
16
+ script[index + 1] !== '&' && script[index + 1] !== '>' && !'&<>|'.includes(script[index - 1] ?? ' ')
17
+
18
+ /**
19
+ * Whether an unquoted `&` outside a comment sends some command of the script into the background. It
20
+ * answers for the whole script, not for one command: `sleep 1 & jh watch` counts as well.
21
+ * Here-document bodies are input and belong out of `script` before it is asked.
22
+ */
23
+ export const hasBackgroundJob = (script: string): boolean => {
24
+ let index = 0
25
+ let wordStart = true
26
+ while (index < script.length) {
27
+ const char = script[index] ?? ''
28
+ if (char === '&' && isLoneAmpersand(script, index)) return true
29
+ if (char === '\\') index += 2
30
+ else if (char === "'" || char === '"') index = afterQuoted(script, index)
31
+ else if (char === '#' && wordStart) index = lineEnd(script, index)
32
+ else index += char === '&' && script[index + 1] === '&' ? 2 : 1
33
+ wordStart = /[\s;|&()]/.test(char)
34
+ }
35
+ return false
36
+ }
@@ -0,0 +1,53 @@
1
+ import { startsIn, type WatcherStart } from './starts'
2
+ import { startsInText } from './text-fallback'
3
+
4
+ export type BashToolInput = { command?: string; run_in_background?: boolean }
5
+
6
+ const LINE_CONTINUATION = /\\\n\s*/g
7
+
8
+ const STREAM_REFUSAL = [
9
+ 'jh watch --stream never ends, so a Bash call never reports it, not even in the background.',
10
+ 'Start the same command with the Monitor tool (timeout_ms 1800000), where every line it prints reaches you.',
11
+ ].join('\n')
12
+
13
+ const BACKGROUND_REFUSAL = [
14
+ 'jh watch only reports back as a background task or under the Monitor tool.',
15
+ 'Start it with the Monitor tool and --stream, or as its own Bash call with run_in_background: true.',
16
+ ].join('\n')
17
+
18
+ const BACKGROUND_JOB_REFUSAL = [
19
+ 'jh watch in a call that sends a command off with & : a trailing & ends the call before the watcher reports.',
20
+ 'Start it with the Monitor tool and --stream, or as its own Bash call with run_in_background: true and no &.',
21
+ ].join('\n')
22
+
23
+ /**
24
+ * The starts in a call. Any failure of the parse (an unreadable call, nesting past the limit, or an error
25
+ * nobody foresaw such as a stack overflow) falls back to the text match: a hook that throws lets the call
26
+ * through, so a doubtful call is refused instead.
27
+ */
28
+ export const startsOf = (command: string, parse: typeof startsIn = startsIn): WatcherStart[] => {
29
+ const script = command.replace(LINE_CONTINUATION, ' ')
30
+ try {
31
+ return parse(script, false)
32
+ } catch {
33
+ return startsInText(script)
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The reason to refuse a Bash call that starts `jh watch` where its report goes unseen, or null.
39
+ * A start is a simple command whose program runs the watcher (the `jh` bin or the older `agent-kit` one, a package runner with
40
+ * the package, or `bun` with the CLI's script) with the subcommand `watch`, also behind wrappers, inside
41
+ * `bash -c`, `eval`, a substitution, or a here-document or here-string fed to a shell. Text that only
42
+ * names the command (an argument, a quoted string, a data here-document) is no start. A `--stream` start
43
+ * never finishes, so no Bash call reports it; a single run reports only as its own background task, in a
44
+ * call that sends nothing off with `&`.
45
+ */
46
+ export const watcherStartViolation = (input: BashToolInput): string | null => {
47
+ if (!input.command) return null
48
+ const starts = startsOf(input.command)
49
+ if (starts.length === 0) return null
50
+ if (starts.some((start) => start.stream)) return STREAM_REFUSAL
51
+ if (input.run_in_background !== true) return BACKGROUND_REFUSAL
52
+ return starts.some((start) => start.callHasBackgroundJob) ? BACKGROUND_JOB_REFUSAL : null
53
+ }
@@ -0,0 +1,11 @@
1
+ // PreToolUse hook for Bash, shipped by jackherd: refuses a `jh watch` start whose
2
+ // reports would go unseen. Reads the hook input on stdin; exit 2 blocks the call and hands the reason on
3
+ // stderr back to the model, exit 0 leaves the call to the permission rules.
4
+ import { watcherStartViolation } from './decision'
5
+
6
+ const payload = await Bun.stdin.json()
7
+ const violation = watcherStartViolation(payload.tool_input ?? {})
8
+ if (violation) {
9
+ console.error(violation)
10
+ process.exit(2)
11
+ }
@@ -0,0 +1,32 @@
1
+ import { parseShell } from '../git-guard/shell'
2
+
3
+ /** Programs that run their `-c` argument as a command line; the names of shells among them also read stdin as a script. */
4
+ export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
5
+
6
+ const SHELL_NAME = `(?:${[...SHELLS].join('|')})`
7
+
8
+ const HEREDOC = /(<<-?\s*\\?(['"]?)([^\s;&|<>()'"]+)\2)([^\n]*)\n([\s\S]*?)\n\t*\3(?=\n|$)/g
9
+ const SHELL_INPUT = new RegExp(`(?:^|[\\s;&|(/])${SHELL_NAME}\\b`)
10
+ const PIPED_TO_SHELL = new RegExp(`\\|\\s*(?:\\S*/)?${SHELL_NAME}\\b`)
11
+ const SHELL_HERESTRING = new RegExp(`(?:^|[\\s;&|(/])${SHELL_NAME}\\b[^\\n<]*<<<\\s*([^\\n]*)`, 'g')
12
+
13
+ /**
14
+ * The text of the line in front of a here-document operator. Found by looking back from a match instead of by
15
+ * a leading `[^\n]*` in the pattern, which would rescan the rest of the line from every position (quadratic).
16
+ */
17
+ const lineBefore = (script: string, index: number): string => script.slice(script.lastIndexOf('\n', index - 1) + 1, index)
18
+
19
+ /** The script without here-document bodies, which are input, not commands of this script. */
20
+ export const withoutHeredocBodies = (script: string): string =>
21
+ script.replace(HEREDOC, (_whole, operator: string, _quote, _tag, rest: string) => `${operator}${rest}`)
22
+
23
+ /** The bodies of here-documents a shell reads as its script: `bash <<EOF`, `sh -s <<EOF`, `cat <<EOF | sh`. */
24
+ export const shellHeredocBodies = (script: string): string[] =>
25
+ [...script.matchAll(HEREDOC)].flatMap((match) => {
26
+ const [, , , , rest = '', body = ''] = match
27
+ return SHELL_INPUT.test(lineBefore(script, match.index)) || PIPED_TO_SHELL.test(rest) ? [body] : []
28
+ })
29
+
30
+ /** The strings a shell reads as its script from a here-string: `bash <<< "<script>"`. */
31
+ export const shellHereStrings = (script: string): string[] =>
32
+ [...script.matchAll(SHELL_HERESTRING)].flatMap(([, rest = '']) => parseShell(rest)[0]?.[0]?.value ?? [])