jackherd 1.15.2 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  What changed for people who use jackherd, newest first.
4
4
 
5
+ ## 1.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
+
5
17
  ## 1.15.2
6
18
 
7
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.
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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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.2/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 |
@@ -1,6 +1,6 @@
1
1
  id = "crossdelta.jackherd"
2
2
  name = "jackherd"
3
- version = "1.15.2"
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,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.15.2}"
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.
@@ -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
  ]
@@ -1,36 +1,41 @@
1
1
  ---
2
2
  name: follow
3
- description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to `watch.sources` after approval. Use when the user asks to follow or be told about such an item, e.g. "/follow PROJ-123".'
3
+ description: 'Have the watcher report changes of something outside GitHub (a ticket, an issue, an error, a status page): pick a recipe, check the tool and its login read only, dry-run the source, write it to the watcher''s sources (personal or repository) after approval. Use when the user asks to follow or be told about such an item, e.g. "/follow PROJ-123".'
4
4
  argument-hint: 'What to follow, e.g. a ticket key, an issue or a query'
5
5
  ---
6
6
 
7
7
  If `.claude/project/follow.md` exists, read it first. It extends this skill for this project and wins where the two disagree.
8
8
 
9
- You add one entry to `watch.sources`, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the project's `.jackherd/config.json` in the main checkout, and only after the user approved the entry. Read the recipe index `recipes/README.md` of the installed package first (`"$(dirname "$(readlink -f "$(command -v jh)")")/../recipes/README.md"`), then only the recipe file it names for the chosen service; do not copy a recipe from memory. Where the recipe leaves a field open, the index's section **Rules every recipe follows** decides, and `recipes/sources.md` beside it describes every field and limit of a source.
9
+ You add one entry to the watcher's sources, so that `jh watch` reports a new or changed entry of an external service as `=== source <name> …`. You run only reading commands; the one file you write is the entry's file (step 4: the user's `~/.jackherd/sources.json` or the project's `.jackherd/config.json` in the main checkout), and only after the user approved the entry. Read the recipe index `recipes/README.md` of the installed package first (`"$(dirname "$(readlink -f "$(command -v jh)")")/../recipes/README.md"`), then only the recipe file it names for the chosen service; do not copy a recipe from memory. Where the recipe leaves a field open, the index's section **Rules every recipe follows** decides, and `recipes/sources.md` beside it describes every field and limit of a source.
10
10
 
11
11
  ## 1. Ask
12
12
 
13
13
  `AskUserQuestion` in {{kit.responseLanguage}}, only for what the argument and the conversation leave open, each with the recommended option first; at most four questions per call, so more open points go into a second call:
14
14
 
15
+ - **Where it lives:** a personal source (`~/.jackherd/sources.json`: runs in every repository of the user, the keychain can hold its secrets; recommended when a personal secret is attached, otherwise second) or a repository source (`watch.sources` of the project: runs for everyone who starts the watcher there, and its `secrets` read only the watcher's environment, never the keychain, so it needs a token from the environment or none).
15
16
  - **Service:** the services the recipe index lists, and "another command that prints JSON".
16
17
  - **What to follow:** the item the argument names (its comments, recommended) or a query over several items.
17
18
  - **Lane:** the lane the reports name, from the board's lanes table, or none.
18
19
  - **Ticket:** whether the entries are tickets (`ticket: true`, recommended for an issue tracker or a project board), which the herd shows as a badge beside the plan of their lane: the lane above, or the lane whose name, board row or plan names the ticket's id. A source of comments or messages is no ticket; it shows in the herd's `Sources` block instead (a click opens the entry at `map.url`). The path of the status you find yourself in step 3.
19
- - **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.- **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
20
+ - **Tone:** for a non-ticket source, the colour of its `Sources` mark while its report is new (`tone`: amber, teal, grey, red, green, blue, magenta); recommended: no `tone`.
21
+ - **Show:** for a non-ticket source whose entries are the day's content (a calendar), `show: "entries"` opens them in a block of their own under "Your turn"; otherwise none.
22
+ - **What reports:** a change of what the user reads (title, body, the latest comment; recommended), or a change of named fields such as a status (`reportOn` with those fields, mapped in `map`).
20
23
  - **Own changes:** ignore the user's own account on that service (`ignoreAuthors`, needs an author in the map), or report everything.
21
24
 
22
25
  ## 2. Check, read only
23
26
 
24
27
  - The tool: `command -v <tool>` for the recipe's program (`curl`, the service's CLI, and `bun` for a recipe with a script).
25
- - The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, and never read a file that holds one.
28
+ - The login: the tool's own status command where it has one. For a token in an environment variable only whether it is set: `test -n "$<VARIABLE>" && echo set || echo missing`. Never print, echo or pass on a token's value, never ask for it, and never read a file that holds one.
26
29
 
27
- Anything missing goes to the user as the command to run themselves (`! <command>`); wait until they did, then check again.
30
+ Anything missing goes to the user as the command to run themselves (`! <command>`; a keychain secret: below, not with `!`); wait until they did, then check again.
31
+
32
+ **A secret** (a token the script needs) is named in the entry's `secrets` (`["NAME"]`) and never passes through you (the rule above). A repository source does not read the keychain: check its environment variable as above. A personal source's secret goes into the keychain in step 4, after the entry is written: `jh secret list` knows only names an entry already lists.
28
33
 
29
34
  ## 3. Fill and dry-run
30
35
 
31
36
  Before filling, read the help of the installed CLI version for every command the recipe calls (`acli jira workitem search --help` and `workitem view --help`, `gh issue list --help`) and confirm that the fields and options the entry needs exist there. Read the provider's web documentation only when the help leaves it open or no recipe fits. A recipe is a starting point; the installed version decides.
32
37
 
33
- Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]`. Script and direct command follow the index's **Rules every recipe follows**.
38
+ Fill the recipe with the item or query and the answers from step 1: a `name` of letters, digits, `.`, `_` and `-` that says what is followed, `lane`, `ignoreAuthors`, `ticket` and `tone` where chosen, and a short `label` (e.g. `Error log · Checkout`; it prefixes the source's `Waiting for you` rows). For a ticket source, find the path of the status in the output (from the CLI's help, or the dry run with `--raw`) and set it as `map.status`; the entry you show for approval in step 4 carries it, and without a status in the output the entry has none and the badge shows only the key. A recipe that needs a script (a token from the environment, or one call per item) gets TypeScript run by Bun: write it, from the recipe's script under `recipes/sources/`, to the scratchpad first as `<name>.ts`, never into the repository before step 4, and let the entry's `command` be `["bun", "scripts/sources/<name>.ts"]` (a personal source runs in `~/.jackherd/`: `["bun", "sources/<name>.ts"]`). Script and direct command follow the index's **Rules every recipe follows**.
34
39
 
35
40
  Write the entry as one JSON object to a draft file in the scratchpad and run, from the main checkout:
36
41
 
@@ -38,12 +43,15 @@ Write the entry as one JSON object to a draft file in the scratchpad and run, fr
38
43
  jh watch source <draft.json> --dry-run
39
44
  ```
40
45
 
41
- It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
46
+ It runs the command once and shows the entries and the report the watcher would print, without touching the watcher's state. A draft runs as a repository source: in the main checkout, without the keychain. A personal entry with `secrets` or a script under `sources/` cannot pass there; skip its draft run, show the entry and the recipe's sample output, and dry-run it by name in step 4. Exit code 1 means the run failed or a `map` field is empty in every entry (`map.<field>: in … entries empty (<path>)`): never write such an entry, correct the path or drop the field. When the run fails or the entries look wrong, run it again with `--raw` to see the command's output, correct `items` or a path of `map`, and try again; after two failed corrections, show the user the reason and the output's first lines and ask how to go on. Show the user the dry run's list and report, not the raw output.
42
47
 
43
48
  ## 4. Write after approval
44
49
 
45
- Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question that the source runs for everyone who starts the watcher in this repository, and that the watcher cannot keep a command from writing, so the command only reads.
50
+ Ask through `AskUserQuestion`: write the entry (recommended), change it, or drop it. Say in the question which file it goes to and that the watcher cannot keep a command from writing, so the command only reads; for a repository source add that it runs for everyone who starts the watcher in this repository.
51
+
52
+ On approval write exactly one of these files, and a script from step 3 under the path the entry names:
46
53
 
47
- On approval add the entry to `watch.sources` in `.jackherd/config.json` of the main checkout (create the array if missing), and a script from step 3 under the path the entry names. Then run `jh config`, which fails on an invalid config, and `jh watch source <name> --dry-run` once more against the written entry.
54
+ - **Personal:** add the entry to `sources` in `~/.jackherd/sources.json`; create the file as `{ "$schema": "<installed package>/schemas/sources.schema.json", "sources": [] }` if missing (the package directory is the one the recipe path above resolves to, minus `/recipes/README.md`). The script goes to `~/.jackherd/sources/<name>.ts`. A repository source of the same `name` wins over it, so pick a name the project does not use. For each name in `secrets`, ask the user to run `jh secret set <NAME>` in a terminal of their own (a separate pane; it asks hidden and refuses without a terminal), wait, then check only `jh secret list`: `set` is fine, `missing` means they still have to, `set (environment)` means the watcher's environment must carry it too, `secret store unavailable` means it can come only from that environment. Then run `jh watch source <name> --dry-run`; it prints `origin: personal`. A failure here is corrected in the written entry as in step 3.
55
+ - **Repository:** add the entry to `watch.sources` in `.jackherd/config.json` of the main checkout (create the array if missing). Then run `jh config`, which fails on an invalid config, and `jh watch source <name> --dry-run` once more.
48
56
 
49
- End with one line: the watcher takes the source over at its next start, so a running watcher (the coordinator's stream) has to be started again. The new file and the changed config are part of the repository and follow its commit rules.
57
+ End with one line: a running watcher and the herd take the source over at their next cycle (about a minute); a file that is invalid then keeps the previous sources and prints `sources not reloaded`. A repository entry and its script are part of the repository and follow its commit rules; the personal file is not.
@@ -84,6 +84,8 @@ otherwise it runs in a fitting lane, as its next phase or as a subagent there (`
84
84
  table are met and step 1 finds nothing. One line to the user afterwards; commit, push, PR and merge
85
85
  stay approval-bound (§ 5, § 6).
86
86
 
87
+ **A pull request of the lane that only waits for its approval or its merge holds the commit, not the next phase.** While only the approval waits there is no commit yet: the next phase goes on in the same worktree, and the commit stages only that PR's files. A round or phase the plan starts "after the PR" begins at once, on a branch over the PR's branch, and after the squash merge moves onto the base branch with `git rebase --onto origin/{{kit.baseBranch}} <old PR head>` (§ 6). Waiting stays for a PR of another lane that shares a write path, for a migration and for a release.
88
+
87
89
  When the user wants a plan's lanes merged without a question for a while: § 5, **Standing approval**.
88
90
 
89
91
  0. **Plan commit as the base**, when § 0 found the board or a plan missing on
@@ -250,7 +252,7 @@ On wake-up:
250
252
  | `blocked` | Read the screen (`--source detection`, or zoom the pane when cut off). A question is vetted (below, **Settled questions stay in the lane**): one the plan or a rule settles you answer in the lane, an open decision goes to the user via `AskUserQuestion`. You never hand the lane's pane to the user. A question dialog in a lane (not a permission dialog) is a gap in the lane prompt (§ 1): send it the text-block rule and the answer by prompt. A permission dialog for a step on the list in § 5 you confirm, after checking the cursor sits on the one-time option; anything else goes to the user. Dim text in the input line is a suggestion, not input (`--format ansi` shows `\e[2m`); in a multi-select dialog `space` toggles an option, `enter` moves on. The watcher prints a duty line for each blocked lane; it is done when the lane has its answer or the user has the question |
251
253
  | `working` with `questions: <n>` | The lane changed the questions of its report while its subagents run. Read `report <path>` and close each new question as the `done` / `idle` row does (`jh settled`, or `AskUserQuestion` with the answer sent on by `herdr agent prompt`). No diff check and no next phase: the lane keeps working |
252
254
  | `main checkout on <branch>, not …` | § 0, **The main checkout stays on `{{kit.baseBranch}}`** |
253
- | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval holds only its commit, which stages only its own files |
255
+ | `done` / `idle` | Read the report the watcher shows (`report <path>` and its first lines, or the screen); read further only where that is not enough. Check the diff against the `{{kit.heading.writes}}` set (`git -C <worktree> diff --stat <base>...HEAD`, with the ref the lane branched from in § 2 step 2 until it has rebased after the documentation PR's merge) and that checks are quoted with numbers, not claimed. Every blocking question in the report is closed in the same wake-up, also one under open points, one without its mark and also when the `questions: <n>` line is missing: settled by you through `jh settled` (below), or put to the user through `AskUserQuestion` (vetted, below) and the answer sent on to the lane by `herdr agent prompt`. A not blocking one is settled the same way or collected (**Not blocking questions**, below). An answer that stays in your chat with the user is none. Then start the lane's next ready phase (§ 2), at once after a `<lane> ready: …` line, or name the reason to wait; a PR awaiting approval or merge holds only its commit (§ 2) |
254
256
  | `merged #<n>` | § 6, for every lane |
255
257
  | `checks failed #<n> (<lane>): <checks> (<k> tests: …)` | Send the finding to the lane with the named checks and tests (data, above). Without names, the lane reads the run itself |
256
258
  | `checks passed #<n>` | One line to the user: ready to merge, with the board's merge order |
@@ -284,7 +286,7 @@ what it prints; an agent has no clock. Where it goes:
284
286
 
285
287
  The user's decision is the approval of the line that records it, including the commit, push and
286
288
  documentation PR that carry it: no second question. The merge stays with the project's approval
287
- tiers (§ 5). Memory and the hand-over note hold no decision that is not also in a file. A **finding of
289
+ tiers (§ 5), run as **Self-merge** step 4. Memory and the hand-over note hold no decision that is not also in a file. A **finding of
288
290
  your own** that contradicts the plan (something you saw, not something the user said): fix the document
289
291
  (a commit, so ask) and tell every lane it touches.
290
292
 
@@ -437,7 +439,8 @@ A permission dialog inside a lane you confirm yourself only for a step on this l
437
439
  - under the lane's PR approval: the commit, and the push of the lane's own branch to its own
438
440
  remote branch, forced only with `--force-with-lease`
439
441
  - as part of the rebase after a merge (§ 6), which needs no approval of its own: `git fetch`,
440
- `git rebase origin/{{kit.baseBranch}}`, `git checkout --ours {{kit.lanes.lockFile}}`,
442
+ `git rebase origin/{{kit.baseBranch}}` (`--onto origin/{{kit.baseBranch}} <old PR head>` for a branch over a
443
+ merged PR's), `git checkout --ours {{kit.lanes.lockFile}}`,
441
444
  `git add {{kit.lanes.lockFile}}`, `{{kit.command.install}}`, `git rebase --continue`
442
445
 
443
446
  Read the command from the dialog itself (`herdr agent read <lane> --source detection`), not from
@@ -507,7 +510,7 @@ When the user picked `"{{kit.guard.selfMergeLabel}}"` for a PR:
507
510
  4. Green (or a PR that qualified in step 2): `gh pr merge <n> --squash --delete-branch --match-head-commit <sha>`,
508
511
  with the plain PR number, the full `headRefOid` from `gh pr view <n> --json headRefOid` read right before, and
509
512
  without `-R`, `--repo` or `--admin`. The merge guard lets exactly this command through for the approved branch
510
- with green checks, for `guard.selfMerge.approvalMinutes` after the answer. Red, or checks that never finish: do not
513
+ with green checks, for `guard.selfMerge.approvalMinutes` after the answer. Run it as a Bash call of its own, without a pipe, `;` or `&&`, and read the result afterwards with `gh pr view <n>`: the guard checks a merge against approval and checks only when it is the sole command of the call, and asks otherwise. Red, or checks that never finish: do not
511
514
  merge, report to the user with the failing check and the lane's next step.
512
515
  5. When the guard asks anyway (the approval lapsed, the branch differs, the command carries an
513
516
  option it does not cover), the dialog is the user's. Do not confirm it for them and do not look
@@ -545,7 +548,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
545
548
  1. A PR behind the base branch is rebased first (§ 6, under the PR's approval), so its checks run
546
549
  against the current base.
547
550
  2. Turn a draft ready as in **Self-merge** step 1, then wait for the checks as in its step 3.
548
- 3. Green: the same command as **Self-merge** step 4, with the `<sha>` read right before. The merge
551
+ 3. Green: the same command as **Self-merge** step 4, run the same way, with the `<sha>` read right before. The merge
549
552
  guard lets it through only before the approval's end, with every check green, GitHub able to merge
550
553
  it and no workflow, migration, env file or file of the guards in it; otherwise it asks, and that dialog is the user's
551
554
  (**Self-merge** step 5).
@@ -563,7 +566,7 @@ For a PR the user approved (step 2) whose lane works on a plan under a standing
563
566
  No overlap, no run; an overlap runs the checks of the touched package roots; whenever in doubt, a lock-file
564
567
  conflict or a change to the root `package.json` among them, all of them run. A `{{kit.lanes.lockFile}}` conflict is never resolved by hand: take the base branch's
565
568
  (`git checkout --ours {{kit.lanes.lockFile}}` during a rebase), run `{{kit.command.install}}`, continue.
566
- - Merge order is the board's. A lane ready early waits; say so.
569
+ - Merge order is the board's. A PR ready early waits for its turn to merge, its lane goes on (§ 2); say so.
567
570
  - **The documentation PR of § 2 step 0 merges first**, before any lane PR that started from it. After its
568
571
  merge, the copies in the main checkout and its branch are cleaned up as in
569
572
  [`references/plan-commit.md`](references/plan-commit.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jackherd",
3
- "version": "1.15.2",
3
+ "version": "1.16.0",
4
4
  "keywords": [
5
5
  "claude-code",
6
6
  "coding-agents",
@@ -52,6 +52,7 @@
52
52
  "!assets/icons/build.ts",
53
53
  "!assets/branding/outline.ts",
54
54
  "recipes",
55
+ "!recipes/**/*.test.ts",
55
56
  "README.md",
56
57
  "INSTALL.md",
57
58
  "CHANGELOG.md",
package/recipes/README.md CHANGED
@@ -10,13 +10,14 @@ Each recipe is one `watch.sources` entry for the jackherd watcher: put in your k
10
10
  | GitHub issues | [github-issues.md](github-issues.md) | `gh auth login` | none |
11
11
  | GitHub Projects, as tickets | [github-projects.md](github-projects.md) | `gh auth refresh -s project` | none |
12
12
  | Sentry | [sentry.md](sentry.md) | `SENTRY_AUTH_TOKEN` | [sources/sentry.ts](sources/sentry.ts) |
13
+ | Google Calendar, today's appointments | [google-calendar.md](google-calendar.md) | secret iCal address in `GOOGLE_CALENDAR_ICS_URL` | [sources/google-calendar-today.ts](sources/google-calendar-today.ts) |
13
14
  | A status page | [status-page.md](status-page.md) | none, public API | none |
14
15
 
15
16
  ## Rules every recipe follows
16
17
 
17
- - **A token never goes into an argument:** an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header.
18
+ - **A token never goes into an argument:** an argument shows in the process list. Where a tool keeps its own login, it is used; where a token sits in an environment variable, a small TypeScript script run by Bun (which runs wherever `jh` does) puts it from `process.env` straight into the request header. The token reaches the script through the entry's `secrets` (the keychain, for a personal source only; see [sources.md](sources.md)) or through the watcher's environment; either way the script reads `process.env`.
18
19
  - **A script prints one JSON value on stdout and nothing else.**
19
20
  - **A script fails with an exit code**, never with an empty answer the watcher would take for a source with nothing new, and it prints neither the token nor the body of a failed answer. A 200 that carries an `errors` list, as GraphQL answers, is a failure too. Every request and every tool call is bounded with `AbortSignal.timeout(20_000)`, well under the 30 seconds a source may run.
20
- - **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository, the path the entry's `command` names. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts.
21
+ - **The scripts are files of their own** under [`sources/`](sources/). Copy one to `scripts/sources/<name>.ts` in your repository (a personal source: `~/.jackherd/sources/<name>.ts`), the path the entry's `command` names. The closing `export {}` makes the file a module, so the type check accepts top-level `await` and the names do not clash between scripts.
21
22
  - **A recipe that needs no script** (`gh …`, `curl` on a public page, `curl --netrc`) stays a direct command.
22
23
  - **`bun` as the first word, never a path to it.** The watcher starts a source without a shell and reads `bun` to mean the Bun that runs the watcher itself, so the entry needs no absolute path, which would differ from machine to machine, even when Bun is not on the `PATH` (installed in `~/.bun/bin` only, say).
@@ -0,0 +1,30 @@
1
+ # Google Calendar, today's appointments
2
+
3
+ Your day, from the calendars you already keep in Google Calendar, opened in the herd under "Your turn". No Google login and no API project: Google gives each calendar a secret address in the iCalendar format, and that address is the credential.
4
+
5
+ **Secret address:** in Google Calendar, open the settings of a calendar, section "Integrate calendar", and copy "Secret address in iCal format". Anyone who has the address can read the calendar, so it never goes into the entry, into a repository or into an argument. Store it in the keychain (the command asks hidden and needs a terminal):
6
+
7
+ ```
8
+ jh secret set GOOGLE_CALENDAR_ICS_URL
9
+ jh secret set GOOGLE_CALENDAR_ICS_URL_2 # a second calendar, and so on
10
+ ```
11
+
12
+ **Script:** the day's appointments are a personal matter, so this is a personal source. Copy both [sources/google-calendar.ts](sources/google-calendar.ts) (the iCalendar reader) and [sources/google-calendar-today.ts](sources/google-calendar-today.ts) to `~/.jackherd/sources/`. The script reads the addresses from `GOOGLE_CALENDAR_ICS_URL`, `GOOGLE_CALENDAR_ICS_URL_2`, … and shows the day in the time zone of the machine. It expands daily, weekly, monthly and yearly series with `INTERVAL`, `UNTIL`, `COUNT`, `BYDAY`, `BYMONTHDAY` and `BYMONTH`, honours moved and cancelled occurrences, and skips a series it cannot expand. One calendar that cannot be read fails the whole run, without printing its address; an appointment with a date that cannot be read is skipped on its own.
13
+
14
+ **Entry:** in `~/.jackherd/sources.json`, under `sources`. List in `secrets` exactly the names you stored; a name that is found nowhere fails the run.
15
+
16
+ ```json
17
+ {
18
+ "name": "calendar",
19
+ "label": "Today",
20
+ "every": 15,
21
+ "command": ["bun", "sources/google-calendar-today.ts"],
22
+ "secrets": ["GOOGLE_CALENDAR_ICS_URL"],
23
+ "show": "entries",
24
+ "map": { "id": ".id", "updatedAt": ".updatedAt", "title": ".title", "url": ".url" }
25
+ }
26
+ ```
27
+
28
+ **Dry run:** a draft outside `~/.jackherd/sources.json` runs without the keychain. Once the entry is in the file and the secrets are stored, `jh watch source calendar --dry-run` runs it by name, with the keychain, and lists today's appointments.
29
+
30
+ **Limits:** one entry per occurrence, so a moved or renamed appointment reports as a change and a new day brings its appointments in as new. Google refreshes the iCal feed with a delay of some minutes up to hours. The secret address stays valid until you reset it in the calendar settings; reset it if it leaks.
@@ -0,0 +1,32 @@
1
+ // Copy to ~/.jackherd/sources/google-calendar-today.ts together with google-calendar.ts; the recipe
2
+ // recipes/google-calendar.md says what to fill in. Today's appointments of the calendars whose secret
3
+ // iCal addresses sit in GOOGLE_CALENDAR_ICS_URL, GOOGLE_CALENDAR_ICS_URL_2, … (the entry's `secrets`).
4
+ // One entry per occurrence, so a moved or renamed appointment reports as a change and a new day brings
5
+ // its appointments in as new. One calendar that cannot be read fails the whole run.
6
+ import { addressesOf, civilOf, occurrencesOn, parseIcs, toItems } from './google-calendar'
7
+
8
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
9
+
10
+ const addresses = addressesOf(process.env)
11
+ if (addresses.length === 0) process.exit(1)
12
+
13
+ // The address is the credential: nothing from a failed request (error text, URL) may reach stderr.
14
+ const readCalendar = async (address: string): Promise<string | undefined> => {
15
+ try {
16
+ const answer = await fetch(address, { signal: AbortSignal.timeout(20_000) })
17
+ const text = answer.status === 200 ? await answer.text() : ''
18
+ return text.includes('BEGIN:VCALENDAR') ? text : undefined
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ const answers = await Promise.all(addresses.map(readCalendar))
25
+ const texts = answers.filter((text) => text !== undefined)
26
+ if (texts.length < answers.length) process.exit(1)
27
+
28
+ const today = civilOf(Date.now(), zone)
29
+ const events = texts.flatMap((text) => parseIcs(text, zone))
30
+ console.log(JSON.stringify(toItems(occurrencesOn(events, today, zone), today, zone)))
31
+
32
+ export {}