jackherd 1.17.0 → 1.18.1

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,18 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.18.1
6
+
7
+ - **Starting the watcher under Monitor works again.** The watch guard refused `jh watch --stream` under the Monitor tool, the very place its message points to, so the coordinator fell back to single background runs. The stream refusal now applies only to a Bash call with `run_in_background`, where a never-ending stream would stay silent; Monitor and a foreground call pass.
8
+ - **No self-merge option where it cannot take effect.** A pull request that changes a guard file (`.claude/hooks/**`, `.jackherd/lock.json` and the like) is never covered by a self-merge approval, yet the coordinator still offered the option and you had to confirm the merge anyway. The question is now sent back when it offers the self-merge label for such a pull request; when the files cannot be determined, it passes and the merge guard still asks at merge time.
9
+ - **Review feedback on the coordinator's own pull requests reaches Jack.** The watcher read reviews and comments only for pull requests of lane branches, so an inline finding on a pull request without a lane reached nobody. It now reads feedback for your open pull requests without a lane too, the same set it already read checks for; the event names them `(branch <name>)`. Previews stay lane-only.
10
+
11
+ ## 1.18.0
12
+
13
+ - **`jh check` measures every file your budget names.** A path in `check.skillBudgets` (no glob, no sum) is now measured even when `jh check` would not read it on its own, such as `CLAUDE.md` or a rule with `paths:`; a named file that is missing warns, it never fails. The number of measured files in the `Skill budgets` line can rise after the update.
14
+ - **Shorter kit texts.** The code style, testing and pull request rules, `/jh-implement`, `/jh-plan` and `/jh-pre-commit` are cut to the rules an agent must keep, about 1,500 words fewer in total; the orchestrate skill loses about 1,200 words, what a reference, a hook message or a watcher line already says included, and its approval questions moved into `references/approvals.md`, which the `approval` row links. Word budgets and ceilings fall with them. Run `jh sync` to get the new texts.
15
+ - **A clearer README.** The intro, the follow section and the Quickstart say what you decide, what Jack does and what a source needs.
16
+
5
17
  ## 1.17.0
6
18
 
7
19
  - **Jack resumes after a crash.** After a machine or herdr crash, herdr restored the agents but nobody restarted the watcher, and every lane whose command was cut off stood still. The watcher now keeps a record in `~/.jackherd/resume/`, and a second `[[startup]]` entry of the herdr plugin runs `jh resume`: it wakes the coordinator with a fixed `/orchestrate` line and every lane that was working with a fixed resume text, at most once per crash. Nothing resumes after a clean stop, for a record older than 12 hours, while a watcher runs, or when a target is ambiguous; `~/.jackherd/logs/resume.log` says why.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  <p>
2
- <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.17.0/assets/branding/jackherd-logo-outline.svg" width="340">
2
+ <img alt="jackherd: a black and white Shih Tzu in a turquoise bandana, three lanes running from him to three agent nodes" src="https://unpkg.com/jackherd@1.18.1/assets/branding/jackherd-logo-outline.svg" width="340">
3
3
  <br>
4
4
  </p>
5
5
 
6
- [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg">
6
+ [![npm](https://img.shields.io/npm/v/jackherd)](https://www.npmjs.com/package/jackherd) [![license: MIT](https://img.shields.io/npm/l/jackherd)](https://www.npmjs.com/package/jackherd) [![2 dependencies](https://img.shields.io/badge/dependencies-2-brightgreen)](https://www.npmjs.com/package/jackherd?activeTab=dependencies) [![macOS](https://img.shields.io/badge/macOS-supported-brightgreen?logo=apple)](https://unpkg.com/jackherd/INSTALL.md) [![Linux](https://img.shields.io/badge/Linux-supported-brightgreen?logo=linux&logoColor=white)](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
10
- You write the plan. Jack, the coordinator, runs it in parallel lanes, settles what the plan already decides and asks you only what needs you. The herd view shows what's running, what's blocked and what waits for you.
10
+ You decide what gets built. Jack, the coordinator, plans it with you, runs it in parallel lanes, answers what the plan already settles and asks you only what needs you. The herd view shows what's running, what's blocked and what waits for you.
11
11
 
12
12
  For developers who'd rather decide than supervise.
13
13
 
@@ -15,27 +15,27 @@ For developers who'd rather decide than supervise.
15
15
  curl -fsSL https://unpkg.com/jackherd/install.sh | sh
16
16
  ```
17
17
 
18
- Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg">
18
+ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quickstart](#quickstart)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.17.0/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
21
+ <img src="https://unpkg.com/jackherd@1.18.1/docs/workflow-dark.svg" width="720" alt="jackherd in herdr: a lane hits a question, Jack asks you as a choice, you pick, and the lane works on while the herd view counts its progress.">
22
22
  <br>
23
- <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg">
23
+ <sub>Jack and his lanes in <a href="https://herdr.dev/">herdr</a>, a terminal for coding agents (recommended). Without it, one Claude Code session builds with subagents.</sub><br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
26
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/lanes.svg" width="24" height="34" align="middle" alt=""> <b>Parallel where it pays.</b><br>Phases that don't collide run side by side, each lane on its own branch and worktree. Jack keeps the merge order and has the others rebase.</p>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Commits, pushes and merges wait for your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours. The guards catch mistakes, not intent; a hard boundary takes branch protection.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A lane gets stuck, a check goes red, a decision needs you: Jack steps in and brings you only what's yours to decide. The herd view shows where every lane stands.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
32
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/skills.svg" width="24" height="34" align="middle" alt=""> <b>Works with your skills.</b><br>Keep your own skills: a lane builds with whichever one you pick. Plans written elsewhere run too, once Jack fills in what they're missing.</p>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments on a lane's pull request go back to that lane, which fixes them with a test. <code>/jh-follow</code> brings in anything a CLI or an API can read as JSON: tickets, error logs, status pages.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
36
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>Picks up where you left off.</b><br>Plan and board hold the state, not the chat.</p>
37
37
 
38
- <p><img src="https://unpkg.com/jackherd@1.17.0/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg"></p>
38
+ <p><img src="https://unpkg.com/jackherd@1.18.1/assets/icons/plan.svg" width="24" height="34" align="middle" alt=""> <b>Lives in your repository.</b><br>Plans, board and every decision are Markdown, reviewed like code. <code>jh init</code> gives your team the same setup.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg"></p>
39
39
 
40
40
  ## Quickstart
41
41
 
@@ -50,43 +50,38 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
50
50
  ```text
51
51
  Install jackherd by following https://unpkg.com/jackherd/INSTALL.md. Ask me the setup questions it lists; herdr is recommended, not required.
52
52
  ```
53
- 2. **Start Claude Code in a repository.**
53
+ 2. **Start Claude Code in a repository.** `/jh-start` checks your setup and offers your first plan, written to `docs/plans/`.
54
54
 
55
55
  ```text
56
56
  /jh-start
57
57
  ```
58
- 3. **Create your first plan.** It lands in `docs/plans/`, and for parallel lanes `/jh-plan` offers to write a board.
59
-
60
- ```text
61
- /jh-plan Add product search
62
- ```
63
- 4. **Build the first phase.**
58
+ 3. **Build the first phase,** in one session, no herdr needed.
64
59
 
65
60
  ```text
66
61
  /jh-implement docs/plans/<plan>.md phase 1
67
62
  ```
68
- 5. **Run phases in parallel with herdr (recommended).** Lanes, the full herd view, review-comment fixes and ticket reports need it. In Claude Code inside herdr, hand Jack the board:
63
+ 4. **Run phases in parallel with herdr and `gh` (recommended).** Lanes, the full herd view, review-comment fixes and ticket reports need them. In Claude Code inside herdr, hand Jack the board:
69
64
 
70
65
  ```text
71
66
  /jh-orchestrate docs/plans/board-<name>/board.md
72
67
  ```
73
68
 
74
- Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg">
69
+ Linux and macOS are supported (Windows through WSL); the requirements are in [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md). By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
75
70
 
76
- ## Follow anything outside GitHub
71
+ ## Follow anything you can query
77
72
 
78
- Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
73
+ Tickets, error logs, status pages: if a command or an API call can read it as JSON with your login, Jack can watch it. Recipes cover Jira, Linear, GitHub issues and Projects, Sentry, Google Calendar and status pages; for anything else, `/jh-follow` writes the command with you. One yes, and every change shows up like this:
79
74
 
80
- <img src="https://unpkg.com/jackherd@1.17.0/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
75
+ <img src="https://unpkg.com/jackherd@1.18.1/docs/follow-dark.svg" width="720" alt="A source (ticket, error log or status page) has a new or changed entry; jh watch reports it to Jack; Jack puts a row into Your turn, prefixed with the source's label; its [ask] button makes Jack ask you, and then a lane works it.">
81
76
 
82
- The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.17.0/assets/icons/space.svg">
77
+ The herd sums up your sources in one `Sources` line: new entries per source, or its last run, red while it fails; opened, a click opens an entry. One that needs you lands in Your turn under the source's label, [ask] has Jack look at it, and a ticket shows on the lane that works it.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.18.1/assets/icons/space.svg">
83
78
 
84
79
  ## In detail
85
80
 
86
81
  <details>
87
82
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
88
83
 
89
- <img src="https://unpkg.com/jackherd@1.17.0/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
84
+ <img src="https://unpkg.com/jackherd@1.18.1/docs/lanes-dark.svg" alt="Jack prompts two lanes and asks you for decisions and approvals; the lanes send questions back as text; jh watch reads lane state, pull requests and checks and wakes Jack.">
90
85
 
91
86
  | On his own | After your approval | Never |
92
87
  | --- | --- | --- |
@@ -121,7 +116,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
121
116
 
122
117
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
123
118
 
124
- <img src="https://unpkg.com/jackherd@1.17.0/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
119
+ <img src="https://unpkg.com/jackherd@1.18.1/docs/skills-dark.svg" alt="Your own plan or an issue is adopted (Jack adds what is missing, you see the diff) and goes to /jh-orchestrate; /jh-grill hands decisions to /jh-plan; /jh-plan feeds /jh-orchestrate or a single /jh-implement, which your own skill can replace; /jh-implement ends in /jh-pre-commit, which runs the reviewers; /jh-diagnose hands a red command to /jh-implement.">
125
120
 
126
121
  | Skill | Does |
127
122
  | --- | --- |
@@ -131,7 +126,7 @@ A **plan** is Markdown with phases, checkbox tasks, a model and a review gate pe
131
126
  | `/jh-implement` | builds and tests one phase and ticks it off; independent phases run as parallel subagents |
132
127
  | `/jh-orchestrate` | runs a board's lanes in herdr, as Jack |
133
128
  | `/jh-pre-commit` | runs `jh check` and the reviewers, proposes the commit message and asks before committing |
134
- | `/jh-follow` | follows something outside GitHub: a ticket, error logs, a status page |
129
+ | `/jh-follow` | follows a source: a ticket, error logs, a status page |
135
130
  | `/jh-diagnose` | turns a bug into a failing command, fixes it with a regression test |
136
131
 
137
132
  - **Your own skills stay.** `jh init` keeps them and gives jackherd's skills a `jh-` prefix where a name is taken, as a global install always does; `jh sync` never writes over a file you changed, it leaves `<file>.kit-new` beside it. If a skill from `npx skills add` later takes the path of one of jackherd's, `jh sync` names it and the way out: `"skillPrefix": "jh-"`. Such skills only warn in `jh check`'s word budgets, never red.
@@ -250,7 +245,7 @@ Without a global install, run them as `bunx jackherd <command>`. The config is `
250
245
  }
251
246
  ```
252
247
 
253
- `jh check` counts the words of every skill, agent and always-loaded rule an agent reads, and warns above its budget; `check.skillBudgets` sets your own, by path or glob. It also reads the rule blocks you added or changed in `**/rules/**/*.md`, `CLAUDE.md` and `AGENTS.md`, and warns (`Lint pointers`) where a block carries a rule your linter could check (a banned import path, a literal comparison, a comment form, a banned call) and names no lint rule. Name it with `` `lint:<rule id>` ``, or write `` `lint:none` `` and a reason where the rule stays prose. `check.lintPointers.level` is `warn` (default), `fail` or `off`; `check.lintPointers.exclude` lists globs that are never read. `watch.parallelTasks.auto` (`since`, `until`, at most seven days) lets Jack start up to two extra subagents in a running lane without asking, under the cost limit and away from paths that always ask; he switches it on only after asking you.
248
+ `jh check` counts the words of every skill, agent and always-loaded rule an agent reads, and warns above its budget; `check.skillBudgets` sets your own, by path or glob. A file it names by its exact path is measured too, even one `jh check` would not read on its own (`CLAUDE.md`, a rule with `paths:`); a named file that is missing warns. It also reads the rule blocks you added or changed in `**/rules/**/*.md`, `CLAUDE.md` and `AGENTS.md`, and warns (`Lint pointers`) where a block carries a rule your linter could check (a banned import path, a literal comparison, a comment form, a banned call) and names no lint rule. Name it with `` `lint:<rule id>` ``, or write `` `lint:none` `` and a reason where the rule stays prose. `check.lintPointers.level` is `warn` (default), `fail` or `off`; `check.lintPointers.exclude` lists globs that are never read. `watch.parallelTasks.auto` (`since`, `until`, at most seven days) lets Jack start up to two extra subagents in a running lane without asking, under the cost limit and away from paths that always ask; he switches it on only after asking you.
254
249
 
255
250
  </details>
256
251
 
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.17.0"
3
+ version = "1.18.1"
4
4
  min_herdr_version = "0.8.2"
5
5
  description = "Show the herd, the state 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.17.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.18.1}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -29,7 +29,7 @@ const onlyOf = (values: string[]): string | undefined => {
29
29
  * The one branch a question names as ``Branch `name` ``, with the one change it names after it; a question that names
30
30
  * none or several of either approves none.
31
31
  */
32
- const namedChange = (question: string): ApprovedChange | undefined => {
32
+ export const namedChange = (question: string): ApprovedChange | undefined => {
33
33
  const branch = onlyOf([...question.matchAll(BRANCH_MENTION)].map(([, name]) => name))
34
34
  const change = onlyOf([...question.matchAll(CHANGE_MENTION)].map(([, id]) => id))
35
35
  return branch !== undefined && change !== undefined && BRANCH_NAME.test(branch) ? { branch, change } : undefined
@@ -7,6 +7,7 @@ import { relative, resolve } from 'node:path'
7
7
  import { type BoardLanguage, lanePlansOf } from '../shared/board-lanes'
8
8
  import { type Board, boardsIn } from '../shared/boards'
9
9
  import { laneOf } from '../shared/lane'
10
+ import { worktreePathOf } from '../shared/worktree-list'
10
11
  import type { BoardSettings } from './board-settings'
11
12
 
12
13
  const GIT_TIMEOUT_MS = 3000
@@ -18,9 +19,7 @@ export const laneInWorktreeList = (
18
19
  worktreeDirectory: string,
19
20
  branch: string,
20
21
  ): string | undefined => {
21
- const blocks = worktreeList.split('\n\n').map((block) => block.split('\n'))
22
- const block = blocks.find((lines) => lines.includes(`branch refs/heads/${branch}`))
23
- const path = block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
22
+ const path = worktreePathOf(worktreeList, branch)
24
23
  if (path === undefined) return undefined
25
24
  const lane = laneOf({ cwd: path, main, worktreeDirectory })
26
25
  return lane !== null && relative(resolve(main, worktreeDirectory), resolve(path)) === lane ? lane : undefined
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The positions of the values `sync` renders into the hook entry's command (`kit/hooks/hooks.json`), after the script
3
+ * path. `arguments.test.ts` checks the entry passes them in this order.
4
+ */
5
+ export const ARGUMENT = { recommended: 2, approvalAllow: 3, approvalDeny: 4, selfMergeLabel: 5, standingApprove: 6 } as const
6
+
7
+ /** The first position of a label a guard reads word for word. */
8
+ export const FIRST_GUARDED_LABEL = ARGUMENT.approvalAllow
@@ -5,7 +5,9 @@
5
5
  // pattern; a cache keyed by each file's time and size spares reading the unchanged ones. A dialog whose question
6
6
  // lacks its recommendation as the first option, marked with argv[2], is denied as well, and so is a coordinator's
7
7
  // question that breaks the form of `form.ts` (🐾 header, short question and labels); argv[3…] are the labels a
8
- // guard reads word for word, which carry no mark. Anything else, and any failure, answers nothing, so the dialog opens.
8
+ // guard reads word for word, which carry no mark. A PR question that offers the self-merge label (argv[5]) for a branch
9
+ // whose staged change carries a guard file is denied too, since the merge guard never lets that approval cover it
10
+ // (`self-merge-files.ts`; the positions are named in `arguments.ts`). Anything else, and any failure, answers nothing, so the dialog opens.
9
11
  import { existsSync, statSync } from 'node:fs'
10
12
  import { join } from 'node:path'
11
13
  import { boardLocationOf, boardsIn } from '../shared/boards'
@@ -17,13 +19,19 @@ import { isCoordinatorAgent } from '../shared/lane'
17
19
  import { ownPaneAgent } from '../shared/own-pane'
18
20
  import { planCodesOf } from '../shared/plan-codes'
19
21
  import { ensurePrivateDirectory, writePrivateFile } from '../shared/private-directory'
22
+ import { ARGUMENT, FIRST_GUARDED_LABEL } from './arguments'
20
23
  import { type CodeCache, lookUpCodes, type PlanFileStat, parseCodeCache, serializeCodeCache } from './code-cache'
21
24
  import { formReasonOf } from './form'
22
25
  import { type RecommendationWords, recommendationReasonOf } from './recommendation'
23
26
  import { denyReasonOf, dialogTextsOf, planPathsOfBoards, planRootsOfBoards } from './run'
27
+ import { selfMergeGuardFileReason } from './self-merge-files'
28
+ import { stagedFilesOfBranch } from './staged-files'
24
29
 
25
30
  const HOOK_EVENT = 'PreToolUse'
26
31
 
32
+ /** argv[5] is the self-merge label, in the order the hook entry passes its arguments; an older install passes none. */
33
+ const selfMergeLabelOf = (argv: readonly string[]): string => argv[ARGUMENT.selfMergeLabel] ?? ''
34
+
27
35
  /** Where the cache lies: in the main checkout beside `.jackherd/approvals`, ignored by git. */
28
36
  const cacheDirectory = (main: string): string => join(main, '.jackherd', 'plan-codes')
29
37
  const CACHE_FILE = 'codes.json'
@@ -35,8 +43,8 @@ const statOf = (path: string): PlanFileStat | null => {
35
43
 
36
44
  /** argv[2] is the recommended mark, argv[3…] the labels a guard reads word for word; an older install passes none. */
37
45
  const recommendationWordsOf = (argv: readonly string[]): RecommendationWords => ({
38
- mark: argv[2] ?? '',
39
- guardedLabels: argv.slice(3),
46
+ mark: argv[ARGUMENT.recommended] ?? '',
47
+ guardedLabels: argv.slice(FIRST_GUARDED_LABEL),
40
48
  })
41
49
 
42
50
  /** Keeps the cache for the next dialog; a cache that cannot be written only costs that dialog a full read. */
@@ -96,13 +104,17 @@ const decide = async (): Promise<string | null> => {
96
104
  try {
97
105
  const input: unknown = JSON.parse(await Bun.stdin.text())
98
106
  if (!isRecord(input) || typeof input.cwd !== 'string') return null
107
+ const { cwd } = input
99
108
  const texts = dialogTextsOf(input.tool_input)
100
109
  if (texts.length === 0) return null
101
110
  const words = recommendationWordsOf(process.argv)
102
111
  const reasons = [
103
- denyReasonOf(texts, planCodesFor(input.cwd)),
112
+ denyReasonOf(texts, planCodesFor(cwd)),
104
113
  recommendationReasonOf(input.tool_input, words),
105
- formReasonFor(input.cwd, input.session_id, input.tool_input, words.guardedLabels),
114
+ formReasonFor(cwd, input.session_id, input.tool_input, words.guardedLabels),
115
+ selfMergeGuardFileReason(input.tool_input, selfMergeLabelOf(process.argv), (branch) =>
116
+ stagedFilesOfBranch(cwd, branch),
117
+ ),
106
118
  ].filter((reason): reason is string => reason !== null)
107
119
  return reasons.length === 0 ? null : reasons.join(' ')
108
120
  } catch {
@@ -0,0 +1,35 @@
1
+ import { alwaysAskingFiles, STANDING_ALWAYS_ASK } from '../merge-guard/always-ask'
2
+ import { namedChange } from '../merge-guard/approval'
3
+ import { questionsOf, recordsOf } from './run'
4
+
5
+ /** The staged files of a branch's change; undefined when they cannot be determined. */
6
+ export type StagedFilesOf = (branch: string) => readonly string[] | undefined
7
+
8
+ const offersLabel = (question: Record<string, unknown>, label: string): boolean =>
9
+ recordsOf(question.options).some((option) => option.label === label)
10
+
11
+ const refusal = (files: readonly string[]): string =>
12
+ `The change carries a guard file (${files.map((file) => `\`${file}\``).join(', ')}), which a self-merge approval never covers, so the user confirms this merge in the merge dialog. Ask with the plain approval and the change option only.`
13
+
14
+ const branchOffering = (question: Record<string, unknown>, label: string): string | undefined =>
15
+ typeof question.question === 'string' && offersLabel(question, label) ? namedChange(question.question)?.branch : undefined
16
+
17
+ /**
18
+ * The reason to send a PR question back: it offers the self-merge label for a branch whose staged change carries a
19
+ * guard file, the same files (`STANDING_ALWAYS_ASK`) the merge guard refuses a self-merge for. Null for a question
20
+ * without the label or without a branch mark, and when the files cannot be determined: the merge guard still asks at
21
+ * merge time, so a doubt here only costs the user a merge dialog.
22
+ */
23
+ export const selfMergeGuardFileReason = (
24
+ toolInput: unknown,
25
+ selfMergeLabel: string,
26
+ stagedFilesOf: StagedFilesOf,
27
+ ): string | null => {
28
+ if (selfMergeLabel === '') return null
29
+ for (const question of questionsOf(toolInput)) {
30
+ const branch = branchOffering(question, selfMergeLabel)
31
+ const guarded = branch === undefined ? [] : alwaysAskingFiles(stagedFilesOf(branch) ?? [], STANDING_ALWAYS_ASK)
32
+ if (guarded.length > 0) return refusal(guarded)
33
+ }
34
+ return null
35
+ }
@@ -0,0 +1,24 @@
1
+ import { baseBranchOf } from '../shared/base-branch'
2
+ import { mainCheckoutOf } from '../shared/checkout'
3
+ import { commandOutput } from '../shared/command'
4
+ import { readConfig } from '../shared/config'
5
+ import { worktreePathOf } from '../shared/worktree-list'
6
+
7
+ const gitOutput = (cwd: string, ...args: string[]): string => commandOutput(['git', ...args], cwd)
8
+
9
+ /**
10
+ * The files staged in the worktree of `branch` against the merge base with the base branch, the base the branch mark's
11
+ * change id is taken from; undefined when the branch has no worktree or git fails.
12
+ */
13
+ export const stagedFilesOfBranch = (cwd: string, branch: string): readonly string[] | undefined => {
14
+ try {
15
+ const main = mainCheckoutOf(cwd)
16
+ const base = baseBranchOf(readConfig([main]))
17
+ const worktree = worktreePathOf(gitOutput(main, 'worktree', 'list', '--porcelain'), branch)
18
+ if (base === undefined || worktree === undefined) return undefined
19
+ const mergeBase = gitOutput(worktree, 'merge-base', `origin/${base}`, 'HEAD')
20
+ return gitOutput(worktree, 'diff', '--cached', '--name-only', mergeBase).split('\n').filter(Boolean)
21
+ } catch {
22
+ return undefined
23
+ }
24
+ }
@@ -0,0 +1,8 @@
1
+ /** The path of the worktree that has `branch` checked out, from `git worktree list --porcelain`; undefined for none. */
2
+ export const worktreePathOf = (porcelain: string, branch: string): string | undefined => {
3
+ const block = porcelain
4
+ .split('\n\n')
5
+ .map((entry) => entry.split('\n'))
6
+ .find((lines) => lines.includes(`branch refs/heads/${branch}`))
7
+ return block?.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
8
+ }
@@ -6,7 +6,7 @@ export type BashToolInput = { command?: string; run_in_background?: boolean }
6
6
  const LINE_CONTINUATION = /\\\n\s*/g
7
7
 
8
8
  const STREAM_REFUSAL = [
9
- 'jh watch --stream never ends, so a Bash call never reports it, not even in the background.',
9
+ 'A background Bash call never reports a jh watch --stream watcher: the stream never ends.',
10
10
  'Start the same command with the Monitor tool (timeout_ms 1800000), where every line it prints reaches you.',
11
11
  ].join('\n')
12
12
 
@@ -40,14 +40,16 @@ export const startsOf = (command: string, parse: typeof startsIn = startsIn): Wa
40
40
  * the package, or `bun` with the CLI's script) with the subcommand `watch`, also behind wrappers, inside
41
41
  * `bash -c`, `eval`, a substitution, or a here-document or here-string fed to a shell. Text that only
42
42
  * names the command (an argument, a quoted string, a data here-document) is no start. A `--stream` start
43
- * never finishes, so no Bash call reports it; a single run reports only as its own background task, in a
44
- * call that sends nothing off with `&`.
43
+ * never finishes, so a background call (`run_in_background: true`) never reports it and is refused; without
44
+ * that key it passes, as Monitor sends it (Monitor calls reach this hook as Bash calls without the key) or
45
+ * as a foreground call that visibly runs into its timeout. A single run reports only as its own background
46
+ * task, in a call that sends nothing off with `&`.
45
47
  */
46
48
  export const watcherStartViolation = (input: BashToolInput): string | null => {
47
49
  if (!input.command) return null
48
50
  const starts = startsOf(input.command)
49
51
  if (starts.length === 0) return null
50
- if (starts.some((start) => start.stream)) return STREAM_REFUSAL
52
+ if (starts.some((start) => start.stream)) return input.run_in_background === true ? STREAM_REFUSAL : null
51
53
  if (input.run_in_background !== true) return BACKGROUND_REFUSAL
52
54
  return starts.some((start) => start.callHasBackgroundJob) ? BACKGROUND_JOB_REFUSAL : null
53
55
  }
@@ -4,6 +4,8 @@
4
4
  import { watcherStartViolation } from './decision'
5
5
 
6
6
  const payload = await Bun.stdin.json()
7
+ // Claude Code passes Monitor calls to Bash-matcher hooks as tool_name "Bash" without run_in_background,
8
+ // which is how the decision tells them apart.
7
9
  const violation = watcherStartViolation(payload.tool_input ?? {})
8
10
  if (violation) {
9
11
  console.error(violation)
@@ -13,42 +13,38 @@ You are the **Pre-Commit Orchestrator**. You measure what a command can measure,
13
13
  bunx jackherd check
14
14
  ```
15
15
 
16
- One command answers everything a command can answer, and **its output is the evidence for steps 1, 3 and 4** — the changed files (including untracked ones), the affected package roots, which reviewers the changed paths call for, which files a generator owns, staged build artifacts, lint over the changed files, types and tests per package root, and the project's own checks. All of it is configured in the `check` block of `.jackherd/config.json`.
16
+ Its output is **the evidence for steps 1, 3 and 4**: the changed files (untracked included), the affected package roots, the reviewers the changed paths call for, the files a generator owns, staged build artifacts, lint, types and tests per package root, and the project's own checks (the `check` block of `.jackherd/config.json`).
17
17
 
18
- **Quote its results; restate nothing from your own reading of the diff.** A verdict about type errors, passing tests or lint that was not measured is a guess, and the failure mode is a confident false PASS. Where the command says `❌`, the summary says FIX FIRST.
18
+ **Quote its results; restate nothing from your own reading of the diff.** An unmeasured verdict is a guess, and its failure mode is a confident false PASS. Where the command says `❌`, the summary says FIX FIRST.
19
19
 
20
20
  `--skip-slow` drops types and tests. Use it only when the user asks for a quick pass, and say so in the summary.
21
21
 
22
22
  ## Step 2: Judgement Review
23
23
 
24
- Run `/review` for what the command cannot decide: dead exports, naming, function size, the design review. The measured points are already settled above.
24
+ Run `/review` for what the command cannot decide: dead exports, naming, function size, the design review.
25
25
 
26
26
  ## Step 3: Specialized Reviewers
27
27
 
28
- **Step 1 printed the reviewer list** (`Reviewers: …`, from `check.reviewerRoutes`, plus `check.publicContent` and `skill-review` for changed agent texts) — dispatch exactly those, each as a subagent via the `Agent` tool with its `subagent_type`:
28
+ **Step 1 printed the reviewer list** (`Reviewers: …`) — dispatch exactly those, each as a subagent via the `Agent` tool with its `subagent_type`:
29
29
 
30
- - Pass in the `prompt`: the `git diff` of the relevant files and the file paths; to `skill-review`, also the `Skill budgets` and `Lint pointers` results of the latest `jh check` run.
30
+ - Pass the `git diff` of the relevant files and their paths; to `skill-review`, also the `Skill budgets` and `Lint pointers` results of the latest `jh check` run.
31
31
  - Start all of them **in parallel**, in a single message, and wait for every result before step 4.
32
32
 
33
- `security-audit` has a judgement half: add it when the diff *does* something security-relevant in a path no route lists — auth middleware, API key handling, a schema at an HTTP endpoint, a raw database query, an access policy.
33
+ Add `security-audit` when the diff *does* something security-relevant in a path no route lists: auth middleware, API key handling, a schema at an HTTP endpoint, a raw database query, an access policy.
34
34
 
35
- **`spec-review` starts in the same message.** It reviews a different axis, not different files, so it applies regardless of which paths changed. Pass it the full `git diff`, the commit list (`git log <base>..HEAD --oneline`, base `{{kit.baseBranch}}`), the base ref and the branch name.
35
+ **`spec-review` starts in the same message.** It applies whichever paths changed. Pass it the full `git diff`, the commit list (`git log <base>..HEAD --oneline`, base `{{kit.baseBranch}}`), the base ref and the branch name.
36
36
 
37
37
  ## Step 4: Generated Files
38
38
 
39
- Step 1 lists the changed files a generator owns and names the generator. A regenerated file lands in the diff the same way as a hand-edited one, so the judgement is whether this one came from the generator. Ask when it looks hand-edited.
39
+ Step 1 lists the changed files a generator owns. Ask when one looks hand-edited rather than regenerated.
40
40
 
41
41
  ## Step 5: Spec Alignment
42
42
 
43
- Take the `spec-review` result. Steps 2 and 3 ask whether the code is written well (**Standards**); this one asks whether it does what was asked (**Spec**). Report the two side by side, each under its own heading, so neither masks the other.
43
+ Steps 2 and 3 judge whether the code is written well (**Standards**), `spec-review` whether it does what was asked (**Spec**). Report the two side by side, each under its own heading.
44
44
 
45
- ## Step 6: arc42 Check
45
+ ## Step 6: arc42 and Architecture
46
46
 
47
- Apply the filter in `.claude/rules/kit/arc42.md` to the changed files. On a hit, **ask the user concretely**. No hit for bug fixes, styling, small UI changes, or refactorings without decision character.
48
-
49
- ## Step 7: Architecture Check
50
-
51
- For changes in a service, a domain package or a data layer, apply the filter in `.claude/rules/kit/architecture-files.md`. On a hit, **ask the user concretely** whether the unit's `ARCHITECTURE.md` needs an update.
47
+ Apply the filter in `.claude/rules/kit/arc42.md` to the changed files; for a service, a domain package or a data layer, also the one in `.claude/rules/kit/architecture-files.md`. On a hit, **ask the user concretely**. No hit for bug fixes, styling or refactorings without decision character.
52
48
 
53
49
  ## Output Format
54
50
 
@@ -75,19 +71,17 @@ Active reviewers: Review + Spec + [routed reviewers]
75
71
  ### Verdict: COMMIT / FIX FIRST
76
72
  ```
77
73
 
78
- On FIX FIRST, apply what a tool fixes on its own — formatting (`{{kit.command.format}}` or the formatter on the file), lint auto-fixes — then **re-run `bunx jackherd check` and quote the new result.** A fix is verified by the command that found the problem.
79
-
80
- A second review round after fixes dispatches only the reviewers whose findings were applied, plus the reviewers of any path the fixes newly touched (the reviewer list of the new `check` run); `jh check` itself always runs in full.
74
+ On FIX FIRST, apply what a tool fixes on its own (`{{kit.command.format}}`, lint auto-fixes), then re-run `bunx jackherd check` and quote the new result. A second review round dispatches only the reviewers whose findings were applied, plus those of any path the fixes newly touched; `jh check` always runs in full.
81
75
 
82
- Architecture problems, missing tests, domain logic errors and **every Spec finding** are reported for the user to decide: whether a requirement was dropped on purpose or the scope grew deliberately is the user's call.
76
+ Architecture problems, missing tests, domain logic errors and **every Spec finding** are reported for the user to decide: a dropped requirement or a grown scope is the user's call.
83
77
 
84
78
  ## Commit
85
79
 
86
80
  On COMMIT, propose a Conventional Commit message (`type(scope): description`, imperative, subject at most 72 characters; the body explains cause and consequence in prose).
87
81
 
88
- Ask the commit question with the `AskUserQuestion` tool, as a clickable choice in {{kit.responseLanguage}}: commit with this message / adjust the message / cancel.
82
+ Ask the commit question with the `AskUserQuestion` tool, in {{kit.responseLanguage}}: commit with this message / adjust the message / cancel.
89
83
 
90
- On confirmation, write the message to a file in your scratchpad with the `Write` tool, stage **the files step 1 listed** by name, and commit from the file:
84
+ On confirmation, write the message to a file in your scratchpad with the `Write` tool, stage **the files step 1 listed** by name, and commit from the file, each as one Bash call:
91
85
 
92
86
  ```bash
93
87
  git add -- "<path from step 1>" "<path from step 1>"
@@ -97,4 +91,4 @@ git add -- "<path from step 1>" "<path from step 1>"
97
91
  git commit -F "<message file>"
98
92
  ```
99
93
 
100
- Each is one Bash call: a chained command cannot be approved as one (Claude Code docs, page "Configure permissions", section "Compound commands"). Staging by name keeps unreviewed files out of the commit. A message file reaches git untouched; passed through the shell (`-m "…"` or a heredoc), the backticks and `$(…)` a commit body quotes could run as commands. Let the repository's own commit hooks run.
94
+ A chained command cannot be approved as one; a message passed through the shell (`-m "…"`, a heredoc) can run the backticks and `$(…)` it quotes. Let the repository's own commit hooks run.