jackherd 1.3.0 → 1.4.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 CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.4.0
6
+
7
+ - **A question in running text gets a hint.** The new `Stop` hook `ask-hint` tells you when the last message of a turn ends in `?` and no `AskUserQuestion` dialog was used. It only hints; the turn ends as before. A lane session stays silent, because a lane asks its coordinator as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`. `jh sync` brings the hook to a project install; a global install ships none.
8
+
5
9
  ## 1.3.0
6
10
 
7
11
  - **A merge or a release asks first.** The new hook `merge-guard` asks you before `gh pr merge` and `gh workflow run` run, behind options before the verb (`gh -R x pr merge`), a chain, a wrapper, `sh -c` and `eval`, also in auto mode. The commands are `guard.commands` in `.jackherd/config.json` (default these two, `git push` not among them); an empty list switches the hook off. It prints nothing unless it asks. It guards against a mistake, not against an agent that sets out to get around it; the README says where the line is.
package/README.md CHANGED
@@ -170,7 +170,7 @@ When `init` creates a new config, it asks:
170
170
  | Use it when | the team shares the kit through the repository | the repository must stay untouched, not even untracked files |
171
171
  | Lives in | the project's `.claude/` | `~/.claude/` |
172
172
  | Skill names | `/plan`, `/implement`, `/orchestrate`, … | `/jh-plan`, `/jh-implement`, `/jh-orchestrate`, … |
173
- | Hooks | `git-guard`, `watch-guard`, `merge-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
173
+ | Hooks | `git-guard`, `watch-guard`, `merge-guard`, `ask-hint`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
174
174
  | Config | `.jackherd/config.json` | `~/.jackherd/config.json`, used where a repository has none |
175
175
  | Update | `jh sync` | `jh update` |
176
176
 
@@ -1077,6 +1077,7 @@ What usually stays to configure is what no file in the repository says, for exam
1077
1077
  - `git-guard` refuses a git command that hides `commit`, `push`, `rebase`, `reset`, `checkout` or `merge` from your permission rules (`git -C … push`, `sh -c "…"`).
1078
1078
  - `watch-guard` refuses a `jh watch` start whose reports would go unseen.
1079
1079
  - `merge-guard` asks you before `gh pr merge` and `gh workflow run` run, when they are spelled out ([Guarded commands](#user-content-guarded-commands-and-self-merge)).
1080
+ - `ask-hint` (event `Stop`) tells you when a turn ends in a question asked as running text and used no `AskUserQuestion` dialog. It only hints, never blocks, and stays silent in a lane session, which asks as a text block. Switch it off with `"hints": { "askUserQuestion": false }` in `.jackherd/config.json`.
1080
1081
  - `format-on-edit` runs the project's own Biome on edited TS, JS and JSON files, when the project has it installed.
1081
1082
  - **herdr plugin:** a plans pane and an action that opens it ([Installation](#user-content-project-install)).
1082
1083
 
package/dist/config.d.ts CHANGED
@@ -139,6 +139,9 @@ declare const jackherdConfigSchema: z.ZodObject<{
139
139
  approvalMinutes: z.ZodDefault<z.ZodNumber>;
140
140
  }, z.core.$strict>>;
141
141
  }, z.core.$strict>>;
142
+ hints: z.ZodPrefault<z.ZodObject<{
143
+ askUserQuestion: z.ZodDefault<z.ZodBoolean>;
144
+ }, z.core.$strict>>;
142
145
  check: z.ZodOptional<z.ZodObject<{
143
146
  lintFilesCommand: z.ZodDefault<z.ZodString>;
144
147
  checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
package/dist/config.js CHANGED
@@ -312,6 +312,10 @@ var guardSchema = z2.strictObject({
312
312
  approvalMinutes: z2.number().int().min(1).max(1440).default(180)
313
313
  }).prefault({})
314
314
  });
315
+ var hintsSchema = z2.strictObject({
316
+ /** Whether the `ask-hint` hook points out a last message that ends in a question while the turn used no `AskUserQuestion`; a lane session is never hinted. */
317
+ askUserQuestion: z2.boolean().default(true)
318
+ });
315
319
  var contextSchema = z2.strictObject({
316
320
  /** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
317
321
  warnAt: share.default(0.6),
@@ -387,6 +391,8 @@ var jackherdConfigSchema = z2.strictObject({
387
391
  }).prefault({}),
388
392
  /** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
389
393
  guard: guardSchema.prefault({}),
394
+ /** Read by the `ask-hint` hook. */
395
+ hints: hintsSchema.prefault({}),
390
396
  /** Read by `jh check`; validated here too, so a typo fails every command alike. */
391
397
  check: checkConfigSchema.optional()
392
398
  });
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the plans of a board's lanes in a herdr pane"
6
6
  platforms = ["linux", "macos"]
package/install.sh CHANGED
@@ -9,7 +9,7 @@ set -eu
9
9
 
10
10
  # The default is the version this file ships with. The release bump changes it together with
11
11
  # package.json; install.test.ts fails while the two differ. Overridable, but only by a plain release.
12
- JACKHERD_VERSION="${JACKHERD_VERSION:-1.3.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.4.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -0,0 +1,28 @@
1
+ export type HintInput = {
2
+ message: string
3
+ tools: string[]
4
+ lane: boolean
5
+ stopHookActive: boolean
6
+ enabled: boolean
7
+ }
8
+
9
+ export type HintDecision = { kind: 'silent' } | { kind: 'hint'; message: string }
10
+
11
+ const ASK_TOOL = 'AskUserQuestion'
12
+
13
+ /** A lane prompt names the lane in its first line; a lane started from a hand-over file reads the file first. */
14
+ const LANE_PROMPT = /^\s*(<pasted_content[^>]*>\s*)?(Du bist die Spur|You are the lane|Read `\.claude\/handover\/)/i
15
+
16
+ const HINT =
17
+ 'Claude\'s last reply ends in a question, but no AskUserQuestion dialog was used. For choices with fixed answers, ask Claude to use the dialog. Turn this notice off with {"hints": {"askUserQuestion": false}} in .jackherd/config.json.'
18
+
19
+ /** The last character that counts is a `?`; closing emphasis, quotes and brackets after it do not hide it. */
20
+ export const endsWithQuestion = (message: string): boolean => /[??][\s*_"')\]“”’»«]*$/.test(message)
21
+
22
+ export const isLanePrompt = (firstPrompt: string): boolean => LANE_PROMPT.test(firstPrompt)
23
+
24
+ export const decideHint = (input: HintInput): HintDecision => {
25
+ if (!input.enabled || input.stopHookActive || input.lane) return { kind: 'silent' }
26
+ if (!endsWithQuestion(input.message) || input.tools.includes(ASK_TOOL)) return { kind: 'silent' }
27
+ return { kind: 'hint', message: HINT }
28
+ }
@@ -0,0 +1,44 @@
1
+ // Stop hook, shipped by jackherd. Points out, once per turn, a last message that ends in a question while the turn used
2
+ // no AskUserQuestion dialog. It only hints (a `systemMessage` for the user, exit 0) and never blocks the stop. Any failure
3
+ // inside the hook is silent (a module that does not load or a missing `bun` is not caught here): a hint must not fail a turn. The Stop input carries `last_assistant_message`, `prompt_id` and
4
+ // `transcript_path`; the tools of the turn and the lane prompt come from the transcript, read only when the message is a question.
5
+ import { readFileSync } from 'node:fs'
6
+ import { decideHint, endsWithQuestion, isLanePrompt } from './decision'
7
+ import { isHintEnabled } from './settings'
8
+ import { readTurn } from './transcript'
9
+
10
+ type StopInput = {
11
+ last_assistant_message?: unknown
12
+ prompt_id?: unknown
13
+ transcript_path?: unknown
14
+ stop_hook_active?: unknown
15
+ }
16
+
17
+ const hintFor = (input: StopInput) => {
18
+ if (typeof input.last_assistant_message !== 'string' || !endsWithQuestion(input.last_assistant_message)) return undefined
19
+ if (typeof input.transcript_path !== 'string' || input.stop_hook_active === true) return undefined
20
+ const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
21
+ if (!isHintEnabled(projectDirectory)) return undefined
22
+ const promptId = typeof input.prompt_id === 'string' ? input.prompt_id : undefined
23
+ const turn = readTurn(readFileSync(input.transcript_path, 'utf8'), promptId)
24
+ return decideHint({
25
+ message: input.last_assistant_message,
26
+ tools: turn.tools,
27
+ lane: isLanePrompt(turn.firstPrompt),
28
+ stopHookActive: false,
29
+ enabled: true,
30
+ })
31
+ }
32
+
33
+ const decide = async () => {
34
+ try {
35
+ return hintFor(JSON.parse(await Bun.stdin.text()) as StopInput)
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ const decision = await decide()
42
+
43
+ if (decision?.kind === 'hint') console.log(JSON.stringify({ systemMessage: decision.message }))
44
+ process.exit(0)
@@ -0,0 +1,21 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ typeof value === 'object' && value !== null && !Array.isArray(value)
7
+
8
+ const readHintsSection = (directories: string[]): Record<string, unknown> => {
9
+ const path = directories.map((directory) => join(directory, '.jackherd', 'config.json')).find((candidate) => existsSync(candidate))
10
+ if (!path) return {}
11
+ try {
12
+ const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
13
+ return isRecord(config) && isRecord(config.hints) ? config.hints : {}
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ /** On unless the first config that exists says `hints.askUserQuestion: false`; a malformed value keeps the hint on. */
20
+ export const isHintEnabled = (projectDirectory: string, home: string = homedir()): boolean =>
21
+ readHintsSection([projectDirectory, home]).askUserQuestion !== false
@@ -0,0 +1,55 @@
1
+ export type Turn = { firstPrompt: string; tools: string[] }
2
+
3
+ type Entry = {
4
+ type?: unknown
5
+ isMeta?: unknown
6
+ promptId?: unknown
7
+ message?: { content?: unknown }
8
+ }
9
+
10
+ type Block = { type?: unknown; text?: unknown; name?: unknown }
11
+
12
+ const parseEntries = (jsonl: string): Entry[] =>
13
+ jsonl.split('\n').flatMap((line) => {
14
+ try {
15
+ const entry: unknown = JSON.parse(line)
16
+ return typeof entry === 'object' && entry !== null ? [entry as Entry] : []
17
+ } catch {
18
+ return []
19
+ }
20
+ })
21
+
22
+ const blocks = (entry: Entry): Block[] => {
23
+ const content = entry.message?.content
24
+ return Array.isArray(content) ? (content as Block[]) : []
25
+ }
26
+
27
+ /** The text a person typed: a string, or text parts. A tool result and the harness's own skill text are no prompt. */
28
+ const promptText = (entry: Entry): string | undefined => {
29
+ if (entry.type !== 'user' || entry.isMeta === true) return undefined
30
+ const content = entry.message?.content
31
+ if (typeof content === 'string') return content
32
+ const texts = blocks(entry).flatMap((block) => (block.type === 'text' && typeof block.text === 'string' ? [block.text] : []))
33
+ return texts.length > 0 ? texts.join('\n') : undefined
34
+ }
35
+
36
+ const toolNames = (entry: Entry): string[] =>
37
+ entry.type === 'assistant'
38
+ ? blocks(entry).flatMap((block) => (block.type === 'tool_use' && typeof block.name === 'string' ? [block.name] : []))
39
+ : []
40
+
41
+ /**
42
+ * The first prompt of the session and the tools the current turn used. The turn starts at the last prompt carrying
43
+ * `promptId` (the Stop input's `prompt_id`), else at the last prompt of the transcript.
44
+ */
45
+ export const readTurn = (jsonl: string, promptId: string | undefined): Turn => {
46
+ const entries = parseEntries(jsonl)
47
+ const prompts = entries.flatMap((entry, index) => (promptText(entry) === undefined ? [] : [index]))
48
+ const ofTurn = prompts.filter((index) => promptId !== undefined && entries[index].promptId === promptId)
49
+ const start = (ofTurn.length > 0 ? ofTurn : prompts).at(-1) ?? entries.length
50
+ const first = prompts[0]
51
+ return {
52
+ firstPrompt: first === undefined ? '' : (promptText(entries[first]) ?? ''),
53
+ tools: entries.slice(start).flatMap(toolNames),
54
+ }
55
+ }
@@ -1,4 +1,12 @@
1
1
  {
2
+ "ask-hint": {
3
+ "event": "Stop",
4
+ "handler": {
5
+ "type": "command",
6
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/ask-hint/index.ts",
7
+ "timeout": 10
8
+ }
9
+ },
2
10
  "format-on-edit": {
3
11
  "event": "PostToolUse",
4
12
  "matcher": "Edit|Write",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -486,6 +486,17 @@
486
486
  },
487
487
  "additionalProperties": false
488
488
  },
489
+ "hints": {
490
+ "default": {},
491
+ "type": "object",
492
+ "properties": {
493
+ "askUserQuestion": {
494
+ "default": true,
495
+ "type": "boolean"
496
+ }
497
+ },
498
+ "additionalProperties": false
499
+ },
489
500
  "check": {
490
501
  "type": "object",
491
502
  "properties": {
@@ -208,6 +208,11 @@ const guardSchema = z.strictObject({
208
208
  .prefault({}),
209
209
  })
210
210
 
211
+ const hintsSchema = z.strictObject({
212
+ /** Whether the `ask-hint` hook points out a last message that ends in a question while the turn used no `AskUserQuestion`; a lane session is never hinted. */
213
+ askUserQuestion: z.boolean().default(true),
214
+ })
215
+
211
216
  const contextSchema = z
212
217
  .strictObject({
213
218
  /** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
@@ -302,6 +307,8 @@ export const jackherdConfigSchema = z.strictObject({
302
307
  .prefault({}),
303
308
  /** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
304
309
  guard: guardSchema.prefault({}),
310
+ /** Read by the `ask-hint` hook. */
311
+ hints: hintsSchema.prefault({}),
305
312
  /** Read by `jh check`; validated here too, so a typo fails every command alike. */
306
313
  check: checkConfigSchema.optional(),
307
314
  })