jackherd 1.8.1 → 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 (111) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +2 -0
  3. package/README.md +37 -44
  4. package/dist/{chunk-OU2KALXP.js → chunk-MCP4XJQH.js} +15 -0
  5. package/dist/config.d.ts +6 -0
  6. package/dist/config.js +19 -3
  7. package/dist/plan-format.d.ts +19 -2
  8. package/dist/plan-format.js +21 -7
  9. package/herdr-plugin/herdr-plugin.toml +1 -1
  10. package/install.sh +1 -1
  11. package/kit/hooks/hooks/approval-gate/index.ts +22 -4
  12. package/kit/hooks/hooks/approval-gate/run.ts +29 -6
  13. package/kit/hooks/hooks/approval-gate/subagent.ts +18 -0
  14. package/kit/hooks/hooks/merge-guard/decision.ts +67 -20
  15. package/kit/hooks/hooks/sent-file/index.ts +19 -0
  16. package/kit/hooks/hooks/sent-file/record.ts +128 -0
  17. package/kit/hooks/hooks/sent-file/run.ts +48 -0
  18. package/kit/hooks/hooks/sent-file/store.ts +18 -0
  19. package/kit/hooks/hooks/shared/board-lanes.ts +15 -2
  20. package/kit/hooks/hooks.json +9 -0
  21. package/kit/hooks/statusline.sh +1 -1
  22. package/kit/workflow/skills/implement/SKILL.md +2 -0
  23. package/kit/workflow/skills/orchestrate/SKILL.md +30 -6
  24. package/package.json +2 -1
  25. package/schemas/jackherd.schema.json +26 -2
  26. package/src/cli.ts +3 -0
  27. package/src/config/schema.ts +22 -2
  28. package/src/context/fill.ts +12 -0
  29. package/src/context/session-log.ts +43 -1
  30. package/src/plan-format/clock.ts +4 -0
  31. package/src/plan-format/dependencies.ts +2 -5
  32. package/src/plan-format/lanes.ts +30 -5
  33. package/src/plan-format/terminal-text.ts +4 -0
  34. package/src/plan-format/waiting.ts +48 -0
  35. package/src/plans/active-tasks.ts +60 -0
  36. package/src/plans/current-phase.ts +17 -7
  37. package/src/plans/current-task.ts +63 -5
  38. package/src/plans/lane-standing.ts +9 -4
  39. package/src/plans/lane-summary.ts +141 -74
  40. package/src/plans/lane-tickets.ts +15 -0
  41. package/src/plans/layout-phase.ts +57 -18
  42. package/src/plans/layout-summary.ts +85 -67
  43. package/src/plans/layout.ts +253 -89
  44. package/src/plans/live.ts +22 -0
  45. package/src/plans/phase-models.ts +1 -1
  46. package/src/plans/phase-ranges.ts +45 -0
  47. package/src/plans/pull-requests.ts +6 -0
  48. package/src/plans/selection.ts +19 -16
  49. package/src/plans/sent-files-read.ts +22 -0
  50. package/src/plans/sent-files.ts +121 -0
  51. package/src/plans/signals-read.ts +54 -0
  52. package/src/plans/snapshot.ts +251 -55
  53. package/src/plans/subagents-read.ts +82 -0
  54. package/src/plans/subagents.ts +132 -0
  55. package/src/plans/texts.ts +16 -11
  56. package/src/plans/unlisted-lanes.ts +10 -5
  57. package/src/plans/view.ts +3 -3
  58. package/src/plans/waiting-click.ts +76 -0
  59. package/src/preview/command.ts +83 -0
  60. package/src/preview/embed.ts +209 -0
  61. package/src/preview/images.ts +89 -0
  62. package/src/preview/open.ts +12 -0
  63. package/src/preview/options.ts +32 -0
  64. package/src/preview/page.ts +89 -0
  65. package/src/preview/render.ts +41 -0
  66. package/src/settled/board-row.ts +1 -2
  67. package/src/shared/is-record.ts +2 -0
  68. package/src/shared/private-records.ts +48 -0
  69. package/src/task-signals/agent.ts +15 -0
  70. package/src/task-signals/claude-read.ts +72 -0
  71. package/src/task-signals/claude-signals.ts +112 -0
  72. package/src/task-signals/command.ts +71 -0
  73. package/src/task-signals/fixtures/session.jsonl +7 -0
  74. package/src/task-signals/matcher.ts +96 -0
  75. package/src/task-signals/record-store.ts +40 -0
  76. package/src/task-signals/record.ts +68 -0
  77. package/src/task-signals/text.ts +13 -0
  78. package/src/task-signals/types.ts +14 -0
  79. package/src/terminal/open.ts +13 -6
  80. package/src/terminal/text.ts +8 -1
  81. package/src/terminal/width.ts +2 -1
  82. package/src/vocabulary/index.ts +28 -0
  83. package/src/waiting/command.ts +35 -0
  84. package/src/watch/appointments.ts +2 -3
  85. package/src/watch/approvals.ts +2 -2
  86. package/src/watch/command.ts +91 -5
  87. package/src/watch/failed-tests.ts +56 -10
  88. package/src/watch/failure-read.ts +18 -6
  89. package/src/watch/failures.ts +29 -5
  90. package/src/watch/feedback.ts +1 -1
  91. package/src/watch/gh-process.ts +9 -3
  92. package/src/watch/json-guards.ts +0 -3
  93. package/src/watch/lane-fill.ts +1 -1
  94. package/src/watch/lane-model.ts +1 -1
  95. package/src/watch/lane-parallel.ts +1 -1
  96. package/src/watch/lane-progress.ts +19 -7
  97. package/src/watch/lane-report.ts +5 -1
  98. package/src/watch/lane-rest.ts +28 -0
  99. package/src/watch/plan-badges.ts +9 -2
  100. package/src/watch/previews.ts +1 -1
  101. package/src/watch/pull-requests.ts +2 -1
  102. package/src/watch/source-path.ts +1 -1
  103. package/src/watch/sources.ts +1 -1
  104. package/src/watch/stream-output.ts +12 -7
  105. package/src/watch/task-parallel-input.ts +118 -0
  106. package/src/watch/task-parallel-read.ts +149 -0
  107. package/src/watch/task-parallel-touched.ts +39 -0
  108. package/src/watch/task-parallel.ts +206 -0
  109. package/src/watch/waiting-line.ts +19 -0
  110. package/src/watch/watch-events.ts +13 -1
  111. package/src/watch/workflow-runs.ts +2 -1
@@ -1,14 +1,15 @@
1
1
  import type { DialogAnswer } from '../shared/dialog-answer'
2
2
  import { ASK_USER_QUESTION_TOOL } from '../shared/dialog-answers'
3
3
  import { isFresh } from '../shared/fresh'
4
- import { laneOfAgent } from '../shared/lane'
4
+ import { isCoordinatorAgent, laneOfAgent } from '../shared/lane'
5
5
  import type { PaneAgent } from '../shared/pane-agent'
6
6
  import { gateDecision, type GateLabels } from './decision'
7
7
  import { type ApprovalRequest, callHash, type GateCall, isStorableInput, newRequest, requestId } from './request'
8
8
  import { gateSettingsOf } from './settings'
9
+ import { type SubagentCall, subagentLabel } from './subagent'
9
10
 
10
- /** A call as the client's hook hands it over, before it is known to be a lane's. */
11
- export type ClientCall = { cwd: string; session: string; tool: string; input: unknown }
11
+ /** A call as the client's hook hands it over, before it is known to be a lane's; `subagent` when a subagent makes it. */
12
+ export type ClientCall = { cwd: string; session: string; tool: string; input: unknown; subagent?: SubagentCall }
12
13
 
13
14
  /** What the gate decided; null decides nothing, so the client's own dialog stays. */
14
15
  export type GateOutcome = { kind: 'allow' } | { kind: 'denied' } | { kind: 'pending' } | null
@@ -18,6 +19,8 @@ export type GateDependencies = {
18
19
  /** The agent of the multiplexer pane this session runs in; null outside the multiplexer. */
19
20
  paneAgent: () => PaneAgent | null
20
21
  mainCheckoutOf: (cwd: string) => string
22
+ /** Whether a worktree of this name exists below the main checkout's worktree directory. */
23
+ isLaneName: (main: string, name: string) => boolean
21
24
  config: (main: string) => Record<string, unknown>
22
25
  readRequests: (main: string) => ApprovalRequest[]
23
26
  writeRequest: (main: string, request: ApprovalRequest) => void
@@ -75,11 +78,31 @@ const awaitDecision = async (
75
78
  /** A question to the user asks for an answer, not for a permission: the lane asks the coordinator in its text instead. */
76
79
  const isHeld = ({ tool, input }: ClientCall): boolean => tool !== ASK_USER_QUESTION_TOOL && isStorableInput(input)
77
80
 
81
+ /**
82
+ * What holds the call: the lane of the pane's agent, else, for a subagent of the coordinator, its own name. The
83
+ * coordinator's session is the one the hook was called for, and the agent of the pane started in the main checkout
84
+ * under no lane's name; the subagent's permission dialog would open in that pane.
85
+ */
86
+ const holderOf = (
87
+ clientCall: ClientCall,
88
+ agent: PaneAgent,
89
+ main: string,
90
+ worktreeDirectory: string,
91
+ dependencies: GateDependencies,
92
+ ): string | null => {
93
+ const lane = laneOfAgent({ agent, session: clientCall.session, main, worktreeDirectory })
94
+ if (lane || !clientCall.subagent) return lane
95
+ const isLane = (name: string) => dependencies.isLaneName(main, name)
96
+ const coordinator = isCoordinatorAgent({ agent, session: clientCall.session, main, isLane })
97
+ return coordinator ? subagentLabel(clientCall.subagent) : null
98
+ }
99
+
78
100
  /**
79
101
  * The gate for a call that would open a permission dialog in a lane: it records the call as a request the watcher
80
102
  * reports, waits for the user's answer to the coordinator's dialog, and allows or denies. The lane is the agent of the
81
- * pane, never the directory of the call. Outside a lane, for a question or a call too large to store, and on any failure
82
- * (the multiplexer or git cannot be asked), it decides nothing, so the dialog stays.
103
+ * pane, never the directory of the call; a subagent of the coordinator is held like one, under its own name. Outside a
104
+ * lane, for a question or a call too large to store, and on any failure (the multiplexer or git cannot be asked), it
105
+ * decides nothing, so the dialog stays.
83
106
  */
84
107
  export const runGate = async (
85
108
  clientCall: ClientCall,
@@ -92,7 +115,7 @@ export const runGate = async (
92
115
  if (agent === null || agent.cwd === null) return null
93
116
  const main = dependencies.mainCheckoutOf(agent.cwd)
94
117
  const { worktreeDirectory, waitMinutes } = gateSettingsOf(dependencies.config(main))
95
- const lane = laneOfAgent({ agent, session: clientCall.session, main, worktreeDirectory })
118
+ const lane = holderOf(clientCall, agent, main, worktreeDirectory, dependencies)
96
119
  if (!lane) return null
97
120
  const { cwd, tool, input } = clientCall
98
121
  const request = openRequest(main, { lane, cwd, tool, input }, dependencies)
@@ -0,0 +1,18 @@
1
+ /** The subagent a hook call comes from: Claude Code's `agent_id` and `agent_type`. */
2
+ export type SubagentCall = { id: string; type: string | null }
3
+
4
+ /** `agent_id` as Claude Code writes it into the subagent's log (`agent-<id>.jsonl`): lower-case hexadecimal. */
5
+ const AGENT_ID = /^[0-9a-f]{8,64}$/
6
+ const AGENT_TYPE = /^[a-z0-9][a-z0-9-]{0,31}$/
7
+ const ID_LENGTH = 8
8
+
9
+ /**
10
+ * The name a subagent of the coordinator is held under, in the place of a lane's name: `subagent-<type>-<id start>`.
11
+ * The type is a plain word or `agent`, so nothing the subagent could choose freely reaches the coordinator's question;
12
+ * null for an id of another shape. The herd derives the same name from the subagent's log (contract test).
13
+ */
14
+ export const subagentLabel = ({ id, type }: SubagentCall): string | null => {
15
+ if (!AGENT_ID.test(id)) return null
16
+ const word = type !== null && AGENT_TYPE.test(type) ? type : 'agent'
17
+ return `subagent-${word}-${id.slice(0, ID_LENGTH)}`
18
+ }
@@ -62,6 +62,9 @@ type Finding = { command: string; merge: PlainMerge | undefined; definition?: Sh
62
62
  /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
63
63
  type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
64
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
+
65
68
  /** The parts of a `gh api` call that decide whether it writes, and where. */
66
69
  type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
67
70
 
@@ -130,6 +133,16 @@ const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
130
133
  const EXPANDED_FUNCTION_NOTE =
131
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.'
132
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
+
133
146
  const PASS: MergeGuardDecision = { kind: 'pass' }
134
147
 
135
148
  /**
@@ -276,10 +289,10 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
276
289
  const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
277
290
  command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
278
291
 
279
- /** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
280
- const mentionsDangerousApiCall = (script: string): boolean => {
281
- const at = findWord(script, API_WORD, 0)
282
- return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
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))
283
296
  }
284
297
 
285
298
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
@@ -314,33 +327,67 @@ const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole:
314
327
  return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
315
328
  }
316
329
 
317
- const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
318
-
319
- const findWord = (script: string, word: string, from: number): number => {
320
- for (let at = script.indexOf(word, from); at !== -1; at = script.indexOf(word, at + 1)) {
321
- if (!isWordCharacter(script[at - 1]) && !isWordCharacter(script[at + word.length])) return at
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
322
342
  }
323
- return -1
343
+ return tokens
324
344
  }
325
345
 
326
- /** Whether the script names the words as whole words in this order; a linear search, so a long script cannot stall it. */
327
- const namesInOrder = (script: string, words: string[]): boolean => {
328
- let from = 0
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
329
357
  for (const word of words) {
330
- const at = findWord(script, word, from)
331
- if (at === -1) return false
332
- 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++
333
368
  }
334
369
  return true
335
370
  }
336
371
 
337
- /** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
338
- const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
339
- commands
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
340
384
  .filter((command) =>
341
- 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)),
342
388
  )
343
389
  .map((command) => ({ command, merge: undefined }))
390
+ }
344
391
 
345
392
  /** A program expanded from a variable or a substitution could be the guarded one. */
346
393
  const runsExpandedProgram = (words: SimpleCommand): boolean =>
@@ -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.
@@ -89,6 +89,9 @@ Always contains:
89
89
  - what the lane must not do: push a database migration and any write against a shared environment
90
90
  (stage, production) without the user's approval, `git reset` or `git stash` on shared refs
91
91
  - "ask before every commit (`/pre-commit`); no push, PR or merge without approval"
92
+ - "before you start a task, mark it in progress in your own task list; without one, run `jh task start
93
+ "<task text>"`, and `jh task done` when it is finished": the herd shows the task you work on from that
94
+ signal
92
95
  - "a new phase starts with its skeleton in the plan (heading, origin, tasks, review gate) before any
93
96
  research, so the herd never shows it as not in the plan"
94
97
  - "the report at the end of each step goes to `.claude/handover/<lane>-report.md` in the worktree,
@@ -411,13 +414,14 @@ On wake-up:
411
414
  | `deploy …` | Result and target as the run states them, to the user; a target `unknown (…)` is reported as unknown, never guessed. A failed run goes to the user as an `AskUserQuestion`: read the logs now or later |
412
415
  | `due <when> · <action>` | An open appointment fell due (**An agreed time**, below); the action is cut to 160 characters, the board row holds it whole. Carry it out as agreed, under the approvals it needs (§ 5), then write the outcome into its status cell in the same turn |
413
416
  | `appointment unreadable <when> · <action>` | An open appointment row whose time cannot be read, or a table without a column the watcher needs (`missing columns: …`). Correct the row or the header in the board; until then the appointment never falls due |
414
- | `heartbeat` | One line of status to the user, also when nothing changed |
417
+ | `heartbeat` | One line of status to the user, also when nothing changed; a resting lane carries `waits on: <question>, <n> open questions` from its report, and `for you (<n>): …` names what still waits on the user (**Anything that waits on the user's answer**, below): remind the user of those items in that line |
415
418
  | `gh unavailable` | Tell the user once; herdr watching continues |
416
419
  | `lane <name> warn` | One line to the user: the lane's fill and that it hands over at a phase boundary if it rises further; no step in the lane |
417
420
  | `lane <name> alert` | § 8: send the lane once the request for its hand-over at the end of its current phase |
418
421
  | `approval <name> <id> <tool> · <call>` | A call of the lane waits for the user's approval (§ 4, Approvals); the duty line names the question's mark and labels |
419
422
  | `model <name> <id> · board <column>` | The lane's last answer came from a model its board row's model column does not name (every model the column names fits). Switch it while it is idle (§ 2 step 6). A column that is wrong goes to the user instead. The watcher reports a deviation once and again only when the model changes |
420
423
  | `parallel <name> P<a> ∥ P<b>` | The lane's plan has ready phases whose `{{kit.heading.writes}}` sets do not overlap and that share no `{{kit.heading.serialMarker}}`, read from the plan's `{{kit.heading.dependencies}}` table and its ticks. Propose them to the user in one line, as parallel lanes (§ 2) or as parallel subagents in one lane (`/implement` Step 0b); nothing starts on the event alone. Before proposing, check what the table cannot see: two phases that collide in content stay your judgement. The watcher reports a set once and again only when the set changes |
424
+ | `parallel tasks <name>: "<a>" ∥ "<b>" · ~<n>k tokens for <m> extra agents` | Open tasks that name paths overlapping neither each other nor what the running agents wrote lately or report (your own subagents in the lane's worktree count as running), at a cost under `watch.parallelTasks.maxTokens`; one free task beside a running agent counts. Once per changed situation, at a heartbeat. Nothing starts on the line. Put it to the user at once, never into an open dialog (right after it), the user's note read first: at most three plain lines (which tasks, why they do not collide, the cost: model and token baseline of a new agent, and what they wait for otherwise; no invented time saving), then one `AskUserQuestion` of at most 15 words, "start in parallel" (recommended only under the threshold; `Parallel starten` / `Nacheinander lassen` for a German user) or "keep in sequence". Until the answer it is a `?` row in the user's answers section (**Anything that waits on the user's answer**). No second proposal for the same situation after "keep in sequence". Two tasks that collide in content stay your judgement |
421
425
  | `coordinator warn` / `coordinator alert` | § 8, for yourself |
422
426
  | `lane added: <name>` | Nothing: the lane is watched. Check that it has its row (§ 2 step 5); a name `<lane>-<purpose>` without a worktree is a helper agent of that lane (§ 1) and needs no row. One the lane did not announce goes to the user |
423
427
  | `lane removed: <name>` | Nothing after you closed it (§ 7). Otherwise its agent exited or its worktree is gone: read why, then tell the user |
@@ -454,6 +458,20 @@ watcher reports an open row once when it falls due (`due`, below); an empty stat
454
458
  and a reworded or moved row is a new appointment and reports again. Close a reported row through its
455
459
  status cell.
456
460
 
461
+ **Anything that waits on the user's answer** (a preview or a file sent to them, a question put to them, a
462
+ report handed over for acceptance) goes into the board at once, in the section `## Wartet auf dich` (German
463
+ plans) or `## Waiting for you` (English), one row `Since | Item | Origin | Status` (`Seit | Punkt | Herkunft |
464
+ Stand`) with the time as in **An agreed time**. The section survives a hand-over, which a message in this chat
465
+ does not. Enter the row in the same turn you send the thing, not when you notice it was missed. The herd shows
466
+ every open row on top as `for you`, and the watcher repeats them in each heartbeat and status until the
467
+ row is closed. Close a row in its status cell in the turn the user's answer arrives (also when the answer is "no"),
468
+ or when the thing is withdrawn; an empty status counts as open. A file sent with `SendUserFile` and an Artifact you publish need no row of your own: the kit hook
469
+ `sent-file` records each in `.jackherd/sent/`, and the herd, the heartbeat and the status line list the open
470
+ ones next to the board's rows (by file name, without an id). The herd also shows them as 📎 badges on the lane
471
+ they belong to. Close them after the answer with `jh waiting close <id>` (or `--all`); `jh waiting` lists
472
+ what is open, with the ids. A question that is answered in a dialog needs no row, since the dialog is the wait. A watcher event or a lane's report is no row either, unless the answer to it is
473
+ the user's.
474
+
457
475
  **A new idea during a release** (the user's or your own, while a plan's phases run toward one
458
476
  release) is a line in the plan's `## Follow-ups` section, with its origin, and one line to the user:
459
477
  no dialog, no new phase. It enters the release at once only when the release would otherwise be
@@ -522,15 +540,21 @@ Question: Retry the failed check, or leave it?
522
540
  ```
523
541
 
524
542
  **A draft for review.** A lane's draft that the user reviews (a README, a page, a plan) goes in
525
- rounds. Show it where the user can read it: rendered, when this session can publish a page,
526
- otherwise the file path. Ask through `AskUserQuestion` with `multiSelect`, one option per remark,
527
- send the chosen remarks to the lane, and show the same place again once the lane has changed it.
528
- The push waits for the approval of the last round.
543
+ rounds. Show it where the user can read it. When this session has the `Artifact` tool and `preview.publish`
544
+ in `.jackherd/config.json` is not `false` (default `true`), render the draft with `jh preview <file>`
545
+ (GitHub and npm side by side, external images embedded) and publish that page as a private Artifact; every
546
+ later round updates the same URL (`url` on the publish call), so the user keeps one link. Without the tool
547
+ (another provider, Codex) or with `preview.publish` off, send the page as a file card (`SendUserFile`) or
548
+ tell the user to run `jh preview --open`; a bare file path is the last resort. Ask through
549
+ `AskUserQuestion` with `multiSelect`, one option per remark, send the chosen remarks to the lane, and
550
+ show the same place again once the lane has changed it. The push waits for the approval of the last round.
529
551
 
530
552
  **Approvals.** In a lane, a call that would open a permission dialog (an ask rule) is held by the
531
553
  approval gate hook (a project install ships it; a global install has none, and the dialog stays in
532
554
  the lane). The hook records the call, the watcher reports `approval <lane> <id> <tool> · <call>`, and
533
- it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. Ask at once, one
555
+ it waits up to `guard.approvals.waitMinutes` (default 8) for the user's answer. A subagent of your own
556
+ session (an `implement` run you started) is held the same way, under the name `subagent-<type>-<id>`
557
+ instead of a lane's; its dialog would otherwise open in your pane and go unnoticed. Ask at once, one
534
558
  question per request, through `AskUserQuestion`:
535
559
 
536
560
  - First read the request, `.jackherd/approvals/<id>.json` in the main checkout, and show its `tool`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -48,6 +48,7 @@
48
48
  "!docs/gen",
49
49
  "assets",
50
50
  "!assets/icons/build.ts",
51
+ "!assets/icons/version.ts",
51
52
  "!assets/branding/outline.ts",
52
53
  "recipes",
53
54
  "README.md",