jackherd 1.18.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,12 @@
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
+
5
11
  ## 1.18.0
6
12
 
7
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.
package/README.md CHANGED
@@ -1,9 +1,9 @@
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.18.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.18.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
 
@@ -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.18.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.18.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.18.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.18.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.18.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.18.0/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>
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.18.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.18.0/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>
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.18.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.18.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.18.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
 
@@ -66,22 +66,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
66
66
  /jh-orchestrate docs/plans/board-<name>/board.md
67
67
  ```
68
68
 
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.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">
70
70
 
71
71
  ## Follow anything you can query
72
72
 
73
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:
74
74
 
75
- <img src="https://unpkg.com/jackherd@1.18.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.">
76
76
 
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.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">
78
78
 
79
79
  ## In detail
80
80
 
81
81
  <details>
82
82
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
83
83
 
84
- <img src="https://unpkg.com/jackherd@1.18.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.">
85
85
 
86
86
  | On his own | After your approval | Never |
87
87
  | --- | --- | --- |
@@ -116,7 +116,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
116
116
 
117
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.
118
118
 
119
- <img src="https://unpkg.com/jackherd@1.18.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.">
120
120
 
121
121
  | Skill | Does |
122
122
  | --- | --- |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.18.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.18.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)
@@ -106,7 +106,7 @@ also reports your own open PRs on a branch no lane has (`(branch <name>)`; act o
106
106
  A restart is safe, the watcher keeps what it saw; a stop during a report re-delivers that report's events, so vet
107
107
  a repeated event against `gh pr view` or the run before acting twice.
108
108
 
109
- - **Check the start.** A start under the Monitor tool is right (the `watch-guard` hook refuses a Bash call);
109
+ - **Check the start.** A start under the Monitor tool is right (the `watch-guard` hook refuses a `--stream` start as a background Bash call);
110
110
  its first line, `armed: …`, comes only when lanes, states or board changed. A start as a Bash call went
111
111
  wrong: stop that task (`TaskStop`) and start it under the Monitor tool.
112
112
  - **A wake-up is done when every watched lane has a started next action or a named reason to wait**
@@ -294,7 +294,8 @@ always goes to the user, whatever the plan settles.
294
294
  The self-merge option is **{{kit.guard.selfMerge}}** (`guard.selfMerge.offer`). When it reads
295
295
  `not offered`, or the project supplement blocks self-merge for the PR's tier, ask with the other two. The
296
296
  merge guard reads the label and the branch mark back word for word and merges only that diff: never reword
297
- either, and write the branch only into a question offering the label.
297
+ either, and write the branch only into a question offering the label. A hook sends the question back when
298
+ the staged change carries a guard file: ask with the plain approval and the change only.
298
299
  3. **Acceptance in the browser**, for every lane with a visible change:
299
300
  [`references/acceptance.md`](references/acceptance.md).
300
301
  4. On approval the lane commits, pushes and opens the PR per `pull-requests.md`. When the lane's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.18.0",
3
+ "version": "1.18.1",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -95,6 +95,8 @@ import {
95
95
  feedbackReadOf,
96
96
  feedbackSince,
97
97
  feedbackTargets,
98
+ isLaneTarget,
99
+ type LaneFeedbackTarget,
98
100
  } from './feedback'
99
101
  import { type FeedbackItem } from './feedback-item'
100
102
  import { type FeedbackItemsRead, readCommentItems, readFeedbackItems, readOwnLogin } from './feedback-read'
@@ -937,7 +939,7 @@ const previewComments = async (
937
939
  const readPreviews = async (
938
940
  watch: Watch,
939
941
  watching: Watching,
940
- targets: FeedbackTarget[],
942
+ targets: LaneFeedbackTarget[],
941
943
  feedbackItems: FeedbackItem[] | null,
942
944
  now: number,
943
945
  ): Promise<PreviewRead | null> => {
@@ -1037,9 +1039,11 @@ const pollGithubAndCoordinator = async (watch: Watch, watching: Watching, now: n
1037
1039
  syncPlanHeads(watch, watching, pullRequests ?? [])
1038
1040
  const branches = { ...watching.state.laneBranches, ...laneBranches }
1039
1041
  const failureReadings = await readFailures(watch, watching, github, branches)
1040
- const targets = feedbackTargets(pullRequests, branches, watching.state.feedback)
1042
+ const targets = feedbackTargets(pullRequests, branches, watching.state.feedback, answered(github)?.own ?? [])
1041
1043
  const feedback = await readFeedback(watch, watching, targets, now)
1042
- const previews = pullRequests ? await readPreviews(watch, watching, targets, feedback?.items ?? null, now) : null
1044
+ const previews = pullRequests
1045
+ ? await readPreviews(watch, watching, targets.filter(isLaneTarget), feedback?.items ?? null, now)
1046
+ : null
1043
1047
  const { progress: modelProgress } = await boardReadOrNote(watch, Object.keys(fills.models), pullRequests ?? [])
1044
1048
  return [
1045
1049
  ...recordGithub(watching, github, laneBranches, now, failureReadings),
@@ -5,8 +5,14 @@ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
5
5
 
6
6
  export const FEEDBACK_EVENT = 'feedback'
7
7
 
8
- /** An open pull request of a lane whose feedback is read. */
9
- export type FeedbackTarget = { number: number; lane: string; headCommit: string }
8
+ /** Whose pull request it is: a lane's, or the user's own on a `branch` no lane has. */
9
+ export type FeedbackOwner = { lane: string } | { lane: null; branch: string }
10
+
11
+ /** An open pull request whose feedback is read: a lane's or an own one without a lane. */
12
+ export type FeedbackTarget = { number: number; headCommit: string } & FeedbackOwner
13
+
14
+ /** A target of a lane, the only kind a preview is read for. */
15
+ export type LaneFeedbackTarget = FeedbackTarget & { lane: string }
10
16
 
11
17
  /**
12
18
  * What the watcher remembers of one pull request's feedback: `seen` maps `<form>:<id>` to the `updatedAt` last seen,
@@ -46,13 +52,12 @@ export type FeedbackEntry = { place: string | null; text: string }
46
52
 
47
53
  export type FeedbackEvent = {
48
54
  kind: typeof FEEDBACK_EVENT
49
- lane: string
50
55
  number: number
51
56
  author: string
52
57
  /** `review <state>`, `inline`, `comment`, or either of the last two with ` edited`. */
53
58
  label: string
54
59
  entries: FeedbackEntry[]
55
- }
60
+ } & FeedbackOwner
56
61
 
57
62
  /**
58
63
  * How far a read reaches back before the last one: room for a clock that differs from GitHub's, and the round of a
@@ -77,6 +82,11 @@ const DECISIVE_REVIEW_STATES: readonly string[] = [
77
82
  const EDITED = 'edited'
78
83
  const MARKDOWN_HEADING = /^#{1,6}\s/
79
84
 
85
+ export const isLaneTarget = (target: FeedbackTarget): target is LaneFeedbackTarget => target.lane !== null
86
+
87
+ const ownerOf = (target: FeedbackTarget): FeedbackOwner =>
88
+ target.lane === null ? { lane: null, branch: target.branch } : { lane: target.lane }
89
+
80
90
  export const emptyFeedbackState = (): FeedbackState => ({ since: null, pullRequests: {} })
81
91
 
82
92
  const isNullableString = (value: unknown): boolean => value === null || value === undefined || typeof value === 'string'
@@ -217,7 +227,7 @@ const eventsOfRecord = (record: FeedbackRecord, items: readonly FeedbackItem[]):
217
227
  const key = `${item.author}\n${label}`
218
228
  const event = groups.get(key) ?? {
219
229
  kind: FEEDBACK_EVENT,
220
- lane: record.lane,
230
+ ...ownerOf(record),
221
231
  number: record.number,
222
232
  author: item.author,
223
233
  label,
@@ -236,7 +246,7 @@ const eventsOfRecord = (record: FeedbackRecord, items: readonly FeedbackItem[]):
236
246
  export const feedbackEvents = (previous: FeedbackState, read: FeedbackRead): FeedbackEvent[] =>
237
247
  read.targets.flatMap((target) => {
238
248
  const record = previous.pullRequests[String(target.number)]
239
- return record ? eventsOfRecord({ ...record, lane: target.lane }, itemsOf(read, target.number)) : []
249
+ return record ? eventsOfRecord({ ...record, ...ownerOf(target) }, itemsOf(read, target.number)) : []
240
250
  })
241
251
 
242
252
  const recordOf = (
@@ -277,19 +287,27 @@ export const feedbackSince = (state: Pick<FeedbackState, 'since'>, now: number):
277
287
  new Date((state.since ?? now) - SINCE_MARGIN_MS).toISOString()
278
288
 
279
289
  /**
280
- * The pull requests whose feedback is read: the open ones of lane branches when GitHub's list answered, else the
281
- * ones the state follows, so a failing list does not stop the feedback.
290
+ * The pull requests whose feedback is read: the open ones of lane branches and the user's own open ones without a
291
+ * lane (`own`, as the checks read them) when GitHub's list answered, else the ones the state follows, so a failing
292
+ * list does not stop the feedback.
282
293
  */
283
294
  export const feedbackTargets = (
284
295
  pullRequests: readonly WatchedPullRequest[] | null,
285
296
  laneBranches: Readonly<Record<string, string>>,
286
297
  state: FeedbackState,
298
+ own: readonly number[],
287
299
  ): FeedbackTarget[] => {
288
300
  if (!pullRequests)
289
- return Object.values(state.pullRequests).map(({ number, lane, headCommit }) => ({ number, lane, headCommit }))
290
- return pullRequests.flatMap(({ number, branch, state: lifecycle, headCommit }) => {
301
+ return Object.values(state.pullRequests).map((record) => ({
302
+ number: record.number,
303
+ headCommit: record.headCommit,
304
+ ...ownerOf(record),
305
+ }))
306
+ return pullRequests.flatMap<FeedbackTarget>(({ number, branch, state: lifecycle, headCommit }) => {
307
+ if (lifecycle !== PULL_REQUEST_STATE.open) return []
291
308
  const lane = Object.keys(laneBranches).find((name) => laneBranches[name] === branch)
292
- return lane && lifecycle === PULL_REQUEST_STATE.open ? [{ number, lane, headCommit }] : []
309
+ if (lane) return [{ number, lane, headCommit }]
310
+ return own.includes(number) ? [{ number, lane: null, branch, headCommit }] : []
293
311
  })
294
312
  }
295
313
 
@@ -303,7 +321,7 @@ export const isReadTime = (value: unknown): boolean =>
303
321
  const isFeedbackRecord = (value: unknown): boolean =>
304
322
  isRecord(value) &&
305
323
  isPullRequestNumber(value.number) &&
306
- typeof value.lane === 'string' &&
324
+ (typeof value.lane === 'string' || (value.lane === null && typeof value.branch === 'string')) &&
307
325
  typeof value.headCommit === 'string' &&
308
326
  typeof value.count === 'number' &&
309
327
  (value.changesRequested === undefined || typeof value.changesRequested === 'boolean') &&
@@ -127,7 +127,7 @@ export const laneStopHeader = (lane: string, status: string, handoverHint: strin
127
127
  }
128
128
 
129
129
  /** The lane, or `branch <name>` for an own pull request without a lane, its name cut like other third-party text. */
130
- const pullRequestOwner = (event: PullRequestEvent): string =>
130
+ const pullRequestOwner = (event: { lane: string } | { lane: null; branch: string }): string =>
131
131
  event.lane === null ? `branch ${terminalSafe(event.branch).slice(0, REPORT_TEXT_LENGTH)}` : terminalSafe(event.lane)
132
132
 
133
133
  /** The lane, `#<number>` for an own pull request without a lane, or `manual`. */
@@ -206,7 +206,7 @@ export const watchEventLine = (event: WatchEvent, target: string | null): string
206
206
  if (event.kind === WATCH_EVENT.coordinator) return `=== ${event.kind} ${event.level} ${contextLabel(event.share)}`
207
207
  if (isPullRequestEvent(event))
208
208
  return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})${failedChecksSuffix(event)}`
209
- if ('number' in event) return `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)})`
209
+ if ('number' in event) return `=== ${event.kind} #${event.number} (${pullRequestOwner(event)})`
210
210
  return `=== ${event.kind}`
211
211
  }
212
212
 
@@ -218,11 +218,11 @@ export const appointmentEventLine = (event: AppointmentEvent): string =>
218
218
  `=== ${event.kind} ${terminalSafe(event.when)} · ${terminalSafe(event.action).slice(0, REPORT_TEXT_LENGTH)}`
219
219
 
220
220
  /**
221
- * The lines of a feedback event: `=== feedback #<number> (lane) <author> <label> · <k> new`, then one line per entry,
221
+ * The lines of a feedback event: `=== feedback #<number> (lane|branch <name>) <author> <label> · <k> new`, then one line per entry,
222
222
  * `<path>:<line> <first line>` or `<first line>`, cut to a length and to `FEEDBACK_ENTRY_LIMIT` entries.
223
223
  */
224
224
  export const feedbackEventLines = (event: FeedbackEvent): string[] => {
225
- const header = `=== ${event.kind} #${event.number} (${terminalSafe(event.lane)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
225
+ const header = `=== ${event.kind} #${event.number} (${pullRequestOwner(event)}) ${terminalSafe(event.author)} ${terminalSafe(event.label)} · ${event.entries.length} new`
226
226
  const { shown: shownEntries, more } = shownWithMore(event.entries, FEEDBACK_ENTRY_LIMIT)
227
227
  const entries = shownEntries.map(({ place, text }) => {
228
228
  const line = terminalSafe(place === null ? text : `${place} ${text}`).slice(0, REPORT_TEXT_LENGTH)
@@ -1,5 +1,5 @@
1
1
  import { errorText } from '../plans/error-text'
2
- import type { FeedbackTarget } from './feedback'
2
+ import type { LaneFeedbackTarget } from './feedback'
3
3
  import { runGh } from './gh'
4
4
  import {
5
5
  deploymentCandidate,
@@ -25,7 +25,7 @@ const STATUS_FIELDS = '.[0] | {state: (.state // ""), url: (.environment_url //
25
25
  */
26
26
  const readHeadPreview = async (
27
27
  root: string,
28
- target: FeedbackTarget,
28
+ target: LaneFeedbackTarget,
29
29
  deployEnvironments: readonly string[],
30
30
  ): Promise<HeadRead> => {
31
31
  const listPath = `repos/{owner}/{repo}/deployments?sha=${target.headCommit}&per_page=${DEPLOYMENT_LIMIT}`
@@ -50,7 +50,7 @@ const readHeadPreview = async (
50
50
  */
51
51
  export const readDeploymentCandidates = async (
52
52
  root: string,
53
- targets: readonly FeedbackTarget[],
53
+ targets: readonly LaneFeedbackTarget[],
54
54
  deployEnvironments: readonly string[],
55
55
  ): Promise<PreviewCandidatesRead> => {
56
56
  const reads = await Promise.all(targets.map((target) => readHeadPreview(root, target, deployEnvironments)))
@@ -1,5 +1,5 @@
1
1
  import { isRecord } from '../shared/is-record'
2
- import { type FeedbackTarget, isReadTime } from './feedback'
2
+ import { isReadTime, type LaneFeedbackTarget } from './feedback'
3
3
  import { FEEDBACK_FORM, type FeedbackItem } from './feedback-item'
4
4
 
5
5
  export const PREVIEW_EVENT = 'preview'
@@ -33,7 +33,7 @@ export type PreviewRecord = { reportedHead: string | null; stamps: Record<string
33
33
  /** `since` is the epoch milliseconds of the last read, null before the first. */
34
34
  export type PreviewState = { since: number | null; pullRequests: Record<string, PreviewRecord> }
35
35
 
36
- export type PreviewRead = { readAt: number; targets: FeedbackTarget[]; candidates: PreviewCandidate[] }
36
+ export type PreviewRead = { readAt: number; targets: LaneFeedbackTarget[]; candidates: PreviewCandidate[] }
37
37
 
38
38
  /** Which comments name a preview: their authors' logins, and optionally a pattern whose first group is the URL. */
39
39
  export type PreviewSettings = { authors: readonly string[]; urlPattern?: string }
@@ -125,7 +125,7 @@ export const previewDeployments = (deployments: readonly Deployment[], deployEnv
125
125
 
126
126
  /** A deployment of `target`'s head as a candidate once its latest status succeeded with an https URL. */
127
127
  export const deploymentCandidate = (
128
- target: FeedbackTarget,
128
+ target: LaneFeedbackTarget,
129
129
  id: number,
130
130
  status: DeploymentStatus,
131
131
  ): PreviewCandidate | null => {
@@ -144,7 +144,7 @@ export const deploymentCandidate = (
144
144
 
145
145
  export const previewReadOf = (
146
146
  readAt: number,
147
- targets: FeedbackTarget[],
147
+ targets: LaneFeedbackTarget[],
148
148
  candidates: readonly PreviewCandidate[],
149
149
  ): PreviewRead => {
150
150
  const numbers = new Set(targets.map(({ number }) => number))
@@ -152,18 +152,18 @@ export const previewReadOf = (
152
152
  }
153
153
 
154
154
  /** A candidate with a URL that may stand for the target's current head: a comment, or a deployment of that head. */
155
- const isUsable = (candidate: PreviewCandidate, target: FeedbackTarget): boolean =>
155
+ const isUsable = (candidate: PreviewCandidate, target: LaneFeedbackTarget): boolean =>
156
156
  candidate.url !== null && (candidate.headCommit === null || candidate.headCommit === target.headCommit)
157
157
 
158
158
  /** Deployments before comments: a deployment names its commit, a comment does not. */
159
159
  const byDeploymentFirst = (a: PreviewCandidate, b: PreviewCandidate): number =>
160
160
  Number(a.source !== PREVIEW_SOURCE.deployment) - Number(b.source !== PREVIEW_SOURCE.deployment)
161
161
 
162
- const candidatesOf = (read: PreviewRead, target: FeedbackTarget): PreviewCandidate[] =>
162
+ const candidatesOf = (read: PreviewRead, target: LaneFeedbackTarget): PreviewCandidate[] =>
163
163
  read.candidates.filter(({ pullRequest }) => pullRequest === target.number).sort(byDeploymentFirst)
164
164
 
165
165
  /** The usable candidate that changed since `record`, when the head has had no preview reported yet. */
166
- const freshPreview = (record: PreviewRecord, target: FeedbackTarget, candidates: readonly PreviewCandidate[]) =>
166
+ const freshPreview = (record: PreviewRecord, target: LaneFeedbackTarget, candidates: readonly PreviewCandidate[]) =>
167
167
  target.headCommit === record.reportedHead
168
168
  ? undefined
169
169
  : candidates.find((candidate) => record.stamps[candidate.key] !== candidate.stamp && isUsable(candidate, target))
@@ -189,7 +189,7 @@ export const previewEvents = (previous: PreviewState, read: PreviewRead): Previe
189
189
  : []
190
190
  })
191
191
 
192
- const recordOf = (record: PreviewRecord | undefined, target: FeedbackTarget, candidates: PreviewCandidate[]) => {
192
+ const recordOf = (record: PreviewRecord | undefined, target: LaneFeedbackTarget, candidates: PreviewCandidate[]) => {
193
193
  const stamps = Object.fromEntries(candidates.map(({ key, stamp }) => [key, stamp]))
194
194
  const shown = record
195
195
  ? freshPreview(record, target, candidates)
@@ -211,7 +211,7 @@ export const recordPreviews = (previous: PreviewState, read: PreviewRead): Previ
211
211
  })
212
212
 
213
213
  /** The targets whose current head has no preview reported yet; only they need their deployments read. */
214
- export const unreportedTargets = (state: PreviewState, targets: readonly FeedbackTarget[]): FeedbackTarget[] =>
214
+ export const unreportedTargets = (state: PreviewState, targets: readonly LaneFeedbackTarget[]): LaneFeedbackTarget[] =>
215
215
  targets.filter(({ number, headCommit }) => state.pullRequests[String(number)]?.reportedHead !== headCommit)
216
216
 
217
217
  const isPreviewRecord = (value: unknown): boolean =>