jackherd 1.8.0 → 1.9.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 (131) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +41 -60
  4. package/assets/branding/README.md +5 -7
  5. package/assets/branding/jackherd-logo-outline.svg +1 -0
  6. package/assets/branding/outline.test.ts +30 -0
  7. package/assets/icons/bark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide.svg +1 -1
  10. package/assets/icons/ear.svg +1 -1
  11. package/assets/icons/lanes.svg +1 -1
  12. package/assets/icons/plan.svg +1 -1
  13. package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +19 -3
  16. package/dist/plan-format.d.ts +22 -3
  17. package/dist/plan-format.js +21 -7
  18. package/herdr-plugin/herdr-plugin.toml +1 -1
  19. package/install.sh +1 -1
  20. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  21. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  22. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  23. package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
  24. package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
  25. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  26. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  27. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  28. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  29. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  30. package/kit/hooks/hooks.json +9 -0
  31. package/kit/hooks/statusline.sh +1 -1
  32. package/kit/workflow/skills/implement/SKILL.md +2 -0
  33. package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
  34. package/package.json +3 -1
  35. package/schemas/jackherd.schema.json +26 -2
  36. package/src/cli.ts +3 -0
  37. package/src/config/read-config.ts +5 -1
  38. package/src/config/schema.ts +22 -2
  39. package/src/context/fill.ts +12 -0
  40. package/src/context/session-log.ts +43 -1
  41. package/src/plan-format/clock.ts +4 -0
  42. package/src/plan-format/dependencies.ts +2 -5
  43. package/src/plan-format/lanes.ts +30 -5
  44. package/src/plan-format/terminal-text.ts +4 -0
  45. package/src/plan-format/waiting.ts +48 -0
  46. package/src/plans/active-tasks.ts +60 -0
  47. package/src/plans/current-phase.ts +17 -7
  48. package/src/plans/current-task.ts +63 -5
  49. package/src/plans/lane-standing.ts +12 -6
  50. package/src/plans/lane-summary.ts +141 -74
  51. package/src/plans/lane-tickets.ts +15 -0
  52. package/src/plans/layout-phase.ts +57 -18
  53. package/src/plans/layout-summary.ts +85 -67
  54. package/src/plans/layout.ts +253 -89
  55. package/src/plans/live.ts +22 -0
  56. package/src/plans/phase-models.ts +1 -1
  57. package/src/plans/phase-ranges.ts +45 -0
  58. package/src/plans/pull-requests.ts +6 -0
  59. package/src/plans/selection.ts +19 -16
  60. package/src/plans/sent-files-read.ts +22 -0
  61. package/src/plans/sent-files.ts +121 -0
  62. package/src/plans/signals-read.ts +54 -0
  63. package/src/plans/snapshot.ts +251 -55
  64. package/src/plans/subagents-read.ts +82 -0
  65. package/src/plans/subagents.ts +132 -0
  66. package/src/plans/texts.ts +16 -11
  67. package/src/plans/unlisted-lanes.ts +10 -5
  68. package/src/plans/view.ts +3 -3
  69. package/src/plans/waiting-click.ts +76 -0
  70. package/src/preview/command.ts +83 -0
  71. package/src/preview/embed.ts +209 -0
  72. package/src/preview/images.ts +89 -0
  73. package/src/preview/open.ts +12 -0
  74. package/src/preview/options.ts +32 -0
  75. package/src/preview/page.ts +89 -0
  76. package/src/preview/render.ts +41 -0
  77. package/src/settled/board-row.ts +1 -2
  78. package/src/shared/is-record.ts +2 -0
  79. package/src/shared/private-records.ts +48 -0
  80. package/src/task-signals/agent.ts +15 -0
  81. package/src/task-signals/claude-read.ts +72 -0
  82. package/src/task-signals/claude-signals.ts +112 -0
  83. package/src/task-signals/command.ts +71 -0
  84. package/src/task-signals/fixtures/session.jsonl +7 -0
  85. package/src/task-signals/matcher.ts +96 -0
  86. package/src/task-signals/record-store.ts +40 -0
  87. package/src/task-signals/record.ts +68 -0
  88. package/src/task-signals/text.ts +13 -0
  89. package/src/task-signals/types.ts +14 -0
  90. package/src/terminal/open.ts +13 -6
  91. package/src/terminal/text.ts +8 -1
  92. package/src/terminal/width.ts +2 -1
  93. package/src/update/command.ts +2 -0
  94. package/src/update/config-shares.ts +30 -0
  95. package/src/update/update.ts +12 -1
  96. package/src/vocabulary/index.ts +33 -1
  97. package/src/waiting/command.ts +35 -0
  98. package/src/watch/appointments.ts +5 -5
  99. package/src/watch/approvals.ts +2 -2
  100. package/src/watch/command.ts +91 -5
  101. package/src/watch/failed-tests.ts +56 -10
  102. package/src/watch/failure-read.ts +18 -6
  103. package/src/watch/failures.ts +29 -5
  104. package/src/watch/feedback.ts +1 -1
  105. package/src/watch/gh-process.ts +9 -3
  106. package/src/watch/json-guards.ts +0 -3
  107. package/src/watch/lane-fill.ts +1 -1
  108. package/src/watch/lane-model.ts +1 -1
  109. package/src/watch/lane-parallel.ts +1 -1
  110. package/src/watch/lane-progress.ts +19 -7
  111. package/src/watch/lane-report.ts +5 -1
  112. package/src/watch/lane-rest.ts +28 -0
  113. package/src/watch/plan-badges.ts +9 -2
  114. package/src/watch/previews.ts +1 -1
  115. package/src/watch/pull-requests.ts +2 -1
  116. package/src/watch/source-path.ts +1 -1
  117. package/src/watch/sources.ts +1 -1
  118. package/src/watch/stream-output.ts +12 -7
  119. package/src/watch/task-parallel-input.ts +118 -0
  120. package/src/watch/task-parallel-read.ts +149 -0
  121. package/src/watch/task-parallel-touched.ts +39 -0
  122. package/src/watch/task-parallel.ts +206 -0
  123. package/src/watch/waiting-line.ts +19 -0
  124. package/src/watch/watch-events.ts +13 -1
  125. package/src/watch/workflow-runs.ts +2 -1
  126. package/assets/icons/bark-dark.svg +0 -1
  127. package/assets/icons/bowl-dark.svg +0 -1
  128. package/assets/icons/decide-dark.svg +0 -1
  129. package/assets/icons/ear-dark.svg +0 -1
  130. package/assets/icons/lanes-dark.svg +0 -1
  131. package/assets/icons/plan-dark.svg +0 -1
@@ -26,6 +26,7 @@ import {
26
26
  shellDefinitionAt,
27
27
  shellDefinitionHint,
28
28
  } from '../shared/shell-aliases'
29
+ import { resolvePlainXargs } from './xargs-program'
29
30
 
30
31
  export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
31
32
 
@@ -61,6 +62,9 @@ type Finding = { command: string; merge: PlainMerge | undefined; definition?: Sh
61
62
  /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
62
63
  type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
63
64
 
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
+
64
68
  /** The parts of a `gh api` call that decide whether it writes, and where. */
65
69
  type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
66
70
 
@@ -129,6 +133,16 @@ const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
129
133
  const EXPANDED_FUNCTION_NOTE =
130
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.'
131
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
+
132
146
  const PASS: MergeGuardDecision = { kind: 'pass' }
133
147
 
134
148
  /**
@@ -275,10 +289,10 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
275
289
  const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
276
290
  command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
277
291
 
278
- /** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
279
- const mentionsDangerousApiCall = (script: string): boolean => {
280
- const at = findWord(script, API_WORD, 0)
281
- return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
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))
282
296
  }
283
297
 
284
298
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
@@ -313,33 +327,67 @@ const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole:
313
327
  return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
314
328
  }
315
329
 
316
- const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
317
-
318
- const findWord = (script: string, word: string, from: number): number => {
319
- for (let at = script.indexOf(word, from); at !== -1; at = script.indexOf(word, at + 1)) {
320
- if (!isWordCharacter(script[at - 1]) && !isWordCharacter(script[at + word.length])) return at
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
321
342
  }
322
- return -1
343
+ return tokens
323
344
  }
324
345
 
325
- /** Whether the script names the words as whole words in this order; a linear search, so a long script cannot stall it. */
326
- const namesInOrder = (script: string, words: string[]): boolean => {
327
- let from = 0
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
328
357
  for (const word of words) {
329
- const at = findWord(script, word, from)
330
- if (at === -1) return false
331
- from = at + word.length
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++
332
368
  }
333
369
  return true
334
370
  }
335
371
 
336
- /** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
337
- const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
338
- commands
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
339
384
  .filter((command) =>
340
- command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
385
+ command === API_COMMAND
386
+ ? mentionsDangerousApiCall(script, tokens)
387
+ : spellsWords(tokens, command.split(' ').slice(1)),
341
388
  )
342
389
  .map((command) => ({ command, merge: undefined }))
390
+ }
343
391
 
344
392
  /** A program expanded from a variable or a substitution could be the guarded one. */
345
393
  const runsExpandedProgram = (words: SimpleCommand): boolean =>
@@ -423,7 +471,8 @@ const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: rea
423
471
 
424
472
  /** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
425
473
  const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
426
- const parsed = parseShell(script)
474
+ const guardedPrograms = programsOf(search.commands)
475
+ const parsed = parseShell(script).map((words) => resolvePlainXargs(words, guardedPrograms))
427
476
  const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
428
477
  const text = searchableText(whole, parsed)
429
478
  return [
@@ -0,0 +1,182 @@
1
+ import { programName, ruleStart } from '../shared/program-position'
2
+ import { expandsBraces, isExpanded, type SimpleCommand, type Word } from '../shared/shell'
3
+
4
+ /**
5
+ * What an `xargs` call is told to run: the program word (absent when it runs `echo`) and where it stands among the
6
+ * words after `xargs`, and the strings `-I`, `-i`, `--replace` or BSD's `-J` replace with the input, which may stand
7
+ * anywhere, the program included.
8
+ */
9
+ type XargsCall = { program: Word | undefined; at: number; replaceStrings: string[] }
10
+
11
+ /** One option word read: the words it used up, and the replace string it set, if any. */
12
+ type OptionRead = { width: number; replaceString?: string }
13
+
14
+ const XARGS = 'xargs'
15
+ const END_OF_OPTIONS = '--'
16
+ const DEFAULT_REPLACE_STRING = '{}'
17
+ const GLOB = /[*?[]/
18
+ const ECHO: Word = { value: 'echo', literal: true }
19
+
20
+ /** GNU and BSD short options: with a value (attached or the next word), with a value only when attached, without one. */
21
+ const SHORT_WITH_VALUE = new Set(['a', 'd', 'E', 'I', 'J', 'L', 'n', 'P', 'R', 's', 'S'])
22
+ const SHORT_WITH_ATTACHED_VALUE = new Set(['e', 'i', 'l'])
23
+ const SHORT_FLAGS = new Set(['0', 'o', 'p', 'r', 't', 'x'])
24
+ const SHORT_REPLACE = new Set(['I', 'J'])
25
+ const SHORT_OLD_REPLACE = 'i'
26
+
27
+ const LONG_WITH_VALUE = new Set([
28
+ '--arg-file',
29
+ '--delimiter',
30
+ '--max-args',
31
+ '--max-chars',
32
+ '--max-procs',
33
+ '--process-slot-var',
34
+ ])
35
+ const LONG_WITH_ATTACHED_VALUE = new Set(['--eof', '--max-lines', '--replace'])
36
+ const LONG_FLAGS = new Set([
37
+ '--exit',
38
+ '--help',
39
+ '--interactive',
40
+ '--no-run-if-empty',
41
+ '--null',
42
+ '--open-tty',
43
+ '--show-limits',
44
+ '--verbose',
45
+ '--version',
46
+ ])
47
+ const LONG_REPLACE = '--replace'
48
+
49
+ /**
50
+ * Programs that read or write the files their arguments name and start nothing, whatever the arguments say: input
51
+ * handed to them by `xargs` stays arguments. A program that runs text or a program its arguments name stays out (`git`
52
+ * with `-c alias.…`, `sed` with `e`, `awk` with `system()`, `rg --pre`, `sort --compress-program`), as does every
53
+ * program not listed: the list is closed, so an unknown program keeps the search of the whole text.
54
+ */
55
+ const PLAIN_PROGRAMS = new Set([
56
+ 'basename',
57
+ 'cat',
58
+ 'chmod',
59
+ 'dirname',
60
+ 'du',
61
+ 'echo',
62
+ 'egrep',
63
+ 'fgrep',
64
+ 'file',
65
+ 'grep',
66
+ 'head',
67
+ 'jq',
68
+ 'ls',
69
+ 'md5sum',
70
+ 'mkdir',
71
+ 'printf',
72
+ 'readlink',
73
+ 'realpath',
74
+ 'rm',
75
+ 'rmdir',
76
+ 'sha1sum',
77
+ 'sha256sum',
78
+ 'shasum',
79
+ 'stat',
80
+ 'tail',
81
+ 'touch',
82
+ 'tr',
83
+ 'uniq',
84
+ 'wc',
85
+ ])
86
+
87
+ /**
88
+ * A word the shell hands over as written, quotes and escapes aside: an expansion, a glob or braces could become
89
+ * several words, or none, and shift which word is the program.
90
+ */
91
+ const isFixed = ({ value }: Word): boolean => !isExpanded(value) && !GLOB.test(value) && !expandsBraces(value)
92
+
93
+ /** A value in the next word must be there and fixed. */
94
+ const nextValue = (words: Word[], index: number): Word | undefined => {
95
+ const next = words[index + 1]
96
+ return next !== undefined && isFixed(next) ? next : undefined
97
+ }
98
+
99
+ const readLongOption = (words: Word[], index: number): OptionRead | undefined => {
100
+ const { value } = words[index]
101
+ const at = value.indexOf('=')
102
+ const name = at === -1 ? value : value.slice(0, at)
103
+ const attached = at === -1 ? undefined : value.slice(at + 1)
104
+ if (LONG_WITH_VALUE.has(name)) {
105
+ if (attached !== undefined) return { width: 1 }
106
+ return nextValue(words, index) ? { width: 2 } : undefined
107
+ }
108
+ if (LONG_WITH_ATTACHED_VALUE.has(name)) {
109
+ const replaceString = name === LONG_REPLACE ? (attached ?? DEFAULT_REPLACE_STRING) : undefined
110
+ return { width: 1, replaceString }
111
+ }
112
+ return LONG_FLAGS.has(name) && attached === undefined ? { width: 1 } : undefined
113
+ }
114
+
115
+ /** A short option that takes a value: the rest of its word, or the next word when nothing is attached. */
116
+ const readShortValue = (words: Word[], index: number, letter: string, attached: string): OptionRead | undefined => {
117
+ if (attached !== '') return { width: 1, replaceString: SHORT_REPLACE.has(letter) ? attached : undefined }
118
+ const separate = nextValue(words, index)
119
+ if (separate === undefined) return undefined
120
+ return { width: 2, replaceString: SHORT_REPLACE.has(letter) ? separate.value : undefined }
121
+ }
122
+
123
+ /** A cluster of short options (`-0rn1`, `-tI{}`): flags, then at most one option that takes the rest as its value. */
124
+ const readShortOptions = (words: Word[], index: number): OptionRead | undefined => {
125
+ const { value } = words[index]
126
+ for (let at = 1; at < value.length; at++) {
127
+ const letter = value[at]
128
+ if (SHORT_FLAGS.has(letter)) continue
129
+ const attached = value.slice(at + 1)
130
+ if (SHORT_WITH_VALUE.has(letter)) return readShortValue(words, index, letter, attached)
131
+ if (!SHORT_WITH_ATTACHED_VALUE.has(letter)) return undefined
132
+ const replaceString = letter === SHORT_OLD_REPLACE ? attached || DEFAULT_REPLACE_STRING : undefined
133
+ return { width: 1, replaceString }
134
+ }
135
+ return { width: 1 }
136
+ }
137
+
138
+ const readOption = (words: Word[], index: number): OptionRead | undefined =>
139
+ words[index].value.startsWith('--') ? readLongOption(words, index) : readShortOptions(words, index)
140
+
141
+ /**
142
+ * Reads the words after `xargs` up to its program. An option this guard does not know, or a word before the program
143
+ * that is not fixed, makes the call unreadable: undefined.
144
+ */
145
+ const readXargsCall = (args: Word[]): XargsCall | undefined => {
146
+ const replaceStrings: string[] = []
147
+ for (let index = 0; index < args.length; ) {
148
+ const word = args[index]
149
+ if (!isFixed(word)) return undefined
150
+ if (word.value === END_OF_OPTIONS) return { program: args[index + 1], at: index + 1, replaceStrings }
151
+ if (!word.value.startsWith('-') || word.value === '-') return { program: word, at: index, replaceStrings }
152
+ const option = readOption(args, index)
153
+ if (option === undefined) return undefined
154
+ if (option.replaceString !== undefined) replaceStrings.push(option.replaceString)
155
+ index += option.width
156
+ }
157
+ return { program: undefined, at: args.length, replaceStrings }
158
+ }
159
+
160
+ const isPlainProgram = (program: Word, replaceStrings: string[], guardedPrograms: readonly string[]): boolean =>
161
+ isFixed(program) &&
162
+ !replaceStrings.some((replaceString) => program.value.includes(replaceString)) &&
163
+ PLAIN_PROGRAMS.has(program.value) &&
164
+ !guardedPrograms.includes(program.value)
165
+
166
+ /**
167
+ * An `xargs` at the start of the command that runs a fixed program starting nothing (D77) becomes that program's
168
+ * call, the words before `xargs` kept: its input then only adds arguments, so the program counts like any other call
169
+ * and the text around it is no sign. Without a program it becomes `echo`, which `xargs` then runs. Every other
170
+ * command comes back as it is, so `xargs` stays opaque for a program from an expansion or a replace string, a glob,
171
+ * a guarded program, a starter (a shell, `env`, `xargs`, `timeout`, an interpreter) and any program not on the list.
172
+ */
173
+ export const resolvePlainXargs = (words: SimpleCommand, guardedPrograms: readonly string[]): SimpleCommand => {
174
+ const start = ruleStart(words)
175
+ if (words[start] === undefined || programName(words[start]) !== XARGS) return words
176
+ const call = readXargsCall(words.slice(start + 1))
177
+ if (call === undefined) return words
178
+ const prefix = words.slice(0, start)
179
+ if (call.program === undefined) return [...prefix, ECHO]
180
+ if (!isPlainProgram(call.program, call.replaceStrings, guardedPrograms)) return words
181
+ return [...prefix, ...words.slice(start + 1 + call.at)]
182
+ }
@@ -0,0 +1,19 @@
1
+ // PostToolUse hook of Claude Code for `SendUserFile` and `Artifact`, shipped by jackherd: records each file a session
2
+ // sends to the user and each artifact it publishes for them, with time and caption, in `.jackherd/sent/` of the main
3
+ // checkout. The herd shows the files as badges on the lane they belong to and as something that waits on the user's
4
+ // answer, until the coordinator closes the record. It prints nothing and never blocks.
5
+ import { mainCheckoutOf } from '../shared/checkout'
6
+ import { runSentFile } from './run'
7
+ import { writeSentRecord } from './store'
8
+
9
+ try {
10
+ runSentFile(JSON.parse(await Bun.stdin.text()), {
11
+ mainCheckoutOf,
12
+ writeRecord: writeSentRecord,
13
+ now: () => Date.now(),
14
+ randomBytes: () => crypto.getRandomValues(new Uint8Array(6)),
15
+ })
16
+ } catch {
17
+ // Unreadable input records nothing.
18
+ }
19
+ process.exit(0)
@@ -0,0 +1,128 @@
1
+ import { isAbsolute, resolve } from 'node:path'
2
+ import { isRecord } from '../shared/json'
3
+
4
+ /**
5
+ * A send of files to the user as the hook records it: the files (absolute), the caption, when it happened, from where
6
+ * (the directory and session of the call, the subagent when one sent it) and when the coordinator closed it, null
7
+ * while the user's answer is awaited. Written by the hook, read by the herd and the watcher.
8
+ */
9
+ export type SentRecord = {
10
+ id: string
11
+ files: string[]
12
+ caption: string | null
13
+ sentAt: number
14
+ cwd: string
15
+ session: string
16
+ agentId: string | null
17
+ closedAt: number | null
18
+ }
19
+
20
+ export type SentCall = {
21
+ /** The tool input of `SendUserFile`: `files`, `caption`, `status`, `display`. */
22
+ toolInput: unknown
23
+ cwd: string
24
+ session: string
25
+ agentId: string | null
26
+ now: number
27
+ id: string
28
+ }
29
+
30
+ export type ArtifactCall = SentCall & { toolResponse: unknown }
31
+
32
+ const ID = /^[0-9a-f]{12}$/
33
+ const MAX_FILES = 20
34
+ const MAX_CAPTION = 200
35
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/
36
+ const CONTROLS = new RegExp(CONTROL.source, 'g')
37
+ /** The address of a published artifact: https on claude.ai, the host a person opens. */
38
+ const ARTIFACT_ADDRESS = /https:\/\/claude\.ai\/(?:code\/)?artifact\/[A-Za-z0-9_-]+/
39
+ const WHOLE_ARTIFACT_ADDRESS = new RegExp(`^${ARTIFACT_ADDRESS.source}$`)
40
+ /** The actions of the Artifact tool that publish; no action means publish. */
41
+ const READING_ACTIONS = new Set(['list', 'read', 'delete', 'open', 'pin', 'unpin', 'quickstart'])
42
+ const MAX_DEPTH = 4
43
+
44
+ const captionOf = (value: unknown): string | null => {
45
+ if (typeof value !== 'string') return null
46
+ const text = value.replace(CONTROLS, ' ').replace(/\s+/g, ' ').trim()
47
+ return text === '' ? null : text.slice(0, MAX_CAPTION)
48
+ }
49
+
50
+ /** A file entry the herd can show and open: a non-empty string without a control character. */
51
+ const isFileEntry = (file: unknown): file is string => typeof file === 'string' && file !== '' && !CONTROL.test(file)
52
+
53
+ /** The fields every record shares: who sent it, when, and still open. */
54
+ const recordOf = (
55
+ { cwd, session, agentId, now, id }: Omit<SentCall, 'toolInput'>,
56
+ files: string[],
57
+ caption: string | null,
58
+ ): SentRecord => ({ id, files, caption, sentAt: now, cwd, session, agentId, closedAt: null })
59
+
60
+ /** The record of a `SendUserFile` call; null when its input names no file or a file with a control character. */
61
+ export const sentRecordOf = ({ toolInput, ...call }: SentCall): SentRecord | null => {
62
+ const files: unknown[] = isRecord(toolInput) && Array.isArray(toolInput.files) ? toolInput.files : []
63
+ if (files.length === 0 || !files.every(isFileEntry)) return null
64
+ const paths = (files as string[])
65
+ .slice(0, MAX_FILES)
66
+ .map((file) => (isAbsolute(file) ? file : resolve(call.cwd, file)))
67
+ return recordOf(call, paths, isRecord(toolInput) ? captionOf(toolInput.caption) : null)
68
+ }
69
+
70
+ /**
71
+ * The strings of an answer, wherever it carries them: the answer itself, an object's `url`, `text` and `content`, a
72
+ * list's entries.
73
+ */
74
+ const stringsOf = (value: unknown, depth = 0): string[] => {
75
+ if (typeof value === 'string') return [value]
76
+ if (depth >= MAX_DEPTH) return []
77
+ if (Array.isArray(value)) return value.flatMap((entry) => stringsOf(entry, depth + 1))
78
+ if (!isRecord(value)) return []
79
+ return [value.url, value.text, value.content].flatMap((entry) => stringsOf(entry, depth + 1))
80
+ }
81
+
82
+ /** The first artifact address an answer names, whether it is an object with `url` or text. */
83
+ const artifactAddressIn = (toolResponse: unknown): string | null => {
84
+ for (const text of stringsOf(toolResponse)) {
85
+ const address = ARTIFACT_ADDRESS.exec(text)?.[0]
86
+ if (address) return address
87
+ }
88
+ return null
89
+ }
90
+
91
+ /** The title the answer names, else the input's `title` or `label`. */
92
+ const artifactCaptionOf = (toolInput: Record<string, unknown>, toolResponse: unknown): string | null =>
93
+ captionOf(isRecord(toolResponse) ? toolResponse.title : null) ??
94
+ captionOf(toolInput.title) ??
95
+ captionOf(toolInput.label)
96
+
97
+ /**
98
+ * The record of an Artifact publish (or update) as a send: its file is the artifact's address, read from the tool's
99
+ * answer (an object with `url`, or text such as `Published … at <address> (Version 3 …)`), and its caption the
100
+ * artifact's title. Null for a listing, a read, an asset upload and an answer without such an address.
101
+ */
102
+ export const artifactRecordOf = ({ toolInput, toolResponse, ...call }: ArtifactCall): SentRecord | null => {
103
+ if (!isRecord(toolInput)) return null
104
+ const action = toolInput.action
105
+ if ((typeof action === 'string' && READING_ACTIONS.has(action)) || toolInput.asset === true) return null
106
+ const address = artifactAddressIn(toolResponse)
107
+ if (address === null) return null
108
+ return recordOf(call, [address], artifactCaptionOf(toolInput, toolResponse))
109
+ }
110
+
111
+ /** A file entry of a record: an artifact address as a whole, or a path. */
112
+ const isRecordedFile = (file: unknown): boolean =>
113
+ typeof file === 'string' && (!file.startsWith('https://') || WHOLE_ARTIFACT_ADDRESS.test(file))
114
+
115
+ /** A record of the shape the hook writes; the herd reads files only through this. */
116
+ export const isSentRecord = (value: unknown): value is SentRecord =>
117
+ isRecord(value) &&
118
+ typeof value.id === 'string' &&
119
+ ID.test(value.id) &&
120
+ Array.isArray(value.files) &&
121
+ value.files.length > 0 &&
122
+ value.files.every(isRecordedFile) &&
123
+ (value.caption === null || typeof value.caption === 'string') &&
124
+ typeof value.sentAt === 'number' &&
125
+ typeof value.cwd === 'string' &&
126
+ typeof value.session === 'string' &&
127
+ (value.agentId === null || typeof value.agentId === 'string') &&
128
+ (value.closedAt === null || typeof value.closedAt === 'number')
@@ -0,0 +1,48 @@
1
+ import { isRecord } from '../shared/json'
2
+ import { artifactRecordOf, type SentRecord, sentRecordOf } from './record'
3
+
4
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
5
+ export type SentFileDependencies = {
6
+ mainCheckoutOf: (cwd: string) => string
7
+ writeRecord: (main: string, record: SentRecord) => void
8
+ now: () => number
9
+ randomBytes: () => Uint8Array
10
+ }
11
+
12
+ /** The tools whose calls are recorded: files sent to the user and artifacts published for them. */
13
+ const SEND_USER_FILE_TOOL = 'SendUserFile'
14
+ const ARTIFACT_TOOL = 'Artifact'
15
+ const ID_BYTES = 6
16
+
17
+ /** The id from random bytes the caller draws: 12 hex characters. */
18
+ const idFrom = (bytes: Uint8Array): string =>
19
+ [...bytes.slice(0, ID_BYTES)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
20
+
21
+ /**
22
+ * Records the files a session sent to the user and the artifacts it published for them, for the herd and the
23
+ * watcher, in the main checkout of the call's directory. Claude Code's hook input: `cwd`, `session_id`,
24
+ * `tool_name`, `tool_input`, and `agent_id` inside a subagent. Records nothing for another tool or a call without
25
+ * files, and nothing on any failure: the record never stands in the agent's way.
26
+ */
27
+ export const runSentFile = (input: unknown, dependencies: SentFileDependencies): void => {
28
+ try {
29
+ if (!isRecord(input) || (input.tool_name !== SEND_USER_FILE_TOOL && input.tool_name !== ARTIFACT_TOOL)) return
30
+ const { cwd, session_id: session, agent_id: agentId } = input
31
+ if (typeof cwd !== 'string' || typeof session !== 'string') return
32
+ const call = {
33
+ toolInput: input.tool_input,
34
+ cwd,
35
+ session,
36
+ agentId: typeof agentId === 'string' ? agentId : null,
37
+ now: dependencies.now(),
38
+ id: idFrom(dependencies.randomBytes()),
39
+ }
40
+ const record =
41
+ input.tool_name === ARTIFACT_TOOL
42
+ ? artifactRecordOf({ ...call, toolResponse: input.tool_response })
43
+ : sentRecordOf(call)
44
+ if (record) dependencies.writeRecord(dependencies.mainCheckoutOf(cwd), record)
45
+ } catch {
46
+ // A failed record is no record; the herd shows no badge.
47
+ }
48
+ }
@@ -0,0 +1,18 @@
1
+ import { chmodSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { ensurePrivateDirectory, PRIVATE_FILE } from '../shared/private-directory'
4
+ import type { SentRecord } from './record'
5
+
6
+ /** Where the records lie: in the main checkout beside `.jackherd/approvals`, which the herd reads. Ignored by git. */
7
+ export const sentDirectory = (main: string): string => join(main, '.jackherd', 'sent')
8
+
9
+ /** Writes the record through a temporary file, so a reader never sees half of it. */
10
+ export const writeSentRecord = (main: string, record: SentRecord): void => {
11
+ const directory = sentDirectory(main)
12
+ ensurePrivateDirectory(directory)
13
+ const path = join(directory, `${record.id}.json`)
14
+ const temporary = `${path}.${process.pid}.tmp`
15
+ writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: PRIVATE_FILE })
16
+ chmodSync(temporary, PRIVATE_FILE)
17
+ renameSync(temporary, path)
18
+ }
@@ -72,6 +72,18 @@ const planPathOf = (boardDirectory: string, cell: string): string[] => {
72
72
  return href === undefined ? [] : [posix.normalize(posix.join(boardDirectory, href.split('#')[0]))]
73
73
  }
74
74
 
75
+ /** The rows of one lane since it was last cleaned up: a named row without the mark after a closed one starts it anew. */
76
+ const currentRows = <Row extends { cells: string[] }>(rows: Row[], closedWord: string): Row[] =>
77
+ rows.reduce<{ closed: boolean; kept: Row[] }>(
78
+ ({ closed, kept }, row) => {
79
+ const first = row.cells[0] ?? ''
80
+ const marked = first.includes(closedWord)
81
+ const startsAnew = closed && !marked && !first.startsWith(CONTINUATION)
82
+ return { closed: marked || (closed && !startsAnew), kept: startsAnew ? [row] : [...kept, row] }
83
+ },
84
+ { closed: false, kept: [] },
85
+ ).kept
86
+
75
87
  /** Every lane the lanes table names, in table order. */
76
88
  export const laneNamesOf = (markdown: string, language: BoardLanguage): string[] => [
77
89
  ...new Set(laneRows(markdown, language).map(({ lane }) => lane)),
@@ -84,8 +96,9 @@ export const lanePlansOf = (
84
96
  lane: string,
85
97
  language: BoardLanguage,
86
98
  ): LanePlans | undefined => {
87
- const rows = laneRows(markdown, language).filter((row) => row.lane === lane)
88
- if (rows.length === 0) return undefined
99
+ const all = laneRows(markdown, language).filter((row) => row.lane === lane)
100
+ if (all.length === 0) return undefined
101
+ const rows = currentRows(all, BOARD_WORDS[language].laneClosed)
89
102
  return {
90
103
  closed: rows.some(({ cells }) => (cells[0] ?? '').includes(BOARD_WORDS[language].laneClosed)),
91
104
  plans: [...new Set(rows.flatMap(({ cells }) => planPathOf(boardDirectory, cells[PLAN_COLUMN] ?? '')))],
@@ -70,6 +70,15 @@
70
70
  "timeout": 10
71
71
  }
72
72
  },
73
+ "sent-file": {
74
+ "event": "PostToolUse",
75
+ "matcher": "SendUserFile|Artifact",
76
+ "handler": {
77
+ "type": "command",
78
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/sent-file/index.ts",
79
+ "timeout": 10
80
+ }
81
+ },
73
82
  "watch-guard": {
74
83
  "event": "PreToolUse",
75
84
  "matcher": "Bash",
@@ -52,7 +52,7 @@ worktrees='.claude/worktrees'
52
52
  [ -n "$config" ] && worktrees=$(jq -r '.worktreeDirectory // ".claude/worktrees"' "$config" 2>/dev/null)
53
53
  case "${cwd:-$PWD}/" in
54
54
  */"$worktrees"/*) block='.plans.context'; warn_at=250000; alert_at=350000 ;;
55
- *) block='.plans.context.coordinator'; warn_at=200000; alert_at=300000 ;;
55
+ *) block='.plans.context.coordinator'; warn_at=350000; alert_at=500000 ;;
56
56
  esac
57
57
  if [ -n "$config" ]; then
58
58
  warn=$(jq -r "$block.warnTokens // empty | floor" "$config" 2>/dev/null)
@@ -48,6 +48,8 @@ Before you write a single line:
48
48
 
49
49
  ## Step 2: Implement
50
50
 
51
+ **Report each task before you start it.** Mark it in progress in your own task list; without one, run `jh task start "<task text>"`, and `jh task done` when it is finished. The herd shows the task you work on from that signal.
52
+
51
53
  **Money, identifiers, state transitions: the test comes first** — write it, run it, see it red, then implement. `.claude/rules/kit/testing.md` → Test-first carries the reason.
52
54
 
53
55
  Follow `.claude/rules/kit/code-style.md`: types → pure functions → higher-order functions, names that reveal intent, no section-label comments.