jackherd 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.26.0
6
+
7
+ - **"Your turn" clears what is obsolete, and says why.** A closed entry now keeps the reason it was closed (`jh waiting close <id> --reason "<text>"`, `jh waiting --closed` lists the last 24 hours). The watcher closes an older sent file by itself when the same file was sent again (`newer version: <id>`, whoever sent it) or its lane's worktree is gone (`lane removed: <name>`), and reports it as `waiting closed`. What only looks obsolete is proposed once as `waiting obsolete?`: a send whose lane's pull request was merged or closed, a board row naming such a pull request or a removed lane, a question a later dialog answered in nearly the same words (`superseded`) and a question whose session ended (`session ended`). Nothing but a send is ever closed without you. A refusal by another hook is no longer recorded as an open question. New hook `question-session-end` (on `SessionEnd`) marks the ending session's open questions. The orchestrate skill's `waiting.md` and `events.md` say how to work through a proposal. Run `jh sync` to get the hook and the texts.
8
+
5
9
  ## 1.25.0
6
10
 
7
11
  - **Every plan phase says in one sentence what it is about.** The plan template puts a line directly under each `### Phase` heading, `**What it is about:** <sentence>` (German: `**Worum es geht:** <Satz>`): what is different afterwards and for whom, or "nothing visible". `jh check` fails a phase the change adds that has tasks and no such line; phases a plan already had are not read, so older plans stay green. `jh plan check` prints a sixth line, `purpose: present` or `purpose: missing <file>:<line> no purpose line in P2, P3`; like the head it is a note and never changes the exit code. In an opened lane the herd shows the sentence dimmed under the head of each phase it draws; the folded row and the sidebar title stay as they are. The plan skill (§ structure), the herd reference and `docs/plan-contract.md` describe it. Run `jh sync` to get the template and the skill text.
package/INSTALL.md CHANGED
@@ -55,7 +55,7 @@ When `init` creates a new config, it asks:
55
55
  | Use it when | the team shares the kit through the repository | the repository must stay untouched, not even untracked files |
56
56
  | Lives in | the project's `.claude/` | `~/.claude/` |
57
57
  | Skill names | `/plan`, `/implement`, `/orchestrate`, … | `/jh-plan`, `/jh-implement`, `/jh-orchestrate`, … |
58
- | Hooks | `git-guard`, `watch-guard`, `lane-start-guard`, `merge-guard`, `approval-gate`, `model-gate`, `model-log`, `ask-hint`, `open-question`, `plain-question`, `sent-file`, `browser-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
58
+ | Hooks | `git-guard`, `watch-guard`, `lane-start-guard`, `merge-guard`, `approval-gate`, `model-gate`, `model-log`, `ask-hint`, `open-question`, `question-session-end`, `plain-question`, `sent-file`, `browser-guard`, `format-on-edit` | none; `init --global` offers only a `SessionStart` update notice |
59
59
  | Config | `.jackherd/config.json` | `~/.jackherd/config.json`, used where a repository has none |
60
60
  | Update | `jh sync` | `jh update` |
61
61
 
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.25.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.26.0/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.25.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.26.0/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,29 +15,29 @@ 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.25.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.26.0/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.25.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.26.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.">
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.25.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.26.0/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.25.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.26.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>
27
27
 
28
- <p><img src="https://unpkg.com/jackherd@1.25.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.26.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>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.25.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.26.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>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.25.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.26.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>
33
33
 
34
- <p><img src="https://unpkg.com/jackherd@1.25.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.26.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>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.25.0/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
36
+ <p><img src="https://unpkg.com/jackherd@1.26.0/assets/icons/model.svg" width="24" height="34" align="middle" alt=""> <b>The right model for each phase.</b><br>Every phase names its model: the stronger one where a mistake is expensive (data, security, a migration), the cheaper one for the rest. Lanes start on it, and a hook refuses to start a phase's subagent on a weaker one.</p>
37
37
 
38
- <p><img src="https://unpkg.com/jackherd@1.25.0/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
38
+ <p><img src="https://unpkg.com/jackherd@1.26.0/assets/icons/bowl.svg" width="24" height="34" align="middle" alt=""> <b>A crash or a full context doesn't cost you the work.</b><br>Plan, board and a hand-over file hold the state, not the chat: the next session picks up where the last one stopped.</p>
39
39
 
40
- <p><img src="https://unpkg.com/jackherd@1.25.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.25.0/assets/icons/space.svg"></p>
40
+ <p><img src="https://unpkg.com/jackherd@1.26.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.26.0/assets/icons/space.svg"></p>
41
41
 
42
42
  ## Quickstart
43
43
 
@@ -68,22 +68,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
68
68
  /jh-orchestrate docs/plans/board-<name>/board.md
69
69
  ```
70
70
 
71
- 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.25.0/assets/icons/space.svg">
71
+ 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.26.0/assets/icons/space.svg">
72
72
 
73
73
  ## Follow anything you can query
74
74
 
75
75
  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:
76
76
 
77
- <img src="https://unpkg.com/jackherd@1.25.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.">
77
+ <img src="https://unpkg.com/jackherd@1.26.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.">
78
78
 
79
- 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.25.0/assets/icons/space.svg">
79
+ 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.26.0/assets/icons/space.svg">
80
80
 
81
81
  ## In detail
82
82
 
83
83
  <details>
84
84
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
85
85
 
86
- <img src="https://unpkg.com/jackherd@1.25.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.">
86
+ <img src="https://unpkg.com/jackherd@1.26.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.">
87
87
 
88
88
  | On his own | After your approval | Never |
89
89
  | --- | --- | --- |
@@ -118,7 +118,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
118
118
 
119
119
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase, and under each phase heading one sentence on what is different afterwards and for whom. 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.
120
120
 
121
- <img src="https://unpkg.com/jackherd@1.25.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.">
121
+ <img src="https://unpkg.com/jackherd@1.26.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.">
122
122
 
123
123
  | Skill | Does |
124
124
  | --- | --- |
@@ -232,7 +232,7 @@ jackherd sets the model, not the effort and not the advisor: those come from Cla
232
232
  | `jh lane close <lane>` | removes a finished lane: agent, worktree, herdr workspace and branch, and marks the board; first checks that nothing is lost, and refuses with every reason; `--dry-run` only checks. Runs without a prompt. |
233
233
  | `jh backend` | the shared backend of the lanes (`lanes.backend`, one pane in the main checkout that the watcher keeps): shows its pane, command and handed-over services; `restart`; `release <service> --lane <lane>` stops it and starts it without that service, naming the port the lane starts it on; `return <service>`. Works from any worktree. A busy service port stops a start, nothing frees a port |
234
234
  | `jh preview <file>` | renders a Markdown file through the GitHub API into a preview in GitHub and npm style |
235
- | `jh waiting` | the files and Artifacts sent to you that you have not answered yet (`close <id>…` or `close --all` once you have) |
235
+ | `jh waiting` | the files and Artifacts sent to you that you have not answered yet (`close <id>… [--reason "<text>"]` or `close --all` once you have; `--closed` lists what closed in the last 24 hours with its reason). The watcher closes an older send by itself once the same file was sent again or its lane is gone, and proposes the rest (a merged pull request, a question that looks answered, a session that ended) as `waiting obsolete?` |
236
236
  | `jh brief` | three lines for each open lane of the board: its goal, its status and what it needs from you, read from the head of its plan (`--board <board.md>` names the board); after `plans.briefAfterMinutes` (default 60, 0 is off) without a prompt, the `return-brief` hook asks Jack to show it first |
237
237
  | `jh context` | how full an agent's context is, as JSON; `--usage [--since 24h\|7d\|<date>]` the tokens a lane used instead (Jack's with `--coordinator`) |
238
238
  | `jh config` | the commands and the base branch the repository uses; a missing value goes to stderr with the line to set, and `jh config <key>` for an unset value exits 3 |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.25.0"
3
+ version = "1.26.0"
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.25.0}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.26.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
@@ -13,8 +13,20 @@ export type QuestionRecord = {
13
13
  cwd: string
14
14
  session: string
15
15
  closedAt: number | null
16
+ /** Who closed the record and why; absent or null on a record that predates the field or is open. */
17
+ closedReason?: string | null
18
+ /** A later dialog's question that probably answered this one in other words; null or absent when none did. */
19
+ supersededBy?: SupersededBy | null
20
+ /** When the session that asked this question ended with it still open (the `SessionEnd` hook); null or absent while it runs. */
21
+ sessionEndedAt?: number | null
16
22
  }
17
23
 
24
+ /** A later dialog's question that answered a record's question, and when. */
25
+ export type SupersededBy = AnsweredQuestion
26
+
27
+ const isSupersededBy = (value: unknown): value is SupersededBy =>
28
+ isRecord(value) && typeof value.question === 'string' && typeof value.answeredAt === 'number'
29
+
18
30
  export type QuestionContext = {
19
31
  cwd: string
20
32
  session: string
@@ -76,6 +88,26 @@ const resultsOf = (entries: readonly JsonObject[]): Map<string, JsonObject | nul
76
88
  return results
77
89
  }
78
90
 
91
+ /** How the client words the result of a dialog that a `PreToolUse` hook sent back: the user never saw it. */
92
+ const HOOK_REFUSAL = new RegExp(`^PreToolUse:${ASK_USER_QUESTION_TOOL} hook error`)
93
+
94
+ const resultTextOf = (block: JsonObject): string => {
95
+ if (typeof block.content === 'string') return block.content
96
+ if (!Array.isArray(block.content)) return ''
97
+ return block.content.map((part) => (isRecord(part) && typeof part.text === 'string' ? part.text : '')).join('\n')
98
+ }
99
+
100
+ /** The ids of the dialog calls a hook refused: the user was never asked, so they leave nothing open. */
101
+ const refusedByHookOf = (entries: readonly JsonObject[]): Set<string> =>
102
+ new Set(
103
+ entries.flatMap((entry) =>
104
+ contentOf(entry)
105
+ .filter((block) => block.type === CONTENT_BLOCK_TYPE.toolResult && block.is_error === true)
106
+ .filter((block) => HOOK_REFUSAL.test(resultTextOf(block)))
107
+ .flatMap((block) => (typeof block.tool_use_id === 'string' ? [block.tool_use_id] : [])),
108
+ ),
109
+ )
110
+
79
111
  /** Whether the transcript went on after the call, other than with the rest of the message that made it. */
80
112
  const wentOnAfter = (ask: Ask, entries: readonly JsonObject[]): boolean =>
81
113
  entries
@@ -102,6 +134,49 @@ export const answeredSince = (
102
134
  at: number,
103
135
  ): AnsweredQuestion | undefined => answered.find((done) => done.question === question && done.answeredAt > at)
104
136
 
137
+ /** The reason a record carries when a later dialog answered its question; the package's `closeReasonOf` spells it the same (contract test). */
138
+ export const ANSWERED_REASON = 'user: answered in a dialog'
139
+
140
+ /** Fewest words the shorter of two questions has to carry before one can be taken for a rewording of the other. */
141
+ const MIN_REWORDED_WORDS = 3
142
+ /** Share of the longer question's words the shorter one has to keep, with no word of its own besides. */
143
+ const MIN_REWORDED_SHARE = 0.7
144
+
145
+ /** Most words the longer question may add: one more word can be a restriction (`lane A`), two or more are another question. */
146
+ const MAX_EXTRA_WORDS = 1
147
+ /** A word that turns a question around: adding one is no rewording (`run it` against `do not run it`). */
148
+ const NEGATIONS: ReadonlySet<string> = new Set(['nicht', 'kein', 'keine', 'keinen', 'nie', 'not', 'no', 'never'])
149
+
150
+ const wordsOf = (question: string): Set<string> =>
151
+ new Set(question.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((word) => word !== ''))
152
+
153
+ /**
154
+ * Whether two different questions read as one asked twice: all the words of the shorter one are in the longer one,
155
+ * and they are most of it. A word that only one side has and the other lacks (`A` against `B`, `export` against
156
+ * `import`) makes them two questions.
157
+ */
158
+ export const isRewording = (left: string, right: string): boolean => {
159
+ const [short, long] = [wordsOf(left), wordsOf(right)].sort((first, second) => first.size - second.size)
160
+ if (short.size < MIN_REWORDED_WORDS || short.size / long.size < MIN_REWORDED_SHARE) return false
161
+ if (![...short].every((word) => long.has(word))) return false
162
+ const extra = [...long].filter((word) => !short.has(word))
163
+ return extra.length <= MAX_EXTRA_WORDS && !extra.some((word) => NEGATIONS.has(word))
164
+ }
165
+
166
+ /**
167
+ * The record with `supersededBy` set to the first dialog after it that answered a different question that reads as the
168
+ * same one reworded; the record itself when none did or one is set already. Only a suspicion for the coordinator to
169
+ * weigh: it never closes the record.
170
+ */
171
+ export const withSupersededBy = (record: QuestionRecord, answered: readonly AnsweredQuestion[]): QuestionRecord => {
172
+ if (record.closedAt !== null || record.supersededBy) return record
173
+ const later = answered.find(
174
+ ({ question, answeredAt }) =>
175
+ answeredAt > record.askedAt && question !== record.question && isRewording(question, record.question),
176
+ )
177
+ return later ? { ...record, supersededBy: { question: later.question, answeredAt: later.answeredAt } } : record
178
+ }
179
+
105
180
  /**
106
181
  * The questions of the main thread that stand open: their dialog was rejected or interrupted, or answered with "later",
107
182
  * and no dialog answered the same text since. A call that carries its own answers asked nobody; a sidechain is no dialog
@@ -110,7 +185,9 @@ export const answeredSince = (
110
185
  export const openQuestionsOf = (entries: readonly unknown[], context: QuestionContext): QuestionRecord[] => {
111
186
  const thread = mainThread(entries)
112
187
  const results = resultsOf(thread)
188
+ const refused = refusedByHookOf(thread)
113
189
  return asksOf(thread)
190
+ .filter((ask) => !refused.has(ask.toolUseId))
114
191
  .filter((ask) => endedUnanswered(ask, results, thread))
115
192
  .map((ask) => ({ ask, question: questionTextOf(ask.question) }))
116
193
  .filter(({ ask, question }) => question !== '' && !answeredSince(context.answered, question, ask.askedAt))
@@ -133,4 +210,7 @@ export const isQuestionRecord = (value: unknown): value is QuestionRecord =>
133
210
  typeof value.askedAt === 'number' &&
134
211
  typeof value.cwd === 'string' &&
135
212
  typeof value.session === 'string' &&
136
- (value.closedAt === null || typeof value.closedAt === 'number')
213
+ (value.closedAt === null || typeof value.closedAt === 'number') &&
214
+ (value.closedReason == null || typeof value.closedReason === 'string') &&
215
+ (value.supersededBy == null || isSupersededBy(value.supersededBy)) &&
216
+ (value.sessionEndedAt == null || typeof value.sessionEndedAt === 'number')
@@ -1,6 +1,13 @@
1
1
  import { ASK_USER_QUESTION_TOOL, parseTranscript } from '../shared/dialog-answers'
2
2
  import { isRecord } from '../shared/json'
3
- import { answeredQuestionsOf, answeredSince, openQuestionsOf, type QuestionRecord } from './record'
3
+ import {
4
+ ANSWERED_REASON,
5
+ answeredQuestionsOf,
6
+ answeredSince,
7
+ openQuestionsOf,
8
+ type QuestionRecord,
9
+ withSupersededBy,
10
+ } from './record'
4
11
 
5
12
  /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
6
13
  export type OpenQuestionDependencies = {
@@ -42,14 +49,21 @@ export const runOpenQuestion = (input: unknown, dependencies: OpenQuestionDepend
42
49
  const answered = answeredQuestionsOf(entries)
43
50
  for (const record of stored.filter(({ closedAt }) => closedAt === null)) {
44
51
  const answer = answeredSince(answered, record.question, record.askedAt)
45
- if (answer) dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt })
52
+ if (answer) {
53
+ dependencies.writeRecord(main, { ...record, closedAt: answer.answeredAt, closedReason: ANSWERED_REASON })
54
+ continue
55
+ }
56
+ if (record.session !== session) continue
57
+ const marked = withSupersededBy(record, answered)
58
+ if (marked !== record) dependencies.writeRecord(main, marked)
59
+ else if (record.sessionEndedAt != null) dependencies.writeRecord(main, { ...record, sessionEndedAt: null })
46
60
  }
47
61
  const known = new Set(stored.map(({ id }) => id))
48
62
  const openTexts = new Set(stored.filter(({ closedAt }) => closedAt === null).map(({ question }) => question))
49
63
  for (const record of openQuestionsOf(entries, { cwd, session, answered, idOf: dependencies.idOf })) {
50
64
  if (known.has(record.id) || openTexts.has(record.question)) continue
51
65
  openTexts.add(record.question)
52
- dependencies.writeRecord(main, record)
66
+ dependencies.writeRecord(main, withSupersededBy(record, answered))
53
67
  }
54
68
  } catch {
55
69
  // A failed record is no record; the herd shows no question.
@@ -0,0 +1,18 @@
1
+ // SessionEnd hook of Claude Code, shipped by jackherd: marks the still open question records that the ending session
2
+ // asked (`sessionEndedAt` in `.jackherd/questions/` of the main checkout), also on `/clear`, so the watcher can propose
3
+ // them as `session ended`. It marks only its own session's records, prints nothing, never blocks and always exits 0.
4
+ import { mainCheckoutOf } from '../shared/checkout'
5
+ import { readQuestionRecords, writeQuestionRecord } from '../open-question/store'
6
+ import { runQuestionSessionEnd } from './run'
7
+
8
+ try {
9
+ runQuestionSessionEnd(JSON.parse(await Bun.stdin.text()), {
10
+ mainCheckoutOf,
11
+ readRecords: readQuestionRecords,
12
+ writeRecord: writeQuestionRecord,
13
+ now: Date.now,
14
+ })
15
+ } catch {
16
+ // Unreadable input marks nothing.
17
+ }
18
+ process.exit(0)
@@ -0,0 +1,37 @@
1
+ import { isRecord } from '../shared/json'
2
+ import type { QuestionRecord } from '../open-question/record'
3
+
4
+ /** Everything with a side effect, passed in so a test reaches the flow without git, files or a clock. */
5
+ export type SessionEndDependencies = {
6
+ mainCheckoutOf: (cwd: string) => string
7
+ readRecords: (main: string) => QuestionRecord[]
8
+ writeRecord: (main: string, record: QuestionRecord) => void
9
+ now: () => number
10
+ }
11
+
12
+ /**
13
+ * The open question records that `session` asked and that carry no end mark yet, marked `sessionEndedAt`. Only the
14
+ * records of the ending session: another session in the same directory is not touched.
15
+ */
16
+ export const endedRecords = (records: readonly QuestionRecord[], session: string, now: number): QuestionRecord[] =>
17
+ records
18
+ .filter((record) => record.session === session && record.closedAt === null && record.sessionEndedAt == null)
19
+ .map((record) => ({ ...record, sessionEndedAt: now }))
20
+
21
+ /**
22
+ * Marks the ending session's still open questions. Claude Code's `SessionEnd` hook input: `session_id`, `cwd`, `reason`
23
+ * (`clear`, `logout`, `prompt_input_exit`, `resume`, `other`); every reason ends the session's own questions, the mark
24
+ * is only a suspicion for the watcher to propose. Writes nothing on any failure.
25
+ */
26
+ export const runQuestionSessionEnd = (input: unknown, dependencies: SessionEndDependencies): void => {
27
+ try {
28
+ if (!isRecord(input)) return
29
+ const { cwd, session_id: session } = input
30
+ if (typeof cwd !== 'string' || typeof session !== 'string') return
31
+ const main = dependencies.mainCheckoutOf(cwd)
32
+ for (const record of endedRecords(dependencies.readRecords(main), session, dependencies.now()))
33
+ dependencies.writeRecord(main, record)
34
+ } catch {
35
+ // A failed mark is no mark; the question stays open as before.
36
+ }
37
+ }
@@ -15,6 +15,8 @@ export type SentRecord = {
15
15
  session: string
16
16
  agentId: string | null
17
17
  closedAt: number | null
18
+ /** Who closed the record and why; absent or null on a record that predates the field or is open. */
19
+ closedReason?: string | null
18
20
  }
19
21
 
20
22
  export type SentCall = {
@@ -125,4 +127,5 @@ export const isSentRecord = (value: unknown): value is SentRecord =>
125
127
  typeof value.cwd === 'string' &&
126
128
  typeof value.session === 'string' &&
127
129
  (value.agentId === null || typeof value.agentId === 'string') &&
128
- (value.closedAt === null || typeof value.closedAt === 'number')
130
+ (value.closedAt === null || typeof value.closedAt === 'number') &&
131
+ (value.closedReason == null || typeof value.closedReason === 'string')
@@ -96,6 +96,14 @@
96
96
  "timeout": 10
97
97
  }
98
98
  },
99
+ "question-session-end": {
100
+ "event": "SessionEnd",
101
+ "handler": {
102
+ "type": "command",
103
+ "command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/question-session-end/index.ts",
104
+ "timeout": 5
105
+ }
106
+ },
99
107
  "return-brief": {
100
108
  "event": "UserPromptSubmit",
101
109
  "handler": {
@@ -1,6 +1,6 @@
1
1
  # § 4 Waiting for you: the rows beyond the basic one
2
2
 
3
- Read when `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
3
+ Read when a watcher line `waiting closed` or `waiting obsolete?`, `jh waiting` or the heartbeat's `for you (<n>)` names this file, a lane waits on the user's answer,
4
4
  or something you sent the user needs no row of its own.
5
5
 
6
6
  - **A lane waits on the answer:** add its name as in the lanes table (a phase may follow) to the optional
@@ -14,3 +14,17 @@ or something you sent the user needs no row of its own.
14
14
  the user's. A dialog that ended without an answer (declined, cut off, or answered "later") needs none
15
15
  either: the kit hook `open-question` records it, and it closes once the same question is answered in a
16
16
  dialog.
17
+ - **The watcher closes a send by itself** when a newer send has the same first entry (`newer version: <id>`,
18
+ whoever sent it) or its lane's worktree stayed gone for two heartbeats (`lane removed: <name>`), and reports
19
+ `waiting closed <n>: <id> <reason>; …`. `jh waiting --closed` lists the last 24 hours with reasons.
20
+ - **A proposal `waiting obsolete? <key> <reason> "<text>"`** closes nothing and comes once per reason. Check
21
+ it against the thing itself. A key `row-…` is a board row (find it by the quoted text): set its status cell to
22
+ `done` (`erledigt` in a German board) with `(<you> <DD.MM.> <HH:MM>: <why>)`; the table is yours alone, no
23
+ code writes into it. Any other key is a record id: `jh waiting close <id> --reason "<why>"`. A thing still
24
+ wanted stays open. Reasons: `pr #<n> merged|closed`,
25
+ `lane removed: <name>`, `superseded: <question>` (a later dialog answered it in nearly the same words),
26
+ `session ended`.
27
+ - **A decision the hooks cannot see** (made in the chat or in a lane's report, written into the board by you):
28
+ close the records it settles in the same turn, `jh waiting close <id> --reason "decided: <decision>"`.
29
+ - **A board row's key is a hash of its item text:** a reworded row gets a new key and can raise its proposal
30
+ again, as can an unreadable `.jackherd/sent`; close it then.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -71,7 +71,9 @@ export const KIT_SKILL_TARGETS: Readonly<Record<string, number>> = {
71
71
  // The dev server restart by PID and `herdr pane run`, the form the merge guard passes (+54).
72
72
  '.claude/skills/orchestrate/references/start-lane.md': 766,
73
73
  // Core cut: § 4 waiting rows beyond the basic one, moved out of the core.
74
- '.claude/skills/orchestrate/references/waiting.md': 216,
74
+ // What the watcher closes by itself, how to work through a `waiting obsolete?` proposal, and the rule for a
75
+ // decision the hooks cannot see: the coordinator's only text for the closing flow.
76
+ '.claude/skills/orchestrate/references/waiting.md': 450,
75
77
  '.claude/skills/orchestrate/references/watch-without-monitor.md': 154,
76
78
  // Prose cut, round 2: cut to its mandatory rules, measured size plus 5 %.
77
79
  // Language split: the note that a template renders in the language of its file name and that a heading in any other language carries a marker (+27).
@@ -1,16 +1,16 @@
1
1
  import { join } from 'node:path'
2
2
  import { readRecords, writeRecordAtomically } from '../shared/private-records'
3
3
 
4
- /** A record that waits on the user until something sets `closedAt`. */
5
- export type ClosableRecord = { id: string; closedAt: number | null }
4
+ /** A record that waits on the user until something sets `closedAt`; `closedReason` says who closed it and why. */
5
+ export type ClosableRecord = { id: string; closedAt: number | null; closedReason?: string | null }
6
6
 
7
7
  export type ClosableRecordLog<T extends ClosableRecord> = {
8
8
  /** Every record of the log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
9
9
  read: (root: string) => T[]
10
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
11
- close: (root: string, ids: readonly string[], now: number) => number
12
- /** Marks every open record `closedAt` and returns how many it closed. */
13
- closeAll: (root: string, now: number) => number
10
+ /** Marks the named records `closedAt` with `reason` and returns how many it closed; one closed already, or unknown, counts for nothing. */
11
+ close: (root: string, ids: readonly string[], now: number, reason: string) => number
12
+ /** Marks every open record `closedAt` with `reason` and returns how many it closed. */
13
+ closeAll: (root: string, now: number, reason: string) => number
14
14
  }
15
15
 
16
16
  /** The log of `.jackherd/<directoryName>/`: records the `guard` accepts, ordered by `timeOf`. */
@@ -21,14 +21,15 @@ export const closableRecordLog = <T extends ClosableRecord>(
21
21
  ): ClosableRecordLog<T> => {
22
22
  const directoryOf = (root: string): string => join(root, '.jackherd', directoryName)
23
23
  const read = (root: string): T[] => readRecords(directoryOf(root), guard, timeOf)
24
- const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number): number => {
24
+ const closeWhere = (root: string, isChosen: (record: T) => boolean, now: number, reason: string): number => {
25
25
  const open = read(root).filter((record) => record.closedAt === null && isChosen(record))
26
- for (const record of open) writeRecordAtomically(directoryOf(root), { ...record, closedAt: now })
26
+ for (const record of open)
27
+ writeRecordAtomically(directoryOf(root), { ...record, closedAt: now, closedReason: reason })
27
28
  return open.length
28
29
  }
29
30
  return {
30
31
  read,
31
- close: (root, ids, now) => closeWhere(root, (record) => ids.includes(record.id), now),
32
- closeAll: (root, now) => closeWhere(root, () => true, now),
32
+ close: (root, ids, now, reason) => closeWhere(root, (record) => ids.includes(record.id), now, reason),
33
+ closeAll: (root, now, reason) => closeWhere(root, () => true, now, reason),
33
34
  }
34
35
  }
@@ -0,0 +1,19 @@
1
+ import { ellipsize, terminalSafe } from '../plan-format/terminal-text'
2
+
3
+ /** Who or what closes a record: the user (a click), the coordinator, a newer send of the same file, a removed lane. */
4
+ export const CLOSE_REASON = {
5
+ user: 'user',
6
+ coordinator: 'coordinator',
7
+ newerVersion: 'newer version',
8
+ laneRemoved: 'lane removed',
9
+ } as const
10
+
11
+ export type CloseReasonKind = (typeof CLOSE_REASON)[keyof typeof CLOSE_REASON]
12
+
13
+ const MAX_DETAIL = 200
14
+
15
+ /** The reason a record carries: the kind, then a free detail on one line without control characters (`coordinator: decided …`). */
16
+ export const closeReasonOf = (kind: CloseReasonKind, detail = ''): string => {
17
+ const text = ellipsize(terminalSafe(detail.replace(/\s+/g, ' ')).trim(), MAX_DETAIL)
18
+ return text === '' ? kind : `${kind}: ${text}`
19
+ }
package/src/plans/live.ts CHANGED
@@ -10,6 +10,7 @@ import { closeWaiting } from '../waiting/command'
10
10
  import { isWatcherRunning } from '../watch/watcher-lock'
11
11
  import { ASKING_NOTICE, type AskDependencies, askCoordinator } from './ask-coordinator'
12
12
  import type { AskTarget } from './ask-target'
13
+ import { CLOSE_REASON, closeReasonOf } from './close-reason'
13
14
  import { errorText } from './error-text'
14
15
  import { focusedScreen } from './focus-overlay'
15
16
  import { noPullRequests } from './github'
@@ -513,7 +514,7 @@ const applyBlockClick = (live: Live, outcome: BlockClick): void => {
513
514
  sprintFile: live.source.sprintFile,
514
515
  language: live.source.settings.boardLanguage,
515
516
  now: new Date(),
516
- closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now()),
517
+ closeRecords: (ids) => closeWaiting(live.source.root, ids, Date.now(), closeReasonOf(CLOSE_REASON.user)),
517
518
  })
518
519
  if (closed) live.closing = [...live.closing, outcome.done]
519
520
  live.notice = notice
@@ -12,8 +12,23 @@ export type QuestionRecord = {
12
12
  cwd: string
13
13
  session: string
14
14
  closedAt: number | null
15
+ /** Who closed the record and why (`closeReasonOf`); absent or null on a record that predates the field or is open. */
16
+ closedReason?: string | null
17
+ /** The later dialog that probably answered this question in other words; null or absent when none did. */
18
+ supersededBy?: SupersededBy | null
19
+ /** When the session that asked this question ended with it still open (the `SessionEnd` hook); null or absent while it runs. */
20
+ sessionEndedAt?: number | null
15
21
  }
16
22
 
23
+ /** A later dialog's question that answered this one, and when. */
24
+ export type SupersededBy = { question: string; answeredAt: number }
25
+
26
+ const isSupersededBy = (value: unknown): value is SupersededBy =>
27
+ typeof value === 'object' &&
28
+ value !== null &&
29
+ typeof (value as SupersededBy).question === 'string' &&
30
+ typeof (value as SupersededBy).answeredAt === 'number'
31
+
17
32
  /** Whether `value` has the shape of a question record's id: twelve lower-case hex digits. */
18
33
  export const isQuestionId = isRecordId
19
34
 
@@ -28,13 +43,19 @@ export const isQuestionRecord = (value: unknown): value is QuestionRecord => {
28
43
  typeof record.askedAt === 'number' &&
29
44
  typeof record.cwd === 'string' &&
30
45
  typeof record.session === 'string' &&
31
- (record.closedAt === null || typeof record.closedAt === 'number')
46
+ (record.closedAt === null || typeof record.closedAt === 'number') &&
47
+ (record.closedReason == null || typeof record.closedReason === 'string') &&
48
+ (record.supersededBy == null || isSupersededBy(record.supersededBy)) &&
49
+ (record.sessionEndedAt == null || typeof record.sessionEndedAt === 'number')
32
50
  )
33
51
  }
34
52
 
53
+ /** What a row of "Your turn" shows of a question, safe for the terminal. */
54
+ export const questionTextOf = ({ question }: QuestionRecord): string => terminalSafe(question)
55
+
35
56
  const itemOf = (record: QuestionRecord): WaitingItem => ({
36
57
  since: clockText(record.askedAt),
37
- item: terminalSafe(record.question),
58
+ item: questionTextOf(record),
38
59
  kind: WAITING_KIND.question,
39
60
  id: record.id,
40
61
  open: true,
@@ -6,8 +6,8 @@ const log = closableRecordLog('questions', isQuestionRecord, ({ askedAt }) => as
6
6
  /** Every record of the question log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
7
  export const readQuestionRecords = log.read
8
8
 
9
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
9
+ /** Marks the named records `closedAt` with a reason and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
10
  export const closeQuestionRecords = log.close
11
11
 
12
- /** Marks every open record `closedAt` and returns how many it closed. */
12
+ /** Marks every open record `closedAt` with a reason and returns how many it closed. */
13
13
  export const closeAllQuestionRecords = log.closeAll
@@ -6,8 +6,8 @@ const log = closableRecordLog('sent', isSentFileRecord, ({ sentAt }) => sentAt)
6
6
  /** Every record of the sent-file log in the main checkout, oldest first; nothing when the directory is missing or unreadable. */
7
7
  export const readSentRecords = log.read
8
8
 
9
- /** Marks the named records `closedAt` and returns how many it closed; one closed already, or unknown, counts for nothing. */
9
+ /** Marks the named records `closedAt` with a reason and returns how many it closed; one closed already, or unknown, counts for nothing. */
10
10
  export const closeSentRecords = log.close
11
11
 
12
- /** Marks every open record `closedAt` and returns how many it closed. */
12
+ /** Marks every open record `closedAt` with a reason and returns how many it closed. */
13
13
  export const closeAllSentRecords = log.closeAll