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.
- package/CHANGELOG.md +39 -0
- package/INSTALL.md +2 -0
- package/README.md +41 -60
- package/assets/branding/README.md +5 -7
- package/assets/branding/jackherd-logo-outline.svg +1 -0
- package/assets/branding/outline.test.ts +30 -0
- package/assets/icons/bark.svg +1 -1
- package/assets/icons/bowl.svg +1 -1
- package/assets/icons/decide.svg +1 -1
- package/assets/icons/ear.svg +1 -1
- package/assets/icons/lanes.svg +1 -1
- package/assets/icons/plan.svg +1 -1
- package/dist/{chunk-M6DDZEW4.js → chunk-MCP4XJQH.js} +17 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +19 -3
- package/dist/plan-format.d.ts +22 -3
- package/dist/plan-format.js +21 -7
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +22 -4
- package/kit/hooks/hooks/approval-gate/run.ts +29 -6
- package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +70 -21
- package/kit/hooks/hooks/merge-guard/xargs-program.ts +182 -0
- package/kit/hooks/hooks/sent-file/index.ts +19 -0
- package/kit/hooks/hooks/sent-file/record.ts +128 -0
- package/kit/hooks/hooks/sent-file/run.ts +48 -0
- package/kit/hooks/hooks/sent-file/store.ts +18 -0
- package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
- package/kit/hooks/hooks.json +9 -0
- package/kit/hooks/statusline.sh +1 -1
- package/kit/workflow/skills/implement/SKILL.md +2 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +31 -6
- package/package.json +3 -1
- package/schemas/jackherd.schema.json +26 -2
- package/src/cli.ts +3 -0
- package/src/config/read-config.ts +5 -1
- package/src/config/schema.ts +22 -2
- package/src/context/fill.ts +12 -0
- package/src/context/session-log.ts +43 -1
- package/src/plan-format/clock.ts +4 -0
- package/src/plan-format/dependencies.ts +2 -5
- package/src/plan-format/lanes.ts +30 -5
- package/src/plan-format/terminal-text.ts +4 -0
- package/src/plan-format/waiting.ts +48 -0
- package/src/plans/active-tasks.ts +60 -0
- package/src/plans/current-phase.ts +17 -7
- package/src/plans/current-task.ts +63 -5
- package/src/plans/lane-standing.ts +12 -6
- package/src/plans/lane-summary.ts +141 -74
- package/src/plans/lane-tickets.ts +15 -0
- package/src/plans/layout-phase.ts +57 -18
- package/src/plans/layout-summary.ts +85 -67
- package/src/plans/layout.ts +253 -89
- package/src/plans/live.ts +22 -0
- package/src/plans/phase-models.ts +1 -1
- package/src/plans/phase-ranges.ts +45 -0
- package/src/plans/pull-requests.ts +6 -0
- package/src/plans/selection.ts +19 -16
- package/src/plans/sent-files-read.ts +22 -0
- package/src/plans/sent-files.ts +121 -0
- package/src/plans/signals-read.ts +54 -0
- package/src/plans/snapshot.ts +251 -55
- package/src/plans/subagents-read.ts +82 -0
- package/src/plans/subagents.ts +132 -0
- package/src/plans/texts.ts +16 -11
- package/src/plans/unlisted-lanes.ts +10 -5
- package/src/plans/view.ts +3 -3
- package/src/plans/waiting-click.ts +76 -0
- package/src/preview/command.ts +83 -0
- package/src/preview/embed.ts +209 -0
- package/src/preview/images.ts +89 -0
- package/src/preview/open.ts +12 -0
- package/src/preview/options.ts +32 -0
- package/src/preview/page.ts +89 -0
- package/src/preview/render.ts +41 -0
- package/src/settled/board-row.ts +1 -2
- package/src/shared/is-record.ts +2 -0
- package/src/shared/private-records.ts +48 -0
- package/src/task-signals/agent.ts +15 -0
- package/src/task-signals/claude-read.ts +72 -0
- package/src/task-signals/claude-signals.ts +112 -0
- package/src/task-signals/command.ts +71 -0
- package/src/task-signals/fixtures/session.jsonl +7 -0
- package/src/task-signals/matcher.ts +96 -0
- package/src/task-signals/record-store.ts +40 -0
- package/src/task-signals/record.ts +68 -0
- package/src/task-signals/text.ts +13 -0
- package/src/task-signals/types.ts +14 -0
- package/src/terminal/open.ts +13 -6
- package/src/terminal/text.ts +8 -1
- package/src/terminal/width.ts +2 -1
- package/src/update/command.ts +2 -0
- package/src/update/config-shares.ts +30 -0
- package/src/update/update.ts +12 -1
- package/src/vocabulary/index.ts +33 -1
- package/src/waiting/command.ts +35 -0
- package/src/watch/appointments.ts +5 -5
- package/src/watch/approvals.ts +2 -2
- package/src/watch/command.ts +91 -5
- package/src/watch/failed-tests.ts +56 -10
- package/src/watch/failure-read.ts +18 -6
- package/src/watch/failures.ts +29 -5
- package/src/watch/feedback.ts +1 -1
- package/src/watch/gh-process.ts +9 -3
- package/src/watch/json-guards.ts +0 -3
- package/src/watch/lane-fill.ts +1 -1
- package/src/watch/lane-model.ts +1 -1
- package/src/watch/lane-parallel.ts +1 -1
- package/src/watch/lane-progress.ts +19 -7
- package/src/watch/lane-report.ts +5 -1
- package/src/watch/lane-rest.ts +28 -0
- package/src/watch/plan-badges.ts +9 -2
- package/src/watch/previews.ts +1 -1
- package/src/watch/pull-requests.ts +2 -1
- package/src/watch/source-path.ts +1 -1
- package/src/watch/sources.ts +1 -1
- package/src/watch/stream-output.ts +12 -7
- package/src/watch/task-parallel-input.ts +118 -0
- package/src/watch/task-parallel-read.ts +149 -0
- package/src/watch/task-parallel-touched.ts +39 -0
- package/src/watch/task-parallel.ts +206 -0
- package/src/watch/waiting-line.ts +19 -0
- package/src/watch/watch-events.ts +13 -1
- package/src/watch/workflow-runs.ts +2 -1
- package/assets/icons/bark-dark.svg +0 -1
- package/assets/icons/bowl-dark.svg +0 -1
- package/assets/icons/decide-dark.svg +0 -1
- package/assets/icons/ear-dark.svg +0 -1
- package/assets/icons/lanes-dark.svg +0 -1
- 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
|
|
281
|
-
return
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
343
|
+
return tokens
|
|
323
344
|
}
|
|
324
345
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
/**
|
|
337
|
-
const
|
|
338
|
-
|
|
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
|
|
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
|
|
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
|
|
88
|
-
if (
|
|
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] ?? '')))],
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -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",
|
package/kit/hooks/statusline.sh
CHANGED
|
@@ -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=
|
|
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.
|