jackherd 1.2.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +29 -4
  3. package/dist/{chunk-R6MGHCD7.js → chunk-UI3AZNZF.js} +5 -0
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +23 -1
  6. package/dist/plan-format.d.ts +7 -1
  7. package/dist/plan-format.js +3 -1
  8. package/herdr-plugin/herdr-plugin.toml +1 -1
  9. package/install.sh +1 -1
  10. package/kit/hooks/hooks/ask-hint/decision.ts +28 -0
  11. package/kit/hooks/hooks/ask-hint/index.ts +44 -0
  12. package/kit/hooks/hooks/ask-hint/settings.ts +21 -0
  13. package/kit/hooks/hooks/ask-hint/transcript.ts +55 -0
  14. package/kit/hooks/hooks/git-guard/decision.ts +10 -79
  15. package/kit/hooks/hooks/merge-guard/approval.ts +107 -0
  16. package/kit/hooks/hooks/merge-guard/decision.ts +286 -0
  17. package/kit/hooks/hooks/merge-guard/index.ts +56 -0
  18. package/kit/hooks/hooks/merge-guard/pull-request.ts +33 -0
  19. package/kit/hooks/hooks/merge-guard/run.ts +60 -0
  20. package/kit/hooks/hooks/merge-guard/settings.ts +56 -0
  21. package/kit/hooks/hooks/merge-guard/worker.ts +14 -0
  22. package/kit/hooks/hooks/shared/program-position.ts +82 -0
  23. package/kit/hooks/hooks/watch-guard/shell-input.ts +1 -1
  24. package/kit/hooks/hooks/watch-guard/starts.ts +1 -1
  25. package/kit/hooks/hooks.json +18 -0
  26. package/kit/workflow/skills/orchestrate/SKILL.md +66 -4
  27. package/package.json +1 -1
  28. package/schemas/jackherd.schema.json +46 -0
  29. package/src/config/schema.ts +30 -0
  30. package/src/sync/command-context.ts +8 -4
  31. package/src/sync/command.ts +27 -9
  32. package/src/sync/render.ts +4 -2
  33. package/src/sync/report.ts +21 -0
  34. package/src/sync/sync.ts +7 -3
  35. package/src/vocabulary/index.ts +10 -0
  36. /package/kit/hooks/hooks/{git-guard → shared}/shell.ts +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
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
+
9
+ ## 1.3.0
10
+
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.
12
+ - **Self-merge in the PR question.** The `orchestrate` skill's PR question has three options: you merge, you approve and the coordinator merges once the checks are green, or you change something first. On the second, the coordinator waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` on green, does not on red, and tells you in one line. The hook lets exactly that merge pass (`gh pr merge <number>` on its own with merge-method and delete-branch flags), and only for the branch you were asked about, for `guard.selfMerge.approvalMinutes` (default 180): it reads your answer from the session transcript, never from the agent's text, and asks GitHub which branch the pull request carries. `guard.selfMerge.offer: false` takes the option away; a project supplement can block it per tier.
13
+ - **Shorter questions in the dialog.** The `orchestrate` skill now asks in one short sentence of 15 words at most, with the context in the chat before it (three lines), options of a few words and mostly two or three of them, and several questions in one call only when they are independent. The pull request question keeps three bullets of context in the chat and a one-line question. A test measures the example dialogs in the skill.
14
+ - **`jh sync` brings the hook.** A project install gets `merge-guard` in `.claude/hooks/` and an entry in `.claude/settings.json`; a global install ships no hook.
15
+
5
16
  ## 1.2.0
6
17
 
7
18
  - **A running session hears about a new release.** The watcher asks npm once a day, on the timestamp of the `SessionStart` hook, and prints `=== update available <old> → <new>` once per version when there is a newer one; with nothing new it prints nothing. The `orchestrate` skill asks you (update now / not now) and runs `jh update` only after a yes.
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`, `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
 
@@ -499,7 +499,7 @@ Lanes ask Jack, not you: the coordinator checks their questions against the plan
499
499
  3. **Runs dev servers.** A lane whose phase changes something a user sees gets its dev server in a pane of its own when the lane starts, never in the lane's shell.
500
500
  4. **Vets questions.** A lane never opens a dialog. It ends its turn with a text block: the question, the options, its recommendation, the evidence at `file:line`. The coordinator checks the premises against the code and the plan, answers what the plan or a rule already settles, and brings only the open decisions to you, each as a clickable choice. A question in a lane's report is closed in the same turn, in the lane or with you. A draft you review (a README, a page) goes in rounds: you pick the remarks, the lane changes it, you see it again, and the push waits for your approval. Your decision goes into the plan or the board in the same turn, with the date, so it is visible in the plan view and survives the chat.
501
501
  5. **Reviews results.** When a lane is `done` or `idle`, it checks the diff against the lane's `Writes` set and that checks are quoted with numbers, not claimed. Then it starts the lane's next ready phase or names the reason to wait.
502
- 6. **Asks once per PR.** A lane works uncommitted until its PR is ready. The coordinator then asks you once, with the diff summary, the checks with numbers, open points and the commit message; for a visible change it first walks the changed path in the browser. That approval covers the commits, the push and the PR.
502
+ 6. **Asks once per PR.** A lane works uncommitted until its PR is ready. The coordinator then asks you once, with the diff summary, the checks with numbers, open points and the commit message; for a visible change it first walks the changed path in the browser. That approval covers the commits, the push and the PR. The question has three options: you merge, you approve and let the coordinator merge once the checks are green ([self-merge](#user-content-guarded-commands-and-self-merge)), or you change something first.
503
503
  7. **Keeps the merge order.** Merge order is the board's, and the documentation PR of a fresh plan merges first. Afterwards the lanes rebase as after any merge, and the coordinator removes the copies of the plan files in the main checkout that equal the merged ones, so the next update of the checkout does not stop on them. After each merge the remaining lanes fetch and rebase on the base branch and re-run their checks; a lock-file conflict is never resolved by hand. Each lane gets its next phase or a named reason to wait.
504
504
  8. **Closes lanes.** After a lane's last merge and your agreement, it ends the agent, proves the worktree holds nothing unmerged, removes the worktree and the branch, and notes the end in the board.
505
505
 
@@ -508,11 +508,34 @@ Lanes ask Jack, not you: the coordinator checks their questions against the plan
508
508
  | On its own | After your approval | Never |
509
509
  | --- | --- | --- |
510
510
  | Starting a ready phase in a new or running lane (one line to you afterwards) | Commits, push and PR: one approval per PR, the documentation PR for a fresh plan included | Implementing lane work itself |
511
- | Answering a question the plan, the board or a rule settles (reported in one line) | Every open decision a lane raises | Merging, unless a project supplement names PRs it may merge |
511
+ | Answering a question the plan, the board or a rule settles (reported in one line) | Every open decision a lane raises | Merging, unless a project supplement names PRs it may merge or you picked self-merge for the PR |
512
512
  | Rebasing lanes after a merge, with their checks re-run | Database migrations and writes against a shared environment | Confirming a chained git command or storing a permission |
513
513
  | Writing your decisions into the plan or the board | Ending an agent, closing a lane, a hand-over to a fresh chat | Steering herdr from outside herdr |
514
514
 
515
- Who merges follows the project's own rules: by default you, unless the project supplement (`.claude/project/orchestrate.md`) names PRs the coordinator may merge itself and under which conditions.
515
+ Who merges follows the project's own rules: by default you, unless the project supplement (`.claude/project/orchestrate.md`) names PRs the coordinator may merge itself and under which conditions, or you picked self-merge for that PR.
516
+
517
+ #### Guarded commands and self-merge
518
+
519
+ A merge or a release is the step nobody takes back. In a project install the `merge-guard` hook asks you before `gh pr merge` and `gh workflow run` (the usual production release) run: it recognises them when the command spells them out: behind options before the verb (`gh -R x pr merge`), a chain (`a && gh pr merge 12`), a wrapper (also one it does not know, such as `mise exec -- gh pr merge 12`, as any command whose words contain the guarded words in this order), `sh -c` and `eval`, and shows the dialog even in auto mode. A quoted text that names the words, such as a commit message, passes, and so does everything else; the hook prints nothing unless it asks.
520
+
521
+ ```json
522
+ {
523
+ "guard": {
524
+ "commands": ["gh pr merge", "gh workflow run"],
525
+ "selfMerge": { "offer": true, "approvalMinutes": 180 }
526
+ }
527
+ }
528
+ ```
529
+
530
+ - `guard.commands` lists the guarded commands as words, a program and one to three plain words after it: letters, digits and `-`, no `:` and no option. These two are the default; `git push` is not on the list. An empty list switches the guard off.
531
+ - `guard.selfMerge.offer` (default `true`) lets `/orchestrate` offer "approve, and merge it yourself after green checks" in its PR question. With `false` the question has two options and the hook honours no such answer.
532
+ - `guard.selfMerge.approvalMinutes` (default 180) is how long that answer lets `gh pr merge <number>` pass without a second dialog.
533
+
534
+ **What self-merge does.** You pick the second option of the PR question. The coordinator commits, pushes and opens the PR, waits for the checks in the background, merges with `gh pr merge <number> --squash --delete-branch` when they are green and tells you in one line; on a red check it does not merge and reports. The project supplement can block the option per approval tier.
535
+
536
+ **What the hook checks.** It does not take the agent's word for your answer. It reads the session's transcript, which Claude Code writes, and looks for a dialog result where you picked that exact option for a question naming one branch. Then it asks GitHub which branch pull request `<number>` carries and lets the merge pass if the two match, the PR is open and not from a fork, and the command is exactly the plain form: `gh pr merge <number>` with `--squash`, `--merge`, `--rebase` and `--delete-branch` as the only flags, run on its own in the directory you work in (no `-R`, no `--admin`, no chain, no wrapper). `gh workflow run` and every other guarded command ask every time.
537
+
538
+ **Its limit.** The hook reads the command as written, so it does not see what a command runs behind its back: a script file or a project script (`bash ./release.sh`, `./release.sh`, `bun run release`, `make release`) that calls `gh workflow run`, a shell alias or function, `gh alias` and git aliases, text that is encoded or assembled at run time, and a wrapper that spells the words differently. Add the script runners you use to `guard.commands`, or protect the repository itself. The agent runs with your account, so it can also edit a transcript, drop the hook from `.claude/settings.json` or merge through `gh api`. The guard stops a merge or a release by mistake, by a misread instruction or a mistyped command; it does not stop an agent that sets out to get around it. For that, use branch protection and required reviews on the repository. The approval covers the branch, not the commits you saw, for the configured minutes. A global install ships no hook, so there the skill text alone asks you. If Bun cannot start (a process out of file descriptors, say), the hook cannot run and every Bash call is blocked, on purpose: a guard that cannot run does not wave commands through.
516
539
 
517
540
  A permission dialog inside a lane the coordinator confirms itself only for the commit and push of the lane's own branch under its PR approval, and for the fetch, rebase and lock-file steps after a merge, always with the one-time option. Every other dialog goes to you.
518
541
 
@@ -1053,6 +1076,8 @@ What usually stays to configure is what no file in the repository says, for exam
1053
1076
  - **Hooks** (project install only):
1054
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 "…"`).
1055
1078
  - `watch-guard` refuses a `jh watch` start whose reports would go unseen.
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`.
1056
1081
  - `format-on-edit` runs the project's own Biome on edited TS, JS and JSON files, when the project has it installed.
1057
1082
  - **herdr plugin:** a plans pane and an action that opens it ([Installation](#user-content-project-install)).
1058
1083
 
@@ -4,6 +4,10 @@ var LANGUAGE_NAME = {
4
4
  [PLAN_LANGUAGE.en]: "English",
5
5
  [PLAN_LANGUAGE.de]: "German"
6
6
  };
7
+ var SELF_MERGE_LABEL = {
8
+ [PLAN_LANGUAGE.en]: "Approve, and merge it yourself after green checks",
9
+ [PLAN_LANGUAGE.de]: "Freigeben, und nach gr\xFCnen Checks selbst mergen"
10
+ };
7
11
  var isPlanLanguage = (value) => Object.values(PLAN_LANGUAGE).includes(value);
8
12
  var PLAN_STATUS = {
9
13
  done: "done",
@@ -84,6 +88,7 @@ var sectionHeading = (language, section) => `## ${PLAN_VOCABULARY[language].sect
84
88
  export {
85
89
  PLAN_LANGUAGE,
86
90
  LANGUAGE_NAME,
91
+ SELF_MERGE_LABEL,
87
92
  isPlanLanguage,
88
93
  PLAN_STATUS,
89
94
  PLAN_VOCABULARY,
package/dist/config.d.ts CHANGED
@@ -132,6 +132,16 @@ declare const jackherdConfigSchema: z.ZodObject<{
132
132
  reportOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
133
133
  }, z.core.$strict>>>;
134
134
  }, z.core.$strict>>;
135
+ guard: z.ZodPrefault<z.ZodObject<{
136
+ commands: z.ZodDefault<z.ZodArray<z.ZodString>>;
137
+ selfMerge: z.ZodPrefault<z.ZodObject<{
138
+ offer: z.ZodDefault<z.ZodBoolean>;
139
+ approvalMinutes: z.ZodDefault<z.ZodNumber>;
140
+ }, z.core.$strict>>;
141
+ }, z.core.$strict>>;
142
+ hints: z.ZodPrefault<z.ZodObject<{
143
+ askUserQuestion: z.ZodDefault<z.ZodBoolean>;
144
+ }, z.core.$strict>>;
135
145
  check: z.ZodOptional<z.ZodObject<{
136
146
  lintFilesCommand: z.ZodDefault<z.ZodString>;
137
147
  checks: z.ZodDefault<z.ZodArray<z.ZodObject<{
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  PLAN_LANGUAGE
3
- } from "./chunk-R6MGHCD7.js";
3
+ } from "./chunk-UI3AZNZF.js";
4
4
 
5
5
  // src/config/json-schema.ts
6
6
  import { z as z3 } from "zod";
@@ -298,6 +298,24 @@ var coordinatorSchema = z2.strictObject({
298
298
  /** Share of the coordinator's context window from which the watcher reports `coordinator alert`. */
299
299
  alertAt: share.default(0.7)
300
300
  }).refine(warnBelowAlert, WARN_BELOW_ALERT);
301
+ var guardedCommand = z2.string().regex(/^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/, "a program and one to three plain words, such as gh pr merge");
302
+ var guardSchema = z2.strictObject({
303
+ /**
304
+ * The commands the merge guard hook asks the user about before they run, also behind options before the verb, a chain,
305
+ * a wrapper or `sh -c`. An empty list switches the guard off.
306
+ */
307
+ commands: z2.array(guardedCommand).default(["gh pr merge", "gh workflow run"]),
308
+ selfMerge: z2.strictObject({
309
+ /** Whether `/orchestrate` offers "merge it yourself after green checks" in the pull request question; off also stops the hook honouring such an answer. */
310
+ offer: z2.boolean().default(true),
311
+ /** How long an answer to that question lets `gh pr merge <number>` pass without asking, counted from the answer. */
312
+ approvalMinutes: z2.number().int().min(1).max(1440).default(180)
313
+ }).prefault({})
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
+ });
301
319
  var contextSchema = z2.strictObject({
302
320
  /** Share of the context window from which a lane's fill shows yellow and `/implement` offers a new chat. */
303
321
  warnAt: share.default(0.6),
@@ -371,6 +389,10 @@ var jackherdConfigSchema = z2.strictObject({
371
389
  previews: previewsSchema.optional(),
372
390
  sources: z2.array(sourceSchema).refine(hasUniqueNames, "source names must be unique").default([])
373
391
  }).prefault({}),
392
+ /** Read by the merge guard hook, which asks before the listed commands run, and by `/orchestrate`. */
393
+ guard: guardSchema.prefault({}),
394
+ /** Read by the `ask-hint` hook. */
395
+ hints: hintsSchema.prefault({}),
374
396
  /** Read by `jh check`; validated here too, so a typo fails every command alike. */
375
397
  check: checkConfigSchema.optional()
376
398
  });
@@ -5,6 +5,12 @@ declare const PLAN_LANGUAGE: {
5
5
  type PlanLanguage = (typeof PLAN_LANGUAGE)[keyof typeof PLAN_LANGUAGE];
6
6
  /** The language's name as a reply or a question says it. */
7
7
  declare const LANGUAGE_NAME: Record<PlanLanguage, string>;
8
+ /**
9
+ * The option of the pull request question that approves the PR and a merge by the coordinator once the checks are
10
+ * green. The merge guard hook matches the user's answer against this exact text, so it is fixed per language and not
11
+ * a config value.
12
+ */
13
+ declare const SELF_MERGE_LABEL: Record<PlanLanguage, string>;
8
14
  declare const isPlanLanguage: (value: string) => value is PlanLanguage;
9
15
  declare const PLAN_STATUS: {
10
16
  readonly done: "done";
@@ -322,4 +328,4 @@ declare const readSprintDocument: (markdown: string, language: PlanLanguage) =>
322
328
  */
323
329
  declare const readPhaseDependencies: (planMarkdown: string, language: PlanLanguage) => Record<string, string>;
324
330
 
325
- export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
331
+ export { type ChecklistItem, type DeferredItem, type DriftFinding, LANGUAGE_NAME, type LaneProgress, type MergedPhaseWithOpenBoxes, type MisplacedDeferral, PHASE_ID_PREFIX, PLAN_LANGUAGE, PLAN_STATUS, PLAN_VOCABULARY, type PhaseProgress, type PhaseTally, type PhaseWithoutGate, type PlanHeader, type PlanLanguage, type PlanPhase, type PlanStatus, type PlanStep, type PlanVocabulary, type PlansByHref, type RowWithoutPhase, SELF_MERGE_LABEL, type SprintDocument, type SprintLane, type SprintPlanRow, type SprintPlanTable, type StatusWord, type StepProgress, activeItem, boardRowsWithoutPhase, classifyStatus, countDecisions, currentPhase, currentPhaseOf, deferralTarget, endsPhase, findDrift, hasLanesTable, isCheckboxLine, isOpenCheckboxLine, isPlanLanguage, isReviewGateLine, isTickedUnmerged, keptPhaseIds, laneNameOf, laneProgress, mergedPhasesWithOpenBoxes, misplacedDeferrals, newPhasesWithoutReviewGate, parsePlanPhases, parseSprintLanes, phaseHeading, phaseHeadingPattern, phaseIdsOfCell, phaseIdsOfPlan, readPhaseDependencies, readPlanHeader, readSprintDocument, resolvePlanPath, sectionHeading, shortTitle, sprintTitleOf, sumPhases, tallyPhases, withoutRepeats };
@@ -3,11 +3,12 @@ import {
3
3
  PLAN_LANGUAGE,
4
4
  PLAN_STATUS,
5
5
  PLAN_VOCABULARY,
6
+ SELF_MERGE_LABEL,
6
7
  classifyStatus,
7
8
  isPlanLanguage,
8
9
  phaseHeading,
9
10
  sectionHeading
10
- } from "./chunk-R6MGHCD7.js";
11
+ } from "./chunk-UI3AZNZF.js";
11
12
 
12
13
  // src/plan-format/terminal-text.ts
13
14
  var UNSAFE_RANGES = [
@@ -596,6 +597,7 @@ export {
596
597
  PLAN_LANGUAGE,
597
598
  PLAN_STATUS,
598
599
  PLAN_VOCABULARY,
600
+ SELF_MERGE_LABEL,
599
601
  activeItem,
600
602
  boardRowsWithoutPhase,
601
603
  classifyStatus,
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.2.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.2.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,5 +1,14 @@
1
1
  import { posix } from 'node:path'
2
- import { parseShell, ShellParseError, type SimpleCommand, type Word } from './shell'
2
+ import {
3
+ ASSIGNMENT,
4
+ inlineScript,
5
+ isOption,
6
+ isProgramPosition,
7
+ programName,
8
+ readsScriptFromInput,
9
+ ruleStart,
10
+ } from '../shared/program-position'
11
+ import { parseShell, ShellParseError, type SimpleCommand, type Word } from '../shared/shell'
3
12
 
4
13
  export type GitGuardDecision = { kind: 'pass' } | { kind: 'deny'; reason: string }
5
14
 
@@ -133,16 +142,6 @@ const OPTIONS_WITH_VALUE = new Set([
133
142
  const CONFIG_OPTIONS = new Set(['-c', '--config-env'])
134
143
  const CONFIG_ENV_ATTACHED = '--config-env='
135
144
 
136
- /**
137
- * Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
138
- * permission matching also reaches into control-flow bodies, which the reserved words open.
139
- */
140
- const RULE_TRANSPARENT_WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'builtin', 'noglob'])
141
- const RESERVED_WORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'do', 'done', 'while', 'until'])
142
- const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
143
- const DURATION = /^\d+(\.\d+)?[smhd]?$/
144
- const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
145
- const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
146
145
  const INLINE_ALIAS = /^alias\./i
147
146
  /** Variables that make git or its hook manager skip the hooks. */
148
147
  const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
@@ -150,19 +149,6 @@ const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
150
149
  const REDIRECTING_VARIABLE = /^(GIT_CONFIG\w*|GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE)\+?=/
151
150
  /** An option the shell rewrites before git sees it: `--forc{e,}` reaches git as `--force`. */
152
151
  const BRACE_IN_OPTION = /^-.*\{.*(,|\.\.).*\}/
153
- /** Programs that run their arguments as a command, so a `git` right after them is the program. */
154
- const COMMAND_RUNNERS = new Set([
155
- ...RULE_TRANSPARENT_WRAPPERS,
156
- 'env',
157
- 'sudo',
158
- 'doas',
159
- 'exec',
160
- 'xargs',
161
- 'timeout',
162
- 'stdbuf',
163
- 'setsid',
164
- 'watch',
165
- ])
166
152
  const PROTECTED_MENTION = new RegExp(`\\bgit\\b[\\s\\S]*\\b(${Object.values(PROTECTED_VERB).join('|')})\\b`)
167
153
 
168
154
  const isProtectedVerb = (value: string): value is ProtectedVerb =>
@@ -170,38 +156,7 @@ const isProtectedVerb = (value: string): value is ProtectedVerb =>
170
156
 
171
157
  const deny = (reason: string): GitGuardDecision => ({ kind: 'deny', reason })
172
158
 
173
- const transparentWidth = (words: SimpleCommand, index: number): number => {
174
- const { value, literal } = words[index]
175
- if (ASSIGNMENT.test(value)) return 1
176
- if (!literal) return 0
177
- if (RESERVED_WORDS.has(value) || RULE_TRANSPARENT_WRAPPERS.has(value)) return 1
178
- return value === 'timeout' && DURATION.test(words[index + 1]?.value ?? '') ? 2 : 0
179
- }
180
-
181
- /** The index of the word a permission rule compares against: past assignments and stripped wrappers. */
182
- const ruleStart = (words: SimpleCommand): number => {
183
- let index = 0
184
- while (index < words.length) {
185
- const width = transparentWidth(words, index)
186
- if (width === 0) break
187
- index += width
188
- }
189
- return index
190
- }
191
-
192
- /** The program a word names, as a case-insensitive file system would find it. */
193
- const programName = (word: Word): string =>
194
- posix
195
- .basename(word.value.replace(/^=/, ''))
196
- .toLowerCase()
197
- .replace(/\.exe$/, '')
198
-
199
159
  const isGit = (word: Word): boolean => programName(word) === 'git'
200
-
201
- /** At the rule start, or anywhere after a runner such as `sudo -u me` or `env FOO=1`: git runs as the program. */
202
- const isProgramPosition = (words: SimpleCommand, index: number): boolean =>
203
- index === ruleStart(words) || words.slice(0, index).some((word) => COMMAND_RUNNERS.has(programName(word)))
204
-
205
160
  const gitPosition = (words: SimpleCommand, index: number, context: ScriptContext): GitPosition => {
206
161
  const word = words[index]
207
162
  const plain = context === SCRIPT_CONTEXT.top && index === ruleStart(words) && word.literal && word.value === 'git'
@@ -209,8 +164,6 @@ const gitPosition = (words: SimpleCommand, index: number, context: ScriptContext
209
164
  return isProgramPosition(words, index) ? GIT_POSITION.program : GIT_POSITION.argument
210
165
  }
211
166
 
212
- const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
213
-
214
167
  const readInvocation = (words: SimpleCommand, gitIndex: number): GitInvocation | undefined => {
215
168
  const options: Word[] = []
216
169
  const directories: string[] = []
@@ -342,28 +295,6 @@ const judgeInvocation = (invocation: GitInvocation, position: GitPosition): GitG
342
295
  return literal || position === GIT_POSITION.argument ? PASS : deny(VERB_HINT)
343
296
  }
344
297
 
345
- const isShell = (word: Word): boolean => SHELLS.has(programName(word))
346
-
347
- /** The script word after `-c`: the first one that is neither an option nor `--`. */
348
- const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
349
- const flag = words.findIndex((word, position) => position > shellIndex && SHELL_COMMAND_FLAG.test(word.value))
350
- if (flag === -1) return undefined
351
- return words.slice(flag + 1).find((word) => !isOption(word))?.value
352
- }
353
-
354
- const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
355
- const word = words[index]
356
- if (word.literal && word.value === 'eval') return words.slice(index + 1).map(({ value }) => value).join(' ')
357
- return isShell(word) ? shellCommandScript(words, index) : undefined
358
- }
359
-
360
- /** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
361
- const readsScriptFromInput = (words: SimpleCommand): boolean => {
362
- const start = ruleStart(words)
363
- const shell = words[start]
364
- return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
365
- }
366
-
367
298
  /**
368
299
  * `HUSKY=0`, `export HUSKY=0`, `local -x HUSKY=0`, `set -a; HUSKY=0`: wherever such an assignment
369
300
  * stands, it may reach a later git call of the script, not only the one it precedes.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * What the user approved, read from the session transcript. The transcript is written by Claude Code,
3
+ * not by the agent: a dialog answer sits in it as the tool result of an `AskUserQuestion` call, with
4
+ * the question the agent put and the label the user picked. Nothing an agent says in a message,
5
+ * a file or a command counts, so a text cannot stand in for the click.
6
+ *
7
+ * The limit: an agent that runs as the user could still write to the transcript file. This guards
8
+ * against a mistake, not against an agent that sets out to get around it.
9
+ */
10
+
11
+ type Record_ = Record<string, unknown>
12
+
13
+ const MS_PER_MINUTE = 60_000
14
+ const BRANCH_MENTION = /\bBranch `([^`]*)`/g
15
+
16
+ /** A branch name as git takes it from a shell word: no leading dash, no space, no `..`, no shell character. */
17
+ const BRANCH_NAME = /^(?![-/.])(?!.*(?:\.\.|\/\/|@\{))[A-Za-z0-9._/-]+$/
18
+
19
+ const isRecord = (value: unknown): value is Record_ => typeof value === 'object' && value !== null && !Array.isArray(value)
20
+
21
+ const contentOf = (entry: Record_): Record_[] => {
22
+ const content = isRecord(entry.message) ? entry.message.content : undefined
23
+ return Array.isArray(content) ? content.filter(isRecord) : []
24
+ }
25
+
26
+ const isMainThread = (entry: Record_): boolean => entry.isSidechain !== true
27
+
28
+ /** One JSON value per line; a line that is not JSON is skipped. */
29
+ export const parseTranscript = (text: string): unknown[] =>
30
+ text.split('\n').flatMap((line) => {
31
+ try {
32
+ return line.trim() === '' ? [] : [JSON.parse(line)]
33
+ } catch {
34
+ return []
35
+ }
36
+ })
37
+
38
+ /** Whether the question offers `label` as one option among single choices: a multi-select joins its picks into one text. */
39
+ const offersLabel = (question: Record_, label: string): boolean =>
40
+ question.multiSelect !== true &&
41
+ Array.isArray(question.options) &&
42
+ question.options.some((option) => isRecord(option) && option.label === label)
43
+
44
+ const questionTexts = (input: unknown, label: string): string[] => {
45
+ if (!isRecord(input) || 'answers' in input || !Array.isArray(input.questions)) return []
46
+ return input.questions.flatMap((question) =>
47
+ isRecord(question) && typeof question.question === 'string' && offersLabel(question, label)
48
+ ? [question.question]
49
+ : [],
50
+ )
51
+ }
52
+
53
+ /**
54
+ * The questions of each `AskUserQuestion` call that offer `label`, by tool use id. A call that carries its own
55
+ * `answers` asked nobody.
56
+ */
57
+ const askedQuestions = (entries: Record_[], label: string): Map<string, string[]> =>
58
+ new Map(
59
+ entries
60
+ .filter(isMainThread)
61
+ .flatMap(contentOf)
62
+ .filter((block) => block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string')
63
+ .map((block): [string, string[]] => [block.id as string, questionTexts(block.input, label)]),
64
+ )
65
+
66
+ const isFresh = (timestamp: unknown, now: number, windowMinutes: number): boolean => {
67
+ const answeredAt = typeof timestamp === 'string' ? Date.parse(timestamp) : Number.NaN
68
+ const age = now - answeredAt
69
+ return age >= 0 && age <= windowMinutes * MS_PER_MINUTE
70
+ }
71
+
72
+ /** The one branch a question names as ``Branch `name` ``; a question that names none or several approves none. */
73
+ const namedBranch = (question: string): string | undefined => {
74
+ const names = new Set([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
75
+ const [only] = names
76
+ return names.size === 1 && BRANCH_NAME.test(only) ? only : undefined
77
+ }
78
+
79
+ /** The branches one result entry approves: the questions of its `AskUserQuestion` call that the user answered with `label`. */
80
+ const branchesApprovedIn = (entry: Record_, asked: Map<string, string[]>, label: string): string[] => {
81
+ const answers = isRecord(entry.toolUseResult) ? entry.toolUseResult.answers : undefined
82
+ if (!isRecord(answers)) return []
83
+ return contentOf(entry)
84
+ .filter((block) => block.type === 'tool_result')
85
+ .flatMap((block) => asked.get(String(block.tool_use_id)) ?? [])
86
+ .filter((question) => answers[question] === label)
87
+ .flatMap((question) => namedBranch(question) ?? [])
88
+ }
89
+
90
+ export type ApprovalQuery = {
91
+ entries: unknown[]
92
+ /** The exact option label that approves a merge. */
93
+ label: string
94
+ /** The time of the check, in milliseconds. */
95
+ now: number
96
+ /** How long an answer holds, in minutes; an answer from the future does not hold at all. */
97
+ windowMinutes: number
98
+ }
99
+
100
+ /** The branches whose question the user answered with `label` within the window. */
101
+ export const approvedBranches = ({ entries, label, now, windowMinutes }: ApprovalQuery): Set<string> => {
102
+ if (label === '') return new Set()
103
+ const records = entries.filter(isRecord)
104
+ const asked = askedQuestions(records, label)
105
+ const fresh = records.filter((entry) => isMainThread(entry) && isFresh(entry.timestamp, now, windowMinutes))
106
+ return new Set(fresh.flatMap((entry) => branchesApprovedIn(entry, asked, label)))
107
+ }