jackherd 1.15.1 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +22 -19
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
  10. package/kit/workflow/skills/follow/SKILL.md +18 -10
  11. package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
  12. package/package.json +2 -1
  13. package/recipes/README.md +3 -2
  14. package/recipes/google-calendar.md +30 -0
  15. package/recipes/sources/google-calendar-today.ts +32 -0
  16. package/recipes/sources/google-calendar.ts +368 -0
  17. package/recipes/sources.md +15 -3
  18. package/schemas/jackherd.schema.json +12 -0
  19. package/schemas/sources.schema.json +141 -0
  20. package/src/check/skill-budget-defaults.ts +11 -4
  21. package/src/cli.ts +1 -0
  22. package/src/config/json-schema.ts +7 -3
  23. package/src/config/location.ts +7 -0
  24. package/src/config/personal-sources.ts +83 -0
  25. package/src/config/schema.ts +29 -0
  26. package/src/plans/herd-claim.ts +9 -31
  27. package/src/plans/layout.ts +135 -18
  28. package/src/plans/live.ts +54 -7
  29. package/src/plans/row-actions.ts +17 -10
  30. package/src/plans/settings.ts +20 -7
  31. package/src/plans/snapshot.ts +34 -22
  32. package/src/plans/source-rows.ts +35 -2
  33. package/src/secrets/command.ts +163 -0
  34. package/src/secrets/environment.ts +45 -0
  35. package/src/secrets/hidden-input.ts +51 -0
  36. package/src/secrets/listing.ts +63 -0
  37. package/src/secrets/options.ts +45 -0
  38. package/src/secrets/store.ts +74 -0
  39. package/src/shared/process-info.ts +89 -0
  40. package/src/sync/command-context.ts +26 -4
  41. package/src/sync/render.ts +9 -4
  42. package/src/watch/command.ts +44 -12
  43. package/src/watch/lane-report.ts +2 -1
  44. package/src/watch/plan-badges-read.ts +28 -10
  45. package/src/watch/source-dry-run-command.ts +23 -12
  46. package/src/watch/source-dry-run.ts +20 -3
  47. package/src/watch/source-reload.ts +100 -0
  48. package/src/watch/source-run.ts +63 -7
  49. package/src/watch/source-tickets.ts +15 -0
  50. package/src/watch/sources.ts +21 -0
  51. package/src/watch/texts.ts +1 -1
  52. package/src/watch/watch-events.ts +9 -0
  53. package/src/watch/watched-source.ts +39 -0
  54. package/src/watch/watcher-lock.ts +8 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.16.0
6
+
7
+ - **Sources of your own.** A source that belongs to you alone, such as your calendar, goes into `~/.jackherd/sources.json` instead of the repository's config: same shape as `watch.sources`. Its command runs in `~/.jackherd/`, not in the repository, in every repository where you start the watcher, and for nobody else. The watcher, the herd and `jh watch source <name> --dry-run` take it up after the repository's sources. If the repository has a source of the same name, the repository's wins and yours is skipped with a note. A broken personal file never stops the watcher. The file has a JSON schema, `schemas/sources.schema.json`.
8
+ - **Secrets in the keychain: `jh secret`.** A source lists the variables it needs in its `"secrets"` field. `jh secret set <NAME>` asks for the value hidden, from a terminal only, and keeps it in the system keychain; `jh secret list` shows which names your sources need and whether each is set, never a value; `jh secret delete <NAME>` removes one. A repository source, which anyone with a commit can change, gets only the watcher's environment, never a keychain value. A value reaches the environment of its source's command and nowhere else: no argument, output, state or log. A missing one fails the run with `secret missing: <NAME>` before the command starts.
9
+ - **Sources change without a restart.** The watcher reads the repository's config and your personal file again when one of them changed; a new source runs at once, a removed one stops. A changed file that does not read keeps the sources as they were and says so once. The herd takes the sources from the running watcher, so a new one shows there too without restarting either.
10
+ - **Entries in a block of their own.** `"show": "entries"` opens a source's entries in a block under "Your turn", headed by its label, in its colour and linked where an entry has an address. Together such blocks show at most five rows, at least one per block, and `+<n>` opens the rest of a block. The source's status stays in the `Sources` line.
11
+ - **`/jh-follow` asks where a source belongs.** It asks whether the source is yours or the repository's and writes exactly that file. For a personal source with a secret, you set the value with `jh secret set` in a terminal of your own; the skill then checks it with `jh secret list` and dry-runs the source by name.
12
+ - **A recipe for today's appointments.** The Google Calendar recipe shows the day's appointments of one or more calendars from their secret iCal addresses, kept in the keychain, recurring events included, each linked to the calendar's day view. No Google login and no API project. A failed request prints nothing of the address.
13
+ - **`[done]` hides its row at once.** A click on `[done]` in "Your turn" removes the row right away instead of at the next read; if the write fails, the row stays and the footer says why.
14
+ - **A cut activity line opens on a click.** The `↳` line of a working lane or subagent that did not fit unfolds when you click it.
15
+ - **The merge guard says why it asks.** An approved merge passes without a question only as a plain `gh pr merge <n>` standing alone. When it asks anyway, the question now says why: run a chained or piped merge alone, and a merge with other flags is let through only in its plain form.
16
+
17
+ ## 1.15.2
18
+
19
+ - **The installer no longer crashes on macOS.** `install.sh` redirected `jh init --global` from `/dev/tty`, which the macOS kqueue that Bun reads input through rejects (`EINVAL: invalid argument, kqueue`) at the first question. It now names the terminal's own device (such as `/dev/ttys003`) first, through `tty` on its input or, since macOS prints `/dev/tty` for `tty </dev/tty`, through `ps`, and reads from that.
20
+ - **An unreadable terminal takes the default.** If the terminal cannot be read during a question, `jh init` prints one note on stderr and takes the default answer instead of stopping with an error.
21
+
22
+ - **The herd recognises an older herd on macOS.** The herd view found out whether an earlier view of the same terminal was still running by reading `/proc`, which macOS does not have, so it never took one over and two herds could draw into one terminal. It now asks `ps` there and keeps `/proc` on Linux.
23
+ - **Copying env files into a lane works on macOS.** The copy command in the lane setup used `cp --parents` and `grep -z`, which only the GNU tools know. It now uses `git ls-files` with an exclude pattern and `tar`, which keep folders and file modes (an env file stays `600`) with the tools of both systems. Run `jh sync` to get the new text.
24
+ - **Every `ps` call is bounded.** A `ps` that hangs no longer holds up the herd or the watcher.
25
+ - **The installer refuses a bun under a relative `PATH` entry on macOS too.** `install.sh` asked `command -v` for the path, which bash 3.2 (macOS `/bin/sh`) answers with an absolute path even for a relative entry, so the refusal never fired. It now walks `PATH` itself and names the entry as written.
26
+
5
27
  ## 1.15.1
6
28
 
7
29
  - **A test with a variable no longer asks.** The merge guard read the `[` of `[ -f $P ]` as a glob program, so any variable after it could be `gh pr merge` and the guard asked, also for a plain grep. `[` and `[[` now count as the test builtin; a merge after a test, and a program from a variable, still ask.
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.15.1/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.16.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)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.15.1/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.16.0/assets/icons/space.svg">
7
7
 
8
8
  # Jack herds your coding agents.
9
9
 
@@ -15,31 +15,31 @@ 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.15.1/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.16.0/assets/icons/space.svg">
19
19
 
20
20
  <p>
21
- <img src="https://unpkg.com/jackherd@1.15.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.">
21
+ <img src="https://unpkg.com/jackherd@1.16.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.15.1/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.16.0/assets/icons/space.svg">
24
24
  </p>
25
25
 
26
- <p><img src="https://unpkg.com/jackherd@1.15.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>
26
+ <p><img src="https://unpkg.com/jackherd@1.16.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.15.1/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.</p>
28
+ <p><img src="https://unpkg.com/jackherd@1.16.0/assets/icons/decide.svg" width="24" height="34" align="middle" alt=""> <b>You make the calls.</b><br>Nothing is committed, pushed or merged without your approval. Jack merges on green checks only where you said so: one pull request, or a plan's for up to 24 hours.</p>
29
29
 
30
- <p><img src="https://unpkg.com/jackherd@1.15.1/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
30
+ <p><img src="https://unpkg.com/jackherd@1.16.0/assets/icons/bark.svg" width="24" height="34" align="middle" alt=""> <b>Jack barks.</b><br>A blocked lane, a red check, a review comment: Jack wakes, and the herd view shows where every lane stands.</p>
31
31
 
32
- <p><img src="https://unpkg.com/jackherd@1.15.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>
32
+ <p><img src="https://unpkg.com/jackherd@1.16.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.15.1/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
34
+ <p><img src="https://unpkg.com/jackherd@1.16.0/assets/icons/ear.svg" width="24" height="34" align="middle" alt=""> <b>Jack listens.</b><br>Review comments go to the lane that fixes them, with a test. <code>/jh-follow</code> brings anything with a CLI or an API into the herd: tickets, error logs, status pages, deploys, and whatever else you point it at.</p>
35
35
 
36
- <p><img src="https://unpkg.com/jackherd@1.15.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>
36
+ <p><img src="https://unpkg.com/jackherd@1.16.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>
37
37
 
38
- <p><img src="https://unpkg.com/jackherd@1.15.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.15.1/assets/icons/space.svg"></p>
38
+ <p><img src="https://unpkg.com/jackherd@1.16.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.16.0/assets/icons/space.svg"></p>
39
39
 
40
40
  ## Quickstart
41
41
 
42
- 1. **Install** (Linux and macOS; needs Claude Code and git). → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)
42
+ 1. **Install** (needs Claude Code and git). → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)
43
43
 
44
44
  ```bash
45
45
  curl -fsSL https://unpkg.com/jackherd/install.sh | sh
@@ -71,22 +71,22 @@ Then `/jh-start` in a repository, and Jack offers your first plan. → [Quicksta
71
71
  /jh-orchestrate docs/plans/board-<name>/board.md
72
72
  ```
73
73
 
74
- By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.15.1/assets/icons/space.svg">
74
+ By hand (Bun 1.3+): `bun add -g jackherd` or `npm install -g jackherd`, then `jh init --global` for yourself, or `jh init` in a repository to give your team the same skills and guard hooks. → [INSTALL.md](https://unpkg.com/jackherd/INSTALL.md)<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.16.0/assets/icons/space.svg">
75
75
 
76
76
  ## Follow anything outside GitHub
77
77
 
78
78
  Whatever you check by hand, Jack can watch: tickets, error logs, status pages, deploys. One `/jh-follow`, one yes, and every change comes to you like this:
79
79
 
80
- <img src="https://unpkg.com/jackherd@1.15.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.">
80
+ <img src="https://unpkg.com/jackherd@1.16.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.">
81
81
 
82
- The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.15.1/assets/icons/space.svg">
82
+ The herd sums up your sources in one `Sources` line: per source its new entries, or when it last ran, red while it fails; opened, a click on an entry opens it. A ticket shows on the lane that works it, and a row in Your turn that came from a source starts with that source's label.<br><img width="1" height="8" alt="" src="https://unpkg.com/jackherd@1.16.0/assets/icons/space.svg">
83
83
 
84
84
  ## In detail
85
85
 
86
86
  <details>
87
87
  <summary><b>What Jack does on his own, and what waits for you</b></summary>
88
88
 
89
- <img src="https://unpkg.com/jackherd@1.15.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.">
89
+ <img src="https://unpkg.com/jackherd@1.16.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.">
90
90
 
91
91
  | On his own | After your approval | Never |
92
92
  | --- | --- | --- |
@@ -121,7 +121,7 @@ The guards catch mistakes, not intent; for that, use branch protection. The hook
121
121
 
122
122
  A **plan** is Markdown with phases, checkbox tasks, a model and a review gate per phase. A **board** lists the lanes of one or more plans: which phases each lane builds, on which model, after what. A **lane** is one agent session in its own git worktree (a second checkout of your repository), and **Jack** is the session in the main checkout that runs `/jh-orchestrate` on a board.
123
123
 
124
- <img src="https://unpkg.com/jackherd@1.15.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.">
124
+ <img src="https://unpkg.com/jackherd@1.16.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.">
125
125
 
126
126
  | Skill | Does |
127
127
  | --- | --- |
@@ -203,7 +203,9 @@ How the herd reads the board:
203
203
 
204
204
  Jack doesn't watch the terminals himself. `jh watch`, which he starts, wakes him when a lane is blocked or done, a check turns red, a pull request gets feedback, a lane's context fills up, a lane waits for an approval or works for ten minutes without reporting its task; deploys and previews too, when configured.
205
205
 
206
- `/jh-follow PROJ-123` adds anything a command can print as JSON: a ticket, an issue, error logs, a status page. It checks the tool and its login, shows you a dry run and writes the source to `watch.sources` after your yes. A token stays in the environment, read by a small Bun script, never in an argument. Each source runs on its own interval, kept across a restart of the watcher. Recipes for Jira, Linear, GitHub issues and Projects, Sentry and status pages come with it. The reports need herdr; a source can be set up and dry-run without it.
206
+ `/jh-follow PROJ-123` adds anything a command can print as JSON: a ticket, an issue, error logs, a status page. It checks the tool and its login, shows you a dry run and writes the source to `watch.sources` after your yes. A token stays in the environment, read by a small Bun script, never in an argument. Each source runs on its own interval, kept across a restart of the watcher. Recipes for Jira, Linear, GitHub issues and Projects, Sentry, status pages and today's Google Calendar appointments come with it. The reports need herdr; a source can be set up and dry-run without it.
207
+
208
+ A source that is yours alone, such as your calendar, goes into `~/.jackherd/sources.json` instead: it runs in every repository where you start the watcher, and for nobody else. Its secrets live in the system keychain: `jh secret set <NAME>` asks for the value hidden, and only the command of the source that lists it receives it. The watcher takes up a changed source without a restart, and any source can show its entries in a block of their own under Your turn with `"show": "entries"`.
207
209
 
208
210
  </details>
209
211
 
@@ -225,6 +227,7 @@ Each phase in the plan names its model: the planning model (Opus by default) whe
225
227
  | `jh check` | types, lint and tests of what you touched, plus the plan and board checks (`--skip-slow`, `--parallel <plan>`) |
226
228
  | `jh herd` | the live view (`--once`, `--lane <name>`) |
227
229
  | `jh watch` | the watcher Jack runs |
230
+ | `jh secret` | keeps a personal source's secrets in the system keychain (`set <NAME>`, `list`, `delete <NAME>`); `list` shows which are set, never a value |
228
231
  | `jh lane prompt <lane>` | writes a lane's first prompt from its board row and its plan (`--out`, `--handover`); your additions below the marker line at its end stay |
229
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. Claude Code asks before each call |
230
233
  | `jh preview <file>` | renders a Markdown file through the GitHub API into a preview in GitHub and npm style |
@@ -160,6 +160,23 @@ var classifyStatus = (language, statusText) => {
160
160
  var phaseHeading = (language, id, title) => `### ${PLAN_VOCABULARY[language].phase} ${id}: ${title}`;
161
161
  var sectionHeading = (language, section) => `## ${PLAN_VOCABULARY[language].sections[section]}`;
162
162
 
163
+ // src/plan-format/terminal-text.ts
164
+ var UNSAFE_RANGES = [
165
+ [0, 31],
166
+ [127, 159],
167
+ [1564, 1564],
168
+ [8206, 8207],
169
+ [8232, 8233],
170
+ [8234, 8238],
171
+ [8294, 8297]
172
+ ];
173
+ var escapeCodePoint = (codePoint) => `\\u${codePoint.toString(16).padStart(4, "0")}`;
174
+ var UNSAFE_TERMINAL_TEXT = new RegExp(
175
+ `[${UNSAFE_RANGES.map(([from, to]) => `${escapeCodePoint(from)}-${escapeCodePoint(to)}`).join("")}]`,
176
+ "g"
177
+ );
178
+ var terminalSafe = (text) => text.replace(UNSAFE_TERMINAL_TEXT, "");
179
+
163
180
  export {
164
181
  PLAN_LANGUAGE,
165
182
  LANGUAGE_NAME,
@@ -177,5 +194,6 @@ export {
177
194
  PLAN_VOCABULARY,
178
195
  classifyStatus,
179
196
  phaseHeading,
180
- sectionHeading
197
+ sectionHeading,
198
+ terminalSafe
181
199
  };
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
 
3
3
  declare const renderConfigJsonSchema: () => string;
4
+ declare const renderSourcesJsonSchema: () => string;
4
5
 
5
6
  declare const TERMINAL_ADAPTER: {
6
7
  readonly none: "none";
@@ -72,6 +73,8 @@ declare const sourceSchema: z.ZodObject<{
72
73
  magenta: "magenta";
73
74
  }>>;
74
75
  reportOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
76
+ secrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
77
+ show: z.ZodOptional<z.ZodLiteral<"entries">>;
75
78
  }, z.core.$strict>;
76
79
  declare const jackherdConfigSchema: z.ZodObject<{
77
80
  $schema: z.ZodOptional<z.ZodString>;
@@ -113,9 +116,9 @@ declare const jackherdConfigSchema: z.ZodObject<{
113
116
  lockFile: z.ZodOptional<z.ZodString>;
114
117
  }, z.core.$strict>>;
115
118
  models: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
119
+ review: "review";
116
120
  plan: "plan";
117
121
  implement: "implement";
118
- review: "review";
119
122
  explore: "explore";
120
123
  orchestrate: "orchestrate";
121
124
  }>, z.ZodString>>;
@@ -249,6 +252,8 @@ declare const jackherdConfigSchema: z.ZodObject<{
249
252
  magenta: "magenta";
250
253
  }>>;
251
254
  reportOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
255
+ secrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
256
+ show: z.ZodOptional<z.ZodLiteral<"entries">>;
252
257
  }, z.core.$strict>>>;
253
258
  }, z.core.$strict>>;
254
259
  guard: z.ZodPrefault<z.ZodPipe<z.ZodObject<{
@@ -338,7 +343,47 @@ type JackherdConfig = z.infer<typeof jackherdConfigSchema>;
338
343
  declare const parseJackherdConfig: (raw: unknown) => JackherdConfig;
339
344
  /** `parseJackherdConfig` for a file: an invalid one throws with `label` (its path) and the issues in words. */
340
345
  declare const parseConfigFile: (raw: unknown, label: string) => JackherdConfig;
346
+ /** The personal sources file (`~/.jackherd/sources.json`): the same entries as `watch.sources`. */
347
+ declare const personalSourcesSchema: z.ZodObject<{
348
+ $schema: z.ZodOptional<z.ZodString>;
349
+ sources: z.ZodDefault<z.ZodArray<z.ZodObject<{
350
+ name: z.ZodString;
351
+ command: z.ZodArray<z.ZodString>;
352
+ every: z.ZodDefault<z.ZodNumber>;
353
+ items: z.ZodOptional<z.ZodString>;
354
+ map: z.ZodObject<{
355
+ id: z.ZodString;
356
+ updatedAt: z.ZodOptional<z.ZodString>;
357
+ title: z.ZodOptional<z.ZodString>;
358
+ url: z.ZodOptional<z.ZodString>;
359
+ body: z.ZodOptional<z.ZodString>;
360
+ author: z.ZodOptional<z.ZodString>;
361
+ status: z.ZodOptional<z.ZodString>;
362
+ count: z.ZodOptional<z.ZodString>;
363
+ }, z.core.$catchall<z.ZodString>>;
364
+ ignoreAuthors: z.ZodDefault<z.ZodArray<z.ZodString>>;
365
+ reportLimit: z.ZodDefault<z.ZodNumber>;
366
+ label: z.ZodOptional<z.ZodString>;
367
+ lane: z.ZodOptional<z.ZodString>;
368
+ ticket: z.ZodOptional<z.ZodBoolean>;
369
+ tone: z.ZodOptional<z.ZodEnum<{
370
+ amber: "amber";
371
+ teal: "teal";
372
+ grey: "grey";
373
+ red: "red";
374
+ green: "green";
375
+ blue: "blue";
376
+ magenta: "magenta";
377
+ }>>;
378
+ reportOn: z.ZodOptional<z.ZodArray<z.ZodString>>;
379
+ secrets: z.ZodOptional<z.ZodArray<z.ZodString>>;
380
+ show: z.ZodOptional<z.ZodLiteral<"entries">>;
381
+ }, z.core.$strict>>>;
382
+ }, z.core.$strict>;
383
+ type PersonalSourcesFile = z.infer<typeof personalSourcesSchema>;
384
+ /** The personal sources file as read from JSON; invalid throws with `label` (its path) and the issues in words. */
385
+ declare const parsePersonalSourcesFile: (raw: unknown, label: string) => PersonalSourcesFile;
341
386
  /** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
342
387
  declare const parseSourceDraft: (raw: unknown, label: string) => z.infer<typeof sourceSchema>;
343
388
 
344
- export { COMMAND_KEYS, type CommandKey, DEFAULT_CONTEXT_WINDOW, DEFAULT_MODELS, DEFAULT_SKILL_PREFIX, type JackherdConfig, KIT_CORE, MODEL_ROLE, type ModelRole, SKILL_PREFIX, TERMINAL_ADAPTER, type TerminalAdapter, isModelRole, isTerminalAdapter, jackherdConfigSchema, parseConfigFile, parseJackherdConfig, parseSourceDraft, renderConfigJsonSchema };
389
+ export { COMMAND_KEYS, type CommandKey, DEFAULT_CONTEXT_WINDOW, DEFAULT_MODELS, DEFAULT_SKILL_PREFIX, type JackherdConfig, KIT_CORE, MODEL_ROLE, type ModelRole, type PersonalSourcesFile, SKILL_PREFIX, TERMINAL_ADAPTER, type TerminalAdapter, isModelRole, isTerminalAdapter, jackherdConfigSchema, parseConfigFile, parseJackherdConfig, parsePersonalSourcesFile, parseSourceDraft, personalSourcesSchema, renderConfigJsonSchema, renderSourcesJsonSchema };
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  PLAN_LANGUAGE,
3
3
  RESPONSE_ADDRESS
4
- } from "./chunk-V4HOLGFP.js";
4
+ } from "./chunk-4LUFR7RC.js";
5
5
 
6
6
  // src/config/json-schema.ts
7
7
  import { z as z3 } from "zod";
@@ -112,6 +112,15 @@ var SKILL_PATH = new RegExp(`^${SKILLS_DIRECTORY}/([^/]+)/(.+)$`);
112
112
  var AGENT_PATH = new RegExp(`^${AGENTS_DIRECTORY}/([^/]+)\\.md$`);
113
113
  var claudePath = (...segments) => [CLAUDE_DIRECTORY, ...segments].join("/");
114
114
 
115
+ // src/version/release.ts
116
+ var CLI_NAME = "jh";
117
+
118
+ // src/secrets/options.ts
119
+ var SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
120
+ var SECRET_USAGE = `Usage: ${CLI_NAME} secret set <NAME> store a value in the keychain, typed hidden in a terminal
121
+ ${CLI_NAME} secret list the names the sources need, set or missing, never a value
122
+ ${CLI_NAME} secret delete <NAME> remove the value from the keychain`;
123
+
115
124
  // src/watch/source-fields.ts
116
125
  var SOURCE_FIELD = {
117
126
  id: "id",
@@ -141,6 +150,38 @@ var pathSteps = (path) => {
141
150
  };
142
151
  var isSourcePath = (path) => pathSteps(path) !== null;
143
152
 
153
+ // src/watch/sources.ts
154
+ import { createHash } from "crypto";
155
+
156
+ // src/watch/feedback-item.ts
157
+ var FEEDBACK_FORM = { review: "review", inline: "inline", comment: "comment" };
158
+
159
+ // src/watch/no-finding.ts
160
+ var ROUND_WINDOW_MS = 5 * 6e4;
161
+
162
+ // src/watch/pull-requests.ts
163
+ var WATCHED_PULL_REQUEST_FIELDS = "number,title,url,headRefName,isDraft,state,headRefOid,mergeCommit";
164
+ var OPEN_PULL_REQUEST_FIELDS = `${WATCHED_PULL_REQUEST_FIELDS},statusCheckRollup,files`;
165
+
166
+ // src/watch/feedback.ts
167
+ var REVIEW_STATE = {
168
+ approved: "APPROVED",
169
+ changesRequested: "CHANGES_REQUESTED",
170
+ commented: "COMMENTED",
171
+ dismissed: "DISMISSED"
172
+ };
173
+ var DECISIVE_REVIEW_STATES = [
174
+ REVIEW_STATE.approved,
175
+ REVIEW_STATE.changesRequested,
176
+ REVIEW_STATE.dismissed
177
+ ];
178
+
179
+ // src/watch/previews.ts
180
+ var PREVIEW_SOURCE = { deployment: "deployment", comment: FEEDBACK_FORM.comment };
181
+
182
+ // src/watch/sources.ts
183
+ var SOURCE_SHOW = { entries: "entries" };
184
+
144
185
  // src/watch/watch-defaults.ts
145
186
  var CLEAN_REVIEW_TEXT = "## Code review\n\nNo issues found. Checked for bugs and CLAUDE.md compliance.";
146
187
  var BUN_FAILED_TEST_PATTERN = "^\\(fail\\) (.+?)(?: \\[[\\d.]+m?s\\])?$";
@@ -434,13 +475,23 @@ var sourceSchema = z2.strictObject({
434
475
  * The mapped fields whose change reports, with the old and the new value, in place of the content of `title`,
435
476
  * `author`, `body` and `url`; without it a change of that content reports.
436
477
  */
437
- reportOn: z2.array(z2.string().min(1)).min(1).max(10).optional()
478
+ reportOn: z2.array(z2.string().min(1)).min(1).max(10).optional(),
479
+ /**
480
+ * Names of secrets the command needs, handed to it as environment variables of the same name; each is looked up
481
+ * in the keychain, then in the watcher's environment.
482
+ */
483
+ secrets: z2.array(z2.string().regex(SECRET_NAME, "capital letters, digits and `_`, starting with a letter")).max(10).refine((names) => new Set(names).size === names.length, "secret names must be unique").optional(),
484
+ /** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
485
+ show: z2.literal(SOURCE_SHOW.entries).optional()
438
486
  }).refine(canChange, {
439
487
  message: "map needs updatedAt, a content field (title, author, body, url) or reportOn",
440
488
  path: ["map"]
441
489
  }).refine((source) => source.ignoreAuthors.length === 0 || source.map.author !== void 0, {
442
490
  message: "ignoreAuthors needs map.author",
443
491
  path: ["ignoreAuthors"]
492
+ }).refine((source) => source.show === void 0 || source.ticket !== true, {
493
+ message: "show cannot be set on a ticket source",
494
+ path: ["show"]
444
495
  }).refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
445
496
  message: "reportOn cannot name id",
446
497
  path: ["reportOn"]
@@ -648,11 +699,18 @@ ${z2.prettifyError(parsed.error)}`);
648
699
  return parsed.data;
649
700
  };
650
701
  var parseConfigFile = (raw, label) => parseLabelled(jackherdConfigSchema, raw, label);
702
+ var personalSourcesSchema = z2.strictObject({
703
+ $schema: z2.string().optional(),
704
+ sources: z2.array(sourceSchema).refine(hasUniqueNames, "source names must be unique").default([])
705
+ });
706
+ var parsePersonalSourcesFile = (raw, label) => parseLabelled(personalSourcesSchema, raw, label);
651
707
  var parseSourceDraft = (raw, label) => parseLabelled(sourceSchema, raw, label);
652
708
 
653
709
  // src/config/json-schema.ts
654
- var renderConfigJsonSchema = () => `${JSON.stringify(z3.toJSONSchema(jackherdConfigSchema, { io: "input" }), null, 2)}
710
+ var renderJsonSchema = (schema) => `${JSON.stringify(z3.toJSONSchema(schema, { io: "input" }), null, 2)}
655
711
  `;
712
+ var renderConfigJsonSchema = () => renderJsonSchema(jackherdConfigSchema);
713
+ var renderSourcesJsonSchema = () => renderJsonSchema(personalSourcesSchema);
656
714
  export {
657
715
  COMMAND_KEYS,
658
716
  DEFAULT_CONTEXT_WINDOW,
@@ -667,6 +725,9 @@ export {
667
725
  jackherdConfigSchema,
668
726
  parseConfigFile,
669
727
  parseJackherdConfig,
728
+ parsePersonalSourcesFile,
670
729
  parseSourceDraft,
671
- renderConfigJsonSchema
730
+ personalSourcesSchema,
731
+ renderConfigJsonSchema,
732
+ renderSourcesJsonSchema
672
733
  };
@@ -15,29 +15,13 @@ import {
15
15
  classifyStatus,
16
16
  isPlanLanguage,
17
17
  phaseHeading,
18
- sectionHeading
19
- } from "./chunk-V4HOLGFP.js";
18
+ sectionHeading,
19
+ terminalSafe
20
+ } from "./chunk-4LUFR7RC.js";
20
21
 
21
22
  // src/plan-format/lanes.ts
22
23
  import { posix } from "path";
23
24
 
24
- // src/plan-format/terminal-text.ts
25
- var UNSAFE_RANGES = [
26
- [0, 31],
27
- [127, 159],
28
- [1564, 1564],
29
- [8206, 8207],
30
- [8232, 8233],
31
- [8234, 8238],
32
- [8294, 8297]
33
- ];
34
- var escapeCodePoint = (codePoint) => `\\u${codePoint.toString(16).padStart(4, "0")}`;
35
- var UNSAFE_TERMINAL_TEXT = new RegExp(
36
- `[${UNSAFE_RANGES.map(([from, to]) => `${escapeCodePoint(from)}-${escapeCodePoint(to)}`).join("")}]`,
37
- "g"
38
- );
39
- var terminalSafe = (text) => text.replace(UNSAFE_TERMINAL_TEXT, "");
40
-
41
25
  // src/plan-format/markdown.ts
42
26
  var SECTION_END = /^##\s/;
43
27
  var TABLE_SEPARATOR = /^\|[\s|:-]+\|$/;
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.15.1"
3
+ version = "1.16.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,11 +9,14 @@ 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.15.1}"
12
+ JACKHERD_VERSION="${JACKHERD_VERSION:-1.16.0}"
13
13
 
14
14
  JACKHERD_PACKAGE='jackherd'
15
15
  # Test seam: the terminal the wizard asks its questions on.
16
16
  JACKHERD_TEST_TTY="${JACKHERD_TEST_TTY:-/dev/tty}"
17
+ # Test seam: a packed tarball (absolute path) installed instead of the published package, so CI can prove a build
18
+ # before it is released. Unset in every real run.
19
+ JACKHERD_TEST_PACKAGE="${JACKHERD_TEST_PACKAGE:-}"
17
20
  BUN_INSTALLER_URL='https://bun.com/install'
18
21
  BUN_MIN_MAJOR=1
19
22
  BUN_MIN_MINOR=3
@@ -55,10 +58,29 @@ bun_bin_dir() {
55
58
  printf '%s/bin\n' "${BUN_INSTALL:-$HOME/.bun}"
56
59
  }
57
60
 
61
+ # The first executable bun along PATH, spelled the way PATH names it. Walked here and not asked of
62
+ # 'command -v', which some shells (bash 3.2 as sh on macOS) answer with an absolute path even for a
63
+ # relative entry, so the check for a relative one in main would never fire. An empty entry means the
64
+ # current directory and counts as relative.
65
+ bun_on_path() {
66
+ rest="$PATH:"
67
+ while [ -n "$rest" ]; do
68
+ entry=${rest%%:*}
69
+ rest=${rest#*:}
70
+ [ -n "$entry" ] || entry=.
71
+ if [ -f "$entry/bun" ] && [ -x "$entry/bun" ]; then
72
+ printf '%s/bun\n' "$entry"
73
+ return 0
74
+ fi
75
+ done
76
+ return 1
77
+ }
78
+
58
79
  # The bun on PATH, else the one the official installer leaves in ~/.bun/bin; empty when there is none.
59
80
  find_bun() {
60
- if command -v bun >/dev/null 2>&1; then
61
- command -v bun
81
+ found=$(bun_on_path) || found=''
82
+ if [ -n "$found" ]; then
83
+ printf '%s\n' "$found"
62
84
  elif [ -x "$(bun_bin_dir)/bun" ]; then
63
85
  printf '%s\n' "$(bun_bin_dir)/bun"
64
86
  fi
@@ -111,10 +133,32 @@ terminal_available() {
111
133
  (: <"$JACKHERD_TEST_TTY") 2>/dev/null
112
134
  }
113
135
 
136
+ # The controlling terminal's own device node, else the path as given. macOS kqueue, which Bun reads stdin through,
137
+ # rejects /dev/tty with EINVAL but accepts the real device (/dev/ttys003). On macOS 'tty </dev/tty' prints
138
+ # /dev/tty itself, so the device comes from 'tty' on the inherited stdin when that is a terminal, else from the
139
+ # shell's own TTY column of 'ps' (ttys003 on macOS, pts/3 on Linux; ? or ?? when there is none).
140
+ terminal_device() {
141
+ device=$(tty 2>/dev/null) || device=''
142
+ if [ "$device" = /dev/tty ] || [ ! -c "$device" ]; then
143
+ name=$(ps -o tty= -p $$ 2>/dev/null) || name=''
144
+ # shellcheck disable=SC2086 # word splitting trims the padding ps puts around the column
145
+ set -- $name
146
+ case ${1:-} in
147
+ '' | '?' | '??') device='' ;;
148
+ *) device="/dev/$1" ;;
149
+ esac
150
+ fi
151
+ if [ -c "$device" ] && [ "$device" != /dev/tty ]; then
152
+ printf '%s\n' "$device"
153
+ else
154
+ printf '%s\n' "$JACKHERD_TEST_TTY"
155
+ fi
156
+ }
157
+
114
158
  run_setup() {
115
159
  if terminal_available; then
116
160
  say "starting the setup: jh init --global"
117
- "$1" init --global <"$JACKHERD_TEST_TTY" || fail "jh init --global failed"
161
+ "$1" init --global <"$(terminal_device)" || fail "jh init --global failed"
118
162
  else
119
163
  say "no terminal to ask questions on, setting up with the defaults: jh init --global --yes"
120
164
  "$1" init --global --yes </dev/null || fail "jh init --global --yes failed"
@@ -145,6 +189,7 @@ main() {
145
189
  export PATH
146
190
 
147
191
  spec="$JACKHERD_PACKAGE@$JACKHERD_VERSION"
192
+ [ -z "$JACKHERD_TEST_PACKAGE" ] || spec="$JACKHERD_TEST_PACKAGE"
148
193
  say "installing $spec: bun add -g --ignore-scripts $spec"
149
194
  "$bun" add -g --ignore-scripts "$spec" || fail "bun add -g failed"
150
195
 
@@ -46,7 +46,8 @@ export type GuardPolicy = {
46
46
  * A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
47
47
  * the shell alias or function that may run one (`definition`, whose name is then the `command`).
48
48
  */
49
- type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit }
49
+ /** A guarded command found in the script; `sole` where it was the one command of the call, read as written by `gh`. */
50
+ type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; sole?: boolean }
50
51
 
51
52
  /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
52
53
  type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
@@ -326,7 +327,7 @@ const findingOf = (command: string, words: SimpleCommand, index: number, sole: b
326
327
  if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
327
328
  if (!isGuardedUse(command, words, index)) return undefined
328
329
  const plain = sole && words[index].literal && words[index].value === GH
329
- return { command, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
330
+ return { command, sole: plain, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
330
331
  }
331
332
 
332
333
  const programsOf = (commands: readonly string[]): string[] => [
@@ -447,6 +448,19 @@ const guardedListSentence = (commands: string[]): string[] =>
447
448
  `${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
448
449
  ]
449
450
 
451
+ const SOLE_MERGE_SENTENCE = `A \`${MERGE_COMMAND}\` is checked against the approval and the checks only as a plain \`${MERGE_COMMAND} <number>\` that is the one command of its Bash call: no pipe, \`;\`, \`&&\`, wrapper or substitution around it. Run it alone, then read the result with \`gh pr view\`.`
452
+
453
+ const PLAIN_MERGE_SENTENCE = `A \`${MERGE_COMMAND}\` is checked against the approval and the checks only in its plain form: the number, merge-method and delete-branch flags and at most one \`--match-head-commit <full sha>\`; any other flag or a word the shell expands is confirmed by the user.`
454
+
455
+ /** Why an unchecked merge was not checked: it did not stand alone in its call, or it did but not in its plain form. */
456
+ const soleMergeSentence = (findings: Finding[]): string[] => {
457
+ const unchecked = findings.filter(
458
+ ({ command, merge, definition }) => command === MERGE_COMMAND && merge === undefined && definition === undefined,
459
+ )
460
+ if (unchecked.some(({ sole }) => !sole)) return [SOLE_MERGE_SENTENCE]
461
+ return unchecked.length > 0 ? [PLAIN_MERGE_SENTENCE] : []
462
+ }
463
+
450
464
  const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
451
465
  const guarded = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
452
466
  const definitions = [
@@ -462,6 +476,7 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
462
476
  (definition) =>
463
477
  `${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
464
478
  ),
479
+ ...soleMergeSentence(findings),
465
480
  ...notes,
466
481
  expandingHereDocumentAdvice(script),
467
482
  ]