jackherd 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/INSTALL.md +1 -1
  3. package/README.md +50 -32
  4. package/dist/{chunk-MCP4XJQH.js → chunk-5MQBCUQ5.js} +9 -4
  5. package/dist/config.d.ts +39 -4
  6. package/dist/config.js +67 -26
  7. package/dist/plan-format.d.ts +45 -6
  8. package/dist/plan-format.js +46 -6
  9. package/docs/workflow-dark.svg +67 -63
  10. package/docs/workflow.svg +67 -63
  11. package/herdr-plugin/herdr-plugin.toml +1 -1
  12. package/install.sh +1 -1
  13. package/kit/hooks/ask-rules.json +15 -0
  14. package/kit/hooks/hooks/ask-hint/decision.ts +82 -14
  15. package/kit/hooks/hooks/ask-hint/index.ts +15 -19
  16. package/kit/hooks/hooks/ask-hint/settings.ts +3 -18
  17. package/kit/hooks/hooks/git-guard/decision.ts +50 -102
  18. package/kit/hooks/hooks/merge-guard/always-ask.ts +63 -0
  19. package/kit/hooks/hooks/merge-guard/{plan-approval-settings.ts → board-settings.ts} +21 -67
  20. package/kit/hooks/hooks/merge-guard/check-free.ts +20 -0
  21. package/kit/hooks/hooks/merge-guard/decision.ts +127 -187
  22. package/kit/hooks/hooks/merge-guard/index.ts +6 -3
  23. package/kit/hooks/hooks/merge-guard/lane-plan.ts +79 -0
  24. package/kit/hooks/hooks/merge-guard/run.ts +59 -54
  25. package/kit/hooks/hooks/merge-guard/settings.ts +13 -1
  26. package/kit/hooks/hooks/merge-guard/standing-approval.ts +142 -0
  27. package/kit/hooks/hooks/open-question/index.ts +26 -0
  28. package/kit/hooks/hooks/open-question/record.ts +135 -0
  29. package/kit/hooks/hooks/open-question/run.ts +52 -0
  30. package/kit/hooks/hooks/open-question/store.ts +39 -0
  31. package/kit/hooks/hooks/sent-file/store.ts +3 -12
  32. package/kit/hooks/hooks/shared/config.ts +7 -0
  33. package/kit/hooks/hooks/shared/dialog-answers.ts +9 -9
  34. package/kit/hooks/hooks/shared/files.ts +7 -0
  35. package/kit/hooks/hooks/shared/lane.ts +1 -1
  36. package/kit/hooks/hooks/shared/newest-verdict.ts +10 -0
  37. package/kit/hooks/hooks/shared/private-directory.ts +56 -9
  38. package/kit/hooks/hooks/shared/program-position.ts +2 -75
  39. package/kit/hooks/hooks/shared/shell-aliases.ts +1 -71
  40. package/kit/hooks/hooks/{ask-hint → shared}/transcript.ts +37 -6
  41. package/kit/hooks/hooks/{merge-guard → shared}/xargs-program.ts +10 -8
  42. package/kit/hooks/hooks/task-hint/decision.ts +49 -0
  43. package/kit/hooks/hooks/task-hint/index.ts +79 -0
  44. package/kit/hooks/hooks/task-hint/run.ts +62 -0
  45. package/kit/hooks/hooks/task-hint/store.ts +51 -0
  46. package/kit/hooks/hooks.json +13 -5
  47. package/kit/review/agents/skill-review.md +34 -0
  48. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  49. package/kit/workflow/skills/follow/SKILL.md +1 -8
  50. package/kit/workflow/skills/implement/SKILL.md +8 -6
  51. package/kit/workflow/skills/orchestrate/SKILL.md +210 -433
  52. package/kit/workflow/skills/orchestrate/handover/decision.ts +43 -8
  53. package/kit/workflow/skills/orchestrate/handover/index.ts +32 -4
  54. package/kit/workflow/skills/orchestrate/handover/standing-approval.ts +84 -0
  55. package/kit/workflow/skills/orchestrate/references/acceptance.md +15 -0
  56. package/kit/workflow/skills/orchestrate/references/close-lane.md +17 -0
  57. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +43 -17
  58. package/kit/workflow/skills/orchestrate/references/draft-review.md +19 -0
  59. package/kit/workflow/skills/orchestrate/references/events.md +86 -0
  60. package/kit/workflow/skills/orchestrate/references/herd.md +6 -6
  61. package/kit/workflow/skills/orchestrate/references/lane-handover.md +18 -0
  62. package/kit/workflow/skills/orchestrate/references/lane-session.md +25 -0
  63. package/kit/workflow/skills/orchestrate/references/lane-setup.md +37 -0
  64. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -10
  65. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +5 -7
  66. package/kit/workflow/skills/plan/SKILL.md +6 -5
  67. package/kit/workflow/skills/plan/references/template-English.md +4 -4
  68. package/kit/workflow/skills/plan/references/template-German.md +4 -4
  69. package/package.json +3 -1
  70. package/recipes/README.md +2 -1
  71. package/recipes/sources.md +1 -1
  72. package/schemas/jackherd.schema.json +59 -5
  73. package/src/check/agent-text-reviewers.ts +23 -0
  74. package/src/check/command.ts +2 -0
  75. package/src/check/plan-structure-check.ts +49 -2
  76. package/src/check/plan.ts +8 -1
  77. package/src/check/schema.ts +9 -0
  78. package/src/check/skill-budget-defaults.ts +55 -0
  79. package/src/check/skill-budgets.ts +185 -0
  80. package/src/cli.ts +1 -0
  81. package/src/config/auto-window.ts +8 -0
  82. package/src/config/link-kinds.ts +7 -1
  83. package/src/config/schema.ts +58 -24
  84. package/src/lane/close-board.ts +106 -0
  85. package/src/lane/close-check.ts +183 -0
  86. package/src/lane/close-command-line.ts +11 -0
  87. package/src/lane/close-command.ts +217 -0
  88. package/src/lane/close-options.ts +43 -0
  89. package/src/lane/close-survey.ts +83 -0
  90. package/src/lane/close.ts +141 -0
  91. package/src/lane/command.ts +13 -0
  92. package/src/lane-prompt/build.ts +74 -0
  93. package/src/lane-prompt/command.ts +87 -0
  94. package/src/lane-prompt/input.ts +116 -0
  95. package/src/lane-prompt/options.ts +68 -0
  96. package/src/lane-prompt/texts.ts +101 -0
  97. package/src/plan-format/clock.ts +8 -0
  98. package/src/plan-format/dependencies.ts +7 -4
  99. package/src/plan-format/index.ts +1 -0
  100. package/src/plan-format/short-names.ts +71 -0
  101. package/src/plan-format/waiting.ts +7 -2
  102. package/src/plans/appointment-rows.ts +59 -0
  103. package/src/plans/ask-coordinator.ts +77 -0
  104. package/src/plans/ask-target.ts +25 -0
  105. package/src/plans/child-stop.ts +51 -0
  106. package/src/plans/closable-records.ts +34 -0
  107. package/src/plans/command.ts +3 -2
  108. package/src/plans/fetch.ts +26 -3
  109. package/src/plans/git.ts +19 -5
  110. package/src/plans/herd-claim.ts +173 -0
  111. package/src/plans/herd-memory.ts +36 -25
  112. package/src/plans/lane-plans.ts +44 -13
  113. package/src/plans/lane-summary.ts +253 -45
  114. package/src/plans/layout-phase.ts +29 -7
  115. package/src/plans/layout-summary.ts +89 -18
  116. package/src/plans/layout.ts +262 -68
  117. package/src/plans/link-chips.ts +1 -0
  118. package/src/plans/live.ts +107 -20
  119. package/src/plans/phase-ids.ts +8 -0
  120. package/src/plans/phase-source.ts +15 -0
  121. package/src/plans/plan-cache.ts +28 -0
  122. package/src/plans/plan-heads.ts +54 -0
  123. package/src/plans/pull-requests.ts +17 -2
  124. package/src/plans/question-records.ts +48 -0
  125. package/src/plans/questions-read.ts +13 -0
  126. package/src/plans/read-cache.ts +8 -0
  127. package/src/plans/record-id.ts +4 -0
  128. package/src/plans/restart-decision.ts +13 -4
  129. package/src/plans/selection.ts +98 -18
  130. package/src/plans/sent-files-read.ts +6 -15
  131. package/src/plans/sent-files.ts +7 -14
  132. package/src/plans/settings.ts +2 -1
  133. package/src/plans/signalled-phase.ts +42 -0
  134. package/src/plans/signals-read.ts +12 -4
  135. package/src/plans/snapshot.ts +85 -62
  136. package/src/plans/sprint-export.ts +11 -11
  137. package/src/plans/sprint-page.ts +5 -5
  138. package/src/plans/subagent-phases.ts +95 -0
  139. package/src/plans/subagent-tasks.ts +51 -0
  140. package/src/plans/subagents-read.ts +66 -17
  141. package/src/plans/subagents.ts +64 -6
  142. package/src/plans/supervisor.ts +14 -4
  143. package/src/plans/symbols.ts +3 -0
  144. package/src/plans/task-name.ts +34 -0
  145. package/src/plans/texts.ts +23 -6
  146. package/src/plans/view.ts +17 -9
  147. package/src/plans/waiting-click.ts +192 -37
  148. package/src/plans/waiting-records.ts +39 -0
  149. package/src/plans/waiting-window.ts +8 -0
  150. package/src/preview/command.ts +25 -3
  151. package/src/shared/private-location.ts +89 -0
  152. package/src/shared/stat-cache.ts +48 -0
  153. package/src/sync/kit-source.ts +29 -10
  154. package/src/sync/kit.ts +2 -0
  155. package/src/sync/manifest.ts +2 -0
  156. package/src/sync/plan-ask-rules.ts +94 -0
  157. package/src/sync/plan-hooks.ts +2 -0
  158. package/src/sync/render.ts +3 -3
  159. package/src/sync/settings-hooks.ts +4 -1
  160. package/src/sync/sync-plan.ts +1 -0
  161. package/src/sync/sync.ts +24 -3
  162. package/src/task-signals/claude-read.ts +27 -10
  163. package/src/terminal/text.ts +8 -3
  164. package/src/version/skill-diff.ts +46 -0
  165. package/src/vocabulary/index.ts +16 -6
  166. package/src/waiting/command.ts +22 -7
  167. package/src/watch/appointments.ts +1 -2
  168. package/src/watch/asking-lanes.ts +40 -0
  169. package/src/watch/base-tip.ts +11 -0
  170. package/src/watch/command.ts +330 -105
  171. package/src/watch/heartbeat-progress.ts +6 -18
  172. package/src/watch/kit-update.ts +28 -9
  173. package/src/watch/lane-name.ts +6 -0
  174. package/src/watch/lane-progress.ts +40 -8
  175. package/src/watch/lane-report-file.ts +20 -5
  176. package/src/watch/lane-report.ts +32 -4
  177. package/src/watch/lane-sources.ts +31 -3
  178. package/src/watch/main-checkout.ts +34 -0
  179. package/src/watch/plan-badges-read.ts +13 -7
  180. package/src/watch/pull-requests.ts +6 -2
  181. package/src/watch/reference-paths.ts +87 -0
  182. package/src/watch/running-subagents.ts +43 -0
  183. package/src/watch/skill-change.ts +124 -32
  184. package/src/watch/source-change.ts +48 -0
  185. package/src/watch/sources.ts +6 -3
  186. package/src/watch/stream-output.ts +4 -3
  187. package/src/watch/task-parallel-auto.ts +208 -0
  188. package/src/watch/task-parallel-data-paths.ts +46 -0
  189. package/src/watch/task-parallel-input.ts +5 -15
  190. package/src/watch/task-parallel-read.ts +3 -1
  191. package/src/watch/task-parallel.ts +36 -7
  192. package/src/watch/task-signal-missing.ts +69 -0
  193. package/src/watch/task-signal-read.ts +29 -0
  194. package/src/watch/texts.ts +10 -0
  195. package/src/watch/update-available.ts +6 -3
  196. package/src/watch/watch-events.ts +23 -0
  197. package/src/watch/watcher-lock.ts +25 -29
  198. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +0 -23
  199. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +0 -196
  200. package/kit/hooks/hooks/merge-guard/plan-approval.ts +0 -168
  201. package/kit/hooks/hooks/merge-guard/plan-growth.ts +0 -40
  202. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +0 -194
  203. package/kit/hooks/hooks/shared/command-text-runners.ts +0 -71
  204. package/kit/hooks/hooks/shared/plan-open-phases.ts +0 -114
@@ -19,3 +19,10 @@ export const readConfig = (directories: readonly string[]): Record<string, unkno
19
19
  /** The object under `key`, `{}` when it is missing or no object. */
20
20
  export const sectionOf = (config: Record<string, unknown>, key: string): Record<string, unknown> =>
21
21
  isRecord(config[key]) ? config[key] : {}
22
+
23
+ /**
24
+ * Whether the hint `key` of the `hints` section is on: off only where the first of `configs` (project before home)
25
+ * that sets it to a boolean says `false`; a missing or malformed value leaves the next config its say.
26
+ */
27
+ export const isHintOn = (configs: readonly Record<string, unknown>[], key: string): boolean =>
28
+ configs.map((config) => sectionOf(config, 'hints')[key]).find((value) => typeof value === 'boolean') !== false
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
3
+ * client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
4
+ * agent put and the label the user picked. Nothing an agent says in a message, a file or a command is one.
5
+ */
1
6
  import type { DialogAnswer } from './dialog-answer'
2
7
  import { isRecord, type JsonObject } from './json'
3
8
 
@@ -6,13 +11,8 @@ export type { DialogAnswer } from './dialog-answer'
6
11
  /** The tool name of the dialog, as hook inputs and transcripts carry it. */
7
12
  export const ASK_USER_QUESTION_TOOL = 'AskUserQuestion'
8
13
 
9
- /**
10
- * The answers of the user's dialogs, read from a session transcript. The transcript is written by the agent's own
11
- * client, not by the agent: a dialog answer sits in it as the result of an `AskUserQuestion` call, with the question the
12
- * agent put and the label the user picked. Nothing an agent says in a message, a file or a command is one.
13
- */
14
-
15
- const contentOf = (entry: JsonObject): JsonObject[] => {
14
+ /** The content blocks of a transcript entry's message; none when it has no list of blocks. */
15
+ export const contentOf = (entry: JsonObject): JsonObject[] => {
16
16
  const content = isRecord(entry.message) ? entry.message.content : undefined
17
17
  return Array.isArray(content) ? content.filter(isRecord) : []
18
18
  }
@@ -34,10 +34,10 @@ const offeredLabels = (question: JsonObject): string[] =>
34
34
  ? question.options.flatMap((option) => (isRecord(option) && typeof option.label === 'string' ? [option.label] : []))
35
35
  : []
36
36
 
37
- type AskedQuestion = { question: string; offered: string[] }
37
+ export type AskedQuestion = { question: string; offered: string[] }
38
38
 
39
39
  /** The questions of an `AskUserQuestion` input; a call that carries its own `answers` asked nobody. */
40
- const questionsOf = (input: unknown): AskedQuestion[] => {
40
+ export const questionsOf = (input: unknown): AskedQuestion[] => {
41
41
  if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
42
42
  return input.questions.flatMap((question) =>
43
43
  isRecord(question) && typeof question.question === 'string'
@@ -1,4 +1,11 @@
1
1
  import { readFileSync, realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative } from 'node:path'
3
+
4
+ /** Whether `path` is `directory` or lies below it; a sibling that only shares the prefix does not. */
5
+ export const isWithinDirectory = (path: string, directory: string): boolean => {
6
+ const inside = relative(directory, path)
7
+ return inside === '' || (!inside.startsWith('..') && !isAbsolute(inside))
8
+ }
2
9
 
3
10
  /** The file's text; null when it cannot be read. */
4
11
  export const readTextOrNull = (path: string): string | null => {
@@ -46,7 +46,7 @@ export const laneOfAgent = ({ agent, session, main, worktreeDirectory }: AgentLo
46
46
  /**
47
47
  * Whether the session that merges is the coordinator: the agent of its pane, in this very session, started in the main
48
48
  * checkout and not under a lane's name. A lane's transcript may lie beside the coordinator's when it was started in the
49
- * main checkout; its name still tells it apart, so the plan approval never covers a lane's own merge.
49
+ * main checkout; its name still tells it apart, so an approval of the coordinator never covers a lane's own call.
50
50
  */
51
51
  export const isCoordinatorAgent = ({ agent, session, main, isLane }: MergingSession): boolean =>
52
52
  agent !== null &&
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The answers that decide a question asked more than once: the newest ones not from the future. Several given at the
3
+ * same moment (two questions in one dialog) decide only together; empty when none was given before `now`.
4
+ */
5
+ export const newestVerdict = <Answer extends { answeredAt: number }>(answers: readonly Answer[], now: number): Answer[] => {
6
+ const past = answers.filter(({ answeredAt }) => answeredAt <= now)
7
+ if (past.length === 0) return []
8
+ const newest = Math.max(...past.map(({ answeredAt }) => answeredAt))
9
+ return past.filter(({ answeredAt }) => answeredAt === newest)
10
+ }
@@ -1,4 +1,12 @@
1
- import { chmodSync, existsSync, lstatSync, mkdirSync, type Stats, writeFileSync } from 'node:fs'
1
+ import {
2
+ chmodSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ renameSync,
6
+ type Stats,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
2
10
  import { join } from 'node:path'
3
11
 
4
12
  /** The mode of a file in a private directory: readable by its owner only. */
@@ -14,16 +22,55 @@ const entryAt = (path: string): Stats | null => {
14
22
  }
15
23
  }
16
24
 
25
+ const removeIfPresent = (path: string): void => {
26
+ try {
27
+ unlinkSync(path)
28
+ } catch {
29
+ // nothing left over
30
+ }
31
+ }
32
+
33
+ /** Whether an entry belongs to `uid`; true where the platform has no user ids (`uid` undefined). */
34
+ export const isOwnedBy = (entry: Pick<Stats, 'uid'>, uid: number | undefined): boolean =>
35
+ uid === undefined || entry.uid === uid
36
+
37
+ const assertPrivateDirectory = (directory: string, entry: Stats | null): void => {
38
+ if (!entry || entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`${directory} is no directory`)
39
+ if (!isOwnedBy(entry, process.getuid?.())) throw new Error(`${directory} belongs to another user`)
40
+ }
41
+
17
42
  /**
18
- * A directory only its owner reads, ignored by git: created `0700` when missing; when present it must be a directory
19
- * and not a link (else this throws, so nothing is written through it), is narrowed to `0700` and gets the `.gitignore`
20
- * it lacks. An existing `.gitignore` is kept.
43
+ * A directory only its owner reads, ignored by git: created `0700` when missing; when present (or once created, which
44
+ * covers a link planted in between) it must be a directory of the current user and not a link (else this throws, so
45
+ * nothing is written through it), is narrowed to `0700` and gets the `.gitignore` it lacks. An existing `.gitignore`
46
+ * is kept.
21
47
  */
22
48
  export const ensurePrivateDirectory = (directory: string): void => {
23
- const entry = entryAt(directory)
24
- if (entry && (entry.isSymbolicLink() || !entry.isDirectory())) throw new Error(`${directory} is no directory`)
25
- if (!entry) mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
49
+ if (!entryAt(directory)) mkdirSync(directory, { recursive: true, mode: PRIVATE_DIRECTORY })
50
+ assertPrivateDirectory(directory, entryAt(directory))
26
51
  chmodSync(directory, PRIVATE_DIRECTORY)
27
- const ignore = join(directory, '.gitignore')
28
- if (!existsSync(ignore)) writeFileSync(ignore, '*\n')
52
+ try {
53
+ writeFileSync(join(directory, '.gitignore'), '*\n', { flag: 'wx', mode: PRIVATE_FILE })
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Writes `text` to `path` (inside a directory `ensurePrivateDirectory` has verified) `0600` through a temporary file, so a
61
+ * reader never sees half of it. A leftover temporary file of an earlier process with the same pid is removed first,
62
+ * and the new one is created exclusively, so a planted link is never followed.
63
+ */
64
+ export const writePrivateFile = (path: string, text: string): void => {
65
+ const temporary = `${path}.${process.pid}.tmp`
66
+ removeIfPresent(temporary)
67
+ writeFileSync(temporary, text, { mode: PRIVATE_FILE, flag: 'wx' })
68
+ chmodSync(temporary, PRIVATE_FILE)
69
+ renameSync(temporary, path)
70
+ }
71
+
72
+ /** Writes `<id>.json` into a private directory through a temporary file, so a reader never sees half of it. */
73
+ export const writePrivateRecord = <T extends { id: string }>(directory: string, record: T): void => {
74
+ ensurePrivateDirectory(directory)
75
+ writePrivateFile(join(directory, `${record.id}.json`), `${JSON.stringify(record)}\n`)
29
76
  }
@@ -1,5 +1,5 @@
1
1
  import { posix } from 'node:path'
2
- import { isExpanded, type SimpleCommand, type Word } from './shell'
2
+ import type { SimpleCommand, Word } from './shell'
3
3
 
4
4
  /**
5
5
  * Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
@@ -72,7 +72,7 @@ export const isProgramPosition = (words: SimpleCommand, index: number): boolean
72
72
 
73
73
  export const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
74
74
 
75
- export const isShell = (word: Word): boolean => SHELLS.has(programName(word))
75
+ const isShell = (word: Word): boolean => SHELLS.has(programName(word))
76
76
 
77
77
  /** The script word after `-c`: the first one that is neither an option nor `--`. */
78
78
  const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
@@ -150,76 +150,3 @@ export const inlineScript = (words: SimpleCommand, index: number): string | unde
150
150
  if (isBuiltinAt(words, index, TRAP_BUILTIN)) return trapAction(words, index)
151
151
  return isShell(words[index]) ? shellCommandScript(words, index) : undefined
152
152
  }
153
-
154
- /** Interpreters that run program text given on the command line or read from their input. */
155
- const INTERPRETERS = new Set(['python', 'python3', 'node', 'nodejs', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
156
- const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '--print', '-r'])
157
- const VERSIONED_PYTHON = /^python\d+(\.\d+)*$/
158
- /** A cluster of short options that ends in the one taking the program text: `python3 -Ic`, `perl -lne`. */
159
- const INLINE_PROGRAM_CLUSTER = /^-[A-Za-z]*[ce]$/
160
- /** An operand that names the interpreter's own input as the program file. */
161
- const INPUT_OPERAND = /^(-|\/dev\/stdin|\/dev\/fd\/0|\/proc\/self\/fd\/0)$/
162
- /** Options that print and exit, so an interpreter without an operand reads no program. */
163
- const PRINT_AND_EXIT = new Set(['--version', '-v', '-V', '--help', '-h'])
164
- /** Interpreters that, without an operand, print their help instead of reading a program; they read one after `run`. */
165
- const SUBCOMMAND_INTERPRETERS = new Set(['bun'])
166
- const RUN_SUBCOMMAND = 'run'
167
- /** An operand that names a script file: the words after it are the script's arguments, not the interpreter's options. */
168
- const SCRIPT_FILE = /\.(py|js|mjs|cjs|ts|mts|cts|jsx|tsx|rb|pl|pm|php|lua)$/i
169
-
170
- const isInterpreter = (word: Word): boolean => {
171
- const name = programName(word)
172
- return INTERPRETERS.has(name) || VERSIONED_PYTHON.test(name)
173
- }
174
-
175
- const isInlineProgramOption = ({ value }: Word): boolean =>
176
- INLINE_PROGRAM_OPTIONS.has(value.split('=')[0]) || INLINE_PROGRAM_CLUSTER.test(value)
177
-
178
- /** The operand that names the program: the first one, or for `bun run` the one after `run`; `-` counts as one. */
179
- const programOperand = (interpreter: Word, rest: Word[]): Word | undefined => {
180
- const operands = rest.filter((word) => !isOption(word) || word.value === '-')
181
- const afterRun = SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && operands[0]?.value === RUN_SUBCOMMAND
182
- return afterRun ? operands[1] : operands[0]
183
- }
184
-
185
- /**
186
- * Whether the interpreter reads its program from its input (`python3 -`, `node` fed by a pipe, `bun -`) or from an
187
- * expansion it cannot show (`python3 <(…)`).
188
- */
189
- const readsProgramFromInput = (interpreter: Word, rest: Word[]): boolean => {
190
- const operand = programOperand(interpreter, rest)
191
- if (operand === undefined) {
192
- return (
193
- !SUBCOMMAND_INTERPRETERS.has(programName(interpreter)) && !rest.some(({ value }) => PRINT_AND_EXIT.has(value))
194
- )
195
- }
196
- // A quoted operand is still a file name; only an expansion (`$f`, `<(…)`) hides where the program comes from.
197
- return isExpanded(operand.value) || INPUT_OPERAND.test(operand.value)
198
- }
199
-
200
- /**
201
- * The words up to a script file operand. Only a file name ends them: any other word could be an option's value
202
- * (`python3 -W ignore -c …`), and stopping there would miss the `-c` behind it.
203
- */
204
- const interpreterOptions = (rest: Word[]): Word[] => {
205
- const scriptFile = rest.findIndex((word) => !isOption(word) && SCRIPT_FILE.test(word.value))
206
- return scriptFile === -1 ? rest : rest.slice(0, scriptFile)
207
- }
208
-
209
- /**
210
- * An interpreter, anywhere a program can stand, handed program text (`python3 -c`, `node -e`) or reading it from its
211
- * input (`python3 - <<EOF`, `… | node`): the program can start any command, and no parse of the shell sees it.
212
- */
213
- export const runsInterpreterProgram = (words: SimpleCommand): boolean =>
214
- words.some((word, index) => {
215
- if (!isProgramPosition(words, index) || !isInterpreter(word)) return false
216
- const rest = words.slice(index + 1)
217
- return interpreterOptions(rest).some(isInlineProgramOption) || readsProgramFromInput(word, rest)
218
- })
219
-
220
- /** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
221
- export const readsScriptFromInput = (words: SimpleCommand): boolean => {
222
- const start = ruleStart(words)
223
- const shell = words[start]
224
- return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
225
- }
@@ -6,7 +6,7 @@ export type ShellDefinitions = { aliases: ReadonlyMap<string, string>; functions
6
6
 
7
7
  const SHELL_DEFINITION_KIND = { alias: 'alias', function: 'function' } as const
8
8
 
9
- export type ShellDefinitionKind = (typeof SHELL_DEFINITION_KIND)[keyof typeof SHELL_DEFINITION_KIND]
9
+ type ShellDefinitionKind = (typeof SHELL_DEFINITION_KIND)[keyof typeof SHELL_DEFINITION_KIND]
10
10
 
11
11
  /** A word at a program position that an alias or function stands for, whose body runs a watched program. */
12
12
  export type ShellDefinitionHit = { kind: ShellDefinitionKind; name: string; body: string }
@@ -23,9 +23,6 @@ const NAME_CHARACTER = /[\w.-]/
23
23
  /** The words a body may call: whatever stands between shell punctuation. */
24
24
  const BODY_WORD = /[^\s;|&(){}<>"'`$=]+/g
25
25
  const SHOWN_BODY_LENGTH = 80
26
- const ALIAS_BUILTIN = 'alias'
27
- /** An entry of the shell's own alias or function table, which defines one as an assignment: `functions[x]=…` (zsh). */
28
- const DEFINITION_TABLE_ENTRY = /^(aliases|galiases|saliases|functions|dis_functions|BASH_ALIASES)\[/
29
26
 
30
27
  /**
31
28
  * Words that are shell builtins or common programs. A word among them is not looked up, so most calls read no
@@ -179,73 +176,6 @@ export const shellDefinitionAt = (
179
176
  return undefined
180
177
  }
181
178
 
182
- /**
183
- * An `alias` command anywhere a program can stand, or an entry of the alias or function table: the definition it adds
184
- * in the script hides its text in a quoted word.
185
- */
186
- export const definesShellAlias = (words: SimpleCommand): boolean =>
187
- words.some(
188
- (word, index) =>
189
- (word.value === ALIAS_BUILTIN && isProgramPosition(words, index)) || DEFINITION_TABLE_ENTRY.test(word.value),
190
- )
191
-
192
- /** A program expanded from a variable or a substitution may name a function; one with a slash is a path. */
193
- const isExpandedProgram = (words: SimpleCommand, index: number): boolean => {
194
- const { value, literal } = words[index]
195
- return !literal && isExpanded(value) && !value.includes('/') && isProgramPosition(words, index)
196
- }
197
-
198
- /**
199
- * The word a parameter expansion falls back to or assigns: `shipit` in `${x:-shipit}`, `${x-shipit}`, `${a[0]:+shipit}`.
200
- * A nested expansion matches from the innermost one, so `${x:-${y:-shipit}}` yields `shipit`. The name may be empty
201
- * (`${:-shipit}`), special (`${@:-shipit}`) or an expansion itself (`${${x}:-shipit}`, zsh).
202
- */
203
- const EXPANSION_OPERAND = /\$\{(?:[^{}:=+?-]|\$\{[^{}]*\})*:?[-=+?]([^{}]*)\}/g
204
-
205
- /** `text` with each expansion operand beside it, so a name after the operator's `-` still stands as a word. */
206
- const withExpansionOperands = (text: string): string =>
207
- [text, ...[...text.matchAll(EXPANSION_OPERAND)].map(([, operand]) => operand)].join('\n')
208
-
209
- /**
210
- * A script that lists the shell's functions can take a function's name from there without writing it out: zsh's
211
- * `functions`, `dis_functions` and `functions_source` tables or builtin, `compgen -A function`/`-c`/`-A command`
212
- * (also bundled, `-bA function`), `declare`/`typeset` with `-f`, `-F` or `+f` (`typeset -fm`, `declare -Fp`),
213
- * `whence`/`which`/`where`/`type -m`, and a bare `set`, which prints the functions in bash.
214
- */
215
- const READS_FUNCTION_TABLE = new RegExp(
216
- [
217
- /(?:^|[^\w.-])(?:dis_)?functions(?:_source)?(?:[^\w.-]|$)/,
218
- /\bcompgen\b[^\n;|&]*-(?:[A-Za-z]*A\s*(?:function|command)\b|[A-Za-z]*c)/,
219
- /\b(?:declare|typeset)\b[^\n;|&]*\s[+-][A-Za-z]*[fF]/,
220
- /(?:^|[\s;|&(`])(?:whence|which|where|type)\s[^\n;|&]*-[A-Za-z]*m/,
221
- /(?:^|[^\w.-])set\s*(?:$|[|;&)`])/,
222
- ]
223
- .map(({ source }) => source)
224
- .join('|'),
225
- 'm',
226
- )
227
-
228
- /**
229
- * Whether a program expanded from a variable or a substitution (`f=shipit; $f`) may call a function of the snapshot
230
- * whose body names one of `programs`. Only a function whose name stands as a word in `text` counts: the script as
231
- * written and its decoded words, where a variable or substitution would take the name from. The shell expands no alias
232
- * from an expansion. `definitions` is called only when such a program stands in the command.
233
- */
234
- export const mayRunDefinedFunction = (
235
- words: SimpleCommand,
236
- definitions: () => ShellDefinitions,
237
- programs: readonly string[],
238
- text: string,
239
- ): boolean => {
240
- if (!words.some((_word, index) => isExpandedProgram(words, index))) return false
241
- const known = definitions()
242
- const searched = withExpansionOperands(text)
243
- const anyName = READS_FUNCTION_TABLE.test(text)
244
- return [...known.functions].some(
245
- ([name, body]) => (anyName || namesWord(searched, name)) && runsProgram(known, body, programs, new Set()),
246
- )
247
- }
248
-
249
179
  const shown = (body: string): string => {
250
180
  const line = body.trim().replace(/\s+/g, ' ')
251
181
  return line.length > SHOWN_BODY_LENGTH ? `${line.slice(0, SHOWN_BODY_LENGTH)}…` : line
@@ -1,5 +1,13 @@
1
+ import { isRecord } from './json'
2
+
1
3
  export type Turn = { firstPrompt: string; tools: string[] }
2
4
 
5
+ /** A lane prompt names the lane in its first line. */
6
+ const LANE_PROMPT = /^\s*(<pasted_content[^>]*>\s*)?(Du bist die Spur|You are the lane)/i
7
+
8
+ /** Whether the session's first prompt opens a lane (`jh lane prompt`); a lane asks the coordinator, not the user. */
9
+ export const isLanePrompt = (firstPrompt: string): boolean => LANE_PROMPT.test(firstPrompt)
10
+
3
11
  type Entry = {
4
12
  type?: unknown
5
13
  isMeta?: unknown
@@ -7,7 +15,10 @@ type Entry = {
7
15
  message?: { content?: unknown }
8
16
  }
9
17
 
10
- type Block = { type?: unknown; text?: unknown; name?: unknown }
18
+ type Block = { type?: unknown; text?: unknown; name?: unknown; input?: unknown }
19
+
20
+ /** A tool call of the transcript: the tool's name and its input, `{}` when it has none. */
21
+ export type ToolUse = { name: string; input: Record<string, unknown> }
11
22
 
12
23
  const parseEntries = (jsonl: string): Entry[] =>
13
24
  jsonl.split('\n').flatMap((line) => {
@@ -33,11 +44,29 @@ const promptText = (entry: Entry): string | undefined => {
33
44
  return texts.length > 0 ? texts.join('\n') : undefined
34
45
  }
35
46
 
36
- const toolNames = (entry: Entry): string[] =>
47
+ const toolUses = (entry: Entry): ToolUse[] =>
37
48
  entry.type === 'assistant'
38
- ? blocks(entry).flatMap((block) => (block.type === 'tool_use' && typeof block.name === 'string' ? [block.name] : []))
49
+ ? blocks(entry).flatMap((block) =>
50
+ block.type === 'tool_use' && typeof block.name === 'string'
51
+ ? [{ name: block.name, input: isRecord(block.input) ? block.input : {} }]
52
+ : [],
53
+ )
39
54
  : []
40
55
 
56
+ /** Every tool call of the transcript lines, in order; a line that does not parse is skipped. */
57
+ export const toolUsesOf = (jsonl: string): ToolUse[] => parseEntries(jsonl).flatMap(toolUses)
58
+
59
+ const firstPromptAmong = (entries: readonly Entry[]): string => {
60
+ for (const entry of entries) {
61
+ const text = promptText(entry)
62
+ if (text !== undefined) return text
63
+ }
64
+ return ''
65
+ }
66
+
67
+ /** The first prompt typed in the session, `''` without one. */
68
+ export const firstPromptOf = (jsonl: string): string => firstPromptAmong(parseEntries(jsonl))
69
+
41
70
  /**
42
71
  * The first prompt of the session and the tools the current turn used. The turn starts at the last prompt carrying
43
72
  * `promptId` (the Stop input's `prompt_id`), else at the last prompt of the transcript.
@@ -47,9 +76,11 @@ export const readTurn = (jsonl: string, promptId: string | undefined): Turn => {
47
76
  const prompts = entries.flatMap((entry, index) => (promptText(entry) === undefined ? [] : [index]))
48
77
  const ofTurn = prompts.filter((index) => promptId !== undefined && entries[index].promptId === promptId)
49
78
  const start = (ofTurn.length > 0 ? ofTurn : prompts).at(-1) ?? entries.length
50
- const first = prompts[0]
51
79
  return {
52
- firstPrompt: first === undefined ? '' : (promptText(entries[first]) ?? ''),
53
- tools: entries.slice(start).flatMap(toolNames),
80
+ firstPrompt: firstPromptAmong(entries),
81
+ tools: entries
82
+ .slice(start)
83
+ .flatMap(toolUses)
84
+ .map(({ name }) => name),
54
85
  }
55
86
  }
@@ -1,5 +1,5 @@
1
- import { programName, ruleStart } from '../shared/program-position'
2
- import { expandsBraces, isExpanded, type SimpleCommand, type Word } from '../shared/shell'
1
+ import { programName, ruleStart } from './program-position'
2
+ import { expandsBraces, isExpanded, type SimpleCommand, type Word } from './shell'
3
3
 
4
4
  /**
5
5
  * What an `xargs` call is told to run: the program word (absent when it runs `echo`) and where it stands among the
@@ -11,10 +11,11 @@ type XargsCall = { program: Word | undefined; at: number; replaceStrings: string
11
11
  /** One option word read: the words it used up, and the replace string it set, if any. */
12
12
  type OptionRead = { width: number; replaceString?: string }
13
13
 
14
- const XARGS = 'xargs'
14
+ export const XARGS = 'xargs'
15
15
  const END_OF_OPTIONS = '--'
16
16
  const DEFAULT_REPLACE_STRING = '{}'
17
- const GLOB = /[*?[]/
17
+ /** The characters that make a shell word a glob. */
18
+ export const GLOB = /[*?[]/
18
19
  const ECHO: Word = { value: 'echo', literal: true }
19
20
 
20
21
  /** GNU and BSD short options: with a value (attached or the next word), with a value only when attached, without one. */
@@ -50,7 +51,7 @@ const LONG_REPLACE = '--replace'
50
51
  * Programs that read or write the files their arguments name and start nothing, whatever the arguments say: input
51
52
  * handed to them by `xargs` stays arguments. A program that runs text or a program its arguments name stays out (`git`
52
53
  * 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
+ * program not listed: the list is closed, so an unknown program keeps every word after `xargs` at a program position.
54
55
  */
55
56
  const PLAIN_PROGRAMS = new Set([
56
57
  'basename',
@@ -166,9 +167,10 @@ const isPlainProgram = (program: Word, replaceStrings: string[], guardedPrograms
166
167
  /**
167
168
  * An `xargs` at the start of the command that runs a fixed program starting nothing (D77) becomes that program's
168
169
  * 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.
170
+ * and its arguments stand at no program position. Without a program it becomes `echo`, which `xargs` then runs. Every
171
+ * other command comes back as it is, so every word after `xargs` keeps a program position for a program from an
172
+ * expansion or a replace string, a glob, a guarded program, a starter (a shell, `env`, `xargs`, `timeout`, an
173
+ * interpreter) and any program not on the list. Used by the merge guard and the git guard (D77, D108).
172
174
  */
173
175
  export const resolvePlainXargs = (words: SimpleCommand, guardedPrograms: readonly string[]): SimpleCommand => {
174
176
  const start = ruleStart(words)
@@ -0,0 +1,49 @@
1
+ import { isRecord } from '../shared/json'
2
+ import { toolUsesOf } from '../shared/transcript'
3
+
4
+ /**
5
+ * Whether the hint is on, the session is a lane, a task signal is open, this task was hinted already, and the call
6
+ * started a subagent.
7
+ */
8
+ export type TaskHintInput = { enabled: boolean; lane: boolean; signalled: boolean; hinted: boolean; subagent: boolean }
9
+
10
+ /** Whether to hint now, and the mark to keep: set once hinted, cleared by a signal so the next task is hinted again. */
11
+ export type TaskHintDecision = { hint: boolean; hinted: boolean }
12
+
13
+ const IN_PROGRESS = 'in_progress'
14
+
15
+ /** The hint the lane reads: a reminder, never a block. */
16
+ export const TASK_HINT_TEXT =
17
+ 'No task signal is open: report the task you work on with `jh task start "<task>"` (and `jh task done` when it is finished), so the herd shows it.'
18
+
19
+ /** The hint after a subagent started: a review runs as one, and the herd shows it as active only with a signal. */
20
+ export const TASK_HINT_SUBAGENT_TEXT =
21
+ 'A subagent started without an open task signal: report what it works on, a review by its gate item, with `jh task start "<item>"` (and `jh task done` when it is finished), so the herd shows it as active.'
22
+
23
+ /**
24
+ * Hint a lane once per task while no signal is open, and at every subagent it starts without one; the coordinator and a
25
+ * switched-off hint never.
26
+ */
27
+ export const decideTaskHint = ({ enabled, lane, signalled, hinted, subagent }: TaskHintInput): TaskHintDecision => {
28
+ if (signalled) return { hint: false, hinted: false }
29
+ if (!enabled || !lane) return { hint: false, hinted }
30
+ return { hint: subagent || !hinted, hinted: true }
31
+ }
32
+
33
+ const todosInProgress = (input: Record<string, unknown>): boolean =>
34
+ Array.isArray(input.todos) && input.todos.some((todo) => isRecord(todo) && todo.status === IN_PROGRESS)
35
+
36
+ /**
37
+ * Whether the session's own task list has a task in progress at the end of `lines` (the transcript's tail): an entry of
38
+ * the last `TodoWrite` in progress, or a `TaskUpdate` that set a task in progress and none after it that moved it on.
39
+ */
40
+ export const hasTaskInProgress = (lines: readonly string[]): boolean => {
41
+ let todos = false
42
+ const tasks = new Map<string, boolean>()
43
+ for (const { name, input } of toolUsesOf(lines.join('\n'))) {
44
+ if (name === 'TodoWrite') todos = todosInProgress(input)
45
+ if (name === 'TaskUpdate' && input.taskId !== undefined && input.status !== undefined)
46
+ tasks.set(String(input.taskId), input.status === IN_PROGRESS)
47
+ }
48
+ return todos || [...tasks.values()].some(Boolean)
49
+ }
@@ -0,0 +1,79 @@
1
+ // PostToolUse hook of Claude Code for Edit, Write and Agent, shipped by jackherd: in a lane without an open task signal
2
+ // it adds the reminder to report the task with `jh task start`, once per task and at every subagent it starts
3
+ // (`additionalContext`, never a block; a subagent in the background starts the hook right after its launch).
4
+ // Anything else, and any failure, answers nothing.
5
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
6
+ import { mainCheckoutOf, projectAndHomeConfigs, repositoryRootOf } from '../shared/checkout'
7
+ import { isRecord } from '../shared/json'
8
+ import { runTaskHint, type TaskHintCall, type TaskHintDependencies } from './run'
9
+ import { openTaskDirectories, readHintMark, writeHintMark } from './store'
10
+
11
+ const HOOK_EVENT = 'PostToolUse'
12
+ /** The first prompt sits at the start of a transcript; a pasted prompt is rarely longer. */
13
+ const HEAD_BYTES = 256 * 1024
14
+ /** The task list's latest state is near the end; a long turn without one falls back to the hint. */
15
+ const TAIL_BYTES = 2 * 1024 * 1024
16
+
17
+ /** The lines of at most `bytes` bytes of the file, from the offset `startOf(size, length)`, and whether that is all of it. */
18
+ const readRange = (
19
+ path: string,
20
+ bytes: number,
21
+ startOf: (size: number, length: number) => number,
22
+ ): { lines: string[]; whole: boolean } => {
23
+ const descriptor = openSync(path, 'r')
24
+ try {
25
+ const { size } = fstatSync(descriptor)
26
+ const length = Math.min(size, bytes)
27
+ const buffer = Buffer.alloc(length)
28
+ readSync(descriptor, buffer, 0, length, startOf(size, length))
29
+ return { lines: buffer.toString('utf8').split('\n'), whole: length === size }
30
+ } finally {
31
+ closeSync(descriptor)
32
+ }
33
+ }
34
+
35
+ /** The whole lines of the first `bytes` bytes of the file; a line cut at the end is dropped. */
36
+ const readHeadLines = (path: string, bytes: number): string[] => {
37
+ const { lines, whole } = readRange(path, bytes, () => 0)
38
+ return whole ? lines : lines.slice(0, -1)
39
+ }
40
+
41
+ /** The whole lines of the last `bytes` bytes of the file; a line cut at the start is dropped. */
42
+ const readTailLines = (path: string, bytes: number): string[] => {
43
+ const { lines, whole } = readRange(path, bytes, (size, length) => size - length)
44
+ return whole ? lines : lines.slice(1)
45
+ }
46
+
47
+ const dependencies: TaskHintDependencies = {
48
+ mainCheckoutOf,
49
+ repositoryRootOf,
50
+ configs: projectAndHomeConfigs,
51
+ readHead: (path) => readHeadLines(path, HEAD_BYTES),
52
+ readTail: (path) => readTailLines(path, TAIL_BYTES),
53
+ openTaskDirectories,
54
+ readMark: readHintMark,
55
+ writeMark: writeHintMark,
56
+ }
57
+
58
+ /** Claude Code's hook input: `session_id`, `cwd` and `transcript_path`. */
59
+ const callOf = (text: string): TaskHintCall | null => {
60
+ const input: unknown = JSON.parse(text)
61
+ if (!isRecord(input)) return null
62
+ const { session_id: session, cwd, transcript_path: transcriptPath, tool_name: tool } = input
63
+ return typeof session === 'string' && typeof cwd === 'string' && typeof transcriptPath === 'string'
64
+ ? { session, cwd, transcriptPath, tool: typeof tool === 'string' ? tool : '' }
65
+ : null
66
+ }
67
+
68
+ const decide = async (): Promise<string | null> => {
69
+ try {
70
+ const call = callOf(await Bun.stdin.text())
71
+ return call ? runTaskHint(call, dependencies) : null
72
+ } catch {
73
+ return null
74
+ }
75
+ }
76
+
77
+ const hint = await decide()
78
+ if (hint) console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: HOOK_EVENT, additionalContext: hint } }))
79
+ process.exit(0)